PE에서 함수 찾기
개요
PE 프로그램을 AI의 도움으로 바이너리를 고급언어로 재작성할 수 있는 하네스를 구축하려고 하는데, 결국엔 바이너리파일 안에서 함수 블록을 찾고 그 함수 블록을 복원하는 것이 큰 가지가 될것이라고 생각했다.
더미다 같은 패커를 사용하지 않았다는 가정하에 어떤 프로그램이든 모든 바이트 코드가 누락과 오류 없이 디스어셈블하면 동일하게 동작할 것이고, 디스어셈블된 코드를 하나씩 c 코드로 디컴파일 하면 완전히 동일한 수준의 복원이 가능할것이다.
동일한 방법론을 arm 기반 ios/aos, 디컴파일을 어렵게 만드는 패커, 패킹된 코드를 복원하는 프로젝트에도 쓸수있을 것이라 생각해서 정리한다.
누락 없는 추출을 위한 함수 패턴
x86 어셈블리 명령어는 가변길이 명령어이고, 점프가 어디로 될지 모르는 상황에서 TEXT 섹션의 모든 바이트코드가 명령어라고 생각하면 코드를 잘못 해석하게된다.
entry부터 따라가면서 명령어를 분석하면서 모든 분기(jmp, call) 을 처리하면 그래프를 그려가면 실행되는 모든 코드를 따라갈 수 있을것이다.
recursive traversal disassembly 이라고 하는데, 이 방법만 사용한다고 하면 고정 주소를 알 수 있는 직접 호출류는 따라갈 수 있지만 런타임 데이터 영역을 통한 간접 점프는 따라갈 수 없게된다.
Root
내부 call/jmp 를 따라가지 않고도 독립적으로 호출할 수 있는 Root 함수들을 의미하며, recursive traversal disassembly 는 발견된 모든 Root 에서 분석을 시작할 수 있을 것이다.
EntryPoint
PE Header에서 OptionalHeader.AddressOfEntryPoint로 지정된 시작함수이며, EXE의 CRT startup, DLL의 _DllMainCRTStartup, DriverEntry 함수가 해당된다. 값이 0이면 없는것
TLS callback
IMAGE_TLS_DIRECTORY.AddressOfCallBacks 에 null로 끝나는 VA 주소배열이 있으며, process/thread attach, detach 에서 로더가 VA주소배열의 모든 함수를 호출해준다.
PE 파일
│
├─ IMAGE_DOS_HEADER
│ └─ e_lfanew
│
└─ IMAGE_NT_HEADERS
├─ Signature
├─ IMAGE_FILE_HEADER
└─ IMAGE_OPTIONAL_HEADER
└─ DataDirectory[9]
├─ VirtualAddress = TLS Directory RVA
└─ Size = TLS Directory 크기
│
▼
IMAGE_TLS_DIRECTORY32/64
│ 64bit 기준
├─ StartAddressOfRawData 8바이트 VA
├─ EndAddressOfRawData 8바이트 VA
├─ AddressOfIndex 8바이트 VA
├─ AddressOfCallBacks 8바이트 VA
├─ SizeOfZeroFill 4바이트 크기
└─ Characteristics 4바이트 플래그
콜백은 아래와 같은형태로, module, reason, reserved 인자 세개를 받는 함수로 구현해야하고 등록하게된다.
로더가 상황에 맞춰서 등록한 module의 base주소와 reason을 채워넣어서 호출해주는데 개발자가 직접 내부에서 reason에 따라 동작하도록 구현해야한다.
- DLL_PROCESS_ATTACH: EXE나 DLL이 최초로 Process에 로드될때 로드하는 이미지의 TLS_Callback 배열을 호출하면서 module, reason을 전달한다(모듈단위 초기화 이벤트). main이 호출되기 전에 TLS_Callback이 먼저 호출된다. 의존이 있다면 의존대상이 먼저 초기화된다.
- DLL_THREAD_ATTACH: 프로세스에서 스레드가 생성됐을때, 로드되어 있는 모든 PE 이미지에 DLL_THREAD_ATTACH 이벤트를 보내면서 TLS_Callback도 실행한다. 생성된 Thread의 메인 함수가 실행되기 전에 실행된다.
void NTAPI TlsCallback1(
PVOID module,
DWORD reason,
PVOID reserved
) {
if (reason == DLL_PROCESS_ATTACH) {
// 프로세스 초기화 때만 실행
}
}
export 함수
사실 PE에서 EXPORT_DIRECTORY 에는 함수만 담는 테이블이 아니라 Forwarded export, Data export 가 함께 포함된다.
IMAGE_OPTIONAL_HEADER
└─ DataDirectory[0]
├─ VirtualAddress = Export Directory RVA
└─ Size = Export Directory 전체 크기
│
▼
IMAGE_EXPORT_DIRECTORY
├─ Name
├─ Base
├─ NumberOfFunctions
├─ NumberOfNames
├─ AddressOfFunctions
│ └─ DWORD[NumberOfFunctions] // Export Address Table (이게 EAT)
├─ AddressOfNames
│ └─ DWORD[NumberOfNames] // Export Name Pointer Table
└─ AddressOfNameOrdinals
└─ WORD[NumberOfNames] // Export Ordinal Table
EAT에는 모든 export 주소가 담겨있고, 이중에서 Names 테이블에는 이름이 있는 export 함수들이 담겨있다. Names 테이블과 NameOrdinals 테이블을 사용해서 EAT에서의 인덱스를 찾을 수 있다.
// public ordinal = 2
// Names[3]="Foo" -> NamesOrdinals[3]=5 -> EAT[7]=Foo RVA
GetProcAddress(module, "Foo")
GetProcAddress(module, MAKEINTRESOURCEA(7))
메모리 구조를 시각화하면 다음과 같다. DataDirectory[0] 의 VA, Size가 가리키고 있는 IMAGE_EXPORT_DIRECTORY 구조체는 0x28 크기이며 위에서 봤듯 Name, Base, EAT를포함한 테이블들의 포인터를 저장하고 있다.
그 이후에는 실제 테이블과 그 안에서 사용하는 문자열들이 연속적으로 존재하기 때문에 구조체보다 더 큰 사이즈인 0x300이 할당되어 있는 것이다.
Export Data Directory
VirtualAddress = 0x5000
Size = 0x300
0x5000 ┌────────────────────────────────┐
│ IMAGE_EXPORT_DIRECTORY │
0x5028 ├────────────────────────────────┤
│ 기타 export metadata │
0x5060 ├────────────────────────────────┤
│ EAT[0] = 0x1200 │
│ EAT[1] = 0x5200 │
│ EAT[2] = 0x3000 │
0x5070 ├────────────────────────────────┤
│ Name Pointer Table │
0x5090 ├────────────────────────────────┤
│ Name Ordinal Table │
0x5100 ├────────────────────────────────┤
│ "Foo\0" │
│ "Sleep\0" │
│ "Counter\0" │
0x5200 ├────────────────────────────────┤
│ "KERNELBASE.Sleep\0" │
0x5300 └────────────────────────────────┘
Forwarded export는 외부 DLL을 export 한다는 의미로, EAT에 저장된 RVA 값이 “KERNELBASE.Sleep” 과 같은 문자열을 가리키게 되고, 이 문자열은 Export Directory의 범위 안에 저장된다.
나머지는 Local Export인데, 간단하게 생각했을때 RVA 영역이 Data는 읽기 전용인 섹션영역, 함수는 실행가능 섹션영역으로 구분할 수도 있지만, 실행가능 섹션에도 jump table, 함수포인터 배열 등 함수가 아닌경우도 있고 비실행 섹션안에서도 VirtualProtect로 동적으로 실행권한을 부여하는 경우가 있을수도 있어서 확정할 수는 없다. 코드 분석을 통해서 확률을 높여야한다.
SafeSEH (x86)
Exception Directory
CLR/혼합 PE entry
간접 호출
간접호출의 경우
-> 만약 이 방법이 모든 객체를 커버하는 방법이 없다면… ? 만약 예상했던 객체가 안나오면? 바로 assert 로 해서 분석 후 다시 추가하는 방식으로? 이게 될까? 이러면 런타임에 분석하면서 다시 재료로 쓸수가 있을지도모름
간접 점프
- Switch jump table
가장 흔한 형태입니다.
cmp eax, 5
ja default_case
jmp dword ptr [eax*4 + 00403000h]
테이블은 다음처럼 구성됩니다.
00403000:
dd 00401100 ; case 0
dd 00401130 ; case 1
dd 00401180 ; case 2
dd 00401180 ; case 3
dd 00401200 ; case 4
dd 00401240 ; case 5
C 코드로는 대략 다음과 같습니다.
switch (eax) {
case 0: goto block_401100;
case 1: goto block_401130;
case 2:
case 3: goto block_401180;
case 4: goto block_401200;
case 5: goto block_401240;
default: goto default_case;
}
분석기는 다음을 복구해야 합니다.
- index 레지스터가 eax
- 상한이 5
- 테이블 시작 주소가 00403000
- entry 크기가 4바이트
- 각 entry가 절대 코드 주소
- default target
- 중복 case target
이 분석에 실패하면 case 블록 전체를 놓칩니다.
실제 컴파일러는 더 복잡한 형태도 만듭니다.
sub eax, 10
cmp eax, 7
ja default_case
jmp [table+eax*4]
또는 테이블 값이 주소가 아니라 상대 오프셋일 수 있습니다.
movsx eax, word ptr [table+eax*2]
add eax, table_base
jmp eax
- Vtable을 이용한 tail dispatch
C++ 객체의 virtual 함수를 호출한 뒤 현재 함수로 돌아올 필요가 없으면 call 대신 jmp로 최적화할 수 있습니다.
mov eax, [ecx] ; vtable
jmp dword ptr [eax+28h]
대략 다음과 같은 tail call입니다.
return object->ProcessMessage();
이 경우 가능한 객체 클래스별로 target이 달라집니다.
CButton::ProcessMessage
CEdit::ProcessMessage
CListBox::ProcessMessage
현재 ecx가 가질 수 있는 클래스 집합을 알아야 대상을 찾을 수 있습니다.
- 함수 포인터 dispatch table
게임 이벤트나 패킷 opcode 처리에서 자주 사용됩니다.
movzx eax, word ptr [packet_opcode]
jmp dword ptr [handler_table+eax*4]
handler_tableopcode;
테이블이 모든 opcode를 직접 포함할 수도 있고, 해시나 범위 변환을 거쳐 선택될 수도 있습니다.
- Import thunk
다음 형태는 비교적 쉽게 해결할 수 있습니다.
jmp dword ptr [__imp__CreateWindowExA]
PE import table을 보면 목적지가 외부 API임을 알 수 있습니다. 이 블록은 흔히 export 또는 import thunk입니다.
내부 target 없음
external target = USER32!CreateWindowExA
원본 DLL 내부 코드로 계속 탐색할 필요는 없지만 외부 호출 경계로 분류해야 합니다.
- 계산된 점프
가장 해결하기 어려운 형태입니다.
mov eax, [state]
shl eax, 4
add eax, 00402000h
jmp eax
가능한 state 값을 알아야 target 집합을 계산할 수 있습니다. 이를 위해 backward slicing, 상수 전파와 value-set analysis
가 필요합니다.
DEAD CODE
구현되어있지만 호출하지는 않는 코드들.
PC 강제변경?
mov나 pop 같은걸로 PC 레지스터를 강제적으로 변경할수도 있을것이다.
===============
R0:
- AddressOfEntryPoint
- TLS callback
- local code export
- SafeSEH handler
- GuardCFFunctionTable
- CRT initializer 배열
- PDB/COFF/FPO 심볼
- relocation-backed callback/vtable 포인터
에서 시작해서 디스어셈블
direct call target
→ 새 함수 후보
함수 주소가 callable API에 전달됨
→ 새 함수 root
정적 함수 포인터 테이블 발견
→ 새 함수 root
반복
Comments