大家好我是Teddybe4r,好久不见,这是2026年的第一篇文章,这篇文章旨在帮助想要开发自己shellcode编译器的朋友,为你们提供思路与解决方案。由于文章产物的特殊性本文将不会提供代码,请各位读者在阅读完文章之后自行实现。现在我们步入正题。
在x64系统的时代shellcode的开发变得极富有技巧性,由于x64的调用约定的改变与微软引入的shadow space的技术出现导致masm32汇编在64上部分具有效率的语法糖彻底失效,由于shadow space机制手写x64汇编会变得有些恶心,在去年我开发了一款 分段式加载 的Rootkit,其中很多功能代码都是通过网络传送并且加载到内存运行的,这也让我的RDK开发变得十分繁琐,于是便有了这篇文章的项目。
在现代编译器的环境下我们想要在原生编译器环境下写出shellcode是一个具有技巧的事情,我们要在代码层面抗优化,从譬如 全局变量,数组赋值,数组,连续变量定义,连续变量赋值,swich-case,条件转移语句等等的代码结构上下功夫利用编译期不可知写法才能够有效规避一些具有全局特征的代码优化, 在这之后还需要对抗MSVC的链接优化,以及一些安全检查 譬如 __chkstk 等等。
本文基于 LLVM 实现了一套面向 Shellcode 的自包含编译框架,通过全局变量下沉、上下文透传、调用链重写消除全局与外部依赖,并结合编译期 API 哈希与Runtime动态符号解析,实现无导入表、无外部符号的纯位置无关代码生成,并且生成的Shellcode R3 R0都可以用。
在本文中我们主要分为如下章节:
整体架构流程
全局变量下沉与透传上下文结构化设计
编译期 API 哈希与运行时动态符号解析

在一开始做这个下沉的时候我还踩了一些坑,当时我直接把调用链上每一个函数都下沉了一份到栈上,这其实是错误的因为根据全局变量的语义如果每个函数都持有一份在自己的栈中那么这个变量将会退化为局部变量,在语义上就发生了根本性的变化所以在设计这个结构的时候我们要引入上下文透传机制。
在常规 C 语言程序中,全局变量与静态变量由操作系统加载器统一分配虚拟地址,并在进程启动时完成初始化,其访问依赖固定的全局符号地址与可执行文件的数据段布局。但在纯 Shellcode 执行环境下,程序不具备独立的进程地址空间管理能力、无加载器支持、无数据段权限初始化机制,直接保留全局变量会导致以下问题:全局地址硬编码导致无法位置无关执行、多份 Shellcode 实例间数据冲突、全局符号引入外部依赖破坏自包含性。因此,需要通过全局变量下沉(Global Variable Lowering) 技术,将分散的全局状态统一收拢为结构化上下文,并在调用链中透明传递。并且由于程序结构的特性只有一个Entry,所以我们在Entry的基础栈帧中插入下沉的GV变成CTX的形式在调用链中传播CTX指针这样就可以做到下沉且保持语义不变。
透我归纳为以下8步
函数属性加固:为调用链中所有函数添加 no-builtins、no-stack-arg-probe 等属性,阻止编译器生成外部依赖代码(如 memset、__chkstk),确保上下文透传过程无额外依赖。
下图展示了透传前后的对比,以及透传之后的栈帧结构

ShellcodeCtx 是一个由 Pass 自动生成的 packed 结构体,其字段顺序与 收集到的全局变量顺序严格一致。每个字段的类型直接取自对应全局变量的 getValueType()(即去掉指针层的底层类型)。

在完成全局变量收集与 ShellcodeCtx 类型构造之后,Pass 需要对调用链中每一个函数执行两类不同的处理:Entry 函数保持原有签名不变(loader 侧仍可通过 (void*)entry 取地址),在其入口块插入 alloca 实例化 ctx 并完成内联初始化;非 entry 函数则需要改写签名,在参数列表末尾追加 ShellcodeCtx* 参数,实现指针向下透传。Entry 函数之所以保持签名不变,是因为 loader 侧通常以 (void*)entry 的形式提取 Shellcode 起始地址或计算代码长度,改变签名会破坏这一用法。
下面给出部分透传代码(addCtxParameter)
签名改写的具体实现是在 addCtxParameter() 中完成的:构造新的 FunctionType(在原参数列表末尾追加 ShellcodeCtx*),用 Function::Create() 创建新函数,通过 ValueToValueMapTy 建立旧参数到新参数的映射,最后调用 CloneFunctionInto() 将原函数体完整克隆到新函数中。旧函数在所有调用点重写完成并确认无引用后被 eraseFromParent() 删除。
调用点重写步骤如下: 遍历 fnRemap 映射,找到所有 CallInst 中调用了旧函数的位置,在参数列表末尾追加当前函数的 fnToCtxPtr(entry 传 alloca 地址,非 entry 传接收到的 ctx 参数),用 IRBuilder 构造新的 CallInst 并替换旧指令。
全局变量的访问替换在重写调用点之后进行,然后每个函数的入口块前置缓存 GEP 指令(避免重复生成),将所有对 @g_xx 的直接引用和 ConstantExpr 包裹的间接引用全部替换为 GEP ctxPtr, 0, fieldIdx。
Pass 的入口工作是定位所有 Shellcode 入口函数,随后以此为根节点递归展开完整的调用图。这两步共同决定了后续所有变换的作用域——只有被纳入调用链的函数才会被改写,链外的函数保持不变。
入口函数定位通过扫描 llvm.global.annotations 元数据实现。在 C 源码侧,开发者通过 __attribute__((annotate("shellcode"))) 标注入口函数,Clang 会将该注解以 ConstantStruct 数组的形式写入 IR 中的 llvm.global.annotations 全局变量,Pass 遍历该数组并比对注解字符串即可精确定位入口。

递归收集以 DFS 方式实现:对每个函数遍历其所有基本块内的全部 CallInst,取 getCalledFunction(),若被调函数有函数体(!isDeclaration())且未曾访问,则递归进入。visited 集合(即最终的 chainFuncs)防止环状调用导致无限递归。值得注意的是我们在实现调用链分析的时候需要检测函数指针逃逸
函数指针逃逸检测是非常重要的安全机制:由于 Pass 要改写所有非 entry 函数的签名,如果某函数的地址在改写前已被存入变量(store)或作为参数传给外部回调(call @qsort),那么运行时调用时签名不匹配会直接导致崩溃。
检测逻辑遍历每个链内函数的所有 Use,凡是出现在 CallInst 的 callee 位置之外的用法,且不属于 LLVM 元数据(llvm.global.annotations / llvm.used)的,均视为逃逸并终止编译。
检测到逃逸后,Pass 会通过 report_fatal_error() 中止整个编译,并打印逃逸点的具体指令与所在函数。
修复方法:将间接调用改写为直接的 switch/if 分派,这里也是Shellcode框架的写法要求。
字节数组分块聚合与内联初始化优化
这里的优化也很重要因为,在把全局变量的初始化数据写入栈上 ShellcodeCtx 时,朴素做法是对每个字节生成一条 store i8——对于 1024 字节的查找表这将产生 1024 条指令,代码体积急剧膨胀,且 LLVM 后端极有可能将密集的 store i8 序列重新合成为 call memset 或 call memcpy,引入外部符号依赖,彻底破坏 Shellcode 的自包含性。且绝对禁止使用 llvm.memset intrinsic。LLVM 后端对超过约 128 字节的 llvm.memset 会展开为 call memset,引入 libc 依赖。即便是 inline 的 intrinsic 形式也存在此风险,因此必须完全绕开。所以我们给出如下的策略


分块聚合的核心逻辑实现:通过 ConstantDataArray::getRawDataValues() 获取原始字节流,按 8 字节边界切分,以小端字节序拼装为 uint64_t 立即数,通过 GEP i8* basePtr, offset 定位目标地址后 bitcast 为 i64*,生成 MaybeAlign(1) 的非对齐 store。这保证了字节精确语义的同时将指令数压缩到 ⌈n/8⌉。
零填充的大数组或大尺寸变量实现:通过 BasicBlock::splitBasicBlock() 手动构造 CFG:将当前插入点之后的指令切分到新块,在中间插入 loop_bb(含 PHI + condBr),后端面对规整的计数器循环有机会优化为 REP STOSQ,最终生成的机器码体积为 O(1),与零填充大小无关。
在这里我们要解决的问题是Shellcode 不具备 PE 导入表,无法通过常规链接器符号机制调用 Windows API。Pass 的最后阶段将调用链中所有对外部函数声明的 CallInst 替换为基于哈希的动态解析模式,消除全部外部符号引用。
整个机制分为两个完全解耦的部分:编译期由 Pass 完成字符串哈希化与 IR 重写;运行时由用户提供的 resolve_api 函数完成 PEB 遍历与符号查找。两者通过一个 64 位哈希值(djb2)对接,Shellcode 中不存在任何 API 名称字符串。这样有个好处就是不论用户将该编译器用于什么环境只要API能通过譬如PEB 或者ssdt表的形式获得就完全能够生成对应环境下的Shellcode, 内核可以通过ssdt,R3通过PEB walker。

IR 重写的具体步骤:扫描函数体内所有调用外部声明函数的 CallInst(跳过 llvm.* intrinsic 与 resolve_api 自身),在每个 call site 前插入 call i64 @resolve_api(i64 hash_imm),将返回的整数通过 IRBuilder::CreateIntToPtr() 转换为原函数指针类型,再构造新的间接 CallInst 替换原有直接调用并删除旧指令。
resolve_api 由用户实现,Pass 仅负责编译期哈希化与 call site 重写,不绑定特定的运行时解析逻辑。用户可根据目标环境选择 PEB walk、自定义 TEB 遍历或其他符号解析方式,只要函数签名为 i64 resolve_api(i64) 即可。
测试代码
编译过程的GV提取以及优化
传播安全知识、拓宽行业人脉——看雪讲师团队等你加入!