← System Programming DUJINLABS.COM

Syscall / ELF · Linux userspace / kernel ABI

동적 링커, PLT/GOT, relocation

DT_NEEDED 탐색, symbol resolution, GOT 갱신, lazy binding과 symbol interposition이 시작 시간과 호출 주소를 어떻게 바꾸는지 확인합니다.

Series
05 / 37
Build
cc -std=c17 -Wall -Wextra -O2 -Wl,-z,relro,-z,now dynlink.c -ldl -o dynlink
Run
./dynlink
Kernel
Linux 6.18.37 LTS

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의 쓰기 가능한 시간을 줄인다.

구조 그림

그림 1. PLT/GOT와 실제 shared object symbol의 연결

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 영역에 들어간다.

호출 흐름

그림 2. 사용자 코드에서 관찰 가능한 결과까지
ELF dynamic DT_NEEDED 목록
ld.so search RUNPATH/cache/default
mmap DSO load bias 확정
relocation GOT/symbol 주소 기록
call PLT 또는 direct branch

symbol lookup과 relocation은 대부분 dynamic linker의 사용자 공간 코드다. 커널은 파일 mapping과 protection 변경을 제공하지만 symbol 의미는 해석하지 않는다.

그림 3. 커널 내부에서 지나가는 주요 지점
load_elf_binary interpreter 적재
userspace ld.so dependency graph
mmap shared object segment
mprotect RELRO read-only
main resolved image

함수 이름을 외우기 위한 그림이 아니다. 반환값, 파일 디스크립터, 메모리 매핑, 대기 큐 가운데 무엇이 다음 단계로 전달되는지 확인한다.

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 변경

실행 예제 원본

아래 코드는 설명을 위해 중간 줄을 생략한 의사 코드가 아니다. 파일로 빌드해 실행할 수 있는 최소 예제다.

빌드cc -std=c17 -Wall -Wextra -O2 -Wl,-z,relro,-z,now dynlink.c -ldl -o dynlink
01#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}

코드 조각별 설명

실제 코드 1행#define _GNU_SOURCE

dlfcn 인터페이스와 관련 확장을 header에서 노출한다. feature-test macro는 어떤 header보다 먼저 정의한다.

실제 코드 10행RTLD_NOW | RTLD_LOCAL

dlopen 시점에 undefined symbol relocation을 끝내고, 이 handle의 symbol을 뒤에 적재되는 object의 global lookup scope에는 넣지 않는다.

실제 코드 16행dlerror();

dlsym 전의 오래된 error 상태를 비운다. dlsym이 NULL을 반환해도 symbol 값 자체가 NULL일 수 있으므로 dlerror로 실패를 판정한다.

실제 코드 17행cos_fn fn =

POSIX 환경에서는 dlsym 반환값을 function pointer로 사용할 수 있다. ISO C만 놓고 보면 object pointer와 function pointer 변환은 일반 보장이 아니다.

실제 코드 21행dlclose(handle)

reference count를 낮춘다. 다른 dependency나 NODELETE 정책 때문에 mapping이 즉시 사라진다고 가정하면 안 된다.

세부 동작

01

검색 순서는 배포 결과를 바꾼다

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로 함께 기록한다.

02

interposition은 최적화를 제한한다

default visibility symbol은 다른 object가 가로챌 수 있어 compiler와 linker가 local direct call로 단정하기 어렵다. -Bsymbolic, protected/hidden visibility, -fno-semantic-interposition은 의미와 최적화 범위를 바꾼다.

LD_PRELOAD 기반 hook이 동작하는 이유도 global lookup scope와 relocation 순서에 있다.

03

RELRO는 쓰기 창을 닫는다

relocation 대상 GOT를 영원히 writable로 두면 임의 쓰기 취약점이 control flow를 바꾸기 쉽다. full RELRO와 immediate binding은 startup 비용을 앞당기는 대신 relocation 후 page를 read-only로 만든다.

checksec 결과만 보지 말고 readelf -l의 GNU_RELRO와 dynamic tag BIND_NOW를 둘 다 확인한다.

객체와 수명

대상언제 생기고 없어지는가확인할 값
link_mapld.so가 object를 적재할 때 만들고 unload 시 제거한다load bias, dynamic section, dependency
GOT slotrelocation 때 주소가 기록되고 RELRO 후 read-only가 될 수 있다symbol version과 resolved address
dlopen handledlopen reference를 나타내며 dlclose로 감소한다RTLD_LOCAL/GLOBAL, NODELETE

실패 조건과 오해하기 쉬운 부분

겉으로 보이는 현상실제 원인 후보확인 방법
shared object not foundRUNPATH/cache/architecture 불일치LD_DEBUG=libs, readelf -d
undefined symbolversion 또는 lookup scope 불일치readelf -Ws, objdump -T
dlclose 뒤 crashDSO 함수 포인터나 TLS/object를 계속 사용handle lifetime과 reference owner 확인

직접 확인

  1. LD_DEBUG=libs,bindings를 각각 켜 library 검색과 symbol binding 시점을 분리해 본다.
  2. -Wl,-z,lazy와 -Wl,-z,now 빌드의 startup syscall과 첫 함수 호출 시간을 비교한다.
  3. LD_PRELOAD로 puts wrapper를 만들고 default visibility와 -Bsymbolic 조합에서 interposition 결과를 확인한다.
실행./dynlink
추적LD_DEBUG=libs,reloc ./dynlink 2>&1 | less

원문