
Steal My Bitcoin 챌린지의 공개 릴리스
Steal My Bitcoin 챌린지에 대한 라이브 PHP 콜백 하나를, 처음부터 끝까지, 표준 라이브러리만 사용하는 단일 Python 파일로.
이 README는 stealmybitcoin_poc.py를 설명합니다. 이 파일은 제 Steal My Bitcoin CTF 실행에서 사용된 요청 시퀀스를 단일 파일 Python으로 재현한 것입니다. Python 표준 라이브러리만 사용합니다. 스크립트는 요청을 직접 구성하고, 다음 단계로 넘어가기 전에 각 유출을 검증하며, 새 실행에서의 HTTP 바이트를 기록합니다.
이것은 아마도 불필요한 디버그 잡동사니 없이 더 쉽게 할 수 있을 것이며, 제 Steam 로컬 권한 상승 관련 John Hammond 영상에서 발견한 것처럼, 이 못생긴 PoC를 정리할 수도 있을 것입니다.
[!NOTE] ⚠️ 솔직히 말해서, 제발 이걸 정리하거나 Sol에게 짓눌리지 않은 더 나은 버전을 직접 만드세요. ⚠️
아래의 모든 내용은 라이브 챌린지가 아닌 Docker 랩을 기준으로 작성되었습니다. 이 랩은 nginx 뒤에 있는 로컬 Alpine PHP 8.4.26 CLI 서버로, 오프셋이 보정된 것과 동일한 빌드 및 워커 모델입니다. 1분 만에 구동하면 전체 체인이 여러분의 머신에서 처음부터 끝까지 실행됩니다. 실제로 챌린지를 해결한 라이브 실행은 맨 아래에 역사적 기록으로 남겨 두었습니다.
stage what goes out what comes back
─────────────────────────────────────────────────────────────────────────────
0 health 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 worker) └─ Zend chunk base
2 code leak 251,557 B producer + 8 x 92,388 B -> 16 B cookie
(OOM poisons a worker) └─ zval_ptr_dtor
3 callback 368,812 B POST -> forged HashTable runs
(fake HashTable + command) └─ zif_system(cmd)
─────────────────────────────────────────────────────────────────────────────
20 HTTP requests at most. No looping, no retries.
zif_system(PHP의 system 핸들러)을 호출한다.print-flag 결과를 돌려보낸다.왜 주소가 두 개인가? OS는 PHP의 코드와 힙이 위치하는 곳을 독립적으로 무작위화한다. 힙 유출은 스크립트에게 위조된 구조체를 어디에 둘지 알려주고, 코드 유출은 호출하려는 함수가 실제로 어디에 있는지 알려준다. 이전 워커나 이전 실행에서 얻은 주소는 안전한 대체물이 아니며, 시도할 때마다 그 시도는 그저 실패했다.
이 PoC의 오프셋은 네 개의 워커로 내장 CLI 서버를 실행하는 Alpine PHP 8.4.26에 맞춰 보정되었다. 실습 환경은 루프백에서 nginx 뒤에 정확히 그 구성을 재현한다.
[!IMPORTANT] 버전 문자열보다 빌드가 더 중요하다. Debian
php:8.4.26-fpm스택은 동일한PHP/8.4.26을 보고하지만 바이너리 레이아웃이 다르므로, 코드 포인터 지문이 절대 일치하지 않고 실행은 코드 유출 단계에서 중단된다. 아래의 Alpine 이미지를 사용하라.
작은 파일 세 개. 빈 디렉터리에 넣어라:
public/php-version.php (헬스 엔드포인트이며, PoC에 필요한 유일한 페이지)
<?php
header('Content-Type: text/plain');
echo 'PHP ', PHP_VERSION, "\n";
echo 'PID: ', getmypid(), "\n";
print-flag (챌린지 바이너리를 대신하는 실행 파일, 실행 가능해야 함)
#!/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;
}
}
}
그런 다음 실행합니다:
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
확인하세요:
$ curl -s http://127.0.0.1:18166/php-version.php
PHP 8.4.26
PID: 1
PID는 응답하는 워커에 따라 달라지며, 이는 문제되지 않습니다. PoC가 확인하는 부분은 버전 라인입니다.
docker rm -f ctf-cross-nginx ctf-cross-php && docker network rm ctf-cross-net 명령으로 정리하세요.
[!TIP] Windows에서는 해당
docker run명령을 PowerShell에서 실행하거나,$PWD를C:/lab과 같은 전체 드라이브 경로로 바꾸세요. Git Bash에서는$PWD가/tmp/...같은 MSYS 경로로 확장되는데, Docker Desktop은 이를 자체 VM 내부에서 해석합니다. 그러면 바인드 마운트가nginx.conf와print-flag가 있어야 할 위치에 빈 디렉터리를 조용히 생성하고, 컨테이너는 망가진 상태로 뜹니다.
이 레시피에 관한 몇 가지 참고 사항입니다. 이미지는 fpm 태그이지만 컨테이너는 php -S를 실행하므로 SAPI는 cli-server이며, 이것이 캘리브레이션 측정 기준입니다. PHP_CLI_SERVER_WORKERS=4는 누출 단계에서 경쟁할 워커를 하나 이상 확보해 주는 설정입니다. 제 랩 하네스는 워커에 트레이서를 붙일 수 있도록 --cap-add SYS_PTRACE --security-opt seccomp=unconfined도 전달했지만, PoC 자체는 그렇게 하지 않으므로 위에서는 제외했습니다.
Python 3.11 이상, 표준 라이브러리만 사용하며 설치할 것은 없습니다:
python .\stealmybitcoin_poc.py --url http://127.0.0.1:18166/php-version.php
인수 없이 실행하면 도움말을 출력하고 네트워크에 접근하지 않은 채 종료됩니다.
정상적인 실행 결과는 다음과 같습니다:
[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": "..."}
해당 zval_ptr_dtor 값의 하위 21비트에 주목하세요: 0x112930으로, 이 Alpine 빌드에서 스크립트가 기대하는 지문과 정확히 일치합니다. 이것이 불일치하는 이미지를 거부하는 검사입니다.
돌아오는 응답 본문은 다음과 같습니다:
<br />
<b>Fatal error</b>: Allowed memory size of 134217728 bytes exhausted (tried to allocate 131072 bytes) in <b>Unknown</b> on line <b>0</b><br />
1ef0e73d8615157a
uid=0(root) gid=0(root) groups=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/local/bin/print-flag
LOCAL_DUMMY_WIF_NEVER_USE
PHP 치명적 오류, 그다음 이 실행에서 생성된 nonce, 그다음 id, 그다음 해석된 바이너리의 해시, 그다음 더미 플래그. 종료 상태는 검증된 실행에서는 0, 그 외의 경우에는 1이다.
[!WARNING] 프로듀서는 의도적으로 PHP를 메모리 한도까지 밀어붙인다. 이 동작이 허가된 곳에서만 실행하라. 스크립트는 챌린지 엔드포인트 또는 루프백 랩 URL과만 통신하며, 그 외의 대상은 패킷이 나가기 전에 중단된다.
한 번의 실행은 헬스 GET 한 번, 최대 두 번의 유출 배치, 그리고 최대 한 번의 최종 요청을 의미한다. 반복도, 재시도도 없다. 모든 멀티파트 본문은 1 MiB 미만으로 유지되지만, 각 유출 배치는 하나의 연결로 아홉 개의 개별 요청을 파이프라인 처리한다.
| 플래그 | 수행하는 작업 |
|---|---|
--url | 랩 엔드포인트 또는 챌린지. 무언가를 하려면 필수. |
--output-dir | 실행의 증거를 기록할 위치. 기본값은 poc-run-<UTC timestamp>. |