猿人学 App 第九题「双向证书」离机复现:从 APK 抠出 mTLS 客户端证书

本文最后更新于:2026年9月2日 下午

[猿人学 App 逆向系列]第九题「借问酒家何处有」。这题没有 sign——请求体只有一个 page
它的全部防护压在传输层:服务器开了 mTLS(双向 TLS),握手时要求客户端出示一张 App 内置的客户端证书,拿不出来就连不上,自然也抓不到、爬不动。
破法很直接:把客户端证书从 APK 里抠出来,自己在 Python 里当客户端证书用。难点全在”证书藏在哪、密码是什么”——而这些字符串都被混淆了。

⚠️ 猿人学闯关 App 是面向逆向练习的 CTF 式靶场;本文仅用于授权范围内的学习研究,请遵守相关服务条款与法律。

本文目录

  • 请求模型:没有 sign,只有 mTLS
  • 读 Fragment:KeyManagerFactory 出卖了一切
  • 字符串被混淆:上 JVM 预言机
  • 抠证书:BKS → PKCS12 → PEM
  • 纯 Python 带客户端证书复现

一、请求模型:没有 sign,只有 mTLS

先看 Retrofit 接口,第九题的方法干净得反常:

1
2
3
@FormUrlEncoded
@POST("/api/app9")
o0OoOo0<ChallengeOneResultBean> OooOO0O(@Field("page") Integer num);

只有 page,没有 sign、没有 token。 那防护在哪?看它的 OkHttpClient 是怎么建的——答案在 TLS 层。

二、读 Fragment:KeyManagerFactory 出卖了一切

ChallengeNineFragment.onCreate() 里手搓了一个 SSLContext:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 1) TrustManager:信任一张内置的自签 CA(用来验证服务器)
TrustManagerFactory tmf = TrustManagerFactory.getInstance(...);
tmf.init(OooO()); // OooO() 从内置 PEM 生成 KeyStore

// 2) KeyManagerFactory:加载一个带密码的客户端 KeyStore —— 这就是 mTLS 的关键
KeyStore ks = KeyStore.getInstance("BKS");
ks.load(getAssets().open("<混淆>"), "<混淆密码>".toCharArray());
KeyManagerFactory kmf = KeyManagerFactory.getInstance(...);
kmf.init(ks, "<混淆密码>".toCharArray());

// 3) 组 SSLContext:同时挂 KeyManager(出示客户端证书)+ TrustManager
SSLContext ctx = SSLContext.getInstance("TLS");
ctx.init(kmf.getKeyManagers(), tmf.getTrustManagers(), new SecureRandom());
builder.sslSocketFactory(ctx.getSocketFactory(), (X509TrustManager) tm);
builder.hostnameVerifier(...); // 自定义:比对证书 CN

KeyManagerFactory.init(ks, ...) 是 mTLS 的铁证——普通 HTTPS 客户端不需要 KeyManager,只有服务器要求”你也得给我一张证书”时才配。

于是任务清单很清楚:从 assets 里那个 BKS keystore + 它的密码,拿到客户端证书。 可这两样都是混淆字符串:

1
2
3
ks.load(getContext()...getAssets().open(
o0ooOoOO.o00O0O.OooO00o(-245062378877207L)), // 文件名?
o0ooOoOO.o00O0O.OooO00o(-245126803386647L).toCharArray()); // 密码?

三、字符串被混淆:上 JVM 预言机

o0ooOoOO.o00O0O.OooO00o(long) 是一套自包含的位运算解密(内部 o00Oo0/o00Ooo 做移位/异或的 PRNG)。这种”纯算术、不依赖 Android API”的解密函数,最省事的还原方式不是照着抠算法重写,而是把这几个 class 拉出来直接在 JVM 里调——预言机对拍,零翻译误差:

1
2
3
4
5
6
7
8
// Decrypt9.java —— classpath 挂 dex2jar 出的 app.jar
public class Decrypt9 {
public static void main(String[] a) {
long[] offs = { -244740256330007L, -245113918484759L, -245062378877207L,
-245126803386647L, -244641472082199L /* ... */ };
for (long o : offs) System.out.println(o0ooOoOO.o00O0O.OooO00o(o));
}
}
1
2
3
d2j-dex2jar base.apk -o app.jar           # dex -> jar
javac -cp app.jar Decrypt9.java
java -cp "app.jar;." Decrypt9

一把梭,全出来了:

混淆偏移 明文
baseUrl https://47.95.8.136:8443
KeyStore 类型 BKS
客户端证书文件 clientCA.bks
keystore / key 密码 MZ4cozY8Qu32UzGe
SSLContext TLS
CertificateFactory X.509
HostnameVerifier CN 前缀 CN=

服务器是 47.95.8.136:8443(和第四/八题同一台机的另一个端口),客户端证书是 assets/clientCA.bks,BKS 格式,密码 MZ4cozY8Qu32UzGe

四、抠证书:BKS → PKCS12 → PEM

从 APK 里取出证书文件:

1
unzip -j base.apk assets/clientCA.bks

BKS 是 BouncyCastle 的 keystore 格式,Python 的 ssl/cryptography 不认。先用带 BC provider 的 keytool 把它转成通用的 PKCS12:

1
2
3
4
5
keytool -importkeystore \
-srckeystore clientCA.bks -srcstoretype BKS -srcstorepass MZ4cozY8Qu32UzGe \
-destkeystore client9.p12 -deststoretype PKCS12 -deststorepass MZ4cozY8Qu32UzGe \
-providerclass org.bouncycastle.jce.provider.BouncyCastleProvider -providerpath bcprov.jar
# 已成功导入别名 1 的条目 (PrivateKeyEntry:客户端私钥 + 证书都在)

再用 cryptography 把 PKCS12 拆成证书 / 私钥 PEM:

1
2
3
4
5
from cryptography.hazmat.primitives.serialization import pkcs12, Encoding, PrivateFormat, NoEncryption
key, cert, _ = pkcs12.load_key_and_certificates(open("client9.p12","rb").read(), b"MZ4cozY8Qu32UzGe")
open("client9_cert.pem","wb").write(cert.public_bytes(Encoding.PEM))
open("client9_key.pem","wb").write(key.private_bytes(Encoding.PEM, PrivateFormat.TraditionalOpenSSL, NoEncryption()))
# cert.subject -> CN=xx,OU=xx,O=xx,... (自签,和服务器同一套 CA)

五、纯 Python 带客户端证书复现

有了客户端证书 + 私钥,requestscert= 参数直接就能做 mTLS。服务器证书是 CN=xx 的自签、又不是域名/IP,主机名对不上,所以 verify=False 跳过服务器校验(mTLS 的关键是我们出示客户端证书,不是去验服务器):

1
2
3
4
5
6
7
8
9
10
11
12
13
import requests, urllib3
urllib3.disable_warnings()

URL = "https://47.95.8.136:8443/api/app9"
CERT = ("client9_cert.pem", "client9_key.pem") # 客户端证书 + 私钥

sess = requests.Session()
total = 0
for page in range(1, 101):
r = sess.post(URL, data={"page": page}, cert=CERT, verify=False, timeout=20)
vals = [int(x["value"]) for x in r.json()["data"]] # 每页 20 个数
total += sum(vals)
print("ANSWER =", total)

跑通:status 200,每页 20 个数正常返回,1-100 页求和即得答案。不带 cert= 时握手直接失败——服务器在 ClientHello 之后就要证书,这正是 mTLS 的拦法。

小结

第九题是本系列里少见的”算法零难度、传输层设卡“的题:

  1. 没有 sign,别去请求体里找签名——防护在 TLS 层;
  2. KeyManagerFactory.init(keystore, pwd) 是 mTLS 的标志,顺藤摸瓜找客户端证书;
  3. 关键字符串(证书文件名、密码)被混淆,JVM 预言机直接调解密函数最稳,不用抠算法;
  4. 证书是 BKS 格式,keytool + BouncyCastle 转 PKCS12 再拆 PEM;
  5. requestscert=(证书, 私钥) 就能离机做 mTLS。

一句话:双向证书的”钥匙”就打包在 APK 里,把它抠出来,你就是合法客户端。


猿人学 App 第九题「双向证书」离机复现:从 APK 抠出 mTLS 客户端证书
https://kingjem.github.io/2026/09/02/猿人学App第九题双向证书离机复现-抠出mTLS客户端证书/
作者
Ruhai
发布于
2026年9月2日
许可协议