如何在程序内部获取到自身加载的DLL的基地址是一个有趣的问题。通过研究这个问题,能够让笔者对Window的可执行程序的运行机制和底层实现(区别于高级语言实现)有更为深入的认识,与此同时该技术广泛应用于ShellCode来定位动态API地址,实践应用面较大,值得好好去分析和学习。
32位在4G内存搜索有一定可行性,但是处理起来其实还是比较麻烦的,因为内存不可读会触发异常,需要对这些异常问题进行处理,可用性和性价比,自然顾名思义。
优化思路:缩小范围、增大搜索步长。
(1)不优化,原始内存特征匹配,容易出错,利用复杂。
(2)优化暴力搜索,有三种方法
只要系统没有做模块基址重定位,那么32位下kernel32
的加载地址在0x70000000
-0x80000000
之间,然后Kernel32.dll加载是64k对齐的,所以查找次数<256MB/64K+1= 4097次,就可以找到。
#include <Windows.h>
#include <stdio.h>int main()
{
HANDLE kernelA = LoadLibrary(L"kernel32.dll");
printf("0x:%p\n", kernelA);
system("pause");
}
win11:0x76640000
win10:0x75710000
win7:0x754A0000
window 2003:0x7c800000
...除开DOS系统,其它系统都可以囊括在这里面。
但是这里判断定位成功条件仍然需要采取两重判断,先判断MZ
头再解析PE结构来获取DLL名称进行判断,从而来降低在其他环境出现地址错误的概率。
导入表与exe实际加载顺序:
ntdll.dll
->kernelbase.dll
->kernel32.dll
->....
可以看到关键的系统模块都分配在了0x70000000上面,故单一匹配MZ头不是100%准确。
进一步优化搜索范围,Window加载可执行程序时,会创建可执行程序的子进程,其主线程被初始化时,执行ExitThread
的指令的地址被压人堆栈,以便线程通过ret返回时可以执行ExitThread
退出线程。而ExitThread
是从KERNEL32.DLL
中导出的函数,故可以从这个地址开始递减0x10000h来搜索"MZ"头从而确定Kernel32.dll
的地址。
调试之前,先了解EXE点击执行的经历阶段:
1.双击exe程序,注册表的Shell键值指定Explorer.exe
作为命令解释器,作为用户桌面应用程序的父进程来启动程序。
计算机\HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon
2.调用Kernel32.dll
的CreateProcess
函数,打开其映像文件,创建一个内存区对象。
3.创建内核中的进程对象,NtCreateProcess-NtCreateProcessEx-PspCreateProcess,其中创建EPROCESS对象、初始化各种参数、创建初始的进程地址空间、创建和初始化句柄表,并设置好EPROCESS 和KPROCESS 中的各种属性,如进程优先级、安全属性等。至此进程地址空间初始化完成、EPROCESS的PEB也初始化完成。
该过程可参考:http://www.alonemonkey.com/createprocess-analyse.html
4.通过调用NtCreateThread
创建初始线程,创建ETHREAD结构、初始化域、创建TEB结构并初始化...
该过程可参考:http://www.alonemonkey.com/createthread-analyse.html
5.进程创建/退出,通知Windows子系统csrss.exe进程,以便对Windows所有进程进行管理。
6.启动初始线程,调用NtResumeThread
唤醒,进入用户态最先执行ntdll.dll
的LdrInitializeThunk
函数,完成用户态进线程的环境初始化,加载DLL并执行入口函数、对"线程本地存储"(TLS)进行初始化。
使用x32dbg运行程序的时候,执行
LdrInitializeThunk
函数时,会在LdrpInitializeProcess
中触发一个int 3异常,用来作为程序运行的"系统断点"。
回到用户态之后,主线程进入Kernel32.dll
的 BaseThreadInitThunk
函数进入入口点函数,开始执行程序后续执行。
进入入口点之后,返回地址入栈,此时就是栈顶位置,指向了Kernel32
内存空间。
但是真正使用的话,这种方法有非常大的局限性的,通过IDA反编译VC2019编译的exe,选定_main函数,View
->Graph
->查看函数调用图
在到达真正用户的入口,会存在大量编译器的包装代码,用于初始化和终止库,在将控制权转交给main函数之前正确配置相关参数,所以内联汇编是没办法使用这个方法的。
在笔者看来这个方法,确实鸡肋,利用栈上残余的地址虽然是个好思路,但是一般都具有强烈的特殊性,这种方法不适合用来做通用寻址的手段。
1.什么是异常处理链表?
当异常发生时,系统从fs:[0]指向的内存地址处取出ExceptionList字段,然后从ExceptionList字段指向的
_EXCEPTION_REGISTRATION_RECORD
结构中取出handler
字段,并根据其中的地址去调用异常处理程序(回调函数)。
异常处理链表是提到的由_EXCEPTION_REGISTRATION_RECORD
结构构成的单链表。
typedef struct _EXCEPTION_REGISTRATION_RECORD
{
PEXCEPTION_REGISTRATION_RECORD Next;
PEXCEPTION_DISPOSITION Handler;
} EXCEPTION_REGISTRATION_RECORD, *PEXCEPTION_REGISTRATION_RECORD;
Next指向异常处理程序的地址,prev 则指向下一个 _EXCEPTION_REGISTRATION_RECORD 结构体,来构成一个单向链表。
2.异常处理链表有什么特点?
当异常发生时,系统会遍历异常处理链表,直到找到正确的异常处理程序。链表最后一项的prev值为0xFFFFFFFF,说明链表已经遍历完毕。
最后一项指向的是系统默认的位于Kernel32.dll
的UnhandledExceptionFilter
顶层异常处理程序的过滤函数,该过滤函数的地址是存在于Kernel32.dll
内存空间的
3.查找Kernel32.dll
加载基址
基于上面1.2的认识,很自然可以得到一个查找Kernel32.dll
加载基址的方法,步骤如下:
1)取fs:[0]的值即ExceptionList
指针指向的地址赋予给edx
寄存器
2)判断Next指针指向的值是否为0xffffffff
计算机负数用补码表示即-1,是的话
mov edx, [edx]
,将值传递到edx寄存器中,接着mov edx,[edx+4]
将Handler
指向的值赋值给edx
,此时edx就在Kernel32.dll
内存空间中,然后开始逐一递减dec edx
来回溯PE头<-cmp word ptr [edx], 'ZM'
,数值存储比较采用小端字节序,CPU读取从低地址读到高地址,所以这里是'ZM'而不是'MZ',网上有些代码是错的,如果不是-1,那么就遍历下一个mov edx, [edx]
汇编代码实现:
#include <stdio.h>
#include <windows.h>
int main()
{
unsigned int kernelAddr;
__asm {
mov edx, fs: [0] ;
Foreach:
cmp [edx], 0xffffffff
je Handle; //if equal : jump
mov edx, [edx];
jmp Foreach;Handle:
mov edx, [edx + 4];_Loop:
cmp word ptr[edx], 'ZM';
jz Kernel;
dec edx;
xor dx, dx;
jmp _Loop
Kernel :
mov kernelAddr, edx;
}
printf(TEXT("Kernel32.dll address: %x\r\n"), kernelAddr);
printf(TEXT("LoadLibrary Kernel32.dll address: %x\r\n"),
LoadLibrary(TEXT("kernel32.dll")));
return 0;
}
在WinServer 2003/XP 上这种方法是可以得到正确结果的。
不过这种方法在win7/win11都是不行的,原因是版本差异,这里获取的是ntdll.dll
的加载地址
造成这种差异的原因,我们可以使用Windbg查看下Win10下最终地址的指向。
查看TEB结构:!teb
查看FS寄存器信息:dg fs
查看fs[0]的值: dd 009b5000
确定ExceptionList
地址指向的结构地址:00aff3d0
。
下面根据该地址查看_EXCEPTION_REGISTRATION_RECORD
结构:
dt -r1 ntdll!_TEB
dt -r1 _EXCEPTION_REGISTRATION_RECORD 0x00aff3d0
可以查看Win10中,最后一个过滤函数在ntdll
的内存空间,而不是Kernel32.dll
,故这种方法在win10是没办法使用的,win7同理。
更多可参考:
维基百科:Windows异常处理机制
windows的SEH异常处理以及顶层异常处理
小结
从上面的三种方法来看,可以看出三者有很明显的共同缺陷,那就是除了暴力的搜索行为之外,还有个致命低兼容性,虽然可以通过进一步加强判断的条件,比如从ntdll.dll
地址继续回溯到Kernel32.dll
,判断PE结构的第一个函数名称等手段来优化,这种代价会进一步增大ShellCode的大小并使程序流程复杂化,同时,内存空间的访问存在很多不可意料的情况。总而言之,搜索内存的方法是一种下下之选。
补充
使用到的分析工具列表如下
调试程序:Windbg、x32dbg
辅助定位:CFF
过程中出现的小问题:
VS2019 正常编译的exe,win2003执行会提示"不是一个合法的win32应用程序",调整编译的平台工具集(xp)可以解决该问题,同时选用静态编译(多线程MT/Release)解决依赖问题。
参考:https://docs.microsoft.com/en-us/cpp/build/configuring-programs-for-windows-xp?redirectedfrom=MSDN&view=msvc-160
在第一节我们提到了暴力搜索并不可取,那么有没有一种优雅地良好兼容性、精确搜索Kernel32.dll
加载基地址的方法呢? 下面来学习一种区别暴力方法,但也比较简单且已经应用成熟的最佳方法。
前面提到了部分与TEB相关的内容,我们进一步来了解TEB与PEB的关系。
TEB(Thread Environment Block,线程环境块)系统在此TEB中保存频繁使用的线程相关的数据。位于用户地址空间,在比 PEB 所在地址低的地方。用户模式下,当前线程的TEB位于独立的4KB段(页),可通过CPU的FS寄存器来访问该段,一般存储在[FS:0]
PEB(Process Environment Block,进程环境块)存放进程信息,每个进程都有自己的PEB信息。位于用户地址空间。可在TEB结构地址偏移0x30处获得PEB的地址位置。
查看结构Windbg 命令:
TEB: !teb
、dt -r1 ntdll!_teb
PEB: !peb
、dt -r1 ntdll!_peb
前面我们已经知道可以通过fs:[0]
寄存器访问到TEB的地址,这里我们又知道了可以通过TEB
结构偏移0x30处指向的地址是PEB结构地址,即fs:[0]
->TEB
->PEB
,在这一步完成PEB地址的定位。
微软文档:_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;
文档中很多是保留(Reserved)字段,这里我们关注下其中一个成员Ldr
,其结构为PPEB_LDR_DATA
。
微软文档介绍: PEB_LDR_DATA structure
Contains information about the loaded modules for the process.
包含有关该过程的加载模块的信息。
typedef struct _PEB_LDR_DATA {
BYTE Reserved1[8];
PVOID Reserved2[3];
LIST_ENTRY InMemoryOrderModuleList;
} PEB_LDR_DATA, *PPEB_LDR_DATA;
第三个参数InMemoryOrderModuleList
The head of a doubly-linked list that contains the loaded modules for the process. Each item in the list is a pointer to an LDR_DATA_TABLE_ENTRY structure. For more information, see Remarks.
双向链表的头部包含进程的加载模块。链表的每一个都是指向
LDR_DATA_TABLE_ENTRY
结构的指针
那么这个链表到底有什么信息呢?
typedef struct _LDR_DATA_TABLE_ENTRY {
PVOID Reserved1[2];
LIST_ENTRY InMemoryOrderLinks;
PVOID Reserved2[2];
PVOID DllBase; // 模块基地址
PVOID EntryPoint;
PVOID Reserved3;
UNICODE_STRING FullDllName;// 模块名称
BYTE Reserved4[8];
PVOID Reserved5[3];
union {
ULONG CheckSum;
PVOID Reserved6;
};
ULONG TimeDateStamp;
} LDR_DATA_TABLE_ENTRY, *PLDR_DATA_TABLE_ENTRY;
其实上面的文档是不够全面的,下面我们用Windbg来看下具体的结构和值。
!peb
->dt -r1 0x774bdca0 _PEB_LDR_DATA
可以看到这里,除了文档InMemoryOrderModuleList
,实际还有两个:
InLoadOrderModuleList
InMemoryOrderModuleList
InInitializationOrderModuleLists
这个其实是模块在不同状态的顺序
InLoadOrderModuleList
指的是模块加载的顺序
InMemoryOrderModuleList
指的是在内存的排列顺序
InInitializationOrderModuleLists
指的是模块初始化装载顺序。
这里选择跟进InLoadOrderModuleList
指向的结构
1)dt -r1 _LIST_ENTRY 0x1023330
(这里取第二个,第一个是exe本身)->dt -r1 0x1023228 _LDR_DATA_TABLE_ENTRY
2)lm
列举出加载的模块信息。
从这图可以得出两个信息,Flink总是指向下一个_LDR_DATA_TABLE_ENTRY
结构对应加载顺序的Flink
指针,_LDR_DATA_TABLE_ENTRY
在0x2c处是加载模块的名称,在0x18偏移处,是该模块的加载基地址。
基于上述认识,使用Windbg遍历一下InMemoryOrderModuleList
加载顺序的完整链结构:
(1)dt -r1 0x774bdca0 _PEB_LDR_DATA
->dt -r1 0x1023338-0x8 _LDR_DATA_TABLE_ENTRY
第一个结构是:PebTest.exe
(2)dt -r1 0x1023230-0x8 _LDR_DATA_TABLE_ENTRY
第二个模块是:ntdll.dll
(3)dt -r1 0x1023718-0x8 _LDR_DATA_TABLE_ENTRY
第三个模块是:KERNEL32.DLL
(Warning,all in uppercase, interesting)
(4)dt -r1 0x1023ad8-0x8 _LDR_DATA_TABLE_ENTRY
第四个模块是:KERNELBASE.dll
(Warning,name uppercase, suffix lowercase,interesting)
(5)...
dt -r1 0x10246d8-0x8 _LDR_DATA_TABLE_ENTRY
-> 第五个模块ucrtbased.dll
dt -r1 0x1024530-0x8 _LDR_DATA_TABLE_ENTRY
-> 第六个模块VCRUNTIME140D.dll
顺序如下: PebTest.exe
->ntdll.dll
->KERNEL32.DLL
->KERNELBASE.dll
->ucrtbased.dll
->VCRUNTIME140D.dll
调试真的很累,直接写个程序,遍历三个链表内容,然后在不同win系统下测试:
#include<stdio.h>
#include<windows.h>typedef struct UNICODE_STRING
{
USHORT _ength;
USHORT MaximumLength;
PWSTR Buffer;
}UNICODE_STRING, * PUNICODE_STRING;typedef struct PEB_LDR_DATA {
ULONG Length;
BOOLEAN initialized;
PVOID SsHandle;
LIST_ENTRY InLoadOrderModuleList;
LIST_ENTRY InMemoryOrderModuleList;
LIST_ENTRY InInitializationOrderModuleList;}PEB_LDR_DATA, * PPEB_LDR_DATA;
typedef struct LDR_DATA_TABLE_ENTRY
{
LIST_ENTRY InLoadOrderModuleList;
LIST_ENTRY InMemoryOrderModuleList;
LIST_ENTRY InInitializationOrderModuleList;
void* BaseAddress;
void* EntryPoint;
ULONG SizeOfImage;
UNICODE_STRING FullDllName;
UNICODE_STRING BaseDllName;
ULONG Flags;
SHORT LoadCount;
SHORT TlsIndex;
HANDLE SectionHandle;
ULONG CheckSum;
ULONG TimeDateStamp;
}MY_LDR_MODULE, * PLDR_MODULE;int main()
{
PEB_LDR_DATA* pEBLDR;
MY_LDR_MODULE* pLdrMod;
PLDR_MODULE PLdr;
LIST_ENTRY* pNext, * pStart;
_asm
{
mov eax, fs: [0x30]
mov eax, [eax + 0xC]
mov pEBLDR, eax
}
printf("GetModuleHandle Kernel32:0x%08x\n", GetModuleHandle("Kernel32"));
printf("GetModuleHandle ntdll:0x%08x\n", GetModuleHandle("ntdll"));
printf("--------------------------------------------------------------------------\n");
printf("PEB_LDR_DATA:0x%08x\n", pEBLDR);
printf("LDR->InLoadOrderModuleList:\t\t0x%08x\n", pEBLDR->InLoadOrderModuleList);
printf(">>>InLoadOrderModuleList<<<\n");
printf("BaseAddress\t\t BaseDllName\n================================================\n");
pNext = (LIST_ENTRY*)&(pEBLDR->InLoadOrderModuleList);
pStart = pNext;
do
{
pNext = pNext->Flink;
pLdrMod = (MY_LDR_MODULE*)pNext;
printf("0x%08x\t\t", pLdrMod->BaseAddress);
wprintf(L"%s\n", pLdrMod->BaseDllName.Buffer);} while (pNext != pStart);
printf("LDR->InMemoryOrderModuleList:\t\t0x%08x\n", pEBLDR->InMemoryOrderModuleList);
printf("BaseAddress\t\t BaseDllName\n================================================\n");
pNext = (LIST_ENTRY*)&(pEBLDR->InMemoryOrderModuleList);
pStart = pNext;
do
{
pNext = pNext->Flink;
pLdrMod = CONTAINING_RECORD(pNext, LDR_DATA_TABLE_ENTRY, InMemoryOrderModuleList);
printf("0x%08x\t\t", pLdrMod->BaseAddress);
wprintf(L"%s\n", pLdrMod->BaseDllName.Buffer);
} while (pNext != pStart);printf("LDR->InInitializationOrderModuleList:\t0x%08x\n", pEBLDR->InInitializationOrderModuleList);
printf("BaseAddress\t\t BaseDllName\n================================================\n");
pNext = (LIST_ENTRY*)&(pEBLDR->InInitializationOrderModuleList);
pStart = pNext;do
{
pNext = pNext->Flink;
pLdrMod = CONTAINING_RECORD(pNext, LDR_DATA_TABLE_ENTRY, InInitializationOrderModuleList);
printf("0x%08x\t\t", pLdrMod->BaseAddress);
wprintf(L"%s\n", pLdrMod->BaseDllName.Buffer);
} while (pNext != pStart);
getchar();
}
Win10:
win7/winxp:
可以观察到在
InLoadOrderModuleList
InMemoryOrderModuleList
前3个DLL无论内容还是顺序都是完全一样的。
而InInitializationOrderModuleLists
则在不同Window版本存在差异,故一般不选用这个内存顺序的方式。
在第二小节,在C高级语言层面,已经有了大体的搜索逻辑,但是在汇编过程需要对寄存器的选用和值的传递,条件判断进行一些规划,这样在编写汇编的时候,写出的代码不仅简洁还可以更容易理解。
1)xor eax, eax
清零,mov eax, fs:[0x30]
获取PEB地址
2)mov eax, [eax + 0x0c]
获取LDR地址,0x30和0x0c上面都有讲的,偏移量。
3)
mov esi, [eax + 0Ch]
//则指向InLoadOrderModuleList
mov esi, [eax + 14h]
//则指向InMemoryOrderModuleList
4)遍历Flink,找到Kernel32.dll的位置
位置在第3个,这里需要简单计算下。
指向InLoadOrderModuleList 的同时就是第一个了。
再指向一次mov esi, [esi]
,就是第二个了。
lodsd
或者mov esi,[esi];mov eax, esi
,就是第三个了
5)获取Kernel地址,这里也需要小小计算一下。
mov eax,[eax+08h]
//InLoadOrderModuleList 顺序
mov eax, [eax+18h]
//InMemoryOrderModuleList 顺序
6)完成赋值,mov address, eax
; 最后输入验证结果。
//InLoadOrderModuleList
#include <Windows.h>
#include <stdio.h>int main()
{
unsigned int address;
__asm {
xor eax, eax
mov eax, fs: [eax + 30h] ; 指向PEB的指针
mov eax, [eax + 0ch]; 指向PEB_LDR_DATA的指针
mov eax, [eax + 0ch]; 根据PEB_LDR_DATA得出InLoadOrderModuleList的Flink字段
mov esi, [eax];
lodsd;
mov eax, [eax + 18h]; Kernel.dll的基地址
mov address, eax;
}
printf("0x:%p\n", address);
HANDLE kernelA = LoadLibrary(L"kernel32.dll");
printf("0x:%p\n", kernelA);
system("pause");
return 0;
}
//InMemoryOrderModuleList 顺序的实现
#include <Windows.h>
#include <stdio.h>int main()
{
unsigned int address;
__asm {
xor eax, eax;
mov eax, fs: [eax + 30h] ; 指向PEB的指针
mov eax, [eax + 0ch]; 指向PEB_LDR_DATA的指针
mov eax, [eax + 14h]; 根据PEB_LDR_DATA得出InMemoryOrderModuleList的Flink字段
mov esi, [eax];
lodsd;
mov eax, [eax + 10h]; Kernel.dll的基地址
mov address, eax;
}
printf("0x:%p\n", address);
HANDLE kernelA = LoadLibrary(L"kernel32.dll");
printf("0x:%p\n", kernelA);
system("pause");
return 0;
}
当然我知道有些怀疑主义严重的小伙伴会想这个加载顺序是不是固定一样的呀?
要是变了的话怎么办,这种固定的写法,是不是会出错呀?
其实这种顾虑不用担心,因为绝大多数都是固定的,不过针对这个问题,我可以优化下汇编代码,使其更加通用。
代码优化实现选择先顺序遍历,再判断长度,因为判断名字有差不多24字节,入栈的话需要倒序,然后小端序来排列,12个push,有点累呀,就没做完整的基于模块名字的准确判断,这里只给个判断长度的Demo代码。当然本质上这种优化Duck 可不必,作为一个脚本小子应该没有机会遇到那么阴间的情况。
#include <Windows.h>
#include <stdio.h>int main()
{
unsigned int address;
__asm {
xor eax, eax
mov eax, fs: [eax + 30h] ; 指向PEB的指针
mov eax, [eax + 0ch]; 指向PEB_LDR_DATA的指针
mov eax, [eax + 0ch]; 根据PEB_LDR_DATA得出InLoadOrderModuleList的Flink字段
push 0x001a0018; //BaseDllName-> Length MaximumLength
mov edi, [esp];
Next: // Foreach InLoadOrderModuleList item
mov eax, [eax]; // Flink -> Flink
cmp edi, [eax + 0x2c];
jne Next
mov eax, [eax + 18h]; Kernel.dll的基地址
mov address, eax;
add esp, 0x4; // make stack balanced
}
printf("0x:%p\n", address);
HANDLE kernelA = LoadLibrary(L"kernel32.dll");
printf("0x:%p\n", kernelA);
system("pause");
return 0;
}
本文内容较为基础简单,是老生常谈的Window x86 ShellCode的组成部分,当然其也是关键的一部分。本文从两个方面对此技术展开了详细的介绍,首先从暴力搜索方面,其作为最早的搜索手段,有一定的时期合理性,但是在现在看来不是一个很好的选择,接着本文继续对基于PEB定位基址的技术,进行了逐步分解介绍,最终以Demo代码实现完结,并给读者留下了进一步尝试的空间。
穿插一句,如果你与笔者一样是个萌新/脚本小子,一样想低门槛地编写无视常规杀软,进阶至在驱动层面透明的ShellCodeLoader,那么可以关注后续笔者相关的产出和找我一起交流。
文章来源于:https://xz.aliyun.com/t/10478
若有侵权请联系删除
加下方wx,拉你一起进群学习
往期推荐
什么?你还不会webshell免杀?(十)
PPL攻击详解
绕过360核晶抓取密码
什么?你还不会webshell免杀?(十)
64位下使用回调函数实现监控
什么?你还不会webshell免杀?(九)
一键击溃360全家桶+核晶
域内持久化后门