行业资讯
📅 2026/7/9 8:10:23
HNCTF PatrolNote
考点Android APK 逆向 SO 原生层逆向 字符串置换 / AES 密码学 正则分组 数组下标置换逆推 AES-128一、阶段 1Java 层定位 Native 本地方法apk文件直接搜索native进入jadx的native可以看见本题核心函数是nativeVerify两个参数str和str2看见了System.loadLibrary(patrolnote)说明是 JNI函数核心校验在apk解压lib文件夹的libxxx.so文件里JIN函数名格式 Java_包名_类名_方法名包名com_hnctf_patrolnote类名NativeBridge方法名nativeVerify所以函数名是Java_com_hnctf_patrolnote_NativeBridge_nativeVerify二、阶段 2SO 原生层逆向求解 nativeVerify 两个入参apk文件核心校验不在Java层在so文件里。apk文件后缀名改成zip并解压将解压后lib文件夹里面so文件放入Ghidra。解压后 lib/arm64-v8a、x86_64、armeabi-v7a 分别对应不同CPU架构的SO文件x86_64架构SO在IDA中反编译可读性最高所以用这个。直接在Ghidra左侧Simple Tree的Filter里Exports窗口搜索JIN函数名Java_com_hnctf_patrolnote_NativeBridge_nativeVerify定位核心函数。分析伪代码undefined8 Java_com_hnctf_patrolnote_NativeBridge_nativeVerify (long *param_1,undefined8 param_2,undefined8 param_3,undefined8 param_4)这是一个 JNI 函数Java 调用 C 的固定格式JNIEnv* env, jobject thiz, jstring str1, jstring str24 个参数固定对应param_1 JNI 环境固定param_2 this 指针固定param_3 Java 传进来的第一个字符串用户名里的 4 位数字param_4 Java 传进来的第二个字符串授权码去横杠后的 12 位因此strparam_3str2param_4{ ulong uVar1; void *pvVar2; char *pcVar3; long lVar4; undefined8 uVar5; char *pcVar6; char *pcVar7; char *pcVar8; byte *pbVar9; byte *pbVar10; char *pcVar11; ulong local_90 [2]; void *local_80; byte local_78 [8]; undefined8 local_70; byte *local_68; byte local_60 [8]; ulong local_58; char *local_50; long local_48;C 申请临时空间全部不用理解lVar4 tpidr_el0; local_48 *(long *)(lVar4 0x28); FUN_00122028(local_60,param_1,param_3); FUN_00122028(local_78,param_1,param_4);把 Java 字符串转成 C 字符串FUN_00122028 系统函数作用把 Java 字符串转成 C 字符串param_3 → local_60 第一个字符串4 位数字param_4 → local_78 第二个字符串12 位授权码因此strlocal_60str2local_78uVar1 (ulong)(local_60[0] 1); //uVar1 字符串长度 if ((local_60[0] 1) ! 0) { uVar1 local_58; //判断是不是 “长字符串”是则真正长度local_58 } if (uVar1 4) { //判断长度是否 4取第一个字符串str1长度 → 必须等于 4local_60[0]→ 字符串的第一个 8 字节数据 1→ 右移 1 位 → 去掉最低位标记得到真实长度local_60[0] 1→ 取最低位pcVar6 (char *)((ulong)local_60 | 3); //如果是短字符串也取 str1[2] 地址 pcVar3 pcVar6; if ((local_60[0] 1) ! 0) { pcVar3 local_50 2; //判断是长字符串-取第三个字符即str1[2]的地址 } if (*pcVar3 1) { //str1[2]1校验第 3 个字符必须是 1str1 [2] 1pcVar11 (char *)((ulong)local_60 | 1); pcVar3 pcVar11; if ((local_60[0] 1) ! 0) { pcVar3 local_50; //str1 [0] } if (*pcVar3 7) {同上校验第 1个字符必须是 7str1 [0] 7pcVar7 (char *)((ulong)local_60 | 4); pcVar3 pcVar7; if ((local_60[0] 1) ! 0) { pcVar3 local_50 3; //str1 [3] } if (*pcVar3 3) {同上校验第 4个字符必须是 3str1 [3] 3pcVar8 (char *)((ulong)local_60 | 2); pcVar3 pcVar8; if ((local_60[0] 1) ! 0) { pcVar3 local_50 1; } if (*pcVar3 4) {同上校验第 2个字符必须是 4str1 [1] 4因此str1 [2] 1str1 [0] 7str1 [3] 3str1 [1] 4→str17413uVar1 (ulong)(local_78[0] 1); if ((local_78[0] 1) ! 0) { uVar1 local_70; } if (uVar1 12) {第二个字符串str2长度必须 12pbVar10 (byte *)((ulong)local_78 | 1); if ((local_78[0] 1) ! 0) { pbVar10 local_68; } if (*pbVar10 9) { pbVar10 (byte *)((ulong)local_78 | 2); if ((local_78[0] 1) ! 0) { pbVar10 local_68 1; } if (*pbVar10 M) { pbVar10 (byte *)((ulong)local_78 | 3); if ((local_78[0] 1) ! 0) { pbVar10 local_68 2; } if (*pbVar10 5) { pbVar10 (byte *)((ulong)local_78 | 4); if ((local_78[0] 1) ! 0) { pbVar10 local_68 3; } if (*pbVar10 Q) { pbVar10 (byte *)((ulong)local_78 | 5); if ((local_78[0] 1) ! 0) { pbVar10 local_68 4; } if (*pbVar10 D) { pbVar10 (byte *)((ulong)local_78 | 6); if ((local_78[0] 1) ! 0) { pbVar10 local_68 5; } if (*pbVar10 2) { pbVar9 (byte *)((ulong)local_78 | 7); pbVar10 pbVar9; if ((local_78[0] 1) ! 0) { pbVar10 local_68 6; } if (*pbVar10 A) { pbVar10 (byte *)local_70; if ((local_78[0] 1) ! 0) { pbVar10 local_68 7; } if (*pbVar10 7) { pbVar10 (byte *)((long)local_70 1); if ((local_78[0] 1) ! 0) { pbVar10 local_68 8; } if (*pbVar10 K) { pbVar10 (byte *)((long)local_70 2); if ((local_78[0] 1) ! 0) { pbVar10 local_68 9; } if (*pbVar10 L) { pbVar10 (byte *)((long)local_70 3); if ((local_78[0] 1) ! 0) { pbVar10 local_68 10; } if (*pbVar10 T) { pbVar10 (byte *)((long)local_70 4); if ((local_78[0] 1) ! 0) { pbVar10 local_68 0xb; } if (*pbVar10 X) {逐字符校验第二个字符串→str29M5QD2A7KLTX校验通过后开始拼返回值push_back就是拼返回值std::__ndk1::basic_string::reserve ((basic_string *)local_90,8);申请 8 字节空间准备返回 8 位字符串local_90。pbVar10 (byte *)((ulong)local_78 | 1); if ((local_78[0] 1) ! 0) { pbVar10 local_68; } std::__ndk1::basic_string::push_back ((basic_string *)local_90,*pbVar10 ^ 0x77);拼第 1 个字符str2 [0] ^ 0x77local_90→ 9 ^ 0x77 Nif ((local_60[0] 1) ! 0) { pcVar8 local_50 1; } std::__ndk1::basic_string::push_back ((basic_string *)local_90,*pcVar8);拼第 2 个字符str1 [1]→4pbVar10 (byte *)((long)local_70 3); if ((local_78[0] 1) ! 0) { pbVar10 local_68 10; } std::__ndk1::basic_string::push_back ((basic_string *)local_90,*pbVar10);拼第 3 个字符str2 [10]→Tif ((local_60[0] 1) ! 0) { pcVar6 local_50 2; } std::__ndk1::basic_string::push_back ((basic_string *)local_90,*pcVar6); //拼第 4 个字符str1 [2] if ((local_78[0] 1) ! 0) { pbVar9 local_68 6; } std::__ndk1::basic_string::push_back ((basic_string *)local_90,*pbVar9 ^ 0x17); //拼第 5 个字符str2[6] ^ 0x17 if ((local_60[0] 1) ! 0) { pcVar7 local_50 3; } std::__ndk1::basic_string::push_back ((basic_string *)local_90,*pcVar7); //拼第 6 个字符str1[3] pbVar10 (byte *)((long)local_70 1); if ((local_78[0] 1) ! 0) { pbVar10 local_68 8; } std::__ndk1::basic_string::push_back ((basic_string *)local_90,*pbVar10); //拼第 7 个字符str2[8] if ((local_60[0] 1) ! 0) { pcVar11 local_50; } std::__ndk1::basic_string::push_back ((basic_string *)local_90,*pcVar11 \x02); //拼第 8 个字符str1[0] 2所以返回的字符串是→ N4T1V3K9最后校验失败返回空成功返回 N4T1V3K9总结str17413str29M5QD2A7KLTX返回值local_90→ N4T1V3K9三、阶段 3Java 层登录逻辑由已知入参反推用户名和 License先从jadx左侧打开AndroidManifest.xml 找启动 Activity搜maincom.hnctf.patrolnote.LoginActivityLoginActivity就是 App 启动后第一个打开的 Activity设置-显示不一致代码勾选上jadx打开com.hnctf.patrolnote.LoginActivity搜索第一个C0.ViewOnClickListenerC0000a发现它只是读取 assets/offline.noticeToast 显示提示另一个F0.c这里是真正的登录按钮这里有两个按钮buttonLogin 登录按钮buttonImport 导入/下载/附件按钮inputUsername); // 用户名输入框 inputLicense); // 授权码输入框 buttonLogin); // 登录按钮 id.textError); // 错误提示文字onClick登录按钮点击事件这里面1. 获取你输入的内容String string editText.getText().toString(); // 用户名 String string2 editText2.getText().toString(); // 授权码2. 用户名正则校验S0.c cVar2 b.f198a; // (1) 从 F0.b 拿正则 Matcher matcher cVar2.f431a.matcher(upperCase); // (2) 用正则匹配用户名进入F0.bb.f198a 正则 ^EMP-2026-(\d{4})$正则知识点 ^ 字符串开头、 $ 字符串结尾、 \d 数字、括号捕获组。再结合F0.cString str (String) bVar2.get(1); // 3 正则提取出 str7413校验用户名必须是EMP-2026-四位数字所以登录输入的用户名是EMP-2026-str即EMP-2026-7413输入之后传入3. 授权码正则校验F0.c中S0.c cVar4 b.b; // 必须格式XXXX-XXXX-XXXX if (cVar4.f431a.matcher(upperCase2).matches())对应F0.b中public static final S0.c b new S0.c(^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$);所以授权码必须是4位-4位-4位格式4. 授权码去横杠 字符换位String strC0 j.C0(upperCase2, -, ); // 去掉 - for (int i3 : b.f199c) { sb.append(strC0.charAt(i3)); }F0.b置换数组idx [5,2,9,0,11,3,7,1,10,4,8,6]正向规则新串 res [i] raw [ idx [i] ]→ 处理后得到str29M5QD2A7KLTX5. 调用 SO所以是用户输入用户名和授权码先检查用户名和授权码的格式再截取用户名最后的4位内容传给so文件里的str授权码去横杠- 字符换位后传给so文件。so文件校验str 7413string4 9M5QD2A7KLTX是否正确正确的返回strNativeVerifyN4T1V3K9可以求得应该输入的正确的授权码idx [i] 是 raw 下标res [i] 是这个下标对应的字符i0 → idx[0]5 → raw[5]res[0]9i1 → idx[1]2 → raw[2]res[1]Mi2 → idx[2]9 → raw[9]res[2]5i3 → idx[3]0 → raw[0]res[3]Qi4 → idx[4]11 → raw[11]res[4]Di5 → idx[5]3 → raw[3]res[5]2i6 → idx[6]7 → raw[7]res[6]Ai7 → idx[7]1 → raw[1]res[7]7i8 → idx[8]10 → raw[10]res[8]Ki9 → idx[9]4 → raw[4]res[9]Li10→ idx[10]8 → raw[8]res[10]Ti11→ idx[11]6 → raw[6]res[11]Xraw 完整Q7M2L9XAT5KD按 4-4-4 加分隔Q7M2-L9XA-T5KD所以输入应该为用户名EMP-2026-7413 授权码Q7M2-L9XA-T5KD四、阶段 4AES 解密加密文件 report.enc 获取 Flag还是在F0.c中6. 构造解密密钥材料String strConcat EMP2026.concat(str); // EMP20267413 int[] iArr {7,8,9,10,0,3,6,2};按下标截取得→7413E26PaVar new a(string5, strNativeVerify);双击a进入F0a分析可知a就是用拼接两个字符串拼接得aVar7413E26P:N4T1V3K97. 解密 report.encF0.c中try { bVar h.E(loginActivity, aVar.f197c); }aVar.f197c7413E26P:N4T1V3K9双击h.E发现进入了A.h.EE () 函数就是用7413E26P:N4T1V3K9生成密钥来解密report.enc得到 Flag 的完整代码解密import base64 import hashlib from Crypto.Cipher import AES ct base64.b64decode(open(report.enc, rb).read()) auth b7413E26P:N4T1V3K9 key hashlib.sha256(auth).digest()[:16] iv hashlib.md5(bPatrolNote-IV).digest() #创建 AES-CBC 解密器解密 pt AES.new(key, AES.MODE_CBC, iv).decrypt(ct) pad pt[-1] AES-CBC 解出来后末尾有 PKCS5/PKCS7 padding。 最后一个字节表示 padding 长度。 比如最后是 0x05说明最后 5 个字节都是填充。 print(pt[:-pad].decode())把解密脚本与report.enc放在同一个文件夹HNCTF{dd8a63b2bf2ac5d46070553f27f3b09a}