手把手教你破解macOS之AnyGo 8.3.x

手把手教你破解macOS之AnyGo 8.3.x

tree_fly

【破文作者】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;

// 本地验证:email + code 传给 snvrfy
ret = snvrfy([email UTF8String], [code UTF8String], &output_buf);
if (ret != 0)
return NO;

// 解析 snvrfy 返回的 JSON
result = [GlobalFunction decodeJosnString:
[NSString stringWithUTF8String:output_buf]];
if ([result[@"code"] intValue] != 0)
return NO;

product_id = [result[@"data"][@"product_id"] intValue];

// product_id 必须是 17 或 48
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;
}

snvrfylibsnvrfy.dylib 动态链接进来,是本地验证的核心。返回 0 表示成功,非 0 表示失败;验证结果 JSON 写入第三个参数 output_buf

2.2 snvrfy 的完整算法

继续把 libsnvrfy.dylib 丢进 Hopper,_snvrfy 的伪代码大概是这样:

int _snvrfy(int arg0 /*email*/, int arg1 /*code*/, int arg2 /*&output_buf*/) {

// ① 公钥
memcpy(&var_80,
"-----BEGIN PUBLIC KEY-----\n"
"MCwwDQYJKoZIhvcNAQEBBQADGwAwGAIRAMHFWywkLO5vdQpvM0UXlrsCAwEAAQ==\n"
"-----END PUBLIC KEY-----\n",
0x76);

// ② 去除破折号,注意:最多只处理 30 个字符(0x1e)!
var_140 = (strlen(arg1) <= 0x1e) ? strlen(arg1) : 0x1e;
for (i = 0; i < var_140; i++) {
if (arg1[i] != '-')
stripped[var_138++] = arg1[i];
}

// ③ Base32 解码
rax = _base32_decode(stripped, decoded_buf, 0x20);
if (rax != 0x10) {
sprintf(output, "{\"code\": -1, \"message\": \"decode length not equal encode length\"}");
return -1;
}

// ④ RSA 公钥解密(X9.31 Padding),得到 12 字节
rax = _public_decrypt(decoded_buf, 0x10, &pubkey_pem, plaintext);
switch (rax) {
case 12:
// ⑤ 校验邮箱绑定:SHA1(email)[0:4] == plaintext[0:4]
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), // product_id LE
*(uint16_t*)(plaintext+6), // month_limit LE
*(uint16_t*)(plaintext+8), // pc_limit LE
*(uint16_t*)(plaintext+10)); // device_limit LE
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];

// MD5 签名
NSString *v = [self getMD5FromString:signRaw];

// 请求体
NSString *body = [NSString stringWithFormat:
@"code=%@&email=%@&uuid=%@&v=%@",
regCode, email, uuid, v];

// POST 到这里
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": {}})

部署步骤:

# Step 1:安装mkcert后,生成受信任证书
JAVA_HOME="" mkcert order.luckydogsoft.com

# Step 2:DNS 重定向
echo "127.0.0.1 order.luckydogsoft.com" | sudo tee -a /etc/hosts

# Step 3:启动
sudo 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等:

<email>\n<serial>

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
# 应该输出:
# [email protected]
# XXXXXXXXX-XXXXXXXXX-XXXXXXXX

0x6、踩坑记录

不出意外要出意外了,来看看踩了哪些坑。

坑 1:注册码长度有硬限制

现象: snvrfy 一直返回 "decode length not equal encode length"

找到根因: snvrfy 对输入最多处理 30 字符(含破折号),超过就截断:

if (strlen(var_128) <= 0x1e) {   // 0x1e = 30
var_170 = strlen(var_128);
} else {
var_170 = 0x1e; // 超过 30 字符直接截断!
}
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:]}"
# → HAHEOBTER-D2MFRS2JA-BSGTDRR4 (28 chars)

坑 2:product_id 用错了

现象: snvrfy 返回 {"code": 0, ...},本地验证已经过了,但注册界面还是失败。

找到根因: 翻 Hopper,发现有两个不同的注册方法:

方法 用途 product_id 要求
registerGoCatcherEmail:code: Go Catcher 设备注册 必须 0xB1 = 177
registerEmail:code:(真正的入口) 主程序注册 必须 0x11 = 170x30 = 48

我一开始看到有个 0xB1 = 177,以为这就是 product_id,直接拿来用了。结果这是 Go Catcher 设备专用的,结果存到 regInfoGoCatcher 里,跟主程序注册完全是两条路。

registerEmail:code: 里的判断逻辑是这样的:

if (r14 != 0x30) {          // product_id != 48
if (r14 == 0x11) { // product_id == 17 → 成功
[var_58 setRegSN:r13];
[var_58 setRegEmail:var_60];
[rax setValue:r15 forKey:@"regInfo"];
[var_58 setIsRegister:0x1];
rax = 0x1;
} else { // product_id == 177 → 走到这里,失败!
[var_58 setIsRegister:0x0];
rax = 0x0;
}
} else { // product_id == 48 → 也成功
[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 pipefail

REGINFO="dHJlZV9mbHlAY2hpbmFweWcuY29tCkhBSEVPQlRFUi1EMk1GUlMySkEtQlNHVERSUjQ="

# block online verify
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

# Exit AnyGo
osascript -e 'quit app "AnyGo"' 2>/dev/null || true
sleep 1

# write plist
defaults write com.itoolab.AnyGo regInfo "$REGINFO"

# kill cfprefsd
killall -u "$USER" cfprefsd 2>/dev/null || true
sleep 1

# print regInfo
defaults read com.itoolab.AnyGo regInfo | base64 -d

# restart
open -a AnyGo

Step 2: 赋予执行权限:

chmod +x anygo_reg.sh

Step 3: 运行:

./anygo_reg.sh

或者,懒得建文件,一行调用也行:

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

  • 标题: 手把手教你破解macOS之AnyGo 8.3.x
  • 作者: tree_fly
  • 创建于 : 2026-06-20 22:33:04
  • 更新于 : 2026-06-20 22:33:04
  • 链接: https://itreefly.com/posts/41374c0d.html
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
评论