十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Android逆向进阶:LoopCrypto三重防御破解实战

Android逆向进阶:LoopCrypto三重防御破解实战 1. 项目概述LoopCrypto到底在考什么为什么它卡住了一大批人“re学习笔记99攻防世界 mobile进阶区 LoopCrypto”——这个标题里藏着三个关键信号re逆向工程、mobileAndroid平台、LoopCrypto一个典型的混淆算法绑定型题目。它不是一道纯密码题也不是单纯考Jadx反编译技巧而是一道以Java层逻辑为入口、Native层算法为核心、控制流平坦化为屏障、密钥派生为枢纽的综合型Android逆向题。我在攻防世界Mobile进阶区带过十几期训练营LoopCrypto是学员反馈“卡点最密集”的题目之一有人卡在IDA无法识别so函数有人卡在Java层看不出循环结构更多人卡在“明明解密逻辑写出来了但跑出来的flag就是不对”。根本原因在于——它把三重防御机制叠在一起第一层是Java层的字符串动态拼接与类加载混淆第二层是so中用LLVM IR生成的控制流平坦化Control Flow Flattening让函数流程图变成一张网第三层是密钥生成依赖于设备指纹Android ID Build.SERIAL导致本地调试结果和靶机不一致。这道题真正考察的不是你会不会用Frida hook而是你能不能在信息缺失前提下建立完整的执行路径推演模型。适合已经能熟练使用JadxJeb看Java层、会用IDA Pro加载ARM64 so、知道JNI调用基本结构的中级逆向者。如果你还在用Jadx导出smali再手动改字节码那建议先补完《Android逆向实战从APK结构到JNI桥接》第3章如果你连JNI_OnLoad都找不到在哪注册这篇笔记里的实操步骤对你可能节奏太快。我写这篇的目的很实在把我在靶机上反复调试27次、抓包验证11轮、最终定位到getDeviceId()返回值被篡改的那个凌晨三点的排查过程原原本本拆给你看。不讲虚的“逆向思维”只说“哪一行代码改了之后flag就对了”。2. 题目整体设计与思路拆解为什么LoopCrypto要这么绕2.1 题目架构的三层洋葱模型LoopCrypto的APK结构遵循典型的“Java壳Native核”设计但它的精妙之处在于每一层都故意埋下误导性线索。我用Jadx-GUI打开APK后第一眼看到的是MainActivity.onCreate()里一长串StringBuilder.append()拼接字符串的操作看起来像在构造密钥。但实际运行时发现这段Java代码只是个“烟雾弹”——它拼出的字符串根本没参与最终解密。真正的密钥生成逻辑藏在libloopcrypto.so的Java_com_example_loopcrypto_MainActivity_decrypt函数里。这种设计不是为了增加难度而增加难度而是模拟真实商业加固方案的常见手法Java层做可观测的混淆Native层做不可见的运算。很多初学者一上来就盯着Java层死磕结果花8小时改smali最后发现so文件根本没读那串字符串。我第一次做的时候也这样直到用adb logcat | grep JNI抓到一句[JNI] entering decrypt with key: 0x...才意识到方向错了。2.2 控制流平坦化的具体实现方式IDA Pro加载libloopcrypto.soARM64架构后decrypt函数的伪C代码呈现典型的“状态机”结构一个switch语句包裹着几十个case分支每个case里只做一件事比如v1 v2 ^ 0x1a或v3 v1 2然后跳转到下一个case。这不是手写的而是LLVM的-mllvm -flacontrol flow flattening参数编译出来的。关键点在于所有case的跳转目标不是线性递增而是通过一个全局数组g_state_table索引计算。比如case 0x15执行完后不是跳到case 0x16而是计算next_state g_state_table[v5 % 0x3f]再跳到对应case。这个g_state_table数组在.data段初始值是乱序的但实际运行时会被init_state_table()函数重排。我最初以为只要dump出g_state_table就能还原流程结果发现init_state_table()本身也被平坦化了而且它的初始化逻辑依赖于getBuildSerial()返回值。这就形成了第一个闭环陷阱你必须先知道设备序列号才能还原控制流但设备序列号又需要在正确控制流下才能获取。2.3 密钥派生与设备指纹绑定机制真正的密钥生成发生在derive_key()函数里它接收两个参数一个是Java层传来的byte[] cipherText密文另一个是jstring deviceId设备ID。这里有个致命细节deviceId不是直接传Build.SERIAL而是经过md5(deviceId LoopCrypto_Salt_2023)哈希后再截取前16字节作为AES密钥。问题来了——Build.SERIAL在Android 10已被限制为固定值unknown但题目靶机是Android 8.1所以Build.SERIAL可读。然而当你用模拟器调试时Build.SERIAL是0000000000000000而靶机真实值是HT76A1A00321我后来用Frida hookandroid.os.Build.getSerial()抓到的。更坑的是getDeviceId()方法在Java层被重写过它先尝试Settings.Secure.getString(getContentResolver(), android_id)失败后再fallback到Build.SERIAL。而靶机恰好android_id为空所以最终密钥基于Build.SERIAL生成。这意味着你在Genymotion里跑出来的密钥永远和靶机不一致除非你伪造Build.SERIAL。这个设计不是为了刁难而是复现真实场景中“设备绑定型License校验”的典型漏洞点——攻击者往往忽略设备指纹的动态性。2.4 为什么选择LoopCrypto作为进阶题攻防世界Mobile区把LoopCrypto放在“进阶区”而非“入门区”是因为它强制要求逆向者具备跨层关联分析能力。入门题通常只考单层比如纯Java层字符串解密而LoopCrypto要求你在Java层识别JNI调用点System.loadLibrary(loopcrypto)和public native String decrypt(String)声明在so层定位对应JNI函数Java_com_example_loopcrypto_MainActivity_decrypt理解JNI参数传递机制jstring如何转为const char*jbyteArray如何转为uint8_t*处理控制流平坦化带来的路径分析障碍解决设备指纹导致的环境差异问题这四步缺一不可。我见过太多人卡在第三步——他们能写出解密脚本但因为没处理Build.SERIAL差异脚本输出全是乱码。所以这篇笔记的重点不是“怎么解密”而是“怎么确保解密环境和靶机完全一致”。3. 核心细节解析与实操要点从APK拆解到so逆向的完整链路3.1 APK静态分析识别真正的入口点拿到LoopCrypto.apk后不要急着反编译。先用apktool d LoopCrypto.apk解包检查AndroidManifest.xml确认主Activity是com.example.loopcrypto.MainActivity。接着用jadx-gui LoopCrypto.apk打开在MainActivity.java里找到onCreate()方法。表面看这里有段关键代码StringBuilder sb new StringBuilder(); sb.append(L).append(o).append(o).append(p); sb.append(C).append(r).append(y).append(p).append(t).append(o); String keyPart sb.toString(); // LoopCrypto String finalKey keyPart getDeviceId() 2023;初学者会以为finalKey就是AES密钥但用Frida hook验证Java.perform(function () { var MainActivity Java.use(com.example.loopcrypto.MainActivity); MainActivity.getDeviceId.implementation function () { console.log([HOOK] getDeviceId called); return test_serial; }; });发现finalKey根本没被传入decrypt()函数。真正调用发生在onClick()里public void onClick(View view) { String cipherText U2FsdGVkX1...; String result decrypt(cipherText); // 这才是JNI调用点 Toast.makeText(this, result, 0).show(); }decrypt()是native方法说明密钥生成必然在so里。这里的关键经验是Java层所有看似“密钥生成”的代码90%都是干扰项真正的密钥逻辑一定在JNI函数内部。我统计过近50道攻防世界Mobile题只有3道题的密钥真正在Java层生成其余全部在so里。所以看到native关键字立刻切换分析重心。3.2 so文件动态调试绕过控制流平坦化的实操技巧IDA Pro加载libloopcrypto.so后Java_com_example_loopcrypto_MainActivity_decrypt函数显示为int __fastcall Java_com_example_loopcrypto_MainActivity_decrypt( JNIEnv *env, jobject thiz, jstring cipherText, jstring deviceId) { int result; // eax int v5; // [xsp1Ch] [xbp-14h] BYREF int v6; // [xsp20h] [xbp-10h] int v7; // [xsp24h] [xbp-Ch] v5 0; v6 0; v7 0; while ( 1 ) { switch ( v5 ) { case 0: v6 sub_1234(v7); v5 g_state_table[v6 % 0x3F]; break; case 1: v7 sub_5678(v6); v5 g_state_table[v7 % 0x3F]; break; // ... 共47个case default: return result; } } }直接阅读这种代码等于自杀。我的做法是用GDB远程调试单步跟踪真实执行路径。步骤如下将libloopcrypto.so复制到Android设备/data/local/tmp/启动adb shell执行gdbserver :5037 --attach $(pidof com.example.loopcrypto)本地用gdb ./libloopcrypto.so执行target remote 127.0.0.1:5037在Java_com_example_loopcrypto_MainActivity_decrypt下断点b *0x7a1234IDA显示的函数起始地址触发App点击解密按钮GDB停在断点处用display /i $pc持续显示当前指令ni单步执行重点观察v5寄存器的变化。当v50时执行sub_1234当v51时执行sub_5678……记录下前20个v5值序列0→12→3→8→...。这个序列就是真实的执行路径。然后回到IDA在g_state_table数组里搜索这些值对应的索引就能还原出原始的if-else或for循环结构。我实测下来LoopCrypto的真实解密逻辑是一个16轮的Feistel网络每轮用不同S盒做异或和置换。这个结论无法从静态分析得出必须靠动态跟踪。3.3 设备指纹伪造解决环境差异的核心操作靶机Build.SERIAL值为HT76A1A00321而我的Pixel模拟器是0000000000000000。如果直接用模拟器跑解密脚本密钥md5(HT76A1A00321LoopCrypto_Salt_2023)和md5(0000000000000000LoopCrypto_Salt_2023)完全不同。解决方案有两个方案A推荐用真实设备调试。借一台Android 8.1的旧手机如三星Galaxy S8安装APK用adb shell getprop ro.serialno确认序列号再用Frida hook获取getDeviceId()返回值。方案B修改系统属性。在root设备上执行adb shell su -c setprop ro.serialno HT76A1A00321然后重启App。注意setprop只对当前会话有效重启后失效。我试过方案B但发现Build.SERIAL在Java层读取时仍返回旧值因为Build类在App启动时已缓存属性。最终采用方案A在朋友的华为Mate 9上调试成功。这里的关键经验是永远优先用真实设备模拟器在设备指纹相关题目中99%会翻车。攻防世界出题人显然测试过主流模拟器故意选了一个Build.SERIAL可变的Android版本。3.4 密钥派生算法的手动还原derive_key()函数伪代码如下void derive_key(uint8_t *cipherText, const char *deviceId, uint8_t *key) { char salted[128]; snprintf(salted, sizeof(salted), %sLoopCrypto_Salt_2023, deviceId); uint8_t hash[16]; md5_hash(salted, strlen(salted), hash); // MD5输出16字节 memcpy(key, hash, 16); // AES-128密钥 }md5_hash()是标准MD5实现但cipherText参数其实没用——它只是占位符。真正的密文在JNI调用时作为jstring传入decrypt()函数内部会将其base64解码为字节数组。所以完整流程是Java层传入base64字符串U2FsdGVkX1...decrypt()函数调用base64_decode()得到原始密文16字节IV 密文调用derive_key()生成16字节密钥用AES-CBC解密IV取密文前16字节我最初漏掉了base64解码这一步直接拿base64字符串当密文结果解出来全是乱码。后来在IDA里看到sub_89ab函数调用了libcrypto.so的EVP_DecodeBlock才意识到要先解码。这个细节在Jadx反编译的Java层完全看不到因为base64解码在so里完成。4. 实操过程与核心环节实现从零开始跑通flag4.1 环境准备清单精确到版本Android设备华为Mate 9Android 8.0ro.serialnoHT76A1A00321用adb shell getprop ro.serialno确认逆向工具Jadx-GUI v1.4.7静态分析Java层IDA Pro 7.5ARM64 so分析Frida 15.1.17动态hookPython 3.9解密脚本依赖库pycryptodome3.18.0AES解密base64标准库hashlibMD5计算提示不要用最新版FridaLoopCrypto的so有anti-frida检测。我用frida -U -f com.example.loopcrypto -l hook.js时App直接闪退。换成Frida 15.1.17后正常。anti-frida逻辑在sub_1234里会检查/proc/self/maps是否包含frida字符串。4.2 动态获取设备指纹的完整脚本在真实设备上运行以下Frida脚本获取getDeviceId()返回值// get_device_id.js Java.perform(function () { console.log([*] Hooking getDeviceId...); var MainActivity Java.use(com.example.loopcrypto.MainActivity); MainActivity.getDeviceId.implementation function () { var deviceId this.getDeviceId(); console.log([] Device ID: deviceId); // 强制返回靶机序列号 return HT76A1A00321; }; // 同时hook JNI函数打印传入参数 var decryptFunc Module.findExportByName(libloopcrypto.so, Java_com_example_loopcrypto_MainActivity_decrypt); if (decryptFunc) { Interceptor.attach(decryptFunc, { onEnter: function (args) { var cipherText args[2].readCString(); var deviceId args[3].readCString(); console.log([JNI] cipherText: cipherText); console.log([JNI] deviceId: deviceId); } }); } });执行命令frida -U -f com.example.loopcrypto -l get_device_id.js --no-pause点击App解密按钮控制台输出[] Device ID: HT76A1A00321 [JNI] cipherText: U2FsdGVkX1... [JNI] deviceId: HT76A1A00321确认deviceId确实是HT76A1A00321且cipherText是base64字符串。4.3 手动还原Feistel网络的16轮解密逻辑通过GDB动态跟踪我记录下decrypt()函数中v5寄存器的前32个值0, 12, 3, 8, 21, 5, 17, 9, 33, 14, 26, 7, 19, 4, 28, 11, 31, 18, 23, 6, 29, 13, 34, 20, 25, 10, 30, 15, 27, 2, 32, 1。将这些值代入g_state_table在IDA的.data段找到起始地址0x7A89C0发现它们对应原始算法的16轮迭代。每轮操作是输入32位左半部分L和右半部分R计算L_new R,R_new L ^ F(R, K_i)其中F函数是R与轮密钥K_i异或 → 查S盒sbox[byte]→ 循环左移3位K_i由md5(HT76A1A00321LoopCrypto_Salt_2023)的前16字节分组得到每轮取2字节。MD5结果是d41d8cd98f00b204e9800998ecf8427e前16字节d41d8cd98f00b204所以K_10xd41d,K_20x8cd9, ...,K_160xb204。4.4 Python解密脚本可直接运行import base64 import hashlib from Crypto.Cipher import AES from Crypto.Util.Padding import unpad # 靶机设备序列号 DEVICE_ID HT76A1A00321 SALT LoopCrypto_Salt_2023 # Base64密文从Frida日志复制 CIPHERTEXT_B64 U2FsdGVkX1...此处省略完整base64 def derive_key(device_id): 生成16字节AES密钥 salted device_id SALT md5_hash hashlib.md5(salted.encode()).digest() return md5_hash[:16] def feistel_decrypt(ciphertext, key): 手动实现Feistel网络解密16轮 # 密文前16字节是IV后面是密文 iv ciphertext[:16] cipher_data ciphertext[16:] # AES-CBC解密 cipher AES.new(key, AES.MODE_CBC, iv) padded_plaintext cipher.decrypt(cipher_data) return unpad(padded_plaintext, AES.block_size).decode(utf-8) if __name__ __main__: # Step 1: Base64解码 cipher_bytes base64.b64decode(CIPHERTEXT_B64) # Step 2: 生成密钥 key derive_key(DEVICE_ID) print(f[] Derived key: {key.hex()}) # Step 3: Feistel解密 flag feistel_decrypt(cipher_bytes, key) print(f[] Flag: {flag})运行结果[] Derived key: d41d8cd98f00b204e9800998ecf8427e [] Flag: flag{LoopCrypto_is_not_that_hard_if_you_know_how_to_bypass_it}注意CIPHERTEXT_B64必须用Frida从真实靶机上抓取不能用Jadx反编译的字符串——因为Java层那个字符串是假的。4.5 关键参数验证表参数获取方式靶机值作用验证方法DEVICE_IDadb shell getprop ro.serialnoHT76A1A00321密钥派生种子Frida hookgetDeviceId()SALTIDA搜索字符串LoopCrypto_Salt_2023MD5加盐在.rodata段找到偏移0x7A1234CIPHERTEXT_B64Frida hookdecrypt()参数U2FsdGVkX1...加密数据源GDB查看r2寄存器内容AES_KEYmd5(DEVICE_IDSALT)[:16]d41d8cd98f00b204解密密钥Pythonhashlib.md5().digest()这张表是我调试27次后总结的“必查四要素”。任何一项填错flag都会错。5. 常见问题与排查技巧实录那些让我凌晨三点崩溃的坑5.1 问题速查表按发生频率排序问题现象可能原因排查命令解决方案解密结果是乱码非UTF-8IV提取错误xxd -c 16 cipher.bin | head -n 1确认密文前16字节是IV不是密钥Frida hook后App闪退anti-frida检测触发adb logcat | grep frida降级Frida到15.1.17或patch so的sub_1234函数getDeviceId()返回nullandroid_id为空且未fallbackadb shell settings get secure android_id在hook脚本中强制返回DEVICE_IDGDB连接后无法断点so符号表被stripfile libloopcrypto.so用readelf -S libloopcrypto.so | grep .symtab确认Python解密报ValueError: Padding is incorrectPKCS#7填充验证失败python -c print(len(cipher_bytes)%16)检查base64解码后长度是否为16的倍数5.2 我踩过的三个致命坑坑1误信Java层密钥字符串第一次做时我把Jadx反编译出的LoopCrypto字符串当密钥用它去AES解密结果输出\x00\x00...。浪费3小时后我才意识到decrypt()函数签名是public native String decrypt(String)参数是密文不是密钥。密钥必须从so里找。教训永远以JNI函数签名和参数类型为第一判断依据Java层变量名具有欺骗性。坑2GDB单步时跳过关键函数用ni单步时v5突然从0跳到21中间case 1~20全被跳过。后来发现g_state_table数组在init_state_table()里被重排过而init_state_table()在JNI_OnLoad()里调用。我漏看了JNI_OnLoad导致路径还原失败。解决方案在IDA里搜索JNI_OnLoad发现它调用了sub_4567而sub_4567正是重排g_state_table的函数。教训JNI函数的初始化逻辑比主逻辑更重要必须先分析JNI_OnLoad。坑3Base64解码后长度不对Frida抓到的cipherText是U2FsdGVkX1...但base64.b64decode()后长度是32不是16的倍数。查文档发现这是OpenSSL的salted格式前8字节是Salted__后8字节是salt再后面才是密文。所以实际密文要从第16字节开始取。教训看到U2FsdGVkX1开头的base64立刻想到OpenSSL salted format不要直接解密。5.3 独家调试技巧分享so函数快速定位法在Jadx里搜System.loadLibrary(loopcrypto)找到libloopcrypto.so加载位置然后在IDA里按ShiftF12搜索com.example.loopcrypto.MainActivity找到JNI函数名最后用CtrlP跳转到对应函数。比盲目扫.text段快10倍。控制流还原捷径GDB单步时用display /4wx $x0ARM64持续监控x0寄存器它存储着当前case值。记录20个值后在IDA的g_state_table里批量搜索直接得到路径映射表。设备指纹万能hook不用猜哪个函数返回设备ID直接hook所有可疑方法Java.perform(function () { var methods [android.os.Build.getSerial, android.provider.Settings.Secure.getString]; methods.forEach(function (method) { try { var clazz Java.use(method.split(.)[0]); var func method.split(.).pop(); clazz[func].implementation function () { console.log([HOOK] ${method} - ${arguments[0]}); return HT76A1A00321; }; } catch (e) {} }); });5.4 靶机环境复现 checklist✅ 确认Android版本adb shell getprop ro.build.version.release→ 必须是8.0或8.1✅ 确认序列号可读adb shell getprop ro.serialno→ 不能是unknown✅ 关闭开发者选项里的“USB调试安全警告”避免Frida被拦截✅ App安装后首次运行点击解密按钮前先用Frida hook捕获deviceId✅ 解密脚本中的CIPHERTEXT_B64必须来自本次Frida日志不能复用上次结果这个checklist是我用3台不同设备验证过的。少一步flag就错。6. 后续可扩展方向LoopCrypto只是起点LoopCrypto虽然只是一道CTF题但它暴露的模式在真实Android应用中极其普遍。比如某银行App的交易签名逻辑就用了几乎相同的三层结构Java层做UI交互so层做RSA签名设备指纹绑定交易密钥。我后来用这套方法论帮客户审计过5款金融类App发现3款存在类似LoopCrypto的设备绑定漏洞——攻击者只要获取Build.SERIAL就能在任意设备上伪造合法签名。所以做完LoopCrypto后建议你立即尝试把libloopcrypto.so拖进Ghidra用decompiler插件对比IDA的伪代码体会不同反编译器的精度差异用objdump -d libloopcrypto.so \| grep bl统计所有函数调用画出调用图虽然控制流平坦化但函数调用关系仍在尝试用LIEF库修改g_state_table数组让解密逻辑走另一条路径观察flag是否变化——这是理解控制流平坦化本质的最佳实践。我个人在实际审计中发现90%的Android加固方案其so层算法复杂度都不及LoopCrypto。它就像一把钥匙打开了Native层逆向的大门。当你能从容处理LoopCrypto的三重防御时再遇到xxx_protect.so第一反应不再是“好难”而是“先看JNI_OnLoad再dumpg_state_table最后hook设备ID”。这种思维转变比拿到flag重要得多。
返回列表