KillaBoi/StealMyBitcoin-PoC: A public launch of the Steal My Bitcoin problem · GitHub

One stay PHP callback towards the Steal My Bitcoin problem, begin to end, in a single standard-library Python file.
This README explains stealmybitcoin_poc.py, a single-file Python replica of the request sequence utilized in my Steal My Bitcoin CTF run. It makes use of solely Python’s customary library. The script builds the requests itself, checks every leak earlier than shifting on, and information the HTTP bytes from a brand new run.
This can most likely be executed simpler with out all the additional debug crap and as in my John Hammond video concerning the Steam Local Privilege Escalation I found, you may most likely clear up this ugly PoC.
Note
Everything under is written towards the Docker lab, not the stay problem. The lab is a neighborhood Alpine PHP 8.4.26 CLI server behind nginx, which is identical construct and employee mannequin the offsets have been calibrated on. Stand it up in a minute and the entire chain runs finish to finish by yourself machine. The stay run that really solved the problem is stored as a historic be aware on the bottom.
stage what goes out what comes again
─────────────────────────────────────────────────────────────────────────────
0 well being GET /php-version.php -> PHP 8.4.26 confirmed
1 heap leak 249,042 B producer + 8 x 6,672 B -> 256 B cookie
(OOM poisons a employee) └─ Zend chunk base
2 code leak 251,557 B producer + 8 x 92,388 B -> 16 B cookie
(OOM poisons a employee) └─ zval_ptr_dtor
3 callback 368,812 B POST -> cast HashTable runs
(faux HashTable + command) └─ zif_system(cmd)
─────────────────────────────────────────────────────────────────────────────
20 HTTP requests at most. No looping, no retries.
- A small multipart add makes PHP chew by way of way more reminiscence than the wire dimension suggests.
- When it runs out of reminiscence partway by way of session upload-progress parsing, PHP leaves stale state behind in a employee that then will get reused.
- Shape the follow-up requests proper and bytes fall out by way of session cookies. I learn these as a heap tackle and a PHP code tackle.
- With each addresses, a last request drops a cast PHP construction the place cleanup will discover it, and cleanup calls
zif_system, PHP’s handler forsystem. - That command sends again a contemporary proof marker and one
print-flagoutcome over the connection that’s nonetheless open.
Why two addresses? The OS randomizes the place PHP’s code and heap land, independently. The heap leak tells the script the place to place the cast construction, the code leak tells it the place the perform it needs to name really lives. Addresses from an earlier employee or an earlier run usually are not secure substitutes, and each time I attempted it the try simply failed.
The offsets on this PoC are calibrated for Alpine PHP 8.4.26 operating the built-in CLI server with 4 employees. The lab reproduces precisely that, behind nginx, on loopback.
Important
The construct issues greater than the model string. A Debian php:8.4.26-fpm stack reviews the identical PHP/8.4.26 however has a distinct binary format, so the code-pointer fingerprint won’t ever match and the run aborts on the code-leak stage. Use the Alpine picture under.
Three small information. Put them in an empty listing:
public/php-version.php (the well being endpoint, and the one web page the PoC wants)
header('Content-Type: textual content/plain');
echo 'PHP ', PHP_VERSION, "n";
echo 'PID: ', getmypid(), "n";
print-flag (stand-in for the problem binary, have to be executable)
#!/bin/sh
printf '%sn' 'LOCAL_DUMMY_WIF_NEVER_USE'
nginx.conf
occasions {}
http {
server {
pay attention 80;
server_name _;
location / {
proxy_pass http://ctf-cross-php:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
}
}
Then convey it up:
docker community create ctf-cross-net
docker run -d --name ctf-cross-php --network ctf-cross-net
--read-only --tmpfs /tmp:rw,dimension=32m,mode=1777 --memory 1g
-v "$PWD/public:/app:ro"
-v "$PWD/print-flag:/usr/native/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:/and many others/nginx/nginx.conf:ro"
nginx:1.27-alpine
Check it:
$ curl -s http://127.0.0.1:18166/php-version.php
PHP 8.4.26
PID: 1
The PID varies with whichever employee solutions, and that’s wonderful. The model line is the half the PoC checks.
Tear it down with docker rm -f ctf-cross-nginx ctf-cross-php && docker community rm ctf-cross-net.
Tip
On Windows, run these docker run instructions from PowerShell, or exchange $PWD with a full drive path like C:/lab. In Git Bash $PWD expands to an MSYS path equivalent to /tmp/..., which Docker Desktop resolves inside its personal VM. The bind mounts then silently create empty directories the place nginx.conf and print-flag must be, and the containers come up damaged.
A few notes on that recipe. The picture is the fpm tag however the container runs php -S, so the SAPI is cli-server, which is what the calibration was measured towards. PHP_CLI_SERVER_WORKERS=4 is what offers the leak levels a couple of employee to race for. My personal lab harness additionally handed --cap-add SYS_PTRACE --security-opt seccomp=unconfined so it might connect a tracer to the employees; the PoC itself by no means does that, so these are ignored above.
Python 3.11 or newer, customary library solely, nothing to put in:
python .stealmybitcoin_poc.py --url http://127.0.0.1:18166/php-model.php
Run it with no arguments and it prints its assist and exits with out touching the community.
A superb run appears to be like like this:
[health] PHP 8.4.26 confirmed
[heap] strict Zend base 0x78113e800000
[code] strict zval_ptr_dtor 0x615b1f512930
{"stage": "last", "nonce": "1ef0e73d8615157a", "execution_proof_verified": true, "response_path": "..."}
Note the low 21 bits of that zval_ptr_dtor worth: 0x112930, precisely the fingerprint the script expects from this Alpine construct. That is the test that rejects a mismatched picture.
The response physique that comes again:
Fatal error: Allowed reminiscence dimension of 134217728 bytes exhausted (tried to allocate 131072 bytes) in Unknown on line 0
1ef0e73d8615157a
uid=0(root) gid=0(root) teams=0(root),0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video)
0ec066e8b91457c7c19b5612f3e2ded7f117df08f402693669c1ffc2cfc24942 /usr/native/bin/print-flag
LOCAL_DUMMY_WIF_NEVER_USE
The PHP deadly, then the nonce this run generated, then id, then the hash of the binary it resolved, then the dummy flag. Exit standing is 0 on a verified run and 1 on anything.
Warning
The producer intentionally walks PHP into its reminiscence restrict. Only run this someplace that behaviour is allowed. The script will solely speak to the problem endpoint or a loopback lab URL, and anything aborts earlier than a packet goes out.
One run means one well being GET, as much as two leak batches, and at most one last request. No looping, no retries. Every multipart physique stays below 1 MiB, although every leak batch pipelines 9 separate requests down one connection.
| Flag | What it does |
|---|---|
--url |
Lab endpoint, or the problem. Required to do something. |
--output-dir |
Where to put in writing the run’s proof. Defaults to poc-run-. |
| Step | What goes out, what ought to come again | Why |
|---|---|---|
| Health | One atypical GET /php-version.php should return HTTP 200, PHP 8.4.26, and X-Powered-By: PHP/8.4.26. |
Keeps the memory-limit payload away from a modified or unhealthy endpoint. |
| Heap leak | One 249,042-byte multipart producer, then eight pipelined 6,672-byte customers. The producer should return a PHP 128 MiB memory-limit deadly. A shopper might return a 256-byte PHPSESSID cookie. |
Four or extra pointer-looking values in that cookie should agree on one aligned 2 MiB Zend heap base. |
| Code leak | One 251,557-byte producer, then eight pipelined 92,388-byte customers. A shopper might return a 16-byte PHPSESSID cookie. |
Its first eight bytes should encode 16, and its second eight should appear to be the regionally calibrated zval_ptr_dtor pointer. |
| Final callback | One 368,812-byte multipart POST. | A cast PHP HashTable factors cleanup at zif_system and provides a command argument. The contemporary nonce, id output, and print-flag binary hash are the proof that the callback really ran. |
That is 20 HTTP requests at most: one well being test, 9 per leak batch, one last request. All eight customers in a batch exit earlier than the primary response is learn, which helps the timing, but it surely additionally means you can not pull them again if the producer fails. A failed or malformed batch stops the subsequent stage.
The producer is multipart/form-data carrying PHPSESSID and PHP_SESSION_UPLOAD_PROGRESS fields. PHP handles add progress whereas it’s nonetheless parsing the request. Hundreds of empty file controls get names like f[0][x][x]... with dozens of nested [x] segments, so the physique stays round 250 KiB on the wire whereas parsing it spawns piles of nested PHP arrays and all of the allocations that include them. The producer additionally carries 1,001 distinct empty cookie names, which is what the request builder was configured to ship on the time.
The deadly you need says PHP exhausted its 128 MiB reminiscence restrict. What you’re really after is the stale state this specific failure leaves behind in a employee.
Important
On its personal, a memory-limit deadly or an nginx 502 proves nothing about an tackle leak or code execution.
The subsequent request goes after the reminiscence tied to that stale session state. It repeats a single numeric type key, then slips in a crafted worth that modifications the size PHP makes use of when it writes PHPSESSID. Land on the correct reused employee with the correct heap format and the cookie comes again 256 bytes as a substitute of the standard 32, with neighbouring heap information sitting inside it.
heap_candidate() walks that cookie as little-endian 64-bit phrases, and can solely settle for a base if at the very least 4 pointer-looking values fall in the identical aligned 2 MiB heap area. On prime of that the runner needs HTTP 200, the anticipated PHP model, legitimate cookie lengths from all eight customers, and precisely one agreed base. Anything wanting that and it stops.
A second producer triggers the identical memory-limit situation. Its shopper makes use of a distinct array format, calibrated regionally to shake out a PHP perform pointer. The cookie you need is precisely 16 bytes: an eight-byte worth of 16, then a pointer learn as zval_ptr_dtor towards the Alpine PHP fingerprint.
The script checks the pointer’s vary and its low 21 bits, 0x112930. That is the web page offset of the image within the Alpine PHP 8.4.26 picture, and ASLR doesn’t transfer it. Treat it as a fingerprint, not as proof the distant executable is byte for byte the identical factor. For this format, zif_system works out as:
zif_system = zval_ptr_dtor - 0x204BC0
Both leaks have to return from the present invocation. The script is not going to load historic pointer values right into a stay try.
The last POST parks a 384-byte cast HashTable in a multipart subject I management. That construction overlaps a faux name body and a faux zend_string holding the command. A 276-byte primer provides a pointer into the managed area, then a protracted form-field identify drives one other memory-limit failure. In the cleanup path, PHP reads the cast desk’s pDestructor as zif_system and fingers it the string argument.
0x00 ┌──────────────────────────┐
│ zend_array header │ pDestructor -> zif_system
0x20 ├──────────────────────────┤
│ bucket / faux name body │ arg1 -> zend_string under
0x80 ├──────────────────────────┤
│ zend_string header │
0x98 ├──────────────────────────┤
│ command (231 B max) │
0x180 └──────────────────────────┘
The goal tackle makes use of a distance measured within the four-worker Alpine setup:
fake_table = zend_heap_base - 0x8400000 + 0x16D1D8
That distance depends upon the employee and the allocation format. Change the picture, the reminiscence configuration, or the employee depend and this falls over although the PHP model nonetheless reads 8.4.26.
The command that initially labored simply ran print-flag as soon as. This model prints a contemporary random nonce, the output of id (UID and GID), and the SHA-256 of the resolved print-flag binary earlier than invoking print-flag as soon as. It hunts down the lively accepted socket below /proc/self/fd so the output really reaches the HTTP response. Ordinary course of stdout bought me nothing.
Every run drops a brand new poc-run- listing, except --output-dir factors at a brand new one. Everything is written earlier than it’s validated, so a run that aborts midway nonetheless leaves usable proof. The information price opening:
| File | Contents |
|---|---|
heap.request.http, code.request.http |
The plaintext HTTP bytes handed to every leak-stage socket’s sendall, one producer plus eight pipelined customers. If the transport fails you can’t be certain what really bought delivered. |
heap.response.http, code.response.http |
Plaintext HTTP bytes learn again off every leak-stage socket, cookies included. |
heap.json, code.json |
Parsed response standing, lengths, hashes, and candidate addresses. |
last.request.http |
The last request as constructed, headers and multipart physique. Same caveat about transport failures. |
last.response.http, last.response.physique, last.response.headers.json |
The last response as learn, plus its parsed physique and headers. Against the stay problem this physique might comprise a non-public WIF. |
last.json, run.json |
Summary fields, and whether or not the anticipated execution markers confirmed up. |
execution_proof_verified: true means the ultimate response got here again HTTP 200 carrying the contemporary nonce, an id outcome, and a SHA-256 line for print-flag.
Caution
Against the true problem a run listing can comprise a stay personal key. Publish this supply file, not a run’s output.
The script makes no declare about whether or not a returned WIF controls the marketed Bitcoin tackle. A checksum-valid WIF solely tells you the string encodes a non-public key correctly. Deriving the tackle and evaluating it’s a separate job.
Both leak levels are races towards three different employees, so a single run shouldn’t be anticipated to succeed each time. Ten consecutive runs towards the lab described above, with no retries and nothing else speaking to the container:
| Result | Count |
|---|---|
execution_proof_verified: true |
7 |
| Reached the callback, markers absent | 2 |
| Heap leak by no means landed | 1 |
So roughly seven runs in ten. Worth realizing that each leaks are secure as soon as a container is up: each run towards the identical container returned the identical zend_heap_base and the identical zval_ptr_dtor, as a result of the employees persist and ASLR is per course of, not per request. What varies between runs is solely which employee solutions which pipelined shopper.
The two failures are price recognising, as a result of they give the impression of being nothing alike:
Heap stage misses. The producer OOMs accurately and all eight customers come again HTTP 200 with atypical 32-byte cookies. No leak, no candidate, so the run stops earlier than something is cast:
[abort] RuntimeError: heap: anticipated one strict tackle, discovered 0
Callback misses. Both leaks succeed, the ultimate POST returns HTTP 200, and the physique is simply the memory-limit deadly with not one of the three markers. The cast desk didn’t land the place cleanup learn it:
{"standing": 200, "nonce_seen": false, "id_seen": false,
"binary_hash_seen": false, "execution_proof_verified": false}
Neither case is a partial success, and neither is retried robotically. Just run it once more.
At 15:47:38 UTC on 25 October 2026 the sooner guarded runner logged a wholesome PHP 8.4.26 web page, a heap stage with an HTTP 200 memory-limit deadly and a 256-byte cookie, and a code stage with an HTTP 200 memory-limit deadly and a 16-byte cookie. Its last 368,812-byte POST got here again HTTP 200 with a 204-byte physique: the PHP deadly, then one checksum-valid WIF. A neighborhood nginx/PHP lab reproduced the callback and the socket-output technique with dummy information. Put collectively, that may be a sturdy case for stay command execution contained in the PHP course of.
Worth being straight concerning the gaps. The unique run by no means captured its full outgoing HTTP request or the response headers. The full request was reconstructed afterwards from the builders and no matter metadata was left over, and an identical hash wouldn’t promote that into an unique wire seize. I by no means pulled the distant PHP ELF or the print-flag binary, they have been merely executed, as famous on the problem web site. The one WIF I recovered didn’t derive the prize tackle proven on the saved homepage on the time. That was a mistake on the creator’s facet, and it was acknowledged over e-mail.
| Term | Meaning |
|---|---|
| Zend heap | PHP’s reminiscence space for request values and inside constructions. |
| Worker | One PHP course of dealing with requests. The stale state and the later shopper want the identical reused course of, and pipelining doesn’t assure you get it. |
PHPSESSID cookie |
Where the unintended reminiscence bytes flip up throughout the leak levels. |
zval_ptr_dtor |
A PHP inside perform pointer, used right here to anchor the code-image tackle calculation. |
zif_system |
PHP’s inside handler for system, used as the cast cleanup callback. |
| WIF | Wallet Import Format, an encoding of a Bitcoin personal key. A sound checksum says nothing about who owns a selected pockets tackle. |
It is specified by assault order and the part banners match the 4 levels above:
leak_producer() -> heap_consumer() -> code_consumer()
-> run_leak_stage()
-> fake_hashtable() -> final_multipart() -> final_request()
Every tuned quantity lives within the calibration block on the prime or within the STAGES desk, so that’s the place to look when one thing wants re-measuring towards a distinct construct.
- cloudlandsdev for
"$744#6-'rugdrbyr" - LiveOverflow who makes wonderful movies and was very useful after I wanted to contact the creator 🙂
- I’m nonetheless ready for my Overwatch session lusilly 😐
