大家好我是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)的,均视为
...(已截断)
---
来源: 看雪论坛
原文链接: https://bbs.kanxue.com/thread-290935.htm
[原创]基于LLVM的通用自包含化(Shellcode)编译器开发思路
384 浏览
22 回复
Xierluo
大佬TQL,想问问是在哪个位置操作LLVM以实现 从Entry函数DFS到所有可达函数的功能的,好像从PipelineStartEP操作Module的话,在跨Module调用的函数情况下没法很好的完成 ...
runOnModule 里面收集有标注的Entry函数就行,DFS那里走了一个去重防止重复收集,然后我没有用新的pass管理器但是你跨模块的情况不管什么EP都管不了,本身语义上就是外部调用,要做的化只能合并模块了,跨 Module 的函数在当前 Module 里是 declaration的会被条件过滤掉的。
大佬TQL,想问问是在哪个位置操作LLVM以实现 从Entry函数DFS到所有可达函数的功能的,好像从PipelineStartEP操作Module的话,在跨Module调用的函数情况下没法很好的完成 ...
runOnModule 里面收集有标注的Entry函数就行,DFS那里走了一个去重防止重复收集,然后我没有用新的pass管理器但是你跨模块的情况不管什么EP都管不了,本身语义上就是外部调用,要做的化只能合并模块了,跨 Module 的函数在当前 Module 里是 declaration的会被条件过滤掉的。
大佬TQL,想问问是在哪个位置操作LLVM以实现 从Entry函数DFS到所有可达函数的功能的,好像从PipelineStartEP操作Module的话,在跨Module调用的函数情况下没法很好的完成这个功能
感谢分享
TQL
qj111111
你把事情搞复杂了,不如这项目,基于coff实现
c15K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6V1j5h3&6A6k6h3I4Z5k6h3&6J5P5h3#2S2L8Y4c8A6L8r3I4S2i4K6u0r3M7$3S2W2L8r3I4U0L8$3c8W2i4K6u0V1k6X3q4U0N6r3!0J5P5b7`.`.
简洁优雅,我基于这个项目实现了分段 ...
我之前写的工具比这个好用,直接把对应的文件拖到工具图标上剪切板直接生成混淆过后的C数组模版的shellcode 并且自动生成bin 文件
你把事情搞复杂了,不如这项目,基于coff实现
c15K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6V1j5h3&6A6k6h3I4Z5k6h3&6J5P5h3#2S2L8Y4c8A6L8r3I4S2i4K6u0r3M7$3S2W2L8r3I4U0L8$3c8W2i4K6u0V1k6X3q4U0N6r3!0J5P5b7`.`.
简洁优雅,我基于这个项目实现了分段 ...
我之前写的工具比这个好用,直接把对应的文件拖到工具图标上剪切板直接生成混淆过后的C数组模版的shellcode 并且自动生成bin 文件
qj111111
你把事情搞复杂了,不如这项目,基于coff实现
56aK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6V1j5h3&6A6k6h3I4Z5k6h3&6J5P5h3#2S2L8Y4c8A6L8r3I4S2i4K6u0r3M7$3S2W2L8r3I4U0L8$3c8W2i4K6u0V1k6X3q4U0N6r3!0J5P5b7`.`.
简洁优雅,我基于这个项目实现了分段 ...
汇编谁都会写,我用C你用汇编可以比比看谁的效率高些。
你把事情搞复杂了,不如这项目,基于coff实现
56aK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6V1j5h3&6A6k6h3I4Z5k6h3&6J5P5h3#2S2L8Y4c8A6L8r3I4S2i4K6u0r3M7$3S2W2L8r3I4U0L8$3c8W2i4K6u0V1k6X3q4U0N6r3!0J5P5b7`.`.
简洁优雅,我基于这个项目实现了分段 ...
汇编谁都会写,我用C你用汇编可以比比看谁的效率高些。
你把事情搞复杂了,不如这项目,基于coff实现
49bK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6V1j5h3&6A6k6h3I4Z5k6h3&6J5P5h3#2S2L8Y4c8A6L8r3I4S2i4K6u0r3M7$3S2W2L8r3I4U0L8$3c8W2i4K6u0V1k6X3q4U0N6r3!0J5P5b7`.`.
简洁优雅,我基于这个项目实现了分段加载上线,远程投递
49bK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6V1j5h3&6A6k6h3I4Z5k6h3&6J5P5h3#2S2L8Y4c8A6L8r3I4S2i4K6u0r3M7$3S2W2L8r3I4U0L8$3c8W2i4K6u0V1k6X3q4U0N6r3!0J5P5b7`.`.
简洁优雅,我基于这个项目实现了分段加载上线,远程投递