Skip to content

DHCPv4: out-of-bounds read in net_dhcpv4_msg_type_name() via sizeof(name) byte-vs-element confusion

Moderate
d3zd3z published GHSA-r5hq-xq42-wcfq Aug 1, 2026

Software

zephyr

Affected versions

>= 1.7.0, <= 4.4.1

Patched versions

4.5.0

Description

DHCPv4: out-of-bounds read in net_dhcpv4_msg_type_name() via sizeof(name) byte-vs-element confusion

Summary

subsys/net/lib/dhcpv4/dhcpv4.c::net_dhcpv4_msg_type_name() validates an
attacker-controlled DHCP option byte against sizeof(name) instead of
ARRAY_SIZE(name). sizeof(name) is the byte size of the pointer
array, not the element count, so on every 64-bit Zephyr target
(x86_64 / arm64) the guard accepts msg_type values in
1..64 instead of the intended 1..8, and the subsequent
name[msg_type - 1] performs an out-of-bounds read of up to 56
pointer slots (448 bytes on a 64-bit target) past the end of the rodata
pointer table. The returned wild pointer is then forwarded to
NET_DBG("%s", ...) for dereference.

The same byte-vs-element confusion is duplicated six lines above in
dhcpv4_state_name() (inside an __ASSERT_NO_MSG).

Reachable pre-authentication from any peer on the same broadcast
domain as a Zephyr DHCPv4 client whose net-log level admits DBG
messages.

Affected component

  • File: subsys/net/lib/dhcpv4/dhcpv4.c
  • Functions: net_dhcpv4_msg_type_name() (line 1804), dhcpv4_state_name() (line 1786)
  • Subsystem: CONFIG_NET_DHCPV4 (Zephyr DHCPv4 client)

Affected versions

Confirmed vulnerable on every released tag from v3.6.0 onward and
on the current main branch (commit 78154294, 2026-05-22).

Tag Vulnerable
main @ 7815429 yes
v4.4.0 (2026-04) yes
v4.3.0 (2025-11) yes
v4.2.2 (2026-03) yes
v4.2.0 (2025-07) yes
v4.1.0 yes
v3.7.2 (LTS) yes
v3.7.0 (LTS) yes
v3.6.0 yes
v3.5.0 and earlier not vulnerable (function did not yet exist in this form)

(Verification: curl -s https://raw.githubusercontent.com/zephyrproject-rtos/zephyr/<tag>/subsys/net/lib/dhcpv4/dhcpv4.c | grep 'msg_type <= sizeof(name)'. Output 1 for every tag above
v3.5.0.)

Vulnerable code

subsys/net/lib/dhcpv4/dhcpv4.c, current main (7815429):

const char *net_dhcpv4_msg_type_name(enum net_dhcpv4_msg_type msg_type)
{
    static const char * const name[] = {
        "discover", "offer", "request", "decline",
        "ack",      "nak",   "release", "inform",  /* 8 entries */
    };

    if (msg_type >= 1 && msg_type <= sizeof(name)) {   /* <-- BUG */
        return name[msg_type - 1];
    }
    return "invalid";
}

Same pattern six lines above:

const char *net_dhcpv4_state_name(enum net_dhcpv4_state state)
{
    static const char * const name[] = {
        "disabled", "init", "init-reboot", "selecting", "requesting",
        "renewing", "rebinding", "bound", "decline,",  /* 9 entries */
    };

    __ASSERT_NO_MSG(state >= 0 && state < sizeof(name));   /* <-- BUG */
    return name[state];
}

sizeof(name) on either array is entries * sizeof(char *) =
entries * 8 on a 64-bit target (* 4 on a 32-bit target). The intent
was ARRAY_SIZE(name) = entries.

Reachability

msg_type is the value of DHCP option 53 ("DHCP Message Type"), read
raw from the wire at dhcpv4.c:1389:

case DHCPV4_OPTIONS_MSG_TYPE: {
    ...
    {
        uint8_t val = 0U;
        if (net_pkt_read_u8(pkt, &val)) { ... return false; }
        *msg_type = val;          /* full 0..255, no range validation */
    }
    break;
}

The C enum enum net_dhcpv4_msg_type does not enforce a range, so
msg_type reaches dhcpv4_handle_reply() carrying the attacker's full
0–255 byte. The dispatch switch (msg_type) at dhcpv4.c:1542 itself
only handles OFFER/ACK/NAK and falls through harmlessly for other
values, but the debug log line immediately before the switch calls
the buggy function on every received reply:

/* dhcpv4.c:1539 */
NET_DBG("state=%s msg=%s",
        net_dhcpv4_state_name(iface->config.dhcpv4.state),
        net_dhcpv4_msg_type_name(msg_type));

NET_DBG(...) expands to LOG_DBG(...), which forwards the %s
argument to the logger backend for strlen() + copy. The
attacker-supplied msg_type ∈ [9, 64] therefore yields a char * that
is loaded from arbitrary memory adjacent to the rodata pointer table,
and is then itself dereferenced by the formatter.

Preconditions for exploitation

  • Target is a Zephyr device/application with CONFIG_NET_DHCPV4=y.
  • Net-log level for the DHCPv4 module admits DBG
    (CONFIG_NET_DHCPV4_LOG_LEVEL_DBG=y, common in development builds
    and field debug captures; also enabled transitively if a developer
    raises the global net log level).
  • 64-bit target (x86_64, arm64). On 32-bit targets the guard
    accepts 1..32 instead of 1..8 — still buggy, just with a
    smaller OOB window.
  • Attacker is on the same broadcast domain (DHCP request /
    reply / unicast — CONFIG_NET_DHCPV4_ACCEPT_UNICAST=y is on by
    default), or has a foothold inside a DHCP relay path.

Attack mechanics

  1. Attacker observes a victim DHCP DISCOVER/REQUEST on the LAN (or
    forges an unsolicited unicast reply if the target accepts unicast,
    which is the default).
  2. Attacker crafts a DHCP reply containing option 53 with a value in
    9..64. (For 32-bit targets, 9..32.) The malicious reply must
    still pass the normal DHCP reply checks such as BOOT_REPLY, matching
    xid, matching chaddr, and expected hlen. An adjacent attacker can
    satisfy these by observing the victim's DISCOVER/REQUEST. Once those
    checks pass, the buggy debug-log path runs before the switch dispatch.
  3. The victim parses the option, propagates the unconstrained byte as
    msg_type, then logs state=%s msg=%s with the wild pointer.
  4. The logger dereferences the wild pointer as a C string, producing
    either:
    • Information disclosure / crash depending on memory layout:
      the attacker can select one of the out-of-bounds pointer slots by
      choosing msg_type, but does not directly control the pointer value.
      If the loaded pointer happens to reference readable memory, the logger
      may disclose bytes as a string through the configured log backend.
      If it points to unmapped or protected memory, the device may fault.
    • Crash (DoS) if the wild pointer lands in an unmapped page or
      a region whose contents trigger an MPU fault during read.

Impact

  • Pre-authentication, no user interaction.

  • Information disclosure / crash depending on memory layout:
    the attacker can select one of the out-of-bounds pointer slots by
    choosing msg_type, but does not directly control the pointer value.
    If the loaded pointer references readable memory, the logger may
    disclose bytes as a string through the configured log backend. If it
    references unmapped or protected memory, the device may fault.

  • Denial of service when the wild pointer resolves to an unmapped
    or protected region.

  • Affects DHCPv4 client only. The DHCPv4 server (dhcpv4_server.c)
    uses a different code path and was scanned but did not exhibit the
    same pattern.

CWE-125 (Out-of-bounds Read) + CWE-682 (Incorrect Calculation), with
CWE-823 (Use of Out-of-range Pointer Offset) as the secondary class
when the wild pointer is dereferenced as a string by the logger.

Severity (CVSS 3.1)

Vector: AV:A/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:L → Base 5.4, Medium

Reasoning per metric:

Metric Value Reasoning
AV Adjacent DHCP discovery is link-local; routed reach requires a relay.
AC Low Single, easily-crafted DHCP packet.
PR None Pre-authentication on the network.
UI None No user interaction.
S Unchanged No privilege/scope change.
C Low Bounded info disclosure over the log backend.
I None Read-only bug.
A Low Crash possible but limited to the DHCPv4 client subsystem.

If the panel decides DHCPv4 across a relay counts as AV:N, the
alternative vector CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:L
yields a base of 6.5 (still Medium). The exploit is unauthenticated
and trivial; only the reachability metric is debatable.

Proof of concept

Two self-contained host harnesses are inlined below. Both copy the
vulnerable function from dhcpv4.c verbatim and exercise it; together
they prove the bug under both UBSan (clean type-bound diagnostic)
and AddressSanitizer (clean global-redzone overflow with shadow
map). No Zephyr build, no board, and no DHCP infrastructure is needed
to reproduce — gcc with -fsanitize=... is sufficient.

PoC 1 — UBSan variant: poc_dhcpv4_msg_type_name_ubsan.c

/*
 * Manual PoC for the sizeof(name) off-by-bounds in zephyr's
 *   subsys/net/lib/dhcpv4/dhcpv4.c::net_dhcpv4_msg_type_name()
 * (line numbers from upstream commit 78154294, May 2026).
 *
 * Bug shape:
 *
 *   const char *net_dhcpv4_msg_type_name(enum net_dhcpv4_msg_type msg_type)
 *   {
 *       static const char * const name[] = {
 *           "discover", "offer", "request", "decline",
 *           "ack", "nak", "release", "inform"          // 8 entries
 *       };
 *
 *       if (msg_type >= 1 && msg_type <= sizeof(name)) // <-- BUG
 *           return name[msg_type - 1];
 *       return "invalid";
 *   }
 *
 * sizeof(name) is the byte size of the pointer array, not its element
 * count. On a 64-bit host that is 8 entries * 8 bytes = 64. The guard
 * therefore accepts msg_type values 9..64 and `name[msg_type-1]` reads up to 56 bytes
 * past the end of the array. The result is a wild pointer that the caller
 * passes to NET_DBG / LOG_DBG, which dereferences it as a C string.
 *
 * The same pattern is duplicated at dhcpv4.c:1810 in dhcpv4_state_name()
 * with the same `name[] of char*` shape and the same wrong `sizeof(name)`
 * comparison wrapped in __ASSERT_NO_MSG.
 *
 * Reachability: msg_type comes from a single byte in the DHCP "Message
 * Type" option (53), read raw from the wire at dhcpv4.c:1389
 *     *msg_type = val;          // val is uint8_t from net_pkt_read_u8
 * with no range check before being passed to dhcpv4_handle_reply() ->
 * net_dhcpv4_msg_type_name(). An attacker on the local broadcast domain
 * can spoof a DHCP OFFER/REPLY with msg_type = 9..64 and force the wild
 * read on any zephyr client with CONFIG_NET_DHCPV4_LOG_LEVEL_DBG enabled.
 *
 * Build (no zephyr toolchain needed):
 *   gcc -fsanitize=address,undefined -fno-sanitize-recover=all -g -O0 \
 *       poc_dhcpv4_msg_type_name_ubsan.c -o poc_dhcpv4_msg_type_name_ubsan
 *
 * Run:
 *   ./poc_dhcpv4_msg_type_name_ubsan
 *
 * Sanitizer fires (global-buffer-overflow read of size 8) when the loop
 * crosses index 7.
 */
#include <stdio.h>
#include <stdint.h>
#include <string.h>

/* Verbatim copy of the buggy function from dhcpv4.c. The function-static
 * `name[]` is hoisted into the global scope so that AddressSanitizer's
 * global-redzone instrumentation will fire on the OOB read; the bytewise
 * semantics of the bug are identical. */
static const char * const name[] = {
    "discover", "offer", "request", "decline",
    "ack",      "nak",   "release", "inform",
};

/* The vulnerable predicate as it appears in subsys/net/lib/dhcpv4/dhcpv4.c. */
__attribute__((noinline))
static const char *net_dhcpv4_msg_type_name(int msg_type)
{
    if (msg_type >= 1 && msg_type <= (int)sizeof(name)) {
        return name[msg_type - 1];
    }
    return "invalid";
}

int main(void)
{
    /* Element count the guard *should* have used. */
    const int real_count = (int)(sizeof(name) / sizeof(name[0]));
    printf("sizeof(name)        = %zu  (byte size of pointer array)\n",
           sizeof(name));
    printf("ARRAY_SIZE(name)    = %d  (intended element count)\n",
           real_count);
    printf("guard accepts msg_type in [1..%zu] but only [1..%d] are valid\n\n",
           sizeof(name), real_count);

    /* Walk msg_type past the end of the array. ASan should fire on the
     * first OOB read (msg_type = 9 -> name[8]). */
    for (int msg_type = 1; msg_type <= (int)sizeof(name); msg_type++) {
        const char *s = net_dhcpv4_msg_type_name(msg_type);
        /* Force the OOB pointer to be dereferenced as a string, mirroring
         * what NET_DBG / LOG_DBG would do. Use %p first so we always emit
         * something even when %s would crash. */
        printf("msg_type=%2d  name=%p", msg_type, (const void *)s);
        if (msg_type > real_count) {
            /* This is the path a real zephyr build with debug logging
             * would take after receiving a malicious DHCP packet. */
            printf("  -> deref as string: %.16s", s);
        }
        printf("\n");
        fflush(stdout);
    }
    return 0;
}

Build & run:

gcc -fsanitize=address,undefined -fno-sanitize-recover=all -g -O0 \
    poc_dhcpv4_msg_type_name_ubsan.c -o poc_ubsan
./poc_ubsan ; echo exit=$?

Captured stdout (msg_type 1..8 print, then UBSan aborts before
msg_type 9 can print its line):

sizeof(name)        = 64  (byte size of pointer array)
ARRAY_SIZE(name)    = 8  (intended element count)
guard accepts msg_type in [1..64] but only [1..8] are valid

msg_type= 1  name=0x5ce5ffcf0020
msg_type= 2  name=0x5ce5ffcf0060
msg_type= 3  name=0x5ce5ffcf00a0
msg_type= 4  name=0x5ce5ffcf00e0
msg_type= 5  name=0x5ce5ffcf0120
msg_type= 6  name=0x5ce5ffcf0160
msg_type= 7  name=0x5ce5ffcf01a0
msg_type= 8  name=0x5ce5ffcf01e0

Captured stderr (UBSan diagnostic, process exits with code 1):

poc_dhcpv4_msg_type_name_ubsan.c:67:20: runtime error: index 8 out of bounds for type 'char *[8]'

PoC 2 — AddressSanitizer variant: poc_dhcpv4_msg_type_name_asan.c

/*
 * Companion PoC for the sizeof(name) bug in
 *   zephyrproject-rtos/zephyr  subsys/net/lib/dhcpv4/dhcpv4.c
 *   net_dhcpv4_msg_type_name()  (line 1804, upstream main @78154294)
 *
 * The primary PoC (poc_dhcpv4_msg_type_name_ubsan.c) lets UBSan abort on
 * the first out-of-bounds index. This second harness uses ASan only so
 * the global-redzone instrumentation reports a clean
 * "global-buffer-overflow ... located 0 bytes after global variable
 * 'name' ... of size 64".
 *
 * Build:
 *   gcc -fsanitize=address -fno-sanitize-recover=all -g -O0 \
 *       poc_dhcpv4_msg_type_name_asan.c -o poc_dhcpv4_msg_type_name_asan
 *
 * Run:
 *   ./poc_dhcpv4_msg_type_name_asan
 */
#include <stdio.h>
#include <stdint.h>
#include <string.h>

/* Verbatim from dhcpv4.c, hoisted to file scope so ASan globals
 * instrumentation sees a global-redzone OOB. */
static const char * const name[] = {
    "discover", "offer", "request", "decline",
    "ack",      "nak",   "release", "inform",
};

__attribute__((noinline))
static const char *net_dhcpv4_msg_type_name(int msg_type)
{
    if (msg_type >= 1 && msg_type <= (int)sizeof(name)) {
        return name[msg_type - 1];
    }
    return "invalid";
}

int main(void)
{
    const int real_count = (int)(sizeof(name) / sizeof(name[0]));

    printf("sizeof(name) = %zu  ARRAY_SIZE = %d\n",
           sizeof(name), real_count);

    /* msg_type = 9: first index past the array. NET_DBG("%s", s) would
     * strlen() and copy bytes from whatever lies after the rodata pointer
     * table — adjacent function pointers or rodata strings. ASan should
     * fire here. */
    const char *s = net_dhcpv4_msg_type_name(9);
    printf("msg_type=9  wild pointer = %p\n", (const void *)s);
    fflush(stdout);

    /* The "logged as %s" sink. */
    printf("deref as string: %s\n", s);
    return 0;
}

Build & run:

gcc -fsanitize=address -fno-sanitize-recover=all -g -O0 \
    poc_dhcpv4_msg_type_name_asan.c -o poc_asan
./poc_asan ; echo exit=$?

Captured stderr (ASan global-buffer-overflow, process exits with
code 1):

=================================================================
==7588==ERROR: AddressSanitizer: global-buffer-overflow on address 0x5fd317160d40 at pc 0x5fd31715e25c bp 0x7ffdfd97aed0 sp 0x7ffdfd97aec0
READ of size 8 at 0x5fd317160d40 thread T0
    #0 0x5fd31715e25b in net_dhcpv4_msg_type_name poc_dhcpv4_msg_type_name_asan.c:36
    #1 0x5fd31715e2b6 in main poc_dhcpv4_msg_type_name_asan.c:52
    #2 0x71e44982a1c9 in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:58
    #3 0x71e44982a28a in __libc_start_main_impl ../csu/libc-start.c:360
    #4 0x5fd31715e144 in _start

0x5fd317160d40 is located 0 bytes after global variable 'name' defined in 'poc_dhcpv4_msg_type_name_asan.c:27:27' (0x5fd317160d00) of size 64
SUMMARY: AddressSanitizer: global-buffer-overflow poc_dhcpv4_msg_type_name_asan.c:36 in net_dhcpv4_msg_type_name
Shadow bytes around the buggy address:
  0x5fd317160a80: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x5fd317160b00: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x5fd317160b80: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x5fd317160c00: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x5fd317160c80: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
=>0x5fd317160d00: 00 00 00 00 00 00 00 00[f9]f9 f9 f9 00 00 00 00
  0x5fd317160d80: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x5fd317160e00: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
==7588==ABORTING

Key line: 0 bytes after global variable 'name' ... of size 64. ASan
confirms that the 8-byte load at index 8 falls exactly one element past
the rodata pointer table — i.e., the guard msg_type <= sizeof(name)
permitted a load whose offset is computed from byte size (64) instead
of element count (8). This is precisely the byte-vs-element confusion
described in §"Vulnerable code" above.

End-to-end on Zephyr

A full board-level PoC requires a Zephyr device running dhcpv4_client
with CONFIG_NET_DHCPV4=y and CONFIG_NET_DHCPV4_LOG_LEVEL_DBG=y, and
a malicious DHCP server (or a host running e.g. scapy) emitting an
OFFER / ACK / NAK with option 53 value in 9..64. The host harnesses
above reproduce the identical arithmetic and dereference behaviour
without requiring a board build, and are sufficient to establish the
bug class and severity.

Suggested fix

--- a/subsys/net/lib/dhcpv4/dhcpv4.c
+++ b/subsys/net/lib/dhcpv4/dhcpv4.c
@@ -1797,7 +1797,7 @@ const char *net_dhcpv4_state_name(enum net_dhcpv4_state state)
 		"decline,"
 	};

-	__ASSERT_NO_MSG(state >= 0 && state < sizeof(name));
+	__ASSERT_NO_MSG(state >= 0 && state < ARRAY_SIZE(name));
 	return name[state];
 }

@@ -1814,7 +1814,7 @@ const char *net_dhcpv4_msg_type_name(enum net_dhcpv4_msg_type msg_type)
 		"inform"
 	};

-	if (msg_type >= 1 && msg_type <= sizeof(name)) {
+	if (msg_type >= 1 && msg_type <= ARRAY_SIZE(name)) {
 		return name[msg_type - 1];
 	}

ARRAY_SIZE() is already in scope through the existing
<zephyr/sys/util.h> chain. Defence-in-depth alternatives that
should also be considered for an LTS backport:

  1. Validate msg_type against the small whitelist
    {1..8} (or even tighter: the four message types the dispatch
    actually handles) before the parser hands it off as msg_type,
    i.e. reject out-of-range option-53 bytes at dhcpv4.c:1389.
  2. Audit other sizeof(<pointer-array>)-as-bound patterns in the
    networking stack — a grep across subsys/net/ should be cheap and
    may find sister bugs.

Discovery

Found by an LLM-driven arithmetic-vulnerability scanner. The tool
flagged Phase-2 risk on the <= sizeof(name) comparison; the
hypothesis-generation stage produced an exploit hypothesis; the
automated PoC build stage cannot link Zephyr translation units as
standalone host harnesses, so the host PoC above was hand-written.

Disclosure timeline

  • 2026-05-22 — Bug found by automated scan against Zephyr main
    @ 7815429.
  • 2026-05-27 — Sanitizer-validated, fix and PoC packaged, reported
    privately via this GitHub Security Advisory.

Credits

Reporter: Yoo Seungju.

Patches

Branch Pull request Status
main #110135 merged
v4.4-branch #112423 merged
v4.3-branch #112422 superseded
v3.7-branch #115146 merged

For more information

If you have any questions or comments about this advisory:

embargo: 2026-08-01

Severity

Moderate

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Adjacent
Attack complexity
Low
Privileges required
None
User interaction
None
Scope
Unchanged
Confidentiality
Low
Integrity
None
Availability
Low

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:A/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:L

CVE ID

CVE-2026-10773

Weaknesses

Out-of-bounds Read

The product reads data past the end, or before the beginning, of the intended buffer. Learn more on MITRE.

Incorrect Calculation

The product performs a calculation that generates incorrect or unintended results that are later used in security-critical decisions or resource management. Learn more on MITRE.

Credits