首页
社区
课程
招聘
[原创]windows内核学习1
发表于: 1天前 489

[原创]windows内核学习1

1天前
489

Windows 把每个进程已加载模块的基址,放在了一个用户态可直接读的结构里——PEB。读它不需要 syscall,不经过任何可被 hook 的 API。这意味着你不必走 GetProcAddress 去问 ntdll 要函数地址(EDR 在那条路上等着),而是自己把基址翻出来、自己算目标函数地址直接调。这篇记的是从 PEB 出发、一行偏移一行偏移把 ntdll.dll 的加载基址取出来的完整过程,代码自己写、能编译能跑,文末有真实输出。

文里标了「自查」的地方,是我给自己留的痕迹——当时卡在哪、怎么想错的、后来怎么弄对的。写下来主要是给自己复习用,顺便留存。如果有路过的同行看到哪里说得不对,能指一下当然好,但这不是本意,没人有这义务。内容均在合法授权的隔离靶场环境内学习验证。

环境:Windows 11 家庭中文版(10.0.26200),MSYS2 gcc 16.1.0,x64。

用户态调系统服务,标准路径是两层下传:

用户态系统调用下传链与 EDR inline hook 插入点

EDR 在用户态的检测就落在这条链上。典型做法是在 ntdllNt* 函数入口插 inline hook(写几字节的 jmp 跳到自己的处理函数),或者 hook kernel32 的导出函数。只要你还走 GetProcAddressntdll 里取函数地址再调用,EDR 都看得到。

绕开这一层的思路:自己把 ntdll 里目标函数的地址算出来直接调。前提是先拿到 ntdll.dll 在当前进程地址空间里的加载基址。这个基址受 ASLR 影响,每次启动都不同,不能写死。

每个线程有一个 TEB,每个进程有一个 PEB。这俩都在进程自身的用户态地址空间里,用户代码可以直接读写,不需要 syscall,不需要内核调用。这就是免杀爱用它的根本原因——访问它们不经过任何可被 hook 的 API。

x64 下,gs 段寄存器指向当前线程的 TEB。TEB 起始地址是 gs:[0],而 TEB 内部偏移 +0x30 处存放着指向所属进程 PEB 的指针。取 PEB 可以一步到位:

gs:[0x60] 等价于先取 TEB(gs:[0])再读其 +0x30 字段,CPU 在段寄存器机制里替你完成了这步寻址。C 里我用 gcc 的内联函数 __readgsqword(0x60) 读取,省得写汇编。

PEB 字段很多,我用到的就两个:

PEB.Ldr 指向 PEB_LDR_DATA 结构,里面维护着当前进程已加载模块的三条双向链表:

三条链表串的是同一批模块节点,区别只在排序方式,以及节点指针挂在结构体的哪个字段上。我选 InMemoryOrderModuleListLdr + 0x20)。

链表中每个节点对应一个 LDR_DATA_TABLE_ENTRY,关键布局(x64,从结构体首地址算):

从 PEB 到 DllBase 的完整偏移链:

容易错的地方:把 curr+0x08 当成取 DllBase 的偏移。+0x08 是 Blink(后向指针),不是 DllBase,DllBase 从 curr 算是 +0x20

还有一点:Ldr + 0x20 这个地址本身是哨兵节点,不是真实模块,要从它的 Flink 跳一步才到第一个真实模块节点。

这是我实际写代码时反复出错的地方。

InMemoryOrderModuleList 是个 LIST_ENTRY,位于 PEB_LDR_DATA + 0x20,它本身是链表的哨兵头节点(不对应任何真实模块)。它的 Flink 指向第一个真实模块节点的 InMemoryOrderLinks 字段——注意,指向的是这个字段,而不是 LDR_DATA_TABLE_ENTRY 结构体的首地址。

由于 InMemoryOrderLinks 在结构体里排在 +0x10,所以链表交到手的指针 curr 满足:

取同一个 DllBase 因此有两种算法:

LDR_DATA_TABLE_ENTRY 内存布局:curr 与结构体首地址差 0x10

两者差 0x10,正是 InMemoryOrderLinks 字段在结构体内的偏移。我最初照搬网上资料写了 +0x30,从 curr 直接加,取到的是 EntryPoint 而非 DllBase,打印出来是个明显不对的值。后来逐个偏移打印验证,确认从 curr 算应当用 +0x20

正规写法是用 CONTAINING_RECORD 宏从字段指针反推结构体首地址:

反推之后所有字段统一用「从首地址算」的偏移。本文的 demo 没用这个宏,直接拿 curr 当基址、配「从 curr 算」的偏移,省去反推一步,代价是每取一个字段都要记得减去 0x10。两种写法取到的是同一个 DllBase

0x10 是 InMemoryOrderLinks 这个字段在 LDR_DATA_TABLE_ENTRY 结构体里的位置偏移,不是「起点」。前面 0x00~0x0F 被第一个 LIST_ENTRYInLoadOrderLinks,16 字节)占满了,所以 InMemoryOrderLinks 排在 +0x10

链表交给你的 curr,就是这个字段本身的地址(= 结构体首地址 + 0x10)。所以从首地址算 DllBase 在 +0x30,从 curr 算就是 0x30 - 0x10 = +0x20。差的 0x10 = 字段在结构体里的偏移量。是字段的位置,不是「起点」。

LIST_ENTRY 是 Windows 里的标准双向链表节点,只有两个指针:

我默写时把这两个记反过一次,以为 Flink+0x08,结果遍历直接绕回链表头。记法是「前在前,0 在前」:前向(Forward)在 +0x00。遍历链表找下一个节点一律用 Flinkcurr + 0x00)。

这些 LDR_DATA_TABLE_ENTRY 是加载器在堆上动态分配的,各节点物理地址并不连续,无法靠固定步长跳转。所以节点内部字段之间用偏移寻址(柜子里第几格),节点之间Flink/Blink 指针寻址(柜子在哪)。偏移只管柜子内部,指针只管柜子之间,两套机制分工。如果节点像数组那样连续排布,理论上可以靠固定偏移跳,但加载器没这么分配,所以必须靠节点自带的指针串。

BaseDllName 字段在 curr + 0x48,但它存的不是字符串本身,而是一个 UNICODE_STRING 结构(x64 下 16 字节):

BaseDllName 与 UNICODE_STRING:取字符串要再读一次 Buffer

要从 curr + 0x48 取到字符串,需要再读一次 Buffer 字段。BufferUNICODE_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_entrysecond_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" 比较。

命中则记录 DllBasebreak

未命中则 curr = *(curr + 0x00)(沿 Flink 跳到下一节点)。

回到哨兵节点 Ldr + 0x20 时停止——哨兵不是真实模块,绕回即意味着遍历完成。

一个需要澄清的说法是「ntdll 永远是第二个节点」。它只对 InLoadOrderModuleList 成立:进程启动时加载器最先映射 exe 本身,紧接着映射 ntdll,所以在按加载顺序排的链表里 ntdll 确实是第二个。但 InMemoryOrderModuleList 按内存地址排序,ntdll 的位置并不固定。我在自己这个 demo 进程里实测 ntdll 恰好是 [1],但用 notepad 验证时,它启动会拉入一批 GUI 相关 DLL,ntdll 在内存序链表里被挤到后面。因此代码没有依赖「跳一次即到 ntdll」的假设,而是老老实实遍历整条链按名字匹配。

第一周的顶点代码,自己写的。保留了前期探测用的旧变量(flinkblinksecond_entryntdll_base)和注释掉的 printf,作为试错过程的记录,未做删除。

几点说明:

curr 声明为 unsigned long long(整数),不能直接解引用,需先 (unsigned long long*) 转为指针类型再 * 取值。

basename_buf 的空指针保护同时覆盖了 printf_wcsicmp 两处调用,避免 Buffer 为 NULL 时崩溃。

命中即 break,避免遍历到链表尾部。

循环条件 curr != (ldr + 0x20) 依赖绕回哨兵即停止;正常情况下 ntdll 必在链中,break 先于绕回触发。若链表损坏导致死循环,可加 i < 50 作为兜底。

输出解读:

第一个节点是 exe 自身(week1_run.exe,基址 7ff785b70000),加载顺序上 exe 总是第一个。

第二个节点即 ntdll.dll,基址 7ffe2f3a0000

found ntdll.dll base 出现两次:第一次在循环内命中时打印并随即 break;第二次是循环结束后打印变量 found_ntdll。两值一致,确认基址已被正确保存。

地址 7ffe... 开头符合 Windows 用户态高地址区分配特征(用户态通常占据地址空间高半区,x64 下大致在 0x00000000_000000000x00007FFF_FFFFFFFF)。该值是本次进程启动中 ASLR 的结果,重启或换机后会变化,因此代码每次运行都重新计算,不写死。

仅靠代码自身打印不足以确认偏移正确——万一取错了字段、碰巧打印出一个看起来合理的值。我用 Sysinternals 的 Process Monitor(Procmon)做外部交叉验证。

Procmon 通过 ETW(Event Tracing for Windows)捕获文件、注册表、网络、进程/线程、镜像加载等事件。在其中过滤 Operation is Load Image(对应模块映射事件,与本文讨论的模块加载对应),可以看到 notepad 的模块地址列表里 ntdll.dll 的基址同样为 7ffe2f3a0000


冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。

最后于 1天前 被aln1lam编辑 ,原因:
收藏
免费 10
打赏
分享
最新回复 (7)
雪    币: 5967
活跃值: (13169)
能力值: (RANK:385 )
在线值:
发帖
回帖
粉丝
2
调整一下.没图.
1天前
0
雪    币: 3477
活跃值: (7653)
能力值: ( LV3,RANK:20 )
在线值:
发帖
回帖
粉丝
3
学习
1天前
0
雪    币: 10246
活跃值: (7139)
能力值: ( LV4,RANK:50 )
在线值:
发帖
回帖
粉丝
4
调整一下.没图. 
1天前
0
雪    币: 5967
活跃值: (13169)
能力值: (RANK:385 )
在线值:
发帖
回帖
粉丝
5
有图了 
1天前
0
雪    币: 841
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
6
TkBinary 有图了[em_014]
Ovo
1天前
0
雪    币: 106
活跃值: (1919)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
7
mark
23小时前
0
雪    币: 206
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
8
666666666666
11小时前
0
游客
登录 | 注册 方可回帖
返回