← 목록으로

NX/DEP 우회와 Return-to-libc

[Pwn] Pwnable · 작성: 2026-07-19 19:31:03 · 수정: 2026-07-21 23:11:54 · 조회 19

NX(No-eXecute)와 그걸 우회하는 Return-to-libc/ROP를 이해하려면, "스택에 코드를 못 심게 막는다"는 설명보다 한 단계 더 내려가야 한다 — 이건 소프트웨어가 아니라 CPU의 MMU(메모리 관리 유닛)와 페이지 테이블이 강제하는 하드웨어 레벨 보호이고, 그래서 우회 방법도 "새 코드를 실행하는" 게 아니라 "이미 존재하는 코드만 재활용하는" 방향으로 완전히 바뀐다.

하드웨어 레벨: NX는 어떻게 "실행 금지"를 강제하는가

x86-64 CPU는 가상 메모리를 페이지(보통 4KB) 단위로 관리하고, 각 페이지의 권한(읽기/쓰기/실행)은 페이지 테이블 엔트리(PTE)의 비트로 결정된다. 그중 **NX 비트(No-eXecute bit, PTE의 63번 비트)**가 1로 설정된 페이지는, CPU가 그 페이지 안의 바이트를 명령어로 fetch하려는 시도 자체를 하드웨어 레벨에서 차단하고 즉시 #PF(Page Fault) 예외를 발생시킨다. 커널은 스택·힙 페이지를 매핑할 때 이 NX 비트를 세팅해두기 때문에, 스택에 아무리 정교한 셸코드를 심어도 RIP가 그 주소를 가리키는 순간 CPU가 "이 페이지는 실행 권한이 없다"며 프로세스를 강제 종료시킨다 — 이건 운영체제의 정책이 아니라 CPU 자신이 물리적으로 강제하는 규칙이라, 소프트웨어적인 우회(권한 상승 등)로는 뚫을 수 없다.

Return-to-libc: "새 코드"가 아니라 "이미 실행 권한이 있는 코드"를 재활용

NX가 막는 건 딱 하나 — 공격자가 새로 주입한 데이터를 코드로 실행하는 것뿐이다. 그런데 프로세스 메모리 안에는 이미 실행 권한이 있는 코드가 잔뜩 있다 — 바로 libc 자신이다. libc는 system(), execve() 같은 셸을 띄우는 함수를 이미 포함하고 있고, 이 코드는 당연히 실행 가능한 페이지에 있다. 리턴 주소를 셸코드 주소가 아니라 system() 함수의 시작 주소로 덮어쓰면, NX와 전혀 상관없이 그 함수가 그대로 실행된다 — 이게 Return-to-libc다.

[buf 채움][saved RBP 채움][리턴 주소 = system()의 주소][system이 끝난 뒤 리턴할 주소 = 아무거나][system의 인자 = "/bin/sh" 문자열 주소]

x86-64에서는 함수 인자가 레지스터(RDI)로 전달되므로, 스택에 인자를 그냥 얹는 32비트 방식이 안 통한다 — 그래서 RDI에 원하는 값을 넣어주는 pop rdi; ret 같은 작은 코드 조각(gadget)을 먼저 거쳐야 한다. 이게 바로 ROP(Return-Oriented Programming)로 자연스럽게 이어지는 지점이다.

ROP: gadget을 이어붙여 "없는 코드"를 만들어내기

ROP는 Return-to-libc의 일반화다. 바이너리나 libc 안에 이미 존재하는, **ret으로 끝나는 짧은 명령어 조각들(gadget)**을 여러 개 찾아서, 리턴 주소 자리에 이 gadget들의 주소를 순서대로 쌓아두면 — 한 gadget이 ret으로 끝나며 스택의 다음 8바이트(다음 gadget 주소)로 실행 흐름이 넘어가고, 그게 다시 ret으로 끝나며 그 다음으로 넘어가는 식으로 스택에 쌓아둔 순서대로 여러 개의 짧은 코드 조각을 연쇄 실행시킬 수 있다.

[pop rdi; ret 의 주소]
["/bin/sh" 문자열이 있는 주소]     ; pop rdi가 이 값을 RDI에 넣음
[system() 함수의 주소]             ; 이어지는 ret이 이리로 점프

RIP를 한 번만 컨트롤할 수 있어도, 이미 메모리에 존재하는 코드 조각들을 이어붙여 사실상 임의의 로직(레지스터 설정 → 함수 호출 → 그 결과로 또 다른 함수 호출)을 조립해낼 수 있다는 게 ROP의 핵심 — 새 코드를 주입하는 게 아니라 기존 코드의 "끝부분들"을 재료 삼아 새 프로그램을 짜 넣는 것이라, NX가 아무리 완벽해도 원천적으로 막을 수 없는 종류의 공격이다.

페이로드가 실제로 작동하는 순서

  1. objdump/ROPgadget으로 바이너리와 libc 안에서 필요한 gadget(pop rdi; ret 등) 주소 확보
  2. 오버플로우 오프셋만큼 채운 뒤, pop rdi; ret 주소 → "/bin/sh" 문자열 주소 → system() 주소 순으로 스택에 쌓은 페이로드 전송
  3. 취약 함수가 ret 실행 → RIP가 pop rdi; ret로 이동 → pop이 스택의 다음 값(문자열 주소)을 RDI에 로드하고 ret으로 스택의 그 다음 값(system 주소)으로 점프
  4. system("/bin/sh") 실행 → NX가 완벽히 걸려있어도 셸 획득

여기서 끝나지 않는다: 체이닝

NX를 "우회 대상"이 아니라 **"공격자의 선택지를 코드 주입에서 코드 재사용으로 강제로 좁힌 보호 기법"**으로 이해하면, 왜 ASLR·RELRO 같은 다른 보호 기법들이 항상 NX와 함께 세트로 언급되는지가 같은 논리로 설명된다 — NX 하나만으로는 충분하지 않고, "재사용할 코드의 주소를 예측 못 하게(ASLR)", "재사용 대상이 될 함수 포인터 자체를 못 바꾸게(RELRO)" 만드는 다른 계층의 방어가 함께 있어야 실질적인 방어선이 된다.

Pwnable 카테고리의 글 (5/14)

  1. 메모리 구조 기초: 스택, 힙, 레지스터부터 이해하기
  2. 스택 버퍼 오버플로우: 가장 기본적인 메모리 손상 공격
  3. Format String 취약점: printf가 위험해지는 순간
  4. Stack Canary: 원리와 우회 방법
  5. NX/DEP 우회와 Return-to-libc
  6. ASLR과 정보 유출(Leak)
  7. ROP (Return-Oriented Programming) 완전 정복
  8. 힙 익스플로잇 기초: Use-After-Free와 Double Free
  9. pwntools 실전 활용법
  10. UDF(User-Defined Function) 기반 DB 권한 상승: 원리부터 실전까지
  11. Pass-the-Hash / Pass-the-Ticket: 원리와 실전
  12. eBPF 기반 지속성(Persistence) 기법
  13. Full RELRO 환경에서의 GOT Overwrite와 우회
  14. ret2dlresolve: 심볼 정보 없이 임의 함수 호출하기
← Stack Canary: 원리와 우회 방법 ASLR과 정보 유출(Leak) →