-
-
frida基于bug的Stalker跟踪检测和修复
-
发表于: 2025-4-2 17:50 2045
-
frida基于bug的Stalker跟踪检测和修复
前言
Stalker 是 Frida 内部的一个模块用于动态追踪目标程序的执行流程,也就是当我们需要知道:
- 函数是怎么调用的?
- 指令是怎么一步步执行的?
- 分支是怎么跳转的?
- 有哪些函数是频繁被调用的?
的时候,会用到
Stalker.follow
跟踪线程将要执行的指令,可以对指令动态插桩修改。对莫名其妙的反调试行为来说,是一个通用的兜底窥探方案。
stalker跟踪检测
关于stalker参考官方文档: https://frida.re/docs/stalker/
注: 这个检测手段仅对于在开启Stalker.follow
跟踪的线程生效
- 原理:stalker会对执行过的内存地址(
block->real_start
)存储的指令会进行缓存,当再次进入要执行同一个地址的指令的时候,通过比对内存和缓存中的指令来确定是否需要重编译block
来确保执行到正确的原指令 - 本应该是如此,但是正如标题所说,这个检测依赖当前frida-gum
项目的stalker
的重编译bug,而这个bug就出现在比对和重编译上,导致了当执行同一个内存地址的block
时候,实际指令都是在跟第一次编译后的缓存block
的原指令快照进行比对。所以这样的后果就是显而易见的,初次执行该内存地址的指令会一直存在于stalker
的"阴影中",下面我会用一个测试例子来说明这个现象。
以上的场景说明存在简化,实际的问题路径在下面对应的代码:
通过下面一个例子来验证这个问题
- 为了利用上面这个bug,我们可以简单的实现两个函数,一个函数(
add_99_func
)是对传递的第一个参数+99,另一个函数(empty_func
)是不做任何操作直接返回原参数。
1 2 3 4 5 6 7 | uint64_t add_99_func(uint64_t count) { return count + 99; } // 对应的汇编可以是: // add x0, x0, #99 // ret |
1 2 3 4 5 6 | uint64 empty_func(uint64_t count){ return count; } // 对应的汇编可以是: // ret |
- 使用
mmap
来创建指定内存块用于复写和执行指令
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 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 | static void *exec_addr = nullptr; // insnBytes: 要写入的指令字节数组 // num: 调用func传递的count值 jstring mmap_exec(JNIEnv *env, jobject thiz, jbyteArray insnBytes, jint num) { size_t page_size = sysconf(_SC_PAGE_SIZE); if (page_size == -1) { std::cerr << "Failed to get page size!" << std::endl; return nullptr; } void *start_addr = exec_addr; int flags = MAP_ANON | MAP_PRIVATE; if (start_addr != nullptr) { flags |= MAP_FIXED; } void *mem = mmap( start_addr, page_size, PROT_READ | PROT_WRITE | PROT_EXEC, flags, -1, 0); if (mem == MAP_FAILED) { std::cerr << "mmap failed!" << std::endl; exec_addr = nullptr; return nullptr; } if (exec_addr == nullptr) { exec_addr = mem; // 下次也使用同一内存地址来写入指令 } jsize length = env->GetArrayLength((insnBytes)); jbyte *code = env->GetByteArrayElements(insnBytes, nullptr); std:: memcpy (mem, code, length); aarch64_sync_cache_range(mem, page_size); // 刷新cpu指令和内存数据缓存 func_t func = reinterpret_cast <func_t>(mem); uint64_t sum = func(num); // 调用函数 munmap(mem, page_size); env->ReleaseByteArrayElements(insnBytes, code, 0); char buff[128]; std:: sprintf (buff, "[%p] sum[%lu]" , mem, sum); return env->NewStringUTF(buff); } // jni native __attribute__((visibility( "default" ))) JNIEXPORT jobjectArray JNICALL Java_com_example_frida_1stalker_1recompile_1fix_MainActivity_mmapExec( JNIEnv *env, jobject thiz, jbyteArray inst1, jbyteArray inst2, jint base_num) { jclass stringCls = env->FindClass( "java/lang/String" ); const jsize test_num = 20; jobjectArray resultArray = env->NewObjectArray(test_num, stringCls, nullptr); // 每点击一次按钮,遍历交替复写执行20次 for (jsize i = 0; i < test_num; i++) { jbyteArray inst = i % 2 == 0 ? inst1 : inst2; jstring result1 = mmap_exec(env, thiz, inst, base_num); env->SetObjectArrayElement(resultArray, i, result1); } return resultArray; } |
- frida测试脚本
setImmediate(() => { Interceptor.attach( Module.getExportByName('libdl.so', 'android_dlopen_ext'), { onEnter(args) { this.filename = args[0].readCString() console.error(`[android_dlopen_ext] ${this.filename}`) }, onLeave(retval) { const filename: string = this.filename if(filename.includes('libtest_frida.so')) { attachMmapExec(Process.findModuleByName(filename)!) } }, } ) function attachMmapExec(mod: Module) { console.error(`[attachMmapExec] ${JSON.stringify(mod)}`) const target = mod.getExportByName("Java_com_example_frida_1stalker_1recompile_1fix_MainActivity_mmapExec") Interceptor.attach(target, { onEnter(args) { console.log(`[mmap_exec] follow => tid[${Process.getCurrentThreadId()}]`) Stalker.follow(Process.getCurrentThreadId(), { transform(iterator: StalkerArm64Iterator) { let inst while((inst = iterator.next()) !== null) { // console.log(`[${Process.getCurrentThreadId()}] ${inst}`) iterator.keep() } } }) }, onLeave(retval) { Stalker.unfollow() console.error(`[mmap_exec] unfollow => tid[${Process.getCurrentThreadId()}]`) }, }) } })
- 通过对于同一个内存块进行来回按照
empty_func
,add_99_func
的顺序来写入对应的的汇编来执行(假设传递的count=1
):
- 第一次执行
empty_func(1)
的时候返回值是1(首次编译,缓存根据empty_func生成的插桩指令) - 通过比对指令,第二次进行了重编译,执行
add_99_func(1)
返回100 (第一次重编译,缓存根据add_99_func生成的插桩指令,但是指令快照却是empty_func) - 第三次执行,预期是执行
empty_func(1)
返回1,但实际却因为快照是第一次编译的指令(empty_func),所以误认为指令没变动,所以执行了第一次重编译的插桩代码add_99_func,导致了此时会返回100。 - 第四次执行,预期是执行
add_99_func(1)
返回100,实际上也是执行了add_99_func(1)
,但是错误的又重编译一次 - ...接下来的都会是执行
add_99_func(1)
的结果
以下展示了三种测试结果用于说明:
1. 正常无frida-stalker跟踪现象

2. 使用原版frida-stalker跟踪现象

3. 使用修复的frida-stalker后的跟踪现象

编译修复frida-server
拉取源项目和合并修改
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | # 从frida中递归子模块拉取主分支(修改基于16.5.9进行,所以我编译这个版本,其他版本如果没冲突可以试试) git clone --branch 16.5.9 --single-branch --recurse-submodules https: //github .com /frida/frida .git # 进入子模块下的frida-gum cd subprojects /frida-gum # 添加修复的远程代码仓库地址 git remote add zsa233 https: //github .com /zsa233/frida-gum .git # 拉取zsa233仓库,并且将对应的修改分支合并到本地仓库 git fetch zsa233 git merge zsa233 /fix/stalker-wrong-recompile # 后面接着就是编译 |
编译
参考官方文档: https://frida.re/docs/building/#cross
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | # 指定ANDROID_NDK_ROOT,我这里下载r25c版本(16.5.9要求 # wget https://dl.google.com/android/repository/android-ndk-r25c-linux.zip # 其中还需要node.js >= 18之类的编译依赖,根据错误提示解决即可,这里不再赘述 # export ANDROID_NDK_VERSION=r25c # !!!/mypath/改成自己的ndk路径 export ANDROID_NDK_ROOT= /mypath/android-ndk- $ANDROID_NDK_VERSION/ # 回到frida项目下,配置编译android-arm64平台 . /configure --host=android-arm64 # 编译 make # 将编译成功的frida-server上传 adb push build /subprojects/frida-core/server/frida-server /data/local/tmp/frida-server # 加上x权限 adb shell chmod u+x /data/local/tmp/frida-server |
测试
资源链接
赞赏记录
参与人
雪币
留言
时间
马先越
期待更多优质内容的分享,论坛有你更精彩!
2025-4-15 19:24
sinker_
谢谢你的细致分析,受益匪浅!
2025-4-3 19:37
陈可牛
非常支持你的观点!
2025-4-3 14:36
pexillove
为你点赞!
2025-4-2 20:35
ngiokweng
感谢你的贡献,论坛因你而更加精彩!
2025-4-2 18:58
赞赏
他的文章
赞赏
雪币:
留言: