找到 ParametrosScanner.getTexto(),里面长这样:
public String getTexto() {
// ......
String t35372426620 = ParametrosScanner.t353724266201u;
// ……
return t35372426620;
}
原来的反编译结果有几十行,但是跟返回值有关的实际上就这一句。继续找谁给 t353724266201u 赋值,找到:
public static void c617893634551k(String p0) {
// ……
ParametrosScanner.t353724266201u = p0;
// ……
}
现在就像剥大葱一样,一层一层往前找,找得我眼睛都花了,一路找,最后停在com.hubox.Azf里的t355299517578u,c203954798102k()把它返回。它还有一个tt(String) 函数负责往这个字符串后面追加字符。
到这卡住了,dd(String)在哪里声明的呢?当时挠破头皮都没想明白,后来发现这个apk好像也不止是只有Java,于是去com/hubox/Azf.java找了一下,找到:
public native int dd(Activity activity);
static {
System.loadLibrary("a");
}
既然是.so,那就没办法啦,打开我心爱的Ghidra,拖进去发现实在实在是看不懂;但是解密之道就在其中,只好请出我的救星蓝色大肥鱼 Deepseek 配合 GPT-6 一起帮我反编译。
先看liba.so里的Java_com_hubox_Azf_dd,找到字符串"tt"和方法签名"(Ljava/lang/String;)V",后面紧跟着CallVoidMethod。往上看是个按字符编号分支的switch,每次生成一个字符再调用tt(),这才接上前面找到的Java字符串。Ghidra没认出部分JNI调用,换LLM反编译出来是这种东西:
uVar16 = (**(code **)(*(long *)param_1 + 0x108))
(param_1, uVar14, "tt", "(Ljava/lang/String;)V");
_JNIEnv::CallVoidMethod(
(_jobject *)param_1, param_2, uVar16, puVar15
);
查JNI函数表,0x108对应GetMethodID,所以这两句就是查找tt(String)再调用它。分析其他地方也用这个办法:认出JNI和密码库调用,给变量补名字,参数不对就回去看ARM64汇编,再把内存复制整理成能读的流程。
从输入开始看,App取的是ZXing的Result.getRawBytes(),也就是扫出来的原始二进制。证件背面的两个二维码共同存一份记录,程序需要知道当前扫到的是哪一部分,才能按顺序拼接;这个编号就藏在原始数据里,直接通过位运算取得。aa()先取编号,再把后面的字节错开四位拼回来:
selector = ((raw[3] & 0x0f) << 4) | (raw[4] >> 4)
part = selector if selector < 2 else selector - 2
payload = bytes(
((raw[i + 4] << 4) | (raw[i + 5] >> 4)) & 0xff
for i in range(len(raw) - 5)
)
selector为0或2时算第一部分,为1或3时算第二部分,其他值不接受。它只表示拼接位置,不是证件编号,也不能靠它判断两张码是不是来自同一张证件。后面的移位是把错开半个字节的数据重新对齐,没有涉及解密。
LLM选择用Java写个例程,用ZXing读取二维码图片(ZXingJS的getRawBytes不知为何不好用:编者注),把getRawBytes()保存到文件里。
两部分二维码数据收齐以后进入dd()。原代码是一堆内存分配和复制,整理以后如下,A、B分别是编号0、1的有效数据:
k = len(A) + len(B) - 1024
R = rsa_public_recover(A[k:] + B, outer_key)
m = 1024 - k
S = rsa_public_recover(A[:k] + R[:m], inner_key)
record = S + R[m:]
它不是两张码各解一次,而是先从两张码里拼出一个1024字节的RSA输入,做完第一轮,再拿恢复结果的一部分和之前留下的数据拼出第二个1024字节输入。目录里的样本两部分各856字节,因此第一轮是A后168字节加整个B,第二轮是A前688字节加第一轮结果前336字节,第二轮结果再接上第一轮剩余内容,才得到完整记录。
接着看RSA调用,找到的是:
rsa.ImportPublicKey(...);
rsa.DecryptBytes(input, false, output);
这里的false表示不用私钥,两处调用前的w2寄存器也确实被置零。App用公钥恢复数据,函数名虽然叫DecryptBytes,但里面没有签发证件用的私钥。两把公钥后来提出来,确认都是8192位,指数65537,1024字节刚好是一个RSA块,核心运算就是:
recovered = pow(
int.from_bytes(block, "big"), 65537, n
).to_bytes(1024, "big")
n是公钥里的模数。算完还要去掉填充,也就是RSA块里用于格式和长度要求的额外字节,剩下的才是下一步要处理的数据;不是做完一次pow()就能直接看到姓名。
公钥没有明文摆在Java里,而是分散包装在libd.so、liba.so和Java表变换中。按Ghidra里的初始化流程往回追,APK内置公钥的解包顺序是:
Java表变换 → RSA公钥恢复 → Base64解码 → AES-256-CBC → 十六进制解码 → RSA公钥恢复 → 两把XML公钥
具体到这个APK,libd.so文件偏移0x34008的256字节先经过Java表变换,得到一把1024位包装公钥;再用它处理0x34110开始的9088字节,也就是71个128字节RSA块,恢复出8216字节Base64文本。继续按代码做AES和下一层RSA,最终得到2883字节字符串,中间用|隔开,两边各是一段1441字符的公钥XML。第一段放进clp[1]供二维码第二轮RSA使用,第二段放进clp[2]供第一轮使用。各层填充都能对上,导出的两把公钥也通过了OpenSSL检查。这里的偏移只针对本次分析的1.3版APK,换版本不能直接照抄。
绕了这么多层,最后藏的仍然是公钥(这是个好事啊!编者注)。拿到它可以恢复已有二维码里的内容,不能给任意姓名和照片生成有效二维码。另外,AES出现在公钥容器的解包流程里,扫描记录本身走的是前面两轮RSA。程序还有使用缓存或下发密钥的分支,这次恢复的是APK内置的那一组,没有检查运行中的安装实例用了哪组。
RSA处理完也不是JSON,文本和照片还得按记录里的长度字段取。文本每6位表示一个字符,从高位往低位读取,再查下面这张表,第一项是空格:
ALPHABET = " ABCDEFGHIJKLMNÑOPQRSTUVWXYZ0123456789:_-,;.\n\\ÁÉÍÓÚÜ^/()&|<>@'"
这就解释了为什么前面有一大串单字符分支:普通大写字母、数字,再加Ñ和西班牙语重音字母,一个字符只占6位。字符表共62项,6位却能表示64个值,剩下的编号62、63没有正常字符可查,应该不会在证件里出现。
记录里的位置计算也整理了出来,BE16表示把两个字节按大端顺序读成整数:
h = record[0]
text_length = BE16(record[h+66 : h+68])
text = record[h+68 : h+68+text_length]
q = h + 68 + text_length
r = q + BE16(record[q : q+2])
metadata = record[r+2 : r+6]
image_length = BE16(record[r+6 : r+8])
image = record[r+8 : r+8+image_length]
文本前面有一段头部,后面还有一段按长度跳过的数据,具体含义没有全部确认。metadata是程序另外取出的四个字节,目前只保留原值,不能因为一开始搜的是男、女,就硬说其中哪个字节代表性别。图片则省掉了部分固定内容,原程序往里面补23个固定字节,其中能看到RIFF、WEBP、VP8,还原成WebP后交给Java显示。目录里的样本最终解出1669字节记录,其中文本编码占130字节,解出173个字符。