最近刷到砍砍开源的 AssppWeb,里面用 libcurl.js 把 HTTPS fetch 加密再转发,这样ZeroTrust的设计真是很有意思啊。

那既然这样,AltStore 能不能也丢到浏览器里跑?这样可是解决了一个大难题啊

简单查了一圈,发现真正难的是 “anisette” 凭据生成。

anisette是什么?苹果为了验证请求发送者是一个真实的系统,在各种平台上封装了一系列库,使用系统调用来判断系统真实性,并生成一个otp用来发送。

哼哼,那这就到我们网安小子最喜欢的地方了,你用系统调用那我反过来hook掉你的调用不就好了~

普遍的anisette做法是从 Apple Music 里抠两个 so,然后本地加载+hook一些函数来做票据。问题来了:浏览器端怎么搞?

这时候就想到 unicorn。翻 issue 发现官方还没做 wasm 支持(说白了就是 qemu‑tci 没接上),但某个 PR 的评论里有人提到这个分支:

https://github.com/petabyt/unicorn/tree/tci-emscripten

那看起来是有人实现过了!

起初还想用 unicorn.js 的维护版,结果 SIMD 指令不全、版本又老,直接劝退。

折腾到后面我发现:JS 对接 C 原生真是邪门,错误一堆一堆,于是决定——全部用 Rust 写,暴露几个简单又不易出错的接口。底层逻辑参考了 pyprovision-uc。(还真是个抖M呢)

Rust + wasm 移植的第一步很朴素:先写一个正常的库,example 里调用一下,确认本地能跑通。事实证明,传说中的“AI 会写 Rust”是真的。

到了 “Rust + unicorn + wasm”,主要卡在两个点:

  1. unicorn 默认 Rust binding 会把本地的 libunicorn.o 打包进去,但我们要的是 wasm,ELF 的 o 显然不适合。
  2. unicorn 的 wasm 产物怎么打包?比如 libunicorn.a?

最后发现 emscripten 给了 emcmake 这种工具,能把中间产物转成 wasm 虚拟机能用的东西。并且看上去完全兼容原来的编译+链接方法

大致这样配:

Python
emcmake cmake .. \  -DCMAKE_BUILD_TYPE=Release \  -DBUILD_SHARED_LIBS=OFF \  -DUNICORN_BUILD_TESTS=OFF \  -DUNICORN_INSTALL=OFF \  -DUNICORN_LEGACY_STATIC_ARCHIVE=ON \  -DUNICORN_INTERPRETER=ON \  -DUNICORN_ARCH="arm;aarch64" \  -DCMAKE_C_COMPILER=emcc

但 CMakeLists 并没有支持 emcc,需要补 patch:

Diff
diff --git a/CMakeLists.txt b/CMakeLists.txtindex e6b60aa8..03501788 100644--- a/CMakeLists.txt+++ b/CMakeLists.txt@@ -284,6 +284,11 @@ else()                 set(UNICORN_TARGET_ARCH "loongarch64")                 break()             endif()+            string(FIND ${UC_COMPILER_MACRO} "__wasm__" UC_RET)+            if (${UC_RET} GREATER "0")+                set(UNICORN_TARGET_ARCH "tci")+                break()+            endif()             message(FATAL_ERROR "Unknown host compiler: ${CMAKE_C_COMPILER}.")         endwhile(TRUE)     endif()@@ -395,10 +400,15 @@ else()     else()         set(UNICORN_EXECUTION_MODE "--disable-interpreter")     endif()+    if(EMSCRIPTEN)+        set(EXTRA_EXTRA_CFLAGS --cpu=i386 --disable-stack-protector)+    endif()+    set (EXTRA_CFLAGS "${EXTRA_CFLAGS} -g")     execute_process(COMMAND sh ${CMAKE_CURRENT_SOURCE_DIR}/qemu/configure         --cc=${CMAKE_C_COMPILER}         ${EXTRA_CFLAGS}         ${TARGET_LIST}+        ${EXTRA_EXTRA_CFLAGS}         ${UNICORN_EXECUTION_MODE}         WORKING_DIRECTORY ${CMAKE_BINARY_DIR}     )@@ -508,8 +518,8 @@ else()         -D_GNU_SOURCE         -D_FILE_OFFSET_BITS=64         -D_LARGEFILE_SOURCE-        -Wall-        -fPIC+        -Wall -O2+        -fPIC -fpic -fvisibility=hidden     )     if (ATOMIC_LINKAGE_FIX)         add_compile_options(

qemu/configure 也得补一下:

Diff
diff --git a/qemu/configure b/qemu/configureindex 30854676..378ffecb 100755--- a/qemu/configure+++ b/qemu/configure@@ -5,6 +5,7 @@ # Unset some variables known to interfere with behavior of common tools, # just as autoconf does.+set -x CLICOLOR_FORCE= GREP_OPTIONS= unset CLICOLOR_FORCE GREP_OPTIONS@@ -402,6 +403,8 @@ EOF if check_define __linux__ ; then   targetos="Linux"+elif check_define __wasm__ ; then+  targetos='Linux' elif check_define _WIN32 ; then   targetos='MINGW32' elif check_define __OpenBSD__ ; then

接下来就是“正常逻辑先跑通,再换 wasm target”。在 wasm 里几乎要 hook 掉所有 syscall(其实是 libc call 或类似接口)去伪造环境。我这边是在 got.plt 的跳转点加 code_hook,再按符号名分发:

Rust
for i in 0..IMPORT_LIBRARY_COUNT {            let base = IMPORT_ADDRESS + (i as u64) * IMPORT_LIBRARY_STRIDE;            uc.mem_map(base, as_usize(IMPORT_SIZE)?, Permission::ALL)?;            let mut stubs = vec![0_u8; IMPORT_SIZE as usize];            for chunk in stubs.chunks_mut(4) {                chunk.copy_from_slice(&RET_AARCH64);            }            uc.mem_write(base, &stubs)?;            uc.add_code_hook(base, base + IMPORT_SIZE - 1, |uc, address, _| {                if let Err(err) = dispatch_import_stub(uc, address) {                    debug_print(format!("import hook failed at 0x{address:X}: {err}"));                    let _ = uc.emu_stop();                }            })?;}pub fn dispatch_import_stub(uc: &mut Unicorn<'_, RuntimeState>, address: u64) -> Result<(), VmError> {  if address < IMPORT_ADDRESS {    return Err(VmError::InvalidImportAddress(address));  }  let offset = address - IMPORT_ADDRESS;  let library_index = (offset / IMPORT_LIBRARY_STRIDE) as usize;  let symbol_index = ((offset % IMPORT_LIBRARY_STRIDE) / 4) as usize;  let symbol_name = {    let state = uc.get_data();    let library = state      .loaded_libraries      .get(library_index)      .ok_or(VmError::LibraryNotLoaded(library_index))?;    let symbol = library      .symbols      .get(symbol_index)      .ok_or_else(|| VmError::SymbolIndexOutOfRange {        library: library.name.clone(),        index: symbol_index,      })?;    symbol.name.clone()  };  handle_stub_by_name(uc, &symbol_name)}fn handle_stub_by_name(  uc: &mut Unicorn<'_, RuntimeState>,  symbol_name: &str,) -> Result<(), VmError> {  match symbol_name {    "malloc" => stub_malloc(uc),    "free" => stub_free(uc),    "strncpy" => stub_strncpy(uc),    "mkdir" => stub_mkdir(uc),    "umask" => stub_umask(uc),    "chmod" => stub_chmod(uc),    "lstat" => stub_lstat(uc),    "fstat" => stub_fstat(uc),    "open" => stub_open(uc),    "ftruncate" => stub_ftruncate(uc),    "read" => stub_read(uc),    "write" => stub_write(uc),    "close" => stub_close(uc),    "dlopen" => stub_dlopen(uc),    "dlsym" => stub_dlsym(uc),    "dlclose" => stub_dlclose(uc),    "pthread_once" => stub_return_zero(uc),    "pthread_create" => stub_return_zero(uc),    "pthread_mutex_lock" => stub_return_zero(uc),    "pthread_rwlock_unlock" => stub_return_zero(uc),    "pthread_rwlock_destroy" => stub_return_zero(uc),    "pthread_rwlock_wrlock" => stub_return_zero(uc),    "pthread_rwlock_init" => stub_return_zero(uc),    "pthread_mutex_unlock" => stub_return_zero(uc),    "pthread_rwlock_rdlock" => stub_return_zero(uc),    "gettimeofday" => stub_gettimeofday(uc),    "__errno" => stub_errno_location(uc),    "__system_property_get" => stub_system_property_get(uc),    "arc4random" => stub_arc4random(uc),    other => {      debug_print(other);      Err(VmError::UnhandledImport(other.to_string()))    }  }}

Hook 出来之后,剩下就是“按需补 stub”。

顺带一提:hook 里只有时间是变量,如果时间不变,生成的 OTP 也会稳定一致。

另外,cargo 编译这块我用 .cargo/pkg-config.toml 强行指定链接地址:

TOML
[env]PKG_CONFIG_ALLOW_CROSS = "1"PKG_CONFIG_PATH = { value = ".cargo/pkgconfig", relative = true }[target.wasm32-unknown-emscripten]rustflags = [  "-C",  "link-arg=--no-entry",  "-C",  "link-arg=-sSTANDALONE_WASM=1",  "-C",  "link-arg=-sALLOW_MEMORY_GROWTH=1",  "-C",  "link-arg=-sINITIAL_MEMORY=268435456",]
TOML
prefix=${pcfiledir}/../../../unicornexec_prefix=${prefix}libdir=${exec_prefix}/buildincludedir=${exec_prefix}/includeName: unicornDescription: Unicorn engine static library (wasm build)Version: 2.1.1Libs: -L${libdir} -lunicorn -lunicorn-common -laarch64-softmmu -larm-softmmuCflags: -I${includedir}

经过一天愉快的调试,本地rust跑通了!

接下来就build emscripten runtime的wasm了!

然后就遇到了程序员经典玄学问题之:为什么它能跑?为什么它不能跑?是 wasm 环境里“同样代码跑不通”。先确认一个前提:问题不在 unicorn 本体上,只可能是 wasm 相关改动。于是用对拍 + 手动 hook register 的方式确认逻辑能完整跑通,然后再把这些调试逻辑清理掉。

做法也挺朴素:固定所有输入(时间 + device token),每 1k 次输出一次寄存器快照,用脚本找差异,缩小区间。原来这就是 Baby Step Giant Step 对拍啊 XD。

第一个坑是 UMULH:应该做 (x1 * x2) >> 64,结果不对。把问题丢给 codex 读了一通,发现是 TCI 的 wasm ABI helper 参数截断。修掉后 UMULH 正常。

第二个坑在内存读取:某些地址读不对。于是我在 Python 版和 Rust wasm 版同一位置加 code hook / mem read hook,定位读地址,再回溯到上一次写地址,dump 一下内存,问题就清楚了。

最后发现是 mmap 的 wasm 实现用了 aligned_alloc,但和原版语义不一致:

C
size_t total = size + align - getpagesize();return aligned_alloc(align, size);

没考虑 round-up,于是改成这样:

C
if (!is_power_of_2(align)) return NULL;size_t rounded = (size + align - 1) & ~(align - 1);void *p = aligned_alloc(align, rounded);return p;

https://github.com/lbr77/unicorn/commit/4de7c20ba8a7119405b3e0e8b82862efc70bda45#diff-842456abe9564ae1e7d75ab8f322be6c27ca3c512e445a18e5898dea68ad9799

最后修改后的代码在这里,方便参考:

https://github.com/lbr77/anisette-js/

后面的签名部分还没完全调通,等我折腾完了再更新ww。