吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 1645|回复: 12
收起左侧

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

  [复制链接]
MeItemi 发表于 2026-7-6 19:56

前言

以下内容基于Codexgpt-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 本身。

我最后收敛到的关键点是:

  1. AISBundledCertAutoImporter
  2. 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 正确解成上层数据。

验证过程很简单:

  1. 先按函数逻辑把 XOR 混淆的大块文本恢复出来
  2. 把恢复结果当 PEM 私钥加载
  3. 对本地 cert 先做 base64 解码
  4. 256 字节切块逐块做 PKCS1 v1.5 私钥解密
  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 上不够用了,所以最后的适配思路是:

  1. 老样本继续保留 numberWithInt: 路线
  2. 新样本增加一个新的 fallback,例如 wnq27-xor
  3. 当老路线拿不到可用私钥时,转去执行新版的 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

免费评分

参与人数 4吾爱币 +4 热心值 +4 收起 理由
hsiun + 1 + 1 热心回复!
ioyr5995 + 1 + 1 热心回复!
buluo533 + 1 + 1 用心讨论,共获提升!
nnzhs + 1 + 1 谢谢@Thanks!

查看全部评分

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

QGCOOL 发表于 2026-7-7 11:56
太详细,细细研究下!
lei122756700 发表于 2026-7-7 13:16
whzhni 发表于 2026-7-7 16:19
shaoyewudao 发表于 2026-7-7 16:28

感谢分享,学习一下
jin951 发表于 2026-7-8 07:08
有没有高手鼓捣一下今日水印相机的本地会员和扫描全能王的本地会员?
ruaruaRap 发表于 2026-7-8 09:33
学习了 感谢分享
jingxiang6 发表于 2026-7-8 16:20
学习了 感谢分享
guangdate 发表于 2026-7-9 21:14

感谢分享,学习一下。
doomvampire 发表于 2026-7-10 15:38
谢谢楼主分享,不错的
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

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

GMT+8, 2026-7-22 04:39

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

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