Linux ELF 符号表 dlopen 顺序初探

@vrqq  November 9, 2021

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)

  1. 以调用者的runtime符号表为基准,生成 {next.so.LOCAL}符号表
  2. ld会按顺序解析next.so头中(DT_NEEDED) other.so 把其他的other.so依次递归解析
  3. 依次遍历{next.so头.dynsym + 上一步的所有recursive other.so的.dynsym}里面的symbol:

    • 先看调用者runtime表 如果有 则用调用者符号填入{next.so.LOCAL}
    • 若没有 则将查到的symbol 填入{next.so.LOCAL}符号表
  4. 一旦{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_NOWRTLD_LAZY 是一组:表示“更新当前runtime符号表”这个动作发生在dllload时,还是function()访问时

    • 通过dlsym找到函数指针,和predefine某个函数然后编译时指定--allow-shlib-undefined,都能定位到函数表内的某函数,从而call之
  • RTLD_GLOBALRTLD_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

方案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

方案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/


添加新评论