因为发现最近 ttnk 在玩这个,咱也玩一下。因为不擅长二进制,所以玩一玩爪洼岛。
我们此次目标是 fatvpn.app ,我们从它的官网上获取了 apk,在手机上简单使用 mt 管理器查阅后发现 mt 管理器不能满足咱们的要求,故而 mt管理器 解 classes.dex 为 classes.jar 。后用 JavaOctetEditor 反编译了其 classes.jar
根据 resource.arsc,查询其 “小胖攀登中...” (vpn_connecting)
0x7f0f00cd 对应的是 2131689677
常量搜索
等一下?这家伙没有混淆?一部分有,一部分没有。
从没混淆的地方开始看,com.fat.vpn.data.remote.response.VpnServerNodesResponse
关键逻辑代码
//z7.j # public final Object p(Object obj)
VpnNodeEncryptedResponse vpnNodeEncryptedResponse = (VpnNodeEncryptedResponse) obj;
return new com.google.gson.j().b(e0.b(e0.G(vpnNodeEncryptedResponse.getKey()), vpnNodeEncryptedResponse.getData()), VpnServerNodesResponse.class);
// j5.e0
public static String b(String var0, String var1) {
e.m(var1, "sSrc");
try {
MessageDigest var4 = MessageDigest.getInstance("MD5");
Charset var2 = a.a;
byte[] var7 = var0.getBytes(var2);
//e.l(var7, "this as java.lang.String).getBytes(charset)");
SecretKeySpec var3 = new SecretKeySpec(var4.digest(var7), "AES");
IvParameterSpec var11 = new IvParameterSpec(new byte[]{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15});
Cipher var8 = Cipher.getInstance("AES/CBC/PKCS5Padding");
var8.init(2, var3, var11);
byte[] var9 = Base64.decode(var1, 0);
byte[] var10 = var8.doFinal(var9, 0, var9.length);
//e.l(var10, "doFinal(...)");
var0 = new String(var10, var2);
} catch (Exception var5) {
var5.printStackTrace();
var0 = var5.toString();
}
return var0;
}
public static String G(String var0) {
e.m(var0, "data");
byte[] var1 = Base64.decode(
"MIIEogIBAAKCAQB/xKmjpGbc6MdYYp0v32JLpIaOvyTrhupgWWcQfRFyuk51p3fo\nf6Eh+M3qp00yQzHtP/MkRP6Ldc5DOP6D9b2s522s7vpzsG3LtBenZZ8xN4usMBuO\nTQzJTvLfCjfLl0gXBDJneKjHPKxLxaPmsFwTS1Mi3cZEMNoA8ns9hmdDwcsCLBAH\nzTjpNcfQN+EQw7mRbKK4n14lLzSfYjjScuE/Oj8WOpy5Wl0/UAoHRLbujPNU26hJ\nlpQa2S6ipvFIBZHjhaU4AesST55XBOLDmPNCaKQV9Nf9RJ2GABazioPAOo+Q7iEf\nEXysNcuhdP7q8gXJ0oNHdyJl6PAZZddiZyB7AgMBAAECggEASJMC8Orvasfmg7Pw\nKUMv6FuZ+vdkF0zZUMU3n8wK3yooavgnSi9E7bEP9hv143j7oRHUIGP4WmseMFzt\nZTNu/Amw6KwOIyyyESVI0lMM673rXnEtFdV6T9bCaiK5srFJx5kgsFl/NTyneZrY\nEK9YfbUpkgJ7HjzJeAREMJxph7h7FsSd2M+MB7m/J+6TGZ6fIsZFcy0vIQLjLSAI\nOuO5T1HEB+m7AFHyvXdAfR+BWKSTIs0pkC2wUgbE6CsMlzx3XvM1DnmUiDF478t0\nUFKEnpQKsnmRSweH7htbmhigiP9kHcidJ8+PNqojy+DGsCjuIoSiOqgGEs1+woYS\nw34pIQKBgQDPmwiPNYxM2OYP7onoBhsBKDmdxGSUnEFmUgmtPpEy5PssJDz/SEoV\nponyW/dPEycn8Bjc+6fnjUi0RuqE2e3JnpSWrhhLBEFhrd16bHVwaHA40IWM8UYs\ngUjmTPdt2DO9+M8ZW+UADUAAlARm/NSZo+Z4PzTBIjW4e4xhxm/2AwKBgQCdjUs4\nVC0qdMHT5ntE5IWIagrXxshacaE8rTAgjYOMaib0Skq593yqksb1kiIF3CeWZACd\nd6qk6Gah5Oc755K8wuixxXZY8vzwzLf5K3NqFkbAhxR251kZoQvmoP/OJDbBxO0Y\n86auuX7m498vIh7raoVWjzXKSHK2/ZyTfEw+KQKBgFH5r7mMtWeqxb1IvZ+muYcN\ncSLA585enNxgTH3iFMd570wQyx0qWEaQSiwu8EqDD5UPk2G+5R/jg+/biMMIooJY\nYefVurX0ajS9yJSMuxq1wopMnE94/fKY4kY94f23v0amNnCW/qe0k68mw04/S1uX\ngmu82YHhlkDQWDBLgO4tAoGAS6xv8rBLuVa3OoY7sw1oLetxJc7usLJfVXuB4EDY\nbHsYFsIQPl5m3K7/LThxawshYJTLztaJege+NAh0IEvMKSodBjXn8DVV1Hsf6mg6\nWTw144d+BtZ771lxE+dEtsiiHFPv5coxxz6Fe3T73/GtlDlnrfm/Rleh8c7Cg/xx\nynECgYEAkIj8otP2kFIqR+5tTl2u3wINlVyPONyWKT0GNWN9am80sUMpYYvI5TUm\nJ47+2709GgiYQWZxSsNCqVPuKM57u1czOo7iRqD2UavL3GiN4zmBbGQ+4TqwPJb1\nk2Y8dpIMwIGRKT9GWLexXYqBLK1JJV3TrJ4CjEA9A/Txy/HByZg=",
0
);
e.l(var1, "decode(...)");
PrivateKey var2 = KeyFactory.getInstance("RSA").generatePrivate(new PKCS8EncodedKeySpec(var1));
Cipher var4 = Cipher.getInstance("RSA/ECB/OAEPWithSHA1AndMGF1Padding");
var4.init(2, var2);
byte[] var3 = var4.doFinal(Base64.decode(var0, 0));
e.l(var3, "doFinal(...)");
return new String(var3, a.a); //Charset.forName("UTF-8")
}
直接请求,521,太乱了不想分析,丢给 Gemini :
这段代码是经过 Jadx 反编译后还原的 Kotlin 协程(Coroutine)代码。虽然它是用 Java 语法表达的,但里面的 label 状态机、-2147483648(Integer.MIN_VALUE)以及 ih.a.s(挂起标志 COROUTINE_SUSPENDED)都是非常典型的 Kotlin 挂起函数编译后的底层实现。
它的核心功能是:一个带有动态签名(Authorization)验证的网络请求,用于获取 VPN 节点数据。
下面我为你把这段晦涩的反编译代码拆解开,看看它每一步到底做了什么:
1. 状态机与协程初始化 (续借与挂起)#
代码一开头和中间的 if (var27 != 0) 分支,是 Kotlin 协程处理异步挂起的经典状态机(State Machine)逻辑。
var26其实是协程的Continuation(续借对象),里面记录了当前执行到第几步(状态K)。- 状态 0: 第一次进入函数,执行网络请求的初始化、签名计算,并发送第一个异步请求。之后函数会直接返回
ih.a.s(即COROUTINE_SUSPENDED,挂起)。 - 状态 1: 当第一个网络请求有结果后,协程框架恢复(Resume)调用此函数,直接跳到状态 1 继续执行。
- 状态 2: 处理反序列化,拿到最终的
VpnNodeEncryptedResponse数据。
2. 核心业务逻辑拆解(网络请求与动态签名)#
当状态为 0 时(首次触发),它开始构建网络请求。这里使用了类似 Ktor 或者是 Koin 依赖注入的网络客户端。
第一步:设置 URL 与基础配置#
lf.c var6 = new lf.c(); // 这通常是一个 HttpClient 或者是 HttpRequestBuilder
j0.b(var6.a, "https://api.fatvpn.pro/api/v2/vpnServerNodes?version=84");
pa.a.P(var6, new d("text", "plain", u.s)); // 设置 Content-Type 为 text/plain
它配置了你要访问的那个 URL,并指定了数据格式。
第二步:动态生成 Authorization 签名(重点:防爬核心)#
接下来这一大段 try-catch 是全自动生成安全请求头的核心算法。它通过 Android 系统获取了应用自身的签名(Certificate SHA1 指纹):
- 获取 Context: 通过 Koin 依赖注入框架拿到当前 App 的
Context。
var7 = mj.a.b; // Koin 注入检查
var10 = (Context)var7.e(...);
var10 = var10.getApplicationContext();
- 提取 SHA1 指纹: 读取当前 APK 的签名信息(底层调用了类似
getPackageManager().getPackageInfo(..., GET_SIGNATURES)的操作,这里被封装在i.f(var10)中),并将其转化为字节数组,然后计算它的 SHA1 哈希值。
byte[] var18 = i.f(var10).toByteArray();
byte[] var19 = MessageDigest.getInstance("SHA1").digest(var18);
- 转大写字符串: 将 SHA1 字节数组转换为形如
AA:BB:CC...的十六进制字符串,并强制转换为大写。
String var20 = i.a(var19);
var16 = var20.toUpperCase(Locale.getDefault());
- Base64 与切片处理: * 将这个大写的 SHA1 字符串再转成字节,进行 Base64 编码 得到
var21。
- 接着创建了一个范围对象
xh.e(0, 15, 1)(代表从 0 到 15)。 - 截取 Base64 字符串的前 16 位字符(
substring(0, 15 + 1))作为最终的密钥。
String var21 = Base64.encodeToString((byte[])var28, 0);
String var22 = var21.substring(0, 16); // 截取前16位
- 强加 Request Headers:
将截取出来的 16 位字符串作为
Authorization请求头传过去。
oc1.u(var6, "Authorization", var22); // 写入 Header
oc1.u(var6, "Cache-Control", "no-cache");
💡 为什么请求这个 URL 需要这样? > 难怪你直接请求这个 URL 拿不到数据,因为服务器在后台会校验你的
Authorization请求头。而这个请求头是基于正版 App 证书的 SHA1 指纹经过SHA1 -> UpperCase -> Base64 -> Substring(0,16)动态计算出来的。如果你用浏览器或普通 cURL 去请求,由于没有这个特定 Header,服务器会直接拒绝(返回 401 或 403)。
第三步:发起异步网络请求#
k var23 = new k(var6, this.a);
var26.K = 1; // 状态机切换到 1
var28 = var23.b(var26); // 发起挂起请求(网络I/O)
这里使用了协程挂起。当网络请求还在路上时,函数直接 return 退出,不阻塞主线程。
第四步:解析加密的返回数据#
当请求成功回来,状态机进入 状态 1 或状态 2:
b var24 = ((mf.c)var10).b();
var28 = x.b(VpnNodeEncryptedResponse.class); // 获取该类的反射信息
// ...
var26.K = 2; // 状态机切换到 2
var10 = (Context)var24.a((ag.a)var28, var26); // 反序列化
服务器返回的二进制流在这里被反序列化成了 VpnNodeEncryptedResponse 对象。
注意这个类的名字:VpnNodeEncryptedResponse(VPN节点加密响应)。这意味着即使你突破了前面的 Authorization 认证拿到了二进制流,里面的内容依然是经过加密的(可能包含密文和 IV 偏移量),需要 App 内部的解密算法(如 AES)解密后,才能看到真正的节点 IP、端口和配置。
总结:如何模拟这个请求?#
如果你想自己写程序成功抓到这个接口的数据,你需要:
- 拿到这个 APK 官方正版的 SHA1 证书指纹 字符串(例如:
7A:4B:92...)。 - 将该字符串转为大写,做 Base64 编码,截取前 16 位。
- 伪装 HTTP 请求,在 Headers 中加入:
Authorization: <你计算出的16位字符串>Cache-Control: no-cacheContent-Type: text/plain
- 发送请求,拿到二进制流后,再去代码里全局搜索
VpnNodeEncryptedResponse这个类,找出它的解密(Decrypt)方法。
于是让 Gemini 写了代码
import base64
import hashlib
import json
import requests
from Crypto.Cipher import AES, PKCS1_OAEP
from Crypto.Hash import SHA1
from Crypto.PublicKey import RSA
from Crypto.Util.Padding import unpad
# 1. 填入你从反编译代码中拿到的硬编码 RSA 私钥 Base64
RSA_PRIVATE_KEY_B64 = """
MIIEogIBAAKCAQB/xKmjpGbc6MdYYp0v32JLpIaOvyTrhupgWWcQfRFyuk51p3fo
f6Eh+M3qp00yQzHtP/MkRP6Ldc5DOP6D9b2s522s7vpzsG3LtBenZZ8xN4usMBuO
TQzJTvLfCjfLl0gXBDJneKjHPKxLxaPmsFwTS1Mi3cZEMNoA8ns9hmdDwcsCLBAH
zTjpNcfQN+EQw7mRbKK4n14lLzSfYjjScuE/Oj8WOpy5Wl0/UAoHRLbujPNU26hJ
nlpQa2S6ipvFIBZHjhaU4AesST55XBOLDmPNCaKQV9Nf9RJ2GABazioPAOo+Q7iEf
EXysNcuhdP7q8gXJ0oNHdyJl6PAZZddiZyB7AgMBAAECggEASJMC8Orvasfmg7Pw
KUMv6FuZ+vdkF0zZUMU3n8wK3yooavgnSi9E7bEP9hv143j7oRHUIGP4WmseMFzt
ZTNu/Amw6KwOIyyyESVI0lMM673rXnEtFdV6T9bCaiK5srFJx5kgsFl/NTyneZrY
EK9YfbUpkgJ7HjzJeAREMJxph7h7FsSd2M+MB7m/J+6TGZ6fIsZFcy0vIQLjLSAI
OuO5T1HEB+m7AFHyvXdAfR+BWKSTIs0pkC2wUgbE6CsMlzx3XvM1DnmUiDF478t0
UFKEnpQKsnmRSweH7htbmhigiP9kHcidJ8+PNqojy+DGsCjuIoSiOqgGEs1+woYS
w34pIQKBgQDPmwiPNYxM2OYP7onoBhsBKDmdxGSUnEFmUgmtPpEy5PssJDz/SEoV
ponyW/dPEycn8Bjc+6fnjUi0RuqE2e3JnpSWrhhLBEFhrd16bHVwaHA40IWM8UYs
gUjmTPdt2DO9+M8ZW+UADUAAlARm/NSZo+Z4PzTBIjW4e4xhxm/2AwKBgQCdjUs4
VC0qdMHT5ntE5IWIagrXxshacaE8rTAgjYOMaib0Skq593yqksb1kiIF3CeWZACd
nd6qk6Gah5Oc755K8wuixxXZY8vzwzLf5K3NqFkbAhxR251kZoQvmoP/OJDbBxO0Y
86auuX7m498vIh7raoVWjzXKSHK2/ZyTfEw+KQKBgFH5r7mMtWeqxb1IvZ+muYcN
cSLA585enNxgTH3iFMd570wQyx0qWEaQSiwu8EqDD5UPk2G+5R/jg+/biMMIooJY
YefVurX0ajS9yJSMuxq1wopMnE94/fKY4kY94f23v0amNnCW/qe0k68mw04/S1uX
gmu82YHhlkDQWDBLgO4tAoGAS6xv8rBLuVa3OoY7sw1oLetxJc7usLJfVXuB4EDY
bHsYFsIQPl5m3K7/LThxawshYJTLztaJege+NAh0IEvMKSodBjXn8DVV1Hsf6mg6
WTw144d+BtZ771lxE+dEtsiiHFPv5coxxz6Fe3T73/GtlDlnrfm/Rleh8c7Cg/xx
ynECgYEAkIj8otP2kFIqR+5tTl2u3wINlVyPONyWKT0GNWN9am80sUMpYYvI5TUm
J47+2709GgiYQWZxSsNCqVPuKM57u1czOo7iRqD2UavL3GiN4zmBbGQ+4TqwPJb1
k2Y8dpIMwIGRKT9GWLexXYqBLK1JJV3TrJ4CjEA9A/Txy/HByZg="
"""
# 固定配置
AES_IV = bytes([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15])
TARGET_URL = "https://api.fatvpn.pro/api/v2/vpnServerNodes?version=84"
def decrypt_rsa_key(encrypted_key_b64: str) -> str:
"""第一步:使用硬编码的 RSA 私钥解密出临时字符串密钥"""
# 格式化私钥文本
key_data = RSA_PRIVATE_KEY_B64.strip().replace("\n", "")
private_key_bytes = base64.b64decode(key_data)
# 导入私钥
rsa_key = RSA.import_key(private_key_bytes)
# 注意 Java 层的 Cipher.getInstance("RSA/ECB/OAEPWithSHA1AndMGF1Padding")
# 对应 Crypto 库中需要显式指定 hashAlgo=SHA1
cipher_rsa = PKCS1_OAEP.new(rsa_key, hashAlgo=SHA1)
encrypted_bytes = base64.b64decode(encrypted_key_b64)
decrypted_bytes = cipher_rsa.decrypt(encrypted_bytes)
return decrypted_bytes.decode("utf-8")
def decrypt_aes_data(temp_key_str: str, encrypted_data_b64: str) -> str:
"""第二步:将临时密钥转成 MD5 作为 AES Key,用 AES-128-CBC 解密出真正数据"""
# 1. 计算临时密钥字符串的 MD5
md5_hash = hashlib.md5()
md5_hash.update(temp_key_str.encode("utf-8"))
aes_key = md5_hash.digest() # 16字节的二进制,对应 AES-128
# 2. 初始化 AES 解密器 (CBC 模式)
cipher_aes = AES.new(aes_key, AES.MODE_CBC, AES_IV)
# 3. 解密并移除 PKCS5/PKCS7 填充
encrypted_bytes = base64.b64decode(encrypted_data_b64)
decrypted_padded = cipher_aes.decrypt(encrypted_bytes)
decrypted_bytes = unpad(decrypted_padded, AES.block_size)
return decrypted_bytes.decode("utf-8")
def main():
# 伪装请求头,避免被风控(Authorization 根据你前一步抓包或计算的结果填入)
# 如果前面分析的 Authorization 生成无误,可以自行计算填入。这里假设你可以通过抓包拿到现成的,或者不需要
headers = {
"User-Agent": "Ktor client",
"Cache-Control": "no-cache",
"Content-Type": "text/plain",
"Authorization": "N0Q6Q0Y6Nzg6RDE6"
}
print(f"正在请求接口: {TARGET_URL} ...")
try:
response = requests.get(TARGET_URL, headers=headers, timeout=15)
if response.status_code != 200:
print(f"请求失败,状态码: {response.status_code}")
return
# 拿到服务器返回的加密 JSON 对象 (包含 key 和 data 字段)
encrypted_json = response.json()
encrypted_key = encrypted_json.get("key")
encrypted_data = encrypted_json.get("data")
if not encrypted_key or not encrypted_data:
print("返回的 JSON 数据结构不完整,未找到 key 或 data 字段。")
return
print("成功获取加密数据,开始执行 RSA 解密...")
# 1. 解密出中间 key
temp_key = decrypt_rsa_key(encrypted_key)
print("RSA 解密成功,开始执行 AES 解密...")
# 2. 用中间 key 解密出真正的明文 JSON
clear_text_json_str = decrypt_aes_data(temp_key, encrypted_data)
print("\n🎉 解密成功!标准的明文 JSON 如下:")
# 格式化输出最终的 JSON
final_json_object = json.loads(clear_text_json_str)
print(json.dumps(final_json_object, indent=4, ensure_ascii=False))
except Exception as e:
print(f"解密过程中发生错误: {e}")
if __name__ == "__main__":
main()
于是成功 Cloudflare 521
怎么又失败呢?呜呜呜!
干脆直接在隔离环境中跑一遍好了。把应用下载一下,平板上跑一跑,唔的咋回事你怎么自己的也连接不了wc。
{
"type": "https://developers.cloudflare.com/support/troubleshooting/http-status-codes/cloudflare-5xx-errors/error-521/",
"title": "Error 521: Web server is down",
"status": 521,
"detail": "Cloudflare attempted to connect to the origin web server, but the connection was refused. The origin is down, blocking Cloudflare IPs, or not accepting connections on the configured port.",
"instance": "a050eeb8dfa3ba3c",
"error_code": 521,
"error_name": "origin_down",
"error_category": "origin",
"ray_id": "a050eeb8dfa3ba3c",
"timestamp": "2026-06-01T20:22:48Z",
"zone": "api.fatvpn.pro",
"cloudflare_error": true,
"retryable": true,
"retry_after": 120,
"owner_action_required": true,
"what_you_should_do": "**Wait and retry.** Back off for at least 120 seconds. If the error persists, the origin is offline or blocking Cloudflare — the website operator must restart the origin or whitelist Cloudflare IPs.",
"footer": "This error was generated by Cloudflare on behalf of the website owner."
}
抓取失败,原本的应用也打不开。
算了,换一个标的,反正咱的请求和真应用的请求差不多,就算咱是成功的好了。
之前把平板用 Any webview 弄了一下,到了 108,刚刚掉回了 74,几年过去,于是 Lsposed 竟年久失修了,呜呜叹息。
于是继续吧,随便去网络上搜索 “vpn 安卓”,也不逆向了,下载后直接抓网络包先。
接下来,模板更改为 com.yingmao.clash ,代号 八爪鱼,来自 https://who168.xunshanpro.com/182235/bzy.apk
捕捉到其请求
GET https://app.bazhuayujiasu.cc:8700/7air2vr7ikiqzgvxz/api/v1/client/subscribe?token=26bdf16e3bbf55131c9ea517e5570a82&flag=clash
直接导入v2rayNG试试看👀
去除&flag=clash,成功导入。
注意到其请求中,7air2vr7ikiqzgvxz 和 token 为魔法,如果更改,会是什么效果呢?
{
"message": "token is error"
}
于是再看看如果改动另一个魔法数字
改了也不行。
那么token和这个魔法数字是从网络来的吗?
查到了,前一个魔法数字写死的,后一个传参过去的。 重新下载安装,发现token一致。要么通过账户密码拼接,要么是通过某次秘闻传递。
通过关键词 flag=clash,查到了 c9.p 其拼接字符串部分物料来自 BeforeNodeChooseActivity 来自 UIMainActivity.Companion.getSubUrl
于是尝试,找到了 httpSuccess 方法(诡异的,为什么都是只混淆了一半,另一半不混淆),于是是来自 SubscriptionRsp 其实这里插入个hook或者像之前那样直接测网络链接即可。但是咱需要全合成!于是HttpApi,唔的注解驱动,kotlin原来那么好用嘛?于是找到 getAirportNode
@Nullable
@POST("/netbarcloud/vpn/airportNode.do")
@Headers({"User-Agent: Octopus_Android"})
Object getAirportNode(@NotNull @QueryMap Map<String, String> map, @NotNull @Header("token") String str, @NotNull Continuation<? super SubscriptionRsp> continuation);
草,怎么传的是 null
好多注解和咳特灵,绕晕了。 败北,绕晕了。
