윈도우 커널 기반 프로세스 생성 및 PE 이미징 로드 기술 해설

프로세스 생성의 아키텍처 개요

운영체제에서 가장 복잡한 작업 중 하나는 새 프로세스를 생성하고 그 명령어 스택을 메모리에 로드하는 과정이다. 이는 사용자 모드 응용 프로그램의 요청부터 실제 CPU 실행까지 여러 컴포넌트가 협력하여 이루어진다. 본 기술 해설에서는 CreateProcess 호출로부터 첫 번째 메인 스레드가 진입점에 도달하기까지의 하위 시스템 상호작용을 상세히 분석한다.

주요 관여 subsystem 은 다음과 같다:

  • 프로세스 관리자 (PS): 프로세스와 스레드 객체의 수명 주기 관리
  • 메모리 관리자 (MM): 가상 주소를 할당하고 물리 메모리 매핑 처리
  • 개체 관리자 (OB): 핸들 및 보안 설명자 관리
  • 보안 서비스 (SE): 토큰 검증 및 접근 제어
  • 파일 관리 (IO): executable 파일 오픈 및 섹션 객체 생성
  • 로더 (LDR): 사용자 공간에서의 DLL 의존성 해결 및 초기화

시스템 콜 전이 경로

사용자 모드에서 시작된 워크플로는 여러 단계를 거쳐 커널로 이전된다. 표준적인 엔트리 포인트 흐름은 다음과 같이 구성된다.

User Mode (Application)
  │
  ▼
kernel32!CreateProcessInternalW
  ├─► 파일 열기 (CreateFile)
  ├─► 메모리 맵 영역 정의 (NtCreateSection)
  └─► 커널 진입 (NtCreateProcess)
      │
      ▼ Syscall (KiSystemCall64 등)
Kernel Mode (ntoskrnl)
  ├─► NtCreateProcess
  └─► PsCreateProcessInternal
      ├─► EPROCESS 구조체 생성
      ├─► 주소 공간 설정 (MmCreateProcessAddressSpace)
      ├─► 보안 토큰 할당
      └─► 메인 스레드 생성 (PsCreateThread)
          └─► KeInitializeThread
              │
              ▼ Context Switch
New User Mode (Entry Point)
  ├─► LdrInitializeThunk (NTDLL)
  │   ├─► PEB_LDR_DATA 초기화
  │   ├─► 의존성 DLL 로딩 (LoadLibrary 계열)
  │   └─► TLS 및 초기 루틴 실행
  └─► WinMain / main 실행

사용자 모드 초기 단계

프로그램 실행을 요청하기 위해 사용하는 핵심 API 는 CreateProcess이다. 이 함수는 다양한 인자를 받아들여 프로세스 환경을 구성한다.

인자 이름 기술적 의미
lpApplicationName 실행 파일의 절대/상대 경로
lpCommandLine 입력되는 인수 문자열 (argv[0]~n)
dwCreationFlags 생성 옵션 (예: CREATE_SUSPEND)
lpEnvironment 프로세스 환경 블록 포인터
lpStartupInfo 창 관리 및 표준 입력/출력 설정
lpProcessInformation 생성된 핸들 및 PID/TID 반환용 출력 버퍼

RTL_USER_PROCESS_PARAMETERS 구조체는 커널로 전달될 사용자의 파라미터 정보를 캡슐화한다. 이를 통해 커널은 프로세스가 실행될 컨텍스트를 파악할 수 있다.

typedef struct _RTL_PROCESS_PARAMS {
    UNICODE_STRING CommandLine;     // 실행 지시문
    UNICODE_STRING ImagePathName;   // exe 파일 위치
    UNICODE_STRING DllSearchPath;   // dll 검색 경로
    PWSTR CurrentDirectory;         // 기본 작업 디렉토리
    PVOID EnvironmentBlock;         // 환경 변수 데이터
} RTL_PROCESS_PARAMS;

커널 내 프로세스 객체 생성

PspCreateProcess 함수는 커널 레벨에서 프로세스의 생명주를 시작한다. 여기서 핵심 작업은 EPROCESS 객체를 생성하고 해당 프로세스에 할당된 고유한 가상 주소 공간을 준비하는 것이다.

주소 공간 초기화 로직은 다음과 같은 순서로 진행된다:

  1. 베이스 어드레스 결정: PE 헤더의 ImageBase 를 참조하나, 충돌 시 재배치된 주소로 대체함.
  2. VAD(Virtual Address Descriptor) 생성: 메모리 관리자가 페이지 프레임 테이블을 초기화함.
  3. 섹션 맵핑: PE 파일의 각 섹션 (.text, .data 등) 을 물리적 페이지에 배치.
  4. PEB 작성: 프로세스 환경 블록 (PEB) 을 가상 메모리에 기록하여 사용자 모드로 전달됨.
// 가상의 주소 공간 설정 함수 예제
STATUS MapExecutableMemory(PROCESS_OBJ* proc, SECTION_HANDLE sec) {
    IMAGE_NT_HEADERS* headers = GetNtHeader(sec);
    UINT64 target_addr = headers->OptionalHeader.ImageBase;
    
    // 현재 메모리 상태 검사 후 충돌 확인
    if (!IsAddressAvailable(proc, target_addr, headers->SizeOfImage)) {
        target_addr = FindFreeAddressRange(proc, headers->SizeOfImage);
        flags |= RELOCATED_IMAGE;
    }
    
    for (INT i = 0; i < headers->FileHeader.NumberOfSections; i++) {
        SECTION_HEADER* sect = GetSection(headers, i);
        void* vaddr = (void*)(target_addr + sect->VirtualAddress);
        
        // 페이지 할당 및 보호 속성 설정
        AllocatePageTable(proc, vaddr, sect->Size);
        SetProtection(vaddr, PAGE_READWRITE | PAGE_EXECUTE);
    }
    
    proc->SectionBase = target_addr;
    return STATUS_SUCCESS;
}

메인 스레드 초기화

프로세스 객체가 준비되면, 반드시 하나 이상의 스레드가 존재해야 한다. 주로 PspCreateThread가 메인 스레드를 생성하며, 이때 초기 등록 정보는 CONTEXT 구조체에 저장된다.

핵심 동작은 레지스터를 올바른 상태로 세팅하는 것이며, 특히 IP(Instruction Pointer) 에는 이미지 진입점 (EntryPoint) 의 주소를 넣어야 한다.

VOID InitializeThreadContext(KTHREAD* thread, CONTEXT* ctx) {
    // 스택 포인터 설정
    ctx->Esp = GetUserStackTop(); 
    
    // 진입점 주소 계산
    ctx->Eip = (ULONG_PTR)thread->ProcessBase + 
               GetEntryPointOffset(thread->ImageInfo);
    
    // 플래그 레지스터 초기화 (인터럽트 가능 상태)
    ctx->EFlags = 0x202; // IF=1, IOPL=0
    
    // 세그먼트 레지스터 설정
    ctx->SegCs = 0x23; // RING0 코드 세그먼트
    ctx->SegDs = 0x2B; // RING3 데이터 세그먼트
    ctx->SegFs = 0x30; // TEB 세그먼트 (Thread Environment Block)
}

PE 형식 파싱 및 로딩 메커니즘

디스크에 저장된 .exe 파일을 메모리로 가져오는 과정은 PE (Portable Executable) 규약을 준수해야 한다.

헤더 검증 단계

  1. DOS 헤더 읽기 (e_magic == 'MZ')
  2. e_lfanew 오프셋으로 이동하여 NT 헤더 확인 ('PE\0\0')
  3. 섹션 헤더 배열을 읽어 메모리 맵 계획 수립

중간 주소 수정 (Relocation)

이미지가 설계되었던 ImageBase 가 아닌 다른 주소에 로드되면, 하드코딩된 주소를 보정해야 한다. 이는 IMAGE_BASE_RELOCATION 테이블을 기반으로 수행된다.

BOOLEAN ApplyRelocations(void* base_addr, IMAGE_NT_HEADERS* hdr) {
    DWORD reloc_va = hdr->DataDir[IMAGE_DIRECTORY_ENTRY_BASERELOC].VirtualAddress;
    if(!reloc_va) return TRUE; // 중복 정보 없음

    BYTE* reloc_table = (BYTE*)((DWORD)base_addr + reloc_va);
    ULONG delta = (DWORD)base_addr - hdr->OptionalHeader.ImageBase;

    while(reloc_table->VirtualAddress != 0) {
        WORD num_entries = (reloc_table->SizeOfBlock - sizeof(BASIC_RELOC)) / 2;
        WORD* entries = (WORD*)(reloc_table + sizeof(BASIC_RELOC));

        for(INT i=0; i<num_entries; i++) {
            WORD entry = entries[i];
            WORD type = entry >> 12;
            WORD offset = entry & 0x0FFF;

            DWORD* addr_ptr = (DWORD*)((DWORD)base_addr + reloc_table->VirtualAddress + offset);
            
            if(type == IMAGE_REL_BASED_HIGHLOW) {
                *addr_ptr += delta;
            }
        }
        reloc_table += reloc_table->SizeOfBlock;
    }
    return TRUE;
}

임포트 테이블 (Import Table) 처리

프로그램이 사용할 외부 DLL 함수의 주소를 찾아야 한다. 이는 로딩 타임에 동적으로 해결되므로, 메모리 상의 Import Address Table(IAT) 에 실제로 호출될 함수 주소가 채워진다.

STATUS ResolveImports(PEPROCESS proc, IMAGE_IMPORT_DESCRIPTOR* imp_desc) {
    while(imp_desc->Name != NULL) {
        char* dll_name = (char*)((DWORD)proc + imp_desc->Name);
        HMODULE mod = LoadDll(dll_name);
        if(mod == NULL) return ERROR_DLL_NOT_FOUND;

        // 원본 스컴과 임포트 스컴 비교
        THUNK_DATA* orig = (THUNK_DATA*)mod.Base + imp_desc->OrigThk;
        THUNK_DATA* curr = (THUNK_DATA*)mod.Base + imp_desc->CurrThk;

        while(orig->u1.AddressOfData != NULL) {
            if(IsOrdinal(orig->u1.Ordinal)) {
                curr->FuncPtr = GetExportByOrdinal(mod, OrdinalNum);
            } else {
                curr->FuncPtr = GetProcAddressByName(mod, OrigName);
            }
            orig++; curr++;
        }
        imp_desc++;
    }
    return STATUS_SUCCESS;
}

사용자 모드 초기화 및 진입

커널 스레드调度를 통과하면, CPU 는 LdrInitializeThunk 함수를 호출한다. 이는 사용자가 직접 코딩하는 WinMain 이나 main 보다 먼저 실행되어 런타임을 준비한다.

  1. NTDLL 로드: 기본적인 Win32 API 서클러
  2. KERNEL32, USER32 등 의존 DLL 로딩: IAT 를 통해 연결됨
  3. TLS(Thread Local Storage) 설정: 스레드별 독립 메모리 영역 확보
  4. DLL_PROCESS_ATTACH 알림: 모든 DLL 에게 초기화 신호 전달
  5. C 런타임 스타트업: msvcrt.dll 에서 초기화 및 main 으로 전환
// 사용자 진입전 라운틴 흐름
void __stdcall LdrInitializeThunk() {
    PEB_LDR_DATA ldr_data = GetPEB()->Ldr;
    ListHead* current = ldr_data.LoadList.Head;
    
    // 모듈 리스트 순회
    while(current != head) {
        MODULE* mod = CONTAINING_RECORD(current, MODULE, Link);
        InitializeModule(mod);
        current = current.Next;
    }
    
    CallInitRoutines(); // DLL_PROCESS_ATTACH 호출
    RunCrtStartup();    // C++ 라이브러리 초기화
    EnterProgramMain(); // WinMain/main 호출로 전달
}

응용 실행 파이프라인 요약

사용자가 파일을 더블 클릭했을 때 일어나는 전체 생명주는 다음과 같은 계층을 거친다.

계층 주요 기능 관련 컴포넌트
Shell Layer 파일 인터페이스 처리 Explorer.exe, Shell32
User-mode Wrapper 인자 정리 및 파일 핸들 획득 kernel32.CreateProcess
Kernel Core 객체 생성, 주소 할당, 스레드 등록 ntoskrnl, PS, MM
User-mode Loader 기능 확장 (DLL), 초기화 루틴 ntdll.LdrInitializeThunk
Application Entry 실제 비즈니스 로직 시작 WinMain / main

이 과정에서 생성된 프로세스 객체들은 PsActiveProcessHead 리스트에 등록되며, 메모리 영역은 가상 주소 변환을 위한 페이저블 구조에 매핑된다. 이후 프로세스가 종료될 때는 해당 자원을 역순으로 해제하는 정형화된 절차가 따라간다.

태그: 윈도우 커널 프로세스 생성 PE 포맷 시스템 콜 리액트오스

9월 18일 08:33에 게시됨