银狐远控在生成被控端时,提供了三种形式:可执行文件(exe)、动态链接库(dll)和shellcode。目前,原版生成shellcode的功能已失效。结合与安全研究同行的技术探讨,本文将从纯技术角度,深入分析银狐实现shellcode生成及规避安全检测(免杀)所采用的核心思路。
银狐被控端在生成过程中,运用了多种技术来隐藏自身行为,本系列文章将逐一剖析这些技术。需要明确的是,所有讨论仅限于技术交流范畴。
在银狐的源码工程中,生成exe和dll的入口是上线模块,而生成shellcode的入口则是执行代码工程。

要深入理解执行代码工程,需要具备一定的Windows安全编程知识。同时,银狐源码本身也是学习C++、网络编程以及安全工程实践的优质材料。
作为系列的第一篇,本文将聚焦于一项基础且关键的技术:恶意软件如何在不依赖导入表的情况下动态解析并调用API函数,这也是深入理解免杀与shellcode修复的第一步。本篇侧重理论梳理,后续文章将结合银狐源码中的具体实现进行验证。
恶意软件作者为对抗分析,不断演进其技术。其中,“PEB遍历”被现代恶意软件广泛采用。本文旨在清晰地阐述这项技术的工作原理,帮助初学者破除常见的误解。
我们将逐步拆解三个核心问题:什么是PEB遍历、为何要使用它以及它是如何工作的,并辅以完整的C++示例代码。
为何恶意软件要规避导入地址表(IAT)?
在深入PEB之前,有必要理解其动机。
无论是静态分析(逆向代码)还是动态分析(运行调试),安全分析师通常会优先检查程序的导入地址表(IAT)。IAT列出了程序运行时需要调用的所有外部Windows API函数,这些信息直接暴露了程序的行为意图。
例如,如果在IAT中发现CreateRemoteThread、WriteProcessMemory或WinExec等函数,很可能意味着该程序具备进程注入、内存写入或创建新进程的能力。
那么,问题出在哪里?
IAT让一切变得过于透明。 恶意软件作者不希望分析师轻易通过查看IAT就推测出其功能。因此,他们放弃在编译时声明API函数(这样会填充IAT),转而采用一种更隐蔽的方法:在运行时通过遍历进程环境块(PEB) 来动态获取所需API的地址。
什么是PEB?
PEB即进程环境块,是Windows操作系统为每个运行的进程在内存中维护的一个关键数据结构。它包含了关于该进程的大量信息,例如:
- 已加载模块(DLL)的列表及其基地址
- 进程启动参数和环境变量
- 进程是否处于被调试状态
- 以及其他进程相关的元数据
一个简化的PEB结构定义如下:
typedef struct _PEB {
BYTE Reserved1[2];
BYTE BeingDebugged;
BYTE Reserved2[1];
PVOID Reserved3[2];
PPEB_LDR_DATA Ldr; // 指向加载器数据的指针
PRTL_USER_PROCESS_PARAMETERS ProcessParameters;
PVOID Reserved4[3];
PVOID AtlThunkSListPtr;
PVOID Reserved5;
ULONG Reserved6;
PVOID Reserved7;
ULONG Reserved8;
ULONG AtlThunkSListPtr32;
PVOID Reserved9[45];
BYTE Reserved10[96];
PPS_POST_PROCESS_INIT_ROUTINE PostProcessInitRoutine;
BYTE Reserved11[128];
PVOID Reserved12[1];
ULONG SessionId;
} PEB, *PPEB;
其中,Ldr成员指向一个PEB_LDR_DATA结构,该结构内包含了所有已加载模块的链表信息。
typedef struct _PEB_LDR_DATA {
BYTE Reserved1[8];
PVOID Reserved2[3];
LIST_ENTRY InMemoryOrderModuleList; // 内存中模块列表的头节点
} PEB_LDR_DATA, *PPEB_LDR_DATA;
通过遍历InMemoryOrderModuleList链表,可以访问到每个已加载模块的详细信息。链表中的每个节点都是一个LDR_DATA_TABLE_ENTRY结构:
typedef struct _LDR_DATA_TABLE_ENTRY {
LIST_ENTRY InLoadOrderLinks;
LIST_ENTRY InMemoryOrderLinks;
LIST_ENTRY InInitializationOrderLinks;
PVOID DllBase; // DLL的基地址
PVOID EntryPoint;
ULONG SizeOfImage;
UNICODE_STRING FullDllName;
UNICODE_STRING BaseDllName; // DLL的短名称(如kernel32.dll)
ULONG Flags;
USHORT LoadCount;
USHORT TlsIndex;
LIST_ENTRY HashLinks;
PVOID SectionPointer;
ULONG CheckSum;
ULONG TimeDateStamp;
PVOID LoadedImports;
PVOID EntryPointActivationContext;
PVOID PatchInformation;
} LDR_DATA_TABLE_ENTRY, * PLDR_DATA_TABLE_ENTRY;
恶意软件最感兴趣的是DllBase(模块基地址)和BaseDllName。通过比对名称,它可以定位到关键的系统DLL,如kernel32.dll和ntdll.dll,这些DLL包含了实现其功能所需的核心API。
如何访问PEB?
在32位系统中,一种常见的方法是使用内联汇编直接访问:
// for 32 bit architecture
#include <stdio.h>
#include <Windows.h>
int main() {
PVOID peb;
__asm {
mov eax, fs:[0x30] // FS寄存器指向TEB,TEB+0x30处为PEB指针
mov peb, eax
}
printf(“PEB Address: %p\n”, peb);
return 0;
}
__asm块通过fs段寄存器访问线程环境块(TEB),在偏移0x30处即可获得PEB的指针。


PEB遍历的核心流程
PEB遍历的首要目标通常是找到kernel32.dll中的两个关键函数:
LoadLibraryA:用于在运行时加载其他DLL。
GetProcAddress:用于获取已加载DLL中指定函数的地址。
一旦获得这两个函数,恶意软件就可以加载任何DLL并调用其中的任何函数,完全摆脱对IAT的依赖。
以下是一个简化的步骤,演示如何通过PEB遍历找到MessageBoxA并调用它:
- 获取当前进程的PEB地址。
- 通过PEB找到
PEB_LDR_DATA结构。
- 遍历
InLoadOrderModuleList链表,查找BaseDllName为”kernel32.dll”的模块条目。
- 从该条目中获取
kernel32.dll的基地址(DllBase)。
- 手动解析
kernel32.dll的PE头部和导出表,找到LoadLibraryA和GetProcAddress函数的地址。
- 调用
LoadLibraryA加载user32.dll。
- 调用
GetProcAddress获取user32.dll中MessageBoxA的地址。
- 调用
MessageBoxA显示消息。
以下是完整的C++实现代码:
#include <stdio.h>
#include <windows.h>
typedef struct _UNICODE_STRING {
USHORT Length;
USHORT MaximumLength;
PWSTR Buffer;
} UNICODE_STRING, * PUNICODE_STRING;
typedef struct _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;
ULONG Flags;
USHORT LoadCount;
USHORT TlsIndex;
LIST_ENTRY HashLinks;
PVOID SectionPointer;
ULONG CheckSum;
ULONG TimeDateStamp;
PVOID LoadedImports;
PVOID EntryPointActivationContext;
PVOID PatchInformation;
} LDR_DATA_TABLE_ENTRY, * PLDR_DATA_TABLE_ENTRY;
typedef struct _PEB_LDR_DATA {
ULONG Length;
BOOLEAN Initialized;
HANDLE SsHandle;
LIST_ENTRY InLoadOrderModuleList;
LIST_ENTRY InMemoryOrderModuleList;
LIST_ENTRY InInitializationOrderModuleList;
} PEB_LDR_DATA, * PPEB_LDR_DATA;
typedef struct _PEB {
BOOLEAN InheritedAddressSpace;
BOOLEAN ReadImageFileExecOptions;
BOOLEAN BeingDebugged;
BOOLEAN SpareBool;
HANDLE Mutant;
PVOID ImageBaseAddress;
PPEB_LDR_DATA Ldr;
} PEB, * PPEB;
typedef FARPROC(WINAPI* GETPROCADDRESS)(HMODULE, LPCSTR);
typedef HMODULE(WINAPI* LOADLIBRARYA)(LPCSTR);
typedef int (WINAPI* MESSAGEBOXA)(HWND, LPCSTR, LPCSTR, UINT);
// 手动解析DLL导出表,根据函数名查找地址
PVOID GetProcAddressKernel32(HMODULE hModule, LPCSTR lpProcName) {
PIMAGE_DOS_HEADER pDOSHeader = (PIMAGE_DOS_HEADER)hModule;
PIMAGE_NT_HEADERS pNTHeaders = (PIMAGE_NT_HEADERS)((BYTE*)hModule + pDOSHeader->e_lfanew);
PIMAGE_EXPORT_DIRECTORY pExportDirectory = (PIMAGE_EXPORT_DIRECTORY)((BYTE*)hModule + pNTHeaders->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress);
DWORD* pAddressOfFunctions = (DWORD*)((BYTE*)hModule + pExportDirectory->AddressOfFunctions);
DWORD* pAddressOfNames = (DWORD*)((BYTE*)hModule + pExportDirectory->AddressOfNames);
WORD* pAddressOfNameOrdinals = (WORD*)((BYTE*)hModule + pExportDirectory->AddressOfNameOrdinals);
for (DWORD i = 0; i < pExportDirectory->NumberOfNames; i++) {
char* functionName = (char*)((BYTE*)hModule + pAddressOfNames[i]);
if (strcmp(functionName, lpProcName) == 0) {
return (PVOID)((BYTE*)hModule + pAddressOfFunctions[pAddressOfNameOrdinals[i]]);
}
}
return NULL;
}
int main() {
PEB* peb;
PLDR_DATA_TABLE_ENTRY module;
LIST_ENTRY* listEntry;
HMODULE kernel32baseAddr = NULL;
GETPROCADDRESS ptrGetProcAddress = NULL;
LOADLIBRARYA ptrLoadLibraryA = NULL;
MESSAGEBOXA ptrMessageBoxA = NULL;
// 内联汇编获取PEB地址
__asm {
mov eax, fs: [0x30]
mov peb, eax
}
// 遍历已加载模块列表,寻找kernel32.dll
listEntry = peb->Ldr->InLoadOrderModuleList.Flink;
do {
module = CONTAINING_RECORD(listEntry, LDR_DATA_TABLE_ENTRY, InLoadOrderLinks);
char baseDllName[256];
size_t i;
for (i = 0; i < module->BaseDllName.Length / sizeof(WCHAR) && i < sizeof(baseDllName) - 1; i++) {
baseDllName[i] = (char)module->BaseDllName.Buffer[i];
}
baseDllName[i] = ‘\0‘;
if (_stricmp(baseDllName, “kernel32.dll”) == 0) {
kernel32baseAddr = (HMODULE)module->DllBase;
break;
}
listEntry = listEntry->Flink;
} while (listEntry != &peb->Ldr->InLoadOrderModuleList);
if (kernel32baseAddr) {
// 手动解析并获取关键函数地址
ptrGetProcAddress = (GETPROCADDRESS)GetProcAddressKernel32(kernel32baseAddr, “GetProcAddress”);
ptrLoadLibraryA = (LOADLIBRARYA)GetProcAddressKernel32(kernel32baseAddr, “LoadLibraryA”);
// 动态加载user32.dll并获取MessageBoxA地址
HMODULE user32Base = ptrLoadLibraryA(“user32.dll”);
ptrMessageBoxA = (MESSAGEBOXA)ptrGetProcAddress(user32Base, “MessageBoxA”);
// 调用MessageBoxA
ptrMessageBoxA(NULL, “success”, NULL, MB_OK);
}
return 0;
}
代码执行效果:

代码解析:
- 结构体定义:定义了Windows内部数据结构(
PEB, PEB_LDR_DATA, LDR_DATA_TABLE_ENTRY),以便程序能够访问和遍历进程的模块列表。
GetProcAddressKernel32函数:手动解析指定DLL(这里指kernel32.dll)的PE头部和导出表,通过函数名称找到其内存地址,绕过了系统的GetProcAddress调用。
- 内联汇编获取PEB:通过FS寄存器直接获取当前进程的PEB地址,这是理解Windows系统底层机制的关键。
- 遍历模块列表:通过PEB找到加载器数据,并遍历
InLoadOrderModuleList链表。将每个模块的Unicode名称转换为ASCII后与”kernel32.dll”比较,找到后记录其基地址。
- 动态解析与调用:使用自定义的解析函数获取
LoadLibraryA和GetProcAddress的地址,然后利用它们加载user32.dll并找到MessageBoxA,最终成功调用。
进阶隐蔽技巧
在上面的代码中,存在这样一行比较语句:
if (_stricmp(baseDllName, “kernel32.dll”) == 0)
这会导致编译后的二进制文件中明文存在”kernel32.dll”字符串,在静态分析中极易被发现。为了增强隐蔽性,一种常见的免杀技术是使用哈希(Hash)函数。例如,对需要查找的字符串(如”kernel32.dll”、”LoadLibraryA”)预先计算一个哈希值。在遍历比较时,不再比较字符串本身,而是比较计算出的哈希值。这样,最终的二进制文件中就不会留下明文的API或模块名称字符串。银狐远控的源码中也应用了此类技术,我们将在后续文章中详细分析。
逆向分析验证(Release版本)
对上述程序编译后的Release版本进行逆向分析,可以观察到其内部执行流程。



关键点在于,尽管程序成功调用了MessageBoxA,但其导入表中并未显示该函数。使用PE工具查看导入表:

可以看到,MessageBoxA并不可见,因为它是在运行时通过PEB遍历动态解析的,没有在编译时声明依赖。
总结
本文详细阐述了恶意软件如何利用PEB遍历技术在运行时动态解析API函数地址,从而完全规避导入地址表(IAT),达到隐藏行为意图的目的。理解这项基础技术对于安全分析与防御至关重要。本篇是后续分析银狐shellcode生成与免杀机制的理论基础,下一篇文章我们将深入银狐“执行代码”工程的源码,具体看其如何实现并优化这一技术。