공유 라이브러리는 프로세스마다 다른 주소에 올라간다. ASLR이 켜져 있으면 같은 libc.so.6도 실행할 때마다 위치가 바뀐다. 그런데 .text는 읽기 전용으로 매핑되어 여러 프로세스가 물리 페이지를 공유하므로, call printf의 피연산자에 실제 주소를 직접 써 넣는 방식은 쓸 수 없다.
그래서 코드는 주소를 데이터 영역의 슬롯에서 읽어 간접 호출하고, 그 슬롯을 동적 링커가 채운다. 이 슬롯 배열이 GOT(Global Offset Table)이고, 슬롯을 경유해 점프해주는 작은 스텁 코드 묶음이 PLT(Procedure Linkage Table)다. 이 글에서는 x86-64 리눅스에서 PLT와 GOT가 실제로 어떻게 배치되고 언제 채워지는지를 objdump·readelf와 자기 자신의 GOT를 들여다보는 C 프로그램으로 직접 확인한다.
실험 환경은 Ubuntu 24.04 / x86-64, gcc 13.3.0, glibc 2.39다.
바이너리 안의 어느 섹션이 무엇을 맡나
| 섹션 | 내용 |
|---|---|
.plt | PLT[0] 리졸버 진입 스텁 + 심볼별 push idx; jmp PLT[0] 스텁 |
.plt.sec | CET/IBT가 켜진 빌드에서 실제 호출 대상이 되는 endbr64; jmp *GOT 스텁 |
.got | 데이터 심볼과 -z now 바인딩용 슬롯 |
.got.plt | 함수용 슬롯. 앞 3칸은 링커가 예약 |
.rela.plt | .got.plt 슬롯을 어느 심볼로 채울지 적어둔 재배치 항목 |
.rela.dyn | 시작 시점에 무조건 처리되는 재배치 항목 |
슬롯을 채우는 방식은 재배치 타입으로 구분된다. 아래 네 가지가 실제로 자주 보이는 것들이다.
| 타입 | 대상 | 처리 시점 |
|---|---|---|
R_X86_64_JUMP_SLOT | .got.plt 함수 슬롯 | lazy면 첫 호출, -z now면 시작 시 |
R_X86_64_GLOB_DAT | .got 슬롯 | 항상 시작 시 |
R_X86_64_COPY | 실행 파일 .bss | 시작 시. 라이브러리 전역 변수를 실행 파일 쪽으로 복사 |
R_X86_64_RELATIVE | 자기 자신 내부 포인터 | 시작 시. 심볼 검색 없이 로드 주소만 더함 |
지연 바인딩의 실제 구조
요즘 배포판 gcc는 기본으로 -z now를 켜므로, 고전적인 지연 바인딩(lazy binding)을 보려면 명시적으로 꺼야 한다.
#include <stdio.h>
#include <string.h>
int main(void)
{
char buf[32];
strcpy(buf, "PLT/GOT");
puts(buf);
printf("len=%zu\n", strlen(buf));
return 0;
}$ gcc -O0 -no-pie -fno-stack-protector -Wl,-z,lazy,-z,norelro -o hello_lazy hello.c
.plt를 뜯어보면 PLT[0]과 심볼별 스텁의 형태가 그대로 드러난다.
$ objdump -d -j .plt hello_lazy
0000000000401020 <.plt>:
401020: ff 35 c2 22 00 00 push 0x22c2(%rip) # 4032e8 <_GLOBAL_OFFSET_TABLE_+0x8>
401026: ff 25 c4 22 00 00 jmp *0x22c4(%rip) # 4032f0 <_GLOBAL_OFFSET_TABLE_+0x10>
40102c: 0f 1f 40 00 nopl 0x0(%rax)
401030: f3 0f 1e fa endbr64
401034: 68 00 00 00 00 push $0x0
401039: e9 e2 ff ff ff jmp 401020 <_init+0x20>
40103e: 66 90 xchg %ax,%ax
401040: f3 0f 1e fa endbr64
401044: 68 01 00 00 00 push $0x1
401049: e9 d2 ff ff ff jmp 401020 <_init+0x20>
40104e: 66 90 xchg %ax,%ax
401050: f3 0f 1e fa endbr64
401054: 68 02 00 00 00 push $0x2
각 스텁은 자기 재배치 인덱스를 스택에 밀어넣고 PLT[0]으로 점프할 뿐이다. 인덱스는 .rela.plt의 몇 번째 항목인지를 가리킨다.
$ readelf -rW hello_lazy | sed -n '/.rela.plt/,$p'
Relocation section '.rela.plt' at offset 0x4f0 contains 3 entries:
Offset Info Type Symbol's Value Symbol's Name + Addend
00000000004032f8 0000000200000007 R_X86_64_JUMP_SLOT 0000000000000000 puts@GLIBC_2.2.5 + 0
0000000000403300 0000000300000007 R_X86_64_JUMP_SLOT 0000000000000000 strlen@GLIBC_2.2.5 + 0
0000000000403308 0000000400000007 R_X86_64_JUMP_SLOT 0000000000000000 printf@GLIBC_2.2.5 + 0
.got.plt의 파일상 초기값을 보면 위 오프셋들이 실제로 무엇으로 채워져 있는지 확인된다.
$ objdump -s -j .got.plt hello_lazy
Contents of section .got.plt:
4032e0 00314000 00000000 00000000 00000000 .1@.............
4032f0 00000000 00000000 30104000 00000000 ........0.@.....
403300 40104000 00000000 50104000 00000000 @.@.....P.@.....
puts 슬롯 0x4032f8에 들어 있는 값은 0x401030, 즉 자기 PLT 스텁의 push 명령이다. 첫 호출은 스텁 → GOT → 다시 스텁으로 되돌아와 PLT[0]으로 흘러가도록 일부러 배치된 것이다.
앞 세 칸의 역할은 다음과 같다. GOT[1]과 GOT[2]는 파일에서 0이고 실행 시 ld.so가 채운다.
| 슬롯 | 주소 | 내용 |
|---|---|---|
| GOT[0] | 0x4032e0 | .dynamic 섹션 주소 |
| GOT[1] | 0x4032e8 | 이 오브젝트의 link_map 포인터 |
| GOT[2] | 0x4032f0 | _dl_runtime_resolve 주소 |
PLT[0]이 하는 일이 정확히 이 둘을 쓰는 것이다. push GOT[1]로 어느 오브젝트인지 알려주고 jmp *GOT[2]로 리졸버에 넘긴다. 실행 중에 세 칸을 직접 읽어 확인해보자.
#define _GNU_SOURCE
#include <stdio.h>
#include <link.h>
int main(void)
{
extern ElfW(Dyn) _DYNAMIC[];
void **got = NULL;
for (ElfW(Dyn) *d = _DYNAMIC; d->d_tag != DT_NULL; d++)
if (d->d_tag == DT_PLTGOT)
got = (void **)d->d_un.d_ptr;
printf("GOT[0] .dynamic = %p\n", got[0]);
printf("GOT[1] link_map = %p\n", got[1]);
printf("GOT[2] resolver = %p\n", got[2]);
FILE *f = fopen("/proc/self/maps", "r");
char line[512];
unsigned long a = (unsigned long)got[2], lo, hi;
while (fgets(line, sizeof line, f))
if (sscanf(line, "%lx-%lx", &lo, &hi) == 2 && a >= lo && a < hi) {
printf(" resolver 가 속한 매핑: %s", line);
break;
}
fclose(f);
return 0;
}$ gcc -O0 -no-pie -Wl,-z,lazy,-z,norelro -o got012 got012.c && ./got012
GOT[0] .dynamic = 0x403180
GOT[1] link_map = 0x7407dafd82e0
GOT[2] resolver = 0x7407dafb42f0
resolver 가 속한 매핑: 7407dafa0000-7407dafcb000 r-xp 00001000 08:30 50403 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
$ readelf -SW got012 | grep '\.dynamic'
[22] .dynamic DYNAMIC 0000000000403180 002180 0001d0 10 WA 7 0 8
GOT[0]은 .dynamic 주소와 정확히 일치하고, GOT[2]는 ld-linux-x86-64.so.2의 실행 매핑 안을 가리킨다. 리졸버 심볼이 로컬이라 dladdr()로는 이름이 안 나오므로 매핑 범위로 확인했다.
GOT 슬롯이 바뀌는 순간 관찰하기
_DYNAMIC에서 .rela.plt와 .dynsym을 직접 훑으면 특정 심볼의 GOT 슬롯 주소를 알 수 있다. 그 슬롯을 첫 호출 전후로 읽어보면 지연 바인딩이 눈에 보인다.
#define _GNU_SOURCE
#include <stdio.h>
#include <string.h>
#include <dlfcn.h>
#include <link.h>
/* .rela.plt 를 훑어 심볼 이름에 해당하는 GOT 슬롯 주소를 찾는다 */
static void **find_got_slot(const char *want)
{
extern ElfW(Dyn) _DYNAMIC[];
const ElfW(Rela) *jmprel = NULL;
const ElfW(Sym) *symtab = NULL;
const char *strtab = NULL;
size_t pltrelsz = 0;
for (ElfW(Dyn) *d = _DYNAMIC; d->d_tag != DT_NULL; d++) {
switch (d->d_tag) {
case DT_JMPREL: jmprel = (const void *)d->d_un.d_ptr; break;
case DT_PLTRELSZ: pltrelsz = d->d_un.d_val; break;
case DT_SYMTAB: symtab = (const void *)d->d_un.d_ptr; break;
case DT_STRTAB: strtab = (const void *)d->d_un.d_ptr; break;
}
}
if (!jmprel || !symtab || !strtab)
return NULL;
for (size_t i = 0; i < pltrelsz / sizeof(*jmprel); i++) {
const char *name = strtab + symtab[ELF64_R_SYM(jmprel[i].r_info)].st_name;
if (strcmp(name, want) == 0)
return (void **)jmprel[i].r_offset;
}
return NULL;
}
int main(void)
{
void **slot = find_got_slot("puts");
if (!slot) {
fprintf(stderr, "puts GOT 슬롯을 찾지 못함\n");
return 1;
}
printf("GOT slot = %p\n", (void *)slot);
printf("호출 전 = %p\n", *slot);
puts("첫 puts() 호출");
printf("호출 후 = %p\n", *slot);
printf("libc puts = %p\n", dlsym(RTLD_NEXT, "puts"));
return 0;
}printf를 먼저 부르므로 printf 쪽 슬롯은 이미 바인딩된 상태이고, puts 슬롯만 아직 스텁 주소를 들고 있다.
$ gcc -O0 -no-pie -Wl,-z,lazy,-z,norelro -o gotwatch gotwatch.c && ./gotwatch
GOT slot = 0x4033a0
호출 전 = 0x401030
첫 puts() 호출
호출 후 = 0x7d67c2487cc0
libc puts = 0x7d67c2487cc0
호출 전 값 0x401030은 앞에서 본 PLT 스텁 주소이고, 호출 후에는 libc의 실제 puts로 바뀌어 dlsym(RTLD_NEXT, "puts")와 같은 값이 된다.
바인딩 시점은 LD_DEBUG=bindings로도 확인된다. 파이프로 넘기면 stdout이 블록 버퍼링되어 순서가 뒤엉키므로 stdbuf로 버퍼링을 끄는 것이 요령이다.
$ stdbuf -o0 -e0 env LD_DEBUG=bindings ./gotwatch 2>&1 | tail -8
3086: binding file ./gotwatch [0] to /lib/x86_64-linux-gnu/libc.so.6 [0]: normal symbol `strcmp' [GLIBC_2.2.5]
3086: binding file ./gotwatch [0] to /lib/x86_64-linux-gnu/libc.so.6 [0]: normal symbol `printf' [GLIBC_2.2.5]
GOT slot = 0x4033a0
호출 전 = 0x401030
3086: binding file ./gotwatch [0] to /lib/x86_64-linux-gnu/libc.so.6 [0]: normal symbol `puts' [GLIBC_2.2.5]
첫 puts() 호출
호출 후 = 0x72823a287cc0
3086: binding file ./gotwatch [0] to /lib/x86_64-linux-gnu/libc.so.6 [0]: normal symbol `dlsym' [GLIBC_2.34]
puts 바인딩 로그가 “호출 전” 출력과 실제 puts 출력 사이에 끼어 있다. 심볼 검색이 정확히 첫 호출 시점에 일어난 것이다.
-z now 와 RELRO
같은 소스를 -z now로 다시 링크하거나 LD_BIND_NOW=1로 실행하면 첫 호출 전에 이미 슬롯이 채워져 있다.
$ gcc -O0 -no-pie -Wl,-z,now,-z,relro -o gotwatch_now gotwatch.c && ./gotwatch_now
GOT slot = 0x403fc8
호출 전 = 0x746666487cc0
첫 puts() 호출
호출 후 = 0x746666487cc0
libc puts = 0x746666487cc0
$ LD_BIND_NOW=1 ./gotwatch
GOT slot = 0x4033a0
호출 전 = 0x770f43487cc0
첫 puts() 호출
호출 후 = 0x770f43487cc0
libc puts = 0x770f43487cc0
바인딩이 시작 시점에 끝나면 GOT를 계속 쓰기 가능으로 둘 이유가 없다. RELRO는 이 점을 이용해 초기화가 끝난 뒤 mprotect()로 해당 페이지를 읽기 전용으로 바꾼다. 슬롯이 어느 매핑에 속하는지 /proc/self/maps에서 찾아보면 세 가지 설정의 차이가 그대로 드러난다.
unsigned long a = (unsigned long)find_got_slot("printf");
FILE *f = fopen("/proc/self/maps", "r");
char line[512];
unsigned long lo, hi;
while (fgets(line, sizeof line, f))
if (sscanf(line, "%lx-%lx", &lo, &hi) == 2 && a >= lo && a < hi) {
printf("printf GOT %#lx -> %s", a, line);
break;
}$ for f in "lazy,-z,norelro" "now,-z,relro" "lazy,-z,relro"; do
gcc -O0 -no-pie -Wl,-z,$f -o perms_x perms.c && ./perms_x
done
printf GOT 0x403360 -> 00403000-00404000 rw-p 00002000 08:30 17865 .../perms_x
printf GOT 0x403fd0 -> 00403000-00404000 r--p 00002000 08:30 17865 .../perms_x
printf GOT 0x404018 -> 00404000-00405000 rw-p 00003000 08:30 17865 .../perms_x
| 설정 | GOT 페이지 권한 | 설명 |
|---|---|---|
-z norelro | rw-p | 보호 없음 |
-z now -z relro (full RELRO) | r--p | .got.plt를 .got에 합쳐 통째로 읽기 전용 |
-z lazy -z relro (partial RELRO) | rw-p | .got만 보호. .got.plt는 다음 페이지에 남아 쓰기 가능 |
partial RELRO에서 슬롯 주소만 0x404018로 다음 페이지에 떨어져 있는 것이 핵심이다. 지연 바인딩을 유지하려면 .got.plt는 계속 써야 하므로 보호 대상에서 빠진다.
쓰기 가능 여부는 GOT를 직접 덮어써보면 확인된다.
static int my_puts(const char *s)
{
return printf("[hooked] %s\n", s);
}
int main(void)
{
void **slot = find_got_slot("puts");
printf("GOT slot = %p\n", (void *)slot);
puts("원본 puts");
*slot = (void *)my_puts; /* GOT 덮어쓰기 */
puts("덮어쓴 뒤");
return 0;
}$ gcc -O0 -no-pie -Wl,-z,lazy,-z,norelro -o gothook_norelro gothook.c && ./gothook_norelro; echo "exit=$?"
GOT slot = 0x403378
원본 puts
[hooked] 덮어쓴 뒤
exit=0
$ gcc -O0 -no-pie -Wl,-z,now,-z,relro -o gothook_relro gothook.c && stdbuf -o0 ./gothook_relro; echo "exit=$?"
GOT slot = 0x403fd8
원본 puts
Segmentation fault (core dumped)
exit=139
full RELRO 쪽은 슬롯 주소를 구해 읽는 것까지는 되지만 대입에서 바로 죽는다. GOT 덮어쓰기가 전형적인 익스플로잇 경로였던 만큼, 현재 배포판 툴체인은 full RELRO를 기본값으로 켜둔다.
라이브러리 내부 호출도 PLT를 거친다
공유 라이브러리 안에서 자기 라이브러리의 전역 함수를 부를 때도 기본적으로 PLT를 경유한다. 심볼 인터포지션(symbol interposition), 즉 먼저 로드된 오브젝트의 같은 이름 심볼이 이기는 규칙을 지키기 위해서다.
/* lib.c */
#include <stdio.h>
void helper(void) { puts("lib: 원본 helper"); }
void entry(void) { helper(); } /* 같은 .so 안에서의 호출 */
/* over.c — LD_PRELOAD 로 끼워넣을 쪽 */
#include <stdio.h>
void helper(void) { puts("preload: 가로챈 helper"); }$ gcc -O2 -fPIC -shared -o liba.so lib.c
$ gcc -O2 -fPIC -shared -o libover.so over.c
$ gcc -O2 -o app app.c -L. -la -Wl,-rpath,'$ORIGIN'
$ objdump -d --no-show-raw-insn liba.so | sed -n '/<entry>:/,/^$/p'
0000000000001150 <entry>:
1150: endbr64
1154: jmp 1070 <helper@plt>
$ ./app
lib: 원본 helper
$ LD_PRELOAD=./libover.so ./app
preload: 가로챈 helper
entry가 helper@plt로 점프하기 때문에 GOT 슬롯만 바꿔치기하면 가로채기가 성립한다. -Bsymbolic으로 링크하면 라이브러리 내부 참조가 직접 호출로 고정되면서 이 경로가 사라진다.
$ gcc -O2 -fPIC -shared -Wl,-Bsymbolic -o liba_sym.so lib.c
$ objdump -d --no-show-raw-insn liba_sym.so | sed -n '/<entry>:/,/^$/p'
0000000000001130 <entry>:
1130: endbr64
1134: jmp 1120 <helper>
$ LD_PRELOAD=./libover.so ./app_sym
lib: 원본 helper
반대 방향으로, 호출 측에서 PLT 스텁 자체를 없앨 수도 있다. -fno-plt는 call PLT 대신 GOT를 통한 간접 호출을 바로 생성한다.
/* callsite.c */
#include <stdio.h>
extern int optind; /* libc 전역 변수 = 데이터 심볼 */
int main(void)
{
printf("optind=%d\n", optind);
return 0;
}$ gcc -O2 -o callsite callsite.c
$ objdump -d --no-show-raw-insn callsite | sed -n '/<main>:/,/^$/p'
107c: call 1050 <__printf_chk@plt>
$ gcc -O2 -fno-plt -o callsite_noplt callsite.c
$ objdump -d --no-show-raw-insn callsite_noplt | sed -n '/<main>:/,/^$/p'
105c: call *0x2f86(%rip) # 3fe8 <__printf_chk@GLIBC_2.3.4>
-fno-plt는 재배치를 전부 GLOB_DAT로 만들어 지연 바인딩을 포기하는 대신 스텁 한 단계를 줄인다. 어차피 -z now가 기본인 환경에서는 손해가 거의 없다.
주의사항
- 배포판 기본 빌드는 이미 full RELRO +
-z now다. 지연 바인딩 동작을 실습하려면-Wl,-z,lazy,-z,norelro를 명시해야 하고, 이 옵션들은 실습용일 뿐 배포 바이너리에 쓰면 안 된다. - CET/IBT가 켜진 빌드에서는
.plt가 아니라.plt.sec의 스텁이 실제 호출 대상이다.objdump -d -j .plt만 보고 “GOT를 참조하는 코드가 없다”고 판단하기 쉬우니readelf -S로 섹션 구성을 먼저 확인할 것. - PIE 실행 파일에서
objdump가 보여주는 주소는 로드 베이스가 더해지기 전 값이다. 실행 중 값과 비교할 때는-no-pie로 빌드하거나/proc/<pid>/maps의 베이스를 더해야 한다. find_got_slot()처럼_DYNAMIC을 직접 훑는 코드는 x86-64 전용이다.ELF64_R_SYM과DT_JMPREL이 RELA를 가리킨다는 가정이 들어 있어 32비트나 REL 방식 아키텍처에서는 그대로 안 돈다.-Bsymbolic은 인터포지션을 막는 만큼LD_PRELOAD기반 프로파일링이나 mock도 함께 막는다. 전역 변수가 있으면 실행 파일 쪽 복사본과 라이브러리 내부 참조가 갈라져 버그가 되므로, 보통은-fvisibility=hidden으로 내보낼 심볼을 줄이는 쪽이 안전하다.
마무리
PLT는 “GOT에서 주소를 읽어 점프하는 스텁”, GOT는 “동적 링커가 채우는 주소 배열”이고, 지연 바인딩은 GOT 슬롯 초기값을 자기 PLT 스텁으로 넣어두는 트릭으로 구현된다. 이 구조를 알아두면 LD_PRELOAD가 왜 먹히고 왜 어떤 경우엔 안 먹히는지, RELRO가 정확히 무엇을 막는지, -fno-plt·-Bsymbolic이 무엇을 바꾸는지가 한 줄로 설명된다.
바이너리를 만났을 때 readelf -d의 BIND_NOW, readelf -lW의 GNU_RELRO, readelf -rW의 재배치 타입 세 가지만 확인해도 그 바이너리의 링크 정책은 거의 파악된다.
참고
- System V ABI: AMD64 Architecture Processor Supplement — 5장 Program Loading and Dynamic Linking
- ld.so(8) man page —
LD_BIND_NOW,LD_PRELOAD,LD_DEBUG - Ulrich Drepper, How To Write Shared Libraries
- GNU ld 문서 —
-z relro,-z now,-Bsymbolic