首頁 >運維 >linux運維 >深入了解Linux中的copy_{to, from}_user()(附程式碼)

深入了解Linux中的copy_{to, from}_user()(附程式碼)

烟雨青岚
烟雨青岚轉載
2020-06-20 12:54:413605瀏覽

深入了解Linux中的copy_{to, from}_user()(附程式碼)

深入了解Linux中的copy_{to, from}_user()(附程式碼)

引言

我們對copy_{to,from}_user()介面的使用應該是再熟悉不過吧。基本Linux書籍都會介紹它的作用。畢竟它是kernel space和user space溝通的橋樑。所有的資料互動都應該使用類似這種介面。所以,我們沒有理由不知道介面的作用。但是,我也曾經有過以下疑問。

  1. 為什麼需要copy_{to,from}_user(),它究竟在背後為我們做了什麼?

  2. copy_{to,from}_user()和memcpy()的差別是什麼,直接使用memcpy()可以嗎?

  3. memcpy()替代copy_{to,from}_user()是不是一定會有問題?

一下子找回了當年困惑的自己。我所提出的每個問題,曾經我也思考過。還不只一次的思考,每次都有不同的想法。當然是因為從一開始就我就沒有完全理解。現在又回到這個沉重的話題,繼續思考這曾經的問題。

百家爭鳴

針對以上問題當然是先百度。百度對於這個問題的部落格也是很多,足以看出這個問題肯定困惑著一大批Linux的愛好者。對於我的查閱結果來說,觀點主要分成以下兩種:

1、copy_{to,from}_user()比memcpy()多了傳入位址合法性校驗。

例如是否屬於使用者空間位址範圍。理論上說,核心空間可以直接使用使用者空間傳過來的指針,即使要做資料拷貝的動作,也可以直接使用memcpy(),事實上在沒有MMU的體系架構上,copy_{to,from}_user( )最終的實作就是利用了mencpy()。

但是對於大多數有MMU的平台,情況就有了些變化:用戶空間傳過來的指針是在虛擬地址空間上的,它所指向的虛擬地址空間很可能還沒有真正映射到實際的實體頁面上。但這又能怎樣呢?缺頁導致的異常會很透明地被核心予以修復(為缺頁的地址空間提交新的實體頁面),訪問到缺頁的指令會繼續運行彷彿什麼都沒有發生一樣。但這只是使用者空間缺頁異常的行為,在核心空間這種缺頁異常必須被明確地修復,這是由核心提供的缺頁異常處理函數的設計模式決定的。

背後的想法是:在核心態,如果程式試圖存取尚未被提交實體頁面的使用者空間位址,核心必須對此保持警惕而不能像使用者空間那樣毫無察覺。

2、如果我們確保用戶態傳遞的指標的正確性,我們完全可以用memcpy()函數取代copy_{to,from}_user()。經過一些試驗測試,發現使用memcpy(),程式的運作上並沒有問題。因此在確保使用者態指標安全的情況下,二者可以替換。

從各家部落格上,觀點主要集中在第一點。看起來第一點受到大家的廣泛認同。但是,注重實踐的人又得出了第二種觀點,畢竟是實踐出真知。真理究竟是掌握在少數人手中呢?還是群眾的眼睛是雪亮的呢?當然,我不否定以上任何一種觀點。也不能向你保證哪一種觀點正確。因為,我相信即使是曾經無懈可擊的理論,隨著時間的推移或特定情況的改變理論也可能不再正確。例如,牛頓的經典力學理論(好像扯得有點遠)。如果要我說人話,就是:隨著時間的推移,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()針對傳入位址進行類似下面的合法性校驗(簡單說點,更多校驗詳情可以參考程式碼)。

  1. 如果從用戶空間copy資料到核心空間,用戶空間位址to及to加上copy的位元組長度n必須位於用戶空間位址空間。

  2. 如果從核心空間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配置选项是不一样的。这个配置项是 <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运维

以上是深入了解Linux中的copy_{to, from}_user()(附程式碼)的詳細內容。更多資訊請關注PHP中文網其他相關文章!

陳述:
本文轉載於:csdn.net。如有侵權,請聯絡admin@php.cn刪除