Steal My Bitcoin CTF Proof of Concept
Dit document legt de werking van stealmybitcoin_poc.py uit, een reproductie van de reeks requests die gebruikt is tijdens de CTF-run. Het script bouwt de requests zelf op, controleert elk lek voordat het verder gaat en legt de HTTP-bytes van een nieuwe run vast.
Let op: De onderstaande informatie is geschreven voor de Docker-labomgeving, niet voor de live challenge. De labomgeving is een lokale Alpine PHP 8.4.26 CLI-server achter nginx, wat exact dezelfde build en worker-model is waarop de offsets zijn gekalibreerd.
Overzicht van de fasen
| Fase | Uitgaande data | Resultaat |
|---|---|---|
| 0. Health | GET /php-version.php | PHP 8.4.26 bevestigd |
| 1. Heap leak | 249.042 B producer + 8 x 6.672 B (Zend chunk base) | 256 B cookie (OOM vergiftigt een worker) |
| 2. Code leak | 251.557 B producer + 8 x 92.388 B (zvalptrdtor) | 16 B cookie (OOM vergiftigt een worker) |
| 3. Callback | 368.812 B POST (fake HashTable + command) | Uitvoering van zif_system(cmd) |
In totaal worden er maximaal 20 HTTP-requests verzonden. Er is geen sprake van looping of retries.
De exploit in het kort
Een kleine multipart-upload zorgt ervoor dat PHP veel meer geheugen verbruikt dan de omvang van de data over het netwerk suggereert. Wanneer PHP tijdens het parsen van de session upload-progress uit het geheugen raakt (Out-Of-Memory), blijft er een verouderde status (stale state) achter in een worker die vervolgens wordt hergebruikt.
Door de vervolgrequests correct vorm te geven, lekken er bytes via de sessie-cookies. Deze worden gelezen als een heap-adres en een PHP-codeadres. Met beide adressen kan in een laatste request een vervalste PHP-structuur worden geplaatst. Wanneer de cleanup-routine deze vindt, wordt zif_system (de PHP-handler voor system) aangeroepen.
Dit commando stuurt een nieuwe bewijsmaker (proof marker) en een print-flag resultaat terug via de verbinding die nog openstaat.
Waarom twee adressen?
Het besturingssysteem randomiseert de locaties van de PHP-code en de heap onafhankelijk van elkaar (ASLR). Het heap-lek vertelt het script waar de vervalste structuur moet worden geplaatst, en het code-lek vertelt waar de functie die aangeroepen moet worden zich daadwerkelijk bevindt. Adressen van een eerdere worker of run zijn niet veilig te gebruiken; elke poging hiermee faalde.
De laboratoriumomgeving (The Lab)
De offsets in deze PoC zijn gekalibreerd voor Alpine PHP 8.4.26, draaiend op de ingebouwde CLI-server met vier workers, achter nginx op loopback.
Belangrijk: De specifieke build is belangrijker dan het versienummer. Een Debian php:8.4.26-fpm stack rapporteert weliswaar dezelfde versie, maar heeft een andere binaire lay-out, waardoor de code-pointer fingerprint niet overeenkomt en de run stopt bij de code-leak fase. Gebruik daarom het Alpine-image.
Benodigde bestanden
Plaats de volgende drie bestanden in een lege directory:
public/php-version.php (het health-eindpunt)
<?php
header('Content-Type: text/plain');
echo 'PHP ', PHP_VERSION, "\n";
echo 'PID: ', getmypid(), "\n";
print-flag (vervanger voor de challenge binary, moet uitvoerbaar zijn)
#!/bin/sh
printf '%s\n' 'LOCAL_DUMMY_WIF_NEVER_USE'
nginx.conf
events {}
http {
server {
listen 80;
server_name _;
location / {
proxy_pass http://ctf-cross-php:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
}
}
Installatie via Docker
Voer de volgende commando's uit:
docker network create ctf-cross-net
docker run -d --name ctf-cross-php --network ctf-cross-net \
--read-only --tmpfs /tmp:rw,size=32m,mode=1777 --memory 1g \
-v "$PWD/public:/app:ro" \
-v "$PWD/print-flag:/usr/local/bin/print-flag:ro" \
-e PHP_CLI_SERVER_WORKERS=4 \
php:8.4.26-fpm-alpine3.23 php -S 0.0.0.0:8000 -t /app
docker run -d --name ctf-cross-nginx --network ctf-cross-net \
-p 127.0.0.1:18166:80 \
-v "$PWD/nginx.conf:/etc/nginx/nginx.conf:ro" \
nginx:1.27-alpine
Controleer de installatie: curl -s http://127.0.0.1:18166/php-version.php
Om de omgeving te verwijderen: docker rm -f ctf-cross-nginx ctf-cross-php && docker network rm ctf-cross-net
Aan de slag
Gebruik Python 3.11 of nieuwer (alleen standaardbibliotheek):
python .\stealmybitcoin_poc.py --url http://127.0.0.1:18166/php-version.php
Verwacht resultaat
Een succesvolle run ziet er als volgt uit:
[health] PHP 8.4.26 confirmed
[heap] strict Zend base 0x78113e800000
[code] strict zval_ptr_dtor 0x615b1f512930
{"stage": "final", "nonce": "1ef0e73d8615157a", "execution_proof_verified": true, "response_path": "..."}
De response body bevat vervolgens:
- Een PHP Fatal error (geheugenlimiet bereikt).
- De gegenereerde nonce.
- De output van het
idcommando. - De hash van het
print-flagbinary. - De dummy flag.
Waarschuwing: De producer dwingt PHP bewust naar zijn geheugenlimiet. Voer dit script alleen uit op systemen waar dit is toegestaan.
Argumenten
--url: Het endpoint van het lab of de challenge (verplicht).--output-dir: Directory voor het opslaan van bewijsmateriaal (standaard:poc-run-<timestamp>).
Gedetailleerde werking van de requests
1. Hoe een kleine producer de geheugenlimiet bereikt
De producer gebruikt multipart/form-data met PHPSESSID en PHPSESSIONUPLOAD_PROGRESS velden. PHP handelt de upload-progress af terwijl het request nog geparsed wordt. Honderden lege bestandscontroles krijgen namen zoals f[0][x][x]... met tientallen geneste [x] segmenten. Hierdoor blijft de body over het netwerk klein (~250 KiB), maar spawnt PHP tijdens het parsen enorme hoeveelen geneste arrays, wat leidt tot een Out-Of-Memory (OOM) conditie.
2. Hoe het heap-adres wordt hersteld
Het volgende request richt zich op het geheugen dat is gekoppeld aan de verouderde sessiestatus. Door een specifieke numeric form key te herhalen en een vervalste waarde in te voegen, wordt de lengte die PHP gebruikt voor het schrijven van de PHPSESSID aangepast. Bij een correct hergebruikte worker komt de cookie terug als 256 bytes in plaats van 32, waardoor naburige heap-data zichtbaar wordt.
3. Hoe het PHP-codeadres wordt hersteld
Een tweede producer triggert opnieuw de geheugenlimiet. De bijbehorende consumer gebruikt een andere array-lay-out om een PHP-functiepointer (zvalptrdtor) te lekken. Het script controleert de lage 21 bits (0x112930), wat de page offset is in het Alpine PHP 8.4.26 image.
Voor deze lay-out wordt zifsystem berekend als: zifsystem = zvalptrdtor - 0x204BC0
4. Hoe het laatste request system bereikt
De finale POST plaatst een vervalste HashTable van 384 bytes in een multipart-veld. Deze structuur overlapt een fake call frame en een fake zendstring met het commando. Een primer levert een pointer naar dit gecontroleerde gebied, waarna een lange form-veldnaam opnieuw een geheugenlimiet-fout veroorzaakt. In het cleanup-pad leest PHP de pDestructor van de vervalste tabel als zifsystem en voert dit uit met het string-argument.
Structuur van de vervalste tabel:
0x00: zendarray header →pDestructorwijst naarzifsystem0x20: bucket / fake call frame →arg1wijst naar zend_string hieronder0x80: zend_string header0x98: command (max 231 B)
De resultaten interpreteren
Elke run maakt een directory poc-run-<timestamp> aan met de volgende bestanden:
| Bestand | Inhoud |
|---|---|
heap.request.http / code.request.http | De plaintext HTTP-bytes verzonden naar de leak-stages. |
heap.response.http / code.response.http | De ontvangen HTTP-bytes, inclusief cookies. |
heap.json / code.json | Geparseerde status, lengtes, hashes en kandidaat-adressen. |
final.request.http | Het geconstrueerde finale request (headers en multipart body). |
final.response.http / .body / .headers.json | De finale response en de geparseerde inhoud. |
final.json / run.json | Samenvatting en verificatie of de execution markers aanwezig zijn. |
executionproofverified: true betekent dat de finale response HTTP 200 was en de nonce, het id resultaat en de SHA-256 hash van print-flag bevatte.
Succespercentage
Omdat beide leak-stages races zijn tegen drie andere workers, slaagt een run niet altijd. In tests tegen het beschreven lab was het resultaat:
- Succes (
executionproofverified: true): 7 van de 10 runs. - Callback bereikt, maar markers afwezig: 2 van de 10 runs.
- Heap leak mislukt: 1 van de 10 runs.
De leaks zijn stabiel zodra een container is opgestart; de zendheapbase en zvalptrdtor blijven gelijk omdat de workers persisten en ASLR per proces werkt, niet per request.
De live run die de challenge oploste
Op 25 oktober 2026 om 15:47:38 UTC werd de challenge succesvol opgelost. Na een gezonde PHP 8.4.26 check en succesvolle heap- en code-leaks, resulteerde de finale POST in een HTTP 200 response met een 204-byte body. Deze bevatte de PHP fatal error, gevolgd door een checksum-valide WIF (Bitcoin private key).
Jargonlijst
| Term | Betekenis |
|---|---|
| Zend heap | Het geheugengebied van PHP voor request-waarden en interne structuren. |
| Worker | Eén PHP-proces dat requests afhandelt. |
| PHPSESSID cookie | De plek waar onbedoelde geheugenbytes verschijnen tijdens de leak-stages. |
| zvalptrdtor | Een interne PHP-functiepointer, gebruikt als anker voor de berekening van het code-adres. |
| zif_system | De interne PHP-handler voor system, gebruikt als de vervalste cleanup-callback. |
| WIF | Wallet Import Format, een encoding van een Bitcoin private key. |
De code structuur
De code is opgebouwd in de volgorde van de aanval: leakproducer() → heapconsumer() → codeconsumer() → runleakstage() → fakehashtable() → finalmultipart() → finalrequest().
Alle specifieke parameters staan in het kalibratieblok bovenaan of in de STAGES tabel.
Groetjes,