Concept
- ELF可执行文件有两种格式: ET_EXEC 和 ET_DYN
- 使用c/c++生成的ELF文件,通常会先经过loader(ld.so) 加载所需库(例如libc.so),同时还会加载编译时使用
-l xxx.so
指定的库,最后才到int main()
- 查看导入导出表:
readelf -s libxxx.so
看.dynsym
部分(UNDEF当然是导入啦) - 下文
symbol <==> 符号
,local/global <==> 私有/全局符号表
同意 - 下文 runtime符号表 表示当前.so的 active search table
.so dependency 覆盖问题
- 整个EXE 有一GLOBAL symbol表,每个.so也有其私有表(LOCAL)
- 编译时
-l
指定的库,会成为此次编译输出者的DT_NEEDED - 编译时
-l
指定的库,所有符号都会加载进GLOBAL表(按load顺序加载) - 编译时
-l
指定的库,这个库的runtime符号表就是GLOBAL表(没有LOCAL表)
使用dlopen("next.so", RTLD_NOW | RTLD_LOCAL)
- 以调用者的runtime符号表为基准,生成 {next.so.LOCAL}符号表
- ld会按顺序解析next.so头中
(DT_NEEDED) other.so
把其他的other.so依次递归解析 依次遍历{next.so头
.dynsym
+ 上一步的所有recursive other.so的.dynsym
}里面的symbol:- 先看调用者runtime表 如果有 则用调用者符号填入{next.so.LOCAL}
- 若没有 则将查到的symbol 填入{next.so.LOCAL}符号表
一旦{next.so.LOCAL}中某个symbol生成,即使GLOBAL表内之前没有 之后有了这条symbol,也不会覆盖之
- 理所应当:函数调用搜索的是runtime表(当前local表)
使用dlopen("next.so", RTLD_NOW | RTLD_LOCAL | RTLD_DEEPBIND)
前两部和上述相同,第三步则是next.so及recursion查到的符号,优先于调用者符号,填入next.so.local表中
确保next.so内部代码发生"call function 时", 不受上层GLOBAL表污染
使用dlopen("next.so", RTLD_NOW | RTLD_GLOBAL)
- 将next.so内(包含DT_NEEDED遍历)的符号 写入GLOBAL symbol表,若某symobol已有 则跳过
- 即使之前next.so 曾以
RTLD_LOCAL
加载,此时在调用这条语句,也会将其symbol写入GLOBAL表! - 调用后 next.so 其runtime表 就是 global表了,注意不是cover to global,而是add if not exist
- 即使之后再调用RTLD_LOCAL语义也一样:GLOBAL已有条目优先
使用dlopen("next.so", RTLD_LAZY | RTLD_GLOBAL)
- lazy发生在该条symbol被访问时才会更新符号表(无论是GLOBAL还是LOCAL表都一样)
- 所以可能会被其他dlopen行为抢占其符号位
- 注意如果next.so内有函数
fun1()
和fun2()
,访问fun1()
不会把fun2()
加入符号表
使用dlmopen("next.so")
- 会创建一隔离环境(namespace),相当于windows下的/MT,新的namespace下,有他自己的malloc/free
- 在新的namespace下不允许使用dlopen(RTLD_GLOBAL) 会crash
summary
- 每个ELF文件(exe和so)都有一个“runtime符号表” 可以和global一致,也可以私有不一致,取决于当前elf文件在被拉起时指定的
RTLD_LOCAL
还是RTLD_GLOBAL
RTLD_NOW
和RTLD_LAZY
是一组:表示“更新当前runtime符号表”这个动作发生在dllload时,还是function()访问时- 通过dlsym找到函数指针,和predefine某个函数然后编译时指定
--allow-shlib-undefined
,都能定位到函数表内的某函数,从而call之
- 通过dlsym找到函数指针,和predefine某个函数然后编译时指定
RTLD_GLOBAL
和RTLD_LOCAL
是一组,表示加载 next.so 时发现的符号,是写入其私有local表,还是写入global表,在next.so中的代码产生call时,是从其local表找symbol,还是从global表找(标定next.so的 runtime符号表)dlopen()
给人以Object Oriented的错觉,以为加载的函数不会影响到当前运行环境,其实他是设定context的函数,而不仅仅产生了一个"pointer"
RTLD_DEEPBIND
是个特殊的flag,表示通过next.so找到的symbol,若 runtime符号表内有重复 则会覆盖重复部分。dlopen(next.so, RTLD_LOCAL | RTLD_DEEPBIND)
其中RTLD_LOCAL
保证next.so内部函数不外泄,RTLD_DEEPBIND
保证外部已有函数不影响其内部- Address sanitizer不能和这个flag同享
ASAN 和 DEEPBIND的解决方案
通过c++写的程序,总是要归到ld.so去加载符号表,比如现在要shim某闭源.so
方案1: 使用dlmopen新开一namespace
- 从理论上讲最可行:隔离,和docker一个思路
- 在新的namespace下不能用dlopen(GLOBAL),限制了很多库
- libcapsule: https://github.com/dezgeg/libcapsule
方案2: 写一加载器
loader.cpp { dlopen("main.so", RTLD_NOW|RTLD_LOCAL); dlopen("xxx.so", RTLD_NOW|RTLD_LOCAL); dlopen(...) }
无法保证xxx.so内部没有
RTLD_GLOBAL
,若出现则会污染全局符号表,影响后续dlopen()
方案3: 改写ld.so
- ELF(ET_EXEC)文件头里 指定了使用哪个ld.so
- 过于复杂 系统相关
方案4: symbol rename
改写 闭源third库的符号表,但不能保证 库内使用dlsym()会扑空
- 或其他混淆软件 如 VMProtect, Sentinel LDK, 和一众国产app
或同时拦截dlsym()和 symbol section动态跳转至third库
- sanitizer有很多injection,可能好改些,例如:
INTERCEPTOR(void*, dlopen, const char *filename, int flag)
拦截dlopen()
- https://github.com/llvm/llvm-project/blob/main/compiler-rt/lib/sanitizer_common/sanitizer_common_interceptors.inc#L6379
- sanitizer有很多injection,可能好改些,例如:
方案5: 新开一process,或使用static glibc避开asan注入
- host namespace下shim_host.cpp : string work1() { rv = ext.do_work1(); ext.free(rv); }
- third.so namespace下: shim_ext.cpp
方案6: 换valgrind
- 直到namespace能用
方案7: 柳暗花明又一村
- 假设一组第三方库a.so, b.so, c.so, 我制作一shim.so
- shim.so --whole-archive{a,b,c} ==> shim.dynsym{ a.dynsym + b.dynsym + c.dynsym }
- 之后加载a.so b.so c.so时会从shim.so派生符号表,自然就指对啦!
- 编译时用linker wrapper拦截dlopen() dlmopen() 不让其污染global namespace
- 接下来就可以用Loader方案,或者dlinfo()或者其他的 修改当前shim.local符号表后 加载其余a.so b.so c.so
- 这个方案尚不可用
后记 Windows篇
Windows与Linux的DLL函数表设计理念不大相同, 通过manifest在linker时就可以以dll为单位, 使子dll的函数不污染祖先dll的符号表: https://blog.vrqq.org/archives/779/