前言
以下内容基于Codex 和 gpt-5.4 ,在前人的基础上对新版本的新路径进行了探索
看了论坛里那篇关于 万能签 证书提取的文章(https://www.52pojie.cn/thread-2100769-1-1.html)
,原帖分析的是旧版 8.2.9,主线是从 +[YYYCertModel autoImportCert] 出发,顺着 numberWithInt: 还原运行时拼接的私钥,再去解包本地 cert。
以发帖时的日期为例子,获取的打包后ipa的版本号是 27.0.5。实际复现时发现,原帖的方法论依然有用,但具体落点已经变了:cert 还是那份本地密文容器,不过私钥的藏法不再是旧版那条整数流路线。
这篇就不重复原帖内容了,只补一份针对 27.0.5 的差异分析,方便后面的人少走一点回头路。
一、先说结论
新版 27.0.5 里:
cert 依然不是现成证书,而是 RSA 2048 分块密文
- 自动导入证书的核心逻辑依然在本地二进制里
- 但私钥不再通过
numberWithInt: 整数流恢复
- 真正的恢复点转移到了
+[AISSignallCertificateRedeemRSA decryptChunkedPKCS1Base64Cipher:error:]
也就是说,原帖“先从本地 cert 文件特征收敛,再去找 App 内部谁在解它”这个思路没有过时,过时的是旧版那条具体的私钥恢复路径。
二、先看 cert,判断方向还是一样的
新版 bundle 里的 cert 依然是一行很长的文本。
对它做 base64 解码之后,得到的长度是:
34560
这个数字很关键:
34560 = 135 * 256
256 依然正好对应 RSA 2048 的单个密文块长度,所以这一版的 cert 本质和旧版没有区别,还是“很多个 RSA 密文块拼起来的一段上层数据”。
这一步和原帖的第二部分是完全一致的,因此新版分析没必要从别的方向重新开局,继续沿着“谁在本地解这份 cert”往下找就行。
直接贴验证这个判断的最小代码:
import base64
from pathlib import Path
cert_text = Path("cert").read_text().strip()
raw = base64.b64decode(cert_text)
print(len(raw)) # 34560
print(len(raw) % 256) # 0
这几行虽然简单,但已经足够说明新版 cert 仍然应该优先按 RSA 2048 分块密文去理解,而不是先往别的加密容器上猜。
三、和原帖旧版相比,关键差异在哪里
3.1 入口类已经不是 YYYCertModel
原帖那条链里,最关键的是:
YYYCertModel
autoImportCert
importNSKCert:
我在 27.0.5 这个样本里继续按这些名字找,已经对不上主要逻辑了。新版 objc metadata 里更关键的几个点是:
AISBundledCertAutoImporter
+[AISBundledCertAutoImporter scheduleIfNeeded]
+[AISBundledCertAutoImporter ais_tryPerformOnce]
+[AISSignallCertificateRedeemRSA decryptChunkedPKCS1Base64Cipher:error:]
从名字就能看出来,这一版把“自动导入 bundled cert”以及“RSA 分块解密”的职责换到了新的类上。
3.2 旧脚本一上来就会先撞到 __objc_stubs
原帖里为了自动定位 numberWithInt:,有一步是扫 __objc_stubs,把 selector 和 stub 对上。
拿旧脚本直接跑新版样本,最先见到的报错就是:
Section not found: __objc_stubs
新版二进制里对应的是 __stubs,不是旧脚本默认假设的 __objc_stubs。
不过这只算表面差异。真正的问题不是 section 名字,而是下一条。
3.3 numberWithInt: 这条恢复路径本身已经不成立
原帖旧版能跑通,靠的是下面这条链:
autoImportCert
-> 大量 numberWithInt:
-> 收集整数流
-> 还原 PEM 私钥
-> 用私钥逐块解 cert
但在 27.0.5 里,即便先把 __objc_stubs 兼容掉,这条路也还是走不通。因为新版已经没有再把私钥做成那种非常显眼的 numberWithInt: 整数流了。
换句话说,旧版的成功经验不能机械照搬。原帖真正值得继承的是分析框架,不是某个固定 helper。
四、新版应该盯住哪里
既然旧入口不再是主路径,一个更稳的做法还是回到字符串和 objc metadata 本身。
我最后收敛到的关键点是:
AISBundledCertAutoImporter
decryptChunkedPKCS1Base64Cipher:error:
第二个名字其实已经相当直白:
Chunked
PKCS1
Base64Cipher
这几个词基本已经把 cert 的处理方式写在脸上了。
继续往这个方法里追,就能发现它不是简单“拿现成私钥解密”,而是在函数内部先恢复一段被混淆的大块文本,再把恢复结果当作 RSA 私钥材料使用。
这也是新版和原帖旧版最本质的区别:
旧版:私钥通过 numberWithInt: 动态拼装
新版:私钥文本在 decryptChunkedPKCS1Base64Cipher:error: 内部按块 XOR 还原
五、新版私钥的实际藏法
5.1 重点不再是整数流,而是运行时 XOR 解混淆
把函数展开之后,能看到几段比较明显的 helper。我这边最后手工/脚本还原出来的关键点有:
init_string_1002863dc
init_string_1002865a4
init_string_100286ed8
init_large_blob_10028556c
前三个更像是一些字符串/标识的延迟恢复,第四个才是真正关键的大块数据恢复逻辑。
5.2 几个 helper 恢复出的内容
其中比较有代表性的恢复结果:
init_string_1002863dc
- 还原出:
AISSignallCertificateRedeemRSA\0
init_string_100286ed8
- 还原出 RSA OID:
2a864886f70d010101
init_large_blob_10028556c
- 还原出一段约
1704 字节的 PEM 文本
- 开头直接就是:
-----BEGIN PRIVATE KEY-----
到这里基本就能确认,新版仍然把真实私钥放在本地,只不过不再像旧版那样借助 numberWithInt: 做整数流,而是把 PEM 文本拆散后异或混淆,等真正解 cert 时再恢复回来。
对应的核心恢复代码,保留最关键的部分,大概是这种形态:
def xor_bytes(a: bytes, b: bytes) -> bytes:
return bytes(x ^ y for x, y in zip(a, b))
def init_large_blob_10028556c(m):
src = 0x1009D9B10
mask = 0x100682CD0
out = bytearray()
for i in range(26):
out.extend(xor_bytes(m.bytes(src + i * 0x20, 16), m.bytes(mask + i * 0x20, 16)))
out.extend(xor_bytes(m.bytes(src + i * 0x20 + 0x10, 16), m.bytes(mask + i * 0x20 + 0x10, 16)))
return bytes(out)
这里只截最关键的一段就够了,重点不是地址本身,而是能看出:这一版私钥确实是由一大块静态数据和 mask 在运行时异或恢复出来的。
把恢复结果直接看一眼,会更直观:
large_plain = init_large_blob_10028556c(m)
print(large_plain[:64])
输出开头已经能看到:
b'-----BEGIN PRIVATE KEY-----\n...'
六、怎么验证这把私钥就是自动导入链实际使用的那把
判断标准和原帖其实一样,不是看“它像不像私钥”,而是看它能不能把 cert 正确解成上层数据。
验证过程很简单:
- 先按函数逻辑把 XOR 混淆的大块文本恢复出来
- 把恢复结果当 PEM 私钥加载
- 对本地
cert 先做 base64 解码
- 按
256 字节切块逐块做 PKCS1 v1.5 私钥解密
- 检查输出是不是合法 JSON
这一版里,最终输出确实直接解成了 JSON,里面包含:
data.p12
data.provision
data.password
因此可以认为,这把从 decryptChunkedPKCS1Base64Cipher:error: 内部恢复出来的 PEM,就是 27.0.5 这版自动导入链真正使用的私钥。
对应的验证代码也不用贴太多,保留最核心的解密主干就够了:
raw = base64.b64decode(cert_b64)
out = bytearray()
for i in range(0, len(raw), 256):
out.extend(key.decrypt(raw[i:i+256], padding.PKCS1v15()))
payload = json.loads(out.decode("utf-8"))
print(payload["data"].keys())
跑通之后,payload["data"] 里能直接看到:
dict_keys(['p12', 'provision', 'password', ...])
这一步的意义在于,它证明这把恢复出来的 PEM 不是“看起来像私钥”,而是确实能把新版 cert 解成自动导入所需的那份上层数据。
七、脚本层面该怎么补
原帖那套自动化思路本身是对的,只是需要补一条新版分支。
旧版脚本主要依赖:
- 扫
__objc_stubs
- 找
numberWithInt:
- 收集整数流
- 还原 PEM
这在 27.0.5 上不够用了,所以最后的适配思路是:
- 老样本继续保留
numberWithInt: 路线
- 新样本增加一个新的 fallback,例如
wnq27-xor
- 当老路线拿不到可用私钥时,转去执行新版的 XOR 恢复逻辑
也就是说,不是推翻原帖脚本,而是在原来的恢复器上再加一条新路径。
我最后在恢复器里加的新分支,抽象后大概就是:
key_source = "numberWithInt"
try:
private_key = recover_old_style_key(macho)
except Exception:
private_key = recover_wnq27_xor_key(macho)
key_source = "wnq27-xor"
思路很朴素,但对兼容多版本很有用:老版继续走老路,新版在老路失败时自动切到 XOR 路线,不需要每碰到一个样本就手工改一遍分析脚本。
八、把新版恢复链压缩成一句话
如果只保留主干,27.0.5 这一版可以概括成:
bundle 内置 cert
-> base64 解码
-> 识别为 RSA 2048 分块密文
-> 从 AISSignallCertificateRedeemRSA 的 decryptChunkedPKCS1Base64Cipher:error: 恢复 PEM 私钥
-> 按 256 字节分块做 PKCS1 v1.5 解密
-> 得到 JSON
-> 从 JSON 提取 p12 / provision / password
和原帖旧版相比,唯一真正变掉的就是:
私钥从哪来
而 cert 这份数据最终怎么被解开,整体思路并没有脱离原帖当时总结出来的框架。
九、验证结果
本地样本已经跑通,恢复结果如下:
type: private
p12_size: 3235
mobileprovision_size: 21299
额外校验也通过了:
p12 内含私钥和证书
mobileprovision 可以正常解析
- profile name:
LHaZ
- expiration:
2027-05-09 01:24:05
- team:
J6PAL2BQG6
这也侧面说明,新版并不是彻底改成了“远程下发不可逆数据”或者“纯服务端代签”,而依然是在本地放了一整条可逆的自动导入链,只是把私钥藏得比旧版深了一层。
十、结尾
如果后面还有人继续跟这个方向,个人感觉最该记住的是:
不要把旧版里成功的 numberWithInt -> 私钥恢复,当成所有版本都通用的模板。
原帖真正有价值的地方,是它把分析顺序理顺了:
- 先看
cert 文件特征
- base64 后先判断是不是 RSA 块长度
- 再去找“本地到底谁在解这份数据”
只要这个框架没丢,换类名、换 helper、换混淆方式,本质上都还是在找“那把最终能把 cert 解成 JSON 的真实私钥”。
而对 27.0.5 这版来说,答案已经比较明确了:私钥不再从 numberWithInt: 整数流恢复,而是在 decryptChunkedPKCS1Base64Cipher:error: 里通过 XOR 还原出 PEM 文本,再用它去解本地 cert。