Windows DLL 로딩과 동적 연결 메커니즘 분석

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)가 호출되는 순서로 정렬되며, 종속성(의존성)을 고려해 구성된다.

파일 검색 및 로딩 절차 개요

  1. 경로 확인: 전체 경로가 지정되면 직접 사용. 그렇지 않으면 표준 검색 순서를 따른다: 실행 파일 디렉터리 → SetDllDirectory 설정 경로 → System32 → Windows 디렉터리 → PATH 환경 변수.
  2. KnownDLLs 체크: ntdll, kernel32 등 핵심 시스템 DLL은 미리 생성된 Section을 공유하여 성능과 보안을 강화한다.
  3. 중복 로드 방지: InLoadOrderModuleList를 탐색해 동일한 DLL이 이미 로드되었는지 확인. 있으면 참조 카운트만 증가.
  4. 섹션 생성: NtCreateSection를 호출해 SEC_IMAGE 유형의 섹션 객체를 생성.
  5. 메모리 매핑: NtMapViewOfSection을 사용해 섹션을 프로세스 주소 공간에 매핑.
  6. 재배치 수행: 요청된 기준 주소(ImageBase)가 충돌하면, .reloc 섹션 정보를 기반으로 모든 절대 주소를 조정.
  7. 의존성 해결: 가져오기 테이블을 분석하고, 각 의존 DLL에 대해 재귀적으로 로딩 프로세스를 수행.
  8. IAT 스냅핑: 가져온 함수의 실제 주소를 계산해 IAT 엔트리를 덮어씀.
  9. 초기화: 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비트 절대 주소를 의미한다. 재배치 알고리즘은 다음과 같다:

  1. 실제 기준 주소와 예상 기준 주소의 차이(Delta)를 계산.
  2. 각 재배치 블록을 순회하며, 해당 페이지의 기준 주소를 결정.
  3. 블록 내 모든 엔트리에 대해, *(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) 과정은 다음과 같다:

  1. IMAGE_IMPORT_DESCRIPTOR 배열을 순회하며 각 의존 DLL을 식별.
  2. LdrLoadDll을 호출해 의존 DLL을 로드하거나 기존 인스턴스를 찾음.
  3. OriginalFirstThunk (INT)와 FirstThunk (IAT)를 사용해 각 가져오기 항목을 처리.
  4. INT 항목이 이름 기반이면, GetProcAddressByName을 호출; 서수 기반이면 GetProcAddressByOrdinal을 호출.
  5. 획득한 함수 주소를 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 알림을 받은 후 메모리 매핑을 해제하고 관련 데이터 구조를 정리한다.

태그: Windows DLL LDR PE Format dynamic linking

8월 22일 06:46에 게시됨