Steal My Bitcoin CTF PoC

In this news:

Key takeaways

  • A single-file Python script reproduces the exploit chain for the Steal My Bitcoin CTF challenge.
  • The exploit leverages memory exhaustion in PHP multipart parsing and worker reuse to leak heap and code addresses.
  • A final forged structure triggers system execution and returns the flag and verification markers.

Overview of the exploit script

This README explains stealmybitcoin_poc.py, a single-file Python reproduction of the request sequence used in my Steal My Bitcoin CTF run. It uses only Python's standard library. The script builds the requests itself, checks each leak before moving on, and records the HTTP bytes from a new run.

Everything below is written against the Docker lab, not the live challenge. The lab is a local Alpine PHP 8.4.26 CLI server behind nginx, which is the same build and worker model the offsets were calibrated on. Stand it up in a minute and the whole chain runs end to end on your own machine. The live run that actually solved the challenge is kept as a historical note at the bottom.

How the attack chain operates

A small multipart upload makes PHP chew through far more memory than the wire size suggests. When it runs out of memory partway through session upload-progress parsing, PHP leaves stale state behind in a worker that then gets reused. Shape the follow-up requests right and bytes fall out through session cookies. I read those as a heap address and a PHP code address.

With both addresses, a final request drops a forged PHP structure where cleanup will find it, and cleanup calls zif_system, PHP's handler for system. That command sends back a fresh proof marker and one print-flag result over the connection that is still open.

Why two addresses? The OS randomizes where PHP's code and heap land, independently. The heap leak tells the script where to put the forged structure, the code leak tells it where the function it wants to call actually lives. Addresses from an earlier worker or an earlier run are not safe substitutes, and every time I tried it the attempt just failed.

Setting up the local lab

The offsets in this PoC are calibrated for Alpine PHP 8.4.26 running the built-in CLI server with four workers. The lab reproduces exactly that, behind nginx, on loopback.

A couple of notes on that recipe. The image is the fpm tag but the container runs php -S, so the SAPI is cli-server, which is what the calibration was measured against. PHP_CLI_SERVER_WORKERS=4 is what gives the leak stages more than one worker to race for. My own lab harness also passed --cap-add SYS_PTRACE --security-opt seccomp=unconfined so it could attach a tracer to the workers; the PoC itself never does that, so those are left out above.

Running the proof of concept

Python 3.11 or newer, standard library only, nothing to install: Run it with no arguments and it prints its help and exits without touching the network.

Note the low 21 bits of that zval_ptr_dtor value: 0x112930, exactly the fingerprint the script expects from this Alpine build. That is the check that rejects a mismatched image.

The response body that comes back: The PHP fatal, then the nonce this run generated, then id, then the hash of the binary it resolved, then the dummy flag. Exit status is 0 on a verified run and 1 on anything else.

Detailed request mechanics

One run means one health GET, up to two leak batches, and at most one final request. No looping, no retries. Every multipart body stays under 1 MiB, though each leak batch pipelines nine separate requests down one connection.

The producer is multipart/form-data carrying PHPSESSID and PHP_SESSION_UPLOAD_PROGRESS fields. PHP handles upload progress while it is still parsing the request. Hundreds of empty file controls get names like f[0][x][x]... with dozens of nested [x] segments, so the body stays around 250 KiB on the wire while parsing it spawns piles of nested PHP arrays and all the allocations that come with them. The producer also carries 1,001 distinct empty cookie names, which is what the request builder was configured to send at the time.

The fatal you want says PHP exhausted its 128 MiB memory limit. What you are actually after is the stale state this particular failure leaves behind in a worker.

Success rates and failure modes

Both leak stages are races against three other workers, so a single run is not expected to succeed every time. Ten consecutive runs against the lab described above, with no retries and nothing else talking to the container: So roughly seven runs in ten. Worth knowing that both leaks are stable once a container is up: every run against the same container returned the same zend_heap_base and the same zval_ptr_dtor, because the workers persist and ASLR is per process, not per request. What varies between runs is purely which worker answers which pipelined consumer.

The two failures are worth recognising, because they look nothing alike: Heap stage misses. The producer OOMs correctly and all eight consumers come back HTTP 200 with ordinary 32-byte cookies. No leak, no candidate, so the run stops before anything is forged: Callback misses. Both leaks succeed, the final POST returns HTTP 200, and the body is only the memory-limit fatal with none of the three markers. The forged table did not land where cleanup read it: Neither case is a partial success, and neither is retried automatically. Just run it again.

Share the article:

Top Trending Cryptocurrencies on The Market

Current Price

$0.07474

7 Days
PI 7 days: -23.52%

Market Cap

$840.5M -2.67%

24h Volume

$3.6M

Supplies

17.3B / 100.0B

Current Price

$11.254

7 Days
USTB 7 days: +0.07%

Market Cap

$824.8M 0.00%

24h Volume

-

Supplies

73.4M / ∞

Current Price

$0.9994

7 Days
OUSD 7 days: +0.14%

Market Cap

$824.7M 0.06%

24h Volume

$290.6K

Supplies

825.0M / ∞

Current Price

$0.8024

7 Days
NEXO 7 days: -0.37%

Market Cap

$801.9M -1.54%

24h Volume

$5.2M

Supplies

1.0B / 1.0B

Current Price

$7.646

7 Days
INJ 7 days: -10.56%

Market Cap

$766.4M -0.04%

24h Volume

$330.3M

Supplies

100.0M / ∞

Current Price

$0.9149

7 Days
FIL 7 days: -13.22%

Market Cap

$764.5M -2.51%

24h Volume

$249.2M

Supplies

2.0B / ∞

Current Price

$3.030

7 Days
LIT 7 days: -30.68%

Market Cap

$760.0M -7.32%

24h Volume

$73.7M

Supplies

1.0B / 1.0B

Current Price

$0.03081

7 Days
AKE 7 days: +6.98%

Market Cap

$721.5M 1.18%

24h Volume

$16.0M

Supplies

100.0B / 100.0B

Current Price

$0.02645

7 Days
STABLE 7 days: -6.27%

Market Cap

$721.3M -1.12%

24h Volume

$9.9M

Supplies

100.0B / 100.0B

Current Price

$0.9984

7 Days
GHO 7 days: -0.09%

Market Cap

$697.9M -0.03%

24h Volume

$1.9M

Supplies

699.0M / ∞

Current Price

$1.696

7 Days
ZRO 7 days: -11.01%

Market Cap

$677.4M -2.05%

24h Volume

$133.1M

Supplies

1.0B / 1.0B

Current Price

$0.6672

7 Days
AERO 7 days: -16.47%

Market Cap

$670.4M -4.02%

24h Volume

$86.9M

Supplies

2.0B / ∞

Notifications Login to see

Today

No Notifications

↑