A 27-Year-Old Authentication Bypass in OpenBSD's PPP Stack (CVE-2026-55706)

A 27-Year-Old Authentication Bypass in OpenBSD’s PPP Stack (CVE-2026-55706)

Original text: “A 27-Year-Old Authentication Bypass in OpenBSD’s PPP Stack”Argus, Argus Blog (2026-06-16). Kernel source snippets and console output below are reproduced verbatim with attribution; the surrounding analysis, the attack-chain diagram and the proof-of-concept are original to core-jmp.org.
OpenBSD console screenshot
OpenBSD — the bug lived in the kernel’s synchronous-PPP layer (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:

Attacker frame on the wire pppoe_data_input() pppoeintr() sppp_input() sppp_pap_input() — vulnerable bcmp()

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:

  1. PPPoE Discovery — the attacker answers the victim’s PADI with PADO, and completes PADR/PADS to open a session.
  2. LCP negotiation — standard Link Control Protocol setup, agreeing on options and selecting PAP as the authentication method.
  3. PAP authentication — the attacker sends an Authentication-Request with name_len = 0 and passwd_len = 0. The kernel returns a PAP-ACK.
  4. IPCP negotiation — IP addresses are assigned and the link comes fully up.
  5. 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.

PropertyValue
CVECVE-2026-55706
Componentsppp_pap_input() in sys/net/if_spppsubr.c
Bug classesAuth bypass (CWE-288) via incorrect comparison (CWE-697); heap over-read (CWE-125)
Attack vectorAdjacent (Layer 2 / same broadcast domain) over PPPoE
Privileges / interactionNone / none — no credentials required
Introduced1999-07-01 (FreeBSD import; zero-length bypass) · 2009-02-16 (refactor adds over-read)
Fixed2026-06-14 by mvs
Summary of CVE-2026-55706. Compiled from the original article and the OpenBSD fix commit.

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

DateEvent
1999-07-01Vulnerable synchronous-PPP code imported into OpenBSD from FreeBSD (zero-length bypass present).
2009-02-16Refactor to dynamically allocated auth fields with a unified AUTHMAXLEN bound — introduces the heap over-read.
2026-06-12Vulnerability reported.
2026-06-14Fix committed by mvs (CVE-2026-55706).
Disclosure and patch timeline. Source: original article.

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) == 0 is “equal.” Any equality check whose length can reach zero is a latent authentication bypass.
  • Upper bounds are not enough. The > AUTHMAXLEN guard 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/strncmp calls 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.

Comments are closed.