Android Hook - 动态加载so库

3,091 阅读17分钟

本文将介绍动态加载so库的相关技术,目标是绕过Android系统限制,使得系统动态连接器(Linker)可以加载我们指定的任意so库。

这项技术通常是为了避免将一些so库打入APK中,从而减少APK体积,在运行时才根据需要去下载这些so库并且加载到内存运行。

另外,本文还会介绍so库加载的原理动态链接器namespace机制ELF文件的解析,为后续dlfcn绕过和实现plt hook打下基础。


一、System.load()加载SO库

1、System.load()

系统提供了方法System.load(String filename)可以用于加载指定路径下的SO库,这里传入的filename必须是绝对路径

以Demo(代码会在文末提供)中的例子libtestso1.so为例进行尝试,System.load()方法确实可以正常加载该so库。

然而使用System.loadLibrary()则会抛出UnsatisfiedLinkError异常:

java.lang.UnsatisfiedLinkError: dlopen failed: library "libtestso1.so" not found
  at java.lang.Runtime.loadLibrary0(Runtime.java:1082)                                       at java.lang.Runtime.loadLibrary0(Runtime.java:1003)
  at java.lang.System.loadLibrary(System.java:1661)

2、SO库加载原理

接下来通过简单梳理源码,介绍SO库加载的原理,了解为什么直接使用System.load()去加载so库可行,而System.loadLibrary()则会失败。

2.1、load()和loadLibrary()

libcore/ojluni/src/main/java/java/lang/System.java

public static void load(String filename) {
    Runtime.getRuntime().load0(Reflection.getCallerClass(), filename);
}

public static void loadLibrary(String libname) {
    Runtime.getRuntime().loadLibrary0(Reflection.getCallerClass(), libname);
}

libcore/ojluni/src/main/java/java/lang/Runtime.java

// BEGIN Android-changed: Different implementation of load0(Class, String).
synchronized void load0(Class<?> fromClass, String filename) {
    File file = new File(filename);
  	//1、必须是绝对路径
    if (!(file.isAbsolute())) {
        throw new UnsatisfiedLinkError(
            "Expecting an absolute path of the library: " + filename);
    }
    ...
    //2、必须是只读文件,省略检查代码

    //3、调用nativeLoad进行加载
    String error = nativeLoad(filename, fromClass.getClassLoader(), fromClass);
    if (error != null) {
        throw new UnsatisfiedLinkError(error);
    }
}

private synchronized void loadLibrary0(ClassLoader loader, Class<?> callerClass, String libname) {
  	//1、动态库名,不能包含/
    if (libname.indexOf((int)File.separatorChar) != -1) {
        throw new UnsatisfiedLinkError(
"Directory separator should not appear in library name: " + libname);
    }
    String libraryName = libname;    
  	//2、如果发起类的ClassLoader不是BootClassLoader,再进行处理,因此BootClassLoader没有findLibrary()方法
    if (loader != null && !(loader instanceof BootClassLoader)) {
      	//3、使用findLibrary(),用动态库名换回动态库的绝对路径
        String filename = loader.findLibrary(libraryName);
        if (filename == null &&
                (loader.getClass() == PathClassLoader.class ||
                 loader.getClass() == DelegateLastClassLoader.class)) {            
          	//4、如果没有找到,那么对于PathClassLoader、DelegateLastClassLoader,拼接为libxxx.so,然后尝试交给底层Linker去加载,因为它有机会通过关联的namespace找到目标动态库
            filename = System.mapLibraryName(libraryName);
        }
        ...
      	//5、调用nativeLoad进行加载
        String error = nativeLoad(filename, loader);
        ...
        return;
    }
		...
    //6、会给传入的libname前后分别拼接上lib和.so,即构成lib${libname}.so,也就是so库的完整文件名。
    String filename = System.mapLibraryName(libraryName);
  	//7、BootClassLoader,同样调用nativeLoad()继续加载
    String error = nativeLoad(filename, loader, callerClass);
    ...
}

从源码可以看出,无论是System.load()还是System.loadLibrary()最终都将调用nativeLoad()方法并传入文件路径和当前的ClassLoader

不同的是System.load()需要传入so文件的绝对路径,而System.loadLibrary()则传入so库的完整文件名


2.2、加载主流程

2.2.1、LoadNativeLibrary()

libcore/ojluni/src/main/native/Runtime.c

JNIEXPORT jstring JNICALL
Runtime_nativeLoad(JNIEnv* env, jclass ignored, jstring javaFilename,
                   jobject javaLoader, jclass caller)
{
 		//1、实际调用JVM_NativeLoad
    return JVM_NativeLoad(env, javaFilename, javaLoader, caller);
}

static JNINativeMethod gMethods[] = {
  ...
  NATIVE_METHOD(Runtime, nativeLoad,
                "(Ljava/lang/String;Ljava/lang/ClassLoader;Ljava/lang/Class;)"
                    "Ljava/lang/String;"),
};

art/openjdkjvm/OpenjdkJvm.cc

JNIEXPORT jstring JVM_NativeLoad(JNIEnv* env,
                                 jstring javaFilename,
                                 jobject javaLoader,
                                 jclass caller) {
  ...
  {
    art::JavaVMExt* vm = art::Runtime::Current()->GetJavaVM();
    //1、调用JavaVMExt->LoadNativeLibrary()
    bool success = vm->LoadNativeLibrary(env,
                                         filename.c_str(),
                                         javaLoader,
                                         caller,
                                         &error_msg);
    if (success) {
      //2、加载成功,则不返回错误
      return nullptr;
    }
  }

  ...
}

art/runtime/jni/java_vm_ext.cc

bool JavaVMExt::LoadNativeLibrary(JNIEnv* env,
                                  const std::string& path,
                                  jobject class_loader,
                                  jclass caller_class,
                                  std::string* error_msg) {
  ...
  SharedLibrary* library;
  Thread* self = Thread::Current();
  {    
    MutexLock mu(self, *Locks::jni_libraries_lock_);
    //1、检查是否已经加载过,也就是已经被虚拟机加载过的so不会被重复加载
    library = libraries_->Get(path);
  }
  void* class_loader_allocator = nullptr;
  std::string caller_location;
  {
    ScopedObjectAccess soa(env);
    // As the incoming class loader is reachable/alive during the call of this function,
    // it's okay to decode it without worrying about unexpectedly marking it alive.
    ObjPtr<mirror::ClassLoader> loader = soa.Decode<mirror::ClassLoader>(class_loader);

    ClassLinker* class_linker = Runtime::Current()->GetClassLinker();
    if (class_linker->IsBootClassLoader(loader)) {
      loader = nullptr;
      class_loader = nullptr;
    }
    if (caller_class != nullptr) {
      ObjPtr<mirror::Class> caller = soa.Decode<mirror::Class>(caller_class);
      ObjPtr<mirror::DexCache> dex_cache = caller->GetDexCache();
      if (dex_cache != nullptr) {
        //2、获调用类所在Dex文件的路径
        caller_location = dex_cache->GetLocation()->ToModifiedUtf8();
      }
    }

    //3、获取ClassLoader对应的allocator
    class_loader_allocator = class_linker->GetAllocatorForClassLoader(loader);
    CHECK(class_loader_allocator != nullptr);
  }
  //如果有记录,那么检查ClassLoader是否相同,因为JNI规定同一个动态库只能由同一个ClassLoader加载
  if (library != nullptr) {    
    //4、通过ClassLoader对应的allocator来判断ClassLoader是否相同,避免直接比较ClassLoader造成的开销
    if (library->GetClassLoaderAllocator() != class_loader_allocator) {
      // The library will be associated with class_loader. The JNI      
      ...
      return false;
    }   
    //5、检查之前JNI_OnLoad()方法是否调用成功
    if (!library->CheckOnLoadResult()) {
      ...
      return false;
    }
    return true;
  }
  
  //6、获取classLoader对应的动态库目录
  ScopedLocalRef<jstring> library_path(env, GetLibrarySearchPath(env, class_loader));

  ...
  //7、继续加载,注意传入library_path
  void* handle = android::OpenNativeLibrary(
      env,
      runtime_->GetTargetSdkVersion(),
      path_str,
      class_loader,
      (caller_location.empty() ? nullptr : caller_location.c_str()),
      library_path.get(),
      &needs_native_bridge,
      &nativeloader_error_msg); 
  ...  
  bool created_library = false;
  {
    //9、加载成功,构造一个SharedLibrary作为记录
    std::unique_ptr<SharedLibrary> new_library(
        new SharedLibrary(env,
                          self,
                          path,
                          handle,
                          needs_native_bridge,
                          class_loader,
                          class_loader_allocator));

    MutexLock mu(self, *Locks::jni_libraries_lock_);   
    library = libraries_->Get(path);
    //10、存储加载记录
    if (library == nullptr) {  // We won race to get libraries_lock.
      library = new_library.release();
      libraries_->Put(path, library);
      created_library = true;
    }
  }  
  ...
  bool was_successful = false;
  //11、找到JNI_OnLoad方法
  void* sym = library->FindSymbol("JNI_OnLoad", nullptr, android::kJNICallTypeRegular);
  if (sym == nullptr) {
    ...
    was_successful = true;
  } else {
    ...
    using JNI_OnLoadFn = int(*)(JavaVM*, void*);
    JNI_OnLoadFn jni_on_load = reinterpret_cast<JNI_OnLoadFn>(sym);
    //12、调用JNI_OnLoad方法
    int version = (*jni_on_load)(this, nullptr);

    ...

    if (version == JNI_ERR) {
      ...
    } else {
      was_successful = true;
    }
    ...
  }

  //13、记录JNI_OnLoad方法的结果
  library->SetResult(was_successful);
  return was_successful;
}

从源码看出,虚拟机加载so库,有以下步骤:

  1. 检查是否so库已经加载过。
    1. 加载过的so都会被记录下来,避免重复加载。
    2. 如果已经加载过,那需要保证当前ClassLoader和首次加载的ClassLoader相同,因为规定同一个SO库只能被同一个ClassLoader加载
  2. 调用android::OpenNativeLibrary()完成加载,并且传入从当前ClassLoader中获得的library_path
  3. 如果加载成功,那么会找到动态库对应的JNI_OnLoad()方法调用,并记录调用结果,如果结果是JNI_ERR,表示SO库加载成功了,但是初始化失败,下次也不会重新加载。

2.2.2、GetLibrarySearchPath()

art/runtime/jni/java_vm_ext.cc

jstring JavaVMExt::GetLibrarySearchPath(JNIEnv* env, jobject class_loader) {
  if (class_loader == nullptr) {
    return nullptr;
  }
  ScopedObjectAccess soa(env);
  ObjPtr<mirror::Object> mirror_class_loader = soa.Decode<mirror::Object>(class_loader);
  //1、不是BaseDexClassLoader或其子类,返回Null
  if (!mirror_class_loader->InstanceOf(WellKnownClasses::dalvik_system_BaseDexClassLoader.Get())) {
    return nullptr;
  }
  return soa.AddLocalReference<jstring>(
    	//2、调用getLdLibraryPath()这个jni方法
      WellKnownClasses::dalvik_system_BaseDexClassLoader_getLdLibraryPath->InvokeVirtual<'L'>(
          soa.Self(), mirror_class_loader));
}

libcore/dalvik/src/main/java/dalvik/system/BaseDexClassLoader.java

///1、、最终调用Java层的BaseDexClassLoader.getLdLibraryPath()方法,
public @NonNull String getLdLibraryPath() {
    StringBuilder result = new StringBuilder();  
  	//2、从pathList中获取,pathList是BaseDexClassLoader的成员,在其构造函数中初始化。
    for (File directory : pathList.getNativeLibraryDirectories()) {
        if (result.length() > 0) {
            result.append(':');
        }
        result.append(directory);
    }

    return result.toString();
}

private final DexPathList pathList;

public BaseDexClassLoader(String dexPath,
            String librarySearchPath, ClassLoader parent, ClassLoader[] sharedLibraryLoaders,
            ClassLoader[] sharedLibraryLoadersAfter,
            boolean isTrusted) {
  ...
  //3、librarySearchPath也就是BaseDexClassLoader所能加载的SO库的目录,在构造函数中传给DexPathList
  this.pathList = new DexPathList(this, dexPath, librarySearchPath, null, isTrusted);
  ...
}

public DexPathList(ClassLoader definingContext, String librarySearchPath) {
        ...
        //4、librarySearchPath字符串实际包含多个目录路径,它们由:拼接,因此需要把它给拆分了。可以看到在BaseDexClassLoader.getLdLibraryPath(),又把它们使用:拼接再作为结果返回。
        this.nativeLibraryDirectories = splitPaths(librarySearchPath, false);
        ...
    }

public List<File> getNativeLibraryDirectories() {
  		//5、返回nativeLibraryDirectories
      return nativeLibraryDirectories;
  }

从上面源码看出,BaseDexClassLoaderlibrary_path实际是它构造函数时传入的so库所在的目录路径列表转换来的,也就是说library_path就是这个BaseDexClassLoader默认所能加载的so库的目录列表。


2.3、OpenNativeLibrary()

接下来,通过OpenNativeLibrary()方法看看是不是真的如此,而OpenNativeLibrary()涉及动态链接器的Namespace机制(在下文介绍),因此只展示相关核心代码。

art/libnativeloader/native_loader.cpp

void* OpenNativeLibrary(JNIEnv* env,
                        int32_t target_sdk_version,
                        const char* path,
                        jobject class_loader,
                        const char* caller_location,
                        jstring library_path_j,
                        bool* needs_native_bridge,
                        char** error_msg) {
#if defined(ART_TARGET_ANDROID)
  ...  
#else   // !ART_TARGET_ANDROID.这里以非ART虚拟机为例。    
  std::string library_path;  // Empty string by default.
  //1、如果传入的path不是以/开头,也就是不是绝对路径,那么才使用前面传入的library_path
  if (library_path_j != nullptr && path != nullptr && path[0] != '/') {
    ScopedUtfChars library_path_utf_chars(env, library_path_j);
    library_path = library_path_utf_chars.c_str();
  }

  //2、正如前面说的,library_path是多个目录路径使用:拼接的,这个需要拆分。如果library_path是空字符串,那么library_paths则只包含一个空字符串,从而可以进入一次下面的循环。
  std::vector<std::string> library_paths = base::Split(library_path, ":");

  //3、遍历所有路径,也是so可能存在的目录
  for (const std::string& lib_path : library_paths) {
    *needs_native_bridge = false;
    const char* path_arg;
    std::string complete_path;
    if (path == nullptr) {
      // Preserve null.
      path_arg = nullptr;
    } else {
      complete_path = lib_path;
      //4、如果path不是绝对路径,而是so文件的名称,那么拼接上lib_path,从而构造完整的so文件所在的完整路径。如果path是绝对路径,lib_path就是空的,因此会使用path本身。
      if (!complete_path.empty()) {
        complete_path.append("/");
      }
      complete_path.append(path);
      path_arg = complete_path.c_str();
    }
    //5、使用dlopen加载
    void* handle = dlopen(path_arg, RTLD_NOW);
    if (handle != nullptr) {
      return handle;
    }
    ...
  }
  return nullptr;
#endif  // !ART_TARGET_ANDROID
}

从代码注释可以看出,我们没有分析ART虚拟机情况下的代码(原因是太复杂了,涉及动态连接器命名空间机制)。但两种情况最终的机制都是一样的,即:

  1. 当path是绝对路径,即对应System.load()的情况,直接将path交由dlopen()处理。
  2. 当path是文件名,即对应System.loadLibray()的情况,则需要拼接上classloader对应的library_paths,即classloader所能加载的so库的目录,从而构成完整的so文件路径,再交由dlopen()处理。

dlopen是Linux系统加载动态库的函数,最终会交给动态链接器处理,Android在调用它前加了一些自己的逻辑。

具体参考man7.org/linux/man-p…

这解释了为什么System.loadLibray()会加载失败,原因是我们加载的so库所在的目录,并不在ClassLoader对应的library_paths中。


二、System.loadLibrary()优化

正如前面所述,使用System.load()传入so库的绝对路径,就可以动态加载so库了。

然而我们在现实项目中依赖的so库,有可能是使用System.loadLibrary()加载的,当然我们可以在动态加载so库的前提下,把System.loadLibrary()全部改成System.load(),但是对于依赖的三方代码,则不是很好修改。

因此,需要优化System.loadLibrary()使得它也可以动态加载so库,而优化的方式则藏在前面的so库加载原理中。

1、修改DexPathList.nativeLibraryDirectories

在so库加载原理中已经提到,System.loadLibrary()实际是将传入的so库名称和DexPathList.nativeLibraryDirectories的目录,拼接成最终的so文件路径再使用dlopen()加载的。

因此,我们只需要将so库所在的目录,加入到DexPathList.nativeLibraryDirectories中,就可以拼接出正确的文件路径,从而加载成功。

这使用反射很容易做到。

libcore/dalvik/src/main/java/dalvik/system/DexPathList.java

//目标是修改nativeLibraryDirectories
private final List<File> nativeLibraryDirectories;

DexPathList(ClassLoader definingContext, String dexPath,
            String librarySearchPath, File optimizedDirectory, boolean isTrusted) {          
      ...
      //1、使用:分割librarySearchPath,得到nativeLibraryDirectories
      this.nativeLibraryDirectories = splitPaths(librarySearchPath, false);
  		//2、读取虚拟机设置,即系统so库所在的目录
      this.systemNativeLibraryDirectories =
              splitPaths(System.getProperty("java.library.path"), true);
  		//3、getAllNativeLibraryDirectories()会返回nativeLibraryDirectories + systemNativeLibraryDirectories,再调用makePathElements()生成对应的nativeLibraryPathElements
      this.nativeLibraryPathElements = makePathElements(getAllNativeLibraryDirectories());

      ...
  }

private List<File> getAllNativeLibraryDirectories() {
        List<File> allNativeLibraryDirectories = new ArrayList<>(nativeLibraryDirectories);
        allNativeLibraryDirectories.addAll(systemNativeLibraryDirectories);
        return allNativeLibraryDirectories;
    }

通过上面DexPathList源码看出,nativeLibraryPathElements是通过nativeLibraryDirectories生成的,因此如果反射修改了nativeLibraryDirectories,那么要一并修改nativeLibraryPathElements

另外需要注意,nativeLibraryDirectories类型是ArrayList,因此修改nativeLibraryDirectories就可能会面临**ConcurrentModificationException**。

即当我们修改nativeLibraryDirectories的同时又加载so库从而触发BaseDexClassLoader.getLdLibraryPath()去遍历nativeLibraryDirectories,就有可能异常,这种概率很小但并非不可能。

为此,我们将nativeLibraryDirectories复制一份再修改,然后反射直接替换DexPathList.nativeLibraryDirectories的值即可,从而避免了对nativeLibraryDirectories的直接修改。

最终,在Android N及以上版本,反射修改nativeLibraryDirectories的逻辑如下:

private val pathListField = lazy {
    BaseDexClassLoader::class.java.getDeclaredField("pathList").apply {
        isAccessible = true
    }
}

private object NativeLibraryPathAppenderApi25 : ILibraryPathAppender {
    override fun append(classLoader: BaseDexClassLoader, folder: File): Boolean = runCatching {
        //1、反射获取pathList
        val pathList = pathListField.value.get(classLoader)
        val nativeLibraryDirectoriesField =
            pathList::class.java.getDeclaredField("nativeLibraryDirectories").apply {
                isAccessible = true
            }
        //2、复制一份nativeLibraryDirectories,并将so所在的目录添加到其中
        val nativeLibraryDirectories: ArrayList<File> =
            ArrayList(
                nativeLibraryDirectoriesField.get(pathList) as ArrayList<File>?
                    ?: ArrayList(2)
            ).apply {
                removeAll {
                    it == folder
                }
                add(0, folder)
            }

        //3、复制一份systemNativeLibraryDirectories
        val systemNativeLibraryDirectories =
            pathList::class.java.getDeclaredField("systemNativeLibraryDirectories").run {
                isAccessible = true
                ArrayList(get(pathList) as ArrayList<File>? ?: ArrayList(2))
            }
        //4、等价于调用getAllNativeLibraryDirectories()
        val newLibDirs = systemNativeLibraryDirectories + nativeLibraryDirectories

        //5、调用makePathElements生成nativeLibraryPathElements
        val dexElements = pathList::class.java.getDeclaredMethod(
            "makePathElements",
            List::class.java,
        ).run {
            isAccessible = true
            invoke(null, newLibDirs) as Array<Any>
        }

        //6、最后修改nativeLibraryDirectories、nativeLibraryPathElements
        nativeLibraryDirectoriesField.set(pathList, nativeLibraryDirectories)
        pathList::class.java.getDeclaredField("nativeLibraryPathElements").apply {
            isAccessible = true
            set(pathList, dexElements)
        }
        true
    }.getOrDefault(false)
}

2、版本兼容

前面介绍的是Android N及以上版本,反射修改nativeLibraryDirectories的逻辑(需要一并修改nativeLibraryPathElements)。

而不同版本DexPathList的源码稍有变化,因此需要进行兼容,但是反射修改nativeLibraryDirectories的目标没有变化。

当前主流的minsdkVersion是21,从21开始有三个版本的DexPathList实现变更对修改nativeLibraryDirectories的逻辑有影响,它们分别是Android L(API 21)Android M(API 23)Android N_MR1(API 25)

读者可以通过阅读它们的源码自行实现,也可以在文末的Github链接找到对应代码。


3、隐藏API风险

2024-12-21 16:00:38.973 14095-14095 .muye.elfloader         com.muye.elfloader                   W  Accessing hidden field Ldalvik/system/BaseDexClassLoader;->pathList:Ldalvik/system/DexPathList; (unsupported, reflection, allowed)
2024-12-21 16:00:38.973 14095-14095 .muye.elfloader         com.muye.elfloader                   W  Accessing hidden field Ldalvik/system/DexPathList;->nativeLibraryDirectories:Ljava/util/List; (unsupported, reflection, allowed)
2024-12-21 16:00:38.973 14095-14095 .muye.elfloader         com.muye.elfloader                   W  Accessing hidden field Ldalvik/system/DexPathList;->systemNativeLibraryDirectories:Ljava/util/List; (unsupported, reflection, allowed)
2024-12-21 16:00:38.974 14095-14095 .muye.elfloader         com.muye.elfloader                   W  Accessing hidden method Ldalvik/system/DexPathList;->makePathElements(Ljava/util/List;)[Ldalvik/system/DexPathList$NativeLibraryElement; (unsupported, reflection, allowed)
2024-12-21 16:00:38.974 14095-14095 .muye.elfloader         com.muye.elfloader                   W  Accessing hidden field Ldalvik/system/DexPathList;->nativeLibraryPathElements:[Ldalvik/system/DexPathList$NativeLibraryElement; (unsupported, reflection, allowed)

在使用反射修改DexPathList.nativeLibraryDirectories成功后,会发现Logcat有如上日志。

这表明DexPathList.nativeLibraryDirectories是隐藏API,但当前的级别仍然是allowed,因此可以反射修改成功。

但这意味着Android后续版本可能禁止直接反射修改DexPathList.nativeLibraryDirectories

对于如何绕过隐藏API限制,在Android Hook - 隐藏API拦截机制Android Hook - 隐藏API绕过实践中详细介绍了8种方案,读者可以任选一种实现。


三、SO库依赖

前文介绍了使用Sytem.load()能够加载指定路径下的so库,并且以加载libtestso1.so来验证并成功。

然而这是因为libtestso1.so并没有依赖我们的其他so库(系统库除外,指业务自己的so库)。

当我们在Android N以上系统,尝试加载libtestso2.so(依赖于libtestso1.so,并且和libtestso1.so位于同一目录下)的时候,就会出现异常:

java.lang.UnsatisfiedLinkError: dlopen failed: library "libtestso1.so" not found: needed by /data/data/com.muye.elfloader/files/lib/arm64-v8a/libtestso2.so in namespace clns-6
at java.lang.Runtime.load0(Runtime.java:933)
at java.lang.System.load(System.java:1625)

libtestso1.so明明是单独可以加载成功的,并且和libtestso2.so在同一目录下,但是日志却表明找不到libtestso1.so

尽管我们修改了DexPathList.nativeLibraryDirectories加入了libtestso1.so、libtestso2.so所在的目录,仍然会出现这个问题。

1、SO库加载的顺序

当我们加载so库时,如果这个so库依赖于其他so库,那么则会先加载其依赖,并且这个过程的递归执行的。

因此,当尝试加载libtestso2.so时,Linker会先加载其依赖的libtestso1.so,上面失败的原因就是Linker没有成功加载libtestso1.so

如果我们先使用Sytem.load()加载libtestso1.so,再加载libtestso2.so,则可以加载成功。

SO库加载原理中提到,虚拟机会在ClassLoder指定的目录中查找对应so库,那为什么没有找libtestso1.so呢。

这是因为这个逻辑是在Sytem.load()流程中触发的,也就是只有Sytem.load()才会在ClassLoder指定的目录中查找。

libtestso2.solibtestso1.so的依赖,则Linker(动态链接器)去解析并执行的,并不在Sytem.load()流程中。

那Linker(动态链接器)是怎么解析libtestso2.solibtestso1.so的依赖呢,为什么似乎没有解析成功,这需要首先介绍动态链接的Namespace机制


2、动态链接器Namespace

根据官方文档链接器命名空间的介绍,动态链接器Namespace是为了隔离不同链接器命名空间中的共享库,以确保具有相同库名称和不同符号的库不会发生冲突。

动态链接器Namespace机制是由libnativeloader这个库和Linker一起实现的,详细文档参考libnativeloader

由于这个机制比较复杂,为了减少理解成本,将会在其他文章详细介绍。这里只需了解和so依赖查找相关的内容。

简单来说,动态链接Namespace有以下规则:

  1. 每个ClassLoader对应一个单独的Namespace。这个Namespace和JAVA层的DexPathList相似,记录着ClassLoader对应的library_paths等信息。
  2. ClassLoader对应的Namespace在ClassLoader首次加载so库时创建Namespace只会创建一次,这意味着Namespace创建后不会被修改。并且通常是ClassLoader首次加载so库时创建,也可以主动创建(例如系统会在APP进程启动后主动给PathClassLoader创建一个Namespace)。
  3. Linker在查找so依赖时,就是在Namespace的library_paths中查找的library_paths就是BaseDexClassLoader初始化时传入的动态库目录列表,这和DexPathList.nativeLibraryDirectories是一致的。

bionic/linker/linker_namespaces.h

struct android_namespace_t {
  ...
 private:
  ...
  //1、ld_library_paths_记录这so库的查找目录
  std::vector<std::string> ld_library_paths_;
  std::vector<std::string> default_library_paths_;
  ...
  soinfo_list_t soinfo_list_;
}

了解上述规则后,我们就可以知道加载libtestso2.so却找不到libtestso1.so的原因了。

原因是libtestso2.solibtestso1.so所在的目录,可以通过反射加入到DexPathList.nativeLibraryDirectories中,然而此时通常ClassLoader对应的Namespace已经创建了,修改DexPathList.nativeLibraryDirectories并不会影响android_namespace_t.ld_library_paths_,进而导致Linker通过android_namespace_t.ld_library_paths_查找libtestso1.so时找不到。

既然如此,一个显然的方案是新建一个DexClassLoader,并在其构造时就将libtestso2.solibtestso1.so所在的目录路径传入。此时使用这个DexClassLoader加载libtestso2.so,就会创建Namespace,而Namespace则包含libtestso2.solibtestso1.so所在的目录,进而可以顺利通过依赖关系找到libtestso1.so

然而这个方案在我们可能需要添加更多so目录时,并不方便,除非每次都创建新的DexClassLoader。

另外一种方案是主动解析so库的依赖关系,先用System.load()加载其依赖,由于System.load()是在DexPathList.nativeLibraryDirectories中查找so库的,因此也可以加载成功。

这需要我们了解so库的文件格式,即ELF文件格式,并从中解析出so库的依赖关系。


3、ELF文件格式

ELF(全称Executable and Linkable Format)文件格式是Android/Linker系统上so库(动态库)的文件格式,了解ELF文件格式对后续更多Hook的实现有着非常重要的意义。

在网上可以找到很多关于ELF文件格式的文章,例如Executable and Linkable Format等,读者可以自行搜索。

ELF文件格式比较复杂,直接长篇累牍的介绍ELF文件格式并没有实际意义,因此本文作为了解ELF文件格式的开端,只介绍ELF中关于so库依赖关系的部分,并结合实践来帮助读者理解。更多ELF格式的内容,会在后续文章中结合实践来展开。

3.1、使用工具查看ELF文件

在了解ELF文件格式前,介绍一些常用工具。

  1. readelf。readelf 是一个用于查看 ELF 文件(Executable and Linkable Format)内容的命令行工具。该工具在安装ndk时自带了。

    例如我们可以通过readelf查看libtestso2.so的依赖关系:

    $ /Users/xxx/Library/Android/sdk/ndk/26.1.10909125/toolchains/llvm/prebuilt/darwin-x86_64/bin/llvm-readelf -d libtestso2.so
    Dynamic section at offset 0x668 contains 27 entries:
      Tag                Type           Name/Value
      # 1、Type为NEEDED,就是libtestso2.so需要依赖so库
      0x0000000000000001 (NEEDED)       Shared library: [libtestso1.so]
      0x0000000000000001 (NEEDED)       Shared library: [libm.so]
      0x0000000000000001 (NEEDED)       Shared library: [libdl.so]
      0x0000000000000001 (NEEDED)       Shared library: [libc.so]
      # 2、Library soname,就是so库本身的名称,可以看到是libtestso2.so
      0x000000000000000e (SONAME)       Library soname: [libtestso2.so]
      ...
      # 3、最后一项类型必须为NULL,这标记这Dynamic section的结束。
      0x0000000000000000 (NULL)         0x0
    
  2. 010 Editor。010 Editor用于可视化查看各种格式的二进制文件。借助它我们可以直观查看ELF文件格式。
    Android Hook - 动态加载so库.png

从010 Editor可以看出ELF文件有elf_headerprogram_header_tablesection_header_table等结构,通过readelf可以解析出so库的依赖,这些依赖以库名表示。我们的目的就是通过代码解析so库,进而找到其依赖。


3.2、ELF文件格式(SO依赖相关)

3.2.1、ELF文件头

ELF文件的起始,是一个称为ELF Header的结构,即ELF文件头。它携带着ELF文件的基本信息,我们可以使用readelf来查看。

% /Users/XXX/Library/Android/sdk/ndk/26.1.10909125/toolchains/llvm/prebuilt/darwin-x86_64/bin/llvm-readelf -h libtestso2.so
ELF Header:
	#ELF的前16个字节记录着重要信息。前4个字节7f 45 4c 46是魔数,即ELF文件的标志。
	#后续每个字节分别表示Class(0x02)、Data(0x01)、Version(0x01)、OS/ABI(0x00)、ABI Version(0x00)。最后7个字节为保留位,都是0x00。
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
  #文件机器字节长度
  Class:                             ELF64
  #多字节数据存储方式,即大端小端
  Data:                              2's complement, little endian
  #ELF文件版本,当前都是1
  Version:                           1 (current)
  #运行平台
  OS/ABI:                            UNIX - System V
  #ABI版本,都是0
  ABI Version:                       0

  #ELF文件类型,DYN表示SO文件,即Shared object file
  Type:                              DYN (Shared object file)
  #硬件平台,这里表示Arm64
  Machine:                           AArch64
  #硬件平台版本
  Version:                           0x1
  #程序入口地址,对于SO文件都是0
  Entry point address:               0x0
  #Program表在文件中的偏移
  Start of program headers:          64 (bytes into file)
  #Section表在文件中的偏移
  Start of section headers:          4440 (bytes into file)
  #平台相关标志
  Flags:                             0x0
  #ELF头大小,通常是64字节
  Size of this header:               64 (bytes)
  ##Program表大小
  Size of program headers:           56 (bytes)
  #Program表项数量
  Number of program headers:         8
  #Section表大小
  Size of section headers:           64 (bytes)
  #Section表项的数量
  Number of section headers:         28
  #Section名字符串表下标
  Section header string table index: 26

使用010 Editor则可以更直观地了解其结构。

Android Hook - 动态加载so库3.png 其中一些项需要重点说明:

  1. 前16个字节。ELF文件的前16个字节,称为e_ident,记录着重要数据。
    1. 前四个字节固定7f 45 4c 46,这是ELF文件的标志。因为我们在检查一个文件是否ELF文件时,就需要检查这个标志。
    2. 第5个字节为ei_class。记录这个ELF文件是32位,还是64位的。例子中是64位。
    3. 第6个字节为ei_data。记录着字节序,即多字节数据怎么读取到内存,及所谓的大端小端。例子中是LSB即小端。
    4. 其他字节不是很重要,只需要按照固定格式解析出来备用即可。
  2. e_type。第17个字节,记录这ELF文件的类型,DYN表示这是一个动态库
  3. Program表。Program表(段表),是ELF文件头中的重要数据,记录这ELF文件在运行时信息。
    1. Start of program headers。表示Program表在文件中的偏移,也就是Program表从哪里开始。
    2. Size of program headers。Program表的大小,即占用多少字节。
    3. Number of program headers。Program表项的数量,用Size of program headers除以Number of program headers,就可以求出每个表项的大小。
  4. Section表。Section表(段表),是ELF文件头中的重要数据,记录这ELF文件在编译时信息。
    1. Start of section headers。表示Section表在文件中的偏移,也就是Section表从哪里开始。
    2. Size of section headers。Section表的大小,即占用多少字节。
    3. Number of section headers。Section表项的数量,用Size of section headers除以Number of section headers,就可以求出每个表项的大小。

其中Program表Section表至关重要,它们的每项记录着ELF文件内容的组成

其中Section表是从ELF文件编译的角度去描述ELF文件内容,它把内容分为多个Section(节),例如我们常见的.text.data,即文本节和数据节等,可以在编译时把数据放到不同的Section中,这相当于我们给某一段数据起了个名称,用来解释它的用途。

Program表是从ELF文件运行的角度去描述ELF文件内容,它把内容分为多个Program(段)。在ELF文件加载到内存后,Linker实际不关心每个Section(节)的用途,它只关心这些数据是可读、可写还是可执行的。

Android Hook - 动态加载so库2.png

因此Program表Section表是对同一ELF文件数据的不同组织方式,编译器和动态链接器(Linker)对ELF文件关注的侧重点不同,即看待ELF文件的角度/方式不同。

举个不恰当的例子,就例如同样是你这个人,公司关注的是你的工作经历,伴侣关注的是你的情感经历。公司可能是从年龄、工作能力、学历来描述你,而伴侣则从年龄、性格、收入来描述你。尽管两者可能有相同之处,但总的来说,这是对同一个事物的不同视角

对于ELF文件也是如此,如果以Program表的角度看待,则称为运行视图。以Section表的角度看待,则称为链接视图


3.2.2、Section表

根据ELF Header的描述,我们可以开始解析Section表Section表由一个个连续的表项组成,每个表项记录着一个Section(节)的信息,表项有它的固定结构

同样可以使用readelf来查看Section表。

% /Users/XXX/Library/Android/sdk/ndk/26.1.10909125/toolchains/llvm/prebuilt/darwin-x86_64/bin/llvm-readelf -S libtestso2.so
There are 28 section headers, starting at offset 0x1158:

Section Headers:
  [Nr] Name              Type            Address          Off    Size   ES Flg Lk Inf Al
  # 1、第一项总是NULL
  [ 0]                   NULL            0000000000000000 000000 000000 00      0   0  0
  ...
  # 2、.text节,即代码节,最常见的节之一。Type为PROGBITS说明记录着机器代码(即编译后的指令)。
  [13] .text             PROGBITS        0000000000000598 000598 000068 00  AX  0   0  4
  ...
  # 3、
  [17] .dynamic          DYNAMIC         0000000000001668 000668 0001b0 10  WA  8   0  8
  ...
  # 4、符号表,记录ELF文件的符号信息
  [25] .symtab           SYMTAB          0000000000000000 000b78 000360 18     27  32  8
  ...
  # 5、字符串表,表示一个字符串池,可以通过偏移量从池子中读取字符串
  [27] .strtab           STRTAB          0000000000000000 001001 000154 00      0   0  1
Key to Flags:
  W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
  L (link order), O (extra OS processing required), G (group), T (TLS),
  C (compressed), x (unknown), o (OS specific), E (exclude),
  R (retain), p (processor specific)

可以看出,每个Section项都包含NameTypeOff等信息,具体的结构是这样的:

elf.h

typedef struct elf64_shdr {
  //节名
  Elf64_Word sh_name;		/* Section name, index in string tbl */
  //节的类型
  Elf64_Word sh_type;		/* Type of section */
  //节的标志位
  Elf64_Xword sh_flags;		/* Miscellaneous section attributes */
  //节虚拟地址
  Elf64_Addr sh_addr;		/* Section virtual addr at execution */
  //节偏移
  Elf64_Off sh_offset;		/* Section file offset */
  //节的长度
  Elf64_Xword sh_size;		/* Size of section in bytes */
  //节链接信息
  Elf64_Word sh_link;		/* Index of another section */
  //节链接信息
  Elf64_Word sh_info;		/* Additional section information */
  //节地址对齐
  Elf64_Xword sh_addralign;	/* Section alignment */
  //节项大小
  Elf64_Xword sh_entsize;	/* Entry size if section holds table */
} Elf64_Shdr;

其中Type表示Section的类型,Off表示Section起始位置在ELF文件中的偏移量。其他成员如注释所述,不特别强调。

正如readelf的输出所示,Section表中记录了很多Section(节),某些特殊的Section(节)例如.text.symtab有很重要的意义,但是由于这次不涉及,因此不关注。

实际解析SO的依赖关系的时候,我们并不需要使用到Section,但是由于Section很重要,因此在这里做简单介绍。


3.2.3、Program表(段表)和Dynamic表

Program表(段表)是从动态链接器的视角看ELF文件的,同样可以使用readelf来查看Program表。

% /Users/XXX/Library/Android/sdk/ndk/26.1.10909125/toolchains/llvm/prebuilt/darwin-x86_64/bin/llvm-readelf -l libtestso2.so

Elf file type is DYN (Shared object file)
Entry point 0x0
There are 8 program headers, starting at offset 64

Program Headers:
  Type           Offset   VirtAddr           PhysAddr           FileSiz  MemSiz   Flg Align
  PHDR           0x000040 0x0000000000000040 0x0000000000000040 0x0001c0 0x0001c0 R   0x8
  LOAD           0x000000 0x0000000000000000 0x0000000000000000 0x000650 0x000650 R E 0x1000
  LOAD           0x000650 0x0000000000001650 0x0000000000001650 0x0001f8 0x0009b0 RW  0x1000
  DYNAMIC        0x000668 0x0000000000001668 0x0000000000001668 0x0001b0 0x0001b0 RW  0x8
  GNU_RELRO      0x000650 0x0000000000001650 0x0000000000001650 0x0001f8 0x0009b0 R   0x1
  GNU_EH_FRAME   0x0004b0 0x00000000000004b0 0x00000000000004b0 0x00003c 0x00003c R   0x4
  GNU_STACK      0x000000 0x0000000000000000 0x0000000000000000 0x000000 0x000000 RW  0x0
  NOTE           0x000200 0x0000000000000200 0x0000000000000200 0x0000bc 0x0000bc R   0x4

 Section to Segment mapping:
  Segment Sections...
   00     
   01     .note.android.ident .note.gnu.build-id .dynsym .gnu.version .gnu.version_r .gnu.hash .hash .dynstr .rela.dyn .rela.plt .eh_frame_hdr .eh_frame .text .plt 
   02     .data.rel.ro .fini_array .dynamic .got.plt .relro_padding 
   03     .dynamic 
   04     .data.rel.ro .fini_array .dynamic .got.plt .relro_padding 
   05     .eh_frame_hdr 
   06     
   07     .note.android.ident .note.gnu.build-id 
   None   .comment .debug_abbrev .debug_info .debug_str .debug_line .symtab .shstrtab .strtab 

可以看出,每个Program项都包含TypeOffsetFlg等信息,具体的结构是这样的:

elf.h

typedef struct elf64_phdr {
  //段类型
  Elf64_Word p_type;
  //段的权限属性
  Elf64_Word p_flags;
  //段在文件中的偏移
  Elf64_Off p_offset;		/* Segment file offset */
  //段在虚拟进程地址空间的起始位置。整个程序头表中,所有LOAD类型的元素按照p_vaddr从小到大排列
  Elf64_Addr p_vaddr;		/* Segment virtual address */
  //段的物理地址,这个值一般和p_vaddr一致
  Elf64_Addr p_paddr;		/* Segment physical address */
  //段在文件中的长度,可能是0则表示段在ELF文件中不存在
  Elf64_Xword p_filesz;		/* Segment size in file */
  //段在进程虚拟地址空间中所占用的长度,以字节为单位。它的值可能是0.
  Elf64_Xword p_memsz;		/* Segment size in memory */
  //段的对齐属性。实际对齐字节等于2的p_alig次。比如p_align等于10,实际等于2^10 = 1024字节。
  Elf64_Xword p_align;		/* Segment alignment, file & memory */
} Elf64_Phdr;

其中我们关注TypeDYNAMIC的段,称为DYNAMIC段。

DYNAMIC        0x000668 0x0000000000001668 0x0000000000001668 0x0001b0 0x0001b0 RW  0x8

DYNAMIC段也是一个列表,每项的类型为Dyn,具体结构是:

typedef struct {
  //表项类型
  Elf64_Sxword d_tag;		/* entry tag value */
  //表项值,如果是整数则读d_val,如果是地址则读d_ptr,具体取决于d_tag
  union {
    Elf64_Xword d_val;
    Elf64_Addr d_ptr;
  } d_un;
} Elf64_Dyn;

Dyn结构很简单,可以视为一个key-Value Pair。

实际上,我们需要找到SO依赖信息,正是记录在DYNAMIC段中。根据d_tag值的不同,Dyn的含义不同。

d_tag值可以取以下值:

/* This is the info that is needed to parse the dynamic section of the file */
#define DT_NULL		0
#define DT_NEEDED	1
#define DT_PLTRELSZ	2
#define DT_PLTGOT	3
#define DT_HASH		4
#define DT_STRTAB	5
#define DT_SYMTAB	6
#define DT_RELA		7
#define DT_RELASZ	8
...

这里我们只需要关注:

  1. d_tagDT_STRTAB(5)时,Dyn.d_val表示一个字符串表的起始偏移。在ELF文件中,用到的字符串会使用\0连接并连续存储在一起,从而构成了一个名为dynstr的Section。

    使用010 Editor可以更直观得看出它们之间的关系。 Android Hook - 动态加载so库4.png

  2. d_tagDT_NEEDED(1)时,说明这一项记录的是SO的一个依赖的名称,名称是字符串,因此此时Dyn.d_val表示该字符串在.dynstr中的偏移

根据上面的信息,就可以找到SO依赖的具体信息了。

我们遍历DYNAMIC段,找到d_tagDT_NEEDED(1)的项,每项表示一个依赖名称。但具体的依赖名称字符串,则需要从d_tagDT_STRTAB(5)表示的字符串表中查找。

SO依赖名称 = dyn.d_val(dyn.tag = DT_NEEDED) + dyn.d_val(dyn.tag = DT_STRTAB)

大家可以使用010 Editor进行验证。

这里只介绍了DT_NEEDEDDT_STRTAB两种类型,更多类型的含义和作用可以见Dynamic Section


4、代码解析SO依赖

在了解ELF文件头、Program表(段表)、Dynamic表的基本组成后,我们就可以动手解析so文件了。

Android Hook - 动态加载so库5.webp.svg

ELF文件的格式是固定,每个数据占多少字节,可以参考elf.h

另外网上也有很多解析ELF文件的例子,例如Android源码中的ReadElf.java

这里来说明一些需要注意的点。

  1. 需要通过ELF文件开头的魔数Type等,来检验当前解析的是否SO库。
  2. 需要通过ELF文件开头的DATAClass来决定怎么读取多字节数据。

大家可以自行解析一遍,从而熟悉ELF文件格式。也可以参考ElfLoader


5、加载SO依赖

libtestso2.so为例,它解析出的依赖是:

$ /Users/xxx/Library/Android/sdk/ndk/26.1.10909125/toolchains/llvm/prebuilt/darwin-x86_64/bin/llvm-readelf -d libtestso2.so
Dynamic section at offset 0x668 contains 27 entries:
  Tag                Type           Name/Value
  # 1、Type为NEEDED,就是libtestso2.so需要依赖so库
  0x0000000000000001 (NEEDED)       Shared library: [libtestso1.so]
  0x0000000000000001 (NEEDED)       Shared library: [libm.so]
  0x0000000000000001 (NEEDED)       Shared library: [libdl.so]
  0x0000000000000001 (NEEDED)       Shared library: [libc.so]

这些依赖有的是系统so库,有的是我们的业务so库。对于系统so库,使用System.loadLibrary()加载,对于业务so库,则需要拼接上目录路径,得到完整so的路径,再使用System.load()去加载。

然而虽然通过解析ELF文件,虽然得到这些依赖的名称,却无法区分是系统so库还是业务so库。

因此可以先尝试使用System.loadLibrary()加载,如果失败了(抛出异常),说明不是系统库。再尝试拼接目录路径,使用System.load()去加载。

具体流程是这样的:

//该方法用于加载指定路径的so库
fun load(file: File, autoInstall: Boolean = true): Boolean = kotlin.runCatching {
    //1、检查so库文件是否存在
    if (!file.exists()) {
        return false
    }
    if (runCatching {
            //2、先尝试使用System.load()加载,如果没有依赖其他业务so,这一步就可以结束了
            System.load(file.absolutePath)
            true
        }.getOrDefault(false)) {
        return true
    }

    //3、否则,先把so库所在的目录,添加到classLoader中,这里省略install代码
    if (autoInstall) {
        file.parentFile?.apply {
            install(this)
        }
    }

    //4、根据ELF文件格式,解析出so库的依赖
    ReadElf(file).use { elf ->
        elf.getDynByTag(DT_NEEDED).map {
            elf.getString(it.d_val)
        }
    }.forEach {
        //5、使用loadLibrary()尝试加载每个依赖
        loadLibrary(unmapLibraryName(it))
    }
    //6、当so的所有依赖都加载成功后,再尝试加载so就可以成功了
    System.load(file.absolutePath)
    true
}.getOrDefault(false)


//加载指定名称的so库
fun loadLibrary(libName: String): Boolean = runCatching {
    //7、先尝试使用System.loadLibrary()加载,如果是系统so,那么加载成功
    System.loadLibrary(libName)
    true
}.onFailure {
    installedDir.forEach { dir ->
        //8、否则,加载的是业务so,那么需要拼接上目录,再使用load()去加载
        if (load(File(dir, "lib${libName}.so"))) {
            return true
        }
    }
    return false
}.getOrDefault(false)

至此,SO的依赖问题就被我们解决了。


四、总结

本文首先通过介绍Android系统加载so库的流程,解释使用System.loadLibrary()来动态加载so库失败的原因,并且通过反射修改ClassLoader.DexPathList.nativeLibraryDirectories解决了这个问题。

接着又提出由于so依赖关系会导致System.load()加载失败的问题,进而介绍动态链接器NamespaceELF文件格式

最后提出主动解析so库依赖的方案,即使得so库的依赖先于其本身被加载,从而避免了Linker加载so时找不到其依赖的问题。

其中,为了避免引入太多的新概念,对ELF文件格式的介绍浅尝辄止,有兴趣的读者可以在Chapter 7 Object File Format做进一步了解。


五、写在最后

1、源码下载

ElfLoader

2、免责声明

本文涉及的代码,旨在展示和描述方案的可行性,可能存bug或者性能问题。

不建议未经修改验证,直接使用于生产环境。

3、转载声明

本文欢迎转载,转载请注明出处

4、留言讨论

你是否也在现实开发中遇到类似的场景或者应用,是否有更多的意见和想法,欢迎留言一起学习讨论。

5、欢迎关注

如果你对更多的Android Hook开发技巧、思路感兴趣的,欢迎关注我的栏目。

后续将提供更多优质内容,硬核干货。