本文所载网络安全技术仅为技术研究与学习之用,严禁用于非法攻击、侵入他人系统等违法违规行为。所有内容均需遵守国家相关法律法规,滥用所产生的一切法律责任,由行为人自行承担,与本文作者、发布者无关。
这次真的书接上回了,上一节写了一些汇编的基础,主要是栈的内容,这期继续跟进
这一集会先从一个汇编的 demo 开始,逐渐深入
将 shellcode 传奇开始时候粗略提过的 PEB-Walk 更深入的实现、分析一遍,顺便把汇编的语法也扯一遍,非常高效率的同时干好几个事情hh
看完这集之后就可以深入的看懂 pebwalk 在汇编层面上的实现,为之后真正深入的分析 c2 的 shellcode 做一个关键的铺垫
鄙人技术浅陋,经网上四处观看浏览总结得此文,如有不足希望师傅们多包容⊙﹏⊙
下面正文开始
引入 demo(part1-peb-walk)
下面引入一个汇编的 demo 程序,功能是动态定位 WinExec 并执行 calc.exe 弹出计算器喵
BITS 64
SECTION .text
global main
main:
sub rsp, 0x28
and rsp, 0xFFFFFFFFFFFFFFF0
xor rcx, rcx
xor rdx, rdx
mov rax, [gs:rcx + 0x60]
mov rax, [rax + 0x18]
mov rsi, [rax + 0x10]
mov dl, 0x4b
pebwalk:
mov rsi, [rsi]
mov rax, [rsi + 0x30]
mov rdi, [rsi + 0x58 + 0x8]
cmp [rdi + 12*2], cx
jne pebwalk
cmp [rdi], dl
jne pebwalk
mov r8, rax
逐行的解析一下这个 demo
开头的声明
开头的声明:
BITS 64
对编译器声明:这是 x64 架构的汇编
SECTION .text
定义代码段 .text 段,这个段用来存放可执行指令
//段(section)相关内容可以翻看 “系统安全及免杀(7) PE的前世今生(1)-浅谈PE文件结构 – 米饭のblog!! (flyingrice.top)“
global main
用 global 声明 main 为全局标签,让其识别为程序入口点
代码内容
main:
sub rsp, 0x28
and rsp, 0xFFFFFFFFFFFFFFF0
sub 其实是 substract ,减法
把 rsp 减去 0x28 也就是 40,作用是申请一段栈空间,此时 rsp 位于距离栈底 40 字节的位置,同时满足了影子空间的要求喵
and 是按位与,这一行会把 rsp 的值与 0xFFFFFFFFFFFFFFF0 做按位与运算,把 rsp 的十六进制最后一位置 0 ,也就是达到了栈对齐(栈对齐详见上一集)
//怕我写的有点模糊,有师傅感受不到这点数学直觉,其实就是像 20、30、能被 10 整除一样,16进制下末位为 0 的自然就都被 16 整除,也就是达成了栈对齐,虽然第一行的 0x28 并不对齐,但也只是申请了空间而已,这是不必对齐的
xor rcx, rcx
xor rdx, rdx
mov rax, [gs:rcx + 0x60]
xor 按位异或,任何一个数与自己按位异或的结果都是 0 ,因为相同为 0 、相异为 1,这两行是非常经典的用法,将寄存器快速清零
mov 的功能就不用多说了(
GS 段寄存器在 x64 的 Windows 中指向 TEB,通过固定偏移量 0x60 找到 PEB,并将 PEB 地址写入 rax,也就是说这一步之后。 rax 寄存器里面就是 PEB 的基地址
//在 shellcode 传奇系列的第 2 集讲解过 PEB-Walk 的大致了,真的是非常非常常用的一个知识点呢
细致扯两句这段的语法:
[gs:rcx + 0x60] 这是 x84/x64 汇编通用的标准的内存寻址方式:段超越前缀 : 基址 + 偏移寻址,这段的意思也就是找 “GS 段中,地址为 rcx+0x60 的内存”
小问题
那既然实际上都在操作数字,为什么不按下面这样写:
mov rcx, 0
mov rax, [gs:0x60]
而是采用异或,和使用 0 + 0x60 ,甚至 0 还是用 rcx 来替代的(
其实是为了优化和效率,按上面这么写也是能达到一样效果的
按照 demo 里面的写,sub rsp, 0x28 只要 2 个字节,编译后的字节数更少,机器指令更短,而且 xor 是 寄存器操作,一次寄存器操作只需要 1 个时钟周期,而 mov 要处理立即数,需要更多的微指令
第二行也是一样的问题,直接访问 rcx 是寄存器操作,而直接写 0 相当于处理一个新的数
都是为了优化和更优雅的代码
说回 demo:
mov rax, [rax + 0x18]
在PEB 结构中,偏移量 0x18 的成员是 Ldr,指向 _PEB_LDR_DATA 结构,将上一步的 rax 加 0x18 之后就是这个结构的地址了,这个结构体的作用参考前面的文章内容即可,并且有方括号做寻址,直接就找过去了
mov rsi, [rax + 0x10]
_PEB_LDR_DATA 结构中,偏移 0x10 的成员是 InLoadOrderModuleList ,一个双向链表头,按加载顺序记录所有已加载的模块
这个 InLoadOrderModuleList 本身就是一个 LIST_ENTRY 结构体,同时它是整个模块加载顺序链表的表头
第一行执行后 rsi 内就是链表的表头,也就是这么一个 LIST_ENTRY 结构体的地址了
这里给出一下 LIST_ENTRY 结构体的定义:
typedef struct _LIST_ENTRY {
struct _LIST_ENTRY *Flink; //向前指针(下一个节点),偏移 0x0
struct _LIST_ENTRY *Blink; //向后指针(上一个节点),偏移 0x8
} LIST_ENTRY;
前面的某篇笔记说过,寄存器加 [] 相当于对寄存器中地址做寻址,此时 rsi中存放的是 InLoadOrderModuleList 表头的地址,寻址到这个表头,这个表头是个 LIST_ENTRY 结构体(正如刚刚所说),所以在没设定偏移量,也就是默认 0 偏移量的前提下,其实是寻到了这个结构体的第一个属性 Flink 的,也就是 [rsi] 是表头的向前指针的值
//循环链表相关的机制不在此赘述
表头本身是不表示模块的,取向前指针 Flink ,实际上是指向了链表里面真正的第一个模块的 LIST_ENTRY
而表头的 Blink 自然就是指向链表中最后一个模块节点,因为是循环链表,所以第一个模块向后寻找会找到最后一个
_LDR_DATA_TABLE_ENTRY
只要程序加载了一个 exe 或 dll,Windows 就会创建一个这个结构体,里面成员是名字,基地址之类的,一些详细的信息
这个结构体的成员内容实在过于大了,这里就只给出精简的版本:
typedef struct _LDR_DATA_TABLE_ENTRY {
// 链表节点(用于遍历)
LIST_ENTRY InLoadOrderLinks; // +0x00 按加载顺序链表
LIST_ENTRY InMemoryOrderLinks; // +0x10 按内存布局顺序链表
LIST_ENTRY InInitializationOrderLinks; // +0x20 按初始化顺序链表
// 模块基址与入口
PVOID DllBase; // +0x30 模块基地址
PVOID EntryPoint; // +0x38 入口点地址
ULONG SizeOfImage; // +0x40 镜像大小
// Unicode字符串 - 模块路径和名称
UNICODE_STRING FullDllName; // +0x48 完整路径 (如 C:\Windows\System32\ntdll.dll)
UNICODE_STRING BaseDllName; // +0x58 基础名称 (如 ntdll.dll)
// 标志与状态
ULONG Flags; // +0x68
USHORT LoadCount; // +0x6C 加载计数
USHORT TlsIndex; // +0x6E
// 哈希链表
LIST_ENTRY HashLinks; // +0x70
// 版本信息
ULONG TimeDateStamp; // +0x80
// Win10+ 新增字段(用于安全缓解)
PVOID EntryPointActivationContext; // +0x88
PVOID Lock; // +0x90
PVOID DdagNode; // +0x98
LIST_ENTRY NodeModuleLink; // +0xA0
PVOID LoadContext; // +0xB0
PVOID ParentDllBase; // +0xB8
PVOID SwitchBackContext; // +0xC0
// 安全相关
RTL_BALANCED_NODE BaseAddressIndexNode; // +0xC8
RTL_BALANCED_NODE MappingInfoIndexNode; // +0xE0
ULONG_PTR OriginalBase; // +0xF8
LARGE_INTEGER LoadTime; // +0x100
// 更多字段...
} LDR_DATA_TABLE_ENTRY, *PLDR_DATA_TABLE_ENTRY;
其实还是很长很长(
// 链表节点(用于遍历)
LIST_ENTRY InLoadOrderLinks; // +0x00 按加载顺序链表
LIST_ENTRY InMemoryOrderLinks; // +0x10 按内存布局顺序链表
LIST_ENTRY InInitializationOrderLinks; // +0x20 按初始化顺序链表
重点是这段,注意这三个成员的数据类型都是 LIST_ENTRY,也就是说这个 _LDR_DATA_TABLE_ENTRY 内部包含着若干 LIST_ENTRY 结构体,其中的 InLoaDOrderLinks 就是我们刚刚说到的
稍微提一句 InMemoryOrderLinks ,这个是按内存分布顺序排列的版本,从名字就能看出来(
总结一下这个结构,在每个加载进来的模块,都会创建一个 _LDR_DATA_TABLE_ENTRY,然后其中设置了一个 LIST_ENTRY 结构体类型的成员,其中有一个 Flink 向前指针,一个 Blink 向后指针,向前指针指向前一个模块的 InLoadOrderLinks 这个 LIST_ENTRY结构体,Blink同理,到此实现了循环链表节点的功能,然后按照加载进来的顺序,组织起来这么一个循环链表
_PEB_LDR_DATA 就通过存了这个循环链表的链表头,来实现存放循环链表,通过这个链表实现遍历模块作用,这就是 PEB-Walk 的实现细节
链表的内容
说好了怎么找链表,就该说说里面都是什么,毕竟一个”按加载顺序排列“不够呢
虽然有很多说法比如老版本 Windows 第一个节点是 exe 自己,第二个是 ntdll.dll ,第三个是 kernel32,等等,但是都不一样,所以动态、具有健壮性的查找就显得十分的必要了
这里回去看 _LDR_DATA_TABLE_ENTRY 的定义,其中:
// 模块基址与入口
PVOID DllBase; // +0x30 模块基地址
PVOID EntryPoint; // +0x38 入口点地址
ULONG SizeOfImage; // +0x40 镜像大小
// Unicode字符串 - 模块路径和名称
UNICODE_STRING FullDllName; // +0x48 完整路径 (如 C:\Windows\System32\ntdll.dll)
UNICODE_STRING BaseDllName; // +0x58 基础名称 (如 ntdll.dll)
偏移量 0x30 的地方是 DllBase,模块的基地址,以及 0x58 的地方是名称
DllBase 没什么好说的,重点看另一个,也是个结构体,定义如下:
typedef struct _UNICODE_STRING {
USHORT Length; // +0x00 字符串长度(字节)
USHORT MaximumLength; // +0x02 缓冲区大小
PWSTR Buffer; // +0x08 指向实际字符串的指针
} UNICODE_STRING, *PUNICODE_STRING;
可以看到,想要获取真正的模块的名称,其实是要找 _LDR_DATA_TABLE_ENTRY 的 0x58 + 0x08 偏移量处,才是获取到要找的名称
至此,链表的结构,以及 _LDR_DATA_TABLE_ENTRY 中的具体的基地址,和名称的位置都确定了,就该正式去找了
说回 demo:
mov dl, 0x4b
0x4b 是 16 进制的 K,Kernel32 的首字母,将这个值存入 dl 寄存器
dl 寄存器是一个 8 位的寄存器,基本上存一个英文字母(8 位)都用这个寄存器,属于是大家的习惯和通俗约定
扯一下这个寄存器吧,之前说易失性寄存器、非易失性寄存器都是在物理电路上有真正的结构的,但是 dl 没有喵
dl 其实是 rdx 寄存器的低 8 位的逻辑划分,还有 dx 寄存器,dx 寄存器是 rdx 寄存器的低 16 位划分,然后 dh 寄存器是 dx 寄存器的高 8 位,也就是说 dl 和 dh 把 dx 瓜分了,然后 edx 寄存器是 rdx 寄存器的低32位 划分,rdx 自身是 64 位的寄存器
然后这时候可以回顾一下,在开始的时候通过 xor 清零了 rdx 和 rcx 两个寄存器
下一行开始就是检验对比逻辑:
pebwalk:
mov rsi, [rsi]
mov rax, [rsi + 0x30]
mov rdi, [rsi + 0x58 + 0x08]
声明一个新的 pebwalk 标签,汇编中声明标签的作用就像是书签一样,因为汇编都是一行一行指令执行的,想要重定向执行流,做循环或者是判断分支,就要用标签,这个 demo 里面在此处做一个循环
上半集刚说到,这个 rsi 寄存器里面存到了表头这个 LIST_ENTRY 结构体的 Flink,此时用方括号做寻址,就跳到下一个链表节点的 InLoadOrderLinks 了,又因为表头啥也不代表,所以进入 pebwalk 之后先 [rsi] 一下,这之后 rsi 里面就是第二个模块,也就是第一个真正的有内容的模块
然后是取 0x30 和 0x58 + 0x08 两个偏移量的内容,也就是基地址和模块名称,分别存到 rax 和 rdi 两个寄存器(划重点,因为最后要找的 kernel32 地址就会存在 rax 里面)
cmp [rdi + 12*2], cx
jne pebwalk
cmp,其实是 compare 的意思,会做减法,前面一个减去后面一个,也就是 rdi 里面的地址之后的第 12*2=24 个字节,与 cx 寄存器的值做比较,但是 cmp 的运算并不在乎减法的结果是多少,只做判断,然后更改标志位
标志位
标志位是 CPU 里面只占 1 个比特的开关,作用就是记录刚才运算的结果状态,true or false
4 个关键的标志位,关系到判断跳转的指令的动作
ZF (Zero Flag) 零标志
- 结果 = 0 → ZF = 1
- 结果 ≠ 0 → ZF = 0
SF (Sign Flag) 符号标志
- 结果负数 → SF=1
- 结果正数 → SF=0
CF (Carry Flag) 进位 / 借位标志
- 无符号数溢出 / 不够减 → CF=1
OF (Overflow Flag) 溢出标志
- 有符号数溢出 → OF=1
jne 指令就会看 ZF 标志位:如果 ZF=0(不相等)→ 跳转,如果 ZF=1(相等),那就不跳
这时候回去看一下做减法比较的两个值,一个是名称的第 24 字节,一个是 cx 寄存器
cx 寄存器和 dl 类似,是 rcx 的低 16 位逻辑划分,在开始的时候 rcx 通过 xor 的方法置零了,也就是说这是在比较左侧的是否为 0
小问题(2)
那么问题来了,为什么要用 cx ?左侧是个内存地址的寻址取值,为什么一定是 16 位?一字节不是8位吗?
在这一步,右边是一个 16 位的 cx 寄存器,所以左侧也就自动取了 对应地址之后的 16 位,两字节的内容
那么这个位数在有寄存器的情况下(比如和 rbx 比就是取 64 位)是按寄存器走,两个数字呢的情况就需要手动指定了
for example:
cmp byte [rdi], 5 ; 8位
cmp word [rdi], 5 ; 16位
cmp dword [rdi], 5 ; 32位
回到 demo 的逻辑:
比较这个的原因就在于:kernel32.dll ,这是 12 个字母
回顾一下我们寻址找的这个地方是什么:
// Unicode字符串 - 模块路径和名称
UNICODE_STRING FullDllName; // +0x48 完整路径 (如 C:\Windows\System32\ntdll.dll)
UNICODE_STRING BaseDllName; // +0x58 基础名称 (如 ntdll.dll)
数据类型是 UNICODE_STRING ,unicode 里面一个字母占 2 字节,包括结束的 NULL 也是,两个字节的 NULL 充当结束符号
//rdi + 24 定位结束符是对的,不用在 +1,因为 rdi 自身的地址就已经是第一个字符了
如果 cmp 的校验通过,都是 0 ,说明这个模块的名称长度和 kernel32.dll 一样,也就是相减为 0 了,就会置 ZF 标志位为 1
然后下一行 jne,看到 ZF 是1 则,不会跳转
反之,如果遍历到的模块不是我们要的,长度不相同,相减之后的标志位为 0 ,jne 就会触发,通过标签重定向到 pebwalk 的位置,重走循环
cmp [rdi], dl
jne pebwalk
这一步很简单了,和刚刚说的一样,做比较,[rdi] 是名称的第一个字符,和 dl 里面存的 “K” 做比较
如果第一个字母是 K 则不触发 jne 的跳转
那么!到这里,就确定了真的找到了 kernel32.dll,那么这时候回顾一下上面,把基地址存到 rax 寄存器了,那么在最后,把 rax 的值,也就是 kernel32 的地址转存到 r8 里面
终于结束了喵…系列的下一集会在这个 part1 的 demo 的基础上,定位 WinExec,然后弹出计算器,收工睡觉了喵
