初试 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!)。

微软还很贴心地提供了VirtualBoxVMWare等 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.dllkernel32.dll,IDA Pro 会弹出窗口提示是否下载并解析 PDB 文件,补完缺少的函数名和全局变量名。

如图,可惜 IDA 免费版不支持这个功能

笔者个人推荐使用 VergiliusProject 网站浏览 Windows 内核数据结构。

Vergilius 网站查看 _PEB_LDR_DATA 结构体

这个网站收集了从 Windows XP 到最近发布的 Windows 11 不同 Windows 内核版本所使用的结构体、枚举量等数据结构,支持搜索、交叉引用和显示结构体字段偏移,比直接从 IDA Pro 上面看方便得多(其实这个网站也是直接扒了微软调试符号服务器上面的 PDB 文件的)。

3. 预备知识

3.1 ntdll.dllkernel32.dll

跟 Linux 相比,Windows 下利用任意地址读写漏洞的思路是差不多的:首先泄露一些 DLL 库基址,然后通过 ROP / Shellcode 执行特定 DLL 库函数来 getshell 或者读取 flag 文件。

在本次漏洞利用中,我们需要泄漏ntdll.dllkernel32.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 PEBTEB

PEB(Process Environment Block,进程环境块)TEB(Thread Environment Block,线程环境块)是两种 Windows 特有的数据结构。每个 Windows 进程拥有一个PEB,进程中的每一条线程都拥有一个单独的TEB。常见的单线程进程一般只有一个PEB和一个TEB

PEBTEB位于所属进程内存空间的随机位置,用于存储当前进程和线程的相关信息,作用类似于 Linux 中的Auxiliary Vector

PEB / TEB可以用来泄露许多重要的内存地址PEB(对应_PEB结构体)可以泄露 程序基址ImageBaseAddress)、 堆地址ProcessHeap)和 ntdll.dll地址Ldr)等等;TEB(对应_TEB结构体)可以泄露程序的 栈地址NtTib.StackBase / NtTib.StackLimit)。

一般有两种途径可以获取PEB / TEB地址:

第一种途径是 gs 寄存器,这是获微软官方(于ntdll.dllkernel32.dll等系统模块中)采用的正规路子。原理是在 Windows 中每个线程的 gs 寄存器指向的是当前线程的TEB,而 TEB中存放有PEBTEB的地址。如下面的汇编代码所示:

; 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!LdrpInitializeProcess

第二种途径是从 ntdll.dll 中泄漏ntdll.dll的某些全局变量存放有当前进程的PEB地址, 通过PEB地址可以计算出TEB地址:TEB = PEB + 0x1000。在多线程进程下,以此类推可以得到所有线程的TEB地址:PEB+0x1000PEB+0x2000PEB+0x3000

3.3 LDR 链表

LDR 链表是 Windows 用于记录进程已加载模块的双向链表。在 LDR 链表上,每个节点表示位于进程内存空间中的某个模块,记录了模块名称、入口地址、基址等信息,因此LDR 链表可以用来泄漏程序的基址,也可以泄漏kernel32.dll等 DLL 库的基址。

LDR 链表的表头位于 ntdll.dllPebLdr全局变量中。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 链表:InLoadOrderModuleListInMemoryOrderModuleListInInitializationOrderModuleList。每种链表上的节点都是一样的,差别仅在于节点在链表中的排列顺序:例如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对应的偏移就能得到链表节点地址。

LDR 节点之间通过 Flink / Blink 指针链接 (Credit:小小的心@pediy)

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 文件。

Credit: Wikimedia

Comments