本文记录了一次完整的"人+AI协作逆向"过程------从一条抓包请求出发,层层递进,最终还原出某航空App的请求体加密算法。重点不在于最终那个Key是什么,而在于AI在整个过程中的推理链路、卡点突破方式,以及那些让人眼前一亮的"骚操作"。关键信息已脱敏
一、起因:一条"看不懂"的请求
某航空App的航班查询接口,抓出来的请求长这样:
http
POST https://app.xxxairlines.com.cn
Content-Type: application/json; charset=utf-8
{
"protocol": {
"functionCode": "fight_query",
"fromPlatform": "***"
},
"param": {
"data": "nNcZPQJo3lUzIueHnAF1HeN1Qxal1Cvvqll1c//aLoLSlDC03CDu2b6rcIcWwdNkwdtnS4wr6P9ULX6cRK5nJs9W/y7gbdebIek6+uSjaCtNloTBGt3Gbp2B2lJTyExyZNJlDXb5hcMBrukW9RvhPdYuhNaA+T/Qfg899A9YUoQKRJjh2FOcpj0LtFUdTQeq5zpXtRUogfA1JuhHkhYkmQ=="
},
"clientInfo": {
"pixelRatio": 2.75,
// ... 大量设备指纹字段
}
}
目标很明确:param.data 这个 Base64 字符串是怎么生成的?
二、我给了AI什么
在开始之前,先交代清楚"人"给"AI"提供了哪些材料------这决定了AI能做到什么程度。
| 材料 | 作用 |
|---|---|
| 完整 curl 请求样本 | 提供密文、header、请求体结构 |
| JADX反编译源码目录 | 第一轮搜索入口 |
| 原始APK | 备用,确保不丢类 |
| dump出来的dex文件 | 关键------源码丢方法体后,靠它补救 |
| 响应JSON样本 | 用于验证解密是否正确 |
关键认知:如果只有JADX反编译源码,这件事做不成。 因为核心方法体被丢了。后面的突破全靠dump的dex + DEX二进制解析。
三、AI的完整推理链路
第1步:从curl反推请求架构
拿到curl之后,AI做的第一件事不是在源码里搜"加密"两个字,而是先判断这个请求是从哪里发出来的。

在目标App的源码包里搜索 fight_query、encryptswitch 等关键字符串,搜索结果非常少。但找到了几个关键线索:
AppSettings.java里有WEBVIEW_USER_AGENT = "Fabric@App/3.4.0"clientInfo里的字段(pixelRatio、safeArea、wifiName、windowHeight)全部来自SystemInfo.javaSystemInfo是通过UtilsJavascriptInterface暴露给JS的
结论:这个请求不是Native Retrofit发出的,而是H5/WebView通过JSBridge调Java HTTP通道发出的。 这意味着加密逻辑可能在Java侧,也可能在H5/JS侧,需要进一步缩小范围。
这一步的"骚"点: AI没有一上来就猜AES,而是先做了一次架构判断------搞清楚请求是谁发的、从哪发的。这个判断直接影响了后续搜索方向。
第2步:定位加解密入口
确定了JSBridge架构后,搜 encryptDecrypt、AES、encrypt 等关键词:
arduino
AppBridgePlugin.java:265 → @JavascriptInterface public void encryptDecrypt(String str, String str2)
EncryptDecryptOption.java → dataString、isEncrypt 字段
AppAESUtil.java → DEFAULT_CIPHER_ALGORITHM = "AES/ECB/PKCS5Padding"
初步判断:
param.data是 Base64 编码的 AES 密文(密文解码后160字节,16字节对齐,符合AES+PKCS padding)- 加密入口是
AppBridgePlugin.encryptDecrypt→AppAESUtil.encrypt - 算法是 AES/ECB/PKCS5Padding

但问题来了------AppAESUtil.java 里关键方法体全是空的:
java
// JADX反编译结果------方法体丢了!
public static String encrypt(String str) {
return null; // ← 实际逻辑缺失
}
private static String getKey() {
throw new UnsupportedOperationException("Method not decompiled");
}
只能看到常量:
java
public static byte[] bytesKey = {66, 71, 52, 57, 57, 68, 50}; // ASCII: BG499D2

第3步:DEX头部修复------AI自己写脚本修dump
JADX反编译丢失方法体,必须回到DEX文件。但4个dump出来的DEX在jadx里全报错:
Bad dex file checksum

AI的判断是:这些dex是脱壳工具dump出来的,header里的checksum/signature/file_size还是壳运行期的临时值,没有被回填。 于是直接写了一版DEX头部修复脚本,重算adler32、SHA1、file_size:
scss
核心思路:
1. file_size → 用实际文件大小覆盖
2. signature → SHA1(data[32:]) 重算
3. checksum → adler32(data[12:]) 重算(必须在signature修复之后,因为checksum覆盖范围包含signature)
修复代码:
python
import hashlib
import struct
import sys
import zlib
from pathlib import Path
DEX_MAGIC_PREFIXES = (b"dex\n", b"dey\n")
# header 里几个关键偏移
OFFSET_CHECKSUM = 0x08 # uint32 adler32(file[12:])
OFFSET_SIGNATURE = 0x0C # 20 byte SHA1(file[32:])
OFFSET_FILE_SIZE = 0x20 # uint32 文件总字节数
def looks_like_dex(buf: bytes) -> bool:
# 简单识别一下文件头,避免把非 dex 文件改坏
return len(buf) >= 0x70 and buf[:4] in DEX_MAGIC_PREFIXES
def fix_one(path: Path, in_place: bool = False) -> dict:
raw = path.read_bytes()
if not looks_like_dex(raw):
return {"path": str(path), "ok": False, "reason": "not a dex (magic mismatch)"}
data = bytearray(raw)
report = {"path": str(path), "ok": True}
# 1) 修 file_size: 直接用真实文件长度
old_file_size = struct.unpack_from("<I", data, OFFSET_FILE_SIZE)[0]
new_file_size = len(data)
if old_file_size != new_file_size:
struct.pack_into("<I", data, OFFSET_FILE_SIZE, new_file_size)
report["file_size"] = {"old": old_file_size, "new": new_file_size}
# 2) 修 signature: SHA1(data[32:])
new_sig = hashlib.sha1(bytes(data[32:])).digest()
old_sig = bytes(data[OFFSET_SIGNATURE:OFFSET_SIGNATURE + 20])
data[OFFSET_SIGNATURE:OFFSET_SIGNATURE + 20] = new_sig
if old_sig != new_sig:
report["signature"] = {"old": old_sig.hex(), "new": new_sig.hex()}
# 3) 修 checksum: adler32(data[12:])
# 必须在 signature 修完之后再算,因为 checksum 的覆盖范围包含 signature 本身
new_cksum = zlib.adler32(bytes(data[12:])) & 0xFFFFFFFF
old_cksum = struct.unpack_from("<I", data, OFFSET_CHECKSUM)[0]
struct.pack_into("<I", data, OFFSET_CHECKSUM, new_cksum)
if old_cksum != new_cksum:
report["checksum"] = {
"old": f"0x{old_cksum:08x}",
"new": f"0x{new_cksum:08x}",
}
# 4) 写回
out_path = path if in_place else path.with_name(path.stem + "_fixed.dex")
out_path.write_bytes(bytes(data))
report["output"] = str(out_path)
return report
def main():
if len(sys.argv) < 2:
print("usage: python fix_dex_header.py <dex_file_or_dir> [--inplace]")
sys.exit(1)
target = Path(sys.argv[1])
in_place = "--inplace" in sys.argv
if target.is_file():
files = [target]
else:
files = sorted(target.rglob("*.dex"))
if not files:
print(f"no .dex found under {target}")
return
for f in files:
try:
r = fix_one(f, in_place=in_place)
except Exception as e:
print(f"[FAIL] {f}: {e}")
continue
if not r["ok"]:
print(f"[SKIP] {f}: {r['reason']}")
continue
print(f"[OK] {r['output']}")
for k in ("file_size", "signature", "checksum"):
if k in r:
print(f" {k}: {r[k]}")
if __name__ == "__main__":
main()
这一步的"骚"点: AI不是简单地说"你去修一下dex",而是直接写了一段完整的Python脚本,理解DEX header二进制结构并逐个字段修复。同时在修复顺序上做对了------先修signature再修checksum(因为checksum覆盖范围包含signature)。
第4步:手撕DEX字节码------本文最大的"骚操作"
修复后的dex可以读取了,但当前环境没有jadx、没有apktool、没有baksmali,只有Python。

AI的做法是:
直接从DEX二进制格式解析class/method/bytecode。
完整解析链路是:
class_defs 表找到目标类 ↓ 解析 class_data_item(含 static_fields/instance_fields/direct_methods/virtual_methods) ↓ 通过 delta 编码还原 method_idx ↓ 定位 code_item ↓ 按 Dalvik 指令集逐字节解码。
同时还需要 string_ids/type_ids/proto_ids/method_ids 几个表做交叉引用,才能把指令中的索引还原为可读的类名、方法名和字符串。
定位到目标dex中的 AppAESUtil 类后,逐个方法反编译:
scss
encrypt() → code offset 0x260f3c
decrypt() → code offset 0x260ed4
getKey() → code offset 0x260fb0
getSecretKey() → code offset 0x261050
手写了一个简易Dalvik反汇编器,覆盖了常见的100+条opcode,把bytecode翻译成可读指令。
最终核心反编译结果:
encrypt() 逻辑还原
scss
1. getKey() 获取密钥字符串
2. new SecretKeySpec(keyBytes, "AES")
3. Cipher.getInstance("AES/ECB/PKCS5Padding")
4. cipher.init(ENCRYPT_MODE, secretKey)
5. cipher.doFinal(plainBytes)
6. Base64.encodeToString(result, 2) // 参数2 = NO_WRAP
decrypt() 逻辑还原
scss
1. Base64.decode(data, 2) // 参数2 = NO_WRAP
2. getKey() 获取密钥
3. AES/ECB/PKCS5Padding 解密
4. new String(result, "UTF-8")
getKey() 逻辑还原------最关键的部分
scss
KEY = getString(0x7f11002a) // 从resources.arsc读资源字符串
+ CalculateUtil.primeNumber(2, 100) // 计算第N个质数
+ "-省略" // 硬编码字符串
+ new String(bytesKey, "UTF-8") // bytesKey = "BG499D2"
第5步:还原Key的每个组成部分
Key的4个组成部分需要逐一确认:
| 组成部分 | 来源 | 值 |
|---|---|---|
getString(0x7f11002a) |
resources.arsc → `activity_bugly_id. |
- |
primeNumber(2, 100) |
2到100之间的第2个质数 | 17 |
| 硬编码 | 直接写在bytecode里 | "-" |
bytesKey |
静态字段 {66,71,52,57,57,68,50} |
"BG499D2" |
其中 primeNumber(2, 100) 需要进一步拆解------AI又去反编译了 CalculateUtil.primeNumber():
sql
primeNumber(start, end):
遍历 start到end,遇质数计数,返回到达目标序号的那个质数
→ 2到100的质数:2,3,5,7,11,13,17,19...
→ 第2个是 17
最终AES Key = 5717C***省略(16字节,恰好AES-128)

第6步:验证------用真实密文回解
用这个Key去解我给的 param.data:
json
明文 = {"tripType":"OW","cityList":[{"arriveCityCode":"*","departCityCode":"*","departDate":"*"}],
"rangeNo":1,"cabinCode":"","flightNo":""}
完美解密出JSON------Key正确。
再用响应数据做二次验证,同样解密成功,说明请求和响应共用这套加解密。
四、这个过程中,AI哪里"骚"了
回顾整个链路,有几个操作是传统"人工逆向"很难做到或者很耗时的,但AI完成得又快又准:
骚操作1:架构判断先于算法猜测
不是一上来就"我感觉是AES",而是先搞清楚请求是谁发的(WebView→JSBridge→Java),这直接决定了后续搜索范围和突破口的选择。
骚操作2:遇到JADX丢方法体,立刻切换策略
没有纠结于"为什么jadx反不出来",而是直接去修DEX header,把dump产物变成可用。
骚操作3:手撕DEX二进制格式
没有任何反编译工具的情况下,直接解析DEX文件结构,手写简易反汇编器,从bytecode还原出完整的方法逻辑。
骚操作4:多源交叉验证
Key有4个来源:资源文件、质数计算、硬编码字符串、字节数组。AI不是"看到BG499D2就以为是Key",而是完整追踪了整个getKey()的bytecode,逐个确认每个组成部分的值。
骚操作5:用真实密文闭环验证
不是"我觉得Key应该是这个"就结束了,而是拿用户原始请求中的密文回解,验证出明文JSON才算完。做工程的都知道,没有验证的结论等于没做。
五、技术总结
一句话公式
less
param.data = Base64.NO_WRAP(
AES/ECB/PKCS5Padding(
plainJson.toUTF8(),
"key"
)
)
仅供学习交流,关键信息已脱敏,若有侵权等行为,私信联系作者删除。