初试 Windows x64 Pwn(Part I)
一些 Windows Pwn 学习笔记和做题记录
1. 前言
本系列文是笔者学习 Windows Pwn 的笔记和做题记录,预定分为两部分:基础知识和漏洞利用。
撰写本文的主要原因是,笔者在 2021 年强网杯线下赛遇过一道 Windows Pwn 题目 。赛后复盘的时候,觉得这道题目难度简单、可以使用许多常见的套路解题,十分适合初学者学习。因此想写一篇博文分析这道题目,顺便学习一下 Windows Pwn。
注:笔者是 Linux Pwn 手,本文可能倾于使用解 Linux Pwn 题的思路来看题。
2. 准备环境
2.1 Windows 10 虚拟机
qwbswap 题目环境是 Windows 10。
由于比赛时间紧张,没空从头开始搭建 Windows 10 环境。因此笔者当时直接去微软官网下载了用于测试 Edge 浏览器的 Windows 10 虚拟机。
下载完导入到虚拟机软件,直接开机就能用了(账户密码是Passw0rd!
)。
微软还很贴心地提供了VirtualBox
、VMWare
等 5 种不同版本的虚拟机镜像。
2.2 x64dbg
调试器
x64dbg 是一款开源的 Windows 调试器,支持 x86 / x64 架构,具有功能强大、容易上手、简单直观的 GUI 界面等特点。官方 Github 项目可以下载到免安装的 snapshot 版本,解压后直接运行release/x64/x64dbg.exe
即可使用。
另一款推荐使用的调试器是微软官方出品的 WinDbg Preview。相比 x64dbg,WinDbg Preview 的功能更为强大(例如支持解析PEB
、堆等 Windows 内部数据结构)。
2.3 调试符号(.pdb
文件)
在 Windows 下,.exe
可执行文件和.dll
库文件一般统称为模块( Modules)。
.pdb
文件(Program DataBase)是微软 Visual Studio 编译模块时生成的调试符号文件,相当于 Linux 的debug symbols
。与 ELF 文件的 DWARF 信息一样,PDB 文件保存了函数名、全局变量名、源码行号等各种调试信息,方便开发者调试程序。
一般情况下,Windows 自带的系统模块(如本文的ntdll.dll
)往往删去了大部分的调试信息, 尤其缺少许多 Windows Pwn 必要的函数名以及全局变量名 。为了获取这些缺少的调试信息, 需要从微软搭建的调试符号服务器(Microsoft Symbol Server)下载对应的 PDB 文件 。
这些 PDB 文件还记录了许多未公开的 Windows 内核数据结构(Windows Kernel structures),有助于泄露地址的时候确定某些结构体字段的偏移位置。
IDA Pro 支持自动下载和加载 PDB 文件。打开题目提供的ntdll.dll
和kernel32.dll
,IDA Pro 会弹出窗口提示是否下载并解析 PDB 文件,补完缺少的函数名和全局变量名。
笔者个人推荐使用 VergiliusProject 网站浏览 Windows 内核数据结构。
这个网站收集了从 Windows XP 到最近发布的 Windows 11 不同 Windows 内核版本所使用的结构体、枚举量等数据结构,支持搜索、交叉引用和显示结构体字段偏移,比直接从 IDA Pro 上面看方便得多(其实这个网站也是直接扒了微软调试符号服务器上面的 PDB 文件的)。
3. 预备知识
3.1 ntdll.dll
与 kernel32.dll
跟 Linux 相比,Windows 下利用任意地址读写漏洞的思路是差不多的:首先泄露一些 DLL 库基址,然后通过 ROP / Shellcode 执行特定 DLL 库函数来 getshell 或者读取 flag 文件。
在本次漏洞利用中,我们需要泄漏ntdll.dll
和kernel32.dll
的基址。
kernel32.dll
定义了大部分的底层 Windows API,可以执行许多需要与 Windows 内核进行交互的底层操作,例如文件读写、内存管理和进程控制等。这个 DLL 库里面可以找到大量有利用价值的 API 函数,例如:
- 文件读写
CreateFileA/ReadFile/WriteFile
(相当于open/read/write
); - 执行 Shell 命令
WinExec
(相当于system
); - 设置内存访问权限
VirtualProtect
(相当于mprotect
)等等。
kernel32.dll
基址主要通过程序IAT
表(相当于 ELF 文件的GOT
表)或者栈上残留的返回地址泄漏。
(注1:部分kernel32.dll
定义的 Windows API 实际上来自于另一个 DLL 库kernelbase.dll
;调用这类 Windows API 时kernel32.dll
会直接转跳到kernelbase.dll
上,故一般没有必要泄漏kernelbase.dll
的基址)
(注2:除了kernel32.dll
,程序调用的微软 Visual C 标准库也是 Windows Pwn 常见的利用目标;它将许多 Windows API 包装成 Linux Pwner 十分熟悉的标准 C 语言函数,如fopen/fread/system
等等,相当于 Linux 下的 glibc libc;然而本题没有提供远程环境的 Visual C 标准库 DLL 文件)
ntdll.dll
是位于 Windows 系统底层的 DLL 库,它在 Windows Pwn 中主要有两种用途:
ntdll.dll
里面有PEB
、LDR 链表等重要 Windows 内核结构体的内存地址,利用这些结构体可以进一步泄漏程序的栈地址以及其他 DLL 库的基址;ntdll.dll
中存在较多可用的 ROP Gadget。
ntdll.dll
基址可以从栈、堆或者kernel32.dll
等地方泄漏。
3.2 PEB
与 TEB
PEB
(Process Environment Block,进程环境块)和 TEB
(Thread Environment Block,线程环境块)是两种 Windows 特有的数据结构。每个 Windows 进程拥有一个PEB
,进程中的每一条线程都拥有一个单独的TEB
。常见的单线程进程一般只有一个PEB
和一个TEB
。
PEB
和TEB
位于所属进程内存空间的随机位置,用于存储当前进程和线程的相关信息,作用类似于 Linux 中的Auxiliary Vector
。
PEB / TEB
可以用来泄露许多重要的内存地址。PEB
(对应_PEB
结构体)可以泄露 程序基址(ImageBaseAddress
)、 堆地址(ProcessHeap
)和 ntdll.dll
地址(Ldr
)等等;TEB
(对应_TEB
结构体)可以泄露程序的 栈地址(NtTib.StackBase / NtTib.StackLimit
)。
一般有两种途径可以获取PEB / TEB
地址:
第一种途径是 gs
寄存器,这是获微软官方(于ntdll.dll
、kernel32.dll
等系统模块中)采用的正规路子。原理是在 Windows 中每个线程的 gs
寄存器指向的是当前线程的TEB
,而 TEB
中存放有PEB
和TEB
的地址。如下面的汇编代码所示:
; r13 寄存器的值为 TEB 地址,rdi 寄存器为 PEB 地址
mov r13, gs:0x30
mov rdi, [r13+0x60] ; 或 mov rdi, gs:0x60
结合TEB
的结构解释一下:这里的 0x30 偏移对应_TEB
结构体的NtTib.self
成员,这是TEB
指向自身的指针;0x60 偏移对应ProcessEnvironmentBlock
成员,即PEB
的地址。
在 IDA Pro 中,上述第一条汇编语句会被反汇编为等效的 Windows API NtGetCurrentTeb
:
第二种途径是从 ntdll.dll
中泄漏。ntdll.dll
的某些全局变量存放有当前进程的PEB
地址, 通过PEB
地址可以计算出TEB
地址:TEB = PEB + 0x1000
。在多线程进程下,以此类推可以得到所有线程的TEB
地址:PEB+0x1000
、PEB+0x2000
、PEB+0x3000
…
3.3 LDR 链表
LDR 链表是 Windows 用于记录进程已加载模块的双向链表。在 LDR 链表上,每个节点表示位于进程内存空间中的某个模块,记录了模块名称、入口地址、基址等信息,因此LDR 链表可以用来泄漏程序的基址,也可以泄漏kernel32.dll
等 DLL 库的基址。
LDR 链表的表头位于 ntdll.dll
的PebLdr
全局变量中。PebLdr
是一个_PEB_LDR_DATA
结构体:
//0x58 bytes (sizeof)
struct _PEB_LDR_DATA
{
[...]
struct _LIST_ENTRY InLoadOrderModuleList; //0x10
struct _LIST_ENTRY InMemoryOrderModuleList; //0x20
struct _LIST_ENTRY InInitializationOrderModuleList; //0x30
[...]
};
一共有三种 LDR 链表:InLoadOrderModuleList
、InMemoryOrderModuleList
和InInitializationOrderModuleList
。每种链表上的节点都是一样的,差别仅在于节点在链表中的排列顺序:例如InMemoryOrderModuleList
链表中的节点按照模块在内存中的位置从低到高排序。
LDR 链表节点的类型为_LDR_DATA_TABLE_ENTRY
, 它的 DllBase
成员就是模块基址。
//0x10 bytes (sizeof)
struct _LIST_ENTRY
{
struct _LIST_ENTRY* Flink; //0x0
struct _LIST_ENTRY* Blink; //0x8
};
//0x120 bytes (sizeof)
struct _LDR_DATA_TABLE_ENTRY
{
struct _LIST_ENTRY InLoadOrderLinks; //0x0
struct _LIST_ENTRY InMemoryOrderLinks; //0x10
struct _LIST_ENTRY InInitializationOrderLinks; //0x20
VOID* DllBase; //0x30
[...]
};
简单介绍一下 LDR 链表节点间的链接方式:在XXXModuleList
链表中,每个链表节点XXXLinks
成员的Flink
指针指向下一个节点的XXXLinks
成员,Blink
指针指向上一个节点的XXXLinks
成员(链表第一个节点由PebLdr
中的XXXModuleList
表头Flink
指针指出)。因此 只要取得Flink / Blink
的指针值,减去XXXLinks
对应的偏移就能得到链表节点地址。
3.4 Windows x64 汇编
Windows x64 汇编与 Linux 的差异主要在于参数寄存器、函数调用约定和 API 函数,其他部分基本一致。
下图是 Windows x64 的函数栈帧布局(自己按照微软文档和调试结果画的,如有错误欢迎指出 XD):
LOW
. . .
. . .
. . .
+-> +-+-+-+-+-+-+-+-+-+-+-+-+
| | [the top of stack] | <----- rsp
| +-+-+-+-+-+-+-+-+-+-+-+-+
| . . [argument registers]
| . . +---+-------------+
| . local variables . |rcx| argument #1 |
| . (saved registers) . +---+-------------+
| . . |rdx| argument #2 |
| . . +---+-------------+
| +-+-+-+-+-+-+-+-+-+-+-+-+ |r8 | argument #3 |
| | security cookie | +---+-------------+
| +-+-+-+-+-+-+-+-+-+-+-+-+ |r9 | argument #4 |
| | return address | +---+-------------+
+-> +-+-+-+-+-+-+-+-+-+-+-+-+
| | |
| + +
| | |
| + shadow space +
| | |
| + +
| | |
| +-+-+-+-+-+-+-+-+-+-+-+-+
| | argument #5 |
| +-+-+-+-+-+-+-+-+-+-+-+-+
| | argument #6 |
| +-+-+-+-+-+-+-+-+-+-+-+-+
| | argument #7 |
| +-+-+-+-+-+-+-+-+-+-+-+-+
. . .
. . .
. . .
HIGH
第一, Windows x64 只有 4 个参数寄存器RCX/RDX/R8/R9
,第 5 个以及以后的函数参数通过栈传参。
第二, 调用函数前,调用者需要在栈上预留 0x20 字节的空间作为 shadow space(又称 home space),用于被调用函数保存 4 个参数寄存器的值。
在 Windows x64 汇编中,函数开头使用以下汇编指令来保存参数寄存器。
; 注意不是所有的函数都保存全部参数寄存器的值
; 举个例子,如果函数只有两个参数,那么它只会保存前 2 个参数寄存器的值。
mov [rsp-0x8], rcx
mov [rsp-0x10], rdx
mov [rsp-0x18], r8
mov [rsp-0x20], r9
编写 Windows ROP 链时注意:由于 shadow space 正好位于返回地址位置的后面。因此 ROP 调用 API 函数会导致后面的 ROP 链被破坏,只能再次执行一个 gadget(即 API 函数的返回地址)。
第三, 使用内核功能需要调用相关 API 函数(例如 Windows API),不能像 Linux 这样可以使用系统调用指令syscall / int 0x80
直接与内核进行交互,主要因为微软没有对外公开 Windows 系统调用表。
4. Part II
下一章,笔者将介绍 qwbswap 这道题目,以及其漏洞利用:如何利用任意地址读写漏洞,通过已知的堆地址泄露栈地址与 DLL 库基址、编写 Shellcode 和 ROP 链读取 flag 文件。
Comments