18

修志龙_ZenonXiu · 2023年04月06日 · 广东

Arm构架如何帮助Linux守护User和Kernel的边界?

Linux系统设计为独立的用户空间和内核空间,从而实现隔离得到安全性。在具有MMU的系统上,安全性是通过控制用户空间地址和内核空间地址的访问权限来是实现的。运行在用户空间的应用没有访问内核地址空间的权限,而内核大多数情况下不应该访问用户空间地址(虽然可能有访问权限)。但用户程序做系统调用时,内核调用服务需要访问用户空间传入的buffer空间,通常需要kernel使用get_user, put_user, copy_to_user, copy_from_user这些专用的接口来显式地访问用户空间地址,在kernel的其他地方都不应该访问到用户空间地址(除非内核空间的错误编程,或是恶意软件导致的恶意地址访问)。

从 Arm9处理器开始,arm构架的CPU广泛运行Linux。通过arm构架提供的硬件机制,可以加固Linux的用户空间和内核空间的隔离。
本文探讨arm/arm64 Linux提供的保护方式。

访问权限的控制

Arm 32位构架(armv5~armv7 和armv8的AArch32)的MMU 可能有两种不同的页表格式:

  1. Short Descriptor: armv5~armv7 和armv8的AArch32 都支持。使用这种格式,在页表项中使用AP[2:0],3个AP bit来控制地址的访问权限。
    image.png

实际上,访问权限还受到Domain access control的控制,在页表项中有一个指向Domain Access Control 寄存器的Index,从而得到这个访问地址对应的Domain,可以是:

  • Domain No Access :不管AP[2:0]的设置,都没有访问权限
  • Domain Client: 以AP [2:0]的设置来决定访问权限
  • Domain Manager: 不管AP[2:0]的设置,都具有访问权限
    copy_user_access-domain.jpg

这种格式还支持配置为将AP[0]用作Access Flag,从而使用作访问权限的只用AP[2:1](2个AP bit)。

  1. Long Descriptor: armv7 LPAE和Armv8 AArch32支持使用这种格式,它使用AP [2:1](2个AP bit)作为访问权限控制。这种格式没有Domain Control的支持。

image.png

  1. Armv8/9-A AArch64页表格式与LPAE类似,AP [2:1](2个AP bit)作为访问权限控制

image.png

get_user, put_user, copy_to_user, copy_from_user可能带来的问题

由上面的访问权限组合可以看出,只要user非特权可以访问的内存地址,kernel特权级都有相等或是更高的访问权限。恶意的/有问题的应用可能传入一个落在kernel空间的buf地址,从而达到破环kernel数据,或是获取kernel数据的目的。
copy_to_user可能的问题:
copy_user_access-Page-1.jpg
copy_from_user可能的问题:
copy_user_access-Page-2.jpg

很早以前,Linux kernel就在这些接口上仔细设计了检查地址是否是用户空间地址的代码。
检查地址的代码为access_ok()。Linux 2.3的实现为:

#define access_ok(type,addr,size)    (__range_ok(addr,size) == 0)


#define __range_ok(addr,size) ({                    \
    unsigned long flag, sum;                    \
    __asm__ __volatile__("subs %1, %0, %3; cmpcs %1, %2; movcs %0, #0" \
        : "=&r" (flag), "=&r" (sum)                \
        : "r" (addr), "Ir" (size), "0" (current->addr_limit)    \
        : "cc");                        \
    flag; })

因为系统运行时系统调用非常多,为了性能, acces_ok通常使用对应CPU构架的汇编实现。

但是某些kernel的服务不仅仅提供给应用来调用,它们也可能被运行于特权级的kernel代码调用。Kernel本身调用这些服务时,传入的是kernel空间的地址,为了使这个地址可以通过access_ok的检查,kernel设计了set_fs的接口, 可以临时将传入的地址允许的空间扩大到整个user和kernel地址空间, 以便其通过access_ok的检查。这是利用修改addr_limit全局变量来实现的。其中的一个例子是,kernel代码通过vfs_readv系统服务打开一个文件:

#define get_ds() (KERNEL_DS)

old_fs = get_fs();
set_fs(get_ds());
res = vfs_readv(file, (const struct iovec __user *)vec, vlen, &pos, 0);
set_fs(old_fs);

在arm64 Linux 5.10上,

#define KERNEL_DS        UL(-1)
#define USER_DS            ((UL(1) << VA_BITS) - 1)

#define get_fs()    (current_thread_info()->addr_limit)

static inline void set_fs(mm_segment_t fs)
{
    current_thread_info()->addr_limit = fs;

如果设置set_fs(KERNEL_DS),那么addr_limit被设置为包含kernel和user地址空间的全部地址。set_fs(USER_DS),那么addr_limit被设置为只含user地址空间。

#define access_ok(addr, size)    __range_ok(addr, size)


/*
 * Test whether a block of memory is a valid user space address.
 * Returns 1 if the range is valid, 0 otherwise.
 *
 * This is equivalent to the following test:
 * (u65)addr + (u65)size <= (u65)current->addr_limit + 1
 */
static inline unsigned long __range_ok(const void __user *addr, unsigned long size)
{
    unsigned long ret, limit = current_thread_info()->addr_limit;
    
    __chk_user_ptr(addr);
    asm volatile(
    // A + B <= C + 1 for all A,B,C, in four easy steps:
    // 1: X = A + B; X' = X % 2^64
    "    adds    %0, %3, %2\n"
    // 2: Set C = 0 if X > 2^64, to guarantee X' > C in step 4
    "    csel    %1, xzr, %1, hi\n"
    // 3: Set X' = ~0 if X >= 2^64. For X == 2^64, this decrements X'
    //    to compensate for the carry flag being set in step 4. For
    //    X > 2^64, X' merely has to remain nonzero, which it does.
    "    csinv    %0, %0, xzr, cc\n"
    // 4: For X < 2^64, this gives us X' - C - 1 <= 0, where the -1
    //    comes from the carry in being clear. Otherwise, we are
    //    testing X' - C == 0, subject to the previous adjustments.
    "    sbcs    xzr, %0, %1\n"
    "    cset    %0, ls\n"
    : "=&r" (ret), "+r" (limit) : "Ir" (size), "0" (addr) : "cc");

    return ret;
}    
    

access_ok被定义为__range_ok,用于检查输入地址范围是否越界。

即使access_ok的检查比较完备,但还是有些driver还是有可能会有漏洞,或者kernel在其他地方也不会不经意访问到user space内存。其中一个例子是:

An issue where a provided address with access_ok() is not checked was discovered in i915_gem_execbuffer2_ioctl in drivers/gpu/drm/i915/i915_gem_execbuffer.c in the Linux kernel through 4.19.13

另一个例子是:

The do_exit function in kernel/exit.c in the Linux kernel before 2.6.36.2 does not properly handle a KERNEL_DS get_fs value, which allows local users to bypass intended access_ok restrictions, overwrite arbitrary kernel memory locations, and gain privileges 

为了进一步增强安全,除了地址空间检查之外,arm构架还引入了一序列的硬件访问权限控制来帮助阻挡不经意和恶意的访问。

arm构架硬件加固访问控制和Linux Kernel的使用

使用'unpriviledge'访问权限的特殊load/store指令

从armv5开始就引入了一类特殊的load store指令( Armv5 – AArch32为:STRT, LDRT, AArch64为STTR, LDTR )在特权级运行这些指令时使用的非特权/user对应的访问权限,而不是特权级对应的权限。
copy_user_access-Page-4.jpg

这样可以使应用传入的只有kernel才有访问权限的kernel地址的访问导致data abort,从而进一步在即使恶意/有问题代码绕过access_ok检查也能避免安全性问题。在put_user, get_user, copy_from_user, copy_to_user实现中使用了这一类的 load/store指令:
copy_user_access-Page-3.jpg

/*
 * Generate the T (user) versions of the LDR/STR and related
 * instructions (inline assembly)
 */
#ifdef CONFIG_CPU_USE_DOMAINS
#define TUSER(instr)    #instr "t"
#else
#define TUSER(instr)    #instr
#endif

为了代码的统一,当kernel调用服务时put_user, get_user, copy_from_user, copy_to_user中依然使用了这一类的load/store 指令,这可能会导致一个问题:kernel传入的地址时kernel空间地址, 这个地址在页表中设置的访问权限一般为只有特权级能访问,非特权级不能访问,即使通过设置set_fs使其能通过access_ok检查,但这一类load/store指令还是会触发data abort。因而需要一些处理来避免这种情况下的data abort。根据不同的构架,Linux kernel在进化的过程中采用了Domain Access Control, UAO等技术处理。

利用Domain Manager:
从armv5开始,MMU就支持Domain Access Control Register,操作系统可利用它更方便地管理内存区域的访问权限。MMU页表项包含访问权限access permission控制APX和AP bits,用于设置内存的特权和非特权的访问权限。同时页表项里的Domain bits 用于检索Domain Access Control Register (DACR)寄存器,它会提供这块内存对应的Domain模式设置信息。

Domain可以是:No Access,Client或是Manager模式。只有当Domain是Client模式时,页表项中的APX,AP起作用控制访问权限,当Domain是No Access模式时,特权和非特权级都不能访问这块内存。当Domain是manager模式时,不管APX,AP的设置,都具备读写权限。利用Domain可以更方便操作系统改变访问权限,而不需要遍历MMU页表修改APX,AP bit。

#define DOMAIN_KERNEL 0
#define DOMAIN_USER  1
#define DOMAIN_IO     2

Linux kernel一般使用的时候,设置内存的Domain为Client。当操作系统利用带有copy_from/to_user的系统服务时,它可以先通过set_fs(KERNEL_DS)临时将addr_limit设置为包括内核地址空间,从而可以通过__range_ok的地址检查。因为这时传入的地址是kernel空间地址,user space应该是没有访问权限,copy_from/to_user中使用的STRT, LDRT不能访问此内存。解决的方式是:在调用copy_from/to_use前,临时将Domain从client改为Manager,绕过访问权限的检查,copy_from/to_use之后再将Domain改回Client模式。

Arm32 Linux kernel是否使用Domain Access Control由CONFIG_CPU_USE_DOMAINS控制。

This option enables or disables the use of domain switching using the DACR (domain access control register) to protect memory domains from each other. In Linux we use three domains: kernel, user and IO. The domains are used to protect userspace from kernelspace and to handle IO-space as a special type of memory by assigning manager or client roles to running code (such as a process).

下面代码来自于Linux arm32 kernel 5.10

/*
 * Domain types
 */
#define DOMAIN_NOACCESS    0
#define DOMAIN_CLIENT    1
#ifdef CONFIG_CPU_USE_DOMAINS
#define DOMAIN_MANAGER    3     //manager mode
#else
#define DOMAIN_MANAGER    1     //client mode
#endif
/*
 * Note that this is actually 0x1,0000,0000
 */
#define KERNEL_DS    0x00000000

static inline void set_fs(mm_segment_t fs)
{
    current_thread_info()->addr_limit = fs;

    /*
     * Prevent a mispredicted conditional call to set_fs from forwarding
     * the wrong address limit to access_ok under speculation.
     */
    dsb(nsh);
    isb();

    modify_domain(DOMAIN_KERNEL, fs ? DOMAIN_CLIENT : DOMAIN_MANAGER);
}

但是,Domain Access Control这个机制和XN工作会有些问题:当设置为Client模式时,设置XN可以阻止Speculative的指令预取。但是,当设置为Manager模式时,XN就不能阻止Speculative的指令预取。

 When using the Short-descriptor translation table format, the XN attribute is not checked for domains marked as Manager.
Therefore, the system must not include read-sensitive memory in domains marked as Manager, because the XN bit does not prevent speculative fetches from a Manager domain.

因此在uboot中,提供了相应的patch。在我支持客户的过程中,时常碰到硬件突发挂死的问题,原因是硬件的speculative的指令预取到了不存在的物理内存,通过建议这个patch可以解决这些问题。

[U-Boot] [RFC PATCH 2/2] ARM: mmu: Set domain permissions to client access (mail-archive.com)

armv7 LPAE,armv8-a AArch64都不再支持Domain Access Control。

利用CONFIG_CPU_SW_DOMAIN_PAN

为了避免在kernel在除copy_from/to_user或get/put_user这些预知的地方之外不经意、恶意的访问user space内存,早期arm32 Linux kernel采用了通过Domain Access实现的Priviledge Access Nerver(PAN)。

异常处理进入kernel模式时,将user memory对应的Domain(DOMAIN_USER)设置为No Access (DOMAIN_NOACCESS), 阻止后面的kernel代码对应user space的访问。
异常退出到user模式时,将user memory对应的Domain(DOMAIN_USER)设置为DOMAIN_CLIENT,让后面的user代码按照页表中设置APX,AP访问权限访问内存。

#define DACR_UACCESS_DISABLE    \
    (__DACR_DEFAULT | domain_val(DOMAIN_USER, DOMAIN_NOACCESS))
#define DACR_UACCESS_ENABLE    \
    (__DACR_DEFAULT | domain_val(DOMAIN_USER, DOMAIN_CLIENT))
    
    .macro    uaccess_disable, tmp, isb=1
#ifdef CONFIG_CPU_SW_DOMAIN_PAN
    /*
     * Whenever we re-enter userspace, the domains should always be
     * set appropriately.
     */
    mov    \tmp, #DACR_UACCESS_DISABLE
    mcr    p15, 0, \tmp, c3, c0, 0        @ Set domain register
    .if    \isb
    instr_sync
    .endif
#endif
    .endm

    .macro    uaccess_enable, tmp, isb=1
#ifdef CONFIG_CPU_SW_DOMAIN_PAN
    /*
     * Whenever we re-enter userspace, the domains should always be
     * set appropriately.
     */
    mov    \tmp, #DACR_UACCESS_ENABLE
    mcr    p15, 0, \tmp, c3, c0, 0
    .if    \isb
    instr_sync
    .endif
#endif
    .endm  
    .macro    usr_entry, trace=1, uaccess=1
    
    
     .if \uaccess
    uaccess_disable ip
    .endif 
    
    .macro    svc_entry, stack_hole=0, trace=1, uaccess=1
    
    uaccess_entry tsk, r0, r1, r2, \uaccess
    /*
     * Save the address limit on entry to a privileged exception.
     *
     * If we are using the DACR for kernel access by the user accessors
     * (CONFIG_CPU_USE_DOMAINS=y), always reset the DACR kernel domain
     * back to client mode, whether or not \disable is set.
     *
     * If we are using SW PAN, set the DACR user domain to no access
     * if \disable is set.
     */
    .macro    uaccess_entry, tsk, tmp0, tmp1, tmp2, disable
 DACR(    mrc    p15, 0, \tmp0, c3, c0, 0)
 DACR(    str    \tmp0, [sp, #SVC_DACR])
    .if \disable && IS_ENABLED(CONFIG_CPU_SW_DOMAIN_PAN)
    /* kernel=client, user=no access */
    mov    \tmp2, #DACR_UACCESS_DISABLE
    mcr    p15, 0, \tmp2, c3, c0, 0
    instr_sync
    .elseif IS_ENABLED(CONFIG_CPU_USE_DOMAINS)
    /* kernel=client */
    bic    \tmp2, \tmp0, #domain_mask(DOMAIN_KERNEL)
    orr    \tmp2, \tmp2, #domain_val(DOMAIN_KERNEL, DOMAIN_CLIENT)
    mcr    p15, 0, \tmp2, c3, c0, 0
    instr_sync
    .endif
    .endm

利用CONFIG_ARM64_SW_TTBR0_PAN

armv7 LPAE和armv8/9的aarch64没有Domain access control的支持。为了避免在kernel在除copy_from/to_user或get/put_user这些预知的地方之外不经意、恶意的访问user space内存,Arm64 Linux kernel采用另外一种方式CONFIG_ARM64_SW_TTBR0_PAN。我们知道arm32 LPAE kernel和arm64 kernel支持两个页表,TTBR0指向的页表为user space地址空间提供地址翻译,TTBR1指向的页表为kernel space地址空间提供地址翻译。不管代码运行在user和kernel模式,只要要访问的地址落在user space地址空间,MMU硬件自动使用TTBR0指向的页表,地址空间落在kernel space,硬件自动使用TTBR1指向的页表。
CONFIG_ARM64_SW_TTBR0_PAN的工作方式是:运行kernel代码时通常将TTBR0设置为reserved_pg_dir,而非user space正常使用的页表,以此避免kernel对user space的不经意/恶意访问。只在copy_from/to_user或get/put_user这些预知的地方将user space的页表设置到TTBR0,以便kernel访问user space内存。

当运行user application时,将正常的user space页表设置到TTBR0.

#ifdef CONFIG_ARM64_SW_TTBR0_PAN
    .macro    __uaccess_ttbr0_disable, tmp1
    mrs    \tmp1, ttbr1_el1            // swapper_pg_dir
    bic    \tmp1, \tmp1, #TTBR_ASID_MASK
    sub    \tmp1, \tmp1, #RESERVED_TTBR0_SIZE    // reserved_ttbr0 just before swapper_pg_dir
    msr    ttbr0_el1, \tmp1            // set reserved TTBR0_EL1
    isb
    add    \tmp1, \tmp1, #RESERVED_TTBR0_SIZE
    msr    ttbr1_el1, \tmp1        // set reserved ASID
    isb
    .endm

    .macro    __uaccess_ttbr0_enable, tmp1, tmp2
    get_current_task \tmp1
    ldr    \tmp1, [\tmp1, #TSK_TI_TTBR0]    // load saved TTBR0_EL1
    mrs    \tmp2, ttbr1_el1
    extr    \tmp2, \tmp2, \tmp1, #48
    ror     \tmp2, \tmp2, #16
    msr    ttbr1_el1, \tmp2        // set the active ASID
    isb
    msr    ttbr0_el1, \tmp1        // set the non-PAN TTBR0_EL1
    isb
    .endm

硬件PAN的支持

Armv8.1-A引入了硬件Privilege Access Never(PAN)的支持。软件可以设置PSTATE.PAN=1使能这个功能。当这个功能使能时,对于任何具有user可读或可写权限的权限的内存,在CPU运行在特权模式时,对这些内存都不能访问(Access Never,不管内存的特权访问权限是什么)。PAN提供一个硬件控制门,实现运行在kernel态时,任何不小心的(如https://www.cvedetails.com/cv...  An issue where a provided address with access_ok() is not checked)或故意的对user space memory的访问都会被PAN=1阻止。只有在copy_from/to_user或get/put_user这些预知的地方设置PAN=0,允许对user space内存的访问。
Arm64 Linux Kernel 4.3引入CONFIG_ARM64_PAN控制选项。没有后面要介绍的Armv8.2-A UAO之前,使用CONFIG_ARM64_PAN时,copy_from/to_user或get/put_user使用的是正常的load/store指令实现。

static inline void __uaccess_disable_hw_pan(void)
{
    asm(ALTERNATIVE("nop", SET_PSTATE_PAN(0), ARM64_HAS_PAN,
            CONFIG_ARM64_PAN));
}

static inline void __uaccess_enable_hw_pan(void)
{
    asm(ALTERNATIVE("nop", SET_PSTATE_PAN(1), ARM64_HAS_PAN,
            CONFIG_ARM64_PAN));
}
ENTRY(__copy_from_user)
ALTERNATIVE("nop", __stringify(SET_PSTATE_PAN(0)), ARM64_HAS_PAN, \
        CONFIG_ARM64_PAN)
    add    x5, x1, x2            // upper user buffer boundary
    subs    x2, x2, #16
    b.mi    1f

UAO的支持

Armv8.2-a引入了User Access Override (UAO)功能。当软件通过设置PSTATE.UAO=1 使能UAO功能时,STTR, LDTR 这些利用非特权权限访问指令会变成正常的Load/store指令(执行在特权级时利用特权级的访问权限,执行在非特权级时利用非特权级的访问权限)。当软件设置PSTATE.UAO=0时,STTR, LDTR 这些利用非特权权限访问指令还是使用非特权级访问权限(即使执行在特权级)。
copy_user_access-Page-5.jpg

Arm64 Linux Kernel通常执行时,设置PSTATE.UAO=0,以便copy_from/to_user或get/put_user能正常利用STTR, LDTR这些指令。而当kernel本身调用带有copy_from/to_user或get/put_user的系统调用时,设置addr_limit包含kernel space,并改变PSTATE.UAO为1,从而不需要改copy_from/to_user或get/put_user的STTR, LDTR指令实现(这时STTR, LDTR变成了一般的load/store)可以访问kernel space内存。

static inline void set_fs(mm_segment_t fs)
{
    current_thread_info()->addr_limit = fs;

    /*
     * Prevent a mispredicted conditional call to set_fs from forwarding
     * the wrong address limit to access_ok under speculation.
     */
    spec_bar();

    /* On user-mode return, check fs is correct */
    set_thread_flag(TIF_FSCHECK);

    /*
     * Enable/disable UAO so that copy_to_user() etc can access
     * kernel memory with the unprivileged instructions.
     */
    if (IS_ENABLED(CONFIG_ARM64_UAO) && fs == KERNEL_DS)
        asm(ALTERNATIVE("nop", SET_PSTATE_UAO(1), ARM64_HAS_UAO));
    else
        asm(ALTERNATIVE("nop", SET_PSTATE_UAO(0), ARM64_HAS_UAO,
                CONFIG_ARM64_UAO));
}


/*
 * Generate the assembly for UAO alternatives with exception table entries.
 * This is complicated as there is no post-increment or pair versions of the
 * unprivileged instructions, and USER() only works for single instructions.
 */
#ifdef CONFIG_ARM64_UAO
    .macro uao_ldp l, reg1, reg2, addr, post_inc
        alternative_if_not ARM64_HAS_UAO
8888:            ldp    \reg1, \reg2, [\addr], \post_inc;
8889:            nop;
            nop;
        alternative_else
            ldtr    \reg1, [\addr];
            ldtr    \reg2, [\addr, #8];
            add    \addr, \addr, \post_inc;
        alternative_endif

        _asm_extable    8888b,\l;
        _asm_extable    8889b,\l;
    .endm

Linux 4.7开始引入的CONFIG_ARM64_UAO kernel选项,可以使能UAO的支持。它和PAN以前使用。
copy_user_access-Page-6.jpg

当UAO,PAN和STTR/LDTR 一起配合起来使用时,PAN=1 + UAO=1提供了全局的阻止kernel访问user space内存的能力,copy_from/to_user或get/put_user这些预知地方需要访问user space内存时,只需要通过切换UAO的值就可以通过STTR/LDTR访问。
copy_user_access-Page-7.jpg

Linux 5.11开始去掉了CONFIG_ARM64_UAO选项。因为从这个版本开始arm64 Linux kernel的uaccess不再考虑设置通过set_fs改变addr_limit(kernel代码调用系统服务直接使用底层实现,而不再需要经过copy_from/to_user或get/put_user)。因此不再需要动态设置UAO,UAO在head.S中无条件设置为0. 相关讨论见以下patch:

https://patchwork.kernel.org/project/linux-arm-kernel/patch/20200925160722.27155-12-mark.rutland@arm.com/#23647737

https://lore.kernel.org/linux-mm/20210726141141.2839385-10-arnd@kernel.org/

因而最新的Arm64 Linux kernel在启动时设置PAN为1就可以了。

总结

本文讨论了arm构架上加固user space和kernel space边界的技术。以便读者了解这些技术后,可以知道如何合理配置其相应平台的kernel选项,加强Linux的安全。对于比较新的Linux和arm64 CPU,只需要CONFIG_ARM64_PAN=y就可以得到安全的加固。

推荐阅读
关注数
8629
内容数
51
目录
极术微信服务号
关注极术微信号
实时接收点赞提醒和评论通知
安谋科技学堂公众号
关注安谋科技学堂
实时获取安谋科技及 Arm 教学资源
安谋科技招聘公众号
关注安谋科技招聘
实时获取安谋科技中国职位信息