Skip to main content

Windows PEB 利用

·1768 words·4 mins·
系统安全 Windows ASLR shellcode PoC
Table of Contents

ASLR
#

ASLR,全称 Address space layout randomization,即地址空间配置随机加载。多数现代的应用程序都会开启 ASLR。目的是防止攻击者事先获知程序的虚拟内存地址,防止攻击者能可靠地跳转到内存的特定位置来利用函数。

在Linux中,ASLR 的实现方式是同一个应用程序每次启动都会被加载到不同的位置。而在 Windows 中,只能保证系统重启后地址的随机性。

究其原因,是对性能和安全性权衡后的结果。由于 Windows 不采用 PIE,因此其 ASLR 的实现需要付出内存代价。 每次将库映射到不同地址时,都会占用更多内存。

当然这也意味着,如果我们在某台 Windows 机器上获取了一次库函数的虚拟地址,在其重启之前,我们都能够继续使用。

PEB
#

在 Linux 中,内核通过task_struct保存并管理进程相关的信息,在 Windows 中起到类似作用的是PEB。当然,还是有许多不同之处。例如 PEB 在用户态中而 task_struct在内核态中。

进程环境块PEB)是 Windows NT操作系统内部使用的数据结构,用以存储每个进程的运行时数据。

维基百科对 PEB 的描述足够全面,推荐感兴趣的读者继续阅读,值得注意的是中文翻译有些瑕疵。说回到 PEB,PEB 是一个结构体,包含了进程是否被调试、被加载模块的虚拟地址等大量信息。

在 Windows 11 23H2 (2023 Update) 版本的内核中,PEB 的部分定义如下:

//0x7d0 bytes (sizeof)
struct _PEB
{
    UCHAR InheritedAddressSpace;                                            //0x0
    UCHAR ReadImageFileExecOptions;                                         //0x1
    UCHAR BeingDebugged;                                                    //0x2
    union
    {
        UCHAR BitField;                                                     //0x3
        struct
        {
            UCHAR ImageUsesLargePages:1;                                    //0x3
            UCHAR IsProtectedProcess:1;                                     //0x3
            UCHAR IsImageDynamicallyRelocated:1;                            //0x3
            UCHAR SkipPatchingUser32Forwarders:1;                           //0x3
            UCHAR IsPackagedProcess:1;                                      //0x3
            UCHAR IsAppContainer:1;                                         //0x3
            UCHAR IsProtectedProcessLight:1;                                //0x3
            UCHAR IsLongPathAwareProcess:1;                                 //0x3
        };
    };
    UCHAR Padding0[4];                                                      //0x4
    VOID* Mutant;                                                           //0x8
    VOID* ImageBaseAddress;                                                 //0x10
    struct _PEB_LDR_DATA* Ldr;                                              //0x18
    struct _RTL_USER_PROCESS_PARAMETERS* ProcessParameters;                 //0x20
    VOID* SubSystemData;                                                    //0x28
    VOID* ProcessHeap;                                                      //0x30
    struct _RTL_CRITICAL_SECTION* FastPebLock;                              //0x38
    union _SLIST_HEADER* volatile AtlThunkSListPtr;                         //0x40
    VOID* IFEOKey;                                                          //0x48
	……
};

在本文中,我们主要关注偏移为 0x18 的 Ldr 字段。为什么?因为它包含了被加载模块(用到的库)的虚拟地址。

Ldr 概览
#

在利用之前,或许应该先看看这个字段包含什么内容。我先在 Windbg 中随机打开一个应用程序看看 PEB 及 Ldr 的内容。

lm

在命令框中键入lm,即 list modules,可以看到这个应用加载了5个模块。其中a是程序本身的名字(a.exe),而KERNEL32是我们关心的另一个模块,因为它控制着系统的内存管理、数据的输入输出操作和中断处理,或者换句话说,其中有许多我们可以利用的函数(如WriteFile()用来写)。在不使用调试工具的时候我们无法如此便捷地获取被加载模块的地址,因此我们需要用到 PEB。

在 Windbg 中也可以很方便地查看 PEB 信息:

!peb

在命令框中键入!peb可以看到 Ldr.InMemoryOrderModuleList下存储着被加载模块地基地址,其中第一个和第三个是我们的目标,其显示的基地址和之前使用lm命令查看到的地址是一致的。

值得一提的是,这些 Modules 正是以 List 链表形式存储的。我们简单地验证一下:

list

不难发现,在每个条目的开头存储着下一个条目的地址,而偏移 0x20 处存储着被加载模块的基地址。因此当我们表头的地址时,我们可以通过每个链表项跳转到下一个链表项、可以获取每个链表项下模块的基地址。

PoC
#

接下来就是编写 C 代码获取被加载模块虚拟地址的 demo 了。我们先提出尚未解决的几个问题:

  1. 如何获得 PEB 结构体在内存中的地址?
  2. Ldr 相对 PEB 的偏移量已知是 0x18,Ldr.InMemoryOrderModuleList 相对 Ldr 的偏移是多少?

先回答第二个问题:Ldr.InMemoryOrderModuleList 相对 Ldr 的偏移是 0x20 。并且在我们编写 C 代码的时候,不需要知道具体的偏移量,只需要知道字段名称即可,相应的库会帮我们处理好偏移量。

接着是第一个问题:Windows 用 FS/GS 寄存器来存储 PEB 的地址,分别对应32位/64位。具体如下:

  • 32位:fs:0x30
  • 64位:gs:0x60

:后代表偏移量。

解决了这两个问题之后就可以编写 C 代码了:

#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <windows.h>
#include <winnt.h>
#include <winternl.h>

int main(void) {
    // __readgsqword(0x60) equals to mov <register>, gs:[0x60]
    PPEB pebPtr = (PPEB)__readgsqword(0x60);
    PPEB_LDR_DATA ldrData = pebPtr->Ldr;
    PLIST_ENTRY moduleList = &ldrData->InMemoryOrderModuleList;
    // Get the first module in the list
    PLDR_DATA_TABLE_ENTRY program_module = CONTAINING_RECORD(moduleList->Flink, LDR_DATA_TABLE_ENTRY, InMemoryOrderLinks);

    // Skip 3 modules to get kernel32.dll
    moduleList = moduleList->Flink;
    moduleList = moduleList->Flink;
    moduleList = moduleList->Flink;
    // Get kernel32.dll
    PLDR_DATA_TABLE_ENTRY kernel32_module = CONTAINING_RECORD(moduleList, LDR_DATA_TABLE_ENTRY, InMemoryOrderLinks);
    PVOID program_base = program_module->DllBase;
    PVOID kernel32_base = kernel32_module->DllBase;
    printf("Program base: %p\n", program_base);
    printf("Kernel32 base: %p\n", kernel32_base);

    return 0;
}

简单地解释一下流程:

  1. 通过 gs 寄存器获取 PEB 地址
  2. 通过 PEB 结构体获取 Ldr
  3. 通过 Ldr 获取 InMemoryOrderModuleList
  4. Flink 一次将取出表头,在固定的偏移处可以取出程序基地址
  5. Flink 3次将取出第三项,在同样的偏移处可以取出 Kernel32.dll 的基地址

编译成可执行文件并运行:

gcc poc.c
./a.exe

得到输出:

Program base: 0000000000400000
Kernel32 base: 00007FFEBE4B0000

发现与在 Windbg 中得到的一致。

这证明:我们可以通过编写 C 代码获得被加载模块的基地址。更进一步地,我们的 C 代码经过编译后反汇编得到的汇编代码简单清晰,这意味着我们可以编写比较简单的汇编来实现这一目标。换言之,我们可以在 shellcode 中实现这一目标从而绕过 ASLR。

BeaCox
Author
BeaCox
Stay humble, remain critical.