TECH 으로 돌아가기
TECH HACKER NEWS 8분 읽기 33 READS

비트코인을 훔치는 CTF: PHP 메모리 익스플로잇을 한 파일로 재현하다

비트코인을 훔치는 CTF: PHP 메모리 익스플로잇을 한 파일로 재현하다
SOURCE IMAGE · HACKER NEWS

KillaBoi라는 연구자가 자신이 풀어낸 'Steal My Bitcoin' CTF 챌린지의 공격 과정을 단일 파이썬 파일로 재현해 공개했다. stealmybitcoin_poc.py라는 이름의 이 스크립트는 외부 라이브러리 없이 파이썬 표준 라이브러리만으로 동작하며, 공격에 필요한 HTTP 요청을 직접 조립하고 각 단계의 정보 유출을 검증한 뒤 다음 단계로 넘어간다. 공개된 저장소의 설명은 실제 챌린지 서버가 아니라 동일한 빌드를 흉내 낸 로컬 도커 랩을 기준으로 작성됐으며, 실제로 챌린지를 풀어낸 라이브 실행은 하단에 기록용으로만 남겨두었다.

이 공격이 성립하는 환경은 매우 구체적이다. nginx 뒤에서 워커 네 개로 돌아가는 Alpine 기반 PHP 8.4.26 CLI 서버가 전제이며, PoC에 들어간 오프셋들은 바로 이 빌드에 맞춰 측정됐다. 저자가 강조하는 지점은 '버전 문자열보다 빌드가 중요하다'는 것이다. 같은 8.4.26이라도 Debian php:8.4.26-fpm 이미지는 바이너리 배치가 달라서 코드 포인터 지문이 절대 들어맞지 않고, 공격은 코드 유출 단계에서 중단된다. 그래서 랩 구성도 fpm 태그 이미지를 쓰되 컨테이너 안에서는 php -S로 띄워 SAPI를 cli-server로 맞추고, PHP_CLI_SERVER_WORKERS=4로 워커를 여러 개 띄워 경쟁 조건을 재현하도록 짜여 있다.

두 번의 누출, 두 개의 주소

공격의 핵심은 서로 독립적으로 무작위화되는 두 종류의 주소를 각각 알아내는 데 있다. 운영체제는 PHP의 코드 영역과 힙 영역이 메모리의 어디에 놓일지를 따로따로 흩뿌리기 때문에, 힙 누출은 조작한 구조체를 어디에 심을지 알려주고 코드 누출은 호출하려는 함수가 실제로 어디 있는지 알려준다. 저자는 과거 워커나 과거 실행에서 얻은 주소를 재활용하면 매번 실패했다고 적으며, 두 누출 모두 반드시 같은 실행에서 나와야 한다는 점을 스크립트 차원에서 강제한다고 설명한다.

누출의 방아쇠는 PHP의 세션 업로드 진행 기능이다. 공격 요청은 PHPSESSID와 PHP_SESSION_UPLOAD_PROGRESS 필드를 담은 multipart/form-data로, 수백 개의 빈 파일 컨트롤에 f[0][x][x]처럼 수십 겹 중첩된 이름을 붙인다. 전송되는 본문은 250KiB 정도로 작지만, PHP가 이를 파싱하면서 중첩 배열과 그에 딸린 할당을 대량으로 만들어내 결국 128MiB 메모리 한도를 넘겨 치명적 오류를 일으킨다. 저자가 노리는 것은 오류 자체가 아니라 그 실패가 워커에 남기는 낡은 상태다. 이어지는 요청은 이 상태를 겨냥해 PHPSESSID를 쓸 때 사용하는 길이를 조작하고, 운이 맞으면 쿠키가 보통의 32바이트가 아니라 256바이트로 돌아오며 그 안에 이웃한 힙 데이터가 실려 나온다.

지문과 콜백

코드 누출 단계에서는 다른 배열 배치로 같은 메모리 한도 오류를 유발해 함수 포인터를 끌어낸다. 이때 돌아오는 16바이트 쿠키에서 스크립트는 포인터의 범위와 하위 21비트가 0x112930인지를 확인한다. 이 값은 Alpine PHP 8.4.26 이미지에서 해당 심볼의 페이지 오프셋이며 ASLR이 건드리지 않기 때문에, 원격 이미지가 맞는 빌드인지 가려내는 지문 역할을 한다. 다만 저자는 이것이 원격 실행 파일이 바이트 단위로 동일하다는 증명은 아니라고 분명히 선을 긋는다. 마지막 단계에서는 384바이트짜리 위조 HashTable을 심어 가짜 호출 프레임과 명령 문자열을 담은 가짜 zend_string을 겹쳐 놓고, 정리 경로에서 PHP가 위조 테이블의 pDestructor를 zif_system으로 읽어 문자열 인자와 함께 실행하게 만든다. 원래 성공한 실행은 print-flag를 한 번 돌렸을 뿐이지만, 공개 버전은 무작위 nonce와 id 명령 출력, 그리고 print-flag 바이너리의 SHA-256을 함께 찍어 실행을 입증하도록 바꿨다.

이 PoC는 실무적으로 몇 가지를 분명히 보여준다. 첫째, 한 번의 실행은 최대 20개의 HTTP 요청으로 끝나고 재시도 루프가 없다. 두 누출 단계 모두 나머지 세 워커와 벌이는 경쟁이라 단일 실행이 항상 성공하지는 않으며, 랩 기준으로 열 번 중 대략 일곱 번 성공한다. 흥미로운 점은 같은 컨테이너에서는 워커가 유지되고 ASLR이 프로세스 단위라, 매 실행마다 동일한 힙 베이스와 동일한 함수 포인터가 돌아온다는 것이다. 실행마다 달라지는 건 어느 워커가 어느 요청에 답하느냐뿐이다. 실패도 두 종류로 뚜렷이 갈린다. 힙 단계가 빗나가면 여덟 소비자가 모두 평범한 32바이트 쿠키로 돌아와 아무것도 위조되기 전에 멈추고, 콜백이 빗나가면 두 누출은 성공했지만 최종 응답에 메모리 오류만 담겨 온다.

증거의 공백을 스스로 밝히다

저자가 재현 과정보다 더 공들여 적은 부분은 오히려 한계다. 실제 챌린지를 푼 2026년 10월 25일 실행에서는 368,812바이트 POST가 HTTP 200으로 돌아오며 PHP 오류 뒤에 체크섬이 유효한 WIF 하나를 담아 왔고, 로컬 랩이 콜백과 소켓 출력 방식을 더미 데이터로 재현해 라이브 명령 실행의 근거를 뒷받침했다. 그러나 원래 실행은 나가는 요청 전문과 응답 헤더를 온전히 기록하지 못했고, 전체 요청은 사후에 빌더와 남은 메타데이터로 재구성한 것이어서 해시가 맞는다고 해서 원본 와이어 캡처로 격상되지는 않는다고 적는다. 원격 PHP ELF나 print-flag 바이너리를 직접 빼낸 적도 없이 그저 실행했을 뿐이다. 더구나 복구한 WIF는 당시 홈페이지에 걸린 상금 주소를 유도하지 못했는데, 이는 출제자 측의 실수였고 이메일로 확인됐다고 한다. 스크립트 자체도 반환된 WIF가 광고된 비트코인 주소를 실제로 통제하는지는 아무것도 주장하지 않으며, 주소를 유도해 대조하는 일은 별개라고 선을 긋는다. 공격 성공을 극적으로 포장하는 대신 무엇이 증명됐고 무엇이 비어 있는지를 또렷이 구분하는 이 태도가, 익스플로잇 기법 자체만큼이나 이 공개물에서 배울 점이다. 저자는 조정된 수치가 전부 상단 캘리브레이션 블록이나 STAGES 표에 모여 있으니 다른 빌드에 맞춰 다시 측정할 때는 그곳을 보라고 안내하며, 승인된 환경에서만 실행하라는 경고를 거듭 붙여 두었다.

SOURCE · HACKER NEWS
원문 전체 보기 → https://github.com/KillaBoi/StealMyBitcoin-PoC
SHARE
NEXT · CHOOSE

변화를 읽었다면,
내가 만들 수익 구조를 고릅니다.

정보를 더 모으는 데서 멈추지 않고, 광고·외주·판매·중개·구독 중 내 상황에 맞는 출발점을 정해보세요.

21가지 수익 구조 살펴보기 →
처리 중...