【破文作者】tree_fly/P.Y.G 【作者邮箱】[email protected] 【作者主页】itreefly.com 【破解工具】Hopper Disassembler; Python 3; OpenSSL ctypes; mkcert 【破解平台】macOS(x86_64) 【软件名称】AnyGo.app 【原版下载】https://itoolab.com/ 【保护方式】本地 RSA 签名验证 + 在线接口二次校验 【破解声明】请勿商用;本文仅做研究所用。
前言: 国行 Apple Watch 的 一些房颤检测、睡眠呼吸监测等功能的开通,要用到更改Location的工具,网络上较多推荐这款App,端午空闲逆之,然常有“卧槽”的声音一直伴耳,遂分享之。
0x1、整体架构先摸清楚 使用Hopper Demo分析一波,收集一些关键信息。AnyGo 的注册保护分两层:
用户输入 email + 注册码 │ ▼ ┌─────────────────────────┐ │ 本地验证(离线) │ ← libsnvrfy.dylib / _snvrfy() │ RSA 签名 + Base32 解码 │ └─────────────┬───────────┘ │ 成功 ▼ ┌─────────────────────────┐ │ 在线验证(联网) │ ← order.luckydogsoft.com/api/verification │ POST email+code 到服务器 │ └─────────────┬───────────┘ │ 成功 ▼ 注册完成,isRegister = 1
PS:frida方案hook isRegister = 1 ,感兴趣的可以写一写;
0x2、本地验证逆向:_snvrfy 是怎么工作的 2.1 找到入口 Hopper 反编译 RegisterManager 类,很快找到 registerEmail:code: 方法。整体流程并不复杂,以下伪代码修剪了一些干扰。
-(char )registerEmail:(NSString *)email code:(NSString *)code { email = [self checkEmailAndRegisterCode:email]; code = [self checkEmailAndRegisterCode:code]; if ([email length] == 0 || [code length] == 0 ) return NO; ret = snvrfy([email UTF8String], [code UTF8String], &output_buf); if (ret != 0 ) return NO; result = [GlobalFunction decodeJosnString: [NSString stringWithUTF8String:output_buf]]; if ([result[@"code" ] intValue] != 0 ) return NO; product_id = [result[@"data" ][@"product_id" ] intValue]; if (product_id == 0x11 || product_id == 0x30 ) { [self setRegSN:code]; [self setRegEmail:email]; regInfo = [[NSString stringWithFormat:@"%@\n%@" , email, code] base64Encoding]; [[NSUserDefaults standardUserDefaults] setValue:regInfo forKey:@"regInfo" ]; [[NSUserDefaults standardUserDefaults] synchronize]; [self setIsRegister:YES]; return YES; } return NO; }
snvrfy 从 libsnvrfy.dylib 动态链接进来,是本地验证的核心。返回 0 表示成功,非 0 表示失败;验证结果 JSON 写入第三个参数 output_buf。
2.2 snvrfy 的完整算法 继续把 libsnvrfy.dylib 丢进 Hopper,_snvrfy 的伪代码大概是这样:
int _snvrfy(int arg0 , int arg1 , int arg2 ) { memcpy (&var_80, "-----BEGIN PUBLIC KEY-----\n" "MCwwDQYJKoZIhvcNAQEBBQADGwAwGAIRAMHFWywkLO5vdQpvM0UXlrsCAwEAAQ==\n" "-----END PUBLIC KEY-----\n" , 0x76 ); var_140 = (strlen (arg1) <= 0x1e ) ? strlen (arg1) : 0x1e ; for (i = 0 ; i < var_140; i++) { if (arg1[i] != '-' ) stripped[var_138++] = arg1[i]; } rax = _base32_decode(stripped, decoded_buf, 0x20 ); if (rax != 0x10 ) { sprintf (output, "{\"code\": -1, \"message\": \"decode length not equal encode length\"}" ); return -1 ; } rax = _public_decrypt(decoded_buf, 0x10 , &pubkey_pem, plaintext); switch (rax) { case 12 : SHA1(arg0, strlen (arg0), sha1_buf); if (strncmp (plaintext, sha1_buf, 4 ) == 0 ) { sprintf (output, "{\"code\": 0, \"data\": {\"product_id\": %d, " "\"month_limit\": %d, \"pc_limit\": %d, \"device_limit\": %d}, " "\"message\": \"success\"}" , *(uint16_t *)(plaintext+4 ), *(uint16_t *)(plaintext+6 ), *(uint16_t *)(plaintext+8 ), *(uint16_t *)(plaintext+10 )); return 0 ; } sprintf (output, "{\"code\": -1, \"message\": \"unknown\"}" ); return -1 ; default : sprintf (output, "{\"code\": -1, \"message\": \"decrypted length not right\"}" ); return -1 ; } }
解密成功后,12 字节明文的结构:
偏移
长度
含义
0–3
4
SHA1(email) 前 4 字节(邮箱绑定)
4–5
2
product_id(LE uint16)
6–7
2
month_limit(LE uint16)
8–9
2
pc_limit(LE uint16)
10–11
2
device_limit(LE uint16)
收集到的关键参数:
RSA:128-bit 模数,公钥指数 e = 65537
Padding:RSA_X931_PADDING(OpenSSL 常量 = 5 )
公钥:PKCS#8 PEM,硬编码 在 libsnvrfy.dylib 栈帧里
2.3 把公钥提取出来 strings /Applications/AnyGo.app/Contents/MacOS/libsnvrfy.dylib \ | grep -A2 "BEGIN PUBLIC KEY"
完整显示出来,不过好久没看到这么短的了:
-----BEGIN PUBLIC KEY----- MCwwDQYJKoZIhvcNAQEBBQADGwAwGAIRAMHFWywkLO5vdQpvM0UXlrsCAwEAAQ== -----END PUBLIC KEY-----
Base64 解码这段 DER,模数 n 是 128-bit(16 字节),藏在 0x02 0x11 0x00 后面:
n = 257565734864128986511771360560061847227 = 0xc1c55b2c242cee6f750a6f33451796bb
等一下,等一下,大兄弟,RSA还在用模数n = 128-bit吗?
2.4 FactorDB 直接查表 128-bit RSA 在现代计算机上是秒级的事,AnyGo 这个模数在 FactorDB 收录了,打开浏览器就能看到因子:
参数
值
n
257565734864128986511771360560061847227
p
13995547319714966219
q
18403405667551598033
e
65537
d
238642393646636866457122714384779211329
0x3、破解策略:直接用原始私钥 既然私钥+算法都拿到,就没必要patch公钥来修改 dylib 了,直接keygen:
def generate_code ( email: str , rsa_priv, crypto, product_id: int = 17 , month_limit: int = 0 , pc_limit: int = 0 , device_limit: int = 0 , ) -> str : sha1 = hashlib.sha1(email.encode()).digest() plain = bytearray (12 ) plain[0 :4 ] = sha1[0 :4 ] struct.pack_into("<H" , plain, 4 , product_id) struct.pack_into("<H" , plain, 6 , month_limit) struct.pack_into("<H" , plain, 8 , pc_limit) struct.pack_into("<H" , plain, 10 , device_limit) rsa_size = crypto.RSA_size(rsa_priv) out_buf = ctypes.create_string_buffer(rsa_size) ret = crypto.RSA_private_encrypt( len (plain), bytes (plain), out_buf, rsa_priv, RSA_X931_PADDING ) if ret != rsa_size: raise RuntimeError(f"RSA_private rsa_size error" ) code = base64.b32encode(bytes (out_buf[:rsa_size])).rstrip(b"=" ).decode() return format_serial(code)
AnyGo 是 x86_64 binary,在 Apple Silicon 上运行于 Rosetta;系统默认 Python 是 arm64,ctypes 加载 x86_64 的 libcrypto.1.1.dylib 时架构冲突。只好强制以 x86_64 运行 Python,同时注入库路径,然后一条命令生成注册码:
DYLD_LIBRARY_PATH=/Applications/AnyGo.app/Contents/MacOS \ arch -x86_64 /usr/bin/python3 keygen_original.py "[email protected] "
0x4、在线验证绕过 本地验证搞定了,注册码传回服务器验证是否合法,接下来要搞定网络验证了。
4.1 在线注册验证接口 Hopper通过关键字符串查找,可以看到 POST 请求是这样构建的:
NSString *signRaw = [NSString stringWithFormat: @"code=%@&email=%@&uuid=%@&vi=!?*@luckydogsoft2019" , regCode, email, uuid]; NSString *v = [self getMD5FromString:signRaw];NSString *body = [NSString stringWithFormat: @"code=%@&email=%@&uuid=%@&v=%@" , regCode, email, uuid, v]; NSURL *url = [NSURL URLWithString: @"https://order.luckydogsoft.com/api/verification" ];
Secret vi: !?*@luckydogsoft2019 明文存在二进制里。
4.2 Mock Server 快速验证的方法常用Charles对这个api直接map一份JSON的response。 现在有了AI的加持,只要动动小手就写好了源码:
class MockHandler (BaseHTTPRequestHandler ): def do_POST (self ): path = urlparse(self .path).path if path == "/api/verification" : self .send_json({ "error_code" : 0 , "msg" : "success" , "data" : {"product_id" : 17 , "days_left" : 36500 , "status" : 1 } }) elif path in ("/api/bluetooth/certify" , "/api/bluetooth/enqueue" ): self .send_json({"error_code" : 0 , "msg" : "success" , "data" : {}})
部署步骤:
JAVA_HOME="" mkcert order.luckydogsoft.com echo "127.0.0.1 order.luckydogsoft.com" | sudo tee -a /etc/hostssudo python3 mock_server.py
重启AnyGo,输入注册码,点击注册即可完成注册!
0x5、终极方案:直接写 plist 前面花了不少时间逆向 RSA、生成注册码、搭 mock server,但继续研究注册信息如何持久化,发现其实有个更直接的路。
5.1 注册状态存在哪里 AnyGo 是非沙盒应用,NSUserDefaults 配置内容会写入文件:
~/Library/Preferences/com.itoolab.AnyGo.plist
启动时从 regInfo 这个 key 恢复注册状态,注销时删的也是它:
[rax removeObjectForKey:@"regInfo" ]; [self setIsRegister:0x0 ]; [self setRegEmail:0x0 ]; [self setRegSN:0x0 ];
5.2 regInfo 是什么格式 继续分析代码,发现注册成功时 app 做的事情是:
rax = [NSString stringWithFormat:@"%@\n%@" , email, serial]; r15 = [[rax base64Encoding] retain]; [[NSUserDefaults standardUserDefaults] setValue:r15 forKey:@"regInfo" ]; [[NSUserDefaults standardUserDefaults] synchronize]; [var_58 setIsRegister:0x1 ];
就是把邮箱和注册码拼一拼,Base64 一下,没有 MAC,没有 HMAC,没有签名回环校验,没有硬件绑定,没有 Keychain等:
5.3 写 plist 的正确姿势 只要 plist 里有合法的 regInfo,app 重启后就直接恢复 isRegister = 1,不用走注册界面,不用调 snvrfy。
有一个坑要特别注意:修改 plist 文件后要更新缓存 。
macOS中,NSUserDefaults 通过 cfprefsd 守护进程中转,该进程在内存里缓存各 domain 的偏好数据。直接修改plist,cfprefsd 察觉不到——app 读到的还是 daemon 的内存缓存。
所以先关闭App,执行修改plist,再杀掉 cfprefsd 强制刷盘
killall -u "$USER " cfprefsd
cfprefsd 会自动重启,重启后从磁盘重新载入刚才写入的 plist。
验证一下是否写入成功
defaults read com.itoolab.AnyGo regInfo | base64 -d
0x6、踩坑记录 不出意外要出意外了,来看看踩了哪些坑。
坑 1:注册码长度有硬限制 现象: snvrfy 一直返回 "decode length not equal encode length"
找到根因: snvrfy 对输入最多处理 30 字符(含破折号),超过就截断:
if (strlen (var_128) <= 0x1e ) { var_170 = strlen (var_128); } else { var_170 = 0x1e ; } for (var_148 = 0x0 ; var_148 < var_140; var_148++) { if (arg1[var_148] != 0x2d ) stripped[var_138++] = arg1[var_148]; }
Base32 本体固定 26 字符 (12 字节明文 → 16 字节 RSA 块 → Base32),破折号只能加 2 个 ,总长 28,才不会触发截断。多放一个破折号,最后一个 Base32 字符就会被截掉,解码只得 15 字节而不是 16 字节。
格式: 我喜欢按 9-9-8 插入 2 个破折号:
def format_serial (code: str ) -> str : return f"{code[:9 ]} -{code[9 :18 ]} -{code[18 :]} "
坑 2:product_id 用错了 现象: snvrfy 返回 {"code": 0, ...},本地验证已经过了,但注册界面还是失败。
找到根因: 翻 Hopper,发现有两个不同的注册方法:
方法
用途
product_id 要求
registerGoCatcherEmail:code:
Go Catcher 设备注册
必须 0xB1 = 177
registerEmail:code:(真正的入口)
主程序注册
必须 0x11 = 17 或 0x30 = 48
我一开始看到有个 0xB1 = 177,以为这就是 product_id,直接拿来用了。结果这是 Go Catcher 设备专用的,结果存到 regInfoGoCatcher 里,跟主程序注册完全是两条路。
registerEmail:code: 里的判断逻辑是这样的:
if (r14 != 0x30 ) { if (r14 == 0x11 ) { [var_58 setRegSN:r13]; [var_58 setRegEmail:var_60]; [rax setValue:r15 forKey:@"regInfo" ]; [var_58 setIsRegister:0x1 ]; rax = 0x1 ; } else { [var_58 setIsRegister:0x0 ]; rax = 0x0 ; } } else { [var_58 setIsRegister:0x1 ]; rax = 0x1 ; }
修法: generate_code() 的 product_id 参数从 177 改成 17。
坑 3:架构冲突(Apple Silicon) 现象: OSError: incompatible architecture (have 'x86_64', need 'arm64')
根因: AnyGo 是 x86_64 binary,Apple Silicon 上跑 Rosetta;系统 Python 是 arm64,ctypes 加载 x86_64 的 libcrypto.1.1.dylib 时架构冲突。
修法: 强制 x86_64 跑 Python,同时注入库路径:
DYLD_LIBRARY_PATH=/Applications/AnyGo.app/Contents/MacOS \ arch -x86_64 /usr/bin/python3 keygen_original.py "[email protected] "
0x7、最后说一句 这套保护用了 128-bit RSA——2026 年了,这个数字挺出乎意料的。更出乎意料的是:这套精心设计的 RSA 保护,最终根本没有被用到 。注册成功后,app 做的事情就是更新下NSUserDefaults。
开发者在入口处设了一把锁,却把钥匙的副本用纸包好放在门口的花盆下面。
0x8、懒人代码包 上面的废话太烦人,下面才是你要的。把脚本存成文件,然后执行:
Step 1: 新建 anygo_reg.sh,粘贴以下内容:
#!/usr/bin/env bash set -euo pipefailREGINFO="dHJlZV9mbHlAY2hpbmFweWcuY29tCkhBSEVPQlRFUi1EMk1GUlMySkEtQlNHVERSUjQ=" if ! grep -qE '127\.0\.0\.1[[:space:]]+order\.luckydogsoft\.com' /etc/hosts; then echo -e "127.0.0.1\torder.luckydogsoft.com" | sudo tee -a /etc/hosts > /dev/null fi osascript -e 'quit app "AnyGo"' 2>/dev/null || true sleep 1defaults write com.itoolab.AnyGo regInfo "$REGINFO " killall -u "$USER " cfprefsd 2>/dev/null || true sleep 1defaults read com.itoolab.AnyGo regInfo | base64 -d open -a AnyGo
Step 2: 赋予执行权限:
Step 3: 运行:
或者,懒得建文件,一行调用也行:
REGINFO="dHJlZV9mbHlAY2hpbmFweWcuY29tCkhBSEVPQlRFUi1EMk1GUlMySkEtQlNHVERSUjQ=" bash -c ' grep -qE ' 127\.0\.0\.1[[:space:]]+order\.luckydogsoft\.com' /etc/hosts || echo -e "127.0.0.1\torder.luckydogsoft.com" | sudo tee -a /etc/hosts > /dev/null osascript -e "quit app \"AnyGo\"" 2>/dev/null || true; sleep 1 defaults write com.itoolab.AnyGo regInfo "$REGINFO" killall -u "$USER" cfprefsd 2>/dev/null || true; sleep 1 open -a AnyGo '
tree_fly/P.Y.G
2026-06