Home >Operation and Maintenance >Linux Operation and Maintenance >In-depth understanding of copy_{to, from}_user() in Linux (with code)

In-depth understanding of copy_{to, from}_user() in Linux (with code)

烟雨青岚
烟雨青岚forward
2020-06-20 12:54:413621browse

In-depth understanding of copy_{to, from}_user() in Linux (with code)

In-depth understanding of copy_{to, from}_user() in Linux (with code)

Introduction

We should be very familiar with the use of the copy_{to,from}_user() interface. Basic Linux books will introduce its function. After all, it is the bridge between kernel space and user space. All data interactions should use interfaces like this. Therefore, there is no reason why we should not know the role of interfaces. However, I have also had the following questions.

  1. Why do we need copy_{to,from}_user()? What does it do for us behind the scenes?

  2. What is the difference between copy_{to,from}_user() and memcpy()? Can I use memcpy() directly?

  3. Is there any problem if memcpy() replaces copy_{to,from}_user()?

I suddenly found myself who was confused back then. Every question I've ever asked has been something I've thought about before. I thought about it more than once, and each time I had a different idea. Of course because I didn't fully understand it from the beginning. Now I return to this heavy topic again and continue to think about this past problem.

A hundred schools of thought contend

In response to the above problems, of course, Baidu should be the first one. Baidu also has many blogs on this issue, which is enough to show that this issue must be confusing a large number of Linux enthusiasts. Regarding my review results, opinions are mainly divided into the following two types:

1. copy_{to,from}_user() has more incoming address legality verification than memcpy() test.

For example, whether it belongs to the user space address range. Theoretically, the kernel space can directly use pointers passed from user space. Even if you want to copy data, you can also use memcpy() directly. In fact, on an architecture without MMU, copy_{to,from}_user( )The final implementation uses mencpy().

But for most platforms with MMU, the situation has changed: the pointer passed from the user space is in the virtual address space, and the virtual address space it points to is probably not actually mapped to On the actual physical page. But so what? Exceptions caused by page faults will be transparently repaired by the kernel (submit a new physical page for the address space of the faulted page), and the instructions that access the faulted page will continue to run as if nothing happened. But this is only the behavior of page missing exceptions in user space. In kernel space, this page missing exception must be explicitly repaired. This is determined by the design pattern of the page missing exception handling function provided by the kernel.

The idea behind it is: in kernel mode, if a program attempts to access a user space address that has not yet committed a physical page, the kernel must be alert to this and cannot be unaware of it like user space.

2. If we ensure the correctness of the pointer passed in user mode, we can completely use the memcpy() function instead of copy_{to,from}_user(). After some experiments and tests, I found that there is no problem with the program running using memcpy(). Therefore, the two can be replaced while ensuring the safety of user-mode pointers.

From various blogs, the opinions mainly focus on the first point. It seems that the first point is widely recognized by everyone. However, people who pay attention to practice have come to the second point of view. After all, practice brings true knowledge. Is the truth in the hands of a few people? Or are the masses’ eyes sharp? Of course, I don't deny any of the above views. I can't guarantee you which view is correct. Because I believe that even a once-impeccable theory may no longer be true as time passes or specific circumstances change. For example, Newton's classical mechanics theory (seems a bit far). If you want me to speak human words, it is this: as time goes by, Linux code is constantly changing. Perhaps the above point of view was correct once. Of course, it may still be true now. The following analysis is my point of view. Likewise, everyone needs to remain skeptical. Now I will give some ideas.

Thumbs up

First let’s take a look at the function definitions of memcpy() and copy_{to,from}_user(). There is almost no difference in the parameters, they all include the destination address, source address and the size of bytes to be copied.

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);

However, there is one thing we know for sure. That is, memcpy() does not check the validity of the incoming address. And copy_{to,from}_user() performs a legality check similar to the following for the incoming address (simply put, please refer to the code for more verification details).

  1. If you copy data from user space to kernel space, the user space address to and to plus the copied byte length n must be located in the user space address space.

  2. If you copy data from kernel space to user space, of course you also need to check the legality of the address. For example, whether it is an out-of-bounds access or whether it is data in a code segment, etc. In short, all illegal operations need to be stopped immediately.

经过简单的对比之后,我们再看看其他的差异以及一起探讨下上面提出的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配置选项是不一样的。这个配置项是 <span class="pln">CONFIG_ARM64_SW_TTBR0_PAN</span>或者 <span class="pln">CONFIG_ARM64_PAN</span>(针对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
  1. ttbr1_el1存储的是内核页表基地址,因此其值就是swapper_pg_dir。

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

  3. 将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
  1. uaccess_enable_not_uao和uaccess_disable_not_uao是上面说到的内核态访问用户空间的开关。

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

  3. <span class="pun">.section.fixup,“ax”</span>定义一个section,名为“.fixup”,权限是ax(‘a’可重定位的段,‘x’可执行段)。 <span class="lit">9998</span>标号处的指令就是“未雨绸缪”的善后处理工作。还记得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个选择。 <span class="pun">.fixup</span>段就是为了实现这个修复功能。当copy过程中出现访问非法用户空间地址的时候,do_page_fault()返回的地址变成 <span class="lit">9998</span>标号处,此时可以计算剩余未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条路,尝试救赎自己。这条路就是利用 <span class="pun">.fixup</span><span class="pln">__ex_table</span>段。

如果无力回天只能给当前进程发送SIGSEGV信号。并且,轻则kernel oops,重则panic(取决于kernel配置选项CONFIG_PANIC_ON_OOPS)。在内核态访问非法用户空间地址的情况下,do_page_fault()最终会跳转 <span class="pln">no_context</span>标号处的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存储着异常地址及其对应修复的地址。

例如上述的 <span class="lit">9998:subx0,end,dst</span>指令的地址就会被找到并修改do_page_fault()函数的返回地址,以达到跳转修复的功能。其实查找过程是根据出问题的地址addr,查找_extable段(exception table)是否有对应的exception table entry,如果有就代表可以被修复。由于32位处理器和64位处理器实现方式有差别,因此我们先从32位处理器异常表的实现原理说起。

_extable段的首尾地址分别是 <span class="pln">__start___ex_table</span><span class="pln">__stop___ex_table</span>(定义在include/asm-generic/vmlinux.lds.h。这段内存可以看作是一个数组,数组的每个元素都是 <span class="kwd">struct exception_table_entry</span>类型,其记录着异常发生地址及其对应的修复地址。

                        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, <span class="pln">__start___ex_table</span>值是0xffff000080000000。那么第一个exception table entry的insn和fixup成员的值分别是:0x80000000和0xbffffffc(这两个值都是负数)。因此,针对copy{to,from}user()汇编代码中每一处用户空间地址访问的指令都会创建一个entry。所以0xffff000000000008地址处的汇编指令也需要创建一个exception table entry。

所以,如果内核态访问非法用户空间地址究竟发生了什么?上面的分析流程可以总结如下:

  1. 访问非法用户空间地址:

    0xffff000000000000:ldr x1,[x0]

  2. MMU触发异常

  3. CPU调用do_page_fault()

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

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

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

  7. 程序继续执行,处理出错情况

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

总结

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

  1. 无论是内核态还是用户态访问合法的用户空间地址,当虚拟地址并未建立物理地址的映射关系的时候,page fault的流程几乎一样,都会帮助我们申请物理内存并创建映射关系。所以这种情况下memcpy()和copy_{to,from}_user()是类似的。

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

  3. 在使能 <span class="pln">CONFIG_ARM64_SW_TTBR0_PAN</span>或者 <span class="pln">CONFIG_ARM64_PAN</span>(硬件支持的情况下才有效)的时候,我们只能使用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

推荐教程:《Linux运维

The above is the detailed content of In-depth understanding of copy_{to, from}_user() in Linux (with code). For more information, please follow other related articles on the PHP Chinese website!

Statement:
This article is reproduced at:csdn.net. If there is any infringement, please contact admin@php.cn delete