一文搞懂系列——替换动态库,为什么导致运行进程异常

2024-01-02 11:18:35

背景

上周我的好同事·金,又遇到了一个crash问题,他是这么描述的:测试同事在验证新版本时,程序正在运行,将新版本的动态库替换到对应目录中,正在运行的程序会出现crash。我的第一反应是,替换磁盘中的动态库,对于正在运行的进程应该是没有影响的。因为,正在运行的进程依赖于旧动态库,即使执行rm lib.so指令,文件系统发现依然有程序对其引用,也不会立刻从磁盘删除,待进程停止后,没有对该动态库应用时,才会真正从磁盘删除。因为当时比较忙,也就没有关注了。

正所谓天道好轮回,苍天饶过谁。就在今天,我在做演示项目时,好像就遇到了类似的问题,经过细细对比,果然和金描述的一致。心想:既然你又找上了我,那我一定不让你失望

经过分析排查,查阅资料。发现之前自己的理解比较片面,也学到了很多。希望本次分享能够帮助到遇到相似问题或有兴趣的朋友。本篇内容难度偏高,希望朋友们能够自己操作一遍。

示例

为了方面操作和理解,我写了以下示例代码。

//main.c
#include<stdlib.h>
#include<stdio.h>
#include<unistd.h>

extern int add(int,int);

int main()
{
        printf("start sleep\n");
        sleep(10);
        printf("over sleep\n");
        add(5,6);
        return 0;
}

//add.c
#include<stdio.h>
int add(int a, int b)
{
        printf("a+b\n",a+b);
        return (a+b);
}

编译:

gcc -g -fPIC -shared add.c -o libadd.so
gcc -g main.c -o main -L. -ladd

创建lib目录,并将libadd.so拷贝一份到lib目录,其中,两个libadd.so内容是完全一样的。最终工程如下:

yihua@ubuntu:~/test/reloadDynamiclibs$ tree
.
├── add.c
├── lib
│   └── libadd.so
├── libadd.so
├── main
└── main.c

1 directory, 5 files
yihua@ubuntu:~/test/reloadDynamiclibs$

运行:其中环境变量动态库加载路径,是期望main链接lib/libadd.so

export LD_LIBRARY_PATH=lib/
./main

正常情况下,输出如下:

yihua@ubuntu:~/test/reloadDynamiclibs$ ./main
start sleep
over sleep
yihua@ubuntu:~/test/reloadDynamiclibs$

问题复现: main运行时,在sleep的10秒内,执行cp libadd.so lib/libadd.solib/libadd.so替换。则输出如下:

yihua@ubuntu:~/test/reloadDynamiclibs$ ./main
start sleep
over sleep
Segmentation fault (core dumped)
yihua@ubuntu:~/test/reloadDynamiclibs$

知识点补充

在进入分析前,我们需要了解两个知识点:文件共享的方式、GOT及PLT表。

文件共享方式

一、文件在磁盘中存储的方式

我们知道文件存储在磁盘中,硬盘的最小存储单位叫做"扇区"(Sector)。每个扇区储存512字节。操作系统对磁盘文件存的取最小单位是“块",最常见的是4KB,即连续八个Sector组成一个block。

通常情况下,文件系统会将文件的实际内容和属性分开存放。

  • 文件的属性保存在 inode 中(i 节点)中,每个 inode 都有自己的编号。每个文件各占用一个 inode。不仅如此,inode 中还记录着文件数据所在 block 块的编号;
  • 文件的实际内容保存在 block 中(数据块),类似衣柜的隔断,用来真正保存衣物。每个 block 都有属于自己的编号。当文件太大时,可能会占用多个block 块。

故文件系统会将磁盘格式化为两个部分inodeblock。举例:若某文件的inode节点为3,其中记录着文件实际内容存储在block1、2、4、5中。那么操作系统读取文件的流程:

  1. 读取inode节点信息,获取文件所占blocks
  2. 读取blocks,获取文件实际内容。

我们可以通过stat查看文件的inode信息。如下:

yihua@ubuntu:~/test/reloadDynamiclibs$ stat libadd.so
  File: libadd.so
  Size: 8344            Blocks: 24         IO Block: 4096   regular file
Device: 801h/2049d      Inode: 1713034     Links: 1
Access: (0775/-rwxrwxr-x)  Uid: ( 1000/   yihua)   Gid: ( 1000/   yihua)
Access: 2023-12-26 01:55:52.528861823 -0800
Modify: 2023-12-26 01:55:23.649023426 -0800
Change: 2023-12-26 01:55:23.649023426 -0800
 Birth: -

其中libadd.soinode节点为1713034,实际占用24个block。

二、进程表和内核文件表

每个进程在进程表中都有一个关于文件描述符的记录部分,其中文件描述符中主要记录两点内容。

  • 文件描述符。也就是我们常见的fd。
  • 文件指针。每个fd都对应一个文件指针,文件指针的作用,是指向内核文件表中的某一项。

内核为所有打开文件维持一张文件表。表中的每一项包含三点内容。

  • 文件的状态标志。比如读、写、添写、同步和非阻塞等。
  • 当前文件偏移量
  • 指向该文件的v节点表项的指针。v节点包含文件类型、对此文件的各种操作函数的指针、以及文件对应的inode

通过以上两点,我们大致可以得出进程访问文件的关系如下:

注:其中文件表项是每一个进程所独有的,但是v节点是文件独有的,即若存在多个进程打开同一个文件,其流程大致如下:

动态链接PLT和GOT表

PLT(Procedure Linkage Table)及GOT(Global Offset Table)是动态链接中的核心点,本文不会进行更进一步的介绍。若要深入了解,可参考我的专栏《程序员的自我修养》。由于libadd.so中引用可printf接口,并且printf定义在libc.so中,因此libadd.soprintf的引用采用运行时重定位。采用objdump -d libadd.so命令查看add函数的反汇编内容。

000000000000060a <add>:
 60a:   55                      push   %rbp
 60b:   48 89 e5                mov    %rsp,%rbp
 60e:   48 83 ec 10             sub    $0x10,%rsp
 612:   89 7d fc                mov    %edi,-0x4(%rbp)
 615:   89 75 f8                mov    %esi,-0x8(%rbp)
 618:   8b 55 fc                mov    -0x4(%rbp),%edx
 61b:   8b 45 f8                mov    -0x8(%rbp),%eax
 61e:   01 d0                   add    %edx,%eax
 620:   89 c6                   mov    %eax,%esi
 622:   48 8d 3d 20 00 00 00    lea    0x20(%rip),%rdi        # 649 <_fini+0x9>
 629:   b8 00 00 00 00          mov    $0x0,%eax
 62e:   e8 dd fe ff ff          callq  510 <printf@plt>
 633:   8b 55 fc                mov    -0x4(%rbp),%edx
 636:   8b 45 f8                mov    -0x8(%rbp),%eax
 639:   01 d0                   add    %edx,%eax
 63b:   c9                      leaveq
 63c:   c3                      retq

可知add函数并不是直接调用printf符号,而是printf@plt符号。其实际访问流程应该是add --> printf@plt --> printf@got --> printf

问题分析

一、首先我们要先确认cp libadd.so lib/做了什么

通过命令前后对比,查看文件的inode值,可发现libadd.so文件的inode值并没有变,说明该操作将磁盘中文件原始数据重新写了一遍

yihua@ubuntu:~/test/reloadDynamiclibs$ stat lib/libadd.so
  File: lib/libadd.so
  Size: 10376           Blocks: 24         IO Block: 4096   regular file
Device: 801h/2049d      Inode: 1714531     Links: 1
Access: (0775/-rwxrwxr-x)  Uid: ( 1000/   yihua)   Gid: ( 1000/   yihua)
Access: 2023-12-26 23:29:55.494489759 -0800
Modify: 2023-12-26 23:29:50.338512140 -0800
Change: 2023-12-26 23:29:50.338512140 -0800
 Birth: -
yihua@ubuntu:~/test/reloadDynamiclibs$
yihua@ubuntu:~/test/reloadDynamiclibs$
yihua@ubuntu:~/test/reloadDynamiclibs$ cp libadd.so lib/libadd.so
yihua@ubuntu:~/test/reloadDynamiclibs$ stat lib/libadd.so
  File: lib/libadd.so
  Size: 10376           Blocks: 24         IO Block: 4096   regular file
Device: 801h/2049d      Inode: 1714531     Links: 1
Access: (0775/-rwxrwxr-x)  Uid: ( 1000/   yihua)   Gid: ( 1000/   yihua)
Access: 2023-12-26 23:29:55.494489759 -0800
Modify: 2023-12-27 00:06:31.073213774 -0800
Change: 2023-12-27 00:06:31.073213774 -0800
 Birth: -
yihua@ubuntu:~/test/reloadDynamiclibs$

二、gdb 调试分析

通过gdb调试分析,发现收到错误信号SIGSEGV,访问非法地址,而0x0000000000000516也很明显是一个错误地址。

Program received signal SIGSEGV, Segmentation fault.
0x0000000000000516 in ?? ()
(gdb) bt
#0  0x0000000000000516 in ?? ()
#1  0x00007ffff7bd1633 in add (a=5, b=6) at add.c:4
#2  0x00005555555547cf in main () at main.c:12
(gdb)

我们跟着堆栈信息不妨一起分析下,通过disassemble 命令,查看进程的汇编内容。

由堆栈分析:是在执行jmpq *0x200b02(%rip)指令出现了crash,并且经过打印可以虚拟地址0x7ffff7dd2018中的值的确是0x0000000000000516,与现象相符。我们可以通过打断点查看正常情况下,该地址中的值。

这时我们可以得到一个结论:替换运行时的动态库,改变了内存进程中的内存值

三、思考:为什么修改文件中的磁盘数据,会影响进程中的内存空间呢?
我们可以通过strace命令查看进程是如何加载libadd.so动态库的。如下:

由图可知,libadd.so是通过mmap将文件映射到内存中的,因此当重新写磁盘内容时,会实时修改进程中的对应内存空间

四、再思考:为什么拷贝前后的libadd.so一摸一样,还是会导致crash呢?

这和进程加载动态库过程有关,因为内容就比较深刻,不进一步详细分析。不过简单理解进程加载动态库中的过程为:

  1. 动态链接器将动态库libadd.sommap到内存中。
  2. libadd.so中的got表进行重定位,即将上面的0x0000000000000516地址,更改为加载后的虚拟地址0x7ffff7bd1516
  3. 加载完成。

实际上此时内存中的文件数据,是经过重定位修改后的内容。与磁盘中libadd.so的原始数据并不一致。若此时,再将磁盘中的原始数据mmap到内存中,上述的步骤二不再执行,因而会出现访问非法内存的情况。

总结

至此,该问题终于解决了。内容较多,希望大家有所收获。

思考题:在知道根因后,我们不妨再想想,如何在程序运行时,替换动态库,却不影响正在程序

提示:先删除,再拷贝。即rm lib/libadd.so + cp libadd.so lib/libadd.so。不妨试试哦,可以把原理在评论区写出。

若我的内容对您有所帮助,还请关注我的公众号。不定期分享干活,剖析案例,也可以一起讨论分享。
我的宗旨:
踩完您工作中的所有坑并分享给您,让你的工作无bug,人生尽是坦途
在这里插入图片描述

文章来源:https://blog.csdn.net/xieyihua1994/article/details/135251553
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。