文章总结: 本文详细讲解在Windowsx64环境下通过PEB结构手动定位ntdll.dll基址以绕过EDR对GetProcAddress的hook检测。核心方法是从gs段寄存器获取PEB地址,经Ldr字段进入PEBLDRDATA,遍历InMemoryOrderModuleList链表,通过偏移计算获取DllBase。文章重点剖析了LDRDATATABLE_ENTRY结构内存布局,指出curr指针与结构体首地址差0x10的常见错误,并给出正确偏移算法。内容基于合法授权靶场环境验证,具有较强实战参考价值。
综合评分: 85
文章分类: 免杀,逆向分析,红队,恶意软件,实战经验
Windows内核免杀学习:不调 API 找 ntdll 基址
aln1lam
aln1lam
看雪学苑
2026年9月16日 17:59
上海
在小说阅读器读本章
去阅读
在公众号小说中沉浸阅读
Windows 把每个进程已加载模块的基址,放在了一个用户态可直接读的结构里——PEB。读它不需要 syscall,不经过任何可被 hook 的 API。这意味着你不必走 GetProcAddress 去问 ntdll 要函数地址(EDR 在那条路上等着),而是自己把基址翻出来、自己算目标函数地址直接调。这篇记的是从 PEB 出发、一行偏移一行偏移把 ntdll.dll 的加载基址取出来的完整过程,代码自己写、能编译能跑,文末有真实输出。
文里标了自查的地方,是我给自己留的痕迹——当时卡在哪、怎么想错的、后来怎么弄对的。写下来主要是给自己复习用,顺便留存。如果有路过的同行看到哪里说得不对,能指一下当然好,但这不是本意,没人有这义务。内容均在合法授权的隔离靶场环境内学习验证。
环境:Windows 11 家庭中文版(10.0.26200),MSYS2 gcc 16.1.0,x64。
EDR 盯的是 GetProcAddress 这条路
用户态调系统服务,标准路径是两层下传:
应用代码
→ kernel32!VirtualAlloc (Win32 API,公开导出)
→ ntdll!NAllocateVirtualMemory (NT API,实际系统调用包装)
→ syscall 指令 (陷入内核,进入 ntoskrnl)
用户态系统调用下传链与EDRinlinehook插入点
EDR 在用户态的检测就落在这条链上。典型做法是在 ntdll 的 Nt* 函数入口插 inline hook(写几字节的 jmp 跳到自己的处理函数),或者 hook kernel32 的导出函数。只要你还走 GetProcAddress 去 ntdll 里取函数地址再调用,EDR 都看得到。
绕开这一层的思路:自己把 ntdll 里目标函数的地址算出来直接调。前提是先拿到 ntdll.dll 在当前进程地址空间里的加载基址。这个基址受 ASLR 影响,每次启动都不同,不能写死。
取 PEB 是一行汇编的事
每个线程有一个 TEB,每个进程有一个 PEB。这俩都在进程自身的用户态地址空间里,用户代码可以直接读写,不需要 syscall,不需要内核调用。这就是免杀爱用它的根本原因——访问它们不经过任何可被 hook 的 API。
x64 下,gs 段寄存器指向当前线程的 TEB。TEB 起始地址是 gs:[0],而 TEB 内部偏移 +0x30 处存放着指向所属进程 PEB 的指针。
取 PEB 可以一步到位:
mov rax, gs:[0x60] ; rax = PEB 地址
gs:[0x60] 等价于先取 TEB(gs:[0])再读其 +0x30 字段,CPU 在段寄存器机制里替你完成了这步寻址。C 里我用 gcc 的内联函数 __readgsqword(0x60) 读取,省得写汇编。
PEB 字段很多,我用到的就两个:
| 偏移 (x64) | 字段 | 用途 |
| — | — | — |
| +0x02 | BeingDebugged | 反调试检测:调试器附加后置 1 |
| +0x18 | Ldr | 指向 PEB_LDR_DATA,模块链表入口 |
#
Ldr 挂着三条链,我选内存序那条
PEB.Ldr 指向 PEB_LDR_DATA 结构,里面维护着当前进程已加载模块的三条双向链表:
| 偏移 (x64) | 链表 | 排列依据 |
| — | — | — |
| +0x10 | InLoadOrderModuleList | 模块加载顺序 |
| +0x20 | InMemoryOrderModuleList | 模块在内存中的地址顺序 |
| +0x30 | InInitializationOrderModuleList | 模块初始化(DLL_PROCESS_ATTACH)顺序 |
三条链表串的是同一批模块节点,区别只在排序方式,以及节点指针挂在结构体的哪个字段上。我选 InMemoryOrderModuleList(Ldr + 0x20)。
链表中每个节点对应一个 LDR_DATA_TABLE_ENTRY,关键布局(x64,从结构体首地址算):
+0x00 InLoadOrderLinks (LIST_ENTRY, 16 字节)
+0x10 InMemoryOrderLinks (LIST_ENTRY, 16 字节) ← 我用的链表挂这里
+0x20 InInitializationOrderLinks (LIST_ENTRY, 16 字节)
+0x30 DllBase (PVOID)
+0x38 EntryPoint (PVOID)
+0x40 SizeOfImage (ULONG)
+0x48 FullDllName (UNICODE_STRING)
+0x58 BaseDllName (UNICODE_STRING)
自查:完整的遍历路径偏移
从 PEB 到 DllBase 的完整偏移链:
gs:[0x60] → PEB
PEB + 0x18 → Ldr (PEB_LDR_DATA)
Ldr + 0x20 → InMemoryOrderModuleList 链表头(哨兵)
头.Flink → 第一个真实节点的 InMemoryOrderLinks 字段(即 curr)
curr + 0x20 → DllBase ← 从节点指针算
curr + 0x48 → BaseDllName ← 从节点指针算
容易错的地方:把 curr+0x08 当成取 DllBase 的偏移。+0x08 是 Blink(后向指针),不是 DllBase,DllBase 从 curr 算是 +0x20。
还有一点:Ldr + 0x20 这个地址本身是哨兵节点,不是真实模块,要从它的 Flink 跳一步才到第一个真实模块节点。
差 0x10 的坑:链表交来的不是结构体首地址
这是我实际写代码时反复出错的地方。
InMemoryOrderModuleList 是个 LIST_ENTRY,位于 PEB_LDR_DATA + 0x20,它本身是链表的哨兵头节点(不对应任何真实模块)。它的 Flink 指向第一个真实模块节点的 InMemoryOrderLinks 字段——注意,指向的是这个字段,而不是 LDR_DATA_TABLE_ENTRY 结构体的首地址。
由于 InMemoryOrderLinks 在结构体里排在 +0x10,所以链表交到手的指针 curr 满足:
curr = 结构体首地址 + 0x10
取同一个 DllBase 因此有两种算法:
算法A(从结构体首地址算):DllBase = entry + 0x30
算法B(从节点指针 curr 算): DllBase = curr + 0x20
LDR_DATA_TABLE_ENTRY内存布局:curr与结构体首地址差0x10
两者差 0x10,正是 InMemoryOrderLinks 字段在结构体内的偏移。我最初照搬网上资料写了 +0x30,从 curr 直接加,取到的是 EntryPoint 而非 DllBase,打印出来是个明显不对的值。后来逐个偏移打印验证,确认从 curr 算应当用 +0x20。
正规写法是用 CONTAINING_RECORD 宏从字段指针反推结构体首地址:
PLDR_DATA_TABLE_ENTRY entry = CONTAINING_RECORD(curr, LDR_DATA_TABLE_ENTRY, InMemoryOrderLinks);
// 等价于 entry = (char*)curr - 0x10
PVOID base = entry->DllBase; // entry + 0x30
反推之后所有字段统一用从首地址算的偏移。本文的 demo 没用这个宏,直接拿 curr 当基址、配从 curr 算的偏移,省去反推一步,代价是每取一个字段都要记得减去 0x10。两种写法取到的是同一个 DllBase。
自查:0x10 到底是什么
0x10 是 InMemoryOrderLinks 这个字段在 LDR_DATA_TABLE_ENTRY 结构体里的位置偏移,不是起点。前面 0x00~0x0F 被第一个 LIST_ENTRY(InLoadOrderLinks,16 字节)占满了,所以 InMemoryOrderLinks 排在 +0x10。
链表交给你的 curr,就是这个字段本身的地址(= 结构体首地址 + 0x10)。所以从首地址算 DllBase 在 +0x30,从 curr 算就是 0x30 - 0x10 = +0x20。差的 0x10 = 字段在结构体里的偏移量。是字段的位置,不是起点。
Flink 在前,Blink 在后
LIST_ENTRY 是 Windows 里的标准双向链表节点,只有两个指针:
curr + 0x00 Flink 前向指针,指向下一个节点
curr + 0x08 Blink 后向指针,指向上一个节点
我默写时把这两个记反过一次,以为 Flink 在 +0x08,结果遍历直接绕回链表头。记法是前在前,0 在前:前向(Forward)在 +0x00。遍历链表找下一个节点一律用 Flink(curr + 0x00)。
自查:链表为什么用指针,不用固定偏移跳
这些 LDR_DATA_TABLE_ENTRY 是加载器在堆上动态分配的,各节点物理地址并不连续,无法靠固定步长跳转。所以节点内部字段之间用偏移寻址(柜子里第几格),节点之间用 Flink/Blink 指针寻址(柜子在哪)。
偏移只管柜子内部,指针只管柜子之间,两套机制分工。如果节点像数组那样连续排布,理论上可以靠固定偏移跳,但加载器没这么分配,所以必须靠节点自带的指针串。
取字符串要先再读一次 Buffer
BaseDllName 字段在 curr + 0x48,但它存的不是字符串本身,而是一个 UNICODE_STRING 结构(x64 下 16 字节):
+0x00 Length (USHORT) 字符串字节数(不含终止符)
+0x02 MaximumLength (USHORT) Buffer 分配的字节数
+0x08 Buffer (PWSTR) 指向真正的 UTF-16 字符串
BaseDllIName与UNICODE_STRING:取字符串要再读一次 Buffer
要从 curr + 0x48 取到字符串,需要再读一次 Buffer 字段。Buffer 在 UNICODE_STRING 内部偏移 +0x08,于是其绝对位置是 0x48 + 0x08 = 0x50,即 curr + 0x50 处存着字符串指针。代码里写成 *(unsigned long long*)(curr + 0x50) 取出该指针,再 (wchar_t*) 转型交给 printf 的 %ws。
Windows 内部字符串一律 UTF-16LE,C 里对应 wchar_t(MSYS2 下 2 字节)。宽字符串字面量用 L 前缀:L"ntdll.dll"。比较宽字符串用 wcscmp(区分大小写)或 _wcsicmp(不区分)。DLL 名大小写并不统一——我这台机器上 ntdll.dll 全小写,KERNEL32.DLL 全大写——所以必须用 _wcsicmp,否则匹配失败。
Buffer 有可能为 NULL(某些节点未填充名称),直接传给 %ws 或 _wcsicmp 会触发空指针访问。代码里用 if (basename_buf) 做了一层保护,字符串比较也放在这层之内。
自查:* 解引用到底读出了什么
* 的作用是去某个地址,把那里存的内容读出来。读出来的东西是地址还是普通数值,取决于你怎么继续用它——内存里存的都是字节,含义由后续使用决定。
- 如果读出来之后还要再
*一次(比如*(*(curr+0x50))),说明那个位置存的是个地址,你在做指针的指针。 - 如果读出来直接交给
printf打印,那它就是个数值(基址、长度之类的值)。
在第一周里,ntdll_base 是当值用的(一个基址数值,打印出来给人看)。但到了第二周解析 PE 头时,这个基址会变成地址——作为 PE 文件在内存里的起点去解引用读 DOS 头。同一个数字,这一周是值,下一周是地址,用法变了,含义就变了。
自查:first_entry / second_entry 是节点还是地址
first_entry 和 second_entry 是同一种东西——都是 curr,都是某个节点的 InMemoryOrderLinks 字段地址,不是结构体首地址。所以取 DllBase 时,两者用的是同一个偏移+0x20(从 curr 算)。
容易错的地方:在 second_entry 上误用 +0x30,因为潜意识里把它当成了结构体首地址。其实 second_entry = first_entry 的 Flink,它和 first_entry 是同一种指针,偏移规则一样。
遍历:绕回哨兵即停
环形双向链表的遍历:
InMemoryOrderModuleList:环形双向链表与哨兵头节点
- 链表头
Ldr + 0x20是哨兵节点,其Flink指向第一个真实模块。 - 从第一个节点起,每轮读取
BaseDllName.Buffer,与L"ntdll.dll"比较。 - 命中则记录
DllBase并break。 - 未命中则
curr = *(curr + 0x00)(沿Flink跳到下一节点)。 - 回到哨兵节点
Ldr + 0x20时停止——哨兵不是真实模块,绕回即意味着遍历完成。
一个需要澄清的说法是ntdll 永远是第二个节点。它只对 InLoadOrderModuleList 成立:进程启动时加载器最先映射 exe 本身,紧接着映射 ntdll,所以在按加载顺序排的链表里 ntdll 确实是第二个。
但 InMemoryOrderModuleList 按内存地址排序,ntdll 的位置并不固定。我在自己这个 demo 进程里实测 ntdll 恰好是 [1],但用 notepad 验证时,它启动会拉入一批 GUI 相关 DLL,ntdll 在内存序链表里被挤到后面。
因此代码没有依赖跳一次即到 ntdll的假设,而是老老实实遍历整条链按名字匹配。
代码
第一周的顶点代码,自己写的。保留了前期探测用的旧变量(flink、blink、second_entry、ntdll_base)和注释掉的 printf,作为试错过程的记录,未做删除。
#include<stdio.h>
#include<intrin.h>
#include<string.h>
#include<wchar.h>
int main(){
unsigned long long peb, ldr, first_entry, dllbase, flink, blink, second_entry, ntdll_base, curr;
int i = 0;
wchar_t* basename_buf = NULL;
peb = __readgsqword(0x60);
ldr = *(unsigned long long*)(peb + 0x18);
first_entry = *(unsigned long long*)(ldr + 0x20);
dllbase = *(unsigned long long*)(first_entry + 0x20);
flink = *(unsigned long long*)(first_entry + 0x00);
blink = *(unsigned long long*)(first_entry + 0x08);
second_entry = flink;
ntdll_base = *(unsigned long long*)(second_entry + 0x20);
curr = first_entry;
unsigned long long found_ntdll = 0;
while(curr != (ldr + 0x20)){
basename_buf = (wchar_t*)(*(unsigned long long*)(curr + 0x50));
dllbase = *(unsigned long long*)(curr + 0x20);
if(basename_buf){
printf("basename:%ws\ndllbase:%llx\n", basename_buf, dllbase);
if(_wcsicmp(basename_buf, L"ntdll.dll") == 0){
found_ntdll = dllbase;
printf("found ntdll.dll base:%llx\n", found_ntdll);
break;
}
}else{
printf("basename_buf is NULL");
}
curr = *(unsigned long long*)(curr + 0x00);
i++;
}
printf("found ntdll.dll base:%llx\n", found_ntdll);
// printf("flink:%llx\n",flink);
// printf("peb:%llx\n", peb);
// printf("ldr:%llx\n", ldr);
// printf("first_entry:%llx\n", first_entry);
// printf("dllbase:%llx\n", dllbase);
// printf("ntdll_base:%llx\n", ntdll_base);
return 0;
}
几点说明:
curr声明为unsigned long long(整数),不能直接解引用,需先(unsigned long long*)转为指针类型再*取值。basename_buf的空指针保护同时覆盖了printf和_wcsicmp两处调用,避免Buffer为 NULL 时崩溃。- 命中即
break,避免遍历到链表尾部。 - 循环条件
curr != (ldr + 0x20)依赖绕回哨兵即停止;正常情况下 ntdll 必在链中,break先于绕回触发。若链表损坏导致死循环,可加i < 50作为兜底。
编译与运行
$ gcc week1.c -o week1_run.exe
$ ./week1_run.exe
basename:week1_run.exe
dllbase:7ff785b70000
basename:ntdll.dll
dllbase:7ffe2f3a0000
found ntdll.dll base:7ffe2f3a0000
found ntdll.dll base:7ffe2f3a0000
输出解读:
- 第一个节点是 exe 自身(
week1_run.exe,基址7ff785b70000),加载顺序上 exe 总是第一个。 - 第二个节点即
ntdll.dll,基址7ffe2f3a0000。 found ntdll.dll base出现两次:第一次在循环内命中时打印并随即break;第二次是循环结束后打印变量found_ntdll。两值一致,确认基址已被正确保存。
地址 7ffe... 开头符合 Windows 用户态高地址区分配特征(用户态通常占据地址空间高半区,x64 下大致在 0x00000000_00000000~0x00007FFF_FFFFFFFF)。该值是本次进程启动中 ASLR 的结果,重启或换机后会变化,因此代码每次运行都重新计算,不写死。
用 Procmon 交叉验证
仅靠代码自身打印不足以确认偏移正确——万一取错了字段、碰巧打印出一个看起来合理的值。我用 Sysinternals 的 Process Monitor(Procmon)做外部交叉验证。
Procmon 通过 ETW(Event Tracing for Windows)捕获文件、注册表、网络、进程/线程、镜像加载等事件。在其中过滤 Operation is Load Image(对应模块映射事件,与本文讨论的模块加载对应),可以看到 notepad 的模块地址列表里 ntdll.dll 的基址同样为 7ffe2f3a0000。
一个来自进程内部读 PEB 的值,一个来自外部 ETW 监控的值,两个独立来源吻合,基址得以确认。
顺带记录:Procmon 看 notepad 另存为
过滤设为 Process Name is notepad.exe + Operation is WriteFile,观察 notepad另存为 temp_test.txt的事件流,发现它并非单次 WriteFile 完成:
- 先以
CreateFile打开目标,Disposition字段出现CREATE、OVERWRITE_IF、OPEN_IF三种取值,分别对应 Win32CreateFile的dwCreationDisposition(新建、覆盖、打开或新建)。分析样本落地文件时,Disposition 是判断其文件创建意图的首要指标。 - 中间写入
.tmp临时文件,完成后通过SetRenameInformationFile改名为正式文件——文件系统的原子写惯例,保证要么完整写入、要么不可见。 - 末尾还出现
nvph开头的文件写入,系 Win11 新版 notepad 的会话状态存储,与另存为本身无关。
另将过滤切换为 Operation is Load Image + Process Name is notepad.exe,观察整个另存为过程:未出现新的 Load Image 事件。模块加载集中于进程启动最初一两秒,运行期基本复用已加载 DLL。这与进程启动早期加载模块、运行期不再加的预期一致。
后续学习内核回调 PsSetLoadImageNotifyRoutine 时,所捕获的也正是这一启动期的加载事件。
关键偏移速查(x64)
gs:[0x60] PEB
PEB + 0x18 Ldr
Ldr + 0x20 InMemoryOrderModuleList(哨兵头)
头.Flink 第一个真实节点(curr)
curr + 0x00 Flink(下一节点)
curr + 0x08 Blink(上一节点)
curr + 0x20 DllBase
curr + 0x48 BaseDllName(UNICODE_STRING)
curr + 0x50 BaseDllName.Buffer(字符串指针)
口诀两条,防记反:
- 前在前,0 在前:Flink(前向)在
+0x00,Blink(后向)在+0x08。 - 字段位置,不是起点:0x10 是
InMemoryOrderLinks在结构体里的位置,从 curr 算比从首地址算少 0x10。
几个还没想通的地方
我自己还有几个点没弄明白,先记着:
- 三条链表怎么选。我选
InMemoryOrderModuleList主要是偏移顺手。写 shellcode 或 loader 时更常用哪条?有没有稳定性上的讲究,比如InLoadOrder的节点顺序是不是更可预测。 - ntdll 第二个的边界。不少公开资料直接假设第二个节点就是 ntdll跳一次取基址,不走遍历。这种写法在
InLoadOrder链上是不是真的跨版本稳定?不同子系统下会不会有例外。我自己是老老实实遍历了,但那个偷懒写法到底能用在什么范围,我还不确定。 - 偏移的版本依赖。本文偏移都是 x64 Win11 实测。这些结构体偏移从 Win7 到 Win11 变过吗?写跨版本兼容的代码是靠判断系统版本选偏移,还是有更稳的办法,我得查查。
- TEB 还能干嘛。这篇我只用 TEB 取了 PEB。
StackBase/StackLimit、LastErrorValue这些字段在规避技术里有没有实际用处,我还没碰到。 - 下一步 PE 解析。拿到基址后我要从内存直接解析导出表(不
LoadLibrary、不MapViewOfFile)。内存解析和文件解析的差异我还没理清,比如节区在内存里按VirtualAddress展开、文件里按PointerToRawData,这块得专门花时间。
下一篇以这篇拿到的 ntdll 基址为起点,走 DOS 头 → NT 头 → 导出表,定位 NtAllocateVirtualMemory 的地址。
看雪ID:aln1lam
https://bbs.kanxue.com/user-home-1051882.htm
*本文为看雪论坛优秀文章,由 aln1lam 原创,转载请注明来自看雪社区
1.25折门票限时抢购
往期推荐
ART 执行链与 Nterp:解析 FART Android12‑16 失效问题
RP2350 硬件固化 CVE‑2022‑38694:突破 Unisoc BSP 安全启动链
球分享
球点赞
球在看
点击阅读原文查看更多
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:看雪学苑 aln1lam
aln1lam《Windows内核免杀学习:不调 API 找 ntdll 基址》