前言
某宝上给 iPhone 买了个 p12 证书用于自签 IPA,通过卖家给的网站输入手机的 UDID 激活后,就自动安装了万能签、全能签这类签名 APP。
对于签名安装 IPA 来说很方便,但是不能导出 p12 证书,导致我没办法和 xcode 配合使用。
于是就以万能签(下称 wnq)为例,看看能不能把 p12 证书给还原出来。
由于我没有任何逆向的基本知识,全程都是用 codex + gpt-5.4 来分析的。从零开始用了 200M tokens 和两天不到的时间,成功还原出了可用的 p12 证书和 mobileprovision 文件。
一、获取安装包和资源
首先既然wnq在我安装的时候就内置了证书,说明要么是证书被直接放在了 App bundle 里,要么就是 App 启动后会自动从远程接口拉取证书并安装。
所以第一步的目标就是把 IPA 安装文件给拿到手,看看里面有没有证书相关的资源。
在电脑上执行相同的安装操作,获取到 plist 配置文件,里面:
<?xml version="1.0" encoding="utf-8"?>
<plist version="1.0">
<dict>
<key>items</key>
<array>
<dict>
<key>assets</key>
<array>
<dict>
<key>kind</key>
<string>software-package</string>
<key>url</key>
<string>https://[REDACTED]/api/[REDACTED].ipa</string>
</dict>
<dict>
<key>kind</key>
<string>display-image</string>
<key>needs-shine</key>
<true/>
<key>url</key>
<string>https://[REDACTED]/asset/appicon/[REDACTED].png</string>
</dict>
</array>
<key>metadata</key>
<dict>
<key>bundle-identifier</key>
<string>[REDACTED]</string>
<key>bundle-version</key>
<string>8.2.9</string>
<key>kind</key>
<string>software</string>
<key>title</key>
<string>万能签</string>
</dict>
</dict>
</array>
</dict>
</plist>
[REDACTED] 这一块是一串十分复杂的字符串,验证了确实是一个定制的 IPA 文件,把用户的 p12 证书或者获取方式单独嵌入其中。
于是根据连接把 ipa 下载下来,解压后就得到了 wnq-signtool.app 这个目录,里面有个二进制文件 wnq-signtool,以及一个 cert 和 SpeedSignV2Config 以及其他文件。从 cert 的大小,先猜测它很可能就是个证书相关的资源。
二、如何从文件特征快速判断方向
2.1 先别急着逆向,先看文件长什么样
第一步不要一上来就反汇编,先看输入文件本身。
在当前目录执行:
file ./wnq/cert ./wnq/SpeedSignV2Config
wc -c ./wnq/cert ./wnq/SpeedSignV2Config
实际输出如下:
./wnq/cert: ASCII text, with very long lines (45740), with no line terminators
./wnq/SpeedSignV2Config: ASCII text, with no line terminators
45740 ./wnq/cert
208 ./wnq/SpeedSignV2Config
45948 total
2.2 这一步怎么分析
从这里可以马上得到两个结论:
cert 和 SpeedSignV2Config 都是文本,不是现成二进制证书文件
cert 是一行很长的文本,长度非常像 base64 容器,而不是 PEM
为什么说它不像 PEM:
- PEM 一般会有
-----BEGIN ...-----
- 这里没有头尾标记
file 也只是把它识别成普通 ASCII 文本
所以这一步最合理的工作假设是:
cert 和 SpeedSignV2Config 都是某种编码后的文本
- 下一步应该尝试做 base64 解码,而不是先拿
openssl pkcs12 硬喂
2.3 对两份文本做 base64 解码
继续执行:
python3 - <<'PY'
from pathlib import Path
import base64
for name in ['cert', 'SpeedSignV2Config']:
p = Path('./wnq') / name
raw = base64.b64decode(p.read_text().strip())
print(name, 'decoded_len=', len(raw), 'head16=', raw[:16].hex())
PY
实际输出如下:
cert decoded_len= 34304 head16= 4a85d7594d9797b19d228c912718d4a4
SpeedSignV2Config decoded_len= 156 head16= 21d47ffb17f4d22e85d1bbc92cc65c1e
2.4 为什么 34304 很关键
这个数字非常重要。
因为:
34304 = 134 * 256
而 256 是什么?
- 它正好是
2048-bit RSA 的一个密文块长度
- 也就是
2048 / 8 = 256
这时就应该立刻想到:
cert 很可能不是随机二进制
- 而是由很多个
RSA 2048 密文块拼起来的容器
这一步是整个分析的第一个大收敛点。
因为如果你忽略这一点,就很容易走去尝试:
- 改后缀成
.p12
- 直接用
openssl x509
- 直接用
plutil
- 甚至一顿猜 DES / AES key
这些都不是最短路径。
最短路径应该是:
cert 像 base64
-> base64 解码后长度是 134 * 256
-> 优先怀疑 RSA 分块密文
2.5 这一步的结论
到这里我们还没有得到 JSON,也没有得到 p12,但已经可以非常稳地得到一个工作结论:
cert 不是直接证书
cert 更像是“经过 RSA 分块封装的一段上层数据”
于是下一步最合理的问题就变成:
- App 内部到底在哪条链上会处理这份本地 cert 资源?
这就进入第三部分,逆向二进制
三、从 autoImportCert 发现 cert 的真正解包路径
3.1 先定位类方法 IMP
既然证书是安装软件后自动初始化的,那么合理的分析方向就是:
现在先不要盲扫整个二进制,而是先找和“自动导入证书”最相关的类方法。
执行:
otool -ov ./wnq/wnq-signtool \
| rg -n -C 3 'YYYCertModel|autoImportCert|importNSKCert:' \
| sed -n '1,40p'
实际输出里最关键的部分如下:
16648- name 0x100749dba certWithResponseData:showMessage:
16649- types 0x10077ea71 @28@0:8@16B24
16650- imp 0x10006ee9c
16651: name 0x100749ddc importNSKCert:
16652- types 0x10077e308 B24@0:8@16
16653- imp 0x10009b84c
16654: name 0x100749deb autoImportCert
16655- types 0x10077e34e v16@0:8
16656- imp 0x1000ba8a4
3.2 这一步怎么分析
从这段输出里,我们能拿到三个非常关键的方法:
+[YYYCertModel autoImportCert]
+[YYYCertModel importNSKCert:]
+[YYYCertModel certWithResponseData:showMessage:]
这三个方法的角色可以先这样理解:
autoImportCert
更像启动时的入口调度器
importNSKCert:
更像处理本地证书包的逻辑入口
certWithResponseData:showMessage:
更像最终处理导入响应、解析 p12 的统一终点
这时就已经比“全局乱翻函数”高效很多了,因为分析范围一下子缩小到了一个非常具体的方法簇。
3.3 为什么这里先不深挖 certWithResponseData:showMessage:
这里有一个很常见的阅读跳跃,需要先补清楚。
第一次看到这三个方法时,很多人会自然地想:
- 既然
certWithResponseData:showMessage: 名字这么像“最终导入证书”,是不是应该先分析它?
这个想法不算错,但对当前目标来说,它不是最短路径。
原因是:
certWithResponseData:showMessage: 更像统一的下游处理器
- 它处理的是“已经准备好的响应数据”
- 它更靠近最终的
p12 解析,而不是最前面的“本地 cert 包如何被解开”
而当前我们最先要解决的问题其实是:
cert 这个本地文本包,到底是怎么变成上游响应数据的
所以这里更适合优先看:
因为它的名字已经直接告诉我们,它就是“导入本地 cert 包”的逻辑入口。
3.4 再看 autoImportCert 实际调用了什么 selector
为了确认这条链是不是本地导入,我们再看一下 autoImportCert 周边的关键 selector。
这里用一个小脚本把 __objc_stubs 里的 selector 映射出来,再配合 lldb 看汇编:
python3 - <<'PY'
from pathlib import Path
import struct
path = Path('./wnq/wnq-signtool')
with path.open('rb') as f:
hdr = f.read(32)
magic, cputype, cpusubtype, filetype, ncmds, sizeofcmds, flags, reserved = struct.unpack('<IiiIIIII', hdr)
off = 32
sections = []
for _ in range(ncmds):
f.seek(off)
cmd, cmdsize = struct.unpack('<II', f.read(8))
if cmd == 0x19:
f.seek(off)
data = f.read(72)
_, _, segname, vmaddr, vmsize, fileoff, filesize, _, _, nsects, _ = struct.unpack('<II16sQQQQiiII', data)
sec_off = off + 72
for _ in range(nsects):
f.seek(sec_off)
sec = f.read(80)
sectname, segname2, addr, size, offset, align, reloff, nreloc, flags, res1, res2, res3 = struct.unpack('<16s16sQQIIIIIIII', sec)
sections.append((sectname.rstrip(b'\0').decode(), addr, size, offset))
sec_off += 80
off += cmdsize
def va_to_off(va):
for sect, addr, size, offset in sections:
if addr <= va < addr + size:
return offset + (va - addr)
raise KeyError(hex(va))
def u32(va):
with path.open('rb') as f:
f.seek(va_to_off(va))
return struct.unpack('<I', f.read(4))[0]
def u64(va):
with path.open('rb') as f:
f.seek(va_to_off(va))
return struct.unpack('<Q', f.read(8))[0]
def cstr(va):
with path.open('rb') as f:
f.seek(va_to_off(va))
return f.read(256).split(b'\0', 1)[0].decode('utf-8', 'replace')
wanted = {
'mainBundle',
'pathForResource:ofType:',
'standardUserDefaults',
'stringForKey:',
'isEqualToString:',
'stringWithContentsOfFile:encoding:error:',
'numberWithInt:',
'initWithBase64EncodedString:options:',
'dataWithBytes:length:',
'appendData:',
}
for stub in range(0x1006a9280, 0x1006c7540, 0x20):
try:
adrp = u32(stub)
ldr = u32(stub + 4)
except Exception:
continue
if (adrp & 0x9f000000) != 0x90000000:
continue
immlo = (adrp >> 29) & 3
immhi = (adrp >> 5) & 0x7ffff
imm = (immhi << 2) | immlo
if imm & (1 << 20):
imm -= 1 << 21
page = (stub & ~0xfff) + (imm << 12)
imm12 = (ldr >> 10) & 0xfff
selref = page + (imm12 << 3)
try:
name = cstr(u64(selref))
except Exception:
continue
if name in wanted:
print(hex(stub), name)
PY
实际输出如下:
0x1006ab240 appendData:
0x1006ae900 dataWithBytes:length:
0x1006b3620 initWithBase64EncodedString:options:
0x1006b4ec0 isEqualToString:
0x1006b63e0 mainBundle
0x1006b7820 numberWithInt:
0x1006b8260 pathForResource:ofType:
0x1006c3a20 standardUserDefaults
0x1006c4300 stringForKey:
0x1006c43a0 stringWithContentsOfFile:encoding:error:
3.5 再看 autoImportCert 的关键汇编片段
继续执行:
lldb -b \
-o 'target create ./wnq/wnq-signtool' \
-o 'disassemble --start-address 0x1000bad90 --end-address 0x1000baf70' \
-o 'quit'
实际输出里最关键的一段如下:
wnq-signtool[0x1000bada8] <+1284>: ldr x0, [x22, #0xb10]
wnq-signtool[0x1000badac] <+1288>: bl 0x1006b63e0
...
wnq-signtool[0x1000badc8] <+1316>: adrp x2, 2058
wnq-signtool[0x1000badcc] <+1320>: add x2, x2, #0x510
wnq-signtool[0x1000badd4] <+1328>: bl 0x1006b8260
...
wnq-signtool[0x1000bae58] <+1460>: bl 0x1006c3a20
...
wnq-signtool[0x1000baea8] <+1540>: bl 0x1006c4300
...
wnq-signtool[0x1000baef8] <+1620>: bl 0x1006b4ec0
...
wnq-signtool[0x1000baf38] <+1684>: ldur x2, [x29, #-0x70]
wnq-signtool[0x1000baf44] <+1696>: bl 0x1006c43a0
3.6 这一段该怎么读
这段汇编几乎就是自动导入主链的“中文直译”:
bl 0x1006b63e0
对应 mainBundle
bl 0x1006b8260
对应 pathForResource:ofType:
bl 0x1006c3a20
对应 standardUserDefaults
bl 0x1006c4300
对应 stringForKey:
bl 0x1006b4ec0
对应 isEqualToString:
bl 0x1006c43a0
对应 stringWithContentsOfFile:encoding:error:
所以这条链可以很稳地翻译成:
mainBundle
-> pathForResource:ofType:
-> 取本地资源路径
-> standardUserDefaults / stringForKey:
-> 比较导入标记
-> stringWithContentsOfFile:encoding:error:
-> 读取本地文本资源
这一步最关键的结论是:
- 自动导入主链明确依赖本地 bundle 资源
- 因此
cert 很可能是默认导入的核心输入之一
这就把分析方向从“也许依赖远程接口”彻底拉回到了“本地 cert 包”。
3.7 接下来为什么自然会进入 importNSKCert:
走到这里,下一步其实已经很自然了。
我们已经知道两件事:
autoImportCert 会读取本地文本资源
YYYCertModel 里有一个名字非常直白的方法叫 importNSKCert:
这时最合理的问题就是:
autoImportCert 读到本地资源之后,真正解包 cert 的第一层逻辑在哪里?
最强的候选就是:
所以这里不是突然跳去看两个陌生 helper,而是先顺着“本地 cert 包导入入口”继续往下走。
3.8 在 importNSKCert: 里如何发现后面那两个关键 helper
现在直接看 importNSKCert: 的一小段关键汇编:
lldb -b \
-o 'target create ./wnq/wnq-signtool' \
-o 'disassemble --start-address 0x1000a2c20 --end-address 0x1000a2c58' \
-o 'quit'
实际输出如下:
wnq-signtool[0x1000a2c24] <+29656>: mov x0, x27
wnq-signtool[0x1000a2c28] <+29660>: bl 0x10009aaf0
wnq-signtool[0x1000a2c2c] <+29664>: mov x29, x29
wnq-signtool[0x1000a2c30] <+29668>: bl 0x1006a6964
wnq-signtool[0x1000a2c34] <+29672>: mov x19, x0
wnq-signtool[0x1000a2c38] <+29676>: ldur x25, [x29, #-0x70]
wnq-signtool[0x1000a2c3c] <+29680>: mov x0, x25
wnq-signtool[0x1000a2c40] <+29684>: mov x1, x19
wnq-signtool[0x1000a2c44] <+29688>: bl 0x10009ae48
3.9 为什么看到这一段后,就知道应该重点盯住这两个函数
这段调用关系给出的信息其实已经非常强了:
importNSKCert: 先调用 10009aaf0
10009aaf0 的返回值被保存起来
- 然后
importNSKCert: 再调用 10009ae48
10009aaf0 的返回值被直接当作 10009ae48 的一个输入
也就是说,仅从调用图就可以先得到一个中间结论:
10009aaf0
-> 先准备一个关键输入
10009ae48
-> 拿到两个关键输入后,继续做真正的核心处理
这就是为什么后面会从 importNSKCert: 收敛到这两个函数。
不是因为它们名字特殊,而是因为:
- 它们位于本地 cert 包主链上
- 而且前后相连
- 第一个函数的返回值立刻喂给第二个函数
在教学上,这种模式通常就是“上游准备数据 -> 下游真正处理”的典型信号。
四、集中逆向 10009aaf0 和 10009ae48 函数
到这里我们已经知道:
- 自动导入会读本地文本资源
cert 很像一段 RSA 分块密文容器
importNSKCert: 是本地 cert 包主链入口
10009aaf0 -> 10009ae48 是这条主链里最先串起来的两层核心 helper
所以接下来真正要回答的是两个更具体的问题:
- 解密用的私钥从哪里来
cert 到底是怎么被解开的
这两个问题分别对应:
4.1 先看 10009ae48 的入口汇编
执行:
lldb -b \
-o 'target create ./wnq/wnq-signtool' \
-o 'disassemble --start-address 0x10009ae48 --end-address 0x10009af24' \
-o 'quit'
实际输出如下:
wnq-signtool[0x10009ae68] <+32>: mov x21, x1
wnq-signtool[0x10009ae6c] <+36>: mov x22, x0
...
wnq-signtool[0x10009aebc] <+116>: bl 0x1006a6820
wnq-signtool[0x10009aec0] <+120>: mov x2, x19
wnq-signtool[0x10009aec4] <+124>: mov w3, #0x1
wnq-signtool[0x10009aec8] <+128>: bl 0x1006b3620
wnq-signtool[0x10009aecc] <+132>: stur x0, [x29, #-0x88]
wnq-signtool[0x10009aed0] <+136>: mov w1, #0x100
wnq-signtool[0x10009aed4] <+140>: bl 0x1000dc2e4
...
wnq-signtool[0x10009aef0] <+168>: mov x0, x20
wnq-signtool[0x10009aef4] <+172>: bl 0x1000d33c0
wnq-signtool[0x10009aef8] <+176>: mov x0, x20
wnq-signtool[0x10009aefc] <+180>: bl 0x1000d33c0
4.2 为什么这里先看 10009ae48,而不是先硬啃 10009aaf0
这里也有一个容易让读者跳步的地方。
虽然调用顺序是:
10009aaf0
-> 10009ae48
但实际逆向时,通常会先更快地看懂 10009ae48,原因是:
10009ae48 内部会出现更强的语义信号
- 比如
initWithBase64EncodedString:options:
- 比如
256 这个分块长度
- 比如后面还能顺到私钥加载与分块解密
这些信号足够强,所以它更适合作为“先确定大方向”的函数。
而 10009aaf0 更像上游准备器,它的语义通常要靠 10009ae48 的参数用法来反推。
4.3 这一步怎么分析参数
先看开头:
mov x21, x1
mov x22, x0
这说明:
x0 / arg0 和 x1 / arg1 都是关键输入
再看后面的调用:
bl 0x1006b3620
而前面我们已经把这个 stub 映射出来了:
0x1006b3620 initWithBase64EncodedString:options:
也就是说,10009ae48 的其中一个输入会被当成 base64 字符串来初始化 NSData。
接着又看到:
mov w1, #0x100
bl 0x1000dc2e4
这说明它把解码后的数据交给了一个“按 256 字节切块”的 helper。
而 256 再次和前面 34304 = 134 * 256 对上了。
这一步的收敛非常关键:
arg0
-> initWithBase64EncodedString:options:
-> 得到 NSData
-> 按 256 字节切块
这基本已经等于在汇编层面承认:
4.4 1000d33c0 为什么像私钥加载器
接着看:
mov x0, x20
bl 0x1000d33c0
对 x20 连续调用两次 1000d33c0,这是个很强的信号。
结合之前对 10009aaf0 上游的观察,很容易推断出:
x20 很可能是私钥相关输入
1000d33c0 很可能是在把 PEM 字符串装载成 RSA key 对象
这里先不用强行把每个寄存器都扣死,教学上抓住语义就够了:
- 一个输入被当成 base64 文本解码并切块
- 另一个输入又被一个“看起来像 key loader”的函数反复处理
这时最自然的工作假设就是:
而后续验证也确实如此。
4.5 到这时为什么会反推 10009aaf0 很可能是在准备私钥相关字符串
现在把第三部分和刚才这部分拼起来看:
- 在
importNSKCert: 里,10009aaf0 的返回值会立刻被喂给 10009ae48
- 在
10009ae48 里,这个输入又会继续进入像 1000d33c0 这样的“私钥加载”逻辑
所以虽然这时我们还没完全读懂 10009aaf0 的每一行,但已经可以反推出:
10009aaf0 很可能是在准备一段私钥相关文本
这就是“先看下游语义,再回推上游作用”的典型做法。
也正因为如此,10009aaf0 重要,不是因为它名字像私钥函数,而是因为:
- 它的输出被
10009ae48 当成了解密所需的关键输入
4.6 10009aaf0 的真实作用是什么
虽然 10009aaf0 的完整伪代码又长又乱,但它的语义其实可以压缩成一句话:
这个结论不是凭空猜的,而是由两层证据拼起来的:
- 在
autoImportCert 里能看到大量 numberWithInt: 调用,说明它确实在构造整数对象序列
- 这些整数按顺序还原后,最终能拼出一把合法的 PEM 私钥,而且这把私钥还能把
cert 正确解成 JSON
在这条链里,它做的是:
- 遍历一大串
numberWithInt: 生成的整数对象
- 把每个整数当成 1 字节字符
- 最终拼成完整文本
而拼出来的文本,经过最终验证,就是:
- 一把真正用于自动导入的
PEM PRIVATE KEY
这一步是整个分析里最关键的纠偏点。
因为如果你没有盯住这一点,就会很容易拿错私钥,然后一直得到错误的中间结果。
4.7 如何真正把这把私钥拿出来
上面虽然已经说明了 10009aaf0 最终拼出来的是私钥,但如果只是停在这个结论,教学上还是不够落地。
这里要再往前补一层:
- 这把私钥到底是怎么从二进制里拿出来的?
- 为什么我们后来能确认“这把才是真私钥”?
这一步建议分成“手工理解”和“自动化提取”两层来看。
4.7.1 手工理解层:为什么会想到用 numberWithInt: 去抽整数流
关键线索有两个:
autoImportCert 主链里确实出现了大量 numberWithInt:
10009aaf0 的语义又高度像“把整数数组转成字符串”
一旦这两个线索合在一起,最自然的工作假设就是:
autoImportCert
-> 大量 numberWithInt:
-> 形成一组 NSNumber
-> 交给 10009aaf0
-> 10009aaf0 把它们拼成完整字符串
-> 这段字符串很可能就是解包 cert 所需的关键文本
而后面我们又看到:
10009aaf0 的输出被直接喂给 10009ae48
10009ae48 的第二个输入会继续走像私钥加载器一样的逻辑
所以这时就有了一个非常具体的提取目标:
- 把所有和
numberWithInt: 相关的立即数,按代码顺序收集起来
- 还原成字符串
- 看其中有没有完整的 PEM 头尾
4.7.2 自动化提取层:脚本是怎么做的
现在这份自动化脚本做的事情,其实就是把上面的手工思路程序化。
为了聚焦“如何抓到真实私钥”,这里只放和私钥提取直接相关的核心源码,不放后面 cert -> JSON -> p12 的解析代码。
这一段源码的作用只有一个:
- 在整个二进制里自动找到
numberWithInt: 这条整数流
- 把它们按顺序还原成候选字符串
- 选出真正的 PEM 私钥
核心代码如下:
PEM_BEGIN = "-----BEGIN PRIVATE KEY-----"
PEM_END = "-----END PRIVATE KEY-----"
def find_stub_by_selector(macho: MachO, selector: str) -> int:
for stub, name in macho.objc_stub_selectors().items():
if name == selector:
return stub
raise ValueError(f"Selector stub not found: {selector}")
def find_number_wrappers(macho: MachO, number_stub: int) -> set[int]:
wrappers = {number_stub}
for addr in macho.iter_text_addresses():
kind, _ = macho.decode_branch(addr)
if kind != "b":
continue
if macho.resolve_branch_chain(addr) == number_stub:
wrappers.add(addr)
return wrappers
def collect_number_streams(macho: MachO, wrappers: set[int]) -> list[CandidateRun]:
hits: list[tuple[int, int]] = []
for addr in macho.iter_text_addresses():
imm = macho.is_movz_w2_imm(macho.read_u32(addr))
if imm is None:
continue
for look_ahead in range(1, 6):
kind, target = macho.decode_branch(addr + 4 * look_ahead)
if kind != "bl" or target is None:
continue
if macho.resolve_branch_chain(target) in wrappers:
hits.append((addr, imm))
break
runs: list[CandidateRun] = []
if not hits:
return runs
current_addrs = [hits[0][0]]
current_vals = [hits[0][1]]
for addr, val in hits[1:]:
if addr - current_addrs[-1] > 0x80:
runs.append(
CandidateRun(
start_addr=current_addrs[0],
end_addr=current_addrs[-1],
text="".join(chr(v) for v in current_vals),
)
)
current_addrs = [addr]
current_vals = [val]
else:
current_addrs.append(addr)
current_vals.append(val)
runs.append(
CandidateRun(
start_addr=current_addrs[0],
end_addr=current_addrs[-1],
text="".join(chr(v) for v in current_vals),
)
)
return runs
def select_private_key_candidate(runs: list[CandidateRun]) -> CandidateRun:
pem_runs = [run for run in runs if PEM_BEGIN in run.text and PEM_END in run.text]
if not pem_runs:
raise ValueError("No PEM private key candidate found in the integer streams")
return max(pem_runs, key=lambda run: run.length)
def normalize_pem(text: str) -> str:
start = text.find(PEM_BEGIN)
end = text.find(PEM_END)
if start == -1 or end == -1:
raise ValueError("PEM markers not found in candidate text")
end += len(PEM_END)
pem = text[start:end]
if not pem.endswith("\n"):
pem += "\n"
return pem
number_stub = find_stub_by_selector(macho, "numberWithInt:")
wrappers = find_number_wrappers(macho, number_stub)
runs = collect_number_streams(macho, wrappers)
candidate = select_private_key_candidate(runs)
pem = normalize_pem(candidate.text)
这段源码可以按下面的顺序理解:
- 先找到
numberWithInt: 对应的 objc stub
- 再把所有最终会跳到这个 stub 的 wrapper 全部找出来
- 然后扫描整个
__TEXT.__text,寻找模式:
mov w2, #imm
- 紧跟
bl wrapper
- 把命中的
imm 按地址顺序收集起来
- 按“地址是否连续”分成多段字符串候选
- 只保留同时包含 PEM 头尾标记的候选
- 取最长的那段,恢复为真正的私钥
它利用的是“语义锚点”:
4.7.3 直接运行脚本,看它如何定位私钥候选
执行:
python3 ./wnq/tools/restore_wnq_cert.py \
--out-dir ./tmp \
--write-pem \
--verbose | sed -n '1,20p'
实际输出如下:
numberWithInt stub: 0x1006b7820
wrapper count: 4577
candidate run count: 48
selected candidate: 0x1000baffc - 0x1000c992c (1703 chars)
detected private key bits: 2048
Restore complete
这段输出可以这样理解:
numberWithInt stub: 0x1006b7820
说明脚本已经自动找到了 numberWithInt: 的 objc stub
wrapper count: 4577
说明它没有只看直连调用,而是把所有最终能跳到 numberWithInt: 的 wrapper 都收进来了
candidate run count: 48
说明脚本在全局代码段中识别出了 48 段可能的字符串整数流
selected candidate: 0x1000baffc - 0x1000c992c (1703 chars)
说明它最终挑出了一段长度 1703 的候选,认为这段最像真实 PEM
detected private key bits: 2048
说明这段候选不只是“看起来像字符串”,而是已经被 OpenSSL 验证成了一把真实私钥
4.7.4 把还原出的私钥直接看一眼
执行:
sed -n '1,6p' ./tmp/auto_import_private_key.pem
tail -n 3 ./tmp/auto_import_private_key.pem
你会看到它已经是完整的 PEM 结构,头尾都齐全:
-----BEGIN PRIVATE KEY-----
...
-----END PRIVATE KEY-----
4.7.5 再用 OpenSSL 验证这把私钥真的可用
执行:
openssl pkey -in ./tmp/auto_import_private_key.pem -noout -text | sed -n '1,8p'
当前样本的实际输出开头如下:
Private-Key: (2048 bit, 2 primes)
modulus:
00:a5:cd:1e:1c:e3:b9:42:b9:e9:5b:a0:74:33:a8:
3a:03:1e:2c:71:7a:bc:80:6f:1d:33:17:12:ed:c5:
这一步的意义非常大,因为它把“我猜这是一把私钥”提升成了“工具已经确认这是一把真实可解析的私钥”。
4.7.6 为什么说这把才是“真正用于自动导入的私钥”
光能被 OpenSSL 解析还不够,还要再验证一件事:
当前样本的验证方式非常直接:
- 用这把私钥去分块解
cert
- 解密结果直接变成合法数据
也就是说,这把私钥不仅“格式正确”,而且“业务上正确”。
这和前面那把静态抽出来、也能被 OpenSSL 解析但解不出合法数据的 PEM,形成了最关键的对比。
所以这里可以下一个非常稳的结论:
- 真正要用的不是“随手 strings 出来那把看起来像 PEM 的私钥”
- 而是
autoImportCert 里通过 numberWithInt: 动态拼出来、并且最终能把 cert 正确解成合法数据的那把私钥
4.8 为什么拿错私钥时一直解不出数据
这里一定要记住一个经验:
- “能被 OpenSSL 正常解析的 PEM 私钥”
- 不等于“这条业务链真正使用的私钥”
前面的弯路就是这样来的:
- 先从二进制里抽到了一把看起来像 PEM 的私钥
- 它也能被 OpenSSL 正常解析
- 用它去解
cert,确实能得到一份二进制输出
- 但那份输出不是合法数据
后面真正解决问题的关键是:
- 不再执着于那把“看起来合理”的静态 PEM
- 而是顺着
autoImportCert -> numberWithInt: 去恢复动态生成的真正私钥
一旦换成这把真实私钥,再去解 cert,结果立刻就是合法数据了。
五、使用正确私钥手工解密 cert
前文的重点是“如何拿到真实私钥”。
而拿到私钥之后,需要继续确认确认:
步骤 1:先用脚本只拿到真实私钥
执行:
python3 ./wnq/tools/restore_wnq_cert.py \
--out-dir ./tmp \
--write-pem \
--verbose | sed -n '1,20p'
确认得到:
实际输出中最关键的是:
numberWithInt stub: 0x1006b7820
wrapper count: 4577
candidate run count: 48
selected candidate: 0x1000baffc - 0x1000c992c (1703 chars)
detected private key bits: 2048
private-key: ./tmp/auto_import_private_key.pem
也就是说,到这一步为止,我们已经有了:
./tmp/auto_import_private_key.pem
步骤 2:手工对 cert 做 base64 解码并按 256 字节分块
执行:
python3 - <<'PY'
from pathlib import Path
import base64
cert_b64 = Path('./wnq/cert').read_text().strip()
raw = base64.b64decode(cert_b64)
print('raw_len =', len(raw))
print('block_count =', len(raw) // 256)
print('remainder =', len(raw) % 256)
PY
当前样本会得到:
raw_len = 34304
block_count = 134
remainder = 0
这一步的意义是:
- 再次确认它确实是完整的
256 字节 RSA 分块流
- 所以接下来完全可以按块做私钥解密
步骤 3:手工用提取出来的私钥分块解密 cert
执行:
python3 - <<'PY'
from pathlib import Path
import base64, subprocess, tempfile, os
cert_b64 = Path('./wnq/cert').read_text().strip()
raw = base64.b64decode(cert_b64)
out = bytearray()
for i in range(0, len(raw), 256):
block = raw[i:i+256]
with tempfile.NamedTemporaryFile(delete=False) as inf:
inf.write(block)
in_name = inf.name
with tempfile.NamedTemporaryFile(delete=False) as outf:
out_name = outf.name
try:
subprocess.run([
'openssl', 'pkeyutl',
'-decrypt',
'-inkey', './tmp/auto_import_private_key.pem',
'-in', in_name,
'-out', out_name,
'-pkeyopt', 'rsa_padding_mode:pkcs1',
], check=True, capture_output=True)
out.extend(Path(out_name).read_bytes())
finally:
os.unlink(in_name)
os.unlink(out_name)
Path('./tmp/manual_decrypted.bin').write_bytes(out)
print('decrypted_len =', len(out))
print('head64_hex =', out[:64].hex())
PY
file ./tmp/manual_decrypted.bin
当前样本的关键输出如下:
decrypted_len = 32659
head64_hex = 7b2264617461223a... [REDACTED]
./tmp/manual_decrypted.bin: JSON data
步骤 4:解析解密后的数据
拿到正确私钥之后,手工分块解密 cert,解密产物的前几个字节直接变成了:
7b 22 64 61 74 61 22 3a ...
把这串十六进制按 ASCII 翻译一下就是:
{"data":...
再结合 file ./tmp/manual_decrypted.bin 的输出:
JSON data
这时能下结论:cert 解密后是一段 JSON 数据。
步骤 5:手工把解密结果按 JSON 打开
执行:
python3 - <<'PY'
from pathlib import Path
import json
obj = json.loads(Path('./tmp/manual_decrypted.bin').read_text())
print('top_keys =', list(obj.keys()))
print('data_keys =', list(obj['data'].keys()))
print('password =', obj['data']['password'])
print('type =', obj['data']['type'])
print('udid =', obj['data']['udid'])
print('p12_prefix =', obj['data']['p12'][:80])
PY
当前样本的输出如下:
top_keys = ['data']
data_keys = ['udid', 'p12', 'provision', 'password', 'type']
password = [REDACTED]
type = private
udid = [REDACTED]
p12_prefix = [REDACTED]
这一步说明:
cert 不是直接 p12
cert 解开后是一份 JSON 响应包
- 真正的
p12 和 mobileprovision 只是这份 JSON 中的两个字段
步骤 6:从 JSON 里提取 p12 和 mobileprovision
执行:
python3 - <<'PY'
from pathlib import Path
import json, base64
obj = json.loads(Path('./tmp/manual_decrypted.bin').read_text())['data']
Path('./tmp/manual_data.p12').write_bytes(base64.b64decode(obj['p12']))
Path('./tmp/manual_data.mobileprovision').write_bytes(base64.b64decode(obj['provision']))
print('password =', obj['password'])
print('p12_size =', Path('./tmp/manual_data.p12').stat().st_size)
print('mobileprovision_size =', Path('./tmp/manual_data.mobileprovision').stat().st_size)
PY
openssl pkcs12 -in ./tmp/manual_data.p12 -passin pass:1 -info -noout | sed -n '1,20p'
当前样本的关键输出如下:
password = [REDACTED]
p12_size = 3251
mobileprovision_size = 21082
MAC: sha256, Iteration 2048
MAC length: 32, salt length: 8
PKCS7 Encrypted data: PBES2, PBKDF2, AES-256-CBC, Iteration 2048, PRF hmacWithSHA256
Certificate bag
PKCS7 Data
Shrouded Keybag: PBES2, PBKDF2, AES-256-CBC, Iteration 2048, PRF hmacWithSHA256
这里的关键结论同样也有明确观察依据:
p12_size = 3251 说明 p12 字段并不是空壳,而是真的能落成独立文件
openssl pkcs12 ... -info -noout 能正常解析,说明这份导出的 p12 是有效的 PKCS#12 容器
mobileprovision_size = 21082 说明 provision 字段也成功还原成了完整文件,对比 wnq 自己包里的 mobileprovision,它们的大小和内容都高度吻合,说明这份 mobileprovision 也是有效的
六、结尾
- AI 真好用
- p12 证书提取成功了,xcode 里能正常安装了
- 附件里有完整的脚本和本文的md,只保证能在当前样本上复现