AI逆向实战:从零还原某航空App的AES加密

本文记录了一次完整的"人+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_queryencryptswitch 等关键字符串,搜索结果非常少。但找到了几个关键线索:

  • AppSettings.java 里有 WEBVIEW_USER_AGENT = "Fabric@App/3.4.0"
  • clientInfo 里的字段(pixelRatiosafeAreawifiNamewindowHeight)全部来自 SystemInfo.java
  • SystemInfo 是通过 UtilsJavascriptInterface 暴露给JS的

结论:这个请求不是Native Retrofit发出的,而是H5/WebView通过JSBridge调Java HTTP通道发出的。 这意味着加密逻辑可能在Java侧,也可能在H5/JS侧,需要进一步缩小范围。

这一步的"骚"点: AI没有一上来就猜AES,而是先做了一次架构判断------搞清楚请求是谁发的、从哪发的。这个判断直接影响了后续搜索方向。


第2步:定位加解密入口

确定了JSBridge架构后,搜 encryptDecryptAESencrypt 等关键词:

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.encryptDecryptAppAESUtil.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"
    )
)

仅供学习交流,关键信息已脱敏,若有侵权等行为,私信联系作者删除。

相关推荐
爱喝铁观音的谷力景辉5 小时前
在Cesium中实现带箭头方向路线样式的技术详解
javascript·cesium
tonydf6 小时前
Nginx爆新的RCE漏洞!别担心,平滑升级即可。
后端·nginx
Java编程爱好者6 小时前
JVM GC调优实战:从线上频繁Full GC到RT降低80%的全过程
后端
Master_Azur6 小时前
JavaEE之多线程
后端
阿丰资源6 小时前
基于Spring Boot的酒店客房管理系统
java·spring boot·后端
无籽西瓜a6 小时前
【西瓜带你学Kafka | 第八期】 Kafka的主从同步、消息可靠性、流处理与顺序消费(文含图解)
java·分布式·后端·kafka·消息队列·mq
安妮的小熊呢6 小时前
CRMEB开源商城系统 & 标准版系统(PHP)开发规范
开发语言·javascript·php
zzqssliu6 小时前
SpringBoot框架搭建跨境独立站|Taocarts代购系统订单模块深度开发
java·spring boot·后端
Loo国昌6 小时前
从 Agent 编排到 Skill Runtime:企业 AI 工程化的下一层抽象
大数据·人工智能·后端·python·自然语言处理