← back to research

The comment cited the limit: an unbounded write from one Router Advertisement in Zephyr

Zephyr's IPv6 stack takes an 8-bit context length straight from a Router Advertisement and never bounds it to the RFC's 128. One packet from any on-link host underflows a memset length and zeroes memory far past a 16-byte buffer.


Zephyr is the Linux Foundation’s real-time OS for microcontrollers, and it ships inside a large and growing number of connected devices. As part of our research into the embedded code that networks run on, Cipher found an out-of-bounds write in its IPv6 stack: a single Router Advertisement, delivered on a device’s local link, drives a memory-zeroing write far past a fixed 16-byte buffer. The handler that parses the packet does no authentication of its own; what governs whether an attacker can reach it is the link, not a credential.

This one is High severity, CVSS 8.1: a memory-corruption crash from one unauthenticated packet. We did not demonstrate code execution, and the write places only zeros. On the kind of device Zephyr runs on, there is rarely an operator watching to restart it.

IMPORTANT

Who’s exposed. You’re affected if you run Zephyr built with CONFIG_NET_6LO_CONTEXT, on any release from 1.8.0 through 4.4.1, where an attacker can deliver a Router Advertisement on the victim’s local link. That option is opt-in: it turns on the RFC 6775 context-based header compression used on low-power 6LoWPAN links. If your build does not set it, this code is not compiled and you are not affected. The handler itself checks no credential, so one crafted advertisement triggers it. What stands between an attacker and the handler is the link: Router Advertisements are not routed, so the sender has to be on the same link, and link-layer security and network filtering decide whether the packet reaches the stack at all. Fixed in Zephyr 4.4.2, with backports on the 4.4, 4.3, and 3.7 branches.

The bug, in handle_ra_6co

A Router Advertisement can carry a 6LoWPAN Context Option (6CO, RFC 6775), which tells the receiver about an IPv6 prefix it can use to compress headers. Zephyr parses that option in handle_ra_6co() in subsys/net/ip/ipv6_nbr.c. The context length is an 8-bit field read straight off the wire, and the code even carries a comment quoting the RFC’s rule for it:

// subsys/net/ip/ipv6_nbr.c, handle_ra_6co() before the fix
/* RFC 6775, 4.2
 * Context Length: 8-bit unsigned integer.  The number of leading
 * bits in the Context Prefix field that are valid.  The value ranges
 * from 0 to 128.  If it is more than 64, then the Length MUST be 3.
 */
if ((context->context_len > 64 && len != 3U) ||
    (context->context_len <= 64U && len != 2U)) {
        return false;              // checks the option's shape, never the 0..128 range
}

context->context_len = context->context_len / 8U;   // wire 0..255 becomes 0..31

if (context->context_len != sizeof(context->prefix)) {          // prefix is 16 bytes
        (void)memset(context->prefix + context->context_len, 0,
                     sizeof(context->prefix) - context->context_len);   // 16 - N
}

The check couples context_len to the option’s length field, but it never enforces the ceiling the comment names. The comment says the value ranges from 0 to 128; the code accepts 0 to 255. Worse, the coupling opens the door: a value above 64 is accepted as long as the length field is 3, so an attacker sets the wire byte anywhere from 0x88 to 0xFF and passes.

From there the arithmetic does the damage. The byte is divided by eight, turning a wire value of 136 to 255 into a context_len of 17 to 31. The final guard skips the memset only when context_len equals 16, the legal maximum of 128 bits in a 16-byte field, so any value above it falls through to the write. And that write is memset(context->prefix + context_len, 0, 16 - context_len): the destination now points past the end of the 16-byte prefix field, and the length 16 - context_len is computed in unsigned arithmetic, so 16 - 17 does not become negative, it wraps to nearly SIZE_MAX. One packet turns into a wild pointer and a gigantic zero-fill.

What we observed

The trigger is a single 80-byte ICMPv6 Router Advertisement carrying one 6CO option, with the context-length byte set to 0xFF and the option length field set to 3, the pairing the pre-fix check requires for any value above 64. We ran it against a host build instrumented with AddressSanitizer, injecting the crafted advertisement on the IPv6 receive path. It faults inside the handler:

==ERROR: AddressSanitizer: negative-size-param: (size=-15)
    #1 handle_ra_6co    subsys/net/ip/ipv6_nbr.c:2535
    #2 handle_ra_input  subsys/net/ip/ipv6_nbr.c:2825

AddressSanitizer intercepts the memset and checks its arguments before the real call runs, so the -15 it reports is the underflow caught at the point of the call: 0xFF divides to 31, and 16 − 31 is mathematically −15, which the unsigned length argument turns into a near-SIZE_MAX value. The boundary fixes the window. A context byte of 0x80 (128) divides to 16, which makes the != 16 condition false, so the memset is skipped: accepted and safe. 0x88 (17) and 0xFF (31) make it true and fall into the underflowing call. A well-formed option carrying a small context length, 0x40 paired with the length field of 2 the RFC requires at 64 or below, is accepted and writes its eight bytes inside the array, with no sanitizer event.

The gate above the handler is attacker-satisfiable and checks no credential: the Router Advertisement path accepts the packet when its hop limit is 255, its source is a link-local fe80:: address, and its ICMP code is 0, all of which a normal advertisement sets. The handler does no authentication of its own. Whether Secure Neighbor Discovery, an RA-guard, or link-layer security sits in front of it is a property of the deployment, not of this code.

Impact

The write is a memset of zeros, not attacker-chosen bytes, aimed one to fifteen bytes past the sixteen-byte prefix field with a near-SIZE_MAX length. An attacker controls where the zero-fill begins, within that fifteen-byte window, and nothing about how far it reaches. On a real target the call runs into whatever memory protection and fatal-error handling the build defines, so the failure mode is a property of the deployment. The advisory characterizes the impact as adjacent-network denial of service with collateral memory corruption, and scores it High, CVSS 8.1 (AV:A/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:H), CWE-787. We did not demonstrate code execution: the primitive places only zeros, which constrains it, but that alone is not proof nothing more is reachable.

Remediation

If you maintain the code. Reject the value before it is divided. The fix adds one clause to the existing check:

if (context->context_len > 128U ||
    (context->context_len > 64 && len != 3U) ||
    (context->context_len <= 64U && len != 2U)) {
        return false;
}

128 is the RFC 6775 maximum the comment already cited. Enforcing it means context_len / 8 can never exceed 16, so 16 - context_len can never go negative. This shipped in 4.4.2 (commit 15e838c).

If you operate it. Upgrade to 4.4.2, or take the backport for your branch: the fix is merged on 4.4, 4.3, and 3.7. If you cannot upgrade at once and your deployment does not need context-based compression, building without CONFIG_NET_6LO_CONTEXT removes the vulnerable code entirely. Where the option has to stay on, the exposure is the local link, so keeping untrusted radios and hosts off that link narrows it, but the upgrade is the fix.

Disclosure

  • Software: Zephyr RTOS, 1.8.0 through 4.4.1 (subsys/net/ip/ipv6_nbr.c, handle_ra_6co), built with CONFIG_NET_6LO_CONTEXT.
  • Class: CWE-787, out-of-bounds write.
  • Severity: High, CVSS 8.1.
  • CVE: CVE-2026-12633.
  • Advisory: GHSA-h5m5-hm6j-cgpf.
  • Fix: Zephyr 4.4.2 (commit 15e838c), backported to the 4.4, 4.3, and 3.7 branches. Reported to the Zephyr security team.

All testing was against a Zephyr build we ran ourselves; the triggering packet is synthetic.

The comment that cited the bound

Directly above the check that misses is a comment quoting RFC 6775: the context length “ranges from 0 to 128.” The bound was known well enough to write down. What the code checked next was the option’s shape, whether the length field matched the size class, and never the range the comment had just named. It is a common gap: a spec constraint gets documented in a comment and then trusted as if the comment enforced it. When a parser carries a comment that states a limit, find the line that rejects values past it, and if there is no such line, that absence is the bug. Here the fix was the single clause the comment had described all along.


This work is part of our ongoing vulnerability research into the network stacks and embedded software that connected devices run on.