-
-
[分享]一个Shellcode加载器的设计思路:从加密到内存对抗
-
发表于: 2026-8-21 17:27 149
-
引言
Shellcode加载器的核心架构是什么?
将Shellcode放在一个地方 → 使用函数申请空间或修改权限 → 执行Shellcode
哪怕加载器再怎么改造、做了多少免杀处理,核心架构始终不变。
我们要面对的是静态查杀、动态查杀,甚至多种多样的行为封锁。Shellcode的落地方式和加载时机,决定了加载器能否在免杀对抗中站住脚。
本文不提供完整源码,而是记录我在构建加载器时的设计思路和决策逻辑。
一、核心架构与关键决策
加载器本质上要解决三个问题:怎么藏住Shellcode、怎么避开检测、怎么让系统帮我执行它。每个问题都有多种解法,但组合在一起时,必须有一条连贯的逻辑线。
我的核心执行流程是:
反沙箱/反调试检测 → 加载加密资源 → 分配RW内存 → 复制密文 →
原地解密 → 修改为可执行权限 → 安装Sleep Hook → 触发执行
这个流程不是技术堆砌,而是基于一个核心判断:每一步都应该让杀软的"异常信号"尽可能低。
下面是我在构建过程中遇到的五个关键决策点,每个决策都直接影响了下游技术的选择。
1. 为什么加密用AES而不是XOR?
XOR加密不改变明文的分布特征。如果Shellcode里有连续的0x90(NOP),XOR后依然呈现规律性重复,YARA规则很容易提取出密文特征。
AES-CBC是块加密,输出接近随机分布。资源区里的密文不会触发签名匹配,配合Sleep Hook使用时,解密后的明文也不会在内存中长期暴露。
2. 为什么把Shellcode放在资源区(.rsrc)而不是数组里?
unsigned char buf[]会落在.data或.rdata节,杀软对整个节的扫描策略通常更严。
资源区的原始用途是存放图标、版本信息,杀软对它的扫描强度相对较弱——不是技术上不能扫,而是误报风险太高。把密文伪装成正常资源数据,是一种"不增加特征暴露面"的做法。
3. 为什么用原地解密?
如果先解密到临时内存再复制到可执行内存,解密后的Shellcode会先后出现在两个内存区域,内存暴露窗口被放大。
原地解密的好处是:解密前后在同一块内存里,没有额外副本。从杀软视角看,这只是"一段资源数据被修改了",而不是"敏感数据被复制到了新位置"。
4. 为什么在Sleep Hook里加内存加密?
杀软的动态内存扫描大多是间歇性触发(比如每2秒扫描一次)。如果Shellcode在两次扫描之间一直处于明文状态,就有可能被命中。
Sleep Hook的逻辑是:执行期间保持明文,进入Sleep后立即加密成随机数据,Sleep结束后再解密回来。
这样,Shellcode在内存中只有"正在执行"时才是明文,而执行是一个极短的瞬时状态。杀软的间歇扫描窗口,撞上明文状态的概率被降到了最低。
5. 为什么反沙箱/反调试要放在最前面?
放在解密之后,调试器或沙箱就有机会在解密后的明文内存上设置断点、Dump完整Shellcode、记录中间数据。
前置检测意味着:任何异常环境在接触到Shellcode之前就被拒绝了。即使对方猜到了加载方式,也无法在受控环境中获得明文的Shellcode。
二、核心技术定位
这一部分介绍每项技术在这个流程中的具体定位——它们不是孤立存在的,而是被放置在流程中某个特定位置,承担特定职责。
1. 反调试与反沙箱技术
定位说明:
云沙箱和自动上传已经是各大杀软的标配。没有这道防线,第一次上传即使没被标记,云沙箱提取的特征也可能在几小时后让你被全网封杀。它解决的是"载荷不该被公开分析"的问题——在流程中负责环境隔离,确保后续操作只发生在受控的真实用户环境中。
常见检测思路:
反调试检测:
IsDebuggerPresent():查询PEB中的BeingDebugged标志
CheckRemoteDebuggerPresent():检查是否有远程调试器
PEB结构字段检测:直接读取NtGlobalFlag等标志位
NtQueryInformationProcess:查询调试端口,比PEB标志更底层
时间检测(rdtsc):调试器单步会拖慢速度,利用时间差判断
异常处理机制:利用调试器优先捕获异常的差异设置陷阱
cpp
// API检测示例
bool CheckDebuggerAPIs() {
if (IsDebuggerPresent()) return true;
BOOL isRemoteDebuggerPresent = FALSE;
CheckRemoteDebuggerPresent(GetCurrentProcess(), &isRemoteDebuggerPresent);
return isRemoteDebuggerPresent;
}
// PEB检测示例
bool CheckPEBFlags() {
PPEB peb = NtCurrentTeb()->ProcessEnvironmentBlock;
if (peb->BeingDebugged) return true;
if (peb->NtGlobalFlag & 0x70) return true;
return false;
}
反沙箱检测:
CPUID指令检测:虚拟机中会置位Hypervisor Present Bit或返回特定厂商标识
硬件信息检测:沙箱通常分配更少资源(内存<4GB、CPU<2核)
延时执行检测:沙箱可能加速Sleep,通过实际耗时与预期的偏差判断
用户交互检测:鼠标是否长期静止、桌面窗口是否存在
cpp
// 沙箱资源检测示例
bool CheckSystemResources() {
MEMORYSTATUSEX memStatus;
memStatus.dwLength = sizeof(memStatus);
GlobalMemoryStatusEx(&memStatus);
DWORDLONG totalPhysMem = memStatus.ullTotalPhys / (1024 * 1024);
SYSTEM_INFO sysInfo;
GetSystemInfo(&sysInfo);
if (totalPhysMem < 4096 || sysInfo.dwNumberOfProcessors < 2) return true;
return false;
}
2. 加密算法与加密混淆
定位说明:
加密的目的是消除载荷的静态特征,让资源区里存放的是一段随机数据,而不是可识别的Shellcode。之所以选择AES-CBC,是因为它的输出分布接近随机,能最大化降低静态匹配的命中率。
核心所在:加密 → 写入资源 → 载入程序
整个加密载荷的"落地"流程分为三步:
第一步:加密Shellcode,生成密文文件
这一步在加载器之外完成。我写了一个独立的加密工具:读取原始Shellcode,用AES-256-CBC加密,输出一段密文。
关键点:加密工具与加载器的唯一耦合是使用相同的AES密钥和IV。密钥变更时,只需要重新加密并替换资源文件,不需要重新编译加载器本身。
第二步:将密文作为资源写入加载器
在加载器工程中,将密文文件以RT_RCDATA类型嵌入到资源区(.rsrc节):
IDR_SHELLCODE RCDATA "shellcode_encrypted.bin"
RT_RCDATA不要求数据符合任何特定格式,任意二进制数据都可以通过这种方式嵌入,且不会被编译器做额外处理。
第三步:加载器运行时通过资源API读取密文
FindResourceExW:定位资源在PE文件中的位置
LoadResource:将资源数据从文件映射到内存
LockResource:获取指向密文数据的指针和数据长度
密文在内存中是以"只读资源页"形式存在的,直到后续的VirtualAlloc+解密操作,才会被复制到可操作的内存区域。
三步关系:
加密工具(外部)→ 生成密文文件 → 资源编译器嵌入(编译时)→
资源API读取(运行时)→ 得到密文指针 → 后续解密
为什么这样设计?把加密过程前置到编译阶段,运行时没有加密开销,解密是纯内存操作,减少了API调用链长度;同时密钥与加载器分离,密钥变更不需要重写加载器逻辑。
3. Hook钩子技术
定位说明:
Hook在这里的用途是在特定事件(Sleep)发生时,触发内存加密动作。它解决的是"载荷在内存中不该长时间明文暴露"的问题。放在解密之后,是因为它依赖解密完成后的Shellcode地址和长度信息。
核心所在:挂载Sleep Hook → 拦截休眠事件 → 执行加密/解密动作
整个Hook机制的触发链:
正常执行流:加载器执行Shellcode → Shellcode内部调用Sleep() →
系统进入休眠 → 休眠结束 → 继续执行
挂载Hook后:加载器执行Shellcode → Shellcode内部调用Sleep() →
被MySleep()拦截 → 先加密内存 → 调用原Sleep() →
休眠结束 → 解密内存 → 返回Shellcode继续执行
Shellcode在休眠期间处于空闲状态,不执行任何指令,但明文代码依然躺在可执行内存里。如果杀软在这时发起内存扫描,明文Shellcode就暴露了。Hook要在系统进入空闲期之前,先把内存"打扫干净"。
挂载操作:
使用Detours库,核心代码三行:
DetourTransactionBegin();
DetourUpdateThread(GetCurrentThread());
DetourAttach(&(PVOID&)OldSleep, MySleep);
DetourTransactionCommit();
为什么选择Sleep作为Hook点?
因为它是"可预测的空闲信号"。很多Shellcode在完成主任务后会进入循环,循环中调用Sleep降低CPU占用。Sleep被调用的时机,恰好是Shellcode"无事可做"的时段,且时长足够完成加密+权限修改操作。如果Hook点选在执行密集的API上,加密开销可能干扰Shellcode的正常逻辑。
挂载时机:
Hook必须在Shellcode解密并进入可执行状态后才能安装。只有解密完成后,全局变量g_pShellcode和g_dwShellcodeSize才有有效值。
解密 → 修改权限 → 保存全局变量 → 安装Hook → 触发执行
4. 内存加解密技术
定位说明:
内存加密是配合Hook使用的执行层保护手段,把Shellcode的明文时间窗口压缩到执行瞬间。在大多数时间里,Shellcode在内存中以密文形式存在。
与Hook的协作关系:
Hook是"触发器",内存加密是"执行器"。
Hook捕获Sleep调用 → 进入MySleep → 调用内存加密 → 权限降级 →
执行原Sleep(此时内存已加密) → 休眠结束 → 解除加密 → 恢复权限
核心所在:加密时机与权限变更
cpp
// 进入休眠前
VirtualProtect(g_pShellcode, g_dwShellcodeSize, PAGE_READWRITE, &old);
memory_crypt(g_pShellcode, g_dwShellcodeSize, AES_ENCRYPT);
VirtualProtect(g_pShellcode, g_dwShellcodeSize, PAGE_READWRITE, &old);
OldSleep(dwMilliseconds);
// 休眠结束后
VirtualProtect(g_pShellcode, g_dwShellcodeSize, PAGE_READWRITE, &old);
memory_crypt(g_pShellcode, g_dwShellcodeSize, AES_DECRYPT);
VirtualProtect(g_pShellcode, g_dwShellcodeSize, PAGE_EXECUTE_READWRITE, &old);
三个关键点:
一、休眠期间加密,休眠结束还原
明文Shellcode只在执行前瞬间和执行中存在,其余时间(包括整个休眠周期)都是密文。杀软的间歇性扫描撞上明文窗口的概率被降至最低。
二、权限同步变更
加密的同时配合内存权限降级,从可执行降为普通读写。即使杀软扫描到这段内存,也不会因为"可执行内存中存在代码"而触发告警。
三、加密范围的选择
直接对整块Shellcode内存做全量加密,不需要区分代码段和数据段。代价是加密/解密时间与Shellcode大小成正比。但Shellcode通常在几百KB以内,Sleep执行间隔是秒级,开销可接受。
三、总结:单点突破与系统设计的区别
从反沙箱检测到内存加密,这个加载器的每一项技术都在回应同一个核心问题:如何让杀软的检测逻辑无法识别我的载荷。
免杀对抗中,单点技术很难长期有效。XOR加密、API混淆、加壳工具——任何一个"单一手段"被研究透之后,杀软都能针对性地提取特征。真正让载荷保持有效周期的,不是某一个技巧有多强,而是整套流程是否自洽。
如果把每项技术单独拆开看,它们都很常见,甚至可以说是免杀领域的"标配":
反调试检测
AES加密
Sleep Hook
内存权限修改
但这些技术组合在一起,就形成了一条有依赖关系的链路:前置检测保护解密环境 → AES消除静态特征 → Sleep Hook缩小明文窗口 → 内存加密降低扫描命中率。 前一步的不足,由后一步来弥补;后一步的有效性,依赖前一步的完成质量。
这个设计本身没有使用任何"黑科技"或未公开技术。它的价值不在于某段代码有多巧妙,而在于把已知技术组合成一套可验证的防御体系——这是我理解的"免杀对抗"的真正含义。
————————————————
版权声明:本文为CSDN博主「万奈&星尘」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:5cdK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6T1L8r3!0Y4i4K6u0W2j5%4y4V1L8W2)9J5k6h3&6W2N6q4)9J5c8U0t1#2x3o6u0Q4y4h3j5&6x3U0M7H3x3U0b7@1y4g2)9J5c8X3q4J5N6r3W2U0L8r3g2Q4x3V1k6V1k6i4c8S2K9h3I4K6i4K6u0r3x3e0j5K6y4U0l9@1y4K6j5J5