copy_{to,from}_user

转载:https://blog.csdn.net/juS3Ve/article/details/100787869html

引言
咱们对copy_{to,from}_user()接口的使用应该是再熟悉不过吧。基本Linux书籍都会介绍它的做用。毕竟它是kernel space和user space沟通的桥梁。全部的数据交互都应该使用相似这种接口。因此,咱们没有理由不知道接口的做用。可是,我也曾经有过如下疑问。linux

为何须要copy_{to,from}_user(),它究竟在背后为咱们作了什么?web

copy_{to,from}_user()和memcpy()的区别是什么,直接使用memcpy()能够吗?编程

memcpy()替代copy_{to,from}_user()是否是必定会有问题?设计模式

一会儿找回了当年困惑的本身。我所提出的每一个问题,曾经我也思考过。还不止一次的思考,每一次都有不一样的想法。固然是由于从一开始就我就没有彻底理解。如今又从新回到这个沉重的话题,继续思考这曾经的问题。数组

舒适提示:文章代码分析基于Linux-4.18.0,部分架构相关代码以ARM64为表明。安全

百家争鸣
针对以上问题固然是先百度。百度对于该问题的博客也是不少,足以看出这个问题确定困惑着一大批Linux的爱好者。对于个人查阅结果来讲,观点主要分红如下两种:架构

copy_{to,from}user()比memcpy()多了传入地址合法性校验。例如是否属于用户空间地址范围。理论上说,内核空间能够直接使用用户空间传过来的指针,即便要作数据拷贝的动做,也能够直接使用memcpy(),事实上在没有MMU的体系架构上,copy{to,from}_user()最终的实现就是利用了mencpy()。可是对于大多数有MMU的平台,状况就有了些变化:用户空间传过来的指针是在虚拟地址空间上的,它所指向的虚拟地址空间极可能尚未真正映射到实际的物理页面上。可是这又能怎样呢?缺页致使的异常会很透明地被内核予以修复(为缺页的地址空间提交新的物理页面),访问到缺页的指令会继续运行仿佛什么都没有发生同样。但这只是用户空间缺页异常的行为,在内核空间这种缺页异常必须被显式地修复,这是由内核提供的缺页异常处理函数的设计模式决定的。其背后的思想是:在内核态,若是程序试图访问一个还没有被提交物理页面的用户空间地址,内核必须对此保持警戒而不能像用户空间那样毫无察觉。app

若是咱们确保用户态传递的指针的正确性,咱们彻底能够用memcpy()函数替代copy_{to,from}_user()。通过一些试验测试,发现使用memcpy(),程序的运行上并无问题。所以在确保用户态指针安全的状况下,两者能够替换。svg

从各家博客上,观点主要集中在第一点。看起来第一点受到你们的普遍承认。可是,注重实践的人又得出了第二种观点,毕竟是实践出真知。真理到底是是掌握在少数人手里呢?仍是群众的眼睛是雪亮的呢?固然,我不否认以上任何一种观点。也不能向你保证哪一种观点正确。由于,我相信即便是曾经无懈可击的理论,随着时间的推移或者特定状况的改变理论也可能再也不正确。好比,牛顿的经典力学理论(好像扯得有点远)。若是要我说人话,就是:随着时间的推移,Linux的代码在不断的变化。或许以上的观点在曾经正确。固然,也可能如今还正确。下面的分析就是个人观点了。一样,你们也是须要保持怀疑的态度。下面我就抛砖引玉。

抛砖引玉
首先咱们看下memcpy()和copy_{to,from}_user()的函数定义。参数几乎没有差异,都包含目的地址,源地址和须要复制的字节size。

static __always_inline unsigned long __must_check
copy_to_user(void __user *to, const void *from, unsigned long n);
static __always_inline unsigned long __must_check
copy_from_user(void *to, const void _user *from, unsigned long n);
void *memcpy(void *dest, const void *src, size_t len);
可是,有一点咱们确定是知道的。那就是memcpy()没有传入地址合法性校验。而copy
{to,from}_user()针对传入地址进行相似下面的合法性校验(简单说点,更多校验详情能够参考代码)。

若是从用户空间copy数据到内核空间,用户空间地址to及to加上copy的字节长度n必须位于用户空间地址空间。

若是从内核空间copy数据到用户空间,固然也须要检查地址的合法性。例如,是否越界访问或者是否是代码段的数据等等。总之一切不合法地操做都须要马上杜绝。

通过简单的对比以后,咱们再看看其余的差别以及一块儿探讨下上面提出的2个观点。咱们先从第2个观点提及。涉及实践,我仍是有点相信实践出真知。从我测试的结果来讲,实现结果分红两种状况。

第一种状况的结果是:使用memcpy()测试,没有出现问题,代码正常运行。测试代码以下(仅仅展现proc文件系统下file_operations对应的read接口函数):

static ssize_t test_read(struct file *file, char __user *buf,
size_t len, loff_t offset)
{
memcpy(buf, “test\n”, 5); /
copy_to_user(buf, “test\n”, 5) */
return 5;
}
咱们使用cat命令读取文件内容,cat会经过系统调用read调用test_read,而且传递的buf大小是4k。测试很顺利,结果很喜人。成功地读到了“test”字符串。看起来,第2点观点是没毛病的。可是,咱们还须要继续验证和探究下去。由于第1个观点提到,“在内核空间这种缺页异常必须被显式地修复”。所以咱们还须要验证的状况是:若是buf在用户空间已经分配虚拟地址空间,可是并无创建和物理内存的具体映射关系,这种状况下会出现内核态page fault。咱们首先须要建立这种条件,找到符合的buf,而后测试。这里我固然没测啦。由于有测试结论(主要是由于我懒,构造这个条件我以为比较麻烦)。这个测试是个人一个朋友,人称宋老师的“阿助教”阿克曼大牛。他曾经作个这个实验,而且获得的结论是:即便是没有创建和物理内存的具体映射关系的buf,代码也能够正常运行。在内核态发生page fault,并被其修复(分配具体物理内存,填充页表,创建映射关系)。同时,我从代码的角度分析,结论也是如此。

通过上面的分析,看起来好像是memcpy()也能够正常使用,鉴于安全地考虑建议使用copy_{to,from}_user()等接口。

第二种状况的结果是:以上的测试代码并无正常运行,而且会触发kernel oops。固然本次测试和上次测试的kernel配置选项是不同的。这个配置项是 CONFIG_ARM64_SW_TTBR0_PAN或者 CONFIG_ARM64_PAN(针对ARM64平台)。两个配置选项的功能都是阻止内核态直接访问用户地址空间。只不过CONFIG_ARM64_SW_TTBR0_PAN是软件仿真实现这种功能,而CONFIG_ARM64_PAN是硬件实现功能(ARMv8.1扩展功能)。咱们以CONFIG_ARM64_SW_TTBR0_PAN做为分析对象(软件仿真才有代码提供分析)。BTW,若是硬件不支持,即便配置CONFIG_ARM64_PAN也没用,只能使用软件仿真的方法。若是须要访问用户空间地址须要经过相似copy_{to,from}_user()的接口,不然会致使kernel oops。

在打开CONFIG_ARM64_SW_TTBR0_PAN的选项后,测试以上代码就会致使kernel oops。缘由就是内核态直接访问了用户空间地址。所以,在这种状况咱们就不可使用memcpy()。咱们别无选择,只能使用copy_{to,from}_user()。

为何咱们须要PAN(Privileged Access Never)功能呢?缘由多是用户空间和内核空间数据交互上容易引入安全问题,因此咱们就不让内核空间轻易访问用户空间,若是非要这么作,就必须经过特定的接口关闭PAN。另外一方面,PAN功能能够更加规范化内核态和用户态数据交互的接口使用。在使能PAN功能的状况下,能够迫使内核或者驱动开发者使用copy_{to,from}_user()等安全接口,提高系统的安全性。相似memcpy()非规范操做,kernel就oops给你看。

因为编程的不规范而引入安全漏洞。例如:Linux内核漏洞CVE-2017-5123能够提高权限。该漏洞的引入缘由就是是缺乏access_ok()检查用户传递地址的合法性。所以,为了不本身编写的代码引入安全问题,针对内核空间和用户空间数据交互上,咱们要格外小心。

刨根问底
既然提到了CONFIG_ARM64_SW_TTBR0_PAN的配置选项。固然我也但愿了解其背后设计的原理。因为ARM64的硬件特殊设计,咱们使用两个页表基地址寄存器ttbr0_el1和ttbr1_el1。处理器根据64 bit地址的高16 bit判断访问的地址属于用户空间仍是内核空间。若是是用户空间地址则使用ttbr0_el1,反之使用ttbr1_el1。所以,ARM64进程切换的时候,只须要改变ttbr0_el1的值便可。ttbr1_el1能够选择不须要改变,由于全部的进程共享相同的内核空间地址。

当进程切换到内核态(中断,异常,系统调用等)后,如何才能避免内核态访问用户态地址空间呢?其实不难想出,改变ttbr0_el1的值便可,指向一段非法的映射便可。所以,咱们为此准备了一份特殊的页表,该页表大小4k内存,其值全是0。当进程切换到内核态后,修改ttbr0_el1的值为该页表的地址便可保证访问用户空间地址是非法访问。由于页表的值是非法的。这个特殊的页表内存经过连接脚本分配。

#define RESERVED_TTBR0_SIZE (PAGE_SIZE)
SECTIONS
{
reserved_ttbr0 = .;
. += RESERVED_TTBR0_SIZE;
swapper_pg_dir = .;
. += SWAPPER_DIR_SIZE;
swapper_pg_end = .;
}
这个特殊的页表和内核页表在一块儿。和swapper_pg_dir仅仅差4k大小。reserved_ttbr0地址开始的4k内存空间的内容会被清零。

当咱们进入内核态后会经过__uaccess_ttbr0_disable切换ttbr0_el1以关闭用户空间地址访问,在须要访问的时候经过_uaccess_ttbr0_enable打开用户空间地址访问。这两个宏定义也不复杂,就以_uaccess_ttbr0_disable为例说明原理。其定义以下:

.macro __uaccess_ttbr0_disable, tmp1
mrs \tmp1, ttbr1_el1 // swapper_pg_dir (1)
bic \tmp1, \tmp1, #TTBR_ASID_MASK
sub \tmp1, \tmp1, #RESERVED_TTBR0_SIZE // reserved_ttbr0 just before
// swapper_pg_dir (2)
msr ttbr0_el1, \tmp1 // set reserved TTBR0_EL1 (3)
isb
add \tmp1, \tmp1, #RESERVED_TTBR0_SIZE
msr ttbr1_el1, \tmp1 // set reserved ASID
isb
.endm
ttbr1_el1存储的是内核页表基地址,所以其值就是swapper_pg_dir。

swapper_pg_dir减去RESERVED_TTBR0_SIZE就是上面描述的特殊页表。

将ttbr0_el1修改指向这个特殊的页表基地址,固然能够保证后续访问用户地址都是非法的。

_uaccess_ttbr0_disable对应的C语言实现能够参考这里。如何容许内核态访问用户空间地址呢?也很简单,就是__uaccess_ttbr0_disable的反操做,给ttbr0_el1赋予合法的页表基地址。这里就没必要重复了。咱们如今须要知道的事实就是,在配置CONFIG_ARM64_SW_TTBR0_PAN的状况下,copy{to,from}user()接口会在copy以前容许内核态访问用户空间,并在copy结束以后关闭内核态访问用户空间的能力。所以,使用copy{to,from}_user()才是正统作法。主要体如今安全性检查及安全访问处理。这里是其比memcpy()多的第一个特性,后面还会介绍另外一个重要特性。

如今咱们能够解答上一节中遗留的问题。怎样才能继续使用memcpy()?如今就很简单了,在memcpy()调用以前经过uaccess_enable_not_uao()容许内核态访问用户空间地址,调用memcpy(),最后经过uaccess_disable_not_uao()关闭内核态访问用户空间的能力。

未雨绸缪
以上的测试用例都是创建在用户空间传递合法地址的基础上测试的,何为合法的用户空间地址?用户空间经过系统调用申请的虚拟地址空间包含的地址范围,便是合法的地址(不管是否分配物理页面创建映射关系)。既然要写一个接口程序,固然也要考虑程序的健壮性,咱们不能假设全部的用户传递的参数都是合法的。咱们应该预判非法传参状况的发生,并提早作好准备,这就是未雨绸缪。

咱们首先使用memcpy()的测试用例,随机传递一个非法的地址。通过测试发现:会触发kernel oops。继续使用copy_{to,from}_user()替代memcpy()测试。测试发现:read()仅仅是返回错误,但不会触发kernel oops。这才是咱们想要的结果。毕竟,一个应用程序不该该触发kernel oops。这种机制的实现原理是什么呢?

咱们以copy_to_user()为例分析。函数调用流程是:

copy_to_user()->_copy_to_user()->raw_copy_to_user()->__arch_copy_to_user()
_arch_copy_to_user()在ARM64平台是汇编代码实现,这部分代码很关键。

end .req x5
ENTRY(__arch_copy_to_user)
uaccess_enable_not_uao x3, x4, x5
add end, x0, x2
#include “copy_template.S”
uaccess_disable_not_uao x3, x4
mov x0, #0
ret
ENDPROC(__arch_copy_to_user)
.section .fixup,“ax”
.align 2
9998: sub x0, end, dst // bytes not copied
ret
.previous
uaccess_enable_not_uao和uaccess_disable_not_uao是上面说到的内核态访问用户空间的开关。

copy_template.S文件是汇编实现的memcpy()的功能,稍后看看memcpy()的实现代码就清楚了。

.section.fixup,“ax”定义一个section,名为“.fixup”,权限是ax(‘a’可重定位的段,‘x’可执行段)。 9998标号处的指令就是“未雨绸缪”的善后处理工做。还记得copy_{to,from}user()返回值的意义吗?返回0表明copy成功,不然返回剩余没有copy的字节数。这行代码就是计算剩余没有copy的字节数。当咱们访问非法的用户空间地址的时候,就必定会触发page fault。这种状况下,内核态发生的page fault并返回的时候并无修复异常,因此确定不能返回发生异常的地址继续运行。因此,系统能够有2个选择:第1个选择是kernel oops,并给当前进程发送SIGSEGV信号;第2个选择是不返回出现异常的地址运行,而是选择一个已经修复的地址返回。若是使用的是memcpy()就只有第1个选择。可是copy{to,from}_user()能够有第2个选择。 .fixup段就是为了实现这个修复功能。当copy过程当中出现访问非法用户空间地址的时候,do_page_fault()返回的地址变成 9998标号处,此时能够计算剩余未copy的字节长度,程序还能够继续执行。

对比前面分析的结果,其实_arch_copy_to_user()能够近似等效以下关系。

uaccess_enable_not_uao();
memcpy(ubuf, kbuf, size); == __arch_copy_to_user(ubuf, kbuf, size);
uaccess_disable_not_uao();
先插播一条消息,解释copy_template.S为什么是memcpy()。memcpy()在ARM64平台是由汇编代码实现。其定义在arch/arm64/lib/memcpy.S文件。

.weak memcpy
ENTRY(__memcpy)
ENTRY(memcpy)
#include “copy_template.S”
ret
ENDPIPROC(memcpy)
ENDPROC(__memcpy)
因此很明显,memcpy()和__memcpy()函数定义是同样的。而且memcpy()函数声明是weak,所以能够重写memcpy()函数(扯得有点远)。再扯一点,为什么使用汇编呢?为什么不使用lib/string.c文件的memcpy()函数呢?固然是为了优化memcpy() 的执行速度。lib/string.c文件的memcpy()函数是按照字节为单位进行copy(再好的硬件也会被粗糙的代码毁掉)。可是如今的处理器基本都是32或者64位,彻底能够4 bytes或者8 bytes甚至16 bytes copy(考虑地址对齐的状况下)。能够明显提高执行速度。因此,ARM64平台使用汇编实现。这部分知识能够参考这篇博客《ARM64 的 memcpy 优化与实现》。

下面继续进入正题,再重复一遍:内核态访问用户空间地址,若是触发page fault,只要用户空间地址合法,内核态也会像什么也没有发生同样修复异常(分配物理内存,创建页表映射关系)。可是若是访问非法用户空间地址,就选择第2条路,尝试救赎本身。这条路就是利用 .fixup和 __ex_table段。若是无力回天只能给当前进程发送SIGSEGV信号。而且,轻则kernel oops,重则panic(取决于kernel配置选项CONFIG_PANIC_ON_OOPS)。在内核态访问非法用户空间地址的状况下,do_page_fault()最终会跳转 no_context标号处的do_kernel_fault()。

static void __do_kernel_fault(unsigned long addr, unsigned int esr,
struct pt_regs regs)
{
/

* Are we prepared to handle this kernel fault?
* We are almost certainly not prepared to handle instruction faults.
/
if (!is_el1_instruction_abort(esr) && fixup_exception(regs))
return;
/
… */
}
fixup_exception()继续调用search_exception_tables(),其经过查找_extable段。__extable段存储exception table,每一个entry存储着异常地址及其对应修复的地址。例如上述的 9998:subx0,end,dst指令的地址就会被找到并修改do_page_fault()函数的返回地址,以达到跳转修复的功能。其实查找过程是根据出问题的地址addr,查找_extable段(exception table)是否有对应的exception table entry,若是有就表明能够被修复。因为32位处理器和64位处理器实现方式有差异,所以咱们先从32位处理器异常表的实现原理提及。

_extable段的首尾地址分别是 __start___ex_table和 __stop___ex_table(定义在include/asm-generic/vmlinux.lds.h。这段内存能够看做是一个数组,数组的每一个元素都是 struct exception_table_entry类型,其记录着异常发生地址及其对应的修复地址。

exception tables

__start___ex_table --> ±--------------+
| entry |
±--------------+
| entry |
±--------------+
| … |
±--------------+
| entry |
±--------------+
| entry |
__stop___ex_table --> ±--------------+
在32位处理器上,struct exception_table_entry定义以下:

struct exception_table_entry {
unsigned long insn, fixup;
};
有一点须要明确,在32位处理器上,unsigned long是4 bytes。insn和fixup分别存储异常发生地址及其对应的修复地址。根据异常地址ex_addr查找对应的修复地址(未找到返回0),其示意代码以下:

unsigned long search_fixup_addr32(unsigned long ex_addr)
{
const struct exception_table_entry *e;
for (e = __start___ex_table; e < __stop___ex_table; e++)
if (ex_addr == e->insn)
return e->fixup;
return 0;
}
在32位处理器上,建立exception table entry相对简单。针对copy{to,from}user()汇编代码中每一处用户空间地址访问的指令都会建立一个entry,而且insn存储当前指令对应的地址,fixup存储修复指令对应的地址。

当64位处理器开始发展起来,若是咱们继续使用这种方式,势必须要2倍于32位处理器的内存存储exception table(由于存储一个地址须要8 bytes)。因此,kernel换用另外一种方式实现。在64处理器上,struct exception_table_entry定义以下:

struct exception_table_entry {
int insn, fixup;
};
每一个exception table entry占用的内存和32位处理器状况同样,所以内存占用不变。可是insn和fixup的意义发生变化。insn和fixup分别存储着异常发生地址及修复地址相对于当前结构体成员地址的偏移(有点拗口)。例如,根据异常地址ex_addr查找对应的修复地址(未找到返回0),其示意代码以下:

unsigned long search_fixup_addr64(unsigned long ex_addr)
{
const struct exception_table_entry *e;
for (e = __start___ex_table; e < __stop___ex_table; e++)
if (ex_addr == (unsigned long)&e->insn + e->insn)
return (unsigned long)&e->fixup + e->fixup;
return 0;
}
所以,咱们的关注点就是如何去构建exception_table_entry。咱们针对每一个用户空间地址的内存访问都须要建立一个exception table entry,并插入_extable段。例以下面的汇编指令(汇编指令对应的地址是随意写的,不用纠结对错。理解原理才是王道)。

0xffff000000000000: ldr x1, [x0]
0xffff000000000004: add x1, x1, #0x10
0xffff000000000008: ldr x2, [x0, #0x10]
/* … */
0xffff000040000000: mov x0, #0xfffffffffffffff2 // -14
0xffff000040000004: ret
假设x0寄存器保存着用户空间地址,所以咱们须要对0xffff000000000000地址的汇编指令建立一个exception table entry,而且咱们指望当x0是非法用户空间地址时,跳转返回的修复地址是0xffff000040000000。为了计算简单,假设这是建立第一个entry, __start___ex_table值是0xffff000080000000。那么第一个exception table entry的insn和fixup成员的值分别是:0x80000000和0xbffffffc(这两个值都是负数)。所以,针对copy{to,from}user()汇编代码中每一处用户空间地址访问的指令都会建立一个entry。因此0xffff000000000008地址处的汇编指令也须要建立一个exception table entry。

因此,若是内核态访问非法用户空间地址究竟发生了什么?上面的分析流程能够总结以下:

访问非法用户空间地址:

0xffff000000000000:ldr x1,[x0]

MMU触发异常

CPU调用do_page_fault()

do_page_fault()调用search_exception_table()(regs->pc == 0xffff000000000000)

查看_extable段,寻找0xffff000000000000 而且返回修复地址0xffff000040000000

do_page_fault()修改函数返回地址(regs->pc = 0xffff000040000000)并返回

程序继续执行,处理出错状况

修改函数返回值x0 = -EFAULT (-14) 并返回(ARM64经过x0传递函数返回值)

总结
到了回顾总结的时候,copy_{to,from}_user()的思考也到此结束。咱们来个总结结束此文。

不管是内核态仍是用户态访问合法的用户空间地址,当虚拟地址并未创建物理地址的映射关系的时候,page fault的流程几乎同样,都会帮助咱们申请物理内存并建立映射关系。因此这种状况下memcpy()和copy_{to,from}_user()是相似的。

当内核态访问非法用户空间地址的时候,根据异常地址查找修复地址。这种修复异常的方法并非创建地址映射关系,而是修改do_page_fault()返回地址。而memcpy()没法作到这点。

在使能 CONFIG_ARM64_SW_TTBR0_PAN或者 CONFIG_ARM64_PAN(硬件支持的状况下才有效)的时候,咱们只能使用copy_{to,from}_user()这种接口,直接使用memcpy()是不行的。

最后,我想说,即便在某些状况下memcpy()能够正常工做。可是,这也是不推荐的,不是良好的编程习惯。在用户空间和内核空间数据交互上,咱们必须使用相似copy_{to,from}user()的接口。为何相似呢?由于还有其余的接口用于内核空间和用户空间数据交互,只是没有copy{to,from}_user()出名。例如:{get,put}_user()。

本文转载自蜗窝科技:

http://www.wowotech.net/memory_management/454.html