最近刷到砍砍开源的 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”,主要卡在两个点:
- unicorn 默认 Rust binding 会把本地的 libunicorn.o 打包进去,但我们要的是 wasm,ELF 的 o 显然不适合。
- unicorn 的 wasm 产物怎么打包?比如 libunicorn.a?
最后发现 emscripten 给了 emcmake 这种工具,能把中间产物转成 wasm 虚拟机能用的东西。并且看上去完全兼容原来的编译+链接方法
大致这样配:
但 CMakeLists 并没有支持 emcc,需要补 patch:
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 --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,再按符号名分发:
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 强行指定链接地址:
[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",]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,但和原版语义不一致:
size_t total = size + align - getpagesize();return aligned_alloc(align, size);没考虑 round-up,于是改成这样:
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/anisette-js/
后面的签名部分还没完全调通,等我折腾完了再更新ww。