本文将介绍动态加载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异常:
shell
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
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
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
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;"),
};
c++
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
c++
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库,有以下步骤:
- 检查是否so库已经加载过。
- 加载过的so都会被记录下来,避免重复加载。
- 如果已经加载过,那需要保证当前ClassLoader和首次加载的ClassLoader相同,因为规定同一个SO库只能被同一个ClassLoader加载。
- 调用
android::OpenNativeLibrary()
完成加载,并且传入从当前ClassLoader中获得的library_path
。 - 如果加载成功,那么会找到动态库对应的
JNI_OnLoad()
方法调用,并记录调用结果,如果结果是JNI_ERR,表示SO库加载成功了,但是初始化失败,下次也不会重新加载。
2.2.2、GetLibrarySearchPath()
art/runtime/jni/java_vm_ext.cc
c++
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
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;
}
从上面源码看出,BaseDexClassLoader 的library_path
实际是它构造函数时传入的so库所在的目录路径列表 转换来的,也就是说library_path
就是这个BaseDexClassLoader默认所能加载的so库的目录列表。
2.3、OpenNativeLibrary()
接下来,通过OpenNativeLibrary()
方法看看是不是真的如此,而OpenNativeLibrary()
涉及动态链接器的Namespace机制(在下文介绍),因此只展示相关核心代码。
art/libnativeloader/native_loader.cpp
c++
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)
...
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
}
从代码注释可以看出:
- 当path是绝对路径,即对应
System.load()
的情况,直接将path交由dlopen()
处理。 - 当path是文件名,即对应
System.loadLibray()
的情况,则需要拼接上classloader对应的library_paths
,即classloader所能加载的so库的目录,从而构成完整的so文件路径,再交由dlopen()
处理。
dlopen是Linux系统加载动态库的函数,最终会交给动态链接器处理,Android在调用它前加了一些自己的逻辑。
这解释了为什么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
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
的逻辑如下:
kotlin
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风险
python
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位于同一目录下)
的时候,就会出现异常:
shell
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.so
对libtestso1.so
的依赖,则Linker (动态链接器 )去解析并执行的,并不在Sytem.load()
流程中。
那Linker(动态链接器)是怎么解析libtestso2.so
对libtestso1.so
的依赖呢,为什么似乎没有解析成功,这需要首先介绍动态链接的Namespace机制。
2、动态链接器Namespace
根据官方文档链接器命名空间的介绍,动态链接器Namespace是为了隔离不同链接器命名空间中的共享库,以确保具有相同库名称和不同符号的库不会发生冲突。
动态链接器Namespace机制是由libnativeloader
这个库和Linker一起实现的,详细文档参考libnativeloader。
由于这个机制比较复杂,为了减少理解成本,将会在其他文章详细介绍。这里只需了解和so依赖查找相关的内容。
简单来说,动态链接Namespace有以下规则:
- 每个ClassLoader对应一个单独的Namespace 。这个Namespace和JAVA层的DexPathList相似,记录着ClassLoader对应的library_paths等信息。
- ClassLoader对应的Namespace在ClassLoader首次加载so库时创建 。Namespace只会创建一次 ,这意味着Namespace创建后不会被修改。并且通常是ClassLoader首次加载so库时创建,也可以主动创建(例如系统会在APP进程启动后主动给
PathClassLoader
创建一个Namespace)。 - Linker在查找so依赖时,就是在Namespace的
library_paths
中查找的 。library_paths
就是BaseDexClassLoader初始化时传入的动态库目录列表,这和DexPathList.nativeLibraryDirectories
是一致的。
bionic/linker/linker_namespaces.h
c++
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.so
、libtestso1.so
所在的目录,可以通过反射加入到DexPathList.nativeLibraryDirectories
中,然而此时通常 ClassLoader对应的Namespace 已经创建了,修改DexPathList.nativeLibraryDirectories
并不会影响android_namespace_t.ld_library_paths_
,进而导致Linker通过android_namespace_t.ld_library_paths_
查找libtestso1.so
时找不到。
既然如此,一个显然的方案是新建一个DexClassLoader ,并在其构造时就将libtestso2.so
、libtestso1.so
所在的目录路径传入。此时使用这个DexClassLoader加载libtestso2.so
,就会创建Namespace ,而Namespace 则包含libtestso2.so
、libtestso1.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文件格式前,介绍一些常用工具。
-
readelf 。readelf 是一个用于查看 ELF 文件(Executable and Linkable Format)内容的命令行工具。该工具在安装ndk时自带了。
例如我们可以通过readelf查看
libtestso2.so
的依赖关系:shell$ /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
-
010 Editor 。010 Editor用于可视化 查看各种格式的二进制文件。借助它我们可以直观查看ELF文件格式。
从010 Editor可以看出ELF文件有elf_header 、program_header_table 、section_header_table 等结构,通过readelf可以解析出so库的依赖,这些依赖以库名表示。我们的目的就是通过代码解析so库,进而找到其依赖。
3.2、ELF文件格式(SO依赖相关)
3.2.1、ELF文件头
ELF文件的起始,是一个称为ELF Header 的结构,即ELF文件头。它携带着ELF文件的基本信息,我们可以使用readelf
来查看。
shell
% /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则可以更直观地了解其结构。
其中一些项需要重点说明:
- 前16个字节 。ELF文件的前16个字节,称为
e_ident
,记录着重要数据。- 前四个字节固定 为
7f 45 4c 46
,这是ELF文件的标志。因为我们在检查一个文件是否ELF文件时,就需要检查这个标志。 - 第5个字节为
ei_class
。记录这个ELF文件是32位 ,还是64位的。例子中是64位。 - 第6个字节为
ei_data
。记录着字节序,即多字节数据怎么读取到内存,及所谓的大端小端。例子中是LSB即小端。 - 其他字节不是很重要,只需要按照固定格式解析出来备用即可。
- 前四个字节固定 为
e_type
。第17个字节,记录这ELF文件的类型,DYN表示这是一个动态库。- Program表 。Program表(段表),是ELF文件头中的重要数据,记录这ELF文件在运行时 的段 信息。
Start of program headers
。表示Program表在文件中的偏移,也就是Program表从哪里开始。Size of program headers
。Program表的大小,即占用多少字节。Number of program headers
。Program表项 的数量,用Size of program headers
除以Number of program headers
,就可以求出每个表项的大小。
- Section表 。Section表(段表),是ELF文件头中的重要数据,记录这ELF文件在编译时 的节 信息。
Start of section headers
。表示Section表在文件中的偏移,也就是Section表从哪里开始。Size of section headers
。Section表的大小,即占用多少字节。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(节)的用途,它只关心这些数据是可读、可写还是可执行的。
因此Program表 和Section表是对同一ELF文件数据的不同组织方式,编译器和动态链接器(Linker)对ELF文件关注的侧重点不同,即看待ELF文件的角度/方式不同。
举个不恰当的例子,就例如同样是你这个人,公司关注的是你的工作经历,伴侣关注的是你的情感经历。公司可能是从年龄、工作能力、学历来描述你,而伴侣则从年龄、性格、收入来描述你。尽管两者可能有相同之处 ,但总的来说,这是对同一个事物的不同视角。
对于ELF文件也是如此,如果以Program表 的角度看待,则称为运行视图 。以Section表 的角度看待,则称为链接视图。
3.2.2、Section表
根据ELF Header的描述,我们可以开始解析Section表 。Section表 由一个个连续的 表项组成,每个表项记录着一个Section(节)的信息,表项有它的固定结构。
同样可以使用readelf
来查看Section表。
shell
% /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项都包含Name
、Type
、Off
等信息,具体的结构是这样的:
c
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表。
shell
% /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项都包含Type
、Offset
、Flg
等信息,具体的结构是这样的:
c
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;
其中我们关注Type
为DYNAMIC的段,称为DYNAMIC段。
shell
DYNAMIC 0x000668 0x0000000000001668 0x0000000000001668 0x0001b0 0x0001b0 RW 0x8
DYNAMIC段 也是一个列表,每项的类型为Dyn
,具体结构是:
c
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
值可以取以下值:
c
/* 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
...
这里我们只需要关注:
-
d_tag
为DT_STRTAB(5)
时,Dyn.d_val
表示一个字符串表的起始偏移。在ELF文件中,用到的字符串会使用\0
连接并连续存储在一起,从而构成了一个名为dynstr
的Section。使用010 Editor 可以更直观得看出它们之间的关系。
-
d_tag
为DT_NEEDED(1)
时,说明这一项记录的是SO的一个依赖的名称 ,名称是字符串,因此此时Dyn.d_val
表示该字符串在.dynstr
中的偏移。
根据上面的信息,就可以找到SO依赖的具体信息了。
我们遍历DYNAMIC段 ,找到d_tag
为DT_NEEDED(1)
的项,每项表示一个依赖名称。但具体的依赖名称字符串,则需要从d_tag
为DT_STRTAB(5)
表示的字符串表中查找。
即
SO依赖名称 = dyn.d_val(dyn.tag = DT_NEEDED) + dyn.d_val(dyn.tag = DT_STRTAB)
大家可以使用010 Editor进行验证。
这里只介绍了DT_NEEDED
和DT_STRTAB
两种类型,更多类型的含义和作用可以见Dynamic Section。
4、代码解析SO依赖
在了解ELF文件头、Program表(段表)、Dynamic表的基本组成后,我们就可以动手解析so文件了。
ELF文件的格式是固定,每个数据占多少字节,可以参考elf.h。
另外网上也有很多解析ELF文件的例子,例如Android源码中的ReadElf.java。
这里来说明一些需要注意的点。
- 需要通过ELF文件开头的魔数 、Type等,来检验当前解析的是否SO库。
- 需要通过ELF文件开头的DATA 、Class来决定怎么读取多字节数据。
大家可以自行解析一遍,从而熟悉ELF文件格式。也可以参考ElfLoader。
5、加载SO依赖
以libtestso2.so
为例,它解析出的依赖是:
shell
$ /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()
去加载。
具体流程是这样的:
kotlin
//该方法用于加载指定路径的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()
加载失败的问题,进而介绍动态链接器Namespace 和ELF文件格式。
最后提出主动解析so库依赖的方案,即使得so库的依赖先于其本身被加载 ,从而避免了Linker加载so时找不到其依赖的问题。
其中,为了避免引入太多的新概念,对ELF文件格式 的介绍浅尝辄止,有兴趣的读者可以在Chapter 7 Object File Format做进一步了解。
五、写在最后
1、源码下载
2、免责声明
本文涉及的代码,旨在展示和描述方案的可行性,可能存bug或者性能问题。
不建议未经修改验证,直接使用于生产环境。
3、转载声明
本文欢迎转载,转载请注明出处。
4、留言讨论
你是否也在现实开发中遇到类似的场景或者应用,是否有更多的意见和想法,欢迎留言一起学习讨论。
5、欢迎关注
如果你对更多的Android Hook开发技巧、思路感兴趣的,欢迎关注我的栏目。
后续将提供更多优质内容,硬核干货。