QUESTION
printf 호출 주소는 link 때 이미 정해져 있는가?
동적 실행 파일의 외부 symbol은 파일을 link할 때 실제 runtime 주소를 알 수 없다. linker는 relocation record와 GOT/PLT를 남기고, ld-linux가 shared object를 mapping한 뒤 load bias를 반영해 주소를 채운다.
lazy binding에서는 첫 PLT 호출이 resolver로 들어가 GOT slot을 갱신한다. BIND_NOW 또는 full RELRO에서는 시작할 때 relocation을 끝내고 GOT의 쓰기 가능한 시간을 줄인다.
STRUCTURE
구조 그림
Executable
- call printf@PLT
- .rela.plt: printf
- DT_NEEDED: libc.so.6
GOT / link_map
- printf GOT slot
- load bias
- symbol version
ld-linux
- lookup scope
- relocation write
- mprotect RELRO
libc.so.6
- printf symbol
- resolved runtime VA
- DSO PT_LOAD
PLT는 호출 경유지이고 GOT slot에는 runtime 주소가 기록된다. full RELRO에서는 relocation 뒤 GOT가 read-only 영역에 들어간다.
CALL PATH
호출 흐름
symbol lookup과 relocation은 대부분 dynamic linker의 사용자 공간 코드다. 커널은 파일 mapping과 protection 변경을 제공하지만 symbol 의미는 해석하지 않는다.
함수 이름을 외우기 위한 그림이 아니다. 반환값, 파일 디스크립터, 메모리 매핑, 대기 큐 가운데 무엇이 다음 단계로 전달되는지 확인한다.
SOURCE COORDINATES
Linux 6.18.37 LTS 소스 위치
glibc 함수에서 멈추지 않고 syscall 구현과 커널 객체가 만나는 파일까지 내려간다. 링크는 동일한 태그의 원본 파일을 가리킨다.
| 파일 | 함수·구조체 | 여기서 볼 것 |
|---|---|---|
| fs/binfmt_elf.c | load_elf_interp() | PT_INTERP 파일을 별도 ELF image로 적재 |
| mm/mmap.c | do_mmap(), vm_mmap_pgoff() | shared object PT_LOAD segment mapping |
| mm/mprotect.c | do_mprotect_pkey() | relocation 뒤 RELRO page protection 변경 |
COMPLETE PROGRAM
실행 예제 원본
아래 코드는 설명을 위해 중간 줄을 생략한 의사 코드가 아니다. 파일로 빌드해 실행할 수 있는 최소 예제다.
cc -std=c17 -Wall -Wextra -O2 -Wl,-z,relro,-z,now dynlink.c -ldl -o dynlink01#define _GNU_SOURCE
02#include <dlfcn.h>
03#include <stdio.h>
04#include <stdlib.h>
05
06typedef double (*cos_fn)(double);
07
08int main(void)
09{
10 void *handle = dlopen("libm.so.6", RTLD_NOW | RTLD_LOCAL);
11 if (handle == NULL) {
12 fprintf(stderr, "dlopen: %s\n", dlerror());
13 return 1;
14 }
15
16 dlerror();
17 cos_fn fn = (cos_fn)dlsym(handle, "cos");
18 const char *error = dlerror();
19 if (error != NULL) {
20 fprintf(stderr, "dlsym: %s\n", error);
21 dlclose(handle);
22 return 1;
23 }
24
25 printf("cos(0.0) = %.1f\n", fn(0.0));
26 return dlclose(handle) != 0;
27}
CODE NOTES
코드 조각별 설명
#define _GNU_SOURCEdlfcn 인터페이스와 관련 확장을 header에서 노출한다. feature-test macro는 어떤 header보다 먼저 정의한다.
RTLD_NOW | RTLD_LOCALdlopen 시점에 undefined symbol relocation을 끝내고, 이 handle의 symbol을 뒤에 적재되는 object의 global lookup scope에는 넣지 않는다.
dlerror();dlsym 전의 오래된 error 상태를 비운다. dlsym이 NULL을 반환해도 symbol 값 자체가 NULL일 수 있으므로 dlerror로 실패를 판정한다.
cos_fn fn =POSIX 환경에서는 dlsym 반환값을 function pointer로 사용할 수 있다. ISO C만 놓고 보면 object pointer와 function pointer 변환은 일반 보장이 아니다.
dlclose(handle)reference count를 낮춘다. 다른 dependency나 NODELETE 정책 때문에 mapping이 즉시 사라진다고 가정하면 안 된다.
DETAILS
세부 동작
검색 순서는 배포 결과를 바꾼다
DT_RPATH와 DT_RUNPATH의 상속 규칙은 다르고, LD_LIBRARY_PATH, ld.so.cache, default directory가 뒤따른다. setuid/setgid secure-execution mode에서는 여러 환경 변수가 무시된다.
실행 파일과 실제 로드된 DSO 조합은 readelf -d, ld.so --list, /proc/PID/maps로 함께 기록한다.
interposition은 최적화를 제한한다
default visibility symbol은 다른 object가 가로챌 수 있어 compiler와 linker가 local direct call로 단정하기 어렵다. -Bsymbolic, protected/hidden visibility, -fno-semantic-interposition은 의미와 최적화 범위를 바꾼다.
LD_PRELOAD 기반 hook이 동작하는 이유도 global lookup scope와 relocation 순서에 있다.
RELRO는 쓰기 창을 닫는다
relocation 대상 GOT를 영원히 writable로 두면 임의 쓰기 취약점이 control flow를 바꾸기 쉽다. full RELRO와 immediate binding은 startup 비용을 앞당기는 대신 relocation 후 page를 read-only로 만든다.
checksec 결과만 보지 말고 readelf -l의 GNU_RELRO와 dynamic tag BIND_NOW를 둘 다 확인한다.
OBJECTS
객체와 수명
| 대상 | 언제 생기고 없어지는가 | 확인할 값 |
|---|---|---|
link_map | ld.so가 object를 적재할 때 만들고 unload 시 제거한다 | load bias, dynamic section, dependency |
GOT slot | relocation 때 주소가 기록되고 RELRO 후 read-only가 될 수 있다 | symbol version과 resolved address |
dlopen handle | dlopen reference를 나타내며 dlclose로 감소한다 | RTLD_LOCAL/GLOBAL, NODELETE |
FAILURE PATH
실패 조건과 오해하기 쉬운 부분
| 겉으로 보이는 현상 | 실제 원인 후보 | 확인 방법 |
|---|---|---|
| shared object not found | RUNPATH/cache/architecture 불일치 | LD_DEBUG=libs, readelf -d |
| undefined symbol | version 또는 lookup scope 불일치 | readelf -Ws, objdump -T |
| dlclose 뒤 crash | DSO 함수 포인터나 TLS/object를 계속 사용 | handle lifetime과 reference owner 확인 |
LAB
직접 확인
- LD_DEBUG=libs,bindings를 각각 켜 library 검색과 symbol binding 시점을 분리해 본다.
- -Wl,-z,lazy와 -Wl,-z,now 빌드의 startup syscall과 첫 함수 호출 시간을 비교한다.
- LD_PRELOAD로 puts wrapper를 만들고 default visibility와 -Bsymbolic 조합에서 interposition 결과를 확인한다.
./dynlinkLD_DEBUG=libs,reloc ./dynlink 2>&1 | lessPRIMARY REFERENCES