HappyLock

比赛的时候写了好久,可惜还是差一些,没有写出来...

赛中进度

模拟器里运行一下,这是一个手势密码验证程序,通过输入正确手势密码得到一个哈希值作为flag

框架oncomplete方法

jadx 反编译,核心在com.crackme.happylock.MainActivity类的oncomplete方法中,这个方法实现了将用户输入的手势密码进行编码加密处理以及与预设值进行对比

解析如下,其中StringObf.decode方法就是 base64 解码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@Override // com.andrognito.patternlockview.listener.PatternLockViewListener
public void onComplete(List<PatternLockView.Dot> list) {
MainActivity $r2 = MainActivity.this;
PatternLockView $r3 = $r2.mPatternLockView;
try {
String $r4 = PatternLockUtils.enc($r3, list); // 将用户绘制的图案进行加密
boolean $z0 = Utils.cmp($r4); // 比较加密后的图案和预设的图案
if (!$z0) {
// 如果图案不匹配
MainActivity $r22 = MainActivity.this;
PatternLockView $r32 = $r22.mPatternLockView;
$r32.setViewMode(2); // 设置图案锁为错误模式(显示错误提示)
MainActivity $r23 = MainActivity.this;
Context $r5 = $r23.context;
Toast $r8 = Toast.makeText($r5, StringObf.decode("VHJ5IGFnYWlu"), 0); // 解密提示信息,base64解码为"Try again"
$r8.show(); // 显示提示信息
return;
}
// 如果图案匹配
MainActivity $r24 = MainActivity.this;
PatternLockView $r33 = $r24.mPatternLockView;
$r33.setViewMode(0); // 设置图案锁为正常模式(显示成功提示)
MainActivity $r25 = MainActivity.this;
Context $r52 = $r25.context;
String $r7 = StringObf.decode("U3VibWl0IGRhcnR7"); // 解密成功提示的字符串,base64解码为"Submit dart{"
StringBuilder $r6 = new StringBuilder($r7);
Toast $r82 = Toast.makeText($r52, $r6.append($r4).append(StringObf.decode("fQ==")).toString(), 1); // 显示包含加密图案的提示信息,base64解码为"}"
$r82.show(); // 显示提示
} catch (UnsupportedEncodingException | NoSuchAlgorithmException $r9) {
// 异常处理
RuntimeException $r10 = new RuntimeException($r9);
throw $r10; // 抛出运行时异常
}
}

可以看到,调用PatternLockUtils.enc方法对用户输入进行加密处理,调用Utils.cmp方法比较加密后的图案和预设的图案,正确会将结果用Submit dart{}包裹起来显示,错误会显示Try again

HookUtils.cmp方法

于是先尝试直接在onComplete里hook比较函数Utils.cmp,将返回值修改为正确,看一下加密结果的格式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function hookUtilsCmp() {
let C06201 = Java.use("com.crackme.happylock.MainActivity$1");
C06201["onComplete"].implementation = function (list) {
let Utils = Java.use("com.crackme.happylock.Utils");
Utils.cmp.implementation = function(str) {
console.log(`Original input string: ${str}`);
return true;
};
this["onComplete"](list);
};
}

function main() {
Java.perform(function () {
hookUtilsCmp();
});
}

setImmediate(main);

不出所料,结果是一个40位十六进制的哈希值,可能是SHA-1做的哈希

同时,安卓端也显示了加密结果

但是上面的代码也可以看到,虽然绕过了cmp的检验,这里显示的哈希值还是我们随机输入的手势密码的结果,并不是目标密码的结果

1
Toast $r82 = Toast.makeText($r52, $r6.append($r4).append(StringObf.decode("fQ==")).toString(), 1);

那么就要去看Utils.cmp方法的具体实现了

主要实现的是反射调用clz.cmp方法

反射调用clz.cmp方法

于是hook查看一下反射调用的clz的具体信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function hookUtilsCmp() {
let Utils = Java.use("com.crackme.happylock.Utils");
Utils["cmp"].implementation = function (str) {
console.log(`Utils.cmp is called: str=${str}`);
var clz = this.clz;
console.log("Reflection will invoke method in class: " + clz);
let result = this["cmp"](str);
console.log(`Utils.cmp result=${result}`);
return result;
};
}

function main() {
Java.perform(function() {
hookUtilsCmp();
});
}

setImmediate(main);

这个时候程序运行崩溃了,打印了clz的相关信息,可以看到 clz实际上是一个Java.Field 类型对象,它的value属性是一个名为com.crackme.happylock.Check的类,比较可疑

于是我们通过clz.value来访问

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function hookUtilsCmp() {
let Utils = Java.use("com.crackme.happylock.Utils");
Utils["cmp"].implementation = function (str) {
console.log(`Utils.cmp is called: str=${str}`);
var clz = this.clz.value;
console.log("Reflection will invoke method in class: " + clz);
let result = this["cmp"](str);
console.log(`Utils.cmp result=${result}`);
return result;
};
}

function main() {
Java.perform(function() {
hookUtilsCmp();
});
}

setImmediate(main);

遗憾的是虽然 clz 被成功获取为 class com.crackme.happylock.Check,但是崩溃仍然发生,后续还换了各种方法,还是一直崩溃

同时注意到,其实 jadx 中并没有com.crackme.happylock.Check类,可能是动态加载的

动态加载Check

先尝试一下用java.use()强制加载

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function hookUtilsCmp() {
let Utils = Java.use("com.crackme.happylock.Utils");
Utils["cmp"].implementation = function (str) {
console.log(`Utils.cmp is called: str=${str}`);
var clz = this.clz.value;
console.log("Reflection will invoke method in class: " + clz);
let check = Java.use(clz.getName());
let result = this["cmp"](str);
console.log(`Utils.cmp result=${result}`);
return result;
};
}

function main() {
Java.perform(function() {
hookUtilsCmp();
});
}

setImmediate(main);

果然不行,应该就是动态加载的

同时观察到com.crackme.happylock.Utils里有一个loadclass方法,猜测Check类是由它加载的,但是 jadx 并没有把它反编译出来,没法看到它的具体实现

hook检查一下它是否调用了 DexClassLoader 或其他类似的类加载器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
function hookDexClassLoader() {
var DexClassLoader = Java.use("dalvik.system.DexClassLoader");

// Hook DexClassLoader 的构造函数
DexClassLoader.$init.overload(
"java.lang.String",
"java.lang.String",
"java.lang.String",
"java.lang.ClassLoader"
).implementation = function (dexPath, optimizedDirectory, librarySearchPath, parent) {
console.log("DexClassLoader initialized:");
console.log(" dexPath: " + dexPath);
console.log(" optimizedDirectory: " + optimizedDirectory);
console.log(" librarySearchPath: " + librarySearchPath);
console.log(" parent: " + parent);

return this.$init(dexPath, optimizedDirectory, librarySearchPath, parent);
};

// Hook loadClass 方法
DexClassLoader.loadClass.overload("java.lang.String").implementation = function (className) {
console.log("DexClassLoader.loadClass called for class: " + className);
return this.loadClass(className);
};
}

function main() {
Java.perform(function () {
hookDexClassLoader(); // Hook DexClassLoader
});
}

setImmediate(main);

可以看到,Check类的加载确实调用了DexClassLoader.loadClass方法,而且在下面还看到了SHA-1字样,验证了之前的猜测,手势密码加密编码之后是用SHA-1做哈希的

我赛中的进度就至此了,后面一直在捣鼓反射的事情,再到后面还尝试了全局扫描SHA-1哈希算法的调用情况,试图扫到目标手势密码编码后做哈希的过程,都失败了...

而且,其实gpt当时给了我正确解法,但我一直忽略了,在纠结反射、自己的loadclass方法什么的...

赛后复现

dumpclasses.dex

正确解法就是去 dump 运行时的dex文件classes.dex,这里用到了一个新工具GDA,具体的介绍见作者知乎

需要注意的是先将Gdumppush到安卓端,同时将GDAadb环境变量放在模拟器adb环境变量之上

打开 GDA,点击导航栏安卓图标的 dump 功能打开GDA Device Dumper,在左侧选择com.crackme.happylock进程,右侧筛选DEX文件,可以看到/data/data/com.crackme.happylock/files/classes.dex,右键Dump Module即可

一开始我也一直 dump 不出来,只能说多试几次,重启一下之类的就可以了,或者就是 GDA 的 Gdump、adb 没有配好,就是我上面说的

也可以全局查找,显示了 PID、地址范围等信息

也可以在下方输入要 dump 的文件的 PID、start_addr 和 size

dump 出来的文件在GDA.exe的同级目录下,拖到 jadx 里分析,但是报错了

日志显示错误为Bad checksum,看大佬的解释是 hook 之后填充字节改变了sum

修改 dump 出来的classes.dexsum值即可

但是 jadx 还是反编译失败了

同样,也可以选择拖到 GDA 里面分析,但仍然反编译不出来什么东西

在学习大佬写的文章复现的时候,还看到了一个frida-dump脚本库,在这里跑出来的结果也是一样的

根据参考的两篇文章,这里是字节码有问题,代码被抽取,这道题实际上就是用 ShadowHook 实现类抽取

修复classes.dex

Java 层卡住了,就要去 Native 层看一下了

ida 分析 libhappylock.so 文件(不得不说 ida9.0 确实好用,8.3 没有 arm、mips 什么的反编译器,还得去用 7.7 反编译,现在 9.0 都有了,定位到JNI_OnLoad函数

一般来说,JNI_OnLoad函数里会有本地方法注册RegisterNatives,但这里并没有

到这里,对于JNI_OnLoad函数的分析,我参考的两篇大佬的文章用了不一样的方法,我这里就复现了一种

使用Bindiff恢复符号

至于怎么看出来是 ShadowHook 的,我就不知道了...

ShadowHook 是字节跳动的一个开源 inline hook 框架

于是下载抖音 apk,提取libshadowhook.so文件,用 ida 打开,保存得到 i64 文件,ida 打开libhappylock.so文件,ctrl+6 调用bindiff,选择 Diff Database,选择libshadowhook.so的 i64 文件

需要注意Bindiff不支持中文路径,所选择的 i64 文件路径不要有中文,也不要有除下划线外的特殊符号

在 Matched Functions 窗口中选中所有匹配的函数符号

右键点击 Import symbols/comments 即可恢复匹配的符号

可以看到,确实有大量 ShadowHook 相关函数,而且JNI_OnLoad函数中就有调用

ShadowHook 分析

其中,由于有符号信息,shadowhook_hook_func_addr函数就是调用shadowhook_hook_sym_name函数

参考ShadowHook 手册,ShadowHook 支持通过“函数地址”或“库名 + 函数名”指定 hook 位置

参考手册,shadowhook_hook_sym_name函数声明如下

1
2
3
#include "shadowhook.h"

void *shadowhook_hook_sym_name(const char *lib_name, const char *sym_name, void *new_addr, void **orig_addr);s

参数

  • lib_name(必须指定):需要被 hook 的函数所在的库名
  • sym_addr(必须指定):需要被 hook 的函数的符号名
  • new_addr(必须指定):新函数(proxy 函数)的绝对地址
  • orig_addr(不需要的话可传 NULL):返回原函数地址

这种方式可以 hook “当前已加载到进程中的动态库”,也可以 hook “还没有加载到进程中的动态库”,即如果 hook 时动态库还未加载,ShadowHook 内部会记录当前的 hook “诉求”,后续一旦目标动态库被加载到内存中,将立刻执行 hook 操作

参考以上,观察这里的shadowhook_hook_sym_name函数的参数,前两个参数如下,但是需要注意的是这里显示的数据不是正确的,交叉引用可以看到对这些值有动态修改

sub_12414函数如下,可以看到其中对0x430C0-0x430C6有一个异或 4 的操作,对0x430B8有一个异或0xE1E1E1E1E1E1E1E1的操作

而查看sub_12414的交叉引用就能发现它在.init_array段,也就意味着这些异或操作程序一运行就会进行

本来想动调的,但是有点问题,那就手动异或回去吧,可以看到lib_namelibc.sosym_nameexecveexecve 是一个系统调用,用来启动一个新程序

看到不是目标函数我就没继续下去了,看大佬的文章,后面代理函数sub_121E4中判断系统调用是否为dex2oat,如果是则不执行,如果不是则执行

下面shadowhook_hook_sym_name_callback里也调用了shadowhook_hook_sym_name

同样地,之前的sub_12414函数中也看到了,对0x430C8有一个异或0x7D7D7D7D7D7D7D7D的操作,异或之后得到lib_namelibart.so

我这里 ida 识别的有点问题,需要手动把下面0x430D00x12带上

这里符号恢复得也有点问题,别人的恢复除了这里的sym_name,也就是sub_1216C,是__android_log_print函数,而代理函数正是我们苦苦寻求的目标函数

这个函数实现的功能是检测dex文件大小,如果匹配到目标dex则执行回填操作,从偏移为0x178(376)开始的0x98大小的字节,回填的内容就是0x42D90处内容

而检测dex文件大小为0x3AC,实际上就是之前 dump 出来的dex文件的有效部分,后面都是0

同时,偏移为0x217(535)的地方也要赋值为0x430A0处的值,同样也要进行异或

把这些内容按照上面的说的偏移位置填入,即修复成功

终于反编译成功!!!

很简单的异或逻辑,exp如下

1
2
3
4
5
6
enc = [118, 17, 2, 80, 9, 125, 6, 22, 113, 66, 0, 81, 94, 41, 87, 20, 122, 65, 88, 5, 94, 41, 7, 19, 118, 22, 3, 2, 90, 41, 87, 71, 117, 68, 4, 7, 95, 116, 4, 67]
key = b'CrackMe!CrackMe!'
flag = []
for i in range(len(enc)):
flag.append(enc[i]^key[i%len(key)])
print(bytes(flag).decode())

参考

[2025软件系统安全赛]HappyLock(对抗使用SHadowHook实现的类抽取)

Android安全-全国高校大学生软件创新大赛-软件系统安全赛 HappyLock复现