本帖最后由 梦幻的彼岸 于 2026-8-4 21:26 编辑
内容概要
欢迎光临 Android 赛博修理铺! 今天接了个狠活:我破我自己。我手写了一个 Demo,自以为搞了 3 层防御,结果一拆……嗯,防了个寂寞。
本期“维修”记录:
青铜防御:明文硬编码。全局搜索,顺藤摸瓜,一秒拿下。
白银防御:Base64 伪装。给代码上点“赛博卸妆水”,看看它的素颜。
黄金防御:控制流“绕口令”。故意写的字符拼接逻辑,在控制流图 (CFG) 面前,就像看地铁线路图一样清晰。
隐藏内鬼:!代码里藏着的真实安全漏洞。
样本信息:
文件名:CTF_Demo.apk
MD5: c8b1fcce18bd73a257f37665fb97ddbd
注:本修理铺所有拆解仅限个人技术探索。自己写的代码自己拆,遵纪守法,文明修机。
一、 样本透视:载入 APK,防御在 Dashboard 前“裸奔”
打开逆向工具,载入 CTF_Demo.apk。等待解析完成后,工具会生成一张“体检报告”(Dashboard 界面)。这张图往往是逆向者的第一手情报。
1. 夸张的“Dex Elements”:28万字符串的迷宫 最扎眼的是紫色的 39.17% string: 281560。28万多个字符串、23万多个方法。
技术推测:一个轻量级 Demo 不可能有这么多代码。这通常是因为引入了庞大的第三方库(如 Kotlin 标准库、协程库 kotlinx.coroutines、AndroidX 等)。
逆向视角:字符串越多,Flag 藏在里面的概率越大,但“噪音”也越多。这要求我们掌握过滤系统库的技巧,而不是盲目大海捞针。
2. 左侧导航栏:技术栈底裤被看穿 看 DexClass 树状图:
kotlin 和 kotlinx.coroutines 暴露了技术栈。协程常被用来做异步混淆,打乱逆向者的时间线分析。
com 包是真正的“主战场”,业务逻辑和 Flag 必然藏在自定义包名下。
修理铺小结: 光是载入这一步,APK 的技术栈、代码规模就已经透明了。接下来,我们正式动刀。
二、 实战拆解:三层“纸糊”防御的覆灭
在 CTF 逆向中,有一套经典链路:搜字符串 → 找交叉引用 → 分析代码逻辑。我们用这个思路来拆解这三层防御。
2.1 青铜防御:明文硬编码与交叉引用(XREF)
【第一步:过滤与搜索】 不要直接搜 AllStrings(包含系统库噪音),而是点击 AppStrings(仅应用自身字符串)。按 Ctrl+F 搜索关键词 flag。
在搜索结果底部,赫然出现:flag{hello_gda_simple}。 这就是第一层 Flag!所谓的“明文硬编码”,耗时不超过 3 秒。
CTF技巧:
CTF题目中,Flag通常以字符串形式存在
常用关键词:flag、ctf、key、secret、success
善用 AppStrings 过滤系统库,专注应用自身字符串
【第二步:交叉引用定位逻辑】 光找到字符串不够,需确认其作用。双击该字符串(或按 X 键),触发交叉引用(Cross Reference)。
操作步骤:
在搜索结果中双击 flag{hello_gda_simple}跳转到字符串位置
之后选中后按快捷键 X
GDA弹出"GDA Searching"窗口,显示所有引用该字符串的代码位置
结果分析: 看右边的搜索结果:
callor(1 results):显示只有 1 个地方引用了这个字符串
checkFlag(String):void:引用它的函数名居然直接叫 checkFlag(检查Flag)!
修理铺吐槽:我作为出题人,连函数名都懒得混淆,直接写 checkFlag……这哪里是防御,这简直是给逆向者指路:"嘿,Flag验证逻辑在这个函数里,快来拆我!"
CTF技巧:
交叉引用(XREF)是逆向工程的核心技能
从"数据"(字符串)定位到"代码"(函数)
函数名往往暴露其功能(如checkFlag、verify、validate)
2.2 白银防御:Base64 伪装与“赛博卸妆”
双击 checkFlag 进入代码全览,三层防御的骨架彻底暴露:
[Java] 纯文本查看 复制代码 private void checkFlag(String input){
String simpleFlag = "flag{hello_gda_simple}"; // 青铜
String encodedFlag = "ZmxhZ3tiYXNlNjRfaXNfZWFzeX0="; // 白银
// ... 省略部分逻辑 ...
if (input.equals(simpleFlag)) {
this.showSuccess(R$string.level1_passed); return;
} else {
// 技术细节:Base64.NO_WRAP 对应数值 2,表示编码不换行
String userBase64 = Base64.encodeToString(input.getBytes(StandardCharsets.UTF_8), 2);
if (userBase64.equals(encodedFlag)) {
this.showSuccess(R$string.level2_passed); return;
}
// ... 黄金防御 ...
}
}
【破解白银防御】 看到 ZmxhZ3tiYXNlNjRfaXNfZWFzeX0=,传统思路是切浏览器找在线解码。但高效的逆向者会直接使用工具内置的算法模块。
选择 Base64 解密,一键执行,明文 flag{base64_is_easy} 直接浮现。
原理延伸:Base64 只是编码(Encoding)而非加密(Encryption),它没有密钥,目的仅是数据格式化,因此防不住逆向。
2.3 黄金防御:控制流混淆与反编译“照妖镜”
代码第 30 行:input.equals(this.OBFUSCATED_FLAG)。这个变量是在构造函数中通过 generateObfuscatedFlag() 动态生成的。
【源码 vs 反编译代码对比】 我故意在源码中加入了控制流混淆,但反编译引擎直接将其“降维”:
| 对比项 | 原始源代码 (Java/Kotlin) | 反编译结果 (伪代码) | 原理推测 | | 循环结构 | for (int i = 0; i < 5; i++) | while (i < 5) | 编译器底层控制流还原 | | 字符串构建 | sb.append("fla") | sb = sb+"fla" | 语法糖剥离,回归底层拼接 | | Foreach循环 | for (char c : chars) | for (int ix = 0; ...) | 迭代器语法被还原为索引遍历 | | 字符数组 | {'c','o','n','t'...} | {'c','o','n','t'...} | 核心数据一字不改 |
【技术深挖:无效计算(Opaque Predicate)】 源码中有一段奇怪的循环:
[Java] 纯文本查看 复制代码 int x = 10;
for (int i = 0; i < 5; i++) {
x = x + i;
if (x > 15) { x = x - 2; }
}
手动模拟可知,这段代码无论怎么跑,都不会影响最终拼接的字符串。这在逆向工程中称为“不透明谓词”或“无效计算”,旨在干扰人工阅读。但在现代反编译引擎面前,只要它不改变最终字符数组 {'c','o','n'...} 的值,就形同虚设。 一眼读出第三层 Flag:flag{control_flow_test}。
三、 效率进阶:从“迷路”到“传送门”
在破解过程中,我们其实可以走“捷径”。传统逆向往往从 AndroidManifest.xml 找入口 Activity,再一层层翻找 onCreate,在大型 APK 中极易“迷路”。
3.1 入口点函数:一键直达核心
利用工具顶部的入口点函数按钮(紫色 Android 图标),可直接跳转至当前类的 onCreate 方法。 结合双击跳转和交叉引用(XREF),我们可以构建一条极速分析链路:
入口点直达 onCreate -> 发现按钮监听器 MainActivity$$ExternalSyntheticLambda0。
双击跳转 至 Lambda 类 -> 发现核心调用 this.checkFlag(userInput)。
双击跳转 至 checkFlag -> 三层 Flag 全部拿下。
逆向方法论总结:
宏观定位:用入口点功能快速锁定程序起点。
微观追踪:用 XREF(交叉引用)追踪数据(字符串/变量)的流向。
全局搜索:用 Ctrl+F 查漏补缺,寻找遗漏的敏感词。
四、 安全审计:代码里的“隐藏内鬼”
除了 CTF 解题,这段代码在真实商业环境中,其实埋下了严重的安全隐患。通过全代码审计,我们发现了以下“内鬼”:
漏洞 1:敏感信息硬编码(高危)
代码位置: checkFlag() 方法第 16-17 行
[Java] 纯文本查看 复制代码 String simpleFlag = "flag{hello_gda_simple}";String encodedFlag = "ZmxhZ3tiYXNlNjRfaXNfZWFzeX0=";
风险原理:
明文存储:Flag 直接以字符串形式嵌入代码,反编译后立即可见
弱编码伪装:ZmxhZ3tiYXNlNjRfaXNfZWFzeX0= 仅是 Base64 编码,不是加密,解码即得 flag{base64_is_easy}
字符串常量池暴露:Java 编译器会将字符串存储在 Dex 文件的字符串常量池中,即使使用 ProGuard/R8 混淆,也不会加密字符串内容,只会改变变量名
攻击场景: 攻击者使用 GDA 的 AppStrings 功能搜索关键词 flag,3 秒内即可定位所有硬编码的敏感数据。
修复方案:
[Java] 纯文本查看 复制代码 // 方案1:服务器端验证(推荐)
private void verifyFlagOnServer(String input) {
// 将用户输入发送到服务器验证
// 客户端不存储任何 Flag
okhttp3.Request request = new okhttp3.Request.Builder()
.url("https://api.example.com/verify?input=" + hash(input))
.build();
}
// 方案2:Native层存储(增加逆向难度)
public native String getSecretKey();
// 在 C/C++ 代码中存储,使用 JNI 调用
// 方案3:运行时动态解密
private String decryptFlag() throws Exception {
byte[] encrypted = Base64.decode("ZW5jcnlwdGVkX2RhdGE=", Base64.DEFAULT);
SecretKeySpec keySpec = new SecretKeySpec(getKey(), "AES");
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, keySpec);
return new String(cipher.doFinal(encrypted), StandardCharsets.UTF_8);
}
漏洞 2:敏感日志泄露(中危)
代码位置: checkFlag() 方法第 18 行
[Java] 纯文本查看 复制代码 Log.d("CTF_DEBUG", "User submitted sensitive data: " + input);
风险原理:
实时数据泄露:用户输入的 Flag(可能是密码、密钥等敏感信息)被写入 Android 系统日志
无需权限读取:在 Root 设备上,任何应用只需执行 adb logcat 或申请 READ_LOGS 权限,即可实时截获日志
调试代码未移除:生产环境保留了开发阶段的调试日志,违反了安全开发最佳实践
攻击演示:
[Java] 纯文本查看 复制代码 # 攻击者连接设备后执行:
adb logcat | grep "CTF_DEBUG"#
输出示例:# D/CTF_DEBUG: User submitted sensitive data: flag{hello_gda_simple}
修复方案:
[Java] 纯文本查看 复制代码 // 方案1:使用 BuildConfig 区分环境
private static final boolean DEBUG = BuildConfig.DEBUG;
private void logDebug(String message) {
if (DEBUG) {
Log.d("CTF", message);
}
}
// 方案2:ProGuard 自动移除日志
// 在 proguard-rules.pro 中添加:
// -assumenosideeffects class android.util.Log {
// public static *** d(...);
// public static *** e(...);
// public static *** i(...);
// public static *** v(...);
// }
// 方案3:使用 Timber 日志框架
if (BuildConfig.DEBUG) {
Timber.d("User input length: %d", input != null ? input.length() : 0);
}
// Release 版本自动剥离日志
// 方案4:数据脱敏
private void logInput(String input) {
// 不打印完整数据,只打印长度或哈希
String masked = input != null ?
input.substring(0, Math.min(3, input.length())) + "***" : "null";
Log.d("CTF", "Input received: " + masked);
}
漏洞 3:弱验证逻辑(中危)
代码位置: checkFlag() 方法第 19-34 行
[Java] 纯文本查看 复制代码 if (input.equals(simpleFlag)) {
this.showSuccess(R$string.level1_passed);
return;
}
风险原理:
内存明文对比:String.equals() 方法在内存中直接进行字符串逐字符比较,攻击者可使用 Frida、Xposed 等动态插桩工具 Hook 该方法
无完整性保护:验证逻辑完全在客户端执行,没有数字签名、HMAC 等防篡改机制
可被绕过:攻击者无需知道 Flag,只需修改验证结果即可通过
Frida 攻击演示:
[JavaScript] 纯文本查看 复制代码 // 攻击者编写 Frida 脚本:
Java.perform(function() {
var MainActivity = Java.use("com.gda.test.ctfdemo.MainActivity");
// 方法1:直接 Hook checkFlag
MainActivity.checkFlag.implementation = function(input) {
// 无视输入,直接调用成功逻辑
this.showSuccess(2131099648); // R$string.level1_passed 的资源 ID
};
// 方法2:Hook String.equals
var String = Java.use("java.lang.String");
String.equals.overload("java.lang.Object").implementation = function(other) {
// 强制返回 true
return true;
};
});
// 执行:
// frida -U -f com.gda.test.ctfdemo -l bypass.js
修复方案:
[Java] 纯文本查看 复制代码 // 方案1:服务器端验证(彻底解决)
private void verifyFlag(String input) {
String timestamp = String.valueOf(System.currentTimeMillis());
String signature = generateHMAC(input + timestamp, SECRET_KEY);
// 发送到服务器验证
RequestBody body = new FormBody.Builder()
.add("input", hash(input))
.add("timestamp", timestamp)
.add("signature", signature)
.build();
// 服务器返回验证结果
}
// 方案2:多层验证(增加难度)
private boolean verifyFlag(String input) {
// 第一层:哈希对比(仍不推荐,但比明文好)
String inputHash = sha256(input);
if (!inputHash.equals(EXPECTED_HASH)) {
return false;
}
// 第二层:时间戳验证(防重放)
long currentTime = System.currentTimeMillis();
if (currentTime - lastVerifyTime < 1000) {
return false; // 1秒内重复验证,疑似自动化攻击
}
lastVerifyTime = currentTime;
// 第三层:环境检测
if (isRooted() || isEmulator()) {
return false; // Root 或模拟器环境,拒绝验证
}
return true;
}
// 方案3:代码混淆 + 反调试
// 使用 ProGuard/R8 混淆类名和方法名
// 添加反调试检测,发现 Frida/Xposed 时退出应用
漏洞 4:控制流可预测(低危)
代码位置: generateObfuscatedFlag() 方法
[Java] 纯文本查看 复制代码 char[] chars = {
'c', 'o', 'n', 't', 'r', 'o', 'l', '_',
'f', 'l', 'o', 'w', '_', 't', 'e', 's', 't'
};
风险原理:
字符数组明文:虽然使用了字符数组拼接,但数组内容在 Dex 文件中完全可见
无效混淆:while 循环中的 x = x + i; if (x > 15) x = x - 2; 是不透明谓词(Opaque Predicate),但计算结果不影响最终 Flag,逆向者可直接忽略
内存驻留:Flag 在运行时以明文形式存在于内存中,可被内存扫描工具提取
修复方案:
[Java] 纯文本查看 复制代码 // 方案1:Native层动态生成
public native String generateFlag();
// 在 C/C++ 代码中实现,增加逆向难度
// 方案2:运行时解密 + 内存清理
private String getDecryptedFlag() {
byte[] encrypted = {0x12, 0x34, 0x56, ...}; // 加密数据
byte[] key = getKeyFromNative(); // 从 Native 层获取密钥
try {
byte[] decrypted = aesDecrypt(encrypted, key);
String flag = new String(decrypted, StandardCharsets.UTF_8);
// 立即清理敏感数据
Arrays.fill(encrypted, (byte) 0);
Arrays.fill(decrypted, (byte) 0);
Arrays.fill(key, (byte) 0);
return flag;
} catch (Exception e) {
return null;
}
}
// 方案3:分片存储 + 动态拼接
private String buildFlag() {
// 从不同位置获取碎片
String part1 = getResources().getString(R.string.flag_part1);
String part2 = getFromSharedPreferences("flag_part2");
String part3 = getFromNative();
// 动态拼接
return part1 + part2 + part3;
}
|