安卓动态分析

Published: 2023年04月03日

In Reverse.

ROOT与HOOK框架

提动态分析,HOOK/Instrument是最常见的手段,尽管存在多种不root也能hook的方案,但它不算主流,因此本文会首先聚焦当前主流的root和hook方案

远古

image-20250817155616055

还记得俺在初中时拥有的第一台安卓手机,那是可以直接下载一个app获取到root权限的,料想当时系统权限限制应该极弱,存在普遍的提权方式,现在俺也不打算考古了,从安卓系统权限来猜猜吧!安卓基于Linux,存在两种权限模型:

  1. DAC:即UGO拥有的RWX权限,提供用户/组级别的粗粒度限制
  2. MAC:即SELinux,提供细粒度(进程/文件/端口等)的权限控制,DAC里root的权限也被限制

在最开始android是没使用MAC的,因此提权会更简单,搞个有SUID的应用就行了,而在支持MAC后还需要再配置/修改SELinux策略

Xposed开发

这一块不再常用就先忽略,但是"万恶之源"XPosed及其后继在现在还是主流,因此需要好好研究一番,先从用户视角体验,还是用Android studio,新建一个项目

1.添加依赖,新版的gradle在settings.gradledependencyResolutionManagement->repositories里添加jcenter()仓库

2.在build.gradle(app)dependencies里添加compileOnly 'de.robv.android.xposed:api:82'

3.创建hook类,例如:

package com.example.lsposed_module_dd;

import static de.robv.android.xposed.XposedHelpers.findAndHookMethod;

import de.robv.android.xposed.IXposedHookLoadPackage;
import de.robv.android.xposed.XC_MethodHook;
import de.robv.android.xposed.XposedBridge;
import de.robv.android.xposed.callbacks.XC_LoadPackage;

public class DisableLonglink implements IXposedHookLoadPackage {

    @Override
    public void handleLoadPackage(XC_LoadPackage.LoadPackageParam lpparam) throws Throwable {
        XposedBridge.log("Loaded app: " + lpparam.packageName);
        if(!lpparam.packageName.contains("xunmeng")){
            XposedBridge.log("skip...");
            return;
        }
        findAndHookMethod("com.xunmeng.basiccomponent.titan.api.helper.ApiNetChannelSelector", lpparam.classLoader, "canUseLongLink", new XC_MethodHook() {
            @Override
            protected void afterHookedMethod(MethodHookParam param) throws Throwable {
                boolean res = (boolean) param.getResult();
                XposedBridge.log("call result:"+param.args[0]+res);
                param.setResult(false);
            }
        });
    }
}

4.在assets/xposed_init文件中添加hook入口类:

com.example.lsposed_module_dd.DisableLonglink

于是就能愉快的玩耍了...

注:它只提供Java层的hook哦~

xposed分析

现在咱从原理来看,翻开GitHub这个工具主要是由三部分组成的:

  1. XposedBridge:这是被注入到目标应用里的库文件
  2. Xposed:这是它能hook应用的核心部分,它修改了zygote来在应用启动前注入hook代码
  3. android_art: 不像openjdk等提供了丰富的插桩能力,ART虚拟机在这方面是严重受限的,它并没有提供运行时替换函数的能力,因此XPosed选择了修改源码的方式来提供最底层能力的支持,它对art的补丁就在这个项目下

现在从下往上分析吧~

android_art修补

它对art的修补分两部分,首先是dex2oat,这个工具是将dex预编译成了机器码,这个过程会有很多优化,例如方法内联,或者直接编码某些确定方法的入口,这会导致后续的hook失效,所以它先disable了这些特性;接着是最主要的,修改ArtMethod类,这是几乎所有安卓hook都会处理的类,该类表示Java的一个方法,每个Java方法在art里都有个对应的artmethod实例,它的结构一直在变化,这里随便贴个做说明:

 class ArtMethod {
 public:
     GcRoot<mirror::Class> declaring_class_; // 指向该方法所属的 Class 对象

     /**
      * - kAccPublic, kAccPrivate, kAccProtected (可见性)
      * - kAccStatic (是否为静态方法)
      * - kAccFinal (是否为 final 方法)
      * - kAccSynchronized (是否为同步方法)
      * - kAccNative (是否为 Native 方法)
      */
     uint32_t access_flags_; // 方法的访问标志,一个32位的 bitfield
     uint32_t dex_code_item_offset_;   // 指向DEX文件中 CodeItem 的偏移量
     uint32_t dex_method_index_; // 方法在所属DEX文件的方法定义列表中的索引值,这是方法在 DEX 文件中的唯一ID,用于在运行时快速查找和链接

     void* entry_point_from_jni_;   // JNI (Java Native Interface) 调用的入口点
     void* entry_point_from_quick_compiled_code_;  // AOT (Ahead-Of-Time) 编译后机器码的入口点

     //...
 };

这里面在hook中最重要的是最后的entry_point_...,在无优化的情况下,做方法调用就是先找到方法的artmethod再根据属性跳到对应的入口点,而hook通常就是修改这些入口点的指向来实现的,xposed通过新增ArtMethod::EnableXposedHook来修改它的:

 void ArtMethod::EnableXposedHook(ScopedObjectAccess& soa, jobject additional_info) {
   if (UNLIKELY(IsXposedHookedMethod())) { // 如果方法已经被标记为“已Hook”,则直接返回,避免重复操作
     return;
   }
   if (UNLIKELY(IsXposedOriginalMethod())) { // 如果方法是“原始方法备份”,这是一个异常情况,这在上一步就该返回了,到这说明有问题直接抛出异常
     ThrowIllegalArgumentException("Cannot hook the method backup");
     return;
   }

   auto* cl = Runtime::Current()->GetClassLinker(); // 获取 ClassLinker 和内存分配器,用于创建新的运行时方法对象
   auto* linear_alloc = cl->GetAllocatorForClassLoader(GetClassLoader());

   ArtMethod* backup_method = cl->CreateRuntimeMethod(linear_alloc); // 创建一个新的 ArtMethod 对象,作为原始方法的完整备份
   backup_method->CopyFrom(this, cl->GetImagePointerSize()); // 将当前方法(this)的所有内容完整地复制到备份对象中
   backup_method->SetAccessFlags(backup_method->GetAccessFlags() | kAccXposedOriginalMethod); // 给备份方法打上一个特殊的标记 kAccXposedOriginalMethod,以便将来识别


   mirror::AbstractMethod* reflected_method; // 让Java层的XposedBridge能够通过标准反射API调用到原始方法
   if (IsConstructor()) {
     reflected_method = mirror::Constructor::CreateFromArtMethod(soa.Self(), backup_method);
   } else {
     reflected_method = mirror::Method::CreateFromArtMethod(soa.Self(), backup_method);
   }
   reflected_method->SetAccessible<false>(true);  // 确保这个Java Method对象是可访问的

   XposedHookInfo* hook_info = reinterpret_cast<XposedHookInfo*>(linear_alloc->Alloc(soa.Self(), sizeof(XposedHookInfo))); // 这个结构体将保存所有与本次Hook相关的信息
   hook_info->reflected_method = soa.Vm()->AddGlobalRef(soa.Self(), reflected_method); // 将上一步创建的指向备份方法的Java Method对象存为JNI全局引用,防止被GC回收
   hook_info->additional_info = soa.Env()->NewGlobalRef(additional_info);  // 将Java层传来的 additional_info (包含回调)也存为JNI全局引用
   hook_info->original_method = backup_method; // 保存指向C++层备份方法(ArtMethod)的指针

   ScopedSuspendAll ssa(__FUNCTION__); // 暂停整个虚拟机,便于修改核心结构
   // ... 

   jit::Jit* jit = art::Runtime::Current()->GetJit();
   if (jit != nullptr) { // 通知JIT编译器,将原来与当前方法关联的已编译代码,转移给备份方法
     jit->GetCodeCache()->MoveObsoleteMethod(this, backup_method);
   }

   SetEntryPointFromJniPtrSize(reinterpret_inpret_cast<uint8_t*>(hook_info), sizeof(void*)); // 将 entry_point_from_jni_指向hook_info结构体,下面会去掉native标志,所以它可以安全的用来存其它数据

   SetEntryPointFromQuickCompiledCode(GetQuickProxyInvokeHandler()); // 将AOT编译代码的入口点,指向GetQuickProxyInvokeHandler

   SetCodeItemOffset(0); // 清空DEX字节码偏移量,因为这个方法已经是一个代理,不再直接对应DEX中的代码

   const uint32_t kRemoveFlags = kAccNative | kAccSynchronized; // 调整访问标志:移除 kAccNative, kAccSynchronized 等标志,因为这些将由Hook处理器来管理,添加 kAccXposedHookedMethod 标志,表明此方法已被Hook
   SetAccessFlags((GetAccessFlags() & ~kRemoveFlags) | kAccXposedHookedMethod);


   MutexLock mu(soa.Self(), *Locks::thread_list_lock_);
   Runtime::Current()->GetThreadList()->ForEach(StackReplaceMethodAndInstallInstrumentation, this); // 遍历所有正在运行的线程,检查它们的调用栈。如果某个线程正停在此方法内部,需要对其栈进行修复,以确保它能正确地从新的Hook逻辑中返回

   // Hook操作完成,函数结束时,scope退出会自动恢复虚拟机运行。
 }

注意到这里,它把入口点改成了GetQuickProxyInvokeHandler,它是段汇编,最后会指向artQuickProxyInvokeHandler,它也被修改啦:

 extern "C" uint64_t artQuickProxyInvokeHandler(
     ArtMethod* proxy_method, mirror::Object* receiver, Thread* self, ArtMethod** sp)
     SHARED_REQUIRES(Locks::mutator_lock_) {

   const bool is_xposed = proxy_method->IsXposedHookedMethod();  // 之前设置过 kAccXposedHookedMethod
   if (!is_xposed) {
     DCHECK(proxy_method->IsRealProxyMethod()) << PrettyMethod(proxy_method);
   }

   const char* old_cause = self->StartAssertNoThreadSuspension("Proxy argument processing"); // 暂时禁止线程挂起,确保能安全地处理栈上的原始对象指针,防止GC移动它们

   JNIEnvExt* env = self->GetJniEnv(); // 准备JNI环境,并创建一个新的局部引用作用域
   ScopedObjectAccessUnchecked soa(env);
   ScopedJniEnvLocalRefState env_state(env);

   const bool is_static = proxy_method->IsStatic(); // 为 receiver(this) 创建一个JNI局部引用。从现在起,使用这个安全的JNI引用(rcvr_jobj)来代替原始的指针(receiver),以避免GC问题
   jobject rcvr_jobj = is_static ? nullptr : soa.AddLocalReference<jobject>(receiver);

   ArtMethod* non_proxy_method = proxy_method->GetInterfaceMethodIfProxy(sizeof(void*));  // 获取原始方法的Shorty签名,它描述了参数和返回值的类型
   uint32_t shorty_len = 0;
   const char* shorty = non_proxy_method->GetShorty(&shorty_len);

   std::vector<jvalue> args;
   BuildQuickArgumentVisitor local_ref_visitor(sp, is_static, shorty, shorty_len, &soa, &args); // 创建一个参数访问器(Visitor),它将根据Shorty签名遍历调用栈(sp),将原始的、类型不明的参数值,转换成一个包含jvalue的vector,对于对象类型的参数,它会自动创建JNI局部引用
   local_ref_visitor.VisitArguments();

   if (!is_static) { // 如果是实例方法,参数列表的第一个元素是'this',我们已经有rcvr_jobj了,所以从vector中移除
     args.erase(args.begin());
   }

   self->EndAssertNoThreadSuspension(old_cause);// 恢复线程挂起,因为所有原始指针都已转换为安全的JNI引用

   if (is_xposed) {
     jmethodID proxy_methodid = soa.EncodeMethod(proxy_method);  // 将C++的ArtMethod指针编码为Java层可识别的jmethodID
     JValue result = InvokeXposedHandleHookedMethod(soa, shorty, rcvr_jobj, proxy_methodid, args);// 这个函数会从proxy_method中取出我们之前存入的hook_info,然后回调Java层的XposedBridge.handleHookedMethod,执行所有模块的hook逻辑。
     local_ref_visitor.FixupReferences(); // 返回前,修复可能被GC移动过的对象引用
     return result.GetJ(); // 将JValue格式的返回值转换为uint64_t并返回给调用者
   } else {
      //... 正常流程
   }
 }

上面可见,如果是代理方法就找到方法签名后调用InvokeXposedHandleHookedMethod,它的实现如下:

 JValue InvokeXposedHandleHookedMethod(ScopedObjectAccessAlreadyRunnable& soa, const char* shorty,
                                       jobject rcvr_jobj, jmethodID method,
                                       std::vector<jvalue>& args) {
   jobjectArray args_jobj = nullptr;// Java层的回调函数需要一个Object[]类型的参数数组
   const JValue zero; // 用于在发生错误时返回一个空值。

   if (args.size() > 0) {
     args_jobj = soa.Env()->NewObjectArray(args.size(), WellKnownClasses::java_lang_Object, nullptr);// 创建一个Object类型的Java数组
     for (size_t i = 0; i < args.size(); ++i) {// 遍历C++的参数向量,填充Java数组
       if (shorty[i + 1] == 'L') {
         jobject val = args.at(i).l;// 如果参数是对象类型('L'),直接放入数组
         soa.Env()->SetObjectArrayElement(args_jobj, i, val);
       } else {
         JValue jv;
         jv.SetJ(args.at(i).j);// 如果参数是基本类型(int, float等),则需要先“装箱”(Box)成对应的包装类(Integer, Float等)
         mirror::Object* val = BoxPrimitive(Primitive::GetType(shorty[i + 1]), jv);
         if (val == nullptr) {
           CHECK(soa.Self()->IsExceptionPending());
           return zero;
         }
         soa.Decode<mirror::ObjectArray<mirror::Object>* >(args_jobj)->Set<false>(i, val); // 将装箱后的对象放入数组
       }
     }
   }

   const XposedHookInfo* hook_info = soa.DecodeMethod(method)->GetXposedHookInfo(); // 从ArtMethod中取出我们之前在EnableXposedHook函数里存入的XposedHookInfo结构体指针 (GetEntryPointFromJniPtrSize)

   // 准备调用Java层 XposedBridge.handleHookedMethod 的5个参数
   // public static Object handleHookedMethod(Member method, int originalMethodId,
   //                                         Object additionalInfoObj, Object thisObject, Object[] args)
   jvalue invocation_args[5];
   invocation_args[0].l = hook_info->reflected_method; // 参数1: 指向原始方法的Java Method对象
   invocation_args[1].i = 1;                            // 参数2: 原始方法ID (此处似乎为固定值或历史遗留)
   invocation_args[2].l = hook_info->additional_info;   // 参数3: 包含所有回调信息的对象
   invocation_args[3].l = rcvr_jobj;                    // 参数4: this对象
   invocation_args[4].l = args_jobj;                    // 参数5: 包含所有参数的Object[]数组

   // 通过JNI调用Java层的静态方法 XposedBridge.handleHookedMethod。
   // ArtMethod::xposed_callback_class 和 ArtMethod::xposed_callback_method 是预先缓存好的类和方法ID。
   // 执行流从这里进入Java世界,执行所有模块的before/after回调逻辑
   jobject result =
       soa.Env()->CallStaticObjectMethodA(ArtMethod::xposed_callback_class,
                                          ArtMethod::xposed_callback_method,    // 下文会看到,叫handleHookedMethod
                                          invocation_args);

   if (UNLIKELY(soa.Self()->IsExceptionPending())) { // 处理Java层返回的结果,并返回给原始调用者
     return zero;  // 如果Java层抛出了异常,直接返回空值
   } else {
     if (shorty[0] == 'V' || (shorty[0] == 'L' && result == nullptr)) {// 如果原始方法返回类型是void('V'),或者返回的是null对象,直接返回空值
       return zero;
     }

     mirror::Class* result_type = soa.DecodeMethod(method)->GetReturnType(true, pointer_size); // 如果Java层返回的是一个包装类对象(如Integer),而原始方法需要的是基本类型(int),则需要“拆箱”(Unbox)操作
     mirror::Object* result_ref = soa.Decode<mirror::Object*>(result);
     JValue result_unboxed;
     if (!UnboxPrimitiveForResult(result_ref, result_type, &result_unboxed)) {
       DCHECK(soa.Self()->IsExceptionPending()); // 拆箱失败
       return zero;
     }
     return result_unboxed; // 返回最终处理好的、类型正确的值
   }
 }

至此,就分析完了art里java方法的hook安装和调用的全流程,从最后看到它是把原始方法id,装箱后的参数等一起打包成数组作为新的参数,去调用到了java层的ArtMethod::xposed_callback_class->ArtMethod::xposed_callback_method,接下来就是看它咯~

附:对art method的深入了解请参考ART的函数运行机制

Xposed

它会替换安卓自带的app_process,回到安卓启动过程,几乎所有Java应用都是从孵化器启动的,孵化器就是app_process出来的,它分为32和64两个版本,在xposed源码里根据sdk版本是否不大于21选择app_main.cpp/app_main2.cpp,咱直接从2开始看吧:

 int main(int argc, char* const argv[])
 {
     if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) < 0) { // 一个标准的Linux安全措施,禁止进程获取新的权限。
         // ...
     }
     if (xposed::handleOptions(argc, argv)) {   // 调用Xposed自己的选项处理器,比如读版本属性,是否安全模式直接退出,处理参数块等
         return 0;
     }

     AppRuntime runtime(argv[0], computeArgBlockSize(argc, argv)); // 创建一个 AppRuntime 实例,这是 Android C++ 运行时库的核心,用于配置和启动ART虚拟机

     // 解析参数,识别运行模式等...

     if (zygote) {
         isXposedLoaded = xposed::initialize(true, startSystemServer, NULL, argc, argv);  // 初始化
         runtimeStart(runtime, isXposedLoaded ? XPOSED_CLASS_DOTS_ZYGOTE : "com.android.internal.os.ZygoteInit", args, zygote); // 这里把主类替换了
     } else if (className) {
         isXposedLoaded = xposed::initialize(false, false, className, argc, argv); // 直接启动也可hook
         runtimeStart(runtime, isXposedLoaded ? XPOSED_CLASS_DOTS_TOOLS : "com.android.internal.os.RuntimeInit", args, zygote);
     } else {
         // 错误处理:没有提供类名也没有指定--zygote...
         return 10;
     }
 }

这里有两个点,先看xposed::initialize,它主要是启动一些服务(主要是过selinux的)和将xposedbridge.jar加入到库路径:

 bool initialize(bool zygote, bool startSystemServer, const char* className, int argc, char* const argv[]) {
 #if !defined(XPOSED_ENABLE_FOR_TOOLS)
     if (!zygote) // 如果Xposed没有被编译为支持命令行工具,并且当前也不是Zygote模式,则直接禁用。
         return false;
 #endif

     if (isMinimalFramework()) {// 如果设备处于一个“最小化框架”状态(通常发生在加密设备输入密码之前),则不加载Xposed,后续再进入到完整模式会再次初始化,那时才有必要hook
         ALOGI("Not loading Xposed for minimal framework (encrypted device)");
         return false;
     }

     xposed->zygote = zygote; // 将当前的运行模式信息存入一个全局的xposed结构体中,供其他模块使用。
     xposed->startSystemServer = startSystemServer;
     xposed->startClassName = className;
     xposed->xposedVersionInt = xposedVersionInt;

 #if XPOSED_WITH_SELINUX
     xposed->isSELinuxEnabled   = is_selinux_enabled() == 1; // 检查SELinux的状态。在现代Android上,Xposed需要与SELinux策略协同工作
     xposed->isSELinuxEnforcing = xposed->isSELinuxEnabled && security_getenforce() == 1;
 #else
     xposed->isSELinuxEnabled   = false;
     xposed->isSELinuxEnforcing = false;
 #endif

     if (startSystemServer) {
         xposed::logcat::printStartupMarker(); // 如果是即将启动System Server的Zygote进程,打印一个启动标记到logcat,便于调试
     } else if (zygote) {
         sleep(10);// 如果是次要的Zygote进程(例如64位系统上的32位Zygote),则等待10秒,这是为了让主要的Zygote进程先启动,同时也避免两个Zygote的日志混杂在一起
     }

     printRomInfo();// 打印当前ROM的一些信息,便于问题排查
     if (startSystemServer) {
         if (!determineXposedInstallerUidGid() || !xposed::service::startAll()) {// 如果是System Server,需要启动完整的Xposed服务,用于和Xposed Installer通信
             return false;
         }
         xposed::logcat::start();// 启动logcat的监控,将日志转发给Xposed Installer
     }
     // ... 其他SELinux相关的服务启动逻辑 ...

     if (zygote && !isSafemodeDisabled() && detectSafemodeTrigger(shouldSkipSafemodeDelay()))// 检查用户是否在开机时按下了特定按键组合来触发“安全模式”,这是一个非常重要的保险丝机制
         disableXposed();  // 如果检测到安全模式触发,则临时禁用Xposed,防止因模块问题导致无法开机

     if (isDisabled() || (!zygote && shouldIgnoreCommand(argc, argv))) // 如果Xposed被(临时或永久)禁用,或者当前是工具模式且该命令被设定为忽略,则返回false
         return false;

     return addJarToClasspath(); // 这个函数会找到 XposedBridge.jar 文件,并将其路径添加到 CLASSPATH 环境变量中
 }

再来看它修改了AppRuntimeonVmCreated

class AppRuntime : public AndroidRuntime
{
public:
    virtual void onVmCreated(JNIEnv* env)
    {
        if (isXposedLoaded)
            xposed::onVmCreated(env);   // 上一步成功,在runtime启动时会到这
    }}
/////////
void onVmCreated(JNIEnv* env) {
    const char* xposedLibPath = NULL;
    determineRuntime(&xposedLibPath)    // 根据/proc/self/maps看当前加载的运行时是delvik还是art
    void* xposedLibHandle = dlopen(xposedLibPath, RTLD_NOW); // 加载对应的运行时 libxposed_*.so

    bool (*xposedInitLib)(XposedShared* shared) = NULL;
    *(void **) (&xposedInitLib) = dlsym(xposedLibHandle, "xposedInitLib");

#if XPOSED_WITH_SELINUX
    xposed->zygoteservice_accessFile = &service::membased::accessFile;
    xposed->zygoteservice_statFile   = &service::membased::statFile;
    xposed->zygoteservice_readFile   = &service::membased::readFile;
#endif  // XPOSED_WITH_SELINUX

    if (xposedInitLib(xposed)) { // 保存shared 设置onVmCreated = &onVmCreatedCommon
        xposed->onVmCreated(env);   // 进入对应的库,这里咱们只关注libxposed_art.cpp + libxposed_common.cpp
    }
}

继续深入:

void onVmCreatedCommon(JNIEnv* env) {
    !initXposedBridge(env) || !initZygoteService(env)) ;// 初始化这两个,它们做的事差不多,只是导出的方法有差别,因为权限不同角色不通

    onVmCreated(env);
    xposedLoadedSuccessfully = true;
    return;
}

bool initXposedBridge(JNIEnv* env) {
    classXposedBridge = env->FindClass(CLASS_XPOSED_BRIDGE);    // 查找de/robv/android/xposed/XposedBridge类,就是xposedbridge.jar里的主要类
    classXposedBridge = reinterpret_cast<jclass>(env->NewGlobalRef(classXposedBridge));
    register_natives_XposedBridge(env, classXposedBridge)   // 为该类注册大量工具方法

    methodXposedBridgeHandleHookedMethod = env->GetStaticMethodID(classXposedBridge, "handleHookedMethod",
        "(Ljava/lang/reflect/Member;ILjava/lang/Object;Ljava/lang/Object;[Ljava/lang/Object;)Ljava/lang/Object;");  // 获取 handleHookedMethod
}

bool onVmCreated(JNIEnv*) {
    ArtMethod::xposed_callback_class = classXposedBridge;   // 熟悉的,绑定类和方法咯!
    ArtMethod::xposed_callback_method = methodXposedBridgeHandleHookedMethod;
    return true;
}

总的来说,它就是劫持了启动过程,注入了xposedbridge.jar库,并对其进行初始化,注入native的代码,为了应对selinux它还启动了一些其它服务,例如文件读取它实现了共享内存和binder两种方式...到此,它已经提供了基本的hook能力了

XposedBridge

终于到你咯!但其实也没啥好说的了,它提供很多封装便于咱们使用,代码用脚都能想清楚,这里只看最核心的两个,首先是hook,无论用什么函数最后都会调用到hookMethod

 public static XC_MethodHook.Unhook hookMethod(Member hookMethod, XC_MethodHook callback) {

     if (!(hookMethod instanceof Method) && !(hookMethod instanceof Constructor<?>)) {// 确保传入的 hookMethod 是一个方法(Method)或构造函数(Constructor)
         throw new IllegalArgumentException("Only methods and constructors can be hooked: " + hookMethod.toString());
     } else if (hookMethod.getDeclaringClass().isInterface()) {// 确保不是一个接口方法
         throw new IllegalArgumentException("Cannot hook interfaces: " + hookMethod.toString());
     } else if (Modifier.isAbstract(hookMethod.getModifiers())) { // 确保不是一个抽象方法,因为抽象方法没有实现体可以Hook
         throw new IllegalArgumentException("Cannot hook abstract methods: " + hookMethod.toString());
     }

     boolean newMethod = false; // 是否是此方法第一次被Hook
     CopyOnWriteSortedSet<XC_MethodHook> callbacks;

     // 必须同步操作,因为多个模块可能同时Hook同一个方法。
     synchronized (sHookedMethodCallbacks) { // `sHookedMethodCallbacks` 是一个全局的静态Map,存储了从“被Hook的方法”到“其所有回调函数集合”的映射
         callbacks = sHookedMethodCallbacks.get(hookMethod); // 尝试获取该方法已有的回调集合
         if (callbacks == null) { // 如果为null,说明这是此方法第一次被Hook
             callbacks = new CopyOnWriteSortedSet<>();
             sHookedMethodCallbacks.put(hookMethod, callbacks);
             newMethod = true; // 设置标志,表示稍后需要执行Native Hook
         }
     }

     callbacks.add(callback); // 将新的回调添加到集合中

     if (newMethod) { // 只有当一个方法是第一次被Hook时,才需要调用到底层C++代码去修改ArtMethod结构 后续对同一个方法的Hook请求,只需要向上面的`callbacks`集合中添加新的回调即可
         Class<?> declaringClass = hookMethod.getDeclaringClass();

         int slot = 0;// 在ART上,Native层可以从Member对象中获取所有需要的信息,这位兼容旧的Dalvik,在它上面,需要通过反射获取一些额外信息(如slot ID)。
         Class<?>[] parameterTypes = null;
         Class<?> returnType = null;
         // ... 兼容旧版Dalvik运行时的代码...

         AdditionalHookInfo additionalInfo = new AdditionalHookInfo(callbacks, parameterTypes, returnType); // 创建一个附加信息对象,它将包含回调集合等信息,并被传递给Native层
         hookMethodNative(hookMethod, declaringClass, slot, additionalInfo);  // 这个JNI调用最终会执行我们之前分析过的 ArtMethod::EnableXposedHook 函数
     }

     return callback.new Unhook(hookMethod); // 创建并返回一个 Unhook 对象,开发者可以保存它,并在需要时调用其 unhook() 方法,以将本次添加的这个callback从回调集合中移除
 }

而在被hook的方法被调用时,最终又会调用到Java里的handleHookedMethod

 private static Object handleHookedMethod(Member method, int originalMethodId, Object additionalInfoObj,
                                          Object thisObject, Object[] args) throws Throwable {
     AdditionalHookInfo additionalInfo = (AdditionalHookInfo) additionalInfoObj;

     if (disableHooks) { // 如果Xposed被临时禁用,则直接调用原始方法,跳过所有Hook逻辑
         try {
             return invokeOriginalMethodNative(method, originalMethodId, additionalInfo.parameterTypes,
                     additionalInfo.returnType, thisObject, args);
         } catch (InvocationTargetException e) {
             throw e.getCause();
         }
     }

     Object[] callbacksSnapshot = additionalInfo.callbacks.getSnapshot(); // 获取当前所有回调的快照。使用快照可以保证线程安全,即使在遍历时有其他模块注册/注销Hook,本次执行的回调列表也不会改变
     final int callbacksLength = callbacksSnapshot.length;
     if (callbacksLength == 0) { // 如果没有任何回调(例如最后一个回调刚刚被注销),也直接调用原始方法
         try {
             return invokeOriginalMethodNative(method, originalMethodId, additionalInfo.parameterTypes,
                     additionalInfo.returnType, thisObject, args);
         } catch (InvocationTargetException e) {
             throw e.getCause();
         }
     }

     MethodHookParam param = new MethodHookParam(); // 创建一个MethodHookParam对象,它是一个“状态载体”,会在所有回调之间传递。模块通过修改这个对象来影响方法的执行
     param.method = method;
     param.thisObject = thisObject;
     param.args = args;

     int beforeIdx = 0;
     do {
         try {
             ((XC_MethodHook) callbacksSnapshot[beforeIdx]).beforeHookedMethod(param); // 按顺序执行所有 beforeHookedMethod 回调
         } catch (Throwable t) {
             XposedBridge.log(t); // 如果某个回调抛出异常,记录日志,但不会中断整个流程
             param.setResult(null); // 重置结果,忽略异常回调所做的修改
             param.returnEarly = false;
             continue;
         }

         if (param.returnEarly) { // 模块可以在 before 回调中设置 param.returnEarly = true。这会使程序跳过原始方法和所有后续的 before/after 回调,直接返回一个指定的结果
             beforeIdx++; // 移动索引,以便 after 回调能从正确的位置开始
             break;
         }
     } while (++beforeIdx < callbacksLength);

     if (!param.returnEarly) { // 如果没有被 "before" 回调提前返回,则执行原始方法
         try {
             Object result = invokeOriginalMethodNative(method, originalMethodId,
                     additionalInfo.parameterTypes, additionalInfo.returnType, param.thisObject, param.args); // 通过JNI调用,执行原始的、未被Hook的方法代码
             param.setResult(result); // 将原始方法的返回值存入param对象
         } catch (InvocationTargetException e) {
             param.setThrowable(e.getCause()); // 如果原始方法抛出异常,捕获它并存入param对象
         }
     }

     int afterIdx = beforeIdx - 1;
     do { // 从最后一个被执行的 "before" 回调开始,以相反的顺序执行 "after" 回调。这种 LIFO (后进先出) 的顺序确保了回调的“洋葱模型”,即 a.before -> b.before -> original -> b.after -> a.after
         Object lastResult =  param.getResult(); 
         Throwable lastThrowable = param.getThrowable();

         try {
             ((XC_MethodHook) callbacksSnapshot[afterIdx]).afterHookedMethod(param);
         } catch (Throwable t) {
             XposedBridge.log(t);
             if (lastThrowable == null) // 如果 after 回调异常,恢复到它执行前的结果,避免污染
                 param.setResult(lastResult);
             else
                 param.setThrowable(lastThrowable);
         }
     } while (--afterIdx >= 0);

     if (param.hasThrowable()) { // 在所有流程结束后,检查param对象的状态
         throw param.getThrowable(); // 如果param中存有异常,则将其抛出
     } else {
         return param.getResult(); // 否则,返回最终的结果(可能已被after回调修改过)
     }
 }

结尾,xposed YYDS,但是直接改art并不优雅,也早早没更新了,它有很多后续,如:

EdXposed 支持Android 8~11

LSPosed 支持Android 8.1~13 (安装后默认从通知栏打开界面)

下面会继续介绍后者~

Magisk

magisk是流行的root框架,相比远古直接改/system分区,它更提前了,直接修改/init来实现控制流的劫持,于是在初始化阶段就能以特权身份启动自己的服务,对se策略做修补等;它还提供了一个包含zygisk的模块系统,它可以在系统启动早期以特权身份做很多事,包括执行脚本、替换文件等,利用zygisk还可以向app中注入额外逻辑,比如类似xposed的功能,前面也提到xposed停止维护了,取而代之的是lspoed,它可以利用zygisk将自身注入目标应用,实现Java级hook (兼容xposed模块),而lsposed底层是lsplant,所以接下来会分别介绍它们!

Magisk分析

它有多个部分:

1.magiskinit:它会被用于替换/init,在执行必要的分区挂载后,它就将magisk服务注入到init.rc中,并且对selinux 策略进行了修补,使其服务能以特权运行

2.magiskpolicy:它可以从不同来源加载SELinux策略,将其保存到文件或直接应用到内核,另外它还会被取别名为supolicy保持兼容

3.magisk:它是一个多功能的二进制文件,类似于busybox可由不同别名启动来实现相关功能,例如su启动就是做提权,resetprop就是直接读些属性(不经过系统原有机制,特权且过检查),它还能作为守护进程提供服务、管理模块等,有很多功能

4.magiskboot:它用于对启动镜像重打包,主要就是将magiskinit替换原始init,并将一些必要的组件(核心magisk.xz存根 stub.xz和接触se策略的 init-ld.xz)打包进boot镜像

模块系统

magisk可安装模块,模块是一个zip文件,会被安装在/data/adb/modules下,模块的典型结构如下:

/data/adb/modules
├── $MODID                  <--- 模块ID,每个模块的ID应该独一无二
   ├── module.prop         <--- 模块元信息:ID,名称,版本,作者,描述,升级URL等
   ├── system              <--- 该目录下的内容将会覆盖到 /system
   ├── zygisk              <--- Zygisk本地库,以架构命名,会根据当前架构注入对应的so
      ├── arm64-v8a.so
      ├── armeabi-v7a.so
      ├── riscv64.so
      ├── x86.so
      ├── x86_64.so
      └── unloaded        <--- 没有兼容库
   ├── skip_mount          <--- 存在则不挂载system
   ├── disable             <--- 存在则模块被禁用
   ├── remove              <--- 存在则下次重启会删除当前模块
   ├── post-fs-data.sh     <--- post-fs-data阶段执行的脚本,这是只是/data被挂载,还很早起,zygote都没初始化,阻塞的
   ├── service.sh          <--- late_start service阶段执行的脚本,非阻塞,通常都是在这个阶段运行
   ├── uninstall.sh        <--- 模块被删除时执行的脚本
   ├── action.sh           <--- Magisk.app里模块action按钮被点击时执行
   ├── system.prop         <--- 会被合并到系统属性
   ├── sepolicy.rule       <--- 自定义的SE策略
   ├── vendor              <--- Symlink to $MODID/system/vendor
   ├── product             <--- Symlink to $MODID/system/product
   ├── system_ext          <--- Symlink to $MODID/system/system_ext

像改/system时有些额外规则,如默认覆盖/新增,而创建.replace则替换整个目录,而mknod <path> c 0 0可删除文件。

在系统启动过程中(post-fs-data阶段),magiskd会扫描模块目录,并根据每个目录里相应的标志决定是否加载模块还是干其它的。

zygisk

这里再看看zygisk,看名字就知道它是magisk for zygote的了,不像xposed直接改app_process,它利用了native bridge机制去注入so劫持zygote fork过程,从而实现任意库注入的,咱先看看它的模块长啥样,直接上样例

class MyModule : public zygisk::ModuleBase {
public:
    void onLoad(Api *api, JNIEnv *env) override {   // 模块被加载时
        this->api = api;
        this->env = env;
    }

    virtual void preAppSpecialize([[maybe_unused]] AppSpecializeArgs *args) {}// app 特化前执行,此时还没沙盒限制(和zygote一样权限),这里可以修改特化参数

    virtual void postAppSpecialize([[maybe_unused]] const AppSpecializeArgs *args) {} // app特化后马上执行

    virtual void preServerSpecialize([[maybe_unused]] ServerSpecializeArgs *args) {} // 对SystemServer的
    virtual void postServerSpecialize([[maybe_unused]] const ServerSpecializeArgs *args) {} 

};

static int urandom = -1;

static void companion_handler(int i) {
    if (urandom < 0) {        urandom = open("/dev/urandom", O_RDONLY);   }
    read(urandom, &r, sizeof(r));       // 它会在特权进程(根据当前进程决定是zygiskd32/64)里运行
    write(i, &r, sizeof(r));
}

REGISTER_ZYGISK_MODULE(MyModule)        // 注册模块
REGISTER_ZYGISK_COMPANION(companion_handler)    // 注册伙伴处理器

可以看出zygisk提供了接口让模块在app/system_server在特化前后执行相关操作,如注入指定so库等的能力,另外zygote本身不是root权限,所以它还提供了root权限运行的zygiskd服务,让so同时在该服务内运行,从而为模块提供高权限的能力。现在从源码开始分析具体的实现,前面提了它是劫持了native bridge,具体来说它在模块加载阶段,就看是否启用zygisk,启用则将ro.dalvik.vm.native.bridge属性备份并重新设置为zygisk的so,该so导出如下:

extern "C" [[maybe_unused]] NativeBridgeCallbacks NativeBridgeItf {     // native bridge的标准导出结构
    .version = 2,
    .padding = {},
    .isCompatibleWith = [](auto) {  // 会被最先执行,看是否能处理,它永远返回false所以不会影响程序执行
        zygisk_logging();
        hook_entry();   // 执行hook操作
        return false;
    },
};

这个hook_entry被用于hook一些点,注意此时art还是在初始化阶段,很多东西还美完成初始化/装载所以它无法在此时直接hook关键点,退而求其次它很聪明的hook了一些其它点,当这些点满足条件时就是合适的时机点咯:

void hook_entry() {
    default_new(g_hook);    // 为HookContext分配内存,它里面保存了plt的备份,NativeBridgeRuntimeCallbacks等信息
    g_hook->hook_plt();
}

 void HookContext::hook_plt() {
     ino_t android_runtime_inode = 0;
     dev_t android_runtime_dev = 0;
     ino_t native_bridge_inode = 0;
     dev_t native_bridge_dev = 0;

     for (auto &map : lsplt::MapInfo::Scan()) { // lsplt::MapInfo::Scan() 会扫描当前进程的内存映射(/proc/self/maps),找到所有已加载的so库
         if (map.path.ends_with("/libandroid_runtime.so")) { // 定位 libandroid_runtime.so,这是Android Java框架与Native世界的桥梁
             android_runtime_inode = map.inode;
             android_runtime_dev = map.dev;
         } else if (map.path.ends_with("/libnativebridge.so")) { // 定位 libnativebridge.so,用于支持跨CPU架构的应用(如在x86上运行ARM应用)
             native_bridge_inode = map.inode;
             native_bridge_dev = map.dev;
         }
     }

      // 下面都是宏定义
     PLT_HOOK_REGISTER(native_bridge_dev, native_bridge_inode, dlclose); // 系统在加载完Native Bridge(一种跨CPU架构的兼容层)后,会尝试通过dlclose卸载Zygisk的加载器(zygisk_loader),这是LoadNativeBridge流程的最后一步,它会在此时调用g_hook->post_native_bridge_load(handle)

     PLT_HOOK_REGISTER(android_runtime_dev, android_runtime_inode, fork); // Zygisk预先fork一个进程,将新进程的PID保存在g_ctx->pid中,然后当系统请求fork时,直接返回这个已经创建好的PID,而不是调用真正的old_fork() ((g_ctx && g_ctx->pid >= 0) ? g_ctx->pid : old_fork())

     PLT_HOOK_REGISTER(android_runtime_dev, android_runtime_inode, unshare); // 当一个新进程被创建后,系统会调用unshare(CLONE_NEWNS)来为这个进程创建一个独立的挂载命名空间(Mount Namespace),使它看不到系统或其他App的挂载点,当unshare被调用时,如果DO_REVERT_UNMOUNT被设置(进程在拒绝列表/zygisk请求设置拒绝),它会调用revert_unmount,这会在新的ns里写在magisk的特殊挂载点,可用于一定程度的痕迹隐藏 (只是卸载了挂载点,其它事还在生效)

     PLT_HOOK_REGISTER(android_runtime_dev, android_runtime_inode, selinux_android_setcontext); // 在降权前保存日志fd,保证写日志正常

     PLT_HOOK_REGISTER(android_runtime_dev, android_runtime_inode, strdup); // 当new_strdup的参数str为"com.android.internal.os.ZygoteInit"时触发,这发生在Android运行时准备调用Zygote的Java主类(ZygoteInit.main)之前,此时runtime已经完成初始化,它会在此调用g_hook->hook_zygote_jni()

     PLT_HOOK_REGISTER_SYM(android_runtime_dev, android_runtime_inode, "__android_log_close", android_log_close);  // 根据SKIP_CLOSE_LOG_PIPE决定是否要关闭日志


     if (!lsplt::CommitHook()) // 在注册完所有Hook后,CommitHook() 会实际地去修改内存中目标库的GOT表,完成地址替换
         ZLOGE("plt_hook failed\n");

     plt_backup.erase(
             std::remove_if(plt_backup.begin(), plt_backup.end(),
             [](auto &t) { return *std::get<3>(t) == nullptr;}),
             plt_backup.end()); // plt_backup 是一个保存了原始函数地址的备份列表,这段代码会遍历备份列表,移除那些由于某种原因未能成功Hook的条目,保持数据一致性
 }

先看post_native_bridge_load,它在我们伪造的native bridge(zygisk loader)被卸载时调用,此时它就可以通过栈回溯找到ART内部的NativeBridgeRuntimeCallbacks

 void HookContext::post_native_bridge_load(void *handle) {
     self_handle = handle; // 保存我们自己的句柄,以便将来可以卸载自己

     // 定义一个函数指针类型,它与 android::LoadNativeBridge 的函数签名相匹配。
     using method_sig = const bool (*)(const char *, const NativeBridgeRuntimeCallbacks *);

     struct trace_arg { // 定义一个结构体,用于从栈回溯的lambda表达式中“返回”多个值
         method_sig load_native_bridge; // 用于存储找到的LoadNativeBridge函数地址
         const NativeBridgeRuntimeCallbacks *callbacks; // 用于存储找到的回调函数表指针
     };
     trace_arg arg{}; // 创建一个实例来接收结果

     _Unwind_Backtrace(+[](_Unwind_Context *ctx, void *arg) -> _Unwind_Reason_Code { // _Unwind_Backtrace 会从当前位置开始,逐层向上遍历函数的调用栈,每遍历一层,它就会调用一次我们提供的lambda表达式
         void *fp = unwind_get_region_start(ctx); // 获取当前栈帧正在执行的代码的地址
         Dl_info info{};
         dladdr(fp, &info); // dladdr可以根据代码地址,反查出它属于哪个so库
         if (info.dli_fname && std::string_view(info.dli_fname).ends_with("/libnativebridge.so")) { // 不断向上回溯,直到找到一个属于 /libnativebridge.so 的栈帧
             auto payload = reinterpret_cast<trace_arg *>(arg);
             payload->load_native_bridge = reinterpret_cast<method_sig>(fp); // 假设当前栈帧的函数指针就是 android::LoadNativeBridge 的地址
             payload->callbacks = find_runtime_callbacks(ctx); // 搜索当前栈帧的寄存器和栈内存,从中找出回调函数表的指针 (libart中的PROT_WRITE | PROT_READ且根据调用约定在栈/寄存器中找到指向该区域的))
             return _URC_END_OF_STACK;
         }
         return _URC_NO_REASON; // 如果没找到,继续向上回溯
     }, &arg);

     if (!arg.load_native_bridge || !arg.callbacks)
         return;


     auto nb = get_prop(NBPROP); // 这里去加载真正的native bridge
     auto len = sizeof(ZYGISKLDR) - 1;
     if (nb.size() > len) {
         arg.load_native_bridge(nb.data() + len, arg.callbacks); // 调用我们刚刚通过栈回溯找到的原始LoadNativeBridge函数,去加载真正的库
     }

     runtime_callbacks = arg.callbacks;  // 保存到全局
 }

接着是hook_zygote_jni,它含hook了nativeForkAndSpecialize/nativeSpecializeAppProcess/nativeForkSystemServer的所有重载,在其执行前与执行后封装了自己的逻辑(module.cpp里),在后者的内部就会调用模块对应的导出函数:

 void HookContext::hook_zygote_jni() {
     // ----------------------------------------------------------------
     // 1. 寻找 JNI_GetCreatedJavaVMs 函数
     // ----------------------------------------------------------------
     // JNI_GetCreatedJavaVMs 是一个标准的JNI函数,可以获取当前进程中所有已创建的JavaVM实例。
     // 这是我们与Java世界建立联系的起点。

     // 定义一个函数指针类型,方便转换。
     using method_sig = jint(*)(JavaVM **, jsize, jsize *); // 定义一个函数指针类型,方便转换

     auto get_created_vms = reinterpret_cast<method_sig>( // 首先,尝试用dlsym(RTLD_DEFAULT, ...)在全局符号中直接查找
             dlsym(RTLD_DEFAULT, "JNI_GetCreatedJavaVMs"));


     if (!get_created_vms) { // 如果全局查找失败(在某些Android版本或设备上可能发生),则启动备用方案
         for (auto &map: lsplt::MapInfo::Scan()) { // 扫描内存,找到 libnativehelper.so 这个库,因为 JNI_GetCreatedJavaVMs 通常由它导出
             if (!map.path.ends_with("/libnativehelper.so")) continue;
             void *h = dlopen(map.path.data(), RTLD_LAZY);
             get_created_vms = reinterpret_cast<method_sig>(dlsym(h, "JNI_GetCreatedJavaVMs")); // 在这个库的句柄中,精确地查找 JNI_GetCreatedJavaVMs 函数
             dlclose(h);
             break;
         }
         if (!get_created_vms) {
             ZLOGW("JNI_GetCreatedJavaVMs not found\n");
             return;
         }
     }

     JavaVM *vm = nullptr;
     jsize num = 0;
     jint res = get_created_vms(&vm, 1, &num); // 调用 JNI_GetCreatedJavaVMs,请求获取最多1个JavaVM实例(Android进程中只有一个)
     if (res != JNI_OK || vm == nullptr) {
         ZLOGW("JavaVM not found\n");
         return;
     }

     JNIEnv *env = nullptr;
     res = vm->GetEnv(reinterpret_cast<void **>(&env), JNI_VERSION_1_6); // 通过获取到的JavaVM实例,得到属于当前线程的JNIEnv环境指针
     if (res != JNI_OK || env == nullptr) {
         ZLOGW("JNIEnv not found\n");
     }

     hook_jni_methods(env, kZygote, zygote_methods);  // kZygote=com/android/internal/os/Zygote  zygote_methods=<jni_hooks.cpp> define 
 }

 void HookContext::hook_jni_methods(JNIEnv *env, const char *clz, JNIMethods methods) {
     jclass clazz;

     if (!runtime_callbacks || !env || !clz || !(clazz = env->FindClass(clz))) { // 如果任何一个关键组件(runtime_callbacks、JNI环境、类名)不存在,或者无法找到目标类,则无法进行Hook
         for (auto &method : methods) {
             method.fnPtr = nullptr;
         }
         return;
     }


     auto total = runtime_callbacks->getNativeMethodCount(env, clazz); // runtime_callbacks是之前通过栈回溯获取到的ART回调函数表,它提供了获取JNI方法表的能力
     auto old_methods = std::make_unique_for_overwrite<JNINativeMethod[]>(total); // 创建一个智能指针数组,用于存放原始的方法表
     runtime_callbacks->getNativeMethods(env, clazz, old_methods.get(), total); // 调用回调,将当前类所有已注册的JNI方法完整地备份到 old_methods 中
     auto new_methods = std::make_unique_for_overwrite<JNINativeMethod[]>(total); // 再创建一个数组,用于在循环中存放更新后的方法表

     for (auto &method : methods) {
         // 允许传入空的函数指针,这可能用于恢复Hook。
         if (!method.fnPtr) continue;

         if (env->RegisterNatives(clazz, &method, 1) == JNI_ERR ||
             env->ExceptionCheck() == JNI_TRUE) { // 调用JNI的RegisterNatives函数,这个函数有一个特性:如果注册的方法名和签名与一个已存在的方法相同,它会用新的函数指针覆盖掉旧的。Zygisk正是利用了这一点来实现Hook。
             if (auto exception = env->ExceptionOccurred()) { // 如果注册失败,清理JNI异常,将fnPtr设为null表示此方法Hook失败,然后继续下一个
                 env->DeleteLocalRef(exception);
             }
             env->ExceptionClear();
             method.fnPtr = nullptr;
             continue;
         }

         runtime_callbacks->getNativeMethods(env, clazz, new_methods.get(), total); // Hook成功后,我们需要找到被替换掉的原始函数指针,以便在我们的新函数中调用它,再次获取JNI方法表,此时它已经被RegisterNatives修改过了
         for (auto i = 0; i < total; ++i) {
             auto &new_method = new_methods[i];
             if (new_method.fnPtr == method.fnPtr && strcmp(new_method.name, method.name) == 0) { // 通过比较函数名和新的函数指针,在更新后的方法表中找到我们刚刚注册的那一项
                 // 获取在相同索引位置的、备份好的原始方法。
                 auto &old_method = old_methods[i];
                 ZLOGD("replace %s#%s%s %p -> %p\n", clz,
                       method.name, method.signature, old_method.fnPtr, method.fnPtr);
                 method.fnPtr = old_method.fnPtr; // [关键] 将我们传入的 method 结构体中的 fnPtr 修改为原始函数的指针, 这样,上层调用者就能通过这个被修改的结构体,拿到原始函数的地址
                 break;
             }
         }
         old_methods.swap(new_methods); // 交换两个数组,下一次循环时,这次的“新”表就成了“旧”表
     }
 }

至此,magisk+zygisk结束咯~

LSPosed分析

LSPlant分析

lsplant是一个安卓ART hook库,也是LSPosed的底层库,所以先看它;不像xposed直接修改ART源码,它是通过hook实现对ART修改的,这里用的hook框架(native hook)是初始化时传入的,可以由用户指定,测试用例中是dobby:

 struct InitInfo {
     // 需要在外部实现下面4个函数(前三个是必选,第四个可选),下面是函数定义和域
     using InlineHookFunType = std::function<void *(void *target, void *hooker)>;
     using InlineUnhookFunType = std::function<bool(void *func)>;
     using ArtSymbolResolver = std::function<void *(std::string_view symbol_name)>;
     using ArtSymbolPrefixResolver = std::function<void *(std::string_view symbol_prefix)>;

     InlineHookFunType inline_hooker;  // Inline Hook函数
     InlineUnhookFunType inline_unhooker;  // 取消Inline Hook函数
     ArtSymbolResolver art_symbol_resolver;  // 用于在libart.so中查找内部的函数或变量的地址(包括导出的和未导出的)
     ArtSymbolPrefixResolver art_symbol_prefix_resolver;  // 一个更灵活的符号查找器,用于查找具有特定前缀的第一个符号,这在符号名可能因编译器或版本而略有变化时非常有用


     // 下面几个是在生成动态代理类时设置的名称,主要是调试用 (改下也可以一定程度躲避检查)
     std::string_view generated_class_name = "LSPHooker_";  // 动态生成的代理类的类名,必选
     std::string_view generated_source_name = "LSP";  // 动态生成的DEX文件的源文件名,可选
     std::string_view generated_field_name = "hooker";  // 在代理类中生成的、用于存放回调对象的静态字段的名称,必选
     std::string_view generated_method_name = "{target}";  // 在代理类中生成的Hook方法的名称,如果设置为"{target}",则生成的方法名将与被Hook的目标方法名相同
 };

现在,咱们得从上往下看它的hook是咋实现的了(说实话下面的不太适合讲解,但是懒得自己写了):

test(){
        var staticMethod = LSPTest.class.getDeclaredMethod("staticMethod");
        var staticMethodReplacement = Replacement.class.getDeclaredMethod("staticMethodReplacement", Hooker.MethodCallback.class);
        Hooker hooker = Hooker.hook(staticMethod, staticMethodReplacement, null);   // 测试类里,去用Replacement.staticMethodReplacement替换LSPTest的staticMethod
}

public class Replacement {
    static boolean staticMethodReplacement(Hooker.MethodCallback callback) {
        return true;    // hook方法直接返回true
    }}
public class Hooker {
    public Object callback(Object[] args) throws InvocationTargetException, IllegalAccessException {
        var methodCallback = new MethodCallback(backup, args);
        return replacement.invoke(owner, methodCallback);       // 调用替换的函数
    }
  public static Hooker hook(Member target, Method replacement, Object owner) {
      Hooker hooker = new Hooker();
      try {
          var callbackMethod = Hooker.class.getDeclaredMethod("callback", Object[].class);
          var result = hooker.doHook(target, callbackMethod); // 核心,做hook,实际调用的lsplant::Hook,这里没有直接替换,而是自己的callback作为hook方法,主要是一个封装,如果为了演示直接替换最简单
          if (result == null) return null;
          hooker.backup = result;   // 保存原始方法和一些额外信息
                     // ... 
      } catch (NoSuchMethodException ignored) {
      }
      return hooker;
  }
}

再往下,看Hook是怎么实现的,这里简单先说下,它利用了ArtMethod的两个函数BackupTo(保存原函数点)和SetEntryPoint修改入口为新地址,先说新地址它并不是直接跳到新的函数处,而是用了段硬编码的蹦床,蹦床是架构相关的,而且也没做啥,就是一个绝对跳转(说实话俺没懂为啥要这样做...),而新的位置,是一段动态生成的Dex的新方法的位置:

 [[maybe_unused]] jobject Hook(JNIEnv *env, jobject target_method, jobject hooker_object,
                               jobject callback_method) {
     // 输入参数校验 ...

     jmethodID hook_method = nullptr; // 预先声明将要使用的变量
     jmethodID backup_method = nullptr;
     jfieldID hooker_field = nullptr;

     auto *target = ArtMethod::FromReflectedMethod(env, target_method);  // 获取目标方法(被hook的方法)在ART虚拟机内部的C++对象 ArtMethod*
     bool is_static = target->IsStatic();

     if (IsHooked(target, true)) { // 检查是否已经Hook过,防止重复Hook
         LOGW("Skip duplicate hook");
         return nullptr;
     }

     ScopedLocalRef<jclass> built_class{env}; //使用一个独立的作用域来管理JNI局部引用和字符串的生命周期
     {
         auto callback_name =
             JNI_Cast<jstring>(JNI_CallObjectMethod(env, callback_method, method_get_name)); // 通过JNI获取进行DEX构建所需的所有信息:回调方法名、目标方法名、回调类的类加载器和类名等
         JUTFString callback_method_name(callback_name);
         // ... (获取其他所需信息) ... 

         std::tie(built_class, hooker_field, hook_method, backup_method) = WrapScope( // 调用BuildDex,在内存中动态生成一个包含代理类的DEX文件,这个代理类里包含了与目标方法签名一致的“Hook方法”和“Backup方法”
             env,
             BuildDex(env, callback_class_loader.get(), // 获取目标方法的shorty签名
                      __builtin_expect(is_proxy, 0) ? GetProxyMethodShorty(env, target_method)
                                                    : ArtMethod::GetMethodShorty(env, target_method),
                      is_static, target->IsConstructor() ? "constructor" : target_method_name.get(),
                      class_name.get(), callback_method_name.get()));
     }

     auto reflected_hook = JNI_ToReflectedMethod(env, built_class, hook_method, is_static); // 将动态生成的Hook方法和Backup方法的jmethodID,转换回Java层的Method对象(jobject)
     auto reflected_backup = JNI_ToReflectedMethod(env, built_class, backup_method, is_static);

     JNI_CallVoidMethod(env, reflected_backup, set_accessible, JNI_TRUE); // 确保Backup方法是可访问的,这样用户才能通过它调用原始方法

     auto *hook = ArtMethod::FromReflectedMethod(env, reflected_hook.get()); // 获取动态生成的Hook方法和Backup方法在ART内部的C++ ArtMethod* 指针
     auto *backup = ArtMethod::FromReflectedMethod(env, reflected_backup.get());

     JNI_SetStaticObjectField(env, built_class, hooker_field, hooker_object); // 将用户传入的hooker_object实例,设置到我们动态生成的代理类的静态字段中,这样,当代理方法被执行时,它就能找到并调用正确的用户回调逻辑

     if (DoHook(target, hook, backup)) { // 调用DoHook,执行“心脏手术”——暂停虚拟机,修改目标ArtMethod的入口点
         std::apply(
             [backup_method, target_method_id = env->FromReflectedMethod(target_method)](auto... v) { // 遍历框架内部缓存的一些JNI方法ID,如果发现其中某个ID正好是我们刚刚Hook的目标,就把它更新为指向Backup方法。这可以防止在回调逻辑中因调用反射而导致的无限递归
                 ((*v == target_method_id &&
                   (LOGD("Propagate internal used method because of hook"), *v = backup_method)) ||
                  ...);
             },
             kInternalMethods);

         jobject global_backup = JNI_NewGlobalRef(env, reflected_backup); // 创建一个全局JNI引用指向Backup方法,这是将要返回给用户的句柄
         RecordHooked(target, target->GetDeclaringClass()->GetClassDef(), global_backup, backup);// 在框架内部记录这次Hook
         if (!is_proxy) [[likely]] { // 记录JIT相关的状态,防止JIT编译干扰Hook
             RecordJitMovement(target, backup);
         }
         RecordDeoptimized(hook->GetDeclaringClass()->GetClassDef(), backup); // 将Backup方法标记为“已去优化”,防止它被意外修改

         return global_backup;
     }

     return nullptr;
 }

上面有两个重点,DoHook说了,而BuildDex也很简单,它创建了一个新的类,类里面有两个和目标方法签名一样的方法hookbackup,后者是dummy没做啥,主要是创建artmethod保存原始方法指针,而前者完成了一个参数装箱和打包为数组的操作,之后会调用传入的hook方法,之后又对hook方法的结果做处理(如拆箱,类似xposed里InvokeXposedHandleHookedMethod做的事),还有一个域存储hook类,其实它为了防止Jit优化,匹配多版本等还做了很多事,可以自己看代码,咱就不继续了!

轮到LSPosed

这个项目依然庞大,先简单列下目录吧:

(base) ➜  LSPosed git:(master) tree -L 1  
.
├── app                    # Manager应用的UI界面,用于管理LSPosed框架和模块
├── core                   # LSPosed框架核心实现模块,会被注入到目标应用  (lspd.dex)
├── daemon                # 后台服务,处理框架的核心逻辑和模块管理
├── dex2oat               # DEX编译优化工具模块  
├── external              # 外部依赖和第三方库目录  
├── hiddenapi            # 提供对Android隐藏API的访问能力  
├── magisk-loader        # Magisk模块加载器,支持Zygisk和Riru两种方式  
└── services             # AIDL服务接口定义模块  

接下来咱只关注它的注入和模块加载过程了,即magisk-loader,而且只关注zygisk,它分为对system_server和普通应用的hook,这里只看后者:

    class ZygiskModule : public zygisk::ModuleBase {
        void preAppSpecialize(zygisk::AppSpecializeArgs *args) override {
            MagiskLoader::GetInstance()->OnNativeForkAndSpecializePre(
                    env_, args->uid, args->gids, args->nice_name,
                    args->is_child_zygote ? *args->is_child_zygote : false, args->app_data_dir);
        }

        void postAppSpecialize(const zygisk::AppSpecializeArgs *args) override {
            MagiskLoader::GetInstance()->OnNativeForkAndSpecializePost(env_, args->nice_name, args->app_data_dir);
            if (*allowUnload) api_->setOption(zygisk::DLCLOSE_MODULE_LIBRARY);
        }
    }

可见主要逻辑还是在MagiskLoader里,它的pre主要是初始化服务连接、对magisk自己授网络权限和判断是否要注入,这里看post部分:

 void MagiskLoader::OnNativeForkAndSpecializePost(JNIEnv *env, jstring nice_name, jstring app_dir) {
     const JUTFString process_name(env, nice_name);
     auto *instance = Service::instance();

     auto binder = skip_ ? ScopedLocalRef<jobject>{env, nullptr}
                         : instance->RequestBinder(env, nice_name); // `skip_` 是在 Pre 函数中设置的布尔值,如果为 false,说明这是一个合法的注入目标,我们需要通过IPC向Magisk服务请求一个Binder通信句柄, 如果为 true,则不请求Binder,后续注入流程将被跳过

     if (binder) {
         lsplant::InitInfo initInfo{//... 我们熟悉的,lsplant初始化结构
                     };
         auto [dex_fd, size] = instance->RequestLSPDex(env, binder); // 通过Binder向Magisk服务请求LSPosed框架的DEX文件描述符和大小 (core:lspd.dex)
         auto obfs_map = instance->RequestObfuscationMap(env, binder);
         ConfigBridge::GetInstance()->obfuscation_map(std::move(obfs_map)); // 请求混淆映射表(用于反混淆)
         LoadDex(env, PreloadedDex(dex_fd, size)); // 将获取到的DEX文件加载到当前进程的内存中
         close(dex_fd); // 加载完即可关闭fd

         InitArtHooker(env, initInfo); // 调用lsplant的初始化函数,传入我们准备好的InitInfo
         InitHooks(env); // 执行LSPosed框架自身需要的一些初始Hook
         SetupEntryClass(env); // 设置LSPosed框架在Java层的入口类 找到:org.lsposed.lspd.core.Main

         FindAndCall(env, "forkCommon",
                     "(ZLjava/lang/String;Ljava/lang/String;Landroid/os/IBinder;)V",
                     JNI_FALSE, nice_name, app_dir, binder); // 调用org.lsposed.lspd.core.Main的`forkCommon`,把进程信息(nice_name, app_dir, binder)传递过去,Java层从这里开始接管,进行加载用户模块等操作

         setAllowUnload(false); // 明确设置不允许卸载Zygisk的so库,因为它正在承载LSPosed框架
         GetArt(true);

     } else {
         auto context = Context::ReleaseInstance(); // 释放所有已初始化的资源
         auto service = Service::ReleaseInstance();
         GetArt(true);
         setAllowUnload(true); // 明确设置允许卸载Zygisk的so库,以节省内存
     }
 }

这里面的InitHooks注册了很多native函数用来做hook等操作(回顾lsplant),不多说,直接去forkCommon

public class Main {

     public static void forkCommon(boolean isSystem, String niceName, String appDir, IBinder binder) {
         Startup.initXposed(isSystem, niceName, appDir, ILSPApplicationService.Stub.asInterface(binder));// 首先,调用Startup.initXposed进行最基础的初始化,比如设置日志、保存进程上下文等
         if ((niceName.equals(BuildConfig.MANAGER_INJECTED_PKG_NAME) || niceName.equals(BuildConfig.DEFAULT_MANAGER_PACKAGE_NAME))
                 && ParasiticManagerHooker.start()) { // 如果当前启动的进程是LSPosed管理器本身,则执行特殊的寄生逻辑,这会在管理器内部设置一些Hook,让管理器能和框架通信,但不会加载其他模块,然后直接返回。
             return;
         }
         Startup.bootstrapXposed(); // 开始执行Xposed的引导程序
     }
 }
 public class Startup {
     private static void startBootstrapHook(boolean isSystem) {
         LSPosedHelper.hookMethod(CrashDumpHooker.class, Thread.class, "dispatchUncaughtException", Throwable.class); // Hook线程的未捕获异常处理器,这样当App崩溃时,LSPosed可以捕获崩溃信息,并在日志中加入LSPosed相关上下文(如激活的模块),便于排查问题

         if (isSystem) { // 如果是System Server进程,Hook ZygoteInit.handleSystemServerProcess,以便在System Server的生命周期中执行特定操作。
             LSPosedHelper.hookAllMethods(HandleSystemServerProcessHooker.class, ZygoteInit.class, "handleSystemServerProcess");
         } else {       // 如果是普通App进程,Hook DexFile的几个open方法,这使得LSPosed可以感知到App加载的所有DEX文件。
             LSPosedHelper.hookAllMethods(OpenDexFileHooker.class, DexFile.class, "openDexFile");
             LSPosedHelper.hookAllMethods(OpenDexFileHooker.class, DexFile.class, "openInMemoryDexFile");
             LSPosedHelper.hookAllMethods(OpenDexFileHooker.class, DexFile.class, "openInMemoryDexFiles");
         }

         LSPosedHelper.hookConstructor(LoadedApkCtorHooker.class, LoadedApk.class,
                 ActivityThread.class, ApplicationInfo.class, CompatibilityInfo.class,
                 ClassLoader.class, boolean.class, boolean.class, boolean.class); // Hook LoadedApk的构造函数。LoadedApk是Android框架中代表一个已加载APK的类,通过Hook它的构造函数,LSPosed可以在一个App被加载的最早期捕获到它的信息。

         LSPosedHelper.hookMethod(LoadedApkCreateCLHooker.class, LoadedApk.class, "createOrUpdateClassLoaderLocked", List.class); // Hook LoadedApk的创建ClassLoader的方法,这是LSPosed能够获取到App的ClassLoader,并将模块代码注入进去的核心Hook点之一

         LSPosedHelper.hookAllMethods(AttachHooker.class, ActivityThread.class, "attach"); // Hook ActivityThread.attach,这是App生命周期中一个非常早的阶段,它在这里开始加载模块
     }

     public static void bootstrapXposed() {
         try {
             startBootstrapHook(XposedInit.startsSystemServer);
             XposedInit.loadLegacyModules();  // 在框架自身准备就绪后,开始扫描、加载并初始化所有用户安装的传统Xposed模块
         } catch (Throwable t) {
             Utils.logE("error during Xposed initialization", t);
         }
     }

     public static void initXposed(boolean isSystem, String processName, String appDir, ILSPApplicationService service) {
         ApplicationServiceClient.Init(service, processName); // 初始化客户端,连接服务端
         XposedBridge.initXResources(); // 初始化Xposed的资源Hook功能
         XposedInit.startsSystemServer = isSystem; // 将进程上下文信息保存到全局静态变量中,供框架各部分使用
         LSPosedContext.isSystemServer = isSystem;
         LSPosedContext.appDir = appDir;
         LSPosedContext.processName = processName;
         PrebuiltMethodsDeopter.deoptBootMethods(); // 对一些系统启动方法进行“去优化”,确保它们可以被稳定Hook
     }
 }

这里最重要的就是对ActivityThread.attach的hook,它在应用初始化的最后阶段执行,在AttachHooker.class里做的事就是调用XposedInit.loadModules

 public final class XposedInit {

     public static void hookResources() throws Throwable { // 初始化资源Hook,资源hook是xposed很少被提及的功能,lsposed也实现了,忽略~
     }
     private static XResources cloneToXResources(XC_MethodHook.MethodHookParam<?> param, String resDir) {  // 将一个标准的Resources对象,转换成我们自定义的XResources对象
     }

     private static final Map<String, Optional<String>> loadedModules = new ConcurrentHashMap<>(); // 用于存放已加载模块信息的Map

     public static void loadLegacyModules() { // 加载所有已安装的Xposed模块。
         var moduleList = serviceClient.getLegacyModulesList(); // 通过Binder向Magisk服务请求已安装的模块列表
         moduleList.forEach(module -> { // 遍历列表,逐一加载
             if (!loadModule(module.packageName, module.apkPath, module.file)) {
                 loadedModules.remove(module.packageName);// 如果加载失败,则从已加载列表中移除
             }
         });
     }
    public static void loadModules(ActivityThread at) {
        var packages = (ArrayMap<?, ?>) XposedHelpers.getObjectField(at, "mPackages");
        serviceClient.getModulesList().forEach(module -> {
            loadedModules.put(module.packageName, Optional.empty());
            if (!LSPosedContext.loadModule(at, module)) {
                loadedModules.remove(module.packageName);
            } else {
                packages.remove(module.packageName);
            }
        });
    }
     private static boolean loadModule(String name, String apk, PreLoadedApk file) {  // 加载单个传统模块的APK文件
         var mcl = LspModuleClassLoader.loadApk(apk, file.preLoadedDexes, librarySearchPath, initLoader); // 创建一个特制的LspModuleClassLoader,用于加载模块的APK,这个ClassLoader被设计用来隔离模块,同时能访问到框架的类
         if (mcl.loadClass(XposedBridge.class.getName()).getClassLoader() != initLoader) {}// 检查模块是否将Xposed API打包进了自己的APK,这是一个常见的错误,会导致类冲突和各种奇怪的问题,如果检测到,则拒绝加载该模块
         initNativeModule(file.moduleLibraryNames); // 初始化模块中的Native库
         return initModule(mcl, apk, file.moduleClassNames);  // 初始化模块中的Java类
     }
     private static boolean initModule(ClassLoader mcl, String apk, List<String> moduleClassNames) {  // 初始化模块中在xposed_init文件中定义的Java类
         var count = 0;
         for (var moduleClassName : moduleClassNames) { // 遍历模块声明的所有入口类
             try {
                 Class<?> moduleClass = mcl.loadClass(moduleClassName); // 使用模块自己的ClassLoader加载入口类
                 if (!IXposedMod.class.isAssignableFrom(moduleClass)) {continue;} // 检查这个类是否实现了Xposed的接口
                 final Object moduleInstance = moduleClass.newInstance(); // 创建模块入口类的实例

                 if (moduleInstance instanceof IXposedHookZygoteInit) { // 根据它实现的接口类型,注册相应的回调 for Zygote初始化
                     ((IXposedHookZygoteInit) moduleInstance).initZygote(param); 
                 }
                 if (moduleInstance instanceof IXposedHookLoadPackage) { // for app包加载
                     XposedBridge.hookLoadPackage(new IXposedHookLoadPackage.Wrapper((IXposedHookLoadPackage) moduleInstance));
                 }
                 if (moduleInstance instanceof IXposedHookInitPackageResources) { // for 资源初始化
                     hookResources(); // 确保资源Hook已初始化
                     XposedBridge.hookInitPackageResources(new IXposedHookInitPackageResources.Wrapper((IXposedHookInitPackageResources) moduleInstance));
                 }
             } catch (Throwable t) {
                 Log.e(TAG, "    Failed to load class " + moduleClassName, t);
             }
         }
         return count > 0; // 如果至少有一个回调被成功加载,则认为模块加载成功。
     }
 }

LSPosed支持两种api,传统的兼容xposed的api和新的基于注解的api,两种模块的加载方式存在差异,上面是传统模块的加载,而loadModules调用的是LSPosedContext.loadModule

 public static boolean loadModule(ActivityThread at, Module module) {
     try {
         var sb = new StringBuilder(); // 创建模块专用的ClassLoader,这部分逻辑与加载旧式模块类似:为模块的APK创建一个独立的ClassLoader,以隔离模块的类,同时设置好so库的搜索路径
         var abis = Process.is64Bit() ? Build.SUPPORTED_64_BIT_ABIS : Build.SUPPORTED_32_BIT_ABIS;
         for (String abi : abis) {
             sb.append(module.apkPath).append("!/lib/").append(abi).append(File.pathSeparator);
         }
         var librarySearchPath = sb.toString();
         var initLoader = XposedModule.class.getClassLoader();
         var mcl = LspModuleClassLoader.loadApk(module.apkPath, module.file.preLoadedDexes, librarySearchPath, initLoader);

         if (mcl.loadClass(XposedModule.class.getName()).getClassLoader() != initLoader) {return false;} // 检查模块是否违规打包了API,同上

         var ctx = new LSPosedContext(module.packageName, module.applicationInfo, module.service); // 实例化并初始化模块 (新版API的核心),为这个模块创建一个专用的LSPosedContext,这个上下文对象将作为模块与框架交互的桥梁

         for (var entry : module.file.moduleClassNames) { // 遍历模块在清单中声明的所有入口类
             var moduleClass = mcl.loadClass(entry);

             if (!XposedModule.class.isAssignableFrom(moduleClass)) {continue;}  // 检查入口类是否实现了新的 XposedModule 接口
             try {

                 var moduleEntry = moduleClass.getConstructor(XposedInterface.class, XposedModuleInterface.ModuleLoadedParam.class); // 寻找构造函数 ctr(XposedInterface context, XposedModuleInterface.ModuleLoadedParam param)

                 var moduleContext = (XposedModule) moduleEntry.newInstance(ctx, new XposedModuleInterface.ModuleLoadedParam() { // 通过调用这个特定的构造函数来实例化模块,用依赖注入主动将自己(ctx)和环境信息(param)注入到模块中,通过一个匿名内部类,向模块的构造函数提供当前的进程信息。
                     @Override
                     public boolean isSystemServer() {
                         return isSystemServer;
                     }

                     @Override
                     public String getProcessName() {
                         return processName;
                     }
                 });
                 modules.add(moduleContext); // 将成功实例化的模块添加到全局列表中
             } catch (Throwable e) {
                 Log.e(TAG, "    Failed to load class " + moduleClass, e);
             }
         }
         module.file.moduleLibraryNames.forEach(NativeAPI::recordNativeEntrypoint); // 记录模块需要加载的Native库
         Log.d(TAG, "Loaded module " + module.packageName + ": " + ctx);
     } catch (Throwable e) {
         Log.d(TAG, "Loading module " + module.packageName, e);
         return false;
     }
     return true;
 }

OK,LSPosed主要部分就结束啦~

使用笔记

安装magisk模块卡在启动界面后:

1.直接adb wait-for-device shell magisk --remove-modules删除所有模块

2.直接去adb shell -c 'rm -rf /data/adb/modules'

模块推荐

Hide-My-Applist:隐藏app列表

LSPosed:用于支持xposed插件

AlwaysTrustUserCerts/Magisk-MoveCACerts/MoveCertificate:自动将证书安装为系统证书,去证书绑定

magisk-frida: 开机后自动运行frida-server

lsposed开发

他们都是兼容xposed api的,开发用的sdk也是用的xposed

KernelSU

或许是第一个开始直接改内核的?说改内核也不准确,它开发了一个内核模块,由于模块直接在内核运行权限当然杠杠的,要安装它,需要是GKI内核,因为GKI是通用的同版本就能用,所以Kernel SU官方可以预先编译含改模块的内核直接替换就好了,除了直接替换内核,还可以用LKM,Kernel SU利用boot_patch.rs去重打包boot.img/init_boot.img,替换init来劫持启动过程,去注入kernelsu.ko;如果不是GKI,那么....有源码也能自己编译内核,把它编进去,如果啥都没有,那看后面的apatch吧!

Kernel SU还有很多可以分析的,比如它的UI,它的内核模块实现,它的守护进程,有空再聊吧~

附一加root笔记

  1. 打开开发者模式,在开发者选项中打开“OEM解锁”和“USB调试”选项。

  2. 对于android13以下和以上要分别用GKI和LKM,但是都需要下全量包去提取启动镜像(git上的GKI直接使用会黑屏原因未分析),可以

  3. 直接去大侠木木处下载全量包,用mt管理器提取boot.img/init_boot.img (也可使用payload-dumper-go直接在电脑上操作),推送到手机里,用kernelsu管理器去打补丁,然后:

adb reboot bootloader

fastboot flashing unlock

fastboot boot boot.img  # 之前有版本支持,后来又不支持了

fastboot flash boot boot.img  # android12及之下

fastboot flash init_boot init_boot.img   # android13及以上

fastboot reboot

安装ZygiskNext去获取magisk提供zygisk功能。

注:KernelSU不会自动弹出授权提示,需手动开启应用的超级用户权限,如要开启adb shell的root权限,需为com.android.shell启用超级用户,打开"超级用户"标签页,找到目标应用(Shell)后启用开关 。

Apatch

Apatch 又是后出来的ROOT框架,它依赖KernelPatch直接在内核里实现相关功能,相比Kernel SU修改了更多底层部分,它本身支持KPM和APM两类模块,前者类似于LKM,而后者和Magisk/KernelSu的用户态模块一致,下面从它的底层开始分析

KernelPatch

它通过对内核打补丁来实现代码注入和内核hook能力,它不需要有内核源码或内核含调试信息,只要编译时开启CONFIG_KALLSYMS=y即可,下面先简单讲下它的组成部分

组成部分

1.符号解析:要做hook得先找到hook点,它通过kernel里的banner信息确认内核版本,再依赖内核文件里的kallsyms节获取函数的地址信息,细节上elf里存储的是压缩后的数据,它自己实现解析与符号查找能力;到目前为止它只找到了函数地址,而它在工作时还需要识别一些关键结构体成员偏移,对此它采用了设置与测试的方式,即备份值后调用相关函数修改值,看哪个偏移被修改了从而识别相关成员

2.HOOK机制:它自己实现了一个hook系统,支持指针hook(主要用在系统调用表上)和内联hook,这个系统支持在原始函数前和后hook,支持多重hook,它使用蹦床代码按优先级(默认是挂钩顺序)执行hook代码,执行过程中可修改参数/返回值,不调用原始函数等

3.内存管理:它的几乎所有功能都在内核完成,它对内核进行了较多修改,并且支持配置与模块加载等,因此它实现了自己的内存管理系统,而不依赖内核本身的内存子系统

4.超级调用:不像KernelSu直接扩展prctl的功能,它直接增加了一个叫做SuperCall的系统调用(调用号45),有权限的用户可以使用该调用完成多种功能,例如ROOT权限管理,KernelPatch管理,KPM模块管理等,同时它提供了用户态的库封装了其调用便于使用

static void before(hook_fargs6_t *args, void *udata)
{
        // ... 
    if (!auth_superkey(key)) {  // 认证superkey,先直接比较,失败会尝试hash(key)==root superkey
        is_key_auth = 1;
    } else if (!strcmp("su", key)) {    // 如果是ru级,判断是否有权限
        uid_t uid = current_uid();
        if (!is_su_allow_uid(uid)) return;
    } else {
        return; // 否则返回,执行原始流程 
    }
    args->ret = supercall(is_key_auth, cmd, a1, a2, a3, a4);
}

static long supercall(int is_key_auth, long cmd, long arg1, long arg2, long arg3, long arg4)
{
    // 信息相关
    case SUPERCALL_HELLO:   // 直接返回魔数
    case SUPERCALL_KLOG:    // 打日志
    case SUPERCALL_KERNELPATCH_VER:
    case SUPERCALL_KERNEL_VER:
    case SUPERCALL_BUILD_TIME:
        // SU管理
    case SUPERCALL_SU:  // 为当前任务配置特权
    case SUPERCALL_SU_TASK: // 为指定任务配置
    case SUPERCALL_SU_GRANT_UID: // 为指定用户授权
    case SUPERCALL_SU_REVOKE_UID:   // 撤销su
    case SUPERCALL_SU_NUMS: // 显示数量
    case SUPERCALL_SU_LIST: // 列出
    case SUPERCALL_SU_PROFILE:  // 获取指定uid的特权配置 (e-uid和se context)
    case SUPERCALL_SU_RESET_PATH: // 重置su路径
    case SUPERCALL_SU_GET_PATH: // 获取su路径
    case SUPERCALL_SU_GET_ALLOW_SCTX: // 获取全局的sctx
    case SUPERCALL_SU_SET_ALLOW_SCTX: // 设置
        // k storage
    case SUPERCALL_KSTORAGE_READ:
    case SUPERCALL_KSTORAGE_WRITE:
    case SUPERCALL_KSTORAGE_LIST_IDS:
    case SUPERCALL_KSTORAGE_REMOVE:
    case SUPERCALL_SU_GET_SAFEMODE: // 是否是安全模式
        // log
    case SUPERCALL_BOOTLOG:
    case SUPERCALL_PANIC:
    case SUPERCALL_TEST:

    if (!is_key_auth) return -EPERM; // 下面的东西就只有sk有权访问了
        // SuperKey相关
    case SUPERCALL_SKEY_GET:    
    case SUPERCALL_SKEY_SET:
    case SUPERCALL_SKEY_ROOT_ENABLE:
        // 模块相关
    case SUPERCALL_KPM_LOAD:
    case SUPERCALL_KPM_UNLOAD:  
    case SUPERCALL_KPM_CONTROL: // 模块控制,当前只实现了ctrl0
    case SUPERCALL_KPM_NUMS:
    case SUPERCALL_KPM_LIST:
    case SUPERCALL_KPM_INFO:
}

5.认证系统:在ROOT权限外,它还自己在内核中实现了所谓SuperKey的一个认证系统,在使用超级调用时,需要传入一个key,它可以是su,此时内核会检查当前用户是否有root权限,有则执行一般特权操作,而超级调用还支持的如KernelPatch/KPM管理等功能则需要key为预设的SuperKey才允许调用

6.ROOT能力:在Linux下限制用户权限的方式有很多,如DAC、SELinux、Seccomp、Capabilities等,调用su是直接改DAC,将euid和egid等改为0,而前面提到的其它工具都是通过新增一个特权服务/修改SELinux 策略的方式来绕过第二个,KP换了种方式,它直接在内核中hook相关校验接口来绕过,Seccomp也是直接disable掉了

7.模块系统:上面提到了它支持开发自己的模块,它将这种模块叫做KernelPatch Module(KPM),它类似于内核模块,可在打补丁时直接嵌入到内核镜像中,也可以在运行时动态加载,它自己实现了一个模块解析与加载机制,利用这个模块就可以方便的在内核中为所欲为

8.存储系统:说实话,俺没看懂,好像就是一个内存中的键值对存储实现,用来保存配置信息的~

对它有个大概了解后就来看看它的工作流程吧

工作流程

在对内核打补丁后,它会直接劫持内核启动过程,在内核启动时跳转到KP的镜像处执行,具体来说它是静态替换了paging_init,将其重定向到镜像的起始位置:

// modify kernel entry
int paging_init_offset = get_symbol_offset_exit(&kallsym, kallsym_kimg, "paging_init");
setup->paging_init_offset = relo_branch_func(kallsym_kimg, paging_init_offset); // kallsym_kimg里

那是一段汇编代码,最终会跑到start里:

int __attribute__((section(".start.text"))) __noinline start(uint64_t kimage_voff, uint64_t linear_voff)
{
    start_init(kimage_voff, linear_voff);   // 初始化 物理地址与线性地址等
    prot_myself();  // 设置自身的内存权限
    restore_map();  // 恢复原始内核代码
    log_regs();         // 输出寄存器信息  
    predata_init(); // 准备自身的一些数据
    symbol_init();  // 符号解析,后续hook用
    rc = patch();       // 打补丁
    return rc;
}

它最后是去安装运行时的钩子,其实依然是跳板:

int patch()
{
    linux_libs_symbol_init();       // 初始化 Linux 库符号表
    linux_misc_symbol_init();       // 初始化 Linux 杂项符号表 
    module_init();                          // 初始化模块子系统
    syscall_init();                         // 初始化系统调用接口

    rc = hook_wrap12((void *)patch_config->panic, before_panic, 0, 0);  // 在panic执行前执行kp的信息,调试用

    // rest_init or cgroup_init
    unsigned long init_addr = patch_config->rest_init || patch_config->cgroup_init
    hook_wrap4((void *)init_addr, before_rest_init, 0, (void *)init_addr);  // rest_init执行前会初始化核心功能
    // kernel_init
    unsigned long kernel_init_addr = patch_config->kernel_init;
    hook_wrap4((void *)kernel_init_addr, before_kernel_init, after_kernel_init, 0); // kernel_init处主要是加载KPM
}

再接着,这里重点看:

static void before_rest_init(hook_fargs4_t *args, void *udata)
{
    if ((rc = bypass_kcfi())) goto out;     // 绕过cfi,不然指针hook会直接崩
    if ((rc = resolve_struct())) goto out;  // 解析关键结构,如task cred等结构体里关键信息的偏移
    if ((rc = bypass_selinux())) goto out;  // 绕过selinux,它直接hook avc_denied和slow_avc_audit,若用户有权限则直接使其失效
    if ((rc = task_observer())) goto out;       // hook任务创建时,它会在task的内核栈结束位置放一个task_ext保存额外的信息,如权限/profile等信息,便于认证
    rc = supercall_install();   // 安装supercall
    rc = kstorage_init();   // storage初始化
    rc = su_compat_init();  // su兼容初始化,它拦截fstatat faccessat execve,当有权限的用户请求su时重定向到正确的位置 (安卓还会额外添加shell和root用户)
    rc = resolve_pt_regs(); // 解析pt结构个域的偏移
    rc = android_sepolicy_flags_fix();  // 这两个仅安卓的 拦截sepolicy写入,确保 android_netlink_route 和 android_netlink_getneigh 标志被正确设置
    rc = android_user_init();  // hook execve/execveat/openat来监听用户空间初始化状态,注入rc脚本等,hook input_handle_event来探测音量键开安全模式
}

ok,KP大体就结束了,现在可以回到AP咯!

注:

  1. 符号信息分多种,CONFIG_KALLSYMS=y只包含函数名,CONFIG_KALLSYMSALL=y额外包含全局变量等的信息,而-g是包含完整的调试信息,包括本地变量等,而在发行版的内核中通常只有第一个被激活
  2. 其实个人感觉SuperKey是个君子协议,都有不受限的root权限了干啥不行,要么直接去内存偷Key或者直接Hook绕过...另外除了SuperKey它还有个Root SuperKey,这个东西是hash后保存的,如果忘了SuperKey还可以用它去认证并重置SuperKey

一笔带过的Native Hook

上面和hook相关的都是ART HOOK,而Native的HOOK有出现dobby,现在简单说下吧,安卓基于Linux用的ELF格式,所以常见两种native hook技术:

1.PLT HOOK:就是改函数表的方式,ELF对于外部符号默认使用懒加载(即使不启用也会有plt/got),pwn手对此很熟悉,plt hook就是直接改got表(所以为啥不叫got hook呢?🤔),它的优点是实现简单(也就很稳定安全咯),缺点就是能hook的函数很有限(只能是外部函数),且可能被绕过(dlsym直接调用)

2.Inline HOOK:这是所有二进制文件通用的hook方式,直接改指定位置的代码(通常是函数入口处),令其跳到hook代码处执行,同时备份原始指令便于调用原始函数或unhook,优点是通用啥都能钩,缺点是稳定性和安全性缺点儿

在安卓上常见的有前面出现过的dobby,字节的android-inline-hook (for inline)和bhook (for plt),And64InlineHookwhale等~

动态分析

JNI追踪

拦截原理

先说下jni基础,android可使用Linux标准的dlopen加载so,新版更常用功能更强大的android_dlopen_ext加载,加载后会使用dlsym去查找jni的函数。jni函数注册方式分两种,静态注册就是按特定规则命名Java_<package name>_<class name>_<method name>,而动态注册是在JNI_OnLoad中调用JNIEnvRegisterNatives注册的,后者的jni函数可任意命名。在逆向一开始就会出现两个关键结构:

JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved) {
    JNIEnv *env = nullptr;
    if (vm->GetEnv((void **)&env, JNI_VERSION_1_6) != JNI_OK) {
        return -1;
    }
    jclass clazz = env->FindClass("com/example/dynamicregister/Test");
    if (env->RegisterNatives(clazz, nativeMethods, sizeof(nativeMethods) / sizeof(nativeMethods[0])) != JNI_OK) {
        return -1;
    }
    return JNI_VERSION_1_6;
}

JavaVM管理当前的Java虚拟机,而JNIEnv是线程相关的,用来具体提供JNI使用的各种功能,所以JNI追踪就是要先定位到JNI函数,并拦截。

注:有些骚操作可以使用更底层的接口去注册JNI,甚至直接操作ART注册,但同样的可直接获取artmethod去找到真实jni的地址

静态处理

通常都会先静态分析下,第一步导头文件不必多说,现在新的ida已经不需要这不了。第二步就是找出所有的JNI函数并且将参数类型改好,这部分工作可使用jni_helper,它使用androguard解析dex文件,获取所有jni函数(即native修饰的函数)的签名,使用pyelftools来定位对应的jni函数(当前只通过命名规则Java_xxx,还不支持动态注册的),将其匹配下来生成json数据库,再使用特定的逆向分析工具脚本将其应用,如ida pro的脚本加载json后自动为函数定义类型,算是给jni来个预处理!

Jnitrace分析

真正动态追踪害得看jnitrace,它能追踪对native方法的调用以及所有标准JNI方法的调用,不过它只是一个命令行封装,主要用于trace,而它的核心jnitrace-engine提供更强大的能力,我们可以直接调用API编写自己的拦截方法,下面说明后者的拦截原理:

1.它通过dlopen监控库加载,dlsym监控对Java_xxxJNI_OnLoad的查找,监控库加载意义不大,因为现代android很少用dlopen了,作者没想勾android_dlopen_ext而是用dlsym去搂它,监控到对这两个的查找时看看处理过没以及要不要处理(黑名单优先级更高),若要则处理

2.处理方式是替换掉JavaVMJNIEnv,前者通常是唯一的,而后者每线程一个,由前者管理。我们编写的JNI函数里对JVM的调用都要经过这两个对象,所以作者就分别创建了一个影子JavaVMJNIEnv作为代理对象,影子对象的方法表变成了新的函数,该函数可在调用原函数之前和之后做一些用户定义的行为,通过替换第一/二个参数就替换了和JVM通信的通道。

这个库还有很多细节,例如内存管理,配置管理,参数输出(特别是变参处理),很值得学习!

# 安装
pip install jnitrace
# trace
jnitrace -l libnative.so -i "Find" -i "Get" -o trace.json com.example.app 
jnitrace -l "*" -e "GetEnv" -e "^Find" com.example.app

系统调用追踪

stackplz

stackplz是个基于eBPF的追踪工具,它提供用户态函数调用追踪、系统调用追踪功能。

先看用户态函数追踪,该功能依赖于uprobe机制,虽然uprobe没用ptrace这个系统调用而是直接在内核里就把目标内存的代码改了,相对隐蔽,但修改的代码依然可以被扫内存的方式发现,另外uprobe会将指令存储在用户态指定内存区域,因此依然有多种被发现的方法,stackplz更进一步提供了硬件断点处理,通过perf子系统为指定进程设置硬件断点,eBPF去捕获断点事件并传给用户。

再看系统调用,它依赖于raw tracepint机制,它会捕获raw_tracepoint/sys_enterraw_tracepoint/sys_exit的事件,而且它内置了常见系统调用的参数格式,便于格式化输出对应参数。

它的另一个亮点是完整的调用栈回溯,这是在另一个项目unwinddaemon里实现的,具体来说,在Linux下栈回溯常见有两种方式:基于栈帧指针和基于DWARF调试信息,像ARM/X86都有栈帧约定,如果遵守约定是能直接根据栈回溯出完整的调用栈的,再根据/proc/<pid>/maps和对应elf的符号信息即可完成符号解析,但如果使用了-fomit-frame-pointer标志编译则不会有帧,还好有另一种方式,就是dwarf,它存储了在每个位置应该怎么回溯栈的方法,因此解析elf的.eh_frame|.eh_frame_hdr也可实现,该库基于安卓的libunwindstack库做的扩展,支持离线(提前给上下文信息)或在线(远程去取上下文数据)去回溯栈,除了支持native的回溯也支持java的回溯,不过后者每次回溯需要暂停jvm。

指令级追踪

之前的文章有讲过frida,它的stalker可做到指令级追踪,但实际用起来效果并不好,相对来说QBDI是个更专注该目的的框架,它通过llvm重新指令(JIT)来实现每指令/块级的插桩,以及内存访问的插桩,这部分以后再详细介绍吧!

social