本文将介绍动态加载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开发技巧、思路感兴趣的,欢迎关注我的栏目。
后续将提供更多优质内容,硬核干货。