安卓逆向初探
声明
萌新初学安卓,主要是跟着吾爱破解正己老师的视频学的,仅作学习记录,源地址 《安卓逆向这档事》一、模拟器环境搭建 - 吾爱破解 - 52pojie.cn
环境配置
整一个redmi老年机,解bl锁,线刷android11,上magisk面具,安装 zygisk+LSPosed 框架即可。可以创建一个LSPosed寄生管理器快捷方式。
由于我的测试机是 redmi9A,非常罕见的处理器是 v8a,但是系统是 32 位的,没有 arm64 的动态链接库,所以刷机配环境这一块折腾了很久,浅浅记录一下:
刷系统
我们先从 小米 ROM 找到对应机型的线刷包,然后使用 miFlash 进行刷机,当然刷官方提供的系统有一万种方法,刷进去就行,当作底包,也是一个从砖机恢复的手段。
这个网盘里有一些 redmi 9A 的工具,Redmi 9A官方版下载,我们可以从里面找到安卓驱动,miFlashPro,搞机助手等工具,以及非官方的自制 LineageOS系统和自制 TWRP recovery.img。
在刷入官方提供的 ROM 以后,其实大部分机型到这里就可以了,但是因为我这个是 32 位的,得接着刷emmm。
按住音量下键和关机键进入 FASTBOOT 引导模式,在该模式下我们把 TWRP 文件夹中的 recovery.img 刷入手机中覆盖官方的 recovery.img,然后我们按住手机的音量上键和关机键,进入TWRP recovery 模式,先格式化 data,然后重启再次进入,把 LineageOS 的ROM放在download 文件夹下,install 后,再次格式化 data,重启进入系统即可。
Magisk
很简单,找到 LineageOS ROM 包的 boot.img放在 download 目录,用 magisk 进行修补,把修补后的 img 拖出来,然后让手机进入 FASTBOOT模式,输入以下命令
1 | fastboot flash boot 面具文件 |
详细步骤见Magisk安装教程
pixel 6 pro
Pixel 刷机教程(已Root 保数据升级)(使用PixelFlasher)
如果你是买的 pixel6 以后的机子最好关一下 AVB 校验
然后去叉
1 | adb shell "settings put global captive_portal_https_url https://captive.v2ex.co/generate_204" |
或者
1 | adb shell "settings put global captive_portal_http_url http://connect.rom.miui.com/generate_204" |
一些工具
- MT & NP 管理器:安卓端的神器,可以干很多事情比如提取安装包,反smail字节码,反编译,patch,自动签名,全局搜索等
- 开发者助手,开发助手,算法助手:hook软件,可以查看每个activity的资源并显示信息,防弹窗等等
- LSPosed:一个hook框架,作用域切换方便
- 秋之盒:一个adb前端,下载的apk文件可以通过它直接安装,还有一些其它功能,用电脑端操作手机非常方便
- 搞机助手:也是一个adb前端,功能比秋之盒要多些,名字要下头些前端要丑些emmm
- Anlink & ScrcpyGUI:支持在电脑上操作手机
- jdax-gui,GDA,JEB:电脑端的反编译工具,JEB支持动态调试
- xAPPdebug:修改apk debug权限
- adb:一份超全超详细的 ADB 用法大全
安卓动调
修改apk文件为可调式,可以添加 xml 文件的
<application>
标签里添加android:isdebuggable
,或者使用xAPPdebug软件,或者使用adb命令:1
2
3
4adb shell
su
magisk resetprop ro.debuggable 1
stop;start打开开发者选项启动USB调试
adb devices
查看设备启动 activity
1
2
3adb shell am start -D -n 包名/类名 如(com.ep.example/.ui.MainActivity)
am start -n 表示启动一个activity
am start -D 表示将应用设置为可调试模式步骤4过后,手机端会显示正在启动debugg模式,说明应用启动成功,接下来我们启用 JEB 附加调试
1
2
3
4ctrl + F6 步入方法
F6 步过
F7 跳出方法
ctrl + R 运行到光标处(需要选中某一行)
log 插桩
定义:Log插桩指的是反编译APK文件时,在对应的smali文件里,添加相应的smali代码,将程序中的关键信息,以log日志的形式进行输出。
1 | invoke-static {对应寄存器}, Lcom/mtools/LogUtils;->v(Ljava/lang/Object;)V |
APK 签名
通过对 Apk 进行签名,开发者可以证明对 Apk 的所有权和控制权,可用于安装和更新其应用。而在 Android 设备上的安装 Apk ,如果是一个没有被签名的 Apk,则会被拒绝安装。在安装 Apk 的时候,软件包管理器也会验证 Apk 是否已经被正确签名,并且通过签名证书和数据摘要验证是否合法没有被篡改。只有确认安全无篡改的情况下,才允许安装在设备上。
简单来说,APK 的签名主要作用有两个:
- 证明 APK 的所有者(APK是谁开发的)
- 允许 Android 市场和设备校验 APK 的正确性。
签名校验
不做任何修改,直接签名安装,应用闪退则说明大概率有签名校验,一般来说,普通的签名校验会导致软件的闪退,黑屏,卡启动页等。
当然,以上都算是比较好的,有一些比较狠的作者,则会直接rm -rf /,把基带都格掉的一键变砖。
特征:
1 | kill/killProcess-----kill/KillProcess()可以杀死当前应用活动的进程,这一操作将会把所有该进程内的资源(包括线程全部清理掉).当然,由于ActivityManager时刻监听着进程,一旦发现进程被非正常Kill,它将会试图去重启这个进程。这就是为什么,有时候当我们试图这样去结束掉应用时,发现它又自动重新启动的原因. |
还有一种三角校验:就是so检测dex,动态加载的dex(在软件运行时会解压释放一段dex文件,检测完后就删除)检测so,dex检测动态加载的dex
签名校验对抗
方法一
使用核心破解插件,不进行签名使用app
方法二
一键过签名工具,例如MT、NP、ARMPro、CNFIX、Modex的去除签名校验功能
方法三
具体分析签名校验逻辑(手撕签名校验),可以用MT & NP 管理器进行patch
例如:首先用算法助手开启拦截应用退出以及读取应用签名监听,从而定位到签名算法的具体位置,然后使用jdax-gui进行反编译找到签名逻辑,一个简单的签名校验如下,系统将应用的签名信息封装在 PackageInfo 中,调用 PackageManager 的 getPackageInfo(String packageName, int flags) 即可获取指定包名的签名信息
1 | private boolean SignCheck() { |
通过算法助手的hook,我们可以获取我们重新签名后的应用签名,程序本身会有一个写死的待验证签名,我们把这个待验证签名修改了即可。
方法四
io 重定向:VA&SVC:ptrace+seccomp
[原创]SVC的TraceHook沙箱的实现&无痕Hook实现思路
手动实现PM代理
PackageManagerService(简称PMS),是Android系统核心服务之一,处理包管理相关的工作,常见的比如安装、卸载应用等。
fourbrother/HookPmsSignature: Android中Hook 应用签名方法
IO 重定向
就是在读A文件的时候指向B文件
asLody/VirtualApp: Virtual Engine for Android
其他检测手段
还有一些其他检测手段,这里直接放上原文吧
校验的N次方-签名校验对抗、PM代{过}{滤}理、IO重定向
smail 变量赋值
1 | const/4: 4bit |
时间戳转换:在线时间戳转换工具
多处patch可以用正则表达式搜索并修改,比如寄存器不同,可以用.*
Xposed 自定义 hook
android studio 配置
用 android studio 开发一个自定义 hook 函数。
Android Studio创建新项目
将下载的xposedBridgeApi.jar包拖进libs文件夹
右击jar包,选择add as library
修改xml文件配置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.XposedMyHook"
tools:targetApi="31" >
<!-- 是否是xposed模块,xposed根据这个来判断是否是模块 -->
<meta-data
android:name="xposedmodule"
android:value="true" />
<!-- 模块描述,显示在xposed模块列表那里第二行 -->
<meta-data
android:name="xposeddescription"
android:value="这是一个Xposed模块" />
<!-- 最低xposed版本号(lib文件名可知) -->
<meta-data
android:name="xposedminversion"
android:value="89" />
</application>修改
build.gradle
,将此处修改为compileOnly
默认的是implementation
,implementation
使用该方式依赖的库将会参与编译和打包compileOnly
只在编译时有效,不会参与打包新建—>Folder—>Assets Folder,创建xposed_init(不要后缀名):只有一行代码,就是说明入口类
新建Hook类,实现IXposedHookLoadPackage接口,然后在handleLoadPackage函数内编写Hook逻辑
1
2
3
4
5
6
7
8import de.robv.android.xposed.IXposedHookLoadPackage;
import de.robv.android.xposed.callbacks.XC_LoadPackage;
public class Hook implements IXposedHookLoadPackage {
public void handleLoadPackage(XC_LoadPackage.LoadPackageParam loadPackageParam) throws Throwable {
}
}
写好 hook 函数以后,点击运行,然后在手机上的LSPosed框架里找到我们写好的自定义模块,这里有一个比较奇怪的问题,就是如果我修改了这个hook模块,我必须在LSPosed里把原先的模块卸载掉,然后重新点击AS里的运行,重新进行安装,才能更新修改以后的hook模块。
出线找不到 activity 的情况需要把 debugConfigrations里的launch options改成nothing
hook 普通方法
修改返回值:
1 | XposedHelpers.findAndHookMethod("类名", loadPackageParam.classLoader, "方法名", String.class, new XC_MethodHook() { |
修改参数:
1 | XposedHelpers.findAndHookMethod("类名", loadPackageParam.classLoader, "方法名", String.class, new XC_MethodHook() { |
Hook复杂&自定义参数
1 | Class a = loadPackageParam.classLoader.loadClass("类名"); |
Hook替换函数
1 | Class a = classLoader.loadClass("类名") |
Hook加固通杀
1 | XposedHelpers.findAndHookMethod(Application.class, "attach", Context.class, new XC_MethodHook() { |
Hook 变量
静态变量:
如果要hook一个String,就用 setStaticObjectField
1 | final Class clazz = XposedHelpers.findClass("类名", classLoader); |
实例变量:
先获取字节码,然后在构造函数以后进行hook
1 | final Class clazz = XposedHelpers.findClass("类名", classLoader); |
Hook构造函数
无参构造函数
1 | XposedHelpers.findAndHookConstructor("com.zj.wuaipojie.Demo", classLoader, new XC_MethodHook() { |
有参构造函数
1 | XposedHelpers.findAndHookConstructor("com.zj.wuaipojie.Demo", classLoader, String.class, new XC_MethodHook() { |
Hook multiDex方法
1 | XposedHelpers.findAndHookMethod(Application.class, "attach", Context.class, new XC_MethodHook() { |
主动调用
静态方法:
1 | Class clazz = XposedHelpers.findClass("类名",lpparam.classLoader); |
实例方法:
1 | Class clazz = XposedHelpers.findClass("类名",lpparam.classLoader); |
Hook内部类
内部类:类里还有一个类class
1 | XposedHelpers.findAndHookMethod("com.zj.wuaipojie.Demo$InnerClass", lpparam.classLoader, "innerFunc",String.class, new XC_MethodHook() { |
反射大法
1 | Class clazz = XposedHelpers.findClass("com.zj.wuaipojie.Demo", lpparam.classLoader); |
遍历所有类下的所有方法
1 | XposedHelpers.findAndHookMethod(ClassLoader.class, "loadClass", String.class, new XC_MethodHook() { |
Xposed妙用
字符串赋值定位:
1 | XposedHelpers.findAndHookMethod("android.widget.TextView", lpparam.classLoader, "setText", CharSequence.class, new XC_MethodHook() { |
点击事件监听:
1 | Class clazz = XposedHelpers.findClass("android.view.View", lpparam.classLoader); |
改写布局:
1 | XposedHelpers.findAndHookMethod("com.zj.wuaipojie.ui.ChallengeSixth", lpparam.classLoader, |
SO 文件分析
SO 加载流程
函数名 | 描述 |
---|---|
android_dlopen_ext() 、dlopen() 、do_dlopen() |
这三个函数主要用于加载库文件。android_dlopen_ext 是系统的一个函数,用于在运行时动态加载共享库。与标准的 dlopen() 函数相比,android_dlopen_ext 提供了更多的参数选项和扩展功能,例如支持命名空间、符号版本等特性。 |
find_library() |
find_library() 函数用于查找库,基本的用途是给定一个库的名字,然后查找并返回这个库的路径。 |
call_constructors() |
call_constructors() 是用于调用动态加载库中的构造函数的函数。 |
init |
库的构造函数,用于初始化库中的静态变量或执行其他需要在库被加载时完成的任务。如果没有定义init 函数,系统将不会执行任何动作。需要注意的是,init 函数不应该有任何参数,并且也没有返回值。 |
init_array |
init_array 是ELF(Executable and Linkable Format,可执行和可链接格式)二进制格式中的一个特殊段(section),这个段包含了一些函数的指针,这些函数将在main() 函数执行前被调用,用于初始化静态局部变量和全局变量。 |
jni_onload |
这是Android JNI(Java Native Interface)中的一个函数。当一个native库被系统加载时,该函数会被自动调用。JNI_OnLoad 可以做一些初始化工作,例如注册你的native方法或者初始化一些数据结构。如果你的native库没有定义这个函数,那么JNI会使用默认的行为。JNI_OnLoad 的返回值应该是需要的JNI版本,一般返回JNI_VERSION_1_6 。 |
JNI动态注册流程:
1 | //第一步,实现JNI_OnLoad方法 |
其中第四步gMethods变量是JNINativeMethod结构体,用于映射Java方法与C/C++函数的关系,其定义如下:
1 | typedef struct { |
下断点时机:
应用级别的:java_com_XXX;
外壳级别的:JNI_Onload,.init,.init_array(反调试);
系统级别的:fopen,fget,dvmdexfileopen(脱壳);
一个 native 方法在 SO 文件里面的参数,第一个为 JNIEnv *env
,第二个是 jclass
,然后就是对应的参数类型,比如 jstring
等
SO 文件动态调试
[原创]新手关于ida动态调试so的一些坑总结-Android安全-看雪-安全社区|安全招聘|kanxue.com
准备工作
在 IDA 目录下的 dbgsrv 找到对应的 android_server,一般是 android_server64
执行以下命令,可以用MT管理器修改 android_server64 的名字,有可能能够绕过调试检测
adb push android_server64 /data/local/tmp adb shell su cd /data/local/tmp chmod 777 android_server64
XappDebug Hook
启动调试
有两种模式,分别是debug模式和普通模式,二者的区别在于使用场景,有时候要动态调试的参数在app一启动的时候就产生了,时机较早,所以需要以debug模式去挂起app。
debug 模式
1
2
3
4adb shell am start -D -n 包名/.类名.方法名 (去掉-D 则表示不以debug模式启动app)
adb forward tcp:23946 tcp:23946 (端口转发)
adb forward tcp:8700 jdwp:PID (pid监听)
jdb -connect com.sun.jdi.SocketAttach:hostname=127.0.0.1,port=8700 (jdb挂起)普通模式
1
2adb shell am start -n com.zj.wuaipojie/.ui.ChallengeEight (去掉-D 则表示不以debug模式启动app)
adb forward tcp:23946 tcp:23946 (端口转发)
此时我们已经打开了对应的手机应用界面
然后再输入以下命令:
1 | adb shell "su -c './data/local/tmp/as64'" |
成功启动 android_server64 以后,显示在 23946 端口进行监听,我们进入 IDA ,下好断点,选择
然后选择 debugger -> attach process,找到对应的进程即可
我们在打开断点窗口找到我们之前下好的断点,点进去
这里有一个小技巧,可以用 HexRays-decompile 插件,把 pring only constant string literals 选项给去掉,就可以直接在 c 语言窗口显示常量字符串,而不是显示一个字符串的变量,点进去才能看见。
运行后我们 F9 一下,此时程序处于接收输入状态,遇到SIGNAL直接静默就可以,我们回到手机界面,在输入栏随便输入一些东西,点击验证,然后就寄啦,程序直接退出。
原来是程序有反调试,由于之前的分析,JNI_OnLoad
还有 init_array
先于我们注册的函数之前运行,有可能出现反调试,我们找到对应的函数查看,果然出现了 ptrace
反调试,nop 掉即可,记得 apply to applications。
这里由于patch了so文件,我们需要和手机里的进行同步,把该 SO 文件传入手机目录,然后使用MT管理器进行同步
然后我们将修改后的 apk 文件重新安装,重新进入调试,这时候我们发现在输入以后点击验证,稍等一会儿就可以运行到断点处(这张图是重新加载的ida,可以和之前对比一下,就会发现区别还是挺大的)
我们在执行完加密函数后找到X0函数,去掉前面的B4偏移,就是结果 wuaipojie2023
可能遇到的问题
无法附加到目标VM
1
2
3
4
5
6
7
8
9
10
11
12java.io.IOException: handshake failed - connection prematurally closed
at com.sun.tools.jdi.SocketTransportService.handshake(SocketTransportService.java:136)
at com.sun.tools.jdi.SocketTransportService.attach(SocketTransportService.java:232)
at com.sun.tools.jdi.GenericAttachingConnector.attach(GenericAttachingConnector.java:116)
at com.sun.tools.jdi.SocketAttachingConnector.attach(SocketAttachingConnector.java:90)
at com.sun.tools.example.debug.tty.VMConnection.attachTarget(VMConnection.java:519)
at com.sun.tools.example.debug.tty.VMConnection.open(VMConnection.java:328)
at com.sun.tools.example.debug.tty.Env.init(Env.java:63)
at com.sun.tools.example.debug.tty.TTY.main(TTY.java:1066)
致命错误:
无法附加到目标 VM。
解决方法:有可能是手机问题,建议低版本真机,不要用模拟器!切命令顺序不要乱!另外也有可能软件有反调试!动态调试中找不到so文件:可以尝试手动复制一份对应的so文件放到data/app/包名/lib目录下
device offline :重新插拔usb,再不行就重启机子
Address 被占用
1
2
3
45.0.0.0.0:23946: bind: Address already in use
解决方法:
adb shell "su -c 'lsof | grep 23946'" //获取pid
adb shell "su -c 'kill -9 PID'" //这里的pid要根据上一步获取的填写
常见反调试
调试端口检测:检测常见的23946端口,所以在运行时可以加 -p 指定一个另外的端口来过掉这个检测
调试进程名检测:固定的进程名 android_server gdb_server等等,所以要改个名字,例如as64
ptrace检测:每个进程同时刻只能被1个调试进程ptrace ,主动ptrace本进程可以使得其他调试器无法调试
1
2
3
4int ptrace_protect()//ptrace附加自身线程 会导致此进程TracerPid 变为父进程的TracerPid 即zygote
{
return ptrace(PTRACE_TRACEME,0,0,0);;//返回-1即为已经被调试
}
常见防护手段
主要功能 | 描述 |
---|---|
SO加壳 | 对C/C++源码编译出来的SO文件进行加壳,使SO文件无法正确反编译和反汇编。 |
SO源码虚拟化保护 | 将原始汇编指令翻译为自定义的虚拟机指令,跳转到自定义的虚拟机中执行,每次保护生成的虚拟机指令随机,且对虚拟机解释器再度混淆 |
SO防调用 | 对SO文件进行授权绑定,防止SO文件被非授权应用调用运行。 |
SO Linker | 对整个SO文件进行加密压缩,包括代码段、符号表和字符串等,运行时再解密解压缩到内存,从而有效的防止SO数据的泄露。 |
SO源码混淆 | 常量字符串加密、分裂基本块、等价指令替换、虚假控制流、控制流平坦化。 |
SO环境监测 | 防frida\xposed\root、防动态调试、防模拟器、防多开等 |
初识 frida
frida是一款基于python + java 的hook框架,可运行在android、ios、linux、win、osx等各平台,主要使用动态二进制插桩技术。
frida框架分为两部分:
- 一部分是运行在系统上的交互工具frida CLI。
- 另一部分是运行在目标机器上的代码注入工具 frida-serve。
Frida 注入的原理:
- 找到目标进程,使用ptrace跟踪目标进程
- 获取mmap,dlpoen,dlsym等函数库的偏移
- 获取mmap,在目标进程申请一段内存空间
- 在目标进程中找到存放frida-agent-32/64.so的空间
- 启动执行各种操作,由agent去实现
组件名称 | 功能描述 |
---|---|
frida-gum | 提供了inline-hook的核心实现,还包含了代码跟踪模块Stalker,用于内存访问监控的MemoryAccessMonitor,以及符号查找、栈回溯实现、内存扫描、动态代码生成和重定位等功能 |
frida-core | fridahook的核心,具有进程注入、进程间通信、会话管理、脚本生命周期管理等功能,屏蔽部分底层的实现细节并给最终用户提供开箱即用的操作接口。包含了frida-server、frida-gadget、frida-agent、frida-helper、frida-inject等关键模块和组件,以及之间的互相通信底座 |
frida-gadget | 本身是一个动态库,可以通过重打包修改动态库的依赖或者修改smali代码去实现向三方应用注入gadget,从而实现Frida的持久化或免root |
frida-server | 本质上是一个二进制文件,类似于前面学习到的android_server,需要在目标设备上运行并转发端口,在Frida hook中起到关键作用 |
frida 环境配置
python 环境配置
在本地开一个 python 虚拟环境,然后安装 frida 和 frida-tools 即可,或者直接用 pycharm
1 | python -m venv venv |
frida - Server
运行以下命令查看 cpu 型号
1 | adb shell getprop ro.product.cpu.abi |
这里选择对应版本的 frida-server,比如我这里选择 arm64 ,然后 push 到 data/local/tmp 目录下,授权后运行
设置端口转发
1 | adb forward tcp:27042 tcp:27042 |
frida 基础知识
基本命令
然后在虚拟环境中使用 frida [options] target
来使用 frida
输入 frida-ps -U
会显示连接的 USB 设备的进程名,说明 frida 安装成功
1 | frida-ps --help |
操作模式
操作模式 | 描述 | 优点 | 主要用途 |
---|---|---|---|
CLI(命令行)模式 | 通过命令行直接将JavaScript脚本注入进程中,对进程进行操作 | 便于直接注入和操作 | 在较小规模的操作或者需求比较简单的场景中使用 |
RPC模式 | 使用Python进行JavaScript脚本的注入工作,实际对进程进行操作的还是JavaScript脚本,可以通过RPC传输给Python脚本来进行复杂数据的处理 | 在对复杂数据的处理上可以通过RPC传输给Python脚本来进行,有利于减少被注入进程的性能损耗 | 在大规模调用中更加普遍,特别是对于复杂数据处理的需求 |
注入模式与启动命令:
注入模式 | 描述 | 命令或参数 | 优点 | 主要用途 |
---|---|---|---|---|
Spawn模式 | 将启动App的权利交由Frida来控制,即使目标App已经启动,在使用Frida注入程序时还是会重新启动App | 在CLI模式中,Frida通过加上 -f 参数指定包名以spawn模式操作App | 适合于需要在App启动时即进行注入的场景,可以在App启动时即捕获其行为 | 当需要监控App从启动开始的所有行为时使用 |
Attach模式 | 在目标App已经启动的情况下,Frida通过ptrace注入程序从而执行Hook的操作 | 在CLI模式中,如果不添加 -f 参数,则默认会通过attach模式注入App | 适合于已经运行的App,不会重新启动App,对用户体验影响较小 | 在App已经启动,或者我们只关心特定时刻或特定功能的行为时使用 |
Spawn模式
1 | frida -U -f 进程名 -l hook.js |
attach模式 :
1 | frida -U 进程名 -l hook.js |
frida_server自定义端口
1 | taimen:/ $ su |
logcat |grep "D.zj2595"
日志捕获,D
表示调试级别,后面的是日志标签adb connect 127.0.0.1:62001
模拟器端口转发
基础语法
API名称 | 描述 |
---|---|
Java.use(className) |
获取指定的Java类并使其在JavaScript代码中可用。 |
Java.perform(callback) |
确保回调函数在Java的主线程上执行。 |
Java.choose(className, callbacks) |
枚举指定类的所有实例。 |
Java.cast(obj, cls) |
将一个Java对象转换成另一个Java类的实例。 |
Java.enumerateLoadedClasses(callbacks) |
枚举进程中已经加载的所有Java类。 |
Java.enumerateClassLoaders(callbacks) |
枚举进程中存在的所有Java类加载器。 |
Java.enumerateMethods(targetClassMethod) |
枚举指定类的所有方法。 |
日志输出语法区别
日志方法 | 描述 | 区别 |
---|---|---|
console.log() |
使用JavaScript直接进行日志打印 | 多用于在CLI模式中,console.log() 直接输出到命令行界面,使用户可以实时查看。在RPC模式中,console.log() 同样输出在命令行,但可能被Python脚本的输出内容掩盖。 |
send() |
Frida的专有方法,用于发送数据或日志到外部Python脚本 | 多用于RPC模式中,它允许JavaScript脚本发送数据到Python脚本,Python脚本可以进一步处理或记录这些数据。 |
frida 常用 API
官方文档:JavaScript API | Frida • A world-class dynamic instrumentation toolkit
HOOK 框架
1 | function main() { |
Hook 普通方法、打印参数和修改返回值
这是一个普通方法,我们怎么来 hook 呢,先给出hook模板
1 | //定义一个名为hookTest1的函数 |
这里的类名就是 com.zj.wuaipojie.Demo
,方法名是 a
,参数只有一个 str
,我们这里只 hook 不修改
1 | function hookTest1() { |
然后运行frida-server-arm64,启动目标程序,再用如下命令用 attach 模式启动 frida 开始 hook
1 | frida -U wuaipojie -l hook.js |
打开第六关,就能看到我们输出的内容了
如果要修改参数,直接对 str 进行修改然后再进行方法调用,在安卓端重新进入该页面就会自动进行 hook,非常方便
再来看下面这个例子
我们想知道 jni.getkey
和 jni.getiv
可以看到有个 Arrays.copyOf(str2, 8)
,这里的 str2
就是我们需要的 key,我们对这个方法进行 hook
1 | setImmediate(function() { |
对于 iv
,我们可以直接对 IvParemeterSpec
对象进行 hook,注意到这一句 IvParameterSpec iv = new IvParameterSpec(ivBytes)
我们对这个方法的构造函数进行 hook,就能截取 ivBytes
参数
1 | setImmediate(function() { |
Hook 重载参数
主要涉及到一个 overload
,对于一些自定义参数可以用这个方法进行 hook,比如下面这个
我们查看 smail 代码找到自定义参数,发现是 com.zj.wuaipojie.Demo$Animal
和 java.lang.String
,当然你也可以直接不写,会有报错来进行提示
1 | // .overload() |
Hook 构造函数
这里我们对有参构造函数进行hook
其他跟前面都一样,构造函数我们把方法名改成 $init
就可以了,对于无参构造函数,也要加上 .overload()
1 | function hookTest3() { |
Hook 字段
分为静态和非静态,先介绍静态字段(就是被 static 修饰的字段),比如下面这个
1 | function hookTest4() { |
保存以后,frida 的日志就会输出静态变量被修改,然后我们查看日志 logcat | grep "D.zj2595"
来查看日志
然后是非静态字段,比如这个 privateInt
,需要用到 choose
方法来枚举所有实例
1 | function hookTest5() { |
Hook内部类
就是在获取类名的时候用 $
符号连接内部类名称,剩下的都一样
1 | function hookTest6() { |
枚举所有的类与类的所有方法
仅参考,不是很完善,一般不这么用,输出一坨东西
1 | function hookTest7() { |
枚举所有方法
同上,注意这里输出的是按顺序的方法执行流
1 | function hookTest8() { |
主动调用
静态方法:
1 | function hookTest9() { |
这里对一个 encode 函数进行主动调用
1 | function hookTest9() { |
非静态方法:
1 | function hookTest10() { |
Objection
环境配置
objection - 基于frida的命令行hook工具食用手册
sensepost/objection: 📱 objection - runtime mobile exploration (项目地址)
objection是基于frida的命令行hook工具,可以让你不写代码,敲几句命令就可以对java函数的高颗粒度hook,还支持RPC调用。目前只支持Java层的hook,但是objection有提供插件接口,可以自己写frida脚本去定义接口。
这个工具已经很久没更新了,因此要找到适配它的 python 版本和 frida 版本
我们新开一个 frida14 的虚拟环境,然后用如下命令行安装即可(好吧,frida14的server我的手机跑不了,但是后面试了以下 frida16 也能用)
1 | python -m venv frida14 |
简单使用
1 | 1. 空格键: 忘记命令直接输入空格键, 会有提示与补全 |
1 | objection --help(help命令) |
注入命令:
1 | objection -g 包名 explore |
启动前就hook
1 | objection -g 进程名 explore --startup-command "android hooking watch class 路径.类名" |
基础 api
memory list modules
- 查看内存中加载的库(不常用)memory list exports so名称
- 查看库的导出函数(不常用)android hooking list activities
- 查看内存中加载的activity /android hooking list services -查看内存中加载的services我们把
fs64
跑起来,然后进入虚拟环境,运行注入命令以 spawn 模式启动 app1
objection -g com.zj.wuaipojie explore
然后运行命令
android intent launch_activity 类名
- 启动activity
或service
(可以用于一些没有验证的activity,在一些简单的ctf中有时候可以出奇效,比如需要绕过某个验证才能打开一个新的窗口,那么我们就直接通过这个方法直接对这个窗口进行启动,直接进行绕过)比如这里我们绕过第三关的广告弹窗,直接打开第三关的界面,找到对应的类名即可
android sslpinning disable
- 关闭ssl校验,和抓包相关android root disable
- 关闭root检测
内存漫游
android heap search instances 类名
- 内存搜刮类实例我们打开之前的第六关界面,然后运行,就会获得这个类的一个 Hashcode
android heap execute Hashcode + method
- 调用实例的方法我们要调用 Demo 类中的一个方法,就需要通过之前获得的 Hashcode + 方法名来实现
如果是一个无参的方法,运行以后就会得到返回值,比如方法
getPublicInt
如果是有参数的方法,可以用
android heap evaluate Hashcode
进入 javascript 编辑界面,然后我们写console.log(clazz.方法名(参数))
,再根据提示按ESC + ENTER
确认android hooking list classes
- 列出内存中所有的类(结果比静态分析的更准确,但是能列出一万个,不好用)android hooking search classes 关键词
- 在内存中所有已加载的类中搜索包含特定关键词的类android hooking search methods 关键词
- 在内存中所有已加载的类的方法中搜索包含特定关键词的方法(一般不建议使用,特别耗时,还可能崩溃)android hooking list class_methods 类名
- 内存漫游类中的所有方法
HOOK 方法
hook类的所有方法
1
android hooking watch class 类名
hook方法的参数、返回值和调用栈
1
android hooking watch class_method 类名.方法名 --dump-args --dump-return --dump-backtrace
开始 hook 以后,我们点击以下第六关的界面,就会展示出整个堆栈、参数、返回值
hook 类的构造方法
1
android hooking watch class_method 类名.$init --dump-args --dump-return --dump-backtrace
hook 方法的所有重载
1
android hooking watch class_method 类名.方法名
r0tracer
r0ysue/r0tracer: 安卓Java层多功能追踪脚本 (项目地址)
主要是用 r0tracer.js
,看一下 main 函数,里面有很多项目自带的注释
1 | function main() { |
ke一看到,最新版本更新了精简模式,然后我们的 tracer 主要有三个模式,这里我们使用 B 模式,只把白名单改为我们想要 hook 的包名或者类名即可。
这里我们改成 hook(com.zj.wuaipojie2023_1","$");
这里我先用 frida-ps -U
来显示所有进程名
然后用如下命令启动 tracer,有 -f
为 spawn 模式,没有就是 attach 模式,--no-pause -o saveLog5.txt
是导出日志
1 | frida -U 【2023春节】解题领红包之四 -l r0tracer.js |
我们点击验证按钮触发一下 tracer
在这里找到了我们输入的两个参数,返回值是 false,我们往上找找,发现程序生成这个返回值
应该就是 flag 了 flag{9hh2430kkk8kjk8918h59077i8k5jiig}
Native-Hook
Process
Process
对象代表当前被Hook的进程,能获取进程的信息,枚举模块,枚举范围等
API | 含义 |
---|---|
Process.id |
返回附加目标进程的 PID |
Process.isDebuggerAttached() |
检测当前是否对目标程序已经附加 |
Process.enumerateModules() |
枚举当前加载的模块,返回模块对象的数组 |
Process.enumerateThreads() |
枚举当前所有的线程,返回包含 id , state , context 等属性的对象数组 |
Module
Module
对象代表一个加载到进程的模块(例如,在 Windows 上的 DLL,或在 Linux/Android 上的 .so 文件),能查询模块的信息,如模块的基址、名称、导入/导出的函数等
API | 含义 |
---|---|
Module.load() |
加载指定so文件,返回一个Module对象 |
enumerateImports() |
枚举所有Import库函数,返回Module数组对象 |
enumerateExports() |
枚举所有Export库函数,返回Module数组对象 |
enumerateSymbols() |
枚举所有Symbol库函数,返回Module数组对象 |
Module.findExportByName(exportName)、Module.getExportByName(exportName) |
寻找指定so中export库中的函数地址 |
Module.findBaseAddress(name)、Module.getBaseAddress(name) |
返回so的基地址 |
Memory
Memory
是一个工具对象,提供直接读取和修改进程内存的功能,能够读取特定地址的值、写入数据、分配内存等
方法 | 功能 |
---|---|
Memory.copy() |
复制内存 |
Memory.scan() |
搜索内存中特定模式的数据 |
Memory.scanSync() |
同上,但返回多个匹配的数据 |
Memory.alloc() |
在目标进程的堆上申请指定大小的内存,返回一个NativePointer |
Memory.writeByteArray() |
将字节数组写入一个指定内存 |
Memory.readByteArray |
读取内存 |
静态注册方法
我们想要对这个 checkVip
进行 Hook,然后点进去能看到这是一个 native 方法,我们进 so 文件看一下
发现这是一个静态注册方法,直接 return 0
了
我们可以直接在 JAVA 层进行 hook,找到这个函数,右击复制成 frida 片段,导入 hook.js
1 | function hookTest1() { |
我们用 frida -U wuaipojie -l hook.js
启动 hook
当然也可以直接修改返回值为 True
1 | function hookTest1() { |
枚举导入导出表
- 导出表(Export Table):列出了库中可以被其他程序或库访问的所有公开函数和符号的名称。
- 导入表(Import Table):列出了库需要从其他库中调用的函数和符号的名称。
简而言之,导出表告诉其他程序:“这些是我提供的功能。”,而导入表则表示:“这些是我需要的功能。”。
当然这些东西可以直接在 ida 里面查出来
1 | function hookTest1() { |
基础 Native 层 hook 打印
布尔、整型、char型
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
26function hookTest2(){
Java.perform(function(){
//根据导出函数名打印地址
var helloAddr = Module.findExportByName("lib52pojie.so","Java_com_zj_wuaipojie_util_SecurityUtil_checkVip");
console.log(helloAddr);
if(helloAddr != null){
//Interceptor.attach是Frida里的一个拦截器
Interceptor.attach(helloAddr,{
//onEnter里可以打印和修改参数
onEnter: function(args){ //args传入参数
console.log(args[0]); //打印第一个参数的值
console.log(this.context.x1); // 打印寄存器内容
console.log(args[1].toInt32()); //toInt32()转十进制
console.log(args[2].readCString()); //读取字符串 char类型
console.log(hexdump(args[2])); //内存dump
},
//onLeave里可以打印和修改返回值
onLeave: function(retval){ //retval返回值
console.log(retval);
console.log("retval",retval.toInt32());
}
})
}
})
}我们找到显示钻石数量的方法
由于没有参数,我们直接修改返回值即可,这里把 99 修改成 99999
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21function hookTest2(){
Java.perform(function(){
//根据导出函数名打印地址
var helloAddr = Module.findExportByName("lib52pojie.so","Java_com_zj_wuaipojie_util_SecurityUtil_diamondNum");
console.log(helloAddr);
if(helloAddr != null){
//Interceptor.attach是Frida里的一个拦截器
Interceptor.attach(helloAddr,{
//onEnter里可以打印和修改参数
onEnter: function(args){
},
//onLeave里可以打印和修改返回值
onLeave: function(retval){ //retval返回值
console.log("修改前:", retval.toInt32());
retval.replace(99999);
console.log("修改后:", retval.toInt32());
}
})
}
})
}string 类型
我们找到这个 vip 等级的方法,可以看出其第三个参数是一个 jstring 类型
hook 模板:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25function hookTest2(){
Java.perform(function(){
//根据导出函数名打印地址
var helloAddr = Module.findExportByName("lib52pojie.so","Java_com_zj_wuaipojie_util_SecurityUtil_vipLevel");
if(helloAddr != null){
Interceptor.attach(helloAddr,{
//onEnter里可以打印和修改参数
onEnter: function(args){ //args传入参数
// 方法一
var jString = Java.cast(args[2], Java.use('java.lang.String'));
console.log("参数:", jString.toString());
// 方法二
var JNIEnv = Java.vm.getEnv();
var originalStrPtr = JNIEnv.getStringUtfChars(args[2], null).readCString();
console.log("参数:", originalStrPtr);
},
//onLeave里可以打印和修改返回值
onLeave: function(retval){ //retval返回值
var returnedJString = Java.cast(retval, Java.use('java.lang.String'));
console.log("返回值:", returnedJString.toString());
}
})
}
})
}基础 Native 层 Hook 修改
整型,布尔,char型
比较简单,对于参数,先转为指针
ptr
再赋值,返回值直接replace
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20function hookTest3(){
Java.perform(function(){
//根据导出函数名打印地址
var helloAddr = Module.findExportByName("lib52pojie.so","Java_com_zj_wuaipojie_util_SecurityUtil_checkVip");
console.log(helloAddr);
if(helloAddr != null){
Interceptor.attach(helloAddr,{
onEnter: function(args){ //args参数
args[0] = ptr(1000); //第一个参数修改为整数 1000,先转为指针再赋值
console.log(args[0]);
},
onLeave: function(retval){ //retval返回值
retval.replace(20000); //返回值修改
console.log("retval",retval.toInt32());
}
})
}
})
}string 类型
对于参数,要借助
JNIEnv
的一些方法,先获取原始的传入参数,再定义你要修改的参数,最后再赋值就可以对于返回值,也是一样,自定义以后再用
replace
进行修改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
28function hookTest2(){
Java.perform(function(){
//根据导出函数名打印地址
var helloAddr = Module.findExportByName("lib52pojie.so","Java_com_zj_wuaipojie_util_SecurityUtil_vipLevel");
if(helloAddr != null){
Interceptor.attach(helloAddr,{
//onEnter里可以打印和修改参数
onEnter: function(args){ //args传入参数
var JNIEnv = Java.vm.getEnv();
var originalStrPtr = JNIEnv.getStringUtfChars(args[2], null).readCString();
console.log("参数:", originalStrPtr);
var modifiedContent = "至尊";
var newJString = JNIEnv.newStringUtf(modifiedContent);
args[2] = newJString;
},
//onLeave里可以打印和修改返回值
onLeave: function(retval){ //retval返回值
var returnedJString = Java.cast(retval, Java.use('java.lang.String'));
console.log("返回值:", returnedJString.toString());
var JNIEnv = Java.vm.getEnv();
var modifiedContent = "无敌";
var newJString = JNIEnv.newStringUtf(modifiedContent);
retval.replace(newJString);
}
})
}
})
}我们给这个普通会员用参数修改加上
至尊
,用返回值修改加上无敌
试试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
28function hookTest2(){
Java.perform(function(){
//根据导出函数名打印地址
var helloAddr = Module.findExportByName("lib52pojie.so","Java_com_zj_wuaipojie_util_SecurityUtil_vipLevel");
if(helloAddr != null){
Interceptor.attach(helloAddr,{
//onEnter里可以打印和修改参数
onEnter: function(args){ //args传入参数
var JNIEnv = Java.vm.getEnv();
var originalStrPtr = JNIEnv.getStringUtfChars(args[2], null).readCString();
console.log("参数:", originalStrPtr);
var modifiedContent = "至尊" + originalStrPtr;
var newJString = JNIEnv.newStringUtf(modifiedContent);
args[2] = newJString;
},
//onLeave里可以打印和修改返回值
onLeave: function(retval){ //retval返回值
var JNIEnv = Java.vm.getEnv();
var returnedJString= JNIEnv.getStringUtfChars(retval, Java.use('java.lang.String'));
console.log("返回值:", returnedJString);
var modifiedContent = "无敌" + returnedJString;
var newJString = JNIEnv.newStringUtf(modifiedContent);
retval.replace(newJString);
}
})
}
})
}
SO 基址获取
SO 基址可以方便我们对未导出函数地址的计算
1 | var moduleAddr1 = Process.findModuleByName("lib52pojie.so").base; |
我们分别打印一下:
1 | function hookTest1() { |
Hook 未导出函数与函数地址计算
1 | function hookTest6(){ |
函数地址计算
安卓里一般32 位的 so 中都是
thumb
指令,64 位的 so 中都是arm
指令通过IDA里的opcode bytes来判断,arm 指令为 4 个字节,
thumb
指令多为 2 个字节,也有 4 个字节thumb 指令,函数地址计算方式: so 基址 + 函数在 so 中的偏移 + 1
arm 指令,函数地址计算方式: so 基址 + 函数在 so 中的偏移函数在 so 中的偏移可以直接在 ida 中查看
Hook_dlopen
我们先来回顾一下 dlopen
是个啥
函数名 | 描述 |
---|---|
android_dlopen_ext() 、dlopen() 、do_dlopen() |
这三个函数主要用于加载库文件。android_dlopen_ext 是系统的一个函数,用于在运行时动态加载共享库。与标准的 dlopen() 函数相比,android_dlopen_ext 提供了更多的参数选项和扩展功能,例如支持命名空间、符号版本等特性。 |
比如有的app在dlopen里面藏东西,其加载的时间非常早,而且只加载一次,可能稍不注意就给漏掉了,这时候我们要对 dlopen进行hook
源码如下:
1 | void* dlopen(const char* filename, int flag) { |
1 | function hook_dlopen() { |
我们对 dlopen
进行 hook
,一旦检测到目标 so 文件被加载,就执行 hookTest2
1 | function hookTest2(){ |
单机 验证
按钮的时候会触发 hook 逻辑
frida 写数据
一般写在app的私有目录里,不然会报错:failed to open file (Permission denied)(实际上就是权限不足)
1 | var file_path = "/data/user/0/com.zj.wuaipojie/test.txt"; |
比如对so层的viplevel函数,我们要把这个函数的返回值给hook了,然后写进一个test.txt文件里,保存在data/usr/0 + 包名
这个私有目录里
1 | function hookTest2(){ |
运行以后找到对应的路径,就能找到我们创建的 test.txt 了
Frida_inline Hook与读写汇编
什么是inlinehook?
Inline hook(内联钩子)是一种在程序运行时修改函数执行流程的技术。它通过修改函数的原始代码,将目标函数的执行路径重定向到自定义的代码段,从而实现对目标函数的拦截和修改。
简单来说就是可以对任意地址的指令进行hook读写操作
常见inline hook框架:
Android-Inline-Hook
whale
Dobby
substrate
好吧,其实就是ida里面的 edit breakpoint
或者 patch assemble
用 frida 进行实现的,比如之前动态调试so那一节分析过的check
函数,其返回值是 v7
,是一个 bool 型的值
这时候我们想无论如何都让这个函数返回 1 应该怎么办呢?我们可以patch掉ARM汇编以后再重新打包,但是这样很麻烦,可以直接用 inline_hook
来实现
观察最后的RET部分,我们先找找对应的寄存器,这里应该是找 W22 的值(W和X是寄存器的前缀,分别表示32位和64位,因此找到X22就是找到W22)
我们看到 MOV W0 W22
这一步(W0是返回值)的偏移是 0x10420
,我们可以用 so_base_addr + offset
找到这条指令,然后对X22
寄存器的值进行修改
1 | function inline_hook() { |
此时我们再次点击验证,返回值已经被修改成了 1
我们也可以将这个地址对应的二进制给解析成汇编
1 | var soAddr = Module.findBaseAddress("lib52pojie.so"); |
当然也可以直接进行 patch assemble
,比如可以直接把这个 MOV W0, W22
改成 MOV W0, 1
,可以通过下面这个网站来获取arm64的字节码
转化以后得到其指令转化为 hex 为0x20008052
,我们进行patchCode,再次运行程序,也能直接绕过判断
1 | function patch_code() { |
普通函数和 JNI 函数主动调用
假如我们要对native的某个函数进行主动调用,比如下面这个函数
看一眼官方文档,frida提供了以下声明类型
数据类型 | 描述 |
---|---|
void | 无返回值 |
pointer | 指针 |
int | 整数 |
long | 长整数 |
char | 字符 |
float | 浮点数 |
double | 双精度浮点数 |
bool | 布尔值 |
显然这个函数的返回值,参数1,参数2都对应的是一个 pointer 类型数据,根据这个来进行主动调用,函数地址偏移直接用ida就能找到0xE85C
1 | var funcAddr = Module.findBaseAddress("lib52pojie.so").add(0xE85C); |
得到返回值
frida-trace
frida-trace | Frida • A world-class dynamic instrumentation toolkit
frida-trace · 逆向调试利器:Frida (crifan.github.io)
HOOK Libart
libart.so
: 在 Android 5.0(Lollipop)及更高版本中,libart.so
是 Android 运行时(ART,Android Runtime)的核心组件,它取代了之前的 Dalvik 虚拟机。可以在 libart.so
里找到 JNI 相关的实现。
PS:在高于安卓10的系统里,so的路径是/apex/com.android.runtime/lib64/libart.so,低于10的则在system/lib64/libart.so
一些函数,比较关键的是前三个函数,在 so 层逆向里面也经常见到
函数名称 | 参数 | 描述 | 返回值 |
---|---|---|---|
RegisterNatives |
JNIEnv *env, jclass clazz, const JNINativeMethod *methods, jint nMethods |
反注册类的本地方法。类将返回到链接或注册了本地方法函数前的状态。该函数不应在本地代码中使用。相反,它可以为某些程序提供一种重新加载和重新链接本地库的途径。 | 成功时返回0;失败时返回负数 |
GetStringUTFChars |
JNIEnv*env, jstring string, jboolean *isCopy |
通过JNIEnv接口指针调用,它将一个代表着Java虚拟机中的字符串jstring引用,转换成为一个UTF-8形式的C字符串 | - |
NewStringUTF |
JNIEnv *env, const char *bytes |
以字节为单位返回字符串的 UTF-8 长度 | 返回字符串的长度 |
FindClass |
JNIEnv *env, const char *name |
通过对象获取这个类。该函数比较简单,唯一注意的是对象不能为NULL,否则获取的class肯定返回也为NULL。 | - |
GetMethodID |
JNIEnv *env, jclass clazz, const char *name, const char *sig |
返回类或接口实例(非静态)方法的方法 ID。方法可在某个 clazz 的超类中定义,也可从 clazz 继承。GetMethodID() 可使未初始化的类初始化。 | 方法ID,如果找不到指定的方法,则为NULL |
GetStaticMethodID |
JNIEnv *env, jclass clazz, const char *name, const char *sig |
获取类对象的静态方法ID | 属性ID对象。如果操作失败,则返回NULL |
GetFieldID |
JNIEnv *env, jclass clazz, const char *name, const char *sig |
回Java类(非静态)域的属性ID。该域由其名称及签名指定。访问器函数的Get |
- |
GetStaticFieldID |
JNIEnv *env,jclass clazz, const char *name, const char *sig |
获取类的静态域ID方法 | - |
Call<type>Method , Call<type>MethodA , Call<type>MethodV |
JNIEnv *env, jobject obj, jmethodID methodID, .../jvalue *args/va_list args |
这三个操作的方法用于从本地方法调用Java 实例方法。它们的差别仅在于向其所调用的方法传递参数时所用的机制。 | NativeType,具体的返回值取决于调用的类型 |
JNI 动态注册流程
hook_art 脚本: https://github.com/lasting-yang/frida_hook_libart
hook RegisterNatives
1 | function find_RegisterNatives(params) { |
通过 IDA 分析我们可以在 JNI_OnLoad
中找到动态注册方法:
就是之前那个密界的UI,我们运行一下hook_RegisterNatives
脚本:
打印出了包名,方法名,签名信息,地址和偏移等等信息,我们再把 libart
的 SO 基址打印出来
这样就可以计算函数的偏移为 0x10484
也就是 check
函数的位置。
hook GetStringUTFChars
1 | function hook_GetStringUTFChars() { |
HOOK Libc
libc.so
: 这是一个标准的 C 语言库,用于提供基本的系统调用和功能,如文件操作、字符串处理、内存分配等。在Android系统中,libc
是最基础的库之一。
类别 | 函数名称 | 参数 | 描述 |
---|---|---|---|
字符串类操作 | strcpy | char *dest, const char *src |
将字符串 src 复制到 dest |
strcat | char *dest, const char *src |
将字符串 src 连接到 dest 的末尾 | |
strlen | const char *str |
返回 str 的长度 | |
strcmp | const char *str1, const char *str2 |
比较两个字符串 | |
文件类操作 | fopen | const char *filename, const char *mode |
打开文件 |
fread | void *ptr, size_t size, size_t count, FILE *stream |
从文件读取数据 | |
fwrite | const void *ptr, size_t size, size_t count, FILE *stream |
写入数据到文件 | |
fclose | FILE *stream |
关闭文件 | |
网络IO类操作 | socket | int domain, int type, int protocol |
创建网络套接字 |
connect | int sockfd, const struct sockaddr *addr, socklen_t addrlen |
连接套接字 | |
recv | int sockfd, void *buf, size_t len, int flags |
从套接字接收数据 | |
send | int sockfd, const void *buf, size_t len, int flags |
通过套接字发送数据 | |
线程类操作 | pthread_create | pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void *), void *arg |
创建线程 |
进程控制操作 | kill | pid_t pid, int sig |
向指定进程发送信号 |
系统属性查询操作 | __system_property_get |
const char *name, char *value |
从Android系统属性服务中读取指定属性的值 |
uname | struct utsname *buf |
获取当前系统的名称、版本和其他相关信息 | |
sysconf | int name |
获取运行时系统的配置信息,如CPU数量、页大小 |
hook kill
相当于直接把kill函数给替换没了,只打印log信息不执行逻辑。
1 | function replaceKILL() { |
hook pthread_create
后面frida检测会用到
1 | function hook_pthread_create(){ |
hook strcmp
CTF比赛能用,可以知道enc是多少也可以知道加密以后的数据是多少
1 | function hook_strcmp() { |
HOOK Libdl
libdl.so
是一个处理动态链接和加载的标准库,它提供了dlopen
、dlclose
、dlsym
等函数,用于在运行时动态地加载和使用共享库
类别 | 函数名称 | 参数 | 描述 |
---|---|---|---|
动态链接库操作 | dlopen | const char *filename, int flag |
打开动态链接库文件 |
dlsym | void *handle, const char *symbol |
从动态链接库中获取符号地址 |
hook dlsym
获取jni静态注册方法地址,可以知道哪个静态方法被调用了,在哪个 SO 文件里面的,还能知道该静态方法的地址和偏移
1 | function hook_dlsym() { |
HOOK Linker
Linker是Android系统动态库so的加载器/链接器,通过android源码分析 init 和 init_array 是在 callConstructor 中被调用的
这个init_arrat常用于 frida 检测,反调试,加壳之类的,在 JNI_Onload函数之前执行
这个脚本可以快速定位 init, init_array
方法的地址,然后再对其进行 hook 操作
frida hook init_array自吐新解-Android安全-看雪-安全社区|安全招聘|kanxue.com
1 | function hook_call_constructors() { |
frida rpc
frida 提供了一种跨平台的 rpc(就是Remote Procedure Call 远程过程调用) 机制,通过 frida rpc 可以在主机和目标设备之间进行通信,并在目标设备上执行代码,简单理解就是可以不需要分析某些复杂加密,通过传入参数获取返回值,进而来实现python或易语言来调用的一系列操作,多用于爬虫。
包名附加进程
1 | import frida, sys |
spawn方式启动
1 | import frida, sys |
连接非标准端口
1 | import frida, sys |
1 | function get_url() { |
我们打开第九关的UI,发现都是电影图片,滚动的时候会异步刷新。
看一下反编译代码,有一个关键函数 getData
,发现其设置了一个网络监听,然后然会了一个 results
扔到了 updateUI
函数里面,看起来这个 results
就是显示在UI的信息了。
找到 updateUI
,其参数是一个 ImageEntity
类的 list
,点进去看一眼,发现有 cover
,就是 url
我们对 updateUI
进行 hook 即可,右击复制为 frida
片段
1 | function hook_updateUI(){ |
javascripts 中 let 和 var 的区别
let和var的区别:
- ES6引入let 和 const ,增加’’TDZ”特性,规定必须先声明后使用。
- let存在块作用域特性,变量只在块域中有效。
- let全局变量与window中的变量分离开。
javascript - JS中var和let有什么区别?(详解) - 个人文章 - SegmentFault 思否
经过总结,只用 let
就可以,let
基本可以完全代替 var
RPC 实现
相当于搭建了一个小型 app 后端,实现主动调用 setupScrollListener
函数并 hook ImageEntity
中的变量,把结果返回到 127.0.0.1:8000
上,我们再写脚本对该 ip 地址进行 download 就可以把爬取结果下载到本地,实现一个爬虫的功能。
依赖如下,python 版本为 3.8
1 | frida-tools,uvicorn,fastapi,requests |
1 | from fastapi import FastAPI |
【2024春节】解题领红包活动
初级题
关键函数如下
对其进行 hook,用 r
读取一个文件流然后找 flag{
开头的索引,并返回,直接主动调用即可
1 | setImmediate(function() { |
中级题
打开是一个手势解锁
看看源码,在 checkPassword
函数里面反射调用了 assets
资源文件里面的 classes.dex
中的 com.zj.wuaipojie2024_2.C
类的 isValidate
方法,去资源文件里找到改 dex 文件并反编译,同时还把 classes.dex
保存为 data/1.dex
找到 isValidate
方法
该方法又反射调用了 com.zj.wuaipojie2024_2.A
类的 d
方法,但是查看该方法感觉不对劲
查看一下 logcat
日志
发现 1.dex
的校验和出错了,需要修复 dex ,我们使用 NP 管理器进行修复,注意这里选仅修复头部
但是这里出现了报错,未解决,可能跟系统有关,尽管我开了核心破解但还是没法装
浅浅记录一下 DEX 文件结构
dex结构体
安卓源码中的dalvik/libdex/DexFile.h
这里可以找到dex文件的数据结构,解析后的几个结构体如下:
部分名称 | 描述 |
---|---|
dex_header | dex文件头,指定了dex文件的一些数据,记录了其他数据结构在dex文件中的物理偏移 |
string_ids | 字符串列表,全局共享使用的常量池 |
type_ids | 类型签名列表,组成的常量池 |
proto_ids | 方法声明列表,组成的常量池 |
field_ids | 字段列表,组成的常量池 |
method_ids | 方法列表,组成的常量池 |
class_defs | 类型结构体列表,组成的常量池 |
map_list | 记录了前面7个部分的偏移和大小 |
DexHeader
字段名 | 描述 | 备注 |
---|---|---|
magic[8] | 表示是一个有效的dex文件 | 值一般固定为dex.035 |
checksum | dex文件的校验和,用来判断文件是否已经损坏或者篡改 | 使用adler32算法 |
signature[kSHA1DigestLen] | SHA-1哈希值,用来识别未经dexopt优化的dex文件 | kSHA1DigestLen 为SHA-1哈希长度 |
fileSize | 整个文件的长度,包括dexHeader在内 | |
headerSize | dexHeader占用的字节数 | 一般都是0x70 |
endianTag | 指定dex运行环境的CPU字节序 | 默认小端字节序0x12345678 |
linkSize | 链接段的大小 | |
linkOff | 链接段的偏移 | |
mapOff | DexMapList结构的文件偏移 | |
stringIdsSize | 字符串列表的大小 | |
stringIdsOff | 字符串列表的文件偏移 | |
typeIdsSize | 类型签名列表的大小 | |
typeIdsOff | 类型签名列表的文件偏移 | |
protoIdsSize | 方法声明列表的大小 | |
protoIdsOff | 方法声明列表的文件偏移 | |
fieldIdsSize | 字段列表的大小 | |
fieldIdsOff | 字段列表的文件偏移 | |
methodIdsSize | 方法列表的大小 | |
methodIdsOff | 方法列表的文件偏移 | |
classDefsSize | 类型结构体列表的大小 | |
classDefsOff | 类型结构体列表的文件偏移 | |
dataSize | 数据段的大小 | |
dataOff | 数据段的文件偏移 |
DexClassDataHeader
字段名 | 描述 |
---|---|
staticFieldsSize | 静态字段的个数 |
instanceFieldsSize | 实例字段的个数 |
directMethodsSize | 直接方法的个数 |
virtualMethodsSize | 虚方法的个数 |
DexField
字段名 | 描述 |
---|---|
fieldIdx | 指向DexFieldId的索引 |
accessFlags | 访问标志 |
DexMethod
字段名 | 描述 |
---|---|
methodIdx | 指向DexMethodId的索引 |
accessFlags | 访问标志 |
codeOff | 指向DexCode结构的偏移 |
DexClassData
字段名 | 描述 |
---|---|
header | 指定字段和方法的个数 |
staticFields | 静态字段 |
instanceFields | 实例字段 |
directMethods | 直接方法 |
virtualMethods | 虚方法 |
frida 检测
检测文件名、端口名、双进程保护、失效的检测点
检测
data/local/tmp
目录下是否有 server 文件,绕过方法:改个名即可检测默认端口 27042,绕过方法:指定端口
1
2
3./fs64 -l 0.0.0.0:6666
adb forward tcp:6666 tcp:6666
frida -H 127.0.0.1:6666 wuaipojie -l hook.js双进程保护,学会看注入报错的日志,比如说当app主动附加自身进程时,这时候再注入就会提示
run frida as root
,绕过方法:以spawn的方式启动进程即可借助脚本定位检测frida的so,可以通过 hook_dlopen 停在哪里得知哪个 so 文件里面存在 frida 检测逻辑
1
2
3
4
5
6
7
8
9
10
11
12
13function hook_dlopen() {
Interceptor.attach(Module.findExportByName(null, "android_dlopen_ext"),
{
onEnter: function (args) {
var pathptr = args[0];
if (pathptr !== undefined && pathptr != null) {
var path = ptr(pathptr).readCString();
console.log("load " + path);
}
}
}
);
}还有一些检测点随着 frida 版本更新已失效,比如 检测D-Bus(D-Bus是一种进程间通信(IPC)和远程过程调用(RPC)机制,最初是为Linux开发的,目的是用一个统一的协议替代现有的和竞争的IPC解决方案),检测fd(
/proc/pid/fd 目录的作用在于提供了一种方便的方式来查看进程的文件描述符信息,这对于调试和监控进程非常有用。通过查看文件描述符信息,可以了解进程打开了哪些文件、网络连接等,帮助开发者和系统管理员进行问题排查和分析工作),检测文件(众所周知frida我们一般都会放在data/local/tmp目录下,旧版fridaserver端运行时都会释放到re.frida.server,所以这里在旧版也会被当做一个检测点,而新版已不再释放)
检测 map
我们启用 frida 之后,打开 app,会显示检测到 frida
执行以下脚本
1 | adb shell ps | findstr com.zj.wuaipojie |
/proc/self/maps
是一个特殊的文件,它包含了当前进程的内存映射信息。当你打开这个文件时,它会显示一个列表,其中包含了进程中每个内存区域的详细信息。这些信息通常包括:
- 起始地址(Start Address)
- 结束地址(End Address)
- 权限(如可读、可写、可执行)
- 共享/私有标志(Shared or Private)
- 关联的文件或设备(如果内存区域是文件映射的)
- 内存区域的偏移量
- 内存区域的类型(如匿名映射、文件映射、设备映射等)
当注入frida后,在maps文件中就会存在frida-agent-64.so
、frida-agent-32.so
等文件。
字段 | 描述 |
---|---|
u0_a274 | 用户ID和应用ID:在Android系统中,u0 代表系统用户(user 0),而a274 是该应用在用户0下的唯一标识符。 |
21448 | PID(进程ID):该进程在操作系统中的标识符。 |
17555 | PPID(父进程ID):该进程的父进程的PID。 |
17077848 | 虚拟内存:进程使用的虚拟内存大小,通常以字节为单位。 |
191532 | 共享内存:进程使用的共享内存大小,同样以字节为单位。 |
0 | CPU时间/线程数:这通常表示进程的CPU时间或者是线程数,具体含义取决于ps 命令的输出格式。 |
S | 状态:其中S 代表睡眠状态(Sleeping),即进程没有在执行,而是在等待某些事件或资源。 |
检测代码
1 | bool check_maps() { |
绕过方法 1 - anti 脚本 hook 返回值
1 | // 定义一个函数anti_maps,用于阻止特定字符串的搜索匹配,避免检测到敏感内容如"Frida"或"REJECT" |
绕过方法 2 重定向 maps
1 | // 定义一个函数,用于重定向并修改maps文件内容,以隐藏特定的库和路径信息 |
绕过方法 3
用eBPF来hook系统调用并修改参数实现目的,使用bpf_probe_write_user向用户态函数地址写内容直接修改参数
1 | char placeholder[] = "/data/data/com.zj.wuaipojie/maps"; |
检测 status(线程名)
1 | ls /proc/pid/task 列出线程id |
- 在
/proc/pid/task
目录下,可以通过查看不同的线程子目录,来获取进程中每个线程的运行时信息。这些信息包括线程的状态、线程的寄存器内容、线程占用的CPU时间、线程的堆栈信息等。通过这些信息,可以实时观察和监控进程中每个线程的运行状态,帮助进行调试、性能优化和问题排查等工作。 - 在某些app中就会去读取
/proc/stask/线程ID/status
文件,如果是运行frida产生的,则进行反调试。例如:gmain/gdbus/gum-js-loop/pool-frida
等
- gmain:Frida 使用 Glib 库,其中的主事件循环被称为 GMainLoop。在 Frida 中,gmain 表示 GMainLoop 的线程。
- gdbus:GDBus 是 Glib 提供的一个用于 D-Bus 通信的库。在 Frida 中,gdbus 表示 GDBus 相关的线程。
- gum-js-loop:Gum 是 Frida 的运行时引擎,用于执行注入的 JavaScript 代码。gum-js-loop 表示 Gum 引擎执行 JavaScript 代码的线程。
- pool-frida:Frida 中的某些功能可能会使用线程池来处理任务,pool-frida 表示 Frida 中的线程池。
- linjector 是一种用于 Android 设备的开源工具,它允许用户在运行时向 Android 应用程序注入动态链接库(DLL)文件。通过注入 DLL 文件,用户可以修改应用程序的行为、调试应用程序、监视函数调用等,这在逆向工程、安全研究和动态分析中是非常有用的。
PS:由于frida可以随时附加到进程,所以写的检测必须覆盖APP的全周期,或者至少是敏感函数执行前
检测方法:
1 | bool check_status() { |
绕过脚本:
1 | function replace_str() { |
检测 inline hook
过Frida查看一个函数hook之前和之后的机器码,以此来判断是否被Frida的inlinehook注入。
将内存中的字节与本地字节逐一比较,若不一致则被修改
1 |
|
获取 hook 前字节码
1 | let bytes_count = 8 |
绕过脚本
1 |
|
魔改 frida-server 端
hzzheyang/strongR-frida-android: An anti detection version frida-server for android. (github.com)
Syscall & SVC & android 系统启动流程
用户空间和内核空间之间,有一个叫做Syscall(系统调用, system call)的中间层,是连接用户态和内核态的桥梁。这样即提高了内核的安全型,也便于移植,只需实现同一套接口即可。Linux系统,用户空间通过向内核空间发出Syscall,产生软中断,从而让程序陷入内核态,执行相应的操作。
SVC(软件中断指令)指令:在ARM架构的系统中,svc
是一条特殊的指令,它允许用户态的程序发起一个系统调用。当这条指令被执行时,CPU会从用户态切换到内核态,从而允许内核处理这个请求。
Linux操作系统是一个巨大的图书馆,而syscall
就是这个图书馆的前台服务窗口。当一个应用程序(比如一个读者)需要借阅书籍(获取系统资源或服务)时,它不能直接进入图书馆的内部书架去拿书,因为那样可能会造成混乱和损坏。所以,读者需要通过前台服务窗口,也就是syscall
,来请求它想要的书籍。
svc
就像是图书馆前台服务窗口的内部电话。当读者通过前台窗口提出请求时,前台工作人员会通过内部电话(svc
)来联系图书馆的内部工作人员,请求他们找到并提供所需的书籍。在Linux系统中,当一个程序通过syscall
请求服务时,实际上是通过svc
这条指令通知内核,然后由内核来处理这些请求。
一个安卓系统开机流程如下:
SVC
1 | bool anti_anti_maps() { |
这里没有自实现 strstr,所以可以通过 hook strstr 来进行绕过,但是如果要重定向 maps 就不行了,我们需要对 SVC 进行 hook,思路为先判断架构时 arm 还是 arm64,然后根据不同架构找到对应的 SVC 指令的 opcode,并找到对应的系统调用号,接下来在 data\app
路径下找所有的 so 文件,并找到 so 库中的 SVC opcode,后面的立即数就是系统调用号,和目标系统调用号进行比对,如果一样就说明目标函数被调用,看起来还是相当复杂的。
1 | function anti_svc(){ |
自定义 strstr
其实这个绕过更简单一些,因为是自实现的只要找到这个函数直接 hook 了就可以。
1 | bool anti_str_maps() { |
frida 持久化
分为非root方案,root方案,源码定制方案,这里略写了。
非root方案主要为:
- Frida的Gadget,用于免root注入hook脚本,Gadget | Frida • A world-class dynamic instrumentation toolkit
- 基于obejction的patchapk功能,Patching Android Applications · sensepost/objection Wiki (github.com)
root 方案:
- 可以patch /data/app/pkgname/lib/arm64(or arm)目录下的so文件,apk安装后会将so文件解压到该目录并在运行时加载,修改该目录下的文件不会触发签名校验。游戏安全实验室 游戏漏洞 外挂分析 (qq.com)
- 基于magisk模块方案注入frida-gadget,实现加载和hook。hanbinglengyue/FridaManager: Frida持久化解决方案 (github.com)
- 基于jshook封装好的fridainject框架实现hook。https://github.com/Xposed-Modules-Repo/me.jsonet.jshook
源码定制方案:
原理:修改aosp源代码,在fork子进程的时候注入frida-gadget
AOSP Android 10内置FridaGadget实践01 - 『移动安全区』 - 吾爱破解 - LCG - LSG |安卓破解|病毒分析|www.52pojie.cn
抓包
前置知识
详见:计算机网络,现代密码学等,此处略过
常见工具
应用图标 | 工具名称 | 类型 | 简介 |
---|---|---|---|
![]() |
Charles | 代理抓包工具 | Charles 是一个HTTP代理/HTTP监视器/反向代理,它允许开发人员查看所有的HTTP和SSL/HTTPS流量。 |
![]() |
Fiddler | 代理抓包工具 | Fiddler 是一个Web调试代理,能够记录和检查从任何浏览器和客户端到服务器的所有HTTP流量。 |
![]() |
Burp Suite | 代理抓包工具(理论上应该叫渗透必备工具) | Burp Suite 是用于攻击web 应用程序的集成平台,包含了许多工具。Burp Suite为这些工具设计了许多接口,以加快攻击应用程序的过程。所有工具都共享一个请求,并能处理对应的HTTP 消息、持久性、认证、代理、日志、警报。 |
![]() |
Reqable | 代理抓包工具 | Reqable = Fiddler + Charles + Postman Reqable拥有极简的设计、丰富的功能、高效的性能和桌面手机双端平台。 |
![]() |
ProxyPin | VPN抓包工具 | 开源免费抓包工具,支持Windows、Mac、Android、IOS、Linux 全平台系统 可以使用它来拦截、检查和重写HTTP(S)流量,ProxyPin基于Flutter开发,UI美观易用。 |
![]() |
WireShark | 网卡抓包工具 | Wireshark是非常流行的网络封包分析软件,可以截取各种网络数据包,并显示数据包详细信息。常用于开发测试过程各种问题定位。 |
![]() |
r0Capture | Hook抓包工具 | 安卓应用层抓包通杀脚本 |
![]() |
tcpdump | 内核抓包工具 | cpdump 是一个强大的命令行网络数据包分析工具,允许用户截获并分析网络上传输的数据包,支持多种协议,包括但不限于TCP、UDP、ICMP等。tcpdump基于libpcap库,该库提供了从网络接口直接访问原始数据包的能力。 |
![]() |
eCapture(旁观者) | 内核抓包工具 | 基于eBPF技术实现TLS加密的明文捕获,无需CA证书。 |
![]() |
ptcpdump | 内核抓包工具 | 基于 eBPF 的 tcpdump |
Reqable 抓包工具
Reqable使用经典的中间人(MITM)技术分析HTTPS流量,当客户端与Reqable的代理服务器(下文简称中间人
)进行通信时,中间人需要重签远程服务器的SSL证书。为了保证客户端与中间人成功进行SSL握手通信,需要将中间人的根证书(下文简称CA根证书
)安装到客户端本地的证书管理中心。
此外,Reqable 无需配置Wifi代理,便可以将手机流量自动转发到桌面端进行分析和数据处理,解决移动端API调试的难题,提高终端研发效率。
我们可以在手机上下载 Reqable 软件,连接电脑以后,电脑端就会显示手机端的流量信息,注意检查这里的代理地址要在同一个网络环境下。
连接成功以后,点击右上角的三个点就可以选择对目标 app 进行过滤
然后开启监听即可。
Charles
免费版有使用期限,分享一个在线的激活码生成网站
原理
如上图所示:
- 客户端发送请求,客户端可以是安卓手机/ios手机/PC机上的浏览器等
- Charles 接收请求,再发送给服务器,这个步骤可以篡改请求内容, 比如请求体的内容,URL GET 方法
?
之后拼接的参数,Header 中的token,cookie 等 - 服务端把响应结果返回给Charles
- Charles 把响应结果再转发给客户端,这个步骤可以篡改响应内容
功能
- 支持HTTP 和 HTTPS 代理
- 支持流量控制,可以用来模拟弱网环境,设置2G、3G、4G等场景的网络环境
- 支持断点调试
- 支持MOCK
- 支持接口请求并发
配置
- 电脑安装证书->安装到本地计算机->选择系统信任
- proxy->proxy_setting->端口号
- SSL proxy settings->Enable SSL->端口
*:443
- help->local IP address->找到wifi的ip地址->手机wifi(手机电脑连一个wifi)设置手动代理 ip+port
- 手机安装证书,先计算hash再用MoveCertificates(一个magisk模块)安装到系统目录(折腾不来system分区解锁,直接用magisk面具吧)
请求体解析
HTTP请求报文分为3部分:第一部分叫起始行(Request line),第二部分叫首部(Request Header),第三部分叫主体(Body)。
HTTP 方法:
方法 | 描述 |
---|---|
GET | 请求指定的页面信息并返回实体主体 |
HEAD | 类似于GET请求,只不过返回的响应中没有具体的内容,用于获取报头 |
POST | 向指定资源提交数据进行处理请求(例如提交表单或者上传文件),数据被包含在请求体中。POST请求可能会导致新的资源的建立和/或对已有资源的修改 |
PUT | 从客户端向服务器传送的数据取代指定文档的内容 |
DELETE | 请求服务器删除指定的页面 |
HTTP 常见状态码:
名 称 | 释 义 |
---|---|
200 OK | 服务器成功处理了请求。 |
301 Moved Permanently | 请求的URL已被永久移动。Response应包含Location URL指向新位置。 |
302 Moved Temporarily | 请求的URL被暂时移动。Response应包含Location URL指向临时位置。 |
304 Not Modified | 客户端缓存的资源是最新的,无需重新发送,客户端应使用缓存。 |
404 Not Found | 请求的资源未在服务器上找到。 |
401 Unauthorized | 请求要求用户的身份认证。 |
500 Internal Server Error | 服务器遇到了意外的情况,无法完成请求。 |
请求头:
名称 | 描述 |
---|---|
Accept | 指定客户端能接收的媒体类型。 |
Accept-Charset | 指定客户端能接收的字符集。 |
Accept-Encoding | 指定客户端能解码的编码方式,如gzip或deflate。 |
Accept-Language | 指定客户端首选的语言。 |
Authorization | 包含用于访问资源的认证信息。 |
Cache-Control | 控制缓存行为,如no-cache或max-age。 |
Connection | 控制HTTP连接是否保持活动状态,如keep-alive或close。 |
Content-Length | 指明请求体的长度。 |
Content-Type | 指明请求体的数据类型,如application/json。 |
Cookie | 包含客户端的cookie信息。 |
Date | 请求生成的时间。 |
Expect | 指定客户端期望服务器执行的操作。 |
From | 发送请求的用户邮箱地址。 |
Host | 请求的目标服务器的域名和端口号。 |
If-Modified-Since | 用于条件性GET,如果资源自指定日期后未被修改则返回304。 |
If-None-Match | 用于条件性GET,如果资源的ETag与提供的不匹配则返回资源。 |
Origin | 指明请求来源的源站地址,常用于跨域资源共享(CORS)。 |
Pragma | 包含与特定代理有关的指令。 |
Referer | 指明请求前一个页面的URL,可用于跟踪引用页面。 |
TE | 表示客户端能处理的传输编码方式。 |
Trailer | 指明报文主体之后的尾部字段。 |
Transfer-Encoding | 指明报文主体的传输编码方式,如chunked。 |
Upgrade | 指示客户端希望升级到另一种协议。 |
User-Agent | 包含客户端软件的名称和版本信息。 |
Via | 记录请求经过的中间节点,用于追踪和诊断。 |
Warning | 包含非致命问题的警告信息。 |
代理检测
代理检测是用于检测设备是否设置了网络代理。这种检测的目的是识别出设备是否尝试通过代理服务器(如抓包工具)来转发网络流量,从而可能截获和分析App的网络通信。App会检查系统设置或网络配置,以确定是否有代理服务器被设置为转发流量。
例如,它可能会检查系统属性或调用特定的网络信息API来获取当前的网络代理状态:
1 | return System.getProperty("http.proxyHost") == null && System.getProperty("http.proxyPort") == null |
或者强制不走代理
1 | connection = (HttpURLConnection) url.openConnection(Proxy.NO_PROXY); |
System类在java.lang包中,直接对 getProperty
方法进行 hook 即可,或者使用透明代理。
透明代理(Transparent Proxy)是一种特殊的代理服务类型,它可以在客户端(如浏览器或应用程序)不知道的情况下拦截、转发和处理网络请求。与传统的代理服务不同,透明代理不需要客户端进行任何配置就能工作。
[Clash版]安卓上基于透明代理实现热点抓包
安卓上基于透明代理对特定APP抓包
我们打开目标软件挂上代理用charles抓包,可以在日志中看到检测代码已经发现了我们的代理IP和PORT
写个脚本 hook 一下:
1 | function hook_proxy_check() { |
VPN 检测
当客户端运行VPN虚拟隧道协议时,会在当前节点创建基于eth
之上的tun0
接口或ppp0
接口。这些接口是用于建立虚拟网络连接的特殊网络接口。
根据OSI七层模型,二者分别支持的协议:
VPN | OpvenVPN、IPsec、IKEv2、PPTP、L2TP、WireGuard等 |
---|---|
代理 | HTTP、HTTPS、SOCKS、FTP、RTSP等 |
VPN 协议大多是作用在 OSI 的第二层和第三层之间,由此可见VPN能抓到代理方式的所有的包。
检测代码,主要依靠networkInterfaces和connectivity。
1 | public final boolean Check_Vpn1() { |
我们用手机版的小黄鸟抓个包不走代理,这时候就会触发vpn检测,可以看到tun0接口被检测到。
写脚本 hook 一下
1 | function hook_vpn_check1() { |
SSL Pinning
SSL Pinning
也称为证书锁定,是Google官方推荐的检验方式,意思是将服务器提供的SSL/TLS证书内置到移动客户端,当客户端发起请求的时候,通过对比内置的证书与服务器的证书是否一致,来确认这个连接的合法性。
PS:这里还要提到一个概念:单向校验
,本质上二者没区别,SSL Pinning
可以理解为加强版的单向校验
具体步骤为:
- 客户端向服务端发送SSL协议版本号、加密算法种类、随机数等信息。
- 服务端给客户端返回SSL协议版本号、加密算法种类、随机数等信息,同时也返回服务器端的证书,即公钥证书
- 客户端使用服务端返回的信息验证服务器的合法性,包括:
- 证书是否过期
- 发型服务器证书的CA是否可靠
- 返回的公钥是否能正确解开返回证书中的数字签名
- 服务器证书上的域名是否和服务器的实际域名相匹配、验证通过后,将继续进行通信,否则,终止通信
- 客户端向服务端发送自己所能支持的对称加密方案,供服务器端进行选择
- 服务器端在客户端提供的加密方案中选择加密程度最高的加密方式。
- 服务器将选择好的加密方案通过明文方式返回给客户端
- 客户端接收服务端返回的加密方式后,使用该加密方式生成产生随机码,用作通信过程中对称加密的密钥,使用服务端返回的公钥进行加密,将加密后的随机码发送至服务器
- 服务器收到客户端返回的加密信息后,使用自己的私钥进行解密,获取对称加密密钥。在接下来的会话中,服务器和客户端将会使用该密码进行对称加密,保证通信过程中信息的安全
SSL Pinning
主流的三套方案:公钥校验
、证书校验
、Host校验
因为是客户端做的校验,所以可以在本地进行hook对抗,参考以下的两个项目:
JustTrustMe、sslunpining
指纹校验
在网站中我们可以看到网站的证书相关信息,其中就包含了指纹信息,对于指纹校验,以okhttp框架为例,检测源码如下:
1 |
|
1 | CertificatePinner certificatePinner0 = new okhttp3.CertificatePinner.Builder() |
写个脚本把 check
函数直接 return
即可。
1 | function hook_ssl_pinning_key() { |
证书校验
获取服务器返回的证书,将其公钥编码为 Base64 字符串;同时从本地资源加载预存的可信客户端证书,并将其公钥也编码为 Base64 字符串。然后,比较这两个公钥是否匹配,以此确认服务器的身份是否合法。最后,使用自定义的 SSLSocketFactory
发起 HTTPS 请求,确保通信过程中只信任预定义的服务器证书,从而有效抵御中间人攻击。
1 | // 定义一个函数用于检查SSL证书 |
本地证书一般存在 app\res\raw
目录下,抓个包看一下结果:
说明存在代理服务器,有的证书内容是只包含公钥(服务器的公钥),如.crt、.cer、.pem;有的证书既包含公钥又包含私钥(服务器的私钥),如.pfx、.p12;另外有些app的证书不走寻常路,不是上面所罗列到的格式,它有可能伪装成png等其他格式。
hook方法为实例化一个trustManager类,然后里面什么都不写,当上面两处调用到这个类时hook这两个地方,把自己定义的空trustManager类放进去。
1 | function anti_ssl_cert() { |
双向认证
- 客户端向服务端发送SSL协议版本号、加密算法种类、随机数等信息。
- 服务端给客户端返回SSL协议版本号、加密算法种类、随机数等信息,同时也返回服务器端的证书,即公钥证书
- 客户端使用服务端返回的信息验证服务器的合法性,包括:
- 证书是否过期
- 发型服务器证书的CA是否可靠
- 返回的公钥是否能正确解开返回证书中的数字签名
- 服务器证书上的域名是否和服务器的实际域名相匹配、验证通过后,将继续进行通信,否则,终止通信
- 服务端要求客户端发送客户端的证书,客户端会将自己的证书发送至服务端
- 验证客户端的证书,通过验证后,会获得客户端的公钥
- 客户端向服务端发送自己所能支持的对称加密方案,供服务器端进行选择
- 服务器端在客户端提供的加密方案中选择加密程度最高的加密方式
- 将加密方案通过使用之前获取到的公钥进行加密,返回给客户端
- 客户端收到服务端返回的加密方案密文后,使用自己的私钥进行解密,获取具体加密方式,而后,产生该加密方式的随机码,用作加密过程中的密钥,使用之前从服务端证书中获取到的公钥进行加密后,发送给服务端
- .服务端收到客户端发送的消息后,使用自己的私钥进行解密,获取对称加密的密钥,在接下来的会话中,服务器和客户端将会使用该密码进行对称加密,保证通信过程中信息的安全。
环境搭建:
用openssl生成服务端证书:
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# 生成CA私钥
openssl genrsa -out ca.key 2048
# 生成CA自签名证书
openssl req -x509 -new -nodes -key ca.key -sha256 -days 1024 -out ca.crt
# 生成 RSA 私钥
openssl genrsa -out server.key 2048
# 新建一个 server_cert.conf 文件,内容如下,ip自定
[req]
distinguished_name = req_distinguished_name
req_extensions = v3_req
prompt = no
[req_distinguished_name]
CN = 10.21.246.227
[v3_req]
subjectAltName = @alt_names
[alt_names]
IP.1 = 10.21.246.227
# 创建证书签名请求
openssl req -new -key server.key -out server.csr -config server_cert.conf
# 使用CA证书签发服务器证书
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt -days 365 -extfile server_cert.conf -extensions v3_req
openssl x509 -req -days 365 -in server.csr -signkey server.key -out server.cer生成客户端证书
1
2
3
4
5openssl genrsa -out client.key 2048
openssl req -new -out client.csr -key client.key
openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out client.crt -days 500 -sha256
# 生成客户端带密码的p12证书(这步很重要,双向认证的话,浏览器访问时候要导入该证书才行;可能某些Android系统版本请求的时候需要把它转成bks来请求双向认证)
openssl pkcs12 -export -out client.p12 -inkey client.key -in client.crt -certfile ca.crt重打包替换生成的server.cer(路径在res/raw),替换ssl_verify方法里的ip地址以及res/xml/network_config.xml的ip地址(通过ipconfig获取实际的ipv4地址。
在本地搭建Flask后端,添加客户端证书校验逻辑:
可以看到开启了双向认证之后,我们过掉前面的证书校验,虽然可以抓包了,但是只有请求没有响应
说明服务器不认可客户端的证书,不返回相应的信息。
过双向检测分两步,第一步就是过掉本地的客户端对于服务端的检测,这一步可以通过hook直接过掉,但是如何过掉服务端对于客户端的检测就比较困难了,我们需要把客户端中的证书dump出来,植入到抓包工具中,在抓包的时候带着正确的证书请求,这样才能返回响应信息。
获取客户端的证书信息和密码(我们在生成客户端证书的时候需要设置密码)也可以通过hook来解决,在Android系统中,获取证书需要用到keyStore类,具体代码如下:
1 | public final SSLSocketFactory getSocketFactory(Context context0) { |
而 keyStore.load
方法有两种重载,我们需要用到第二种重载即密钥库数据来源(输入流)和全局密码。
1 | function hook_KeyStore_load() { |
拷贝出来即可,密码是PASSWORD
,我们用charles导入该证书,找到 proxy -> SSL Proxying Settings -> Client Certificates
,首次使用要设置一个密码来保护我们的客户端证书,然后就可以输入刚在dump下来的密码 PASSWORD
导入 p12 证书了。
Unidbg 使用
Unidbg主要用来模拟执行Android平台上的Native代码,用于模拟执行、监控观察以及辅助算法分析,基于maven构建。
下载源码,使用IDEA打开源码,重点看下面这几个目录:
1 | ├── assets/ # 存放模拟过程中使用的资源文件 |
一个例子
test里面有个 kanxue.test2,我们来看一下这个例子
函数是上了混淆的
看一下用unidbg怎么做,题目只要求输入三个字母,可以直接对三个字母进行爆破:
1 | package com.kanxue.test2; |
Flutter 逆向
Flutter是Google构建在开源的Dart VM之上,使用Dart语言开发的移动应用开发框架,可以帮助开发者使用一套Dart代码就能快速在移动iOS 、Android上构建高质量的原生用户界面,同时还支持开发Web和桌面应用。
Flutter应用解包后可在 assets目录和lib目录找到其特征。
Flutter 抓包对抗
- Dart语言标准库的网路请求不走WiFi代理
- Dart SDK只信任
/system/etc/security/cacerts
中的系统证书
验证证书链的关键函数 session_verify_cert_chain
可以直接查看源码
通过ssl_client
来在so文件中定位该函数,查看其前十个字节,从内存中scan找到目标函数进行修改返回值即可。
1 | function hook_session_verify_cert_chain(address) { |
Flutter 逆向方法
快照
使用readelf -s
命令读取保存快照信息的libapp.so
将会输出下面的内容
1 | Symbol table '.dynsym' contains 6 entries: |
“快照”指的是 Flutter 应用在编译过程中生成的特定数据结构,用于加速应用的启动和运行。具体来说,快照包括四种类型:
_kDartVmSnapshotData
: 代表 isolate 之间共享的 Dart 堆 (heap) 的初始状态。有助于更快地启动 Dart isolate,但不包含任何 isolate 专属的信息。_kDartVmSnapshotInstructions
:包含 VM 中所有 Dart isolate 之间共享的通用例程的 AOT 指令。这种快照的体积通常非常小,并且大多会包含程序桩 (stub)。_kDartIsolateSnapshotData
:代表 Dart 堆的初始状态,并包含 isolate 专属的信息。_kDartIsolateSnapshotInstructions
:包含由 Dart isolate 执行的 AOT 代码。
其中_kDartIsolateSnapshotInstructions
是最为重要的,因为包含了所有要执行的AOT代码,即业务相关的代码。
逆向的方法主要分为动静态两种:
静态方法:worawit/blutter: Flutter Mobile Application Reverse Engineering Tool 。解析libapp.so,即写一个解析器,将libapp.so中的快照数据按照其既定格式进行解析,获取业务代码的类的各种信息,包括类的名称、其中方法的偏移等数据,从而辅助逆向工作。
动态方法:Impact-I/reFlutter: Flutter Reverse Engineering Framework 。编译修改过的
libflutter.so
并且重新打包到APK中,在启动APP的过程中,由修改过的引擎动态链接库将快照数据获取并且保存。
使用blutter进行静态逆向
安装blutter(全程需要挂代理):
安装py310或以上版本,vs studio 2022,gitbash
# 配置全局代理 git config --global http.proxy http://127.0.0.1:7890 git config --global https.proxy http://127.0.0.1:7890
git clone https://github.com/worawit/blutter --depth=1
进入blutter文件夹运行
python .\scripts\init_env_win.py
提取arm-v8a中的libapp.so和libflutter.so到指定目录
找到
x64 Native Tools Command Prompt for VS 2022
程序,切换到blutter工作目录运行
python blutter.py .\blutterWorkstation\input .\blutterWorkstation\output
output目录文件解释:
- asm:对dart语言的反编译结果,里面有很多dart源代码的对应偏移
- ida_script:so文件的符号表还原脚本
- blutter_frida.js:目标应用程序的 frida 脚本模板
- objs.txt:对象池中对象的完整(嵌套)转储,对象池里面的方法和相应的偏移量
- p.txt:对象池中的所有 Dart 对象
我们用ida打开libapp.so文件,再用ida_script里面的脚本运行,就能恢复符号表了。