环境配置

整一个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 校验

1750ce462d230e70bc90afd5e26cc983

然后去叉

1
2
adb shell "settings put global captive_portal_https_url https://captive.v2ex.co/generate_204"
adb shell "settings put global captive_portal_https_url https://www.google.cn/generate_204"

或者

1
2
adb shell "settings put global captive_portal_http_url http://connect.rom.miui.com/generate_204"
adb shell "settings put global captive_portal_https_url https://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 用法大全

安卓动调

  1. 修改apk文件为可调式,可以添加 xml 文件的 <application>标签里添加 android:isdebuggable,或者使用xAPPdebug软件,或者使用adb命令:

    1
    2
    3
    4
    adb shell
    su
    magisk resetprop ro.debuggable 1
    stop;start
  2. 打开开发者选项启动USB调试

  3. adb devices 查看设备

  4. 启动 activity

    1
    2
    3
    adb shell am start -D -n 包名/类名 如(com.ep.example/.ui.MainActivity)
    am start -n 表示启动一个activity
    am start -D 表示将应用设置为可调试模式
  5. 步骤4过后,手机端会显示正在启动debugg模式,说明应用启动成功,接下来我们启用 JEB 附加调试

    1
    2
    3
    4
    ctrl + 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 的签名主要作用有两个:

  1. 证明 APK 的所有者(APK是谁开发的)
  2. 允许 Android 市场和设备校验 APK 的正确性。

签名校验

不做任何修改,直接签名安装,应用闪退则说明大概率有签名校验,一般来说,普通的签名校验会导致软件的闪退,黑屏,卡启动页等。
当然,以上都算是比较好的,有一些比较狠的作者,则会直接rm -rf /,把基带都格掉的一键变砖。

特征:

1
2
3
4
5
kill/killProcess-----kill/KillProcess()可以杀死当前应用活动的进程,这一操作将会把所有该进程内的资源(包括线程全部清理掉).当然,由于ActivityManager时刻监听着进程,一旦发现进程被非正常Kill,它将会试图去重启这个进程。这就是为什么,有时候当我们试图这样去结束掉应用时,发现它又自动重新启动的原因.

system.exit-----杀死了整个进程,这时候活动所占的资源也会被释放。

finish----------仅仅针对Activity,当调用finish()时,只是将活动推向后台,并没有立即释放内存,活动的资源并没有被清理

还有一种三角校验:就是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
2
3
4
5
6
7
8
9
10
11
12
13
14
private boolean SignCheck() {
String trueSignMD5 = "d0add9987c7c84aeb7198c3ff26ca152";
String nowSignMD5 = "";
try {
// 得到签名的MD5
PackageInfo packageInfo = getPackageManager().getPackageInfo(getPackageName(),PackageManager.GET_SIGNATURES);
Signature[] signs = packageInfo.signatures;
String signBase64 = Base64Util.encodeToString(signs[0].toByteArray());
nowSignMD5 = MD5Utils.MD5(signBase64);
} catch (PackageManager.NameNotFoundException e) {
e.printStackTrace();
}
return trueSignMD5.equals(nowSignMD5);
}

通过算法助手的hook,我们可以获取我们重新签名后的应用签名,程序本身会有一个写死的待验证签名,我们把这个待验证签名修改了即可。

方法四

io 重定向:VA&SVC:ptrace+seccomp

[原创]SVC的TraceHook沙箱的实现&无痕Hook实现思路

手动实现PM代理

PackageManagerService(简称PMS),是Android系统核心服务之一,处理包管理相关的工作,常见的比如安装、卸载应用等。

fourbrother/HookPmsSignature: Android中Hook 应用签名方法

IO 重定向

就是在读A文件的时候指向B文件

virjarRatel/ratel-core

asLody/VirtualApp: Virtual Engine for Android

其他检测手段

还有一些其他检测手段,这里直接放上原文吧

校验的N次方-签名校验对抗、PM代{过}{滤}理、IO重定向

smail 变量赋值

1
2
3
4
5
6
const/4: 4bit
const/16:16bit
const:32bit
const/high16:32bit只存放高16bit
const-wide vx, lit32 表示将一个 32 位的常量存储到 vx 与 vx+1 两个寄存器中

时间戳转换:在线时间戳转换工具

多处patch可以用正则表达式搜索并修改,比如寄存器不同,可以用.*

Xposed 自定义 hook

android studio 配置

用 android studio 开发一个自定义 hook 函数。

  1. Android Studio创建新项目

  2. 将下载的xposedBridgeApi.jar包拖进libs文件夹

  3. 右击jar包,选择add as library

  4. 修改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>
  5. 修改 build.gradle,将此处修改为 compileOnly 默认的是 implementation, implementation 使用该方式依赖的库将会参与编译和打包compileOnly 只在编译时有效,不会参与打包

  6. 新建—>Folder—>Assets Folder,创建xposed_init(不要后缀名):只有一行代码,就是说明入口类

  7. 新建Hook类,实现IXposedHookLoadPackage接口,然后在handleLoadPackage函数内编写Hook逻辑

    1
    2
    3
    4
    5
    6
    7
    8
    import de.robv.android.xposed.IXposedHookLoadPackage; 
    import de.robv.android.xposed.callbacks.XC_LoadPackage;

    public class Hook implements IXposedHookLoadPackage {
    @Override
    public void handleLoadPackage(XC_LoadPackage.LoadPackageParam loadPackageParam) throws Throwable {
    }
    }

写好 hook 函数以后,点击运行,然后在手机上的LSPosed框架里找到我们写好的自定义模块,这里有一个比较奇怪的问题,就是如果我修改了这个hook模块,我必须在LSPosed里把原先的模块卸载掉,然后重新点击AS里的运行,重新进行安装,才能更新修改以后的hook模块。

出线找不到 activity 的情况需要把 debugConfigrations里的launch options改成nothing

hook 普通方法

修改返回值:

1
2
3
4
5
6
7
8
XposedHelpers.findAndHookMethod("类名", loadPackageParam.classLoader, "方法名", String.class, new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) throws Throwable {
super.afterHookedMethod(param);
param.setResult("自定义返回值");
}
});

修改参数:

1
2
3
4
5
6
7
XposedHelpers.findAndHookMethod("类名", loadPackageParam.classLoader, "方法名", String.class, new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
super.beforeHookedMethod(param);
String a = "自定义参数";
param.args[0] = a; }
});

Hook复杂&自定义参数

1
2
3
4
5
6
7
8
Class a = loadPackageParam.classLoader.loadClass("类名");
XposedBridge.hookAllMethods(a, "方法名", new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
super.beforeHookedMethod(param);

}
});

Hook替换函数

1
2
3
4
5
6
7
Class a = classLoader.loadClass("类名")
XposedBridge.hookAllMethods(a,"方法名",new XC_MethodReplacement() {
home.php?mod=space&uid=1892347
protected Object replaceHookedMethod(MethodHookParam methodHookParam) throws Throwable {
return "";
}
});

Hook加固通杀

1
2
3
4
5
6
7
8
XposedHelpers.findAndHookMethod(Application.class, "attach", Context.class, new XC_MethodHook() {  
@Override
protected void afterHookedMethod(MethodHookParam param) throws Throwable {
Context context = (Context) param.args[0];
ClassLoader classLoader = context.getClassLoader();
//hook逻辑在这里面写
}
});

Hook 变量

静态变量:

如果要hook一个String,就用 setStaticObjectField

1
2
final Class clazz = XposedHelpers.findClass("类名", classLoader);  
XposedHelpers.setStaticIntField(clazz, "变量名", 999);

实例变量:

先获取字节码,然后在构造函数以后进行hook

1
2
3
4
5
6
7
8
9
10
final Class clazz = XposedHelpers.findClass("类名", classLoader);  
XposedBridge.hookAllConstructors(clazz, new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) throws Throwable {
super.afterHookedMethod(param);
//param.thisObject获取当前所属的对象
Object ob = param.thisObject;
XposedHelpers.setIntField(ob,"变量名",9999);
}
});

Hook构造函数

无参构造函数

1
2
3
4
5
6
7
8
9
10
XposedHelpers.findAndHookConstructor("com.zj.wuaipojie.Demo", classLoader, new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
super.beforeHookedMethod(param);
}
@Override
protected void afterHookedMethod(MethodHookParam param) throws Throwable {
super.afterHookedMethod(param);
}
});

有参构造函数

1
2
3
4
5
6
7
8
9
10
XposedHelpers.findAndHookConstructor("com.zj.wuaipojie.Demo", classLoader, String.class, new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
super.beforeHookedMethod(param);
}
@Override
protected void afterHookedMethod(MethodHookParam param) throws Throwable {
super.afterHookedMethod(param);
}
});

Hook multiDex方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
XposedHelpers.findAndHookMethod(Application.class, "attach", Context.class, new XC_MethodHook() {  
@Override
protected void afterHookedMethod(MethodHookParam param) throws Throwable {
ClassLoader cl= ((Context)param.args[0]).getClassLoader();
Class<?> hookclass=null;
try {
hookclass=cl.loadClass("类名");
}catch (Exception e){
Log.e("zj2595","未找到类",e);
return;
}
XposedHelpers.findAndHookMethod(hookclass, "方法名", new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) throws Throwable {
}
});
}
});

主动调用

静态方法:

1
2
Class clazz = XposedHelpers.findClass("类名",lpparam.classLoader);
XposedHelpers.callStaticMethod(clazz,"方法名",参数(非必须));

实例方法:

1
2
Class clazz = XposedHelpers.findClass("类名",lpparam.classLoader);
XposedHelpers.callMethod(clazz.newInstance(),"方法名",参数(非必须));

Hook内部类

内部类:类里还有一个类class

1
2
3
4
5
6
7
XposedHelpers.findAndHookMethod("com.zj.wuaipojie.Demo$InnerClass", lpparam.classLoader, "innerFunc",String.class,  new XC_MethodHook() {  
@Override
protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
super.beforeHookedMethod(param);

}
});

反射大法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Class clazz = XposedHelpers.findClass("com.zj.wuaipojie.Demo", lpparam.classLoader);
XposedHelpers.findAndHookMethod("com.zj.wuaipojie.Demo$InnerClass", lpparam.classLoader, "innerFunc",String.class, new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
super.beforeHookedMethod(param);
//第一步找到类
//找到方法,如果是私有方法就要setAccessible设置访问权限
//invoke主动调用或者set修改值(变量)
Class democlass = Class.forName("com.zj.wuaipojie.Demo",false,lpparam.classLoader);
Method demomethod = democlass.getDeclaredMethod("refl");
demomethod.setAccessible(true);
demomethod.invoke(clazz.newInstance());
}
});

遍历所有类下的所有方法

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
XposedHelpers.findAndHookMethod(ClassLoader.class, "loadClass", String.class, new XC_MethodHook() {  
@Override
protected void afterHookedMethod(MethodHookParam param) throws Throwable {
super.afterHookedMethod(param);
Class clazz = (Class) param.getResult();
String clazzName = clazz.getName();
//排除非包名的类
if(clazzName.contains("com.zj.wuaipojie")){
Method[] mds = clazz.getDeclaredMethods();
for(int i =0;i<mds.length;i++){
final Method md = mds[i];
int mod = mds[i].getModifiers();
//去除抽象、native、接口方法
if(!Modifier.isAbstract(mod)
&& !Modifier.isNative(mod)
&&!Modifier.isAbstract(mod)){
XposedBridge.hookMethod(mds[i], new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
super.beforeHookedMethod(param);
Log.d("zj2595",md.toString());
}
});
}

}
}

}
});

Xposed妙用

字符串赋值定位:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
XposedHelpers.findAndHookMethod("android.widget.TextView", lpparam.classLoader, "setText", CharSequence.class, new XC_MethodHook() {  
@Override
protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
super.beforeHookedMethod(param);
Log.d("zj2595",param.args[0].toString());
if(param.args[0].equals("已过期")){
printStackTrace();
}
}
});
private static void printStackTrace() {
Throwable ex = new Throwable();
StackTraceElement[] stackElements = ex.getStackTrace();
for (int i = 0; i < stackElements.length; i++) {
StackTraceElement element = stackElements[i];
Log.d("zj2595","at " + element.getClassName() + "." + element.getMethodName() + "(" + element.getFileName() + ":" + element.getLineNumber() + ")");
}
}

点击事件监听:

1
2
3
4
5
6
7
8
9
10
11
Class clazz = XposedHelpers.findClass("android.view.View", lpparam.classLoader);
XposedBridge.hookAllMethods(clazz, "performClick", new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) throws Throwable {
super.afterHookedMethod(param);
Object listenerInfoObject = XposedHelpers.getObjectField(param.thisObject, "mListenerInfo");
Object mOnClickListenerObject = XposedHelpers.getObjectField(listenerInfoObject, "mOnClickListener");
String callbackType = mOnClickListenerObject.getClass().getName();
Log.d("zj2595",callbackType);
}
});

改写布局:

1
2
3
4
5
6
7
8
9
10
11
XposedHelpers.findAndHookMethod("com.zj.wuaipojie.ui.ChallengeSixth", lpparam.classLoader,  
"onCreate", Bundle.class, new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) throws Throwable {
super.afterHookedMethod(param);
View img = (View)XposedHelpers.callMethod(param.thisObject,
"findViewById", 0x7f0800de);
img.setVisibility(View.GONE);

}
});

SO 文件分析

SO 加载流程

img

函数名 描述
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//第一步,实现JNI_OnLoad方法
JNIEXPORT jint JNI_OnLoad(JavaVM* jvm, void* reserved){
//第二步,获取JNIEnv
JNIEnv* env = NULL;
if(jvm->GetEnv((void**)&env, JNI_VERSION_1_6) != JNI_OK){
return JNI_FALSE;
}
//第三步,获取注册方法所在Java类的引用
jclass clazz = env->FindClass("com/curz0n/MainActivity");
if (!clazz){
return JNI_FALSE;
}
//第四步,动态注册native方法
if(env->RegisterNatives(clazz, gMethods, sizeof(gMethods)/sizeof(gMethods[0]))){
return JNI_FALSE;
}
return JNI_VERSION_1_6;
}

其中第四步gMethods变量是JNINativeMethod结构体,用于映射Java方法与C/C++函数的关系,其定义如下:

1
2
3
4
5
typedef struct {
const char* name; //动态注册的Java方法名
const char* signature; //描述方法参数和返回值
void* fnPtr; //指向实现Java方法的C/C++函数指针
} JNINativeMethod;

下断点时机:
应用级别的:java_com_XXX;
外壳级别的:JNI_Onload,.init,.init_array(反调试);
系统级别的:fopen,fget,dvmdexfileopen(脱壳);

一个 native 方法在 SO 文件里面的参数,第一个为 JNIEnv *env,第二个是 jclass,然后就是对应的参数类型,比如 jstring

SO 文件动态调试

[原创]新手关于ida动态调试so的一些坑总结-Android安全-看雪-安全社区|安全招聘|kanxue.com

准备工作

  1. 在 IDA 目录下的 dbgsrv 找到对应的 android_server,一般是 android_server64

  2. 执行以下命令,可以用MT管理器修改 android_server64 的名字,有可能能够绕过调试检测

    adb push android_server64 /data/local/tmp
    adb shell
    su
    cd /data/local/tmp
    chmod 777 android_server64
    
  3. XappDebug Hook

启动调试

有两种模式,分别是debug模式和普通模式,二者的区别在于使用场景,有时候要动态调试的参数在app一启动的时候就产生了,时机较早,所以需要以debug模式去挂起app。

  1. debug 模式

    1
    2
    3
    4
    adb 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挂起)
  2. 普通模式

    1
    2
    adb shell am start -n com.zj.wuaipojie/.ui.ChallengeEight (去掉-D 则表示不以debug模式启动app)
    adb forward tcp:23946 tcp:23946 (端口转发)

此时我们已经打开了对应的手机应用界面

image-20240423203409685

然后再输入以下命令:

1
adb shell "su -c './data/local/tmp/as64'"

成功启动 android_server64 以后,显示在 23946 端口进行监听,我们进入 IDA ,下好断点,选择

image-20240423202646467

然后选择 debugger -> attach process,找到对应的进程即可

我们在打开断点窗口找到我们之前下好的断点,点进去

image-20240423203044653

这里有一个小技巧,可以用 HexRays-decompile 插件,把 pring only constant string literals 选项给去掉,就可以直接在 c 语言窗口显示常量字符串,而不是显示一个字符串的变量,点进去才能看见。

image-20240423203051247

运行后我们 F9 一下,此时程序处于接收输入状态,遇到SIGNAL直接静默就可以,我们回到手机界面,在输入栏随便输入一些东西,点击验证,然后就寄啦,程序直接退出。

原来是程序有反调试,由于之前的分析,JNI_OnLoad还有 init_array 先于我们注册的函数之前运行,有可能出现反调试,我们找到对应的函数查看,果然出现了 ptrace 反调试,nop 掉即可,记得 apply to applications。

image-20240423204841005

这里由于patch了so文件,我们需要和手机里的进行同步,把该 SO 文件传入手机目录,然后使用MT管理器进行同步

image-20240423205546626

然后我们将修改后的 apk 文件重新安装,重新进入调试,这时候我们发现在输入以后点击验证,稍等一会儿就可以运行到断点处(这张图是重新加载的ida,可以和之前对比一下,就会发现区别还是挺大的)

image-20240424001728502

我们在执行完加密函数后找到X0函数,去掉前面的B4偏移,就是结果 wuaipojie2023

可能遇到的问题

  1. 无法附加到目标VM

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    java.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。
    解决方法:有可能是手机问题,建议低版本真机,不要用模拟器!切命令顺序不要乱!另外也有可能软件有反调试!
  2. 动态调试中找不到so文件:可以尝试手动复制一份对应的so文件放到data/app/包名/lib目录下

  3. device offline :重新插拔usb,再不行就重启机子

  4. Address 被占用

    1
    2
    3
    4
    5.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要根据上一步获取的填写

常见反调试

  1. 调试端口检测:检测常见的23946端口,所以在运行时可以加 -p 指定一个另外的端口来过掉这个检测

  2. 调试进程名检测:固定的进程名 android_server gdb_server等等,所以要改个名字,例如as64

  3. ptrace检测:每个进程同时刻只能被1个调试进程ptrace ,主动ptrace本进程可以使得其他调试器无法调试

    1
    2
    3
    4
    int 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 • 官方网站

Frida • 项目地址

frida是一款基于python + java 的hook框架,可运行在android、ios、linux、win、osx等各平台,主要使用动态二进制插桩技术。

frida框架分为两部分:

  1. 一部分是运行在系统上的交互工具frida CLI。
  2. 另一部分是运行在目标机器上的代码注入工具 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
2
3
4
5
python -m venv venv
venv/Srcipts/activate
pip install frida
pip install frida-tools
# deactivate

frida - Server

Frida - Server 下载

运行以下命令查看 cpu 型号

1
adb shell getprop ro.product.cpu.abi

这里选择对应版本的 frida-server,比如我这里选择 arm64 ,然后 push 到 data/local/tmp 目录下,授权后运行

设置端口转发

1
2
adb forward tcp:27042 tcp:27042
adb forward tcp:27043 tcp:27043

frida 基础知识

基本命令

然后在虚拟环境中使用 frida [options] target来使用 frida

输入 frida-ps -U 会显示连接的 USB 设备的进程名,说明 frida 安装成功

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
frida-ps --help
使用方式: frida-ps [选项]

选项:
-h, --help 显示帮助信息并退出
-D ID, --device ID 连接到具有给定ID的设备
-U, --usb 连接到USB设备
-R, --remote 连接到远程frida-server
-H HOST, --host HOST 连接到HOST上的远程frida-server
--certificate CERTIFICATE
与HOST进行TLS通信,期望的CERTIFICATE
--origin ORIGIN 连接到设置了"Origin"头为ORIGIN的远程服务器
--token TOKEN 使用TOKEN验证HOST
--keepalive-interval INTERVAL
设置心跳包间隔(秒),或设置为0以禁用(默认为-1,根据传输方式自动选择)
--p2p 与目标建立点对点连接
--stun-server ADDRESS
设置与--p2p一起使用的STUN服务器地址
--relay address,username,password,turn-{udp,tcp,tls}
添加与--p2p一起使用的中继
-O FILE, --options-file FILE
包含额外命令行选项的文本文件
--version 显示程序版本号并退出
-a, --applications 只列出应用程序
-i, --installed 包括所有已安装的应用程序
-j, --json 以JSON格式输出结果

操作模式

操作模式 描述 优点 主要用途
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
2
3
taimen:/ $ su
taimen:/ # cd data/local/tmp/
taimen:/data/local/tmp # ./fs1280 -l 0.0.0.0:6666

logcat |grep "D.zj2595"日志捕获,D 表示调试级别,后面的是日志标签
adb connect 127.0.0.1:62001模拟器端口转发

image-20240514131206992

基础语法

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
2
3
4
5
6
function main() {
Java.perform(function() {
hookTest1();
});
}
setImmediate(main);

Hook 普通方法、打印参数和修改返回值

image-20240514202837488

这是一个普通方法,我们怎么来 hook 呢,先给出hook模板

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//定义一个名为hookTest1的函数
function hookTest1(){
//获取一个名为"类名"的Java类,并将其实例赋值给JavaScript变量utils
var utils = Java.use("类名");
//修改"类名"的"method"方法的实现。这个新的实现会接收两个参数(a和b)
utils.method.implementation = function(a, b){
//将参数a和b的值改为123和456。
a = 123;
b = 456;
//调用修改过的"method"方法,并将返回值存储在`retval`变量中
var retval = this.method(a, b);
//在控制台上打印参数a,b的值以及"method"方法的返回值
console.log(a, b, retval);
//返回"method"方法的返回值
return retval;
}
}

这里的类名就是 com.zj.wuaipojie.Demo,方法名是 a,参数只有一个 str,我们这里只 hook 不修改

1
2
3
4
5
6
7
8
function hookTest1() {
var utils = Java.use("com.zj.wuaipojie.Demo");
utils.a.implementation = function(str) {
var res = this.a(str);
console.log(str, res);
return res;
};
}

然后运行frida-server-arm64,启动目标程序,再用如下命令用 attach 模式启动 frida 开始 hook

1
frida -U wuaipojie -l hook.js

打开第六关,就能看到我们输出的内容了

image-20240514203856172

如果要修改参数,直接对 str 进行修改然后再进行方法调用,在安卓端重新进入该页面就会自动进行 hook,非常方便

再来看下面这个例子

image-20240518123214342

我们想知道 jni.getkeyjni.getiv

可以看到有个 Arrays.copyOf(str2, 8),这里的 str2 就是我们需要的 key,我们对这个方法进行 hook

1
2
3
4
5
6
7
8
9
10
11
setImmediate(function() {
Java.perform(function() {
var Arrays = Java.use("java.util.Arrays"); // 找到该方法
Arrays.copyOf.overload('[B', 'int').implementation = function(original, newLength) { // [B 表示 Byte []
var originalStr = Java.use("java.lang.String").$new(original, "UTF-8"); // 转成 string
console.log("Arrays.copyOf called with: " + originalStr + ", newLength: " + newLength);
return this.copyOf(original, newLength);
};
console.log("Hooking Arrays.copyOf(byte[], int)...");
});
});

对于 iv,我们可以直接对 IvParemeterSpec 对象进行 hook,注意到这一句 IvParameterSpec iv = new IvParameterSpec(ivBytes)

我们对这个方法的构造函数进行 hook,就能截取 ivBytes 参数

1
2
3
4
5
6
7
8
9
10
11
setImmediate(function() {
Java.perform(function() {
var IvParameterSpec = Java.use("javax.crypto.spec.IvParameterSpec");
IvParameterSpec.$init.overload('[B').implementation = function(iv) {
var ivStr = Java.use("java.lang.String").$new(iv, "UTF-8");
console.log("IvParameterSpec initialized with byte array: " + ivStr);
return this.$init(iv);
};
console.log("Hooking IvParameterSpec constructor with single byte array argument...");
});
});

Hook 重载参数

主要涉及到一个 overload,对于一些自定义参数可以用这个方法进行 hook,比如下面这个

image-20240514204916167

我们查看 smail 代码找到自定义参数,发现是 com.zj.wuaipojie.Demo$Animaljava.lang.String,当然你也可以直接不写,会有报错来进行提示

image-20240514205116758

1
2
3
4
5
6
7
8
9
10
11
// .overload()
// .overload('自定义参数')
// .overload('int')
function hookTest2() {
var utils = Java.use("com.zj.wuaipojie.Demo");
//overload定义重载函数,根据函数的参数类型填
utils.Inner.overload('com.zj.wuaipojie.Demo$Animal','java.lang.String').implementation = function(a, str){
this.Inner(a, str);
console.log(str);
}
}

image-20240514205518122

Hook 构造函数

这里我们对有参构造函数进行hook

image-20240514205749390

其他跟前面都一样,构造函数我们把方法名改成 $init 就可以了,对于无参构造函数,也要加上 .overload()

1
2
3
4
5
6
7
8
9
function hookTest3() {
var utils = Java.use("com.zj.wuaipojie.Demo");
//修改类的构造函数的实现,$init表示构造函数
utils.$init.overload('java.lang.String').implementation = function(str){
str = "epsilon";
console.log(str);
this.$init(str);
}
}

image-20240514205941920

Hook 字段

分为静态和非静态,先介绍静态字段(就是被 static 修饰的字段),比如下面这个

image-20240514210701444

1
2
3
4
5
6
7
8
9
function hookTest4() {
Java.perform(function() {
//静态字段的修改
var utils = Java.use("com.zj.wuaipojie.Demo");
//修改类的静态字段"staticField"的值
utils.staticField.value = "我是被修改的静态变量";
console.log(utils.staticField.value);
});
}

保存以后,frida 的日志就会输出静态变量被修改,然后我们查看日志 logcat | grep "D.zj2595" 来查看日志

image-20240514211206541

然后是非静态字段,比如这个 privateInt,需要用到 choose 方法来枚举所有实例

image-20240514225117924

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function hookTest5() {
Java.perform(function() {
//使用`Java.choose()`枚举类的所有实例
Java.choose("com.zj.wuaipojie.Demo", {
onMatch: function(obj){
//修改实例的非静态字段"_privateInt"的值为"123456",并修改非静态字段"privateInt"的值为9999。
// obj._privateInt.value = "123456"; //字段名与函数名相同 前面加个下划线
obj.privateInt.value = 9999;
console.log(obj.privateInt.value);
},
onComplete: function(){
}
});
})
}

image-20240514230629502

Hook内部类

就是在获取类名的时候用 $ 符号连接内部类名称,剩下的都一样

image-20240514230821216

1
2
3
4
5
6
7
8
9
10
11
function hookTest6() {
Java.perform(function() {
//内部类
var innerClass = Java.use("com.zj.wuaipojie.Demo$InnerClass");
console.log(innerClass);
innerClass.$init.overload().implementation = function() {
console.log('InnerClass 的构造函数被调用了');
// 但是在这里用 this.$init() 会引起 frida 崩溃,不知道为什么
}
});
}

image-20240514232550594

枚举所有的类与类的所有方法

仅参考,不是很完善,一般不这么用,输出一坨东西

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function hookTest7() {
Java.perform(function() {
//枚举所有的类与类的所有方法,异步枚举
Java.enumerateLoadedClasses({
onMatch: function(name, handle) {
//过滤类名
if(name.indexOf("com.zj.wuaipojie.Demo") != -1) {
console.log(name);
var clazz =Java.use(name);
console.log(clazz);
var methods = clazz.class.getDeclaredMethods();
console.log(methods);
}
},
onComplete: function(){}
})
})
}

image-20240514233127068

枚举所有方法

同上,注意这里输出的是按顺序的方法执行流

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function hookTest8() {
Java.perform(function() {
var Demo = Java.use("com.zj.wuaipojie.Demo");
//getDeclaredMethods枚举所有方法
var methods =Demo.class.getDeclaredMethods();
for(var j = 0; j < methods.length; j++) {
var methodName = methods[j].getName();
console.log(methodName);
for(var k = 0; k<Demo[methodName].overloads.length; k++) {
Demo[methodName].overloads[k].implementation = function() {
for(var i = 0; i < arguments.length; i++) {
console.log(arguments[i]);
}
return this[methodName].apply(this, arguments);
}
}
}
})
}

image-20240514233258678

主动调用

静态方法:

1
2
3
4
5
6
7
function hookTest9() {
Java.perform(function() {
var Demo = Java.use("类名");
var ret = demo.method("epsilon");
console.log(ret);
})
}

这里对一个 encode 函数进行主动调用

1
2
3
4
5
6
7
function hookTest9() {
Java.perform(function() {
var encode = Java.use("com.zj.wuaipojie.Encode");
var ret = encode.encode("epsilon");
console.log(ret);
})
}

image-20240514233931612

非静态方法:

image-20240514234554306

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function hookTest10() {
var ret = null;
Java.perform(function () {
Java.choose("com.zj.wuaipojie.Demo", { //要hook的类
onMatch:function(instance) {
ret = instance.privateFunc("epsilon"); //要hook的方法
},
onComplete:function() {
console.log("result: " + ret);
}
});
})
return ret;
}

image-20240514234540938

Objection

环境配置

objection - 基于frida的命令行hook工具食用手册

sensepost/objection: 📱 objection - runtime mobile exploration (项目地址)

objection是基于frida的命令行hook工具,可以让你不写代码,敲几句命令就可以对java函数的高颗粒度hook,还支持RPC调用。目前只支持Java层的hook,但是objection有提供插件接口,可以自己写frida脚本去定义接口。

这个工具已经很久没更新了,因此要找到适配它的 python 版本和 frida 版本

image-20240515171137884

我们新开一个 frida14 的虚拟环境,然后用如下命令行安装即可(好吧,frida14的server我的手机跑不了,但是后面试了以下 frida16 也能用)

1
2
3
4
5
python -m venv frida14
frida14/Scripts/activate
pip install objection==1.11.0
pip install frida-tools==9.2.4
pip install frida==14.2.18

简单使用

1
2
3
4
1. 空格键: 忘记命令直接输入空格键, 会有提示与补全
2. help: help [command] 会有详细介绍指定命令的作用与例子
3. jobs: 任务管理系统, 可以方便的查看与删除任务
4. 日志:objection的日志文件生成在 C:\Users\Administrator\.objection
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
objection --help(help命令)
Checking for a newer version of objection...
Usage: objection [OPTIONS] COMMAND [ARGS]...

_ _ _ _
___| |_|_|___ ___| |_|_|___ ___
| . | . | | -_| _| _| | . | |
|___|___| |___|___|_| |_|___|_|_|
|___|(object)inject(ion)

Runtime Mobile Exploration
by: @leonjza from @sensepost

默认情况下,通信将通过USB进行,除非提供了`--network`选项。

选项:
-N, --network 使用网络连接而不是USB连接。
-h, --host TEXT [默认: 127.0.0.1]
-p, --port INTEGER [默认: 27042]
-ah, --api-host TEXT [默认: 127.0.0.1]
-ap, --api-port INTEGER [默认: 8888]
-g, --gadget TEXT 要连接的Frida Gadget/进程的名称。 [默认: Gadget]
-S, --serial TEXT 要连接的设备序列号。
-d, --debug 启用带有详细输出的调试模式。(在堆栈跟踪中包括代{过}{滤}理源图)
--help 显示此消息并退出。

命令:
api 以无头模式启动objection API服务器。
device-type 获取关于已连接设备的信息。
explore 启动objection探索REPL。
patchapk 使用frida-gadget.so补丁一个APK。
patchipa 使用FridaGadget dylib补丁一个IPA。
run 运行单个objection命令。
signapk 使用objection密钥对APK进行Zipalign和签名。
version 打印当前版本并退出。

注入命令:

1
2
3
4
5
6
objection -g 包名 explore

- help:不知道当前命令的效果是什么,在当前命令前加help比如:help env,回车之后会出现当前命令的解释信息
- 按空格:不知道输入什么就按空格,会有提示出来
- jobs:可以进行多项hook
- 日志:objection的日志文件生成在 C:\Users\Administrator\.objection

启动前就hook

1
objection -g 进程名 explore --startup-command "android hooking watch class 路径.类名"

基础 api

  1. memory list modules - 查看内存中加载的库(不常用)

  2. memory list exports so名称 - 查看库的导出函数(不常用)

  3. android hooking list activities - 查看内存中加载的activity /android hooking list services -查看内存中加载的services

    我们把 fs64 跑起来,然后进入虚拟环境,运行注入命令以 spawn 模式启动 app

    1
    objection -g com.zj.wuaipojie explore

    image-20240515184651305

    然后运行命令

    image-20240515184802632

  4. android intent launch_activity 类名 - 启动activityservice(可以用于一些没有验证的activity,在一些简单的ctf中有时候可以出奇效,比如需要绕过某个验证才能打开一个新的窗口,那么我们就直接通过这个方法直接对这个窗口进行启动,直接进行绕过)

    比如这里我们绕过第三关的广告弹窗,直接打开第三关的界面,找到对应的类名即可

    image-20240515185114574

    image-20240515185145454

  5. android sslpinning disable- 关闭ssl校验,和抓包相关

  6. android root disable - 关闭root检测

内存漫游

  1. android heap search instances 类名 - 内存搜刮类实例

    我们打开之前的第六关界面,然后运行,就会获得这个类的一个 Hashcode

    image-20240515190319840

  2. android heap execute Hashcode + method - 调用实例的方法

    我们要调用 Demo 类中的一个方法,就需要通过之前获得的 Hashcode + 方法名来实现

    如果是一个无参的方法,运行以后就会得到返回值,比如方法 getPublicInt

    image-20240515190608877

    如果是有参数的方法,可以用 android heap evaluate Hashcode 进入 javascript 编辑界面,然后我们写console.log(clazz.方法名(参数)),再根据提示按 ESC + ENTER 确认

    image-20240515190848565

  3. android hooking list classes - 列出内存中所有的类(结果比静态分析的更准确,但是能列出一万个,不好用)

  4. android hooking search classes 关键词 - 在内存中所有已加载的类中搜索包含特定关键词的类

  5. android hooking search methods 关键词 - 在内存中所有已加载的类的方法中搜索包含特定关键词的方法(一般不建议使用,特别耗时,还可能崩溃)

  6. android hooking list class_methods 类名 - 内存漫游类中的所有方法

HOOK 方法

  1. hook类的所有方法

    1
    android hooking watch class 类名
  2. hook方法的参数、返回值和调用栈

    1
    android hooking watch class_method 类名.方法名 --dump-args --dump-return --dump-backtrace

    开始 hook 以后,我们点击以下第六关的界面,就会展示出整个堆栈、参数、返回值

    image-20240515191716343

  3. hook 类的构造方法

    1
    android hooking watch class_method 类名.$init --dump-args --dump-return --dump-backtrace

    image-20240515214941769

  4. hook 方法的所有重载

    1
    android hooking watch class_method 类名.方法名

r0tracer

r0ysue/r0tracer: 安卓Java层多功能追踪脚本 (项目地址)

主要是用 r0tracer.js,看一下 main 函数,里面有很多项目自带的注释

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
function main() {
console.Purple("r0tracer begin ... !")
//0. 增加精简模式,就是以彩虹色只显示进出函数。默认是关闭的,注释此行打开精简模式。
//isLite = true;
/*
//以下三种模式,取消注释某一行以开启
*/
//A. 简易trace单个类
// traceClass("ViewController")
//B. 黑白名单trace多个函数,第一个参数是白名单(包含关键字),第二个参数是黑名单(不包含的关键字)
// hook("com.uzmap.pkg.EntranceActivity", "$");
hook("ViewController","UI")
//C. 报某个类找不到时,将某个类名填写到第三个参数,比如找不到com.roysue.check类。(前两个参数依旧是黑白名单)
// hook("com.roysue.check"," ","com.roysue.check");
//D. 新增hookALL() 打开这个模式的情况下,会hook属于app自己的所有业务类,小型app可用 ,中大型app几乎会崩溃,经不起
// hookALL()
}
/*
//setImmediate是立即执行函数,setTimeout是等待毫秒后延迟执行函数
//二者在attach模式下没有区别
//在spawn模式下,hook系统API时如javax.crypto.Cipher建议使用setImmediate立即执行,不需要延时
//在spawn模式下,hook应用自己的函数或含壳时,建议使用setTimeout并给出适当的延时(500~5000)
*/
setImmediate(main)
//
// setTimeout(main, 2000);


// 玄之又玄,众妙之门
// Frida的崩溃有时候真的是玄学,大项目一崩溃根本不知道是哪里出的问题,这也是小而专的项目也有一丝机会的原因
// Frida自身即会经常崩溃,建议多更换Frida(客/服要配套)版本/安卓版本,我自己常用的组合是两部手机,Frida12.8.0全家桶+安卓8.1.0,和Frida14.2.2全家桶+安卓10

ke一看到,最新版本更新了精简模式,然后我们的 tracer 主要有三个模式,这里我们使用 B 模式,只把白名单改为我们想要 hook 的包名或者类名即可。

image-20240515225538217

这里我们改成 hook(com.zj.wuaipojie2023_1","$");

这里我先用 frida-ps -U 来显示所有进程名

image-20240515231828387

然后用如下命令启动 tracer,有 -f 为 spawn 模式,没有就是 attach 模式,--no-pause -o saveLog5.txt是导出日志

1
frida -U 【2023春节】解题领红包之四 -l r0tracer.js

image-20240515231938397

image-20240515232203854

我们点击验证按钮触发一下 tracer

image-20240515232141263

在这里找到了我们输入的两个参数,返回值是 false,我们往上找找,发现程序生成这个返回值

image-20240515234049292

应该就是 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 读取内存

静态注册方法

image-20240516231042599

我们想要对这个 checkVip 进行 Hook,然后点进去能看到这是一个 native 方法,我们进 so 文件看一下

image-20240516231143115

发现这是一个静态注册方法,直接 return 0

image-20240516231242785

我们可以直接在 JAVA 层进行 hook,找到这个函数,右击复制成 frida 片段,导入 hook.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function hookTest1() {
let SecurityUtil = Java.use("com.zj.wuaipojie.util.SecurityUtil");
SecurityUtil["checkVip"].implementation = function () {
console.log('checkVip is called');
let ret = this.checkVip();
console.log('checkVip ret value is ' + ret);
return ret;
};
}

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

setImmediate(main);

我们用 frida -U wuaipojie -l hook.js 启动 hook

image-20240516231727465

当然也可以直接修改返回值为 True

1
2
3
4
5
6
7
8
9
function hookTest1() {
let SecurityUtil = Java.use("com.zj.wuaipojie.util.SecurityUtil");
SecurityUtil["checkVip"].implementation = function () {
console.log('checkVip is called');
let ret = this.checkVip();
console.log('checkVip ret value is ' + ret);
return true;
};
}

image-20240516231911717

枚举导入导出表

  1. 导出表(Export Table):列出了库中可以被其他程序或库访问的所有公开函数和符号的名称。
  2. 导入表(Import Table):列出了库需要从其他库中调用的函数和符号的名称。

简而言之,导出表告诉其他程序:“这些是我提供的功能。”,而导入表则表示:“这些是我需要的功能。”。

当然这些东西可以直接在 ida 里面查出来

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function hookTest1() {
Java.perform(function() {
//打印导入表
var imports = Module.enumerateImports("lib52pojie.so"); // 枚举导入表
for(var i = 0; i < imports.length; i++) { // 循环遍历导入表
if(imports[i].name == "vip") {
console.log(JSON.stringify(imports[i])); //通过JSON.stringify打印object数据
console.log(imports[i].address);
}
}
//打印导出表
var exports = Module.enumerateExports("lib52pojie.so");
for(var i = 0; i < exports.length; i++) {
console.log(JSON.stringify(exports[i]));
}
})
}

基础 Native 层 hook 打印

  1. 布尔、整型、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
    26
    function 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());
    }
    })
    }
    })
    }

    我们找到显示钻石数量的方法

    image-20240516233050112

    由于没有参数,我们直接修改返回值即可,这里把 99 修改成 99999

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    function 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());
    }
    })
    }
    })
    }

    image-20240516233708342

  2. string 类型

    我们找到这个 vip 等级的方法,可以看出其第三个参数是一个 jstring 类型

    image-20240516233915789

    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
    25
    function 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());
    }
    })
    }
    })
    }

    image-20240516234820945

    基础 Native 层 Hook 修改

  3. 整型,布尔,char型

    比较简单,对于参数,先转为指针 ptr 再赋值,返回值直接 replace

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    function 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());
    }
    })
    }
    })
    }
  4. 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
    28
    function 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
    28
    function 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);
    }
    })
    }
    })
    }

    image-20240517000841642

    image-20240517000849170

SO 基址获取

SO 基址可以方便我们对未导出函数地址的计算

1
2
3
var moduleAddr1 = Process.findModuleByName("lib52pojie.so").base;  
var moduleAddr2 = Process.getModuleByName("lib52pojie.so").base;
var moduleAddr3 = Module.findBaseAddress("lib52pojie.so");

我们分别打印一下:

1
2
3
4
5
6
7
8
9
10
function hookTest1() {
Java.perform(function() {
var moduleAddr1 = Process.findModuleByName("lib52pojie.so").base;
var moduleAddr2 = Process.getModuleByName("lib52pojie.so").base;
var moduleAddr3 = Module.findBaseAddress("lib52pojie.so");
console.log("moduleAddr1", moduleAddr1);
console.log("moduleAddr2", moduleAddr2);
console.log("moduleAddr3", moduleAddr3);
})
}

image-20240517001639014

Hook 未导出函数与函数地址计算

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function hookTest6(){
Java.perform(function(){
//根据导出函数名打印基址
var soAddr = Module.findBaseAddress("lib52pojie.so"); // 先找到 so 基址
console.log(soAddr);
var funcaddr = soAddr.add(0x1071C); // 然后加上函数偏移地址
console.log(funcaddr);
if(funcaddr != null){ // 利用计算出的地址进行 hook
Interceptor.attach(funcaddr,{
onEnter: function(args){ //args参数
},
onLeave: function(retval){ //retval返回值
console.log(retval.toInt32());
}
})
}
})
}

函数地址计算

  1. 安卓里一般32 位的 so 中都是thumb指令,64 位的 so 中都是arm指令

  2. 通过IDA里的opcode bytes来判断,arm 指令为 4 个字节thumb 指令多为 2 个字节,也有 4 个字节

  3. 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

dlopen 源码

android_dlopen_ext 源码

源码如下:

1
2
3
4
void* dlopen(const char* filename, int flag) {
const void* caller_addr = __builtin_return_address(0);
return __loader_dlopen(filename, flag, caller_addr);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function hook_dlopen() {
var dlopen = Module.findExportByName(null, "dlopen");
Interceptor.attach(dlopen, {
onEnter: function (args) {
var so_name = args[0].readCString(); // 根据源码,先获取 so 文件的名称,再过滤
if (so_name.indexOf("lib52pojie.so") >= 0) this.call_hook = true;
}, onLeave: function (retval) { // 设置监听,捕捉so文件加载的时机,后面才是真正的 Hook 逻辑
if (this.call_hook) hookTest2();
}
});
// 高版本Android系统使用android_dlopen_ext
var android_dlopen_ext = Module.findExportByName(null, "android_dlopen_ext");
Interceptor.attach(android_dlopen_ext, {
onEnter: function (args) {
var so_name = args[0].readCString();
if (so_name.indexOf("lib52pojie.so") >= 0) this.call_hook = true;
}, onLeave: function (retval) {
if (this.call_hook) hookTest2();
}
});
}

我们对 dlopen 进行 hook,一旦检测到目标 so 文件被加载,就执行 hookTest2

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
function 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);
}
})
}
})
}

function hook_dlopen() {
var android_dlopen_ext = Module.findExportByName(null, "android_dlopen_ext");
Interceptor.attach(android_dlopen_ext, {
onEnter: function (args) {
var so_name = args[0].readCString();
if (so_name.indexOf("lib52pojie.so") >= 0) this.call_hook = true;
}, onLeave: function (retval) {
if (this.call_hook) hookTest2();
}
});
}

单机 验证 按钮的时候会触发 hook 逻辑

image-20240525183941339

frida 写数据

一般写在app的私有目录里,不然会报错:failed to open file (Permission denied)(实际上就是权限不足)

1
2
3
4
5
6
7
var file_path = "/data/user/0/com.zj.wuaipojie/test.txt";
var file_handle = new File(file_path, "wb");
if (file_handle && file_handle != null) {
file_handle.write(data); //写入数据
file_handle.flush(); //刷新
file_handle.close(); //关闭
}

比如对so层的viplevel函数,我们要把这个函数的返回值给hook了,然后写进一个test.txt文件里,保存在data/usr/0 + 包名 这个私有目录里

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
function 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){
},
//onLeave里可以打印和修改返回值
onLeave: function(retval){ //retval返回值
var returnedJString = Java.cast(retval, Java.use('java.lang.String'));
var file_path = "/data/user/0/com.zj.wuaipojie/test.txt";
var file_handle = new File(file_path, "wb");
if (file_handle && file_handle != null) {
file_handle.write(returnedJString.toString()); //写入数据
file_handle.flush(); //刷新
file_handle.close(); //关闭
console.log("写入成功")
}
}
})
}
})
}

运行以后找到对应的路径,就能找到我们创建的 test.txt 了

image-20240528192138948

image-20240528192220314

image-20240528192235564

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 型的值

image-20240528192944769

这时候我们想无论如何都让这个函数返回 1 应该怎么办呢?我们可以patch掉ARM汇编以后再重新打包,但是这样很麻烦,可以直接用 inline_hook 来实现

image-20240528195215285

观察最后的RET部分,我们先找找对应的寄存器,这里应该是找 W22 的值(W和X是寄存器的前缀,分别表示32位和64位,因此找到X22就是找到W22)

我们看到 MOV W0 W22 这一步(W0是返回值)的偏移是 0x10420,我们可以用 so_base_addr + offset 找到这条指令,然后对X22寄存器的值进行修改

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function inline_hook() {
var so_base_addr = Module.findBaseAddress("lib52pojie.so")
if(so_base_addr) {
var func_addr = so_base_addr.add(0x10428)
Java.perform(function() {
Interceptor.attach(func_addr, {
onEnter : function(args) {
console.log(JSON.stringify(this.context)) // 可以先把整个context打印出来,我们会看到很多寄存器的值,x22是0x0
this.context.x22 = ptr(1)
console.log(this.context.x22)
},
onLeave : function(retval) {
}
})
})
}
}

image-20240528201210112

此时我们再次点击验证,返回值已经被修改成了 1

image-20240528201358328

我们也可以将这个地址对应的二进制给解析成汇编

1
2
3
var soAddr = Module.findBaseAddress("lib52pojie.so");
var codeAddr = Instruction.parse(soAddr.add(0x10428));
console.log(codeAddr.toString());

image-20240528220044976

当然也可以直接进行 patch assemble,比如可以直接把这个 MOV W0, W22 改成 MOV W0, 1,可以通过下面这个网站来获取arm64的字节码

Online ARM to HEX Converter

转化以后得到其指令转化为 hex 为0x20008052,我们进行patchCode,再次运行程序,也能直接绕过判断

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function patch_code() {
var soAddr = Module.findBaseAddress("lib52pojie.so");
if(soAddr) {
var codeAddr = soAddr.add(0x10428);
Memory.patchCode(codeAddr, 4, function(code) {
const writer = new Arm64Writer(code, { pc: codeAddr });
writer.putBytes(hexToBytes("20008052"));
writer.flush();
});
function hexToBytes(hex) {
let bytes = [];
for (let i = 0; i < hex.length; i += 2) {
bytes.push(parseInt(hex.substr(i, 2), 16));
}
return bytes;
}
}
}

image-20240528221416469

普通函数和 JNI 函数主动调用

假如我们要对native的某个函数进行主动调用,比如下面这个函数

image-20240528221954122

看一眼官方文档,frida提供了以下声明类型

数据类型 描述
void 无返回值
pointer 指针
int 整数
long 长整数
char 字符
float 浮点数
double 双精度浮点数
bool 布尔值

显然这个函数的返回值,参数1,参数2都对应的是一个 pointer 类型数据,根据这个来进行主动调用,函数地址偏移直接用ida就能找到0xE85C

image-20240528222452440

1
2
3
4
5
6
7
var funcAddr = Module.findBaseAddress("lib52pojie.so").add(0xE85C);
//声明函数指针
//NativeFunction的第一个参数是地址,第二个参数是返回值类型,第三个[]里的是传入的参数类型(有几个就填几个)
var aesAddr = new NativeFunction(funcAddr , 'pointer', ['pointer', 'pointer']);
var encry_text = Memory.allocUtf8String("OOmGYpk6s0qPSXEPp4X31g=="); //开辟一个指针存放字符串
var key = Memory.allocUtf8String('wuaipojie0123456');
console.log(aesAddr(encry_text ,key).readCString());

得到返回值

image-20240528222539041

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。该域由其名称及签名指定。访问器函数的GetField 及 SetField系列使用域 ID 检索对象域。GetFieldID() 不能用于获取数组的长度域。应使用GetArrayLength()。 -
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
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
function find_RegisterNatives(params) {
// 在 libart.so 库中枚举所有符号(函数、变量等)
let symbols = Module.enumerateSymbolsSync("libart.so");
let addrRegisterNatives = null; // 用于存储 RegisterNatives 方法的地址

// 遍历所有符号来查找 RegisterNatives 方法
for (let i = 0; i < symbols.length; i++) {
let symbol = symbols[i]; // 当前遍历到的符号

// 检查符号名称是否符合 RegisterNatives 方法的特征
if (symbol.name.indexOf("art") >= 0 && //RegisterNatives 是 ART(Android Runtime)环境的一部分
symbol.name.indexOf("JNI") >= 0 && //RegisterNatives 是 JNI(Java Native Interface)的一部分
symbol.name.indexOf("RegisterNatives") >= 0 && //检查符号名称中是否包含 "RegisterNatives" 字样。
symbol.name.indexOf("CheckJNI") < 0) { //CheckJNI 是用于调试和验证 JNI 调用的工具,如果不过滤,会有两个RegisterNatives,而带有CheckJNI的系统一般是关闭的,所有要过滤掉
addrRegisterNatives = symbol.address; // 保存方法地址
console.log("RegisterNatives is at ", symbol.address, symbol.name); // 输出地址和名称
hook_RegisterNatives(addrRegisterNatives); // 调用hook函数
}
}
}

function hook_RegisterNatives(addrRegisterNatives) {
// 确保提供的地址不为空
if (addrRegisterNatives != null) {
// 使用 Frida 的 Interceptor hook指定地址的函数
Interceptor.attach(addrRegisterNatives, {
// 当函数被调用时执行的代码
onEnter: function (args) {
// 打印调用方法的数量
console.log("[RegisterNatives] method_count:", args[3]);

// 获取 Java 类并打印类名
let java_class = args[1];
let class_name = Java.vm.tryGetEnv().getClassName(java_class);

let methods_ptr = ptr(args[2]); // 获取方法数组的指针
let method_count = parseInt(args[3]); // 获取方法数量

// 遍历所有方法
//jni方法里包含三个部分:方法名指针、方法签名指针和方法函数指针。每个指针在内存中占用 Process.pointerSize 的空间(这是因为在 32 位系统中指针大小是 4 字节,在 64 位系统中是 8 字节)。为了提高兼容性,统一用Process.pointerSize,系统会自动根据架构来适配
for (let i = 0; i < method_count; i++) {
// 读取方法的名称、签名和函数指针
let name_ptr = Memory.readPointer(methods_ptr.add(i * Process.pointerSize * 3));//读取方法名的指针。这是每个方法结构体的第一部分,所以直接从起始地址读取。
let sig_ptr = Memory.readPointer(methods_ptr.add(i * Process.pointerSize * 3 + Process.pointerSize));//读取方法签名的指针。这是结构体的第二部分,所以在起始地址的基础上增加了一个指针的大小
let fnPtr_ptr = Memory.readPointer(methods_ptr.add(i * Process.pointerSize * 3 + Process.pointerSize * 2));//读取方法函数的指针。这是结构体的第三部分,所以在起始地址的基础上增加了两个指针的大小(Process.pointerSize * 2)。

// 将指针内容转换为字符串
let name = Memory.readCString(name_ptr);
let sig = Memory.readCString(sig_ptr);

// 获取方法的调试符号
let symbol = DebugSymbol.fromAddress(fnPtr_ptr);

// 打印每个注册的方法的相关信息
console.log("[RegisterNatives] java_class:", class_name, "name:", name, "sig:", sig, "fnPtr:", fnPtr_ptr, " fnOffset:", symbol, " callee:", DebugSymbol.fromAddress(this.returnAddress));
}
}
});
}
}

setImmediate(find_RegisterNatives); // 立即执行 find_RegisterNatives 函数

通过 IDA 分析我们可以在 JNI_OnLoad 中找到动态注册方法:

QQ_1722187480299

就是之前那个密界的UI,我们运行一下hook_RegisterNatives 脚本:

QQ_1722187762516

打印出了包名,方法名,签名信息,地址和偏移等等信息,我们再把 libart 的 SO 基址打印出来

QQ_1722188084842

这样就可以计算函数的偏移为 0x10484

QQ_1722188156943

也就是 check 函数的位置。

hook GetStringUTFChars

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
function hook_GetStringUTFChars() {
var GetStringUTFChars_addr = null;
// jni 系统函数都在 libart.so 中
var module_libart = Process.findModuleByName("libart.so");
var symbols = module_libart.enumerateSymbols();
for (var i = 0; i < symbols.length; i++) {
var name = symbols[i].name;
if ((name.indexOf("JNI") >= 0)
&& (name.indexOf("CheckJNI") == -1)
&& (name.indexOf("art") >= 0)) {
if (name.indexOf("GetStringUTFChars") >= 0) {
// 获取到指定 jni 方法地址
GetStringUTFChars_addr = symbols[i].address;
}
}
}

Java.perform(function(){
Interceptor.attach(GetStringUTFChars_addr, {
onEnter: function(args){

}, onLeave: function(retval){
// retval const char*
console.log("GetStringUTFChars onLeave : ", ptr(retval).readCString());
if(ptr(retval).readCString().indexOf("普通") >=0){
console.log("GetStringUTFChars onLeave : ", ptr(retval).readCString());
console.log(Thread.backtrace(this.context, Backtracer.ACCURATE).map(DebugSymbol.fromAddress).join('\n') + '\n');
}

}
})
})
}
setImmediate(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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function replaceKILL() {
// 查找libc.so库中kill函数的地址
var kill_addr = Module.findExportByName("libc.so", "kill");
// 使用Interceptor.replace来替换kill函数
Interceptor.replace(kill_addr, new NativeCallback(function (arg0, arg1) {
// 当kill函数被调用时,打印第一个参数(通常是进程ID)
console.log("arg0=> ", arg0);
// 打印第二个参数(通常是发送的信号)
console.log("arg1=> ", arg1);
// 打印调用kill函数的堆栈跟踪信息
console.log('libc.so!kill called from:\n' +
Thread.backtrace(this.context, Backtracer.ACCURATE)
.map(DebugSymbol.fromAddress).join('\n') + '\n');
}, "int", ["int", "int"]))
}

hook pthread_create

后面frida检测会用到

1
2
3
4
5
6
7
8
9
10
11
12
function hook_pthread_create(){
//hook反调试
var pthread_create_addr = Module.findExportByName("libc.so", "pthread_create");
console.log("pthread_create_addr: ", pthread_create_addr);
Interceptor.attach(pthread_create_addr,{
onEnter:function(args){
console.log(args[0], args[1], args[2], args[4]);
},onLeave:function(retval){
console.log("retval is =>",retval)
}
})
}

hook strcmp

CTF比赛能用,可以知道enc是多少也可以知道加密以后的数据是多少

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function hook_strcmp() {
var pt_strcmp = Module.findExportByName("libc.so", 'strcmp');
Interceptor.attach(pt_strcmp, {
onEnter: function (args) {
var str1 = args[0].readCString();
var str2 = args[1].readCString();
if (str2.indexOf("hh") !== -1) {
console.log("strcmp-->", str1, str2);
this.printStack = true;
}
}, onLeave: function (retval) {
if (this.printStack) {
var stack = Thread.backtrace(this.context, Backtracer.ACCURATE)
.map(DebugSymbol.fromAddress).join("\n");
console.log("Stack trace:\n" + stack);
}
}
})
}

HOOK Libdl

libdl.so是一个处理动态链接和加载的标准库,它提供了dlopendlclosedlsym等函数,用于在运行时动态地加载和使用共享库

类别 函数名称 参数 描述
动态链接库操作 dlopen const char *filename, int flag 打开动态链接库文件
dlsym void *handle, const char *symbol 从动态链接库中获取符号地址

hook dlsym

获取jni静态注册方法地址,可以知道哪个静态方法被调用了,在哪个 SO 文件里面的,还能知道该静态方法的地址和偏移

1
2
3
4
5
6
7
8
9
10
11
12
13
function hook_dlsym() {
var dlsymAddr = Module.findExportByName("libdl.so", "dlsym");
Interceptor.attach(dlsymAddr, {
onEnter: function(args) {
this.args1 = args[1];
},
onLeave: function(retval) {
var module = Process.findModuleByAddress(retval);
if (module === null) return;
console.log(this.args1.readCString(), module.name, retval, retval.sub(module.base));
}
});
}

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
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
function hook_call_constructors() {
// 初始化变量
let get_soname = null;
let call_constructors_addr = null;
let hook_call_constructors_addr = true;
// 根据进程的指针大小找到对应的linker模块
let linker = null;
if (Process.pointerSize == 4) {
linker = Process.findModuleByName("linker");
} else {
linker = Process.findModuleByName("linker64");
}
// 枚举linker模块中的所有符号
let symbols = linker.enumerateSymbols();
for (let index = 0; index < symbols.length; index++) {
let symbol = symbols[index];
// 查找名为"__dl__ZN6soinfo17call_constructorsEv"的符号地址
if (symbol.name == "__dl__ZN6soinfo17call_constructorsEv") {
call_constructors_addr = symbol.address;
// 查找名为"__dl__ZNK6soinfo10get_sonameEv"的符号地址,获取soname
} else if (symbol.name == "__dl__ZNK6soinfo10get_sonameEv") {
get_soname = new NativeFunction(symbol.address, "pointer", ["pointer"]);
}
}
// 如果找到了所有需要的地址和函数
if (hook_call_constructors_addr && call_constructors_addr && get_soname) {
// 挂钩call_constructors函数
Interceptor.attach(call_constructors_addr,{
onEnter: function(args){
// 从参数获取soinfo对象
let soinfo = args[0];
// 使用get_soname函数获取模块名称
let soname = get_soname(soinfo).readCString();
// 调用tell_init_info函数并传递一个回调,用于记录构造函数的调用信息
tell_init_info(soinfo, new NativeCallback((count, init_array_ptr, init_func) => {
console.log(`[call_constructors] ${soname} count:${count}`);
console.log(`[call_constructors] init_array_ptr:${init_array_ptr}`);
console.log(`[call_constructors] init_func:${init_func} -> ${get_addr_info(init_func)}`);
// 遍历所有初始化函数,并打印它们的信息
for (let index = 0; index < count; index++) {
let init_array_func = init_array_ptr.add(Process.pointerSize * index).readPointer();
let func_info = get_addr_info(init_array_func);
console.log(`[call_constructors] init_array:${index} ${init_array_func} -> ${func_info}`);
}
}, "void", ["int", "pointer", "pointer"]));
}
});
}
}

frida rpc

frida 提供了一种跨平台的 rpc(就是Remote Procedure Call 远程过程调用) 机制,通过 frida rpc 可以在主机和目标设备之间进行通信,并在目标设备上执行代码,简单理解就是可以不需要分析某些复杂加密,通过传入参数获取返回值,进而来实现python或易语言来调用的一系列操作,多用于爬虫。

包名附加进程

1
2
3
4
5
6
7
import frida, sys
jsCode = """ ...... """
script.exports.rpcfunc()
process = frida.get_usb_device().attach('包名') # 获取USB设备并附加到应用
script = process.create_script(jsCode) # 创建并加载脚本
script.load()# 执行脚本
sys.stdin.read()# 保持脚本运行状态,防止它执行完毕后立即退出

spawn方式启动

1
2
3
4
5
6
7
8
9
10
11
import frida, sys
jsCode = """ ...... """
script.exports.rpcfunc()
device = frida.get_usb_device()
pid = device.spawn(["包名"]) #以挂起方式创建进程
process = device.attach(pid)
script = process.create_script(jsCode)
script.load()
device.resume(pid) #加载完脚本, 恢复进程运行
sys.stdin.read()

连接非标准端口

1
2
3
4
5
6
7
import frida, sys
jsCode = """ ...... """
script.exports.rpcfunc()
process = frida.get_device_manager().add_remote_device('192.168.1.22:6666').attach('包名')
script = process.create_script(jsCode)
script.load()
sys.stdin.read()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
function get_url() {
let ChallengeNinth = Java.use("com.zj.wuaipojie.ui.ChallengeNinth");
ChallengeNinth["updateUI"].implementation = function (list) {
let ret = this.updateUI(list);
// 获取List的大小
var size = list.size();
// 遍历并打印List中的每个ImageEntity对象
for (var i = 0; i < size; i++) {
var imageEntity = Java.cast(list.get(i), Java.use('com.zj.wuaipojie.entity.ImageEntity'));
console.log(imageEntity.name.value + imageEntity.cover.value);
}
return ret;
};
}

我们打开第九关的UI,发现都是电影图片,滚动的时候会异步刷新。

image-20240808004030687

看一下反编译代码,有一个关键函数 getData,发现其设置了一个网络监听,然后然会了一个 results扔到了 updateUI 函数里面,看起来这个 results 就是显示在UI的信息了。

image-20240808004020879

找到 updateUI ,其参数是一个 ImageEntity 类的 list,点进去看一眼,发现有 cover ,就是 url

image-20240808004400434

image-20240808004437221

我们对 updateUI 进行 hook 即可,右击复制为 frida 片段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function hook_updateUI(){
let ChallengeNinth = Java.use("com.zj.wuaipojie.ui.ChallengeNinth");
ChallengeNinth["updateUI"].implementation = function (list) {
console.log('updateUI is called' + ', ' + 'list: ' + list);
let ret = this.updateUI(list);
console.log('updateUI ret value is ' + ret);
return ret;
};
}

function main() {
Java.perform(function() {
hook_updateUI();
});
}
setImmediate(main);

javascripts 中 let 和 var 的区别

let和var的区别:

  1. ES6引入let 和 const ,增加’’TDZ”特性,规定必须先声明后使用。
  2. let存在块作用域特性,变量只在块域中有效。
  3. 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
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
from fastapi import FastAPI
from fastapi.responses import JSONResponse
import frida, sys
import uvicorn

# 创建FastAPI应用实例
app = FastAPI()

# 定义一个GET请求的路由'/download-images/'
@app.get("/download-images/")
def download_images():
# 定义处理frida消息的回调函数
def on_message(message, data):
message_type = message['type']
if message_type == 'send':
print('[* message]', message['payload'])
elif message_type == 'error':
stack = message['stack']
print('[* error]', stack)
else:
print(message)

# Frida脚本代码,用于在目标应用内部执行
jsCode = """
function getinfo(){
var result = [];
Java.perform(function(){
Java.choose("com.zj.wuaipojie.ui.ChallengeNinth",{
onMatch:function(instance){
instance.setupScrollListener(); // 调用目标方法
},
onComplete:function(){}
});

Java.choose("com.zj.wuaipojie.entity.ImageEntity",{
onMatch:function(instance){
var name = instance.getName();
var cover = instance.getCover();
result.push({name: name, cover: cover}); // 收集数据
},
onComplete:function(){}
});
});
return result; // 返回收集的结果
}
rpc.exports = {
getinfo: getinfo // 导出函数供外部调用
};
"""

# 使用frida连接到设备并附加到指定进程
process = frida.get_usb_device().attach("com.zj.wuaipojie")
# 创建并加载Frida脚本
script = process.create_script(jsCode)
script.on("message", on_message) # 设置消息处理回调
script.load() # 加载脚本
getcovers = script.exports.getinfo() # 调用脚本中的函数获取信息
print(getcovers)

# 返回获取的信息作为JSON响应
return JSONResponse(content=getcovers)

# 主入口,运行FastAPI应用
if __name__ == "__main__":
uvicorn.run(app, host="127.0.0.1", port=8000) # 使用uvicorn作为ASGI服务器启动应用

【2024春节】解题领红包活动

初级题

关键函数如下

image-20240808152947801

对其进行 hook,用 r 读取一个文件流然后找 flag{ 开头的索引,并返回,直接主动调用即可

1
2
3
4
5
6
7
8
setImmediate(function() {
Java.perform(function() {
let YSQDActivity = Java.use("com.zj.wuaipojie2024_1.YSQDActivity");
let res = YSQDActivity.extractDataFromFile("/data/user/0/com.zj.wuaipojie2024_1/files/ys.mp4");
console.log(res);
});
});
// flag{happy_new_year_2024}

中级题

打开是一个手势解锁

image-20240808222903656

看看源码,在 checkPassword 函数里面反射调用了 assets 资源文件里面的 classes.dex 中的 com.zj.wuaipojie2024_2.C 类的 isValidate 方法,去资源文件里找到改 dex 文件并反编译,同时还把 classes.dex 保存为 data/1.dex

image-20240808223005616

找到 isValidate 方法

image-20240808223158742

该方法又反射调用了 com.zj.wuaipojie2024_2.A 类的 d 方法,但是查看该方法感觉不对劲

image-20240808223244733

查看一下 logcat 日志

image-20240808223344438

发现 1.dex 的校验和出错了,需要修复 dex ,我们使用 NP 管理器进行修复,注意这里选仅修复头部

image-20240808223919288

但是这里出现了报错,未解决,可能跟系统有关,尽管我开了核心破解但还是没法装

eb1df66aff87e286f174121001332c2c

浅浅记录一下 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 检测

检测文件名、端口名、双进程保护、失效的检测点

  1. 检测 data/local/tmp 目录下是否有 server 文件,绕过方法:改个名即可

  2. 检测默认端口 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
  3. 双进程保护,学会看注入报错的日志,比如说当app主动附加自身进程时,这时候再注入就会提示run frida as root,绕过方法:以spawn的方式启动进程即可

  4. 借助脚本定位检测frida的so,可以通过 hook_dlopen 停在哪里得知哪个 so 文件里面存在 frida 检测逻辑

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    function 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);
    }
    }
    }
    );
    }
  5. 还有一些检测点随着 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

image-20240810001150955

执行以下脚本

1
2
3
4
adb shell ps | findstr com.zj.wuaipojie
adb shell
su
cat proc/进程ID/maps | grep frida

/proc/self/maps 是一个特殊的文件,它包含了当前进程的内存映射信息。当你打开这个文件时,它会显示一个列表,其中包含了进程中每个内存区域的详细信息。这些信息通常包括:

  • 起始地址(Start Address)
  • 结束地址(End Address)
  • 权限(如可读、可写、可执行)
  • 共享/私有标志(Shared or Private)
  • 关联的文件或设备(如果内存区域是文件映射的)
  • 内存区域的偏移量
  • 内存区域的类型(如匿名映射、文件映射、设备映射等)
    当注入frida后,在maps文件中就会存在 frida-agent-64.sofrida-agent-32.so 等文件。

image-20240810001127622

字段 描述
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
bool check_maps() {
// 定义一个足够大的字符数组line,用于存储读取的行
char line[512];
// 打开当前进程的内存映射文件/proc/self/maps进行读取
FILE* fp = fopen("/proc/self/maps", "r");
if (fp) {
// 如果文件成功打开,循环读取每一行
while (fgets(line, sizeof(line), fp)) {
// 使用strstr函数检查当前行是否包含"frida"字符串
if (strstr(line, "frida") || strstr(line, "gadget")) {
// 如果找到了"frida",关闭文件并返回true,表示检测到了恶意库
fclose(fp);
return true; // Evil library is loaded.
}
}
// 遍历完文件后,关闭文件
fclose(fp);
} else {
// 如果无法打开文件,记录错误。这可能意味着系统状态异常
// 注意:这里的代码没有处理错误,只是注释说明了可能的情况
}
// 如果没有在内存映射文件中找到"frida",返回false,表示没有检测到恶意库
return false; // No evil library detected.
}

image-20240810003234293

绕过方法 1 - anti 脚本 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
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// 定义一个函数anti_maps,用于阻止特定字符串的搜索匹配,避免检测到敏感内容如"Frida"或"REJECT"
function anti_maps() {
// 查找libc.so库中strstr函数的地址,strstr用于查找字符串中首次出现指定字符序列的位置
var pt_strstr = Module.findExportByName("libc.so", 'strstr');
// 查找libc.so库中strcmp函数的地址,strcmp用于比较两个字符串
var pt_strcmp = Module.findExportByName("libc.so", 'strcmp');
// 使用Interceptor模块附加到strstr函数上,拦截并修改其行为
Interceptor.attach(pt_strstr, {
// 在strstr函数调用前执行的回调
onEnter: function (args) {
// 读取strstr的第一个参数(源字符串)和第二个参数(要查找的子字符串)
var str1 = args[0].readCString();
var str2 = args[1].readCString();
// 检查子字符串是否包含"REJECT"或"frida",如果包含则设置hook标志为true
if (str2.indexOf("REJECT") !== -1 || str2.indexOf("frida") !== -1) {
this.hook = true;
}
},
// 在strstr函数调用后执行的回调
onLeave: function (retval) {
// 如果之前设置了hook标志,则将strstr的结果替换为0(表示未找到),从而隐藏敏感信息
if (this.hook) {
retval.replace(0);
}
}
});

// 对strcmp函数做类似的处理,防止通过字符串比较检测敏感信息
Interceptor.attach(pt_strcmp, {
onEnter: function (args) {
var str1 = args[0].readCString();
var str2 = args[1].readCString();
if (str2.indexOf("REJECT") !== -1 || str2.indexOf("frida") !== -1) {
this.hook = true;
}
},
onLeave: function (retval) {
if (this.hook) {
// strcmp返回值为0表示两个字符串相等,这里同样替换为0以避免匹配成功
retval.replace(0);
}
}
});
}

绕过方法 2 重定向 maps

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
// 定义一个函数,用于重定向并修改maps文件内容,以隐藏特定的库和路径信息
function mapsRedirect() {
// 定义伪造的maps文件路径
var FakeMaps = "/data/data/com.zj.wuaipojie/maps";
// 获取libc.so库中'open'函数的地址
const openPtr = Module.getExportByName('libc.so', 'open');
// 根据地址创建一个新的NativeFunction对象,表示原生的'open'函数
const open = new NativeFunction(openPtr, 'int', ['pointer', 'int']);
// 查找并获取libc.so库中'read'函数的地址
var readPtr = Module.findExportByName("libc.so", "read");
// 创建新的NativeFunction对象表示原生的'read'函数
var read = new NativeFunction(readPtr, 'int', ['int', 'pointer', "int"]);
// 分配512字节的内存空间,用于临时存储从maps文件读取的内容
var MapsBuffer = Memory.alloc(512);
// 创建一个伪造的maps文件,用于写入修改后的内容,模式为"w"(写入)
var MapsFile = new File(FakeMaps, "w");
// 使用Interceptor替换原有的'open'函数,注入自定义逻辑
Interceptor.replace(openPtr, new NativeCallback(function(pathname, flag) {
// 调用原始的'open'函数,并获取文件描述符(FD)
var FD = open(pathname, flag);
// 读取并打印尝试打开的文件路径
var ch = pathname.readCString();
if (ch.indexOf("/proc/") >= 0 && ch.indexOf("maps") >= 0) {
console.log("open : ", pathname.readCString());
// 循环读取maps内容,并写入伪造的maps文件中,同时进行字符串替换以隐藏特定信息
while (parseInt(read(FD, MapsBuffer, 512)) !== 0) {
var MBuffer = MapsBuffer.readCString();
MBuffer = MBuffer.replaceAll("/data/local/tmp/re.frida.server/frida-agent-64.so", "FakingMaps");
MBuffer = MBuffer.replaceAll("re.frida.server", "FakingMaps");
MBuffer = MBuffer.replaceAll("frida-agent-64.so", "FakingMaps");
MBuffer = MBuffer.replaceAll("frida-agent-32.so", "FakingMaps");
MBuffer = MBuffer.replaceAll("frida", "FakingMaps");
MBuffer = MBuffer.replaceAll("/data/local/tmp", "/data");
// 将修改后的内容写入伪造的maps文件
MapsFile.write(MBuffer);
}
// 为返回伪造maps文件的打开操作,分配UTF8编码的文件名字符串
var filename = Memory.allocUtf8String(FakeMaps);
// 返回打开伪造maps文件的文件描述符
return open(filename, flag);
}
// 如果不是目标maps文件,则直接返回原open调用的结果
return FD;
}, 'int', ['pointer', 'int']));
}

绕过方法 3

用eBPF来hook系统调用并修改参数实现目的,使用bpf_probe_write_user向用户态函数地址写内容直接修改参数

1
2
char placeholder[] = "/data/data/com.zj.wuaipojie/maps";
bpf_probe_write_user((void*)addr, placeholder, sizeof(placeholder));

检测 status(线程名)

1
2
ls /proc/pid/task 列出线程id
cat /proc/pid/task/线程id/status
  • /proc/pid/task 目录下,可以通过查看不同的线程子目录,来获取进程中每个线程的运行时信息。这些信息包括线程的状态、线程的寄存器内容、线程占用的CPU时间、线程的堆栈信息等。通过这些信息,可以实时观察和监控进程中每个线程的运行状态,帮助进行调试、性能优化和问题排查等工作。
  • 在某些app中就会去读取 /proc/stask/线程ID/status 文件,如果是运行frida产生的,则进行反调试。例如:gmain/gdbus/gum-js-loop/pool-frida
  1. gmain:Frida 使用 Glib 库,其中的主事件循环被称为 GMainLoop。在 Frida 中,gmain 表示 GMainLoop 的线程。
  2. gdbus:GDBus 是 Glib 提供的一个用于 D-Bus 通信的库。在 Frida 中,gdbus 表示 GDBus 相关的线程。
  3. gum-js-loop:Gum 是 Frida 的运行时引擎,用于执行注入的 JavaScript 代码。gum-js-loop 表示 Gum 引擎执行 JavaScript 代码的线程。
  4. pool-frida:Frida 中的某些功能可能会使用线程池来处理任务,pool-frida 表示 Frida 中的线程池。
  5. linjector 是一种用于 Android 设备的开源工具,它允许用户在运行时向 Android 应用程序注入动态链接库(DLL)文件。通过注入 DLL 文件,用户可以修改应用程序的行为、调试应用程序、监视函数调用等,这在逆向工程、安全研究和动态分析中是非常有用的。
    PS:由于frida可以随时附加到进程,所以写的检测必须覆盖APP的全周期,或者至少是敏感函数执行前

检测方法:

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
bool check_status() {
DIR *dir = opendir("/proc/self/task/");
struct dirent *entry;
char status_path[MAX_PATH];
char buffer[MAX_BUFFER];
int found = false;

if (dir) {
while ((entry = readdir(dir)) != NULL) {
if (entry->d_type == DT_DIR) {
if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0) {
continue;
}
snprintf(status_path, sizeof(status_path), "/proc/self/task/%s/status", entry->d_name);
if (read_file(status_path, buffer, sizeof(buffer)) == -1) {
continue;
}
if (strcmp(buffer, "null") == 0) {
continue;
}
char *line = strtok(buffer, "\n");
while (line) {
if (strstr(line, "Name:") != NULL) {
const char *frida_name = strstr(line, "gmain");
if (frida_name || strstr(line, "gum-js-loop") || strstr(line, "pool-frida") || strstr(line, "gdbus")) {
found = true;
break;
}
}
line = strtok(NULL, "\n");
}
if (found) break;
}
}
closedir(dir);
}
return found;
}

绕过脚本:

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
function replace_str() {
var pt_strstr = Module.findExportByName("libc.so", 'strstr');
var pt_strcmp = Module.findExportByName("libc.so", 'strcmp');

Interceptor.attach(pt_strstr, {
onEnter: function (args) {
var str1 = args[0].readCString();
var str2 = args[1].readCString();
if (str2.indexOf("tmp") !== -1 ||
str2.indexOf("frida") !== -1 ||
str2.indexOf("gum-js-loop") !== -1 ||
str2.indexOf("gmain") !== -1 ||
str2.indexOf("gdbus") !== -1 ||
str2.indexOf("pool-frida") !== -1||
str2.indexOf("linjector") !== -1) {
//console.log("strcmp-->", str1, str2);
this.hook = true;
}
}, onLeave: function (retval) {
if (this.hook) {
retval.replace(0);
}
}
});

Interceptor.attach(pt_strcmp, {
onEnter: function (args) {
var str1 = args[0].readCString();
var str2 = args[1].readCString();
if (str2.indexOf("tmp") !== -1 ||
str2.indexOf("frida") !== -1 ||
str2.indexOf("gum-js-loop") !== -1 ||
str2.indexOf("gmain") !== -1 ||
str2.indexOf("gdbus") !== -1 ||
str2.indexOf("pool-frida") !== -1||
str2.indexOf("linjector") !== -1) {
//console.log("strcmp-->", str1, str2);
this.hook = true;
}
}, onLeave: function (retval) {
if (this.hook) {
retval.replace(0);
}
}
})

}

检测 inline hook

过Frida查看一个函数hook之前和之后的机器码,以此来判断是否被Frida的inlinehook注入。

将内存中的字节与本地字节逐一比较,若不一致则被修改

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
66
#include <jni.h>
#include <string>
#include <dlfcn.h>
#include "dlfcn/local_dlfcn.h"

bool check_inlinehook() {
// 根据系统架构选择对应的libc.so库路径
const char *lib_path;
#ifdef __LP64__
lib_path = "/system/lib64/libc.so";
#else
lib_path = "/system/lib/libc.so";
#endif

// 定义要比较的字节数
const int CMP_COUNT = 8;
// 指定要查找的符号名,这里是"open"函数
const char *sym_name = "open";

// 使用local_dlopen函数打开指定的共享库,并获取操作句柄
struct local_dlfcn_handle *handle = static_cast<local_dlfcn_handle *>(local_dlopen(lib_path));
if (!handle) {
return JNI_FALSE; // 如果无法打开共享库,返回false
}

// 获取"open"函数在libc.so中的偏移量
off_t offset = local_dlsym(handle, sym_name);

// 关闭handle,因为我们接下来使用标准的dlopen/dlsy来获取函数地址
local_dlclose(handle);

// 打开libc.so文件,准备读取数据
FILE *fp = fopen(lib_path, "rb");
if (!fp) {
return JNI_FALSE; // 如果无法打开文件,返回false
}

// 定义一个缓冲区,用于存储读取的文件内容
char file_bytes[CMP_COUNT] = {0};
// 读取指定偏移量处的CMP_COUNT个字节
fseek(fp, offset, SEEK_SET);
fread(file_bytes, 1, CMP_COUNT, fp);
fclose(fp);

// 使用dlopen函数打开libc.so共享库,并获取操作句柄
void *dl_handle = dlopen(lib_path, RTLD_NOW);
if (!dl_handle) {
return JNI_FALSE; // 如果无法打开共享库,返回false
}

// 使用dlsym函数获取"open"函数的地址
void *sym = dlsym(dl_handle, sym_name);
if (!sym) {
dlclose(dl_handle);
return JNI_FALSE; // 如果无法找到符号,返回false
}

// 比较原libc.so中的"open"函数内容与通过dlsym获取的"open"函数内容是否一致
int is_hook = memcmp(file_bytes, sym, CMP_COUNT) != 0;

// 关闭dlopen打开的共享库句柄
dlclose(dl_handle);

// 返回比较结果,如果函数被hook则返回JNI_TRUE,否则返回JNI_FALSE
return is_hook ? JNI_TRUE : JNI_FALSE;
}

获取 hook 前字节码

1
2
3
4
5
6
7
8
9
10
11
12
let bytes_count = 8
let address = Module.getExportByName("libc.so","open")

let before = ptr(address)
console.log("")
console.log(" before hook: ")
console.log(hexdump(before, {
offset: 0,
length: bytes_count,
header: true,
ansi: true
}));

绕过脚本

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

function hook_memcmp_addr(){
//hook反调试
var memcmp_addr = Module.findExportByName("libc.so", "fread");
if (memcmp_addr !== null) {
console.log("fread address: ", memcmp_addr);
Interceptor.attach(memcmp_addr, {
onEnter: function (args) {
this.buffer = args[0]; // 保存 buffer 参数
this.size = args[1]; // 保存 size 参数
this.count = args[2]; // 保存 count 参数
this.stream = args[3]; // 保存 FILE* 参数
},
onLeave: function (retval) {
// 这里可以修改 buffer 的内容,假设我们知道何时 fread 被用于敏感操作
console.log(this.count.toInt32());
if (this.count.toInt32() == 8) {
// 模拟 fread 读取了预期数据,伪造返回值
Memory.writeByteArray(this.buffer, [0x50, 0x00, 0x00, 0x58, 0x00, 0x02, 0x1f, 0xd6]);
retval.replace(8); // 填充前8字节
console.log(hexdump(this.buffer));
}
}
});
} else {
console.log("Error: memcmp function not found in libc.so");
}
}

魔改 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
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
bool anti_anti_maps() {
// 定义一个足够大的字符数组line,用于存储读取的行
const int buf_size = 512;
char buf[buf_size];
int fd; // 文件描述符
// 使用 my_openat 打开当前进程的内存映射文件 /proc/self/maps 进行读取
// AT_FDCWD 表示当前工作目录,"r" 表示只读方式打开
fd = my_openat(AT_FDCWD, "/proc/self/maps", O_RDONLY | O_CLOEXEC, 0);
if (fd != -1) {
// 如果文件成功打开,循环读取每一行
while ((read_line(fd, buf, buf_size)) > 0) {
// 使用strstr函数检查当前行是否包含"frida"字符串
if (strstr(buf, "frida") || strstr(buf, "gadget")) {
// 如果找到了"frida",关闭文件并返回true,表示检测到了恶意库
close(fd);
return true; // Evil library is loaded.
}
}
// 遍历完文件后,关闭文件
close(fd);
} else {
// 如果无法打开文件,记录错误。这可能意味着系统状态异常
// 注意:这里的代码没有处理错误,只是注释说明了可能的情况
}
// 如果没有在内存映射文件中找到"frida",返回false,表示没有检测到恶意库
return false; // No evil library detected.
}

ENTRY(my_openat) // 定义函数入口,标签my_openat
mov x8, __NR_openat // 将openat系统调用号(__NR_openat)移动到x8寄存器,x8用于存储系统调用号
svc #0 // 触发系统调用异常,进入操作系统执行系统调用
cmn x0, #(MAX_ERRNO + 1) // 将函数返回值(存储在x0寄存器)与MAX_ERRNO + 1进行无符号比较
cneg x0, x0, hi // 如果上面的比较结果大于或等于零(即没有错误),则将x0的符号位取反(如果原来是负则变正)
b.hi __set_errno_internal // 如果上面的比较结果大于或等于零(即发生了错误),则跳转到__set_errno_internal进行错误处理
ret // 从函数返回,继续执行调用者代码
END(my_openat) // 标记函数结束

这里没有自实现 strstr,所以可以通过 hook strstr 来进行绕过,但是如果要重定向 maps 就不行了,我们需要对 SVC 进行 hook,思路为先判断架构时 arm 还是 arm64,然后根据不同架构找到对应的 SVC 指令的 opcode,并找到对应的系统调用号,接下来在 data\app 路径下找所有的 so 文件,并找到 so 库中的 SVC opcode,后面的立即数就是系统调用号,和目标系统调用号进行比对,如果一样就说明目标函数被调用,看起来还是相当复杂的。

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
66
67
68
69
70
71
72
73
74
75
76
77
78
79
function anti_svc(){
let target_code_hex; // 用于搜索特定汇编指令序列的十六进制字符串
let call_number_openat; // 系统调用号对应的数值,openat
let arch = Process.arch; // 获取当前进程的架构

if ("arm" === arch){ // 如果架构是ARM
target_code_hex = "00 00 00 EF"; // ARM架构下svc指令的十六进制表示
call_number_openat = 322; // openat在ARM架构中的系统调用号
}else if("arm64" === arch){ // 如果架构是ARM64
target_code_hex = "01 00 00 D4"; // ARM64架构下svc指令的十六进制表示
call_number_openat = 56; // openat在ARM64架构中的系统调用号
}else {
console.log("arch not support!"); // 如果架构不支持,打印错误信息
}

if (arch){ // 如果成功获取了架构信息
console.log("\nthe_arch = " + arch); // 打印当前架构
// 枚举进程的内存范围,寻找只读内存段
Process.enumerateRanges('r--').forEach(function (range) {
if(!range.file || !range.file.path){ // 如果内存段没有文件路径,跳过
return;
}
let path = range.file.path; // 获取内存段的文件路径
// 如果文件路径不是以"/data/app/"开头或不以".so"结尾,跳过
if ((!path.startsWith("/data/app/")) || (!path.endsWith(".so"))){
return;
}
let baseAddress = Module.getBaseAddress(path); // 获取so库的基址
let soNameList = path.split("/"); // 通过路径分割获取so库的名称
let soName = soNameList[soNameList.length - 1]; // 获取so库的名称
console.log("\npath = " + path + " , baseAddress = " + baseAddress +
" , rangeAddress = " + range.base + " , size = " + range.size);
// 在so库的内存范围内搜索target_code_hex对应的指令序列
Memory.scan(range.base, range.size, target_code_hex, {
onMatch: function (match){
let code_address = match; // 获取匹配到的指令地址
let code_address_str = code_address.toString(); // 转换为字符串
// 如果地址的最低位是0, 4, 8, c中的任意一个,说明可能是svc指令
if (code_address_str.endsWith("0") || code_address_str.endsWith("4") ||
code_address_str.endsWith("8") || code_address_str.endsWith("c")){
console.log("--------------------------");
let call_number = 0; // 初始化系统调用号
if ("arm" === arch){
// 获取svc指令后面的立即数,作为系统调用号
call_number = (code_address.sub(0x4).readS32()) & 0xFFF;
}else if("arm64" === arch){
call_number = (code_address.sub(0x4).readS32() >> 5) & 0xFFFF;
}else {
console.log("the arch get call_number not support!"); // 如果架构不支持,打印错误信息
}
console.log("find svc : so_name = " + soName + " , address = " + code_address +
" , call_number = " + call_number + " , offset = " + code_address.sub(baseAddress));
// 如果匹配到的系统调用号是openat,挂钩该地址
if (call_number_openat === call_number){
let target_hook_addr = code_address;
let target_hook_addr_offset = target_hook_addr.sub(baseAddress);
console.log("find svc openat , start inlinehook by frida!");
Interceptor.attach(target_hook_addr, {
onEnter: function (args){ // 当进入挂钩函数时
console.log("\nonEnter_" + target_hook_addr_offset + " , __NR_openat , args[1] = " +
args[1].readCString());
// 修改openat的第一个参数为指定路径
this.new_addr = Memory.allocUtf8String("/data/user/0/com.zj.wuaipojie/maps");
args[1] = this.new_addr;
console.log("onEnter_" + target_hook_addr_offset + " , __NR_openat , args[1] = " +
args[1].readCString());
},
onLeave: function (retval){ // 当离开挂钩函数时
console.log("onLeave_" + target_hook_addr_offset + " , __NR_openat , retval = " + retval)
}
});
}
}
},
onComplete: function () {} // 搜索完成后的回调函数
});
});
}
}

自定义 strstr

其实这个绕过更简单一些,因为是自实现的只要找到这个函数直接 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
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
bool anti_str_maps() {
// 定义一个足够大的字符数组line,用于存储读取的行
char line[512];
// 打开当前进程的内存映射文件/proc/self/maps进行读取
FILE* fp = fopen("/proc/self/maps", "r");
if (fp) {
// 如果文件成功打开,循环读取每一行
while (fgets(line, sizeof(line), fp)) {
// 使用自定义strstr函数检查当前行是否包含"frida"指纹
if (my_strstr(line, "frida") || my_strstr(line, "gadget")) {
// 如果找到了,关闭文件并返回true,表示检测到了恶意库
fclose(fp);
return true;
}
}
// 遍历完文件后,关闭文件
fclose(fp);
} else {
// 如果无法打开文件,记录错误。这可能意味着系统状态异常
// 注意:这里的代码没有处理错误,只是注释说明了可能的情况
}
// 如果没有在内存映射文件中找到"frida",返回false,表示没有检测到恶意库
return false;
}

//自实现了libc里的几个系统函数
__attribute__((always_inline))
static inline char *
my_strstr(const char *s, const char *find)
{
char c, sc;
size_t len;

if ((c = *find++) != '\0') {
len = my_strlen(find);
do {
do {
if ((sc = *s++) == '\0')
return (NULL);
} while (sc != c);
} while (my_strncmp(s, find, len) != 0);
s--;
}
return ((char *)s);
}

frida 持久化

分为非root方案,root方案,源码定制方案,这里略写了。

非root方案主要为:

  1. Frida的Gadget,用于免root注入hook脚本,Gadget | Frida • A world-class dynamic instrumentation toolkit
  2. 基于obejction的patchapk功能,Patching Android Applications · sensepost/objection Wiki (github.com)

root 方案:

  1. 可以patch /data/app/pkgname/lib/arm64(or arm)目录下的so文件,apk安装后会将so文件解压到该目录并在运行时加载,修改该目录下的文件不会触发签名校验。游戏安全实验室 游戏漏洞 外挂分析 (qq.com)
  2. 基于magisk模块方案注入frida-gadget,实现加载和hook。hanbinglengyue/FridaManager: Frida持久化解决方案 (github.com)
  3. 基于jshook封装好的fridainject框架实现hook。https://github.com/Xposed-Modules-Repo/me.jsonet.jshook

源码定制方案:

原理:修改aosp源代码,在fork子进程的时候注入frida-gadget

深海游弋的鱼 – 默默的点滴 (mobibrw.com)

AOSP Android 10内置FridaGadget实践01 - 『移动安全区』 - 吾爱破解 - LCG - LSG |安卓破解|病毒分析|www.52pojie.cn

抓包

前置知识

详见:计算机网络,现代密码学等,此处略过

常见工具

应用图标 工具名称 类型 简介
Charles Charles 代理抓包工具 Charles 是一个HTTP代理/HTTP监视器/反向代理,它允许开发人员查看所有的HTTP和SSL/HTTPS流量。
Fiddler Fiddler 代理抓包工具 Fiddler 是一个Web调试代理,能够记录和检查从任何浏览器和客户端到服务器的所有HTTP流量。
burp Burp Suite 代理抓包工具(理论上应该叫渗透必备工具) Burp Suite 是用于攻击web 应用程序的集成平台,包含了许多工具。Burp Suite为这些工具设计了许多接口,以加快攻击应用程序的过程。所有工具都共享一个请求,并能处理对应的HTTP 消息、持久性、认证、代理、日志、警报。
Reqable Reqable 代理抓包工具 Reqable = Fiddler + Charles + Postman
Reqable拥有极简的设计、丰富的功能、高效的性能和桌面手机双端平台。
ProxyPin ProxyPin VPN抓包工具 开源免费抓包工具,支持Windows、Mac、Android、IOS、Linux 全平台系统
可以使用它来拦截、检查和重写HTTP(S)流量,ProxyPin基于Flutter开发,UI美观易用。
WireShark WireShark 网卡抓包工具 Wireshark是非常流行的网络封包分析软件,可以截取各种网络数据包,并显示数据包详细信息。常用于开发测试过程各种问题定位。
r0Capture r0Capture Hook抓包工具 安卓应用层抓包通杀脚本
tcpdump tcpdump 内核抓包工具 cpdump 是一个强大的命令行网络数据包分析工具,允许用户截获并分析网络上传输的数据包,支持多种协议,包括但不限于TCP、UDP、ICMP等。tcpdump基于libpcap库,该库提供了从网络接口直接访问原始数据包的能力。
ecapture eCapture(旁观者) 内核抓包工具 基于eBPF技术实现TLS加密的明文捕获,无需CA证书。
ptcpdump ptcpdump 内核抓包工具 基于 eBPF 的 tcpdump

Reqable 抓包工具

Reqable使用经典的中间人(MITM)技术分析HTTPS流量,当客户端与Reqable的代理服务器(下文简称中间人)进行通信时,中间人需要重签远程服务器的SSL证书。为了保证客户端与中间人成功进行SSL握手通信,需要将中间人的根证书(下文简称CA根证书)安装到客户端本地的证书管理中心。

此外,Reqable 无需配置Wifi代理,便可以将手机流量自动转发到桌面端进行分析和数据处理,解决移动端API调试的难题,提高终端研发效率。

我们可以在手机上下载 Reqable 软件,连接电脑以后,电脑端就会显示手机端的流量信息,注意检查这里的代理地址要在同一个网络环境下。

image-20240811175150896

连接成功以后,点击右上角的三个点就可以选择对目标 app 进行过滤

image-20240811175357018

然后开启监听即可。

Charles

Charles 抓包工具教程(连载)

免费版有使用期限,分享一个在线的激活码生成网站

Charles破解工具 (zzzmode.com)

原理

在这里插入图片描述

如上图所示:

  1. 客户端发送请求,客户端可以是安卓手机/ios手机/PC机上的浏览器等
  2. Charles 接收请求,再发送给服务器,这个步骤可以篡改请求内容, 比如请求体的内容,URL GET 方法?之后拼接的参数,Header 中的token,cookie 等
  3. 服务端把响应结果返回给Charles
  4. Charles 把响应结果再转发给客户端,这个步骤可以篡改响应内容

功能

  • 支持HTTP 和 HTTPS 代理
  • 支持流量控制,可以用来模拟弱网环境,设置2G、3G、4G等场景的网络环境
  • 支持断点调试
  • 支持MOCK
  • 支持接口请求并发

配置

  1. 电脑安装证书->安装到本地计算机->选择系统信任
  2. proxy->proxy_setting->端口号
  3. SSL proxy settings->Enable SSL->端口 *:443
  4. help->local IP address->找到wifi的ip地址->手机wifi(手机电脑连一个wifi)设置手动代理 ip+port
  5. 手机安装证书,先计算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 包含非致命问题的警告信息。