This kit reproduces the Phase 2 measurement published in validation/: where
the in-memory session id (PS(id)) and the session HashTable land, and why
the Set-Cookie window cannot reach the HashTable under
display_errors=Off.
It is lab-only. Payload A is destructive by design: it kills the request with an
OOM inside php_session_send_cookie(). Run it only against a disposable
container you built for this purpose.
| Step | Action |
|---|---|
| 1 | Restart the container (clean allocator state). |
| 2 | Attach psid_probe.gdb to the single worker. |
| 3 | Optional warm-up request (warmup.py). |
| 4 | Payload A (make_payload_a.py): exhausts the bins and pages so the id encode aborts. |
| 5 | Payload B (b_forge.py): re-allocates the freed id chunk with a fake zend_string of length 4000. |
| 6 | Read the probe log, parse RESET/TRACK lines, append a CSV row. |
The probe logs, for every request:
RESET hit=N id=0x... len=... send=... chunk=0x... page=0x...
TRACK hit=N id=0x... ht=0x... ht_chunk=0x... ht_page=0x... delta=...
delta = ht - id is the quantity of interest. The cookie can only read forward
from the id's value pointer to the end of its 2 MiB chunk (at most 8168 B from
the id value). A negative delta, or a different chunk, means the HashTable is
unreachable.
-
Debug build of the target PHP with symbols. The reference build is PHP 8.4.26 with the
docker-library/phpconfigure (Apacheapxs2, 39 extensions) andCFLAGS="-O2 -g3". Recorded configure:--build=x86_64-linux-gnu --sysconfdir=/usr/local/etc --with-config-file-path=/usr/local/etc/php --with-config-file-scan-dir=/usr/local/etc/php/conf.d --enable-option-checking=fatal --with-mhash --with-pic --enable-mbstring --enable-mysqlnd --with-password-argon2 --with-sodium=shared --with-pdo-sqlite=/usr --with-sqlite3=/usr --with-curl --with-iconv --with-openssl --with-readline --with-zlib --disable-phpdbg --with-pear --with-libdir=lib/x86_64-linux-gnu --disable-cgi --with-apxs2A build with
--enable-debug --disable-alldoes not reproduce the allocator behaviour: the extension set andmax_input_varschange the request path. The probe needsphp_session_reset_id,php_session_track_init,php_session_initializeandzend_mm_safe_errorvisible inlibphp.so. Verify with./check_build.sh <container>. -
One prefork worker (
apache-prefork.conf). Both requests must land in the same process. -
CAP_SYS_PTRACEandseccomp=unconfinedfor the container, otherwise gdb cannot attach:docker run --cap-add=SYS_PTRACE --security-opt seccomp=unconfined ... -
The lab
php.ini(php.iniin this directory):display_errors=Off,memory_limit=8M,post_max_size=64M, upload progress on.
The 8M limit is a deliberate calibration override. With the PHP defaults
(128M / 8M) payload A does not abort and the study does not apply; see
tooling/oom_reach.py.
# 1. Check the container first.
./check_build.sh php2shell-lab
# 2. One calibrated point (matches the `base_off` row in validation/).
python3 sweep.py \
--container php2shell-lab \
--url http://127.0.0.1:18080/index.php \
--outdir out \
--label base_off \
--warmup 1500000 --S 63 --NB 235 --BL 15000 --X 208 --K 4 --KL 4000
# 3. A small grid (warm-up x S x X), if you want to map the neighbourhood.
python3 sweep.py --container php2shell-lab --url http://127.0.0.1:18080/index.php \
--outdir out --grid --K 4 --KL 4000Every point restarts the container. Do not point the driver at anything that is not disposable.
sweep.py writes, per point:
<label>.gdb.log— probe log (RESET,TRACK, chunk and bin dumps);<label>.a.resp,<label>.b.resp— raw HTTP responses;sweep.csv— one row per point withid_page,ht_page,delta,same_chunk,window_ok, response sizes and errors.
window_ok = 1 means the HashTable starts at or after the id value and ends
within the readable window. It does not mean session data leaked: that also
requires the forged id to be the one the worker sends (the id_len in RESET)
and the HashTable to actually be reached. Note the two channels are separate:
the cookie over-reads past the forge filler and can disclose adjacent heap
objects (in the published matrix: 47 pointer-like values per run, session table
never reached); see ../validation/ for the corrected counts.
The defaults (--d 1036288, --bb1 1048551, --read 4000, field counts) are
calibrated for the reference build, one worker, the lab php.ini, and the
warm-up above. They are sensitive to PHP build, extension set and ASLR state.
If your build differs:
- Run one point and read
id_pageandht_pagefrom the probe log. - Adjust
--dso thatBF_start + dequals theRESETid address. The probe printsidand the BF buffer starts at a stable offset from the request allocation;--dis the offset inside the value. - If
RESETnever fires, the worker is not the one serving requests, the build lacks symbols, or gdb is not attached (see prerequisites).
Against the reference image at memory_limit=8M, one worker, display_errors=Off:
a_resp_len 211 OOM aborted inside php_session_send_cookie
b_resp_len 5579 forged 4000-byte cookie returned
id_page 0x1fe000
ht_page 0x571c0
same_chunk 0
window_ok 0 HashTable lives in another 2 MiB chunk
This matches the base_off row in validation/sweep-matrix.csv (chunk base and
therefore delta vary with the run; the geometry does not).
| File | Purpose |
|---|---|
make_payload_a.py |
OOM-in-cookie payload generator (bins + page exhaustion) |
warmup.py |
Forces and releases a large chunk before payload A |
b_forge.py |
Forged session-id payload with absolute header offset |
psid_probe.gdb |
Read-only gdb probe: RESET/TRACK, chunk and bin dumps |
sweep.py |
Driver: restart, attach, A, B, parse, CSV |
php.ini |
Lab configuration |
apache-prefork.conf |
Single worker, KeepAlive off |
check_build.sh |
Verifies symbols, config and worker count |
This is measurement instrumentation, not an exploit. It targets a build you
control, with one worker and a known configuration, and it aborts requests on
purpose. The published results (including the negative ones) are in
../validation/.