吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 3131|回复: 30
上一主题 下一主题
收起左侧

[iOS 原创] 【AI辅助】iOS p12证书签名软件的逆向分析 + 证书获取

  [复制链接]
跳转到指定楼层
楼主
yjr888 发表于 2026-4-1 16:54 回帖奖励

前言

某宝上给 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,以及一个 certSpeedSignV2Config 以及其他文件。从 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 这一步怎么分析

从这里可以马上得到两个结论:

  1. certSpeedSignV2Config 都是文本,不是现成二进制证书文件
  2. cert 是一行很长的文本,长度非常像 base64 容器,而不是 PEM

为什么说它不像 PEM:

  • PEM 一般会有 -----BEGIN ...-----
  • 这里没有头尾标记
  • file 也只是把它识别成普通 ASCII 文本

所以这一步最合理的工作假设是:

  • certSpeedSignV2Config 都是某种编码后的文本
  • 下一步应该尝试做 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 这一步怎么分析

从这段输出里,我们能拿到三个非常关键的方法:

  1. +[YYYCertModel autoImportCert]
  2. +[YYYCertModel importNSKCert:]
  3. +[YYYCertModel certWithResponseData:showMessage:]

这三个方法的角色可以先这样理解:

  • autoImportCert
    更像启动时的入口调度器
  • importNSKCert:
    更像处理本地证书包的逻辑入口
  • certWithResponseData:showMessage:
    更像最终处理导入响应、解析 p12 的统一终点

这时就已经比“全局乱翻函数”高效很多了,因为分析范围一下子缩小到了一个非常具体的方法簇。

3.3 为什么这里先不深挖 certWithResponseData:showMessage:

这里有一个很常见的阅读跳跃,需要先补清楚。

第一次看到这三个方法时,很多人会自然地想:

  • 既然 certWithResponseData:showMessage: 名字这么像“最终导入证书”,是不是应该先分析它?

这个想法不算错,但对当前目标来说,它不是最短路径。

原因是:

  • certWithResponseData:showMessage: 更像统一的下游处理器
  • 它处理的是“已经准备好的响应数据”
  • 它更靠近最终的 p12 解析,而不是最前面的“本地 cert 包如何被解开”

而当前我们最先要解决的问题其实是:

  • cert 这个本地文本包,到底是怎么变成上游响应数据的

所以这里更适合优先看:

  • importNSKCert:

因为它的名字已经直接告诉我们,它就是“导入本地 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 这一段该怎么读

这段汇编几乎就是自动导入主链的“中文直译”:

  1. bl 0x1006b63e0
    对应 mainBundle
  2. bl 0x1006b8260
    对应 pathForResource:ofType:
  3. bl 0x1006c3a20
    对应 standardUserDefaults
  4. bl 0x1006c4300
    对应 stringForKey:
  5. bl 0x1006b4ec0
    对应 isEqualToString:
  6. bl 0x1006c43a0
    对应 stringWithContentsOfFile:encoding:error:

所以这条链可以很稳地翻译成:

mainBundle
-> pathForResource:ofType:
-> 取本地资源路径
-> standardUserDefaults / stringForKey:
-> 比较导入标记
-> stringWithContentsOfFile:encoding:error:
-> 读取本地文本资源

这一步最关键的结论是:

  • 自动导入主链明确依赖本地 bundle 资源
  • 因此 cert 很可能是默认导入的核心输入之一

这就把分析方向从“也许依赖远程接口”彻底拉回到了“本地 cert 包”。

3.7 接下来为什么自然会进入 importNSKCert:

走到这里,下一步其实已经很自然了。

我们已经知道两件事:

  1. autoImportCert 会读取本地文本资源
  2. YYYCertModel 里有一个名字非常直白的方法叫 importNSKCert:

这时最合理的问题就是:

  • autoImportCert 读到本地资源之后,真正解包 cert 的第一层逻辑在哪里?

最强的候选就是:

  • importNSKCert:

所以这里不是突然跳去看两个陌生 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 为什么看到这一段后,就知道应该重点盯住这两个函数

这段调用关系给出的信息其实已经非常强了:

  1. importNSKCert: 先调用 10009aaf0
  2. 10009aaf0 的返回值被保存起来
  3. 然后 importNSKCert: 再调用 10009ae48
  4. 10009aaf0 的返回值被直接当作 10009ae48 的一个输入

也就是说,仅从调用图就可以先得到一个中间结论:

10009aaf0
-> 先准备一个关键输入

10009ae48
-> 拿到两个关键输入后,继续做真正的核心处理

这就是为什么后面会从 importNSKCert: 收敛到这两个函数。

不是因为它们名字特殊,而是因为:

  • 它们位于本地 cert 包主链上
  • 而且前后相连
  • 第一个函数的返回值立刻喂给第二个函数

在教学上,这种模式通常就是“上游准备数据 -> 下游真正处理”的典型信号。


四、集中逆向 10009aaf010009ae48 函数

到这里我们已经知道:

  • 自动导入会读本地文本资源
  • cert 很像一段 RSA 分块密文容器
  • importNSKCert: 是本地 cert 包主链入口
  • 10009aaf0 -> 10009ae48 是这条主链里最先串起来的两层核心 helper

所以接下来真正要回答的是两个更具体的问题:

  1. 解密用的私钥从哪里来
  2. cert 到底是怎么被解开的

这两个问题分别对应:

  • 10009aaf0
  • 10009ae48

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 / arg0x1 / 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 字节切块

这基本已经等于在汇编层面承认:

  • cert 是 RSA 分块密文容器

4.4 1000d33c0 为什么像私钥加载器

接着看:

mov x0, x20
bl 0x1000d33c0

x20 连续调用两次 1000d33c0,这是个很强的信号。

结合之前对 10009aaf0 上游的观察,很容易推断出:

  • x20 很可能是私钥相关输入
  • 1000d33c0 很可能是在把 PEM 字符串装载成 RSA key 对象

这里先不用强行把每个寄存器都扣死,教学上抓住语义就够了:

  • 一个输入被当成 base64 文本解码并切块
  • 另一个输入又被一个“看起来像 key loader”的函数反复处理

这时最自然的工作假设就是:

  • 前者是证书包文本
  • 后者是解包所需的私钥文本

而后续验证也确实如此。

4.5 到这时为什么会反推 10009aaf0 很可能是在准备私钥相关字符串

现在把第三部分和刚才这部分拼起来看:

  1. importNSKCert: 里,10009aaf0 的返回值会立刻被喂给 10009ae48
  2. 10009ae48 里,这个输入又会继续进入像 1000d33c0 这样的“私钥加载”逻辑

所以虽然这时我们还没完全读懂 10009aaf0 的每一行,但已经可以反推出:

  • 10009aaf0 很可能是在准备一段私钥相关文本

这就是“先看下游语义,再回推上游作用”的典型做法。

也正因为如此,10009aaf0 重要,不是因为它名字像私钥函数,而是因为:

  • 它的输出被 10009ae48 当成了解密所需的关键输入

4.6 10009aaf0 的真实作用是什么

虽然 10009aaf0 的完整伪代码又长又乱,但它的语义其实可以压缩成一句话:

  • NSArray<NSNumber> -> 字符串

这个结论不是凭空猜的,而是由两层证据拼起来的:

  1. autoImportCert 里能看到大量 numberWithInt: 调用,说明它确实在构造整数对象序列
  2. 这些整数按顺序还原后,最终能拼出一把合法的 PEM 私钥,而且这把私钥还能把 cert 正确解成 JSON

在这条链里,它做的是:

  1. 遍历一大串 numberWithInt: 生成的整数对象
  2. 把每个整数当成 1 字节字符
  3. 最终拼成完整文本

而拼出来的文本,经过最终验证,就是:

  • 一把真正用于自动导入的 PEM PRIVATE KEY

这一步是整个分析里最关键的纠偏点。

因为如果你没有盯住这一点,就会很容易拿错私钥,然后一直得到错误的中间结果。

4.7 如何真正把这把私钥拿出来

上面虽然已经说明了 10009aaf0 最终拼出来的是私钥,但如果只是停在这个结论,教学上还是不够落地。

这里要再往前补一层:

  • 这把私钥到底是怎么从二进制里拿出来的?
  • 为什么我们后来能确认“这把才是真私钥”?

这一步建议分成“手工理解”和“自动化提取”两层来看。

4.7.1 手工理解层:为什么会想到用 numberWithInt: 去抽整数流

关键线索有两个:

  1. autoImportCert 主链里确实出现了大量 numberWithInt:
  2. 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)

这段源码可以按下面的顺序理解:

  1. 先找到 numberWithInt: 对应的 objc stub
  2. 再把所有最终会跳到这个 stub 的 wrapper 全部找出来
  3. 然后扫描整个 __TEXT.__text,寻找模式:
    • mov w2, #imm
    • 紧跟 bl wrapper
  4. 把命中的 imm 按地址顺序收集起来
  5. 按“地址是否连续”分成多段字符串候选
  6. 只保留同时包含 PEM 头尾标记的候选
  7. 取最长的那段,恢复为真正的私钥

它利用的是“语义锚点”:

  • numberWithInt:
  • 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

这段输出可以这样理解:

  1. numberWithInt stub: 0x1006b7820
    说明脚本已经自动找到了 numberWithInt: 的 objc stub
  2. wrapper count: 4577
    说明它没有只看直连调用,而是把所有最终能跳到 numberWithInt: 的 wrapper 都收进来了
  3. candidate run count: 48
    说明脚本在全局代码段中识别出了 48 段可能的字符串整数流
  4. selected candidate: 0x1000baffc - 0x1000c992c (1703 chars)
    说明它最终挑出了一段长度 1703 的候选,认为这段最像真实 PEM
  5. 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 的私钥

当前样本的验证方式非常直接:

  1. 用这把私钥去分块解 cert
  2. 解密结果直接变成合法数据

也就是说,这把私钥不仅“格式正确”,而且“业务上正确”。

这和前面那把静态抽出来、也能被 OpenSSL 解析但解不出合法数据的 PEM,形成了最关键的对比。

所以这里可以下一个非常稳的结论:

  • 真正要用的不是“随手 strings 出来那把看起来像 PEM 的私钥”
  • 而是 autoImportCert 里通过 numberWithInt: 动态拼出来、并且最终能把 cert 正确解成合法数据的那把私钥

4.8 为什么拿错私钥时一直解不出数据

这里一定要记住一个经验:

  • “能被 OpenSSL 正常解析的 PEM 私钥”
  • 不等于“这条业务链真正使用的私钥”

前面的弯路就是这样来的:

  1. 先从二进制里抽到了一把看起来像 PEM 的私钥
  2. 它也能被 OpenSSL 正常解析
  3. 用它去解 cert,确实能得到一份二进制输出
  4. 但那份输出不是合法数据

后面真正解决问题的关键是:

  • 不再执着于那把“看起来合理”的静态 PEM
  • 而是顺着 autoImportCert -> numberWithInt: 去恢复动态生成的真正私钥

一旦换成这把真实私钥,再去解 cert,结果立刻就是合法数据了。


五、使用正确私钥手工解密 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 响应包
  • 真正的 p12mobileprovision 只是这份 JSON 中的两个字段

步骤 6:从 JSON 里提取 p12mobileprovision

执行:

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,只保证能在当前样本上复现


share.zip

17.83 KB, 下载次数: 50, 下载积分: 吾爱币 -1 CB

python脚本 + markdown

免费评分

参与人数 15吾爱币 +14 热心值 +14 收起 理由
f5888978 + 1 + 1 不用这么udid.23-3.麻烦com 这里可以免费领取一份udid证书
thinque + 1 + 1 谢谢@Thanks!
yeesen + 1 + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!
IceSoldier + 1 用心讨论,共获提升!
HideOnaBush + 1 + 1 热心回复!
woodjecket + 1 + 1 我很赞同!
jiyu0418 + 1 + 1 谢谢@Thanks!
忘了忘记 + 1 + 1 欢迎分析讨论交流,吾爱破解论坛有你更精彩!
allspark + 1 + 1 用心讨论,共获提升!
妹控真是太好了 + 1 + 1 热心回复!
wc614445720 + 1 + 1 太强了!!
eec + 1 + 1 我很赞同!
Quincy379 + 1 + 1 热心回复!
FallingStar + 1 热心回复!
cick + 1 + 1 虽然我可以巨魔

查看全部评分

发帖前要善用论坛搜索功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

推荐
s121091 发表于 2026-4-8 21:43
感谢分享,整个分析过程非常扎实,尤其从 34304 = 134 × 256 推断出 RSA 分块密文这一段,思路很值得学习。
我按你的方法在自己手上的样本里跑了一下,确实顺着 numberWithInt: 的整数流还原出来的那串 PEM 才能正确解密 cert,之前 strings 出来那把静态的私钥虽然格式对但解出来是乱码,这一步的坑踩过才懂。
最后导出 p12 和 mobileprovision 的步骤也很清晰,我已经成功在自己的万能签里提取出了证书,感谢楼主铺路。
沙发
放手一搏09 发表于 2026-4-2 14:28
3#
Souseki 发表于 2026-4-2 14:40
4#
mrliuyiming 发表于 2026-4-2 15:54
那么问题来了 提取出来p12了但是需要密码!!这个就难办了!
5#
Trico 发表于 2026-4-2 16:15
学习一下哦
6#
adiom 发表于 2026-4-2 18:04
这个是很厉害了,利用AI加速逆向破解
7#
yearnxiao 发表于 2026-4-3 09:56
向 大佬致敬,要努力学习了
8#
jiaxin9873 发表于 2026-4-3 11:23
点赞,谢谢分享
9#
watersoft 发表于 2026-4-3 13:56
想问买的证书可靠吗?会不会掉签
10#
abz 发表于 2026-4-3 14:06
开发者证书并非完全可靠,如果一个证书多人安装,或者卖家为了XX导致开发者账户异常,苹果那边会锁手机的 UDID。
导致一个期限(可能是30天也可能是360天)无法买到新证书。
我的手机的 UDID就是被拉黑了,30天无法购买,如果需要立即购买,则价格翻倍(这里的原理搞不明白)
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-4-21 18:50

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表