DLL 로딩 및 런타임 바인딩의 핵심 프로세스
윈도우 운영체제에서 DLL(Dynamic-Link Library)은 코드 공유와 재사용의 기본 단위다. 일반적인 Win32 애플리케이션은 시작 시 ntdll.dll, kernel32.dll 등의 핵심 라이브러리를 자동으로 로드하며, 이 과정은 사용자 모드의 LDR(Load Resolver) 서브시스템에 의해 처리된다. 본 문서는 DLL 파일을 메모리에 매핑하고 외부 함수 참조를 해결하는 일련의 절차를 심층적으로 다룬다.
로딩과 연결의 개념적 구분
- 로딩 (Loading): 디스크 상의 DLL 파일을 가상 주소 공간에 매핑하고 기준 주소 재배치(relocation)를 수행하여 코드가 올바르게 실행되도록 준비하는 단계.
- 연결 (Linking / Snap): 이미 로드된 DLL의 가져오기 테이블(.idata)을 해석해, 의존하는 다른 DLL 내 함수의 실제 주소를 IAT(Import Address Table)에 기록하는 작업. 이를 통해
call [IAT_xxx]명령어가 정확한 대상 함수를 호출할 수 있다.
간단히 말해, "로딩"은 DLL이 메모리에 존재하게 하고, "연결"은 그 DLL이 외부 API를 사용할 수 있도록 만든다.
핵심 데이터 구조: LDR_DATA_TABLE_ENTRY
PEB(Process Environment Block) 내부에는 PEB_LDR_DATA 구조체가 있으며, 여기서 관리되는 세 가지 이중 연결 리스트가 DLL 모듈의 상태를 추적한다:
typedef struct _LDR_DATA_TABLE_ENTRY {
LIST_ENTRY InLoadOrderLinks; // 로딩 순서
LIST_ENTRY InMemoryOrderLinks; // 메모리 주소 순서
LIST_ENTRY InInitializationOrderLinks; // 초기화 순서
PVOID DllBase; // 모듈 기준 주소 (HMODULE)
PVOID EntryPoint; // 진입점 주소 (DllMain)
ULONG SizeOfImage;
PUNICODE_STRING FullDllName;
PUNICODE_STRING BaseDllName;
USHORT LoadCount; // 참조 카운터
} LDR_DATA_TABLE_ENTRY;
- InLoadOrderModuleList: 모듈이 로드된 순서대로 기록.
- InMemoryOrderModuleList: 기준 주소(DllBase)의 오름차순으로 정렬되어 탐색 최적화.
- InInitializationOrderModuleList:
DllMain(DLL_PROCESS_ATTACH)가 호출되는 순서로 정렬되며, 종속성(의존성)을 고려해 구성된다.
파일 검색 및 로딩 절차 개요
- 경로 확인: 전체 경로가 지정되면 직접 사용. 그렇지 않으면 표준 검색 순서를 따른다: 실행 파일 디렉터리 → SetDllDirectory 설정 경로 → System32 → Windows 디렉터리 → PATH 환경 변수.
- KnownDLLs 체크: ntdll, kernel32 등 핵심 시스템 DLL은 미리 생성된 Section을 공유하여 성능과 보안을 강화한다.
- 중복 로드 방지:
InLoadOrderModuleList를 탐색해 동일한 DLL이 이미 로드되었는지 확인. 있으면 참조 카운트만 증가. - 섹션 생성:
NtCreateSection를 호출해SEC_IMAGE유형의 섹션 객체를 생성. - 메모리 매핑:
NtMapViewOfSection을 사용해 섹션을 프로세스 주소 공간에 매핑. - 재배치 수행: 요청된 기준 주소(ImageBase)가 충돌하면,
.reloc섹션 정보를 기반으로 모든 절대 주소를 조정. - 의존성 해결: 가져오기 테이블을 분석하고, 각 의존 DLL에 대해 재귀적으로 로딩 프로세스를 수행.
- IAT 스냅핑: 가져온 함수의 실제 주소를 계산해 IAT 엔트리를 덮어씀.
- 초기화:
InInitializationOrderModuleList순서에 따라DllMain(DLL_PROCESS_ATTACH)를 호출.
기준 주소 재배치 (Relocation)
PE 파일은 링커에 의해 예상 기준 주소(ImageBase)를 가지며, 대부분의 절대 주소 참조는 이 값을 기준으로 계산된다. 그러나 이 주소가 이미 사용 중이면, OS는 다른 위치에 매핑하고 .reloc 섹션의 정보를 이용해 모든 관련 주소를 수정해야 한다.
.reloc 섹션은 4KB 페이지 단위로 조직되며, 각 블록은 다음으로 구성된다:
struct IMAGE_BASE_RELOCATION {
DWORD VirtualAddress; // 페이지의 RVA
DWORD SizeOfBlock; // 블록 크기
};
이후에는 16비트 엔트리들이 오는데, 상위 4비트는 재배치 타입(type), 하위 12비트는 페이지 내 오프셋(offset)이다. 가장 흔한 타입은 x86의 IMAGE_REL_BASED_HIGHLOW(값: 3)이며, 이는 32비트 절대 주소를 의미한다. 재배치 알고리즘은 다음과 같다:
- 실제 기준 주소와 예상 기준 주소의 차이(
Delta)를 계산. - 각 재배치 블록을 순회하며, 해당 페이지의 기준 주소를 결정.
- 블록 내 모든 엔트리에 대해,
*(DWORD*)(pageBase + offset) += Delta연산을 수행.
/FIXED 옵션으로 컴파일된 모듈은 .reloc 섹션이 없으므로 재배치가 불가능하며, ImageBase가 충돌하면 로딩에 실패한다. 반면 /DYNAMICBASE는 ASLR(Address Space Layout Randomization)를 활성화해 보안성을 향상시킨다.
런타임 연결: INT와 IAT의 역할
링크 타임에 모든 함수 주소를 결정할 수 없다. 이유는 다음과 같다: (1) DLL의 실행 시간 주소는 불확실하다, (2) 여러 프로세스가 DLL의 코드 페이지를 공유하므로, 특정 프로세스를 위해 DLL 자체를 수정할 수 없다, (3) 지연 로딩(delay-load)이나 SxS(Assembly) 같은 동적 요구 사항 때문이다. 따라서 PE 포맷은 두 개의 병렬 테이블을 제공한다:
- INT (Import Name Table): 원래 이름 또는 서수(ordinal) 정보를 포함하는 읽기 전용 테이블. 연결 과정에서 변경되지 않는다.
- IAT (Import Address Table): 처음에는 INT와 동일한 내용을 가지지만, 런타임에 실제 함수 주소로 덮어쓰는 쓰기 가능한 테이블.
이중 구조는 연결 후에도 원래의 가져오기 정보를 유지할 수 있게 하며, 디버깅이나 문제 해결에 필수적이다. 연결(Snap) 과정은 다음과 같다:
IMAGE_IMPORT_DESCRIPTOR배열을 순회하며 각 의존 DLL을 식별.LdrLoadDll을 호출해 의존 DLL을 로드하거나 기존 인스턴스를 찾음.OriginalFirstThunk(INT)와FirstThunk(IAT)를 사용해 각 가져오기 항목을 처리.- INT 항목이 이름 기반이면,
GetProcAddressByName을 호출; 서수 기반이면GetProcAddressByOrdinal을 호출. - 획득한 함수 주소를
InterlockedExchangePointer를 사용해 IAT 항목에 원자적으로 기록.
GetProcAddress와 내보내기 테이블
GetProcAddress는 DLL의 내보내기 테이블(.edata)을 조회해 함수 주소를 반환한다. 이 테이블은 세 개의 배열로 구성된 효율적인 설계를 따른다:
struct IMAGE_EXPORT_DIRECTORY {
DWORD AddressOfFunctions; // 함수 RVA 배열
DWORD AddressOfNames; // 이름 RVA 배열 (사전순 정렬)
DWORD AddressOfNameOrdinals; // 이름에 대한 서수 배열
};
- 이름으로 검색:
AddressOfNames배열에서 이진 탐색을 수행해 인덱스를 찾고, 이를 통해AddressOfNameOrdinals에서 서수를 얻은 후, 최종적으로AddressOfFunctions에서 함수 RVA를 조회. - 서수로 검색: 주어진 서수에서
Base값을 빼 0 기반 인덱스로 변환한 후,AddressOfFunctions배열에 직접 접근.
이 설계는 이름 검색을 O(log N), 서수 검색을 O(1)로 가능하게 한다. 또한 내보내기 항목이 실제로는 다른 DLL의 함수를 가리키는 경우(예: NTDLL.RtlDecodePointer), 이를 Forwarder로 처리해 재귀적으로 조회를 진행한다.
DllMain과 초기화 순서
DLL은 선택적으로 DllMain이라는 진입점을 정의할 수 있으며, 이는 네 가지 이유로 호출된다: 프로세스/스레드 연결 및 해제. 중요한 점은, DllMain 내에서는 LoadLibrary, CreateThread 등을 절대 호출해서는 안 된다는 것이다. 그 이유는 전역 Loader Lock 때문이다.
LDR은 LdrpLoaderLock이라는 임계 영역(Critical Section)으로 자신의 내부 상태를 보호한다. LdrLoadDll이나 DllMain 호출은 모두 이 락을 획득한 상태에서 실행된다. 만약 DllMain 내에서 또 다른 LoadLibrary를 호출하면, 동일한 스레드가 다시 락을 획득하려 하게 되고, 이는 Vista 이전 버전에서는 즉시 데드락을 발생시키고, 이후 버전에서도 예측할 수 없는 동작을 유발할 수 있다.
또한, A.dll이 B.dll에 의존한다면, B.dll의 DllMain이 먼저 호출되어야 A.dll이 B.dll의 함수를 사용할 수 있다. LDR은 InInitializationOrderModuleList를 적절히 정렬함으로써 이 규칙을 보장한다.
참조 카운팅과 언로딩
DLL은 FreeLibrary를 통해 해제될 수 있지만, 즉시 제거되지는 않는다. LDR_DATA_TABLE_ENTRY.LoadCount 필드는 현재 얼마나 많은 LoadLibrary 호출이 해당 DLL을 참조하고 있는지를 나타내는 카운터다. FreeLibrary는 이 카운터를 감소시키며, 값이 0이 될 때 비로소 실제 언로딩 절차가 시작된다. 이는 하나의 DLL이 여러 소스(다른 DLL, COM 객체, 콜백 등)에 의해 참조될 수 있기 때문에 필요한 메커니즘이다.
언로딩 과정은 초기화의 역순으로 진행되며, DLL_PROCESS_DETACH 알림을 받은 후 메모리 매핑을 해제하고 관련 데이터 구조를 정리한다.