一 背景
谈到MAP_DENYWRITE,可能有些陌生。这个flag很少被用户态开发者关注,其中没有被关注的理由主要是“this flag is ignored by os”,简而言之,操作系统(Linux内核)将会忽略掉用户传入的MAP_DENYWRITE标志。回到MAP_DENYWRITE是什么?与MAP_ANONYMOUS、MAP_SHARED、MAP_PRIVATE等一样,是系统调用mmap()函数为映射目标设置映射方式的一种flag。顾名思义,MAP_DENYWRITE表示这段映射的虚拟地址区间不允许写操作,例如,我们通过open()以可写的方式获取文件句柄,将会返回“ETXTBSY: text file is busy”错误。
本文主要介绍MAP_DENYWRITE相关的内容,例如使用了MAP_DENYWRITE的可执行二进制文件和忽略MAP_DENYWRITE的动态共享库。为了描述简单,后面直接使用EXEC和DSO(Dynamic Shared Object)分别表示可执行二进制文件和动态共享库。为了更加清晰的展示MAP_DENYWRITE作用,设计了实验1和实验2来显示用户态可见的差异。同时,借助实验1和实验2也为引出两个疑问:(a)为什么i_writecount会导致“ETXTBSY: text file is busy”;(b)为什么vim“写-存”DSO触发程序“Segment fault”;其答案分别可以在第三节和第四节获取。最后,第五节设计了最后一个实验对实验3进行补充。
二 MAP_DENYWRITE是什么
关于MAP_DENYWRITE是什么?这里先直接引用一下man2上的描述:
图1:MAP_DENYWRITE官方描述
看样子,Linux内核会忽略用户态传入的MAP_DENYWRITE标志(参考:https://man7.org/linux/man-pages/man2/mmap.2.html),主要是为了防止DDoS攻击。
早期的社区讨论
2001年这个问题就已经被讨论了,如下图:
图2:Linus关于解释Linux内核屏蔽MAP_DENYWRITE的邮件
图2中,Linus以mmap("/etc/passwd")为例,说明了内核为什么忽略MAP_DENYWRITE。另外,更明显的例子便是普通的log日志文件,如果有用户恶意使用MAP_DENYWRITE的方式map了该文件,那么对该日志文件存在写操作的所有进程都将会崩溃,这是一个严重的漏洞。
尽管Linux内核屏蔽的用户态传入的MAP_DENYWRITE,但是,还是有那么一个“固执”的程序至今为止仍坚持在使用mmap()映射DSO时使用MAP_DENYWRITE,即ld.so。既然DSO在映射过程中实际传入到内核的MAP_DENYWRITE被忽略了,那么为何不提交一个补丁在glibc中去掉这一块相关的代码?考古发现,其实也有人多年前提交过,我们可以找到当年的邮件讨论,下面是截取的其中一个Maintainer的观点:
图3:glibc社区对是否保留MAP_DENYWRITE的讨论
图中的意见基本表示glibc会保留在映射DSO时加入MAP_DENYWRITE标志,认为这种问题应该Linux内核去解决,详细可以参考链接1。
写到这里似乎这篇文章就可以全剧终了。
当然还有剧情,男女主角还没出现!“作者还能继续编”!尽管Linux内核忽略了从用户态传入的该参数,但是这个参数内核自己还是可以用:内核在加载初始化ELF可执行文件时,整个过程都在内核态完成,所以内核直接可以加上这个flag,而不会引入DDoS攻击。
本小节接下来将会设计两个与MAP_DENYWRITE有关联的实验,这两个实验可以帮助我们更清晰的解读MAP_DENYWRITE的故事:
-
实验1:EXEC文件本身在执行过程中,操作系统将会在其代码段的映射区间印上MAP_DENYWRITE,那么如何可以看出来?同时正如前面的提到的,对其代码进行写,如何触发“ ETXTBSY: text file is busy ”错误。
-
实验2:为了防止DDoS攻击,操作系统忽略掉了用户(例如ld.so)传入的MAP_DENYWRITE。那么对其进行写打开又有哪些现象?与EXEC又有哪些不同?
实验1:对EXEC的代码段进行测试
下面是一个进程的可执行文件本身的代码段在被映射后的截图:
图4:EXEC代码段smaps信息
在图2中dw是该进程的可执行文件本身,并且最后VmFlags一行可以包含dw的标志,表示内核在映射dw的代码段过程中,带上了MAP_DENYWRITE。下面一个很简单的用例来表示如何复现“ETXTBSY: text file is busy”。
图5:一个发生“ETXTBSY: text file is busy”错误的用例演示
图5中,演示了一个程序在执行过程中,对自己的EXEC文件dw_txtbsy以可写的方式进行打开,其中使用的测试用例如下:
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main(void)
{
int fd;
fd = open("/mnt/nvme2/map_denywrite/dw_txtbsy", O_WRONLY);
if (fd < 0)
perror("dw_txtbsy:");
return 0;
}
实验2:对DSO的代码段进行测试
与实验1相同,首先展示DSO的代码段在建立映射后的smaps信息,如下图所示。
图6:DSO代码段smap信息
图6选择几乎所有的程序都依赖的libc.so为例。在其VmFlags中并没有发现dw标志,所以从逻辑上,我们可以想到如果对其进行EXEC同样的实验,并不会发生“ETXTBSY: text file is busy”错误。这里给出测试的用例,感兴趣的小伙伴可以自行验证。
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main(void)
{
int fd;
fd = open("/usr/lib64/libc-2.32.so", O_WRONLY);
if (fd < 0)
perror("dw_txtbsy:");
return 0;
}
其实在这个实验中,我们更关系的是下面的实验:
实验设计:既然前面提到DSO可以以写的方式open,那么我们自己写一个简单的DSO文件,并让另外一个可执行文件中链接调用其中的函数,保持进程运行状态,同时通过vim对该DSO进行修改,看看会出现什么现象。
代码1:load.c
#include <stdio.h>
int foo(void);
int foo(void)
{
printf("foo: 1\n");
return 0;
}
代码2:dso_w.c
#include <stdio.h>
#include <unistd.h>
extern int foo(void);
int main(void)
{
while(1) {
foo();
sleep(3);
}
return 0;
}
编译过程:
$ gcc -O2 -fPIC -c -o load.o load.c
$ gcc -shared -o libload.so load.o
$ gcc -O2 -o dso_w dso_w.c libload.so -Wl,-R,.
$ ./dso_w
下面的实验的截图:
图7:对正在使用的DSO进行修改发生Segmentation fault
图7中,最开始程序正常打印“foo: 1”,但是当我们直接使用vim对libload.so进行修改成“foo: 2”后,发生了“Segmentation fault”。当我们重新执行该程序时,我们的修改起效了。由此,本实验基本可以得出结论:对于DSO,虽然由于没有MAP_DENYWRITE,可以允许用户对其进行修改,但是通过vim“修改-保存”的操作,将会导致正常依赖该DSO的程序直接异常退出。
写到这里,DSO的第二个实验基本完成。
小结
前面两个实验主要为证明在存在或没有MAP_DENYWRITE时,程序行为有何差异。当然除以上实验外,与MAP_DENYWRITE相关的还有install、cp和mv等命令。对于已经被MAP_DENYWRITE映射的文件,可以使用install可以替换inode,但是无法使用cp或者mv。其中详细读者可以自行调研。
此外,前面的两个实验也为引出下面的问题:
-
对EXEC代码段进行写时,发生“ ETXTBSY: text file is busy ”的底层内核逻辑是什么?
-
对正常使用的DSO写时,为什么会发生“Segmentation fault”?以及背后的符号重定向逻辑细节是什么?
下面一节将尝试一一解释。
三 为什么i_writecount会导致“ETXTBSY: text file is busy”
紧跟上文,前面提到EXEC的代码段和数据段的映射过程基本都在内核态完成,因此加入MAP_DENYWRITE是可信任的。因此,本节基于此,想要理清楚对EXEC代码段进行写时,发生“ETXTBSY: text file is busy”的底层内核逻辑是什么?下面会首先给出导致ETXTBSY错误的根本变量:inode->i_writecount值,该值表示当前有多少写者正在占用该file。最后简单给出open()、mmap()和exec()与其相关联的Linux内核调用链,以供读者复现。
下面是内核中与i_writecount相关的四个函数:
static inline int get_write_access(struct inode *inode)
{
return atomic_inc_unless_negative(&inode->i_writecount) ? 0 : -ETXTBSY;
}
static inline int deny_write_access(struct file *file)
{
struct inode *inode = file_inode(file);
return atomic_dec_unless_positive(&inode->i_writecount) ? 0 : -ETXTBSY;
}
static inline void put_write_access(struct inode * inode)
{
atomic_dec(&inode->i_writecount);
}
static inline void allow_write_access(struct file *file)
{
if (file)
atomic_inc(&file_inode(file)->i_writecount);
}
其中put_write_access()函数会在建立映射的过程中,检测是否存在MAP_DENYWRITE,如果存在,则调用put_write_access()将i_writecount值减1,函数调用链路为:
exec()->load_elf_binary()->vm_mmap_pgoff()->do_mmap()->mmap_region()
->{deny | put }_write_access
而当程序通过open(file, O_WRONLY)的方式打开某文件时,内核为做一些列检查,其中包括调用get_write_access()获取写的权限,当该值为负值,get_write_access()将会返回-ETXTBSY,即用户看见的“ETXTBSY: text file is busy”,调用路径大致为:
open()->path_openat->do_o_path->vfs_open->do_dentry_open->get_write_access
如果用一张图总结上面的描述,大致可以画成:
图8:i_writecount与“ETXTBSY: text file is busy”框图
第一个问题到此基本结束。总结一句话:对EXEC而言,最终MAP_DENYWRITE落实到inode->i_writecount发挥作用。相对问题2,简单很多。
四 为什么vim“写-存”DSO触发程序“Segment fault”?
在开始一堆概念解释前,首先先给出标题的答案:在实验2中,我们通过vim修改二进制的方式,将“printf("foo: 1\n")”改为“printf("foo: 2\n")”,该操作将会触发“清空libload.so pagecache--重新读取到pagecache”等操作【注】,该操作完成以后GOT和PLT中的数据没并用根据当前实际映射情况进行更新(正常情况下ld.so会在映射DSO完成后,调用_dl_runtime_resolve函数初始化库中成员函数的实际映射地址)。简而言之,便是再次执行foo函数时,由于DSO实际映射的数据段中GOT和PLT数据未被初始化,为异常地址,直接执行这些异常地址便会产生段错误。
注:
(1)使用vim修改DSO并保存到磁盘时,vim使用了open(O_WRONLY | O_TRUNC),其中O_TRUNC会截断整个文件,执行“truncate pagecache”操作,清空文件在内存中的pagecache和页表。当下次访问DSO中的成员函数时,重新触发缺页异常,初始化pagecache等。这属于vim行为,若使用open(O_WRONLY)不会影响在内存DSO的pagecache。
(2)正常情况下,DSO被映射后,其内存中的GOT和PLT数据与磁盘中DSO文件的GOT和PLT段数据是不同的。
GOT和PLT
在讲GOT和PLT的功能前,可以先思考这样一个问题:一个DSO会被不同的进程依赖,并且在不同进程地址空间中,所才的地址空间也不同,因此这种差异是如何解决的?并且让不同的进程可以和谐运行下去。
答案就是GOT,不同的进程对不同的DSO映射地址的保存都是私有的数据,这些私有的GOT数据就保存在各自DSO内存中的数据段中。
链接器ld.so在执行重定向时会用到的部分, 先来看他们的定义。
(1).got
这是我们常说的GOT, 即Global Offset Table, 全局偏移表. 这是链接器在执行链接时,实际上要填充的部分, 保存了所有外部符号的地址信息。GOT表项还保留了3个公共表项, 每项32位(4字节), 保存在前三个位置, 分别是:
-
got[0]:本ELF动态段(.dynamic段)的装载地址;
-
got[1]:本ELF的link_map数据结构描述符地址;
-
got[2]:_dl_runtime_resolve函数的地址。 对于一个运行的进程,其本身的GOT以及依赖库的GOT皆是由ld.so来填充 ;
(2).plt
这也是我们常说的PLT(Procedure Linkage Table),即进程链接表. 这个表里包含了一些代码,主要有两个作用:
-
调用链接器来解析某个外部函数的地址, 并填充到.got.plt中, 然后跳转到该函数;
-
直接在.got.plt中查找并跳转到对应外部函数(如果已经填充过);
(3).got.plt
.got.plt相当于.plt的GOT全局偏移表, 其内容有两种情况, 1)如果在之前查找过该符号,内容为外部函数的具体地址. 2)如果没查找过,则内容为跳转回.plt的代码, 并执行查找。至于为什么要这么绕, 后面会说明具体原因。
(4).plt.got
略。
这里就以实验2中dso_w.c和load.c为例,探究调用foo函数的过程中涉及的PLT和GOT访问。详细的流程如下图所示:
图9:函数foo重定向流程框图
图9中展示了第一次访问foo函数时发生跳转以及访问GOT和PLE表的四个重要过程。四个过程如图中标记,其简介大致为:
-
过程1:主要是访问foo函数,此时会跳转到foo@plt,该地址处于PLT表中;
-
过程2:在foo@plt中,需要访问.got.plt表,获取0x420000+32处存在值,即0x400520。并跳转到该位置;
-
过程3:经过过程2的跳转,此时处于PLT表的最开始位置(0x400520),此处会访问0x41f00+4088处( 位于GOT表 )所存值,并跳转,即执行_dl_runtime_resolve函数;
-
过程4,在该函数中,将会修改过程2中访问的0x400520为真实的函数地址。当下次访问foo函数时,在执行过程1以后就会执行DSO中真正的foo函数;
看完这四个过程,不禁让人疑惑_dl_runtime_resolve函数是在何时被写入到GOT表中以及它如何计算出foo函数实际的映射地址。关于第一个问题,前面有简单的提到(“对于一个运行的进程,其本身的GOT以及依赖库的GOT皆是由ld.so来填充”),对于本文比较关心的DSO而言,其GOT便是在ld.so将其mmap到进程的地址空间以后初始的。作者在此大胆推测:前面所写DSO说发生的段错误,应该是由于重新缺页读取的DSO后,其GOT和PLT各项为非法地址,例如过程2访问.got.plt表时读取的异常地址发生错误跳转后便触发段错误。
一段插曲:在实验验证前,原文此处为“大胆推测:前面所写DSO说发生的段错误,应该是由于重新缺页读取的DSO后,其GOT各项为非法地址,例如过程3执行_dl_runtime_resolve函数就发生的段错误。”。但是实验验证后发现此推测忽略了DSO的PLT也属于代码段,映射后地址也发生了变化。
基于该推测性结论,我们再大胆猜测是否存在另一个结论:如果DSO中的foo函数,没有再调用其他库函数,即不涉及访问GOT/PLT行为,那么对该DSO任意修改(看上去像所谓的DSO热升级),应该不会导致“Segmentation fault”。关于该结论的验证,留到本节最后“最后一个实验”进行验证。
实验3:问题解剖
经过上一节分析,本节开始验证前面的推测:“前面所写DSO说发生的段错误,应该是由于重新缺页读取的DSO后,其GOT和PLT各项为非法地址,例如过程2访问.got.plt表时读取的异常地址发生错误跳转后便触发段错误”。为验证该推测,任然选择前面实验2的代码:dso_w.c和load.c。本实验其实是实验2的后续,实验步骤完全相同,但是本实验中需要分析生成的core.pid文件,以及找到出现段错误的根本原因。
在实验前,首先需要对环境进行配置,确保可以生成core dump文件。
$ sysctl -w kernel.pattern="core.$p"
此外,实验过程中借助objdump -DTR dso_w命令获取GOT/PLT表地址信息,例如获取GOT的地址为0x41ffd8,如下所示。同时,利用gdb查看GOT表中内容:
gef➤ disassemble 0x41ffd8
Dump of assembler code for function _GLOBAL_OFFSET_TABLE_:
0x000000000041ffd8: .inst 0x0041fde8 ; undefined
0x000000000041ffdc: .inst 0x00000000 ; undefined
0x000000000041ffe0: .inst 0x00000000 ; undefined
0x000000000041ffe4: .inst 0x00000000 ; undefined
End of assembler dump.
注意,在上面的gdb中,我们查看的是非运行时的dso_w数据,因此看到的GOT表中各项基本为零值。
下面是定位原因的过程。
$ gdb dso_w core.262672
gef➤ bt # 第一步
#0 0x00000000000004a0 in ?? ()
#1 0x0000ffff8f48c5ec in foo () at load.c:9
#2 0x0000000000400670 in main () at dso_w.c:9
gef➤ disassemble 0x0000ffff8f48c5ec # 第二步
Dump of assembler code for function foo:
0x0000ffff8f48c5d4 <+0>: stp x29, x30, [sp, #-32]!
0x0000ffff8f48c5d8 <+4>: mov x29, sp
0x0000ffff8f48c5dc <+8>: str wzr, [sp, #28]
0x0000ffff8f48c5e0 <+12>: adrp x0, 0xffff8f48c000
0x0000ffff8f48c5e4 <+16>: add x0, x0, #0x610
0x0000ffff8f48c5e8 <+20>: bl 0xffff8f48c4e0 <puts@plt>
0x0000ffff8f48c5ec <+24>: ldr w0, [sp, #28]
0x0000ffff8f48c5f0 <+28>: ldp x29, x30, [sp], #32
0x0000ffff8f48c5f4 <+32>: ret
End of assembler dump.
gef➤ disassemble 0xffff8f48c4e0 # 第三步
Dump of assembler code for function puts@plt:
0x0000ffff8f48c4e0 <+0>: adrp x16, 0xffff8f4ac000 <__cxa_finalize@got.plt>
0x0000ffff8f48c4e4 <+4>: ldr x17, [x16, #16]
0x0000ffff8f48c4e8 <+8>: add x16, x16, #0x10
0x0000ffff8f48c4ec <+12>: br x17
End of assembler dump.
gef➤ x /x 0xffff8f4ac010 # 最后一步
0xffff8f4ac010 <puts@got.plt>: 0x000004a0
上面标出了四个步骤:
-
第一步:通过core dump文件,可以看到发生错误的原因是pc异常值:0x4a0;
-
第二步:查看foo函数的汇编代码,确定跳转到0xffff8f48c4e0;
-
第三步:查看0xffff8f48c4e0处的汇编代码,发现接下来将会从.got.plt中读取0xffff8f4ac010处的值;
-
最后一步:查看0xffff8f4ac010地址上所存值为0x4a0,导致原因找到;
上面四步其实就是将图9中展示的几个过程在gdb中复现了一遍。最后一步读取的值“0x000004a0”直接指明了发生段错误就是因为.got.plt表各项没有被初始化,还是错误的地址。
五 最后一个实验
与前面实验2相似,如下为load.c源码,在这个实验中,通过二进制的方式将“int i = 0”这行代码修改为"int i = 1"的代码。根据前面的GOT和PLT分析,该操作不会导致程序段错误。
#include <stdio.h>
int foo(void);
int foo(void)
{
int i = 0;
return i;
}
本实验需要的步骤与实验2完全相同,仅仅是修改的数据不同。在这个实验中,首先在libload.so中定位到“int i = 0”的机器码,对机器码直接修改。在vim中修改时,通过借助xxd和xxd -r进行十六进制转换以及恢复。
如前面猜测的那样,foo函数不依赖DSO的GOT/PLT内容,随意修改成合法的指令照样运行。因此本实验并不会触发程序段错误。
六 总结
前面主要通过四个实验来介绍MAP_DENYWRITE,以及解释与MAP_DENYWRITE有关的两个“为什么”。其实本文最初目的是搞清楚“为什么vim“写-存”DSO时会触发程序Segment fault?”,其他内容主要铺垫和引出该问题。文中大量的概念并没有完全阐述清楚,例如GOT/PLT,其内容并不止于文中提到的部分,涉及到许多其他复杂的概念和过程。
本文所描述的问题主要是代码加速项目中对DSO使用大页实现过程中遗留的疑问,项目成员包括弃余,据德,钟江。
参考
-
聊聊动态链接和dl_runtime_resolve: 点我