深入解析 Linux 新系统调用 mseal:内存密封技术详解

本文详细介绍了 Linux 6.10 内核引入的 mseal 系统调用,探讨其内存密封机制如何防止恶意权限篡改和内存取消映射攻击,分析内核实现细节及实际应用场景。

深入解析 Linux 的新 mseal 系统调用

如果您热衷于漏洞缓解措施,可能已经听说名为 mseal 的新系统调用已进入 Linux 内核的 6.10 版本,提供了一种称为“内存密封”的保护机制。除了作者的说明外,关于此缓解措施的信息很少。在本博客文章中,我们将解释这个系统调用的含义,包括它与先前内存保护方案的不同之处,以及它如何在内核中保护虚拟内存。我们还将描述 mseal 在 Linux 用户空间中有助于阻止的特定漏洞利用场景,例如阻止恶意权限篡改和防止内存取消映射攻击。

mseal 是什么(以及不是什么)

内存密封允许开发者在程序运行时使内存区域免受非法修改。当虚拟内存地址(VMA)范围被密封时,具有代码执行原语的攻击者无法执行后续虚拟内存操作来更改 VMA 的权限或修改其布局以谋取利益。

如果您像我一样关注了内核邮件列表中围绕此系统调用的激烈讨论,可能已经注意到 Chrome 的安全团队引入了它来支持其 V8 CFI 策略,最初用于基于 Linux 的 ChromeOS。经过一些漫长的审议和多次重写,它最终进入了内核,并计划通过集成到 glibc(可能在版本 2.41 中)将其用例扩展到浏览器之外。

mseal 的安全保证与 Linux 的 memfd_create 及其 memfd_secret 变体不同,后者提供文件密封。memfd_creatememfd_secret 允许创建 RAM 支持的匿名文件作为将内容存储到 tmpfs 的替代方案,memfd_secret 更进一步,确保内存区域仅对持有文件描述符的进程可访问。这使开发者能够创建“安全飞地”式的用户空间映射,可以保护敏感的内存中数据。

mseal 与 Linux 上先前的内存保护方案不同,因为它是一个专门为漏洞缓解而设计的系统调用,针对寻求代码执行的远程攻击者,而不是可能试图窃取内存中敏感秘密的本地攻击者。

要理解 mseal 的安全缓解措施,我们必须首先研究其实现以了解其操作方式。幸运的是,mseal 易于理解,因此让我们看看它如何在内核中工作!

深入内部机制

mseal 具有简单的函数签名:

1
int mseal(unsigned long start, size_t len, unsigned long flags)

startlen 表示我们要密封的有效 VMA 的起始/结束范围,len 必须正确地对齐页面。flags 在撰写本文时未使用,必须设置为 0。

在 6.12 内核中,其系统调用定义调用 do_mseal

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
static int do_mseal(unsigned long start, size_t len_in, unsigned long flags)
{
    size_t len;
    int ret = 0;
    unsigned long end;
    struct mm_struct *mm = current->mm;     // [1]

    // ... 检查 flags == 0,检查页面对齐,并计算 `end`

    if (mmap_write_lock_killable(mm))          // [2]
        return -EINTR;

    /*
     * 第一遍,这有助于避免
     * 在输入地址范围错误的情况下部分密封,
     * 例如 ENOMEM 错误。
     */
    ret = check_mm_seal(start, end);            // [3]
    if (ret)
        goto out;

    /*
     * 第二遍,这应该成功,除非有错误
     * 来自 vma_modify_flags,例如合并/拆分错误,或进程
     * 达到最大支持的 VMA,但这些情况应该
     * 很少见。
     */
    ret = apply_mm_seal(start, end);            // [4] 

out:
    mmap_write_unlock(current->mm);
    return ret;
}

do_mseal 将首先从提供的长度计算结束偏移量,并锁定内存区域 [2] 以防止并发访问页面。[1] 处的全局 current 表示当前执行的 task_struct(即调用 mseal 的进程)。引用的字段是表示任务整个虚拟内存地址空间的 mm_struct。此系统调用将操作的 mm_struct 中的关键字段是 mmap,一个 vm_area_struct 值的列表。这表示由 mmap 创建的单个连续内存区域,例如堆栈或 VDSO。

[3] 处的 check_mm_seal 调用通过迭代从 current->mm 开始的每个 VMA 来测试边界正确性,确保目标内存映射用于密封是有效范围:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
static int check_mm_seal(unsigned long start, unsigned long end)
{
    struct vm_area_struct *vma;
    unsigned long nstart = start;

    VMA_ITERATOR(vmi, current->mm, start);

    /* 遍历每个 vma 进行检查。 */
    for_each_vma_range(vmi, vma, end) {
        if (vma->vm_start > nstart)
            /* 找到未分配的内存。 */
            return -ENOMEM;
        if (vma->vm_end >= end)
            return 0;

        nstart = vma->vm_end;
    }
    return -ENOMEM;
}

魔法发生在 apply_mm_seal 调用 [4] 中,它再次遍历每个 VMA,并通过 mseal_fixup 调用安排目标区域具有额外的 VM_SEALED 标志:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
static int apply_mm_seal(unsigned long start, unsigned long end)
{
    // ...
    nstart = start;
    for_each_vma_range(vmi, vma, end) {
        int error;
        unsigned long tmp;
        vm_flags_t newflags;

        newflags = vma->vm_flags | VM_SEALED;
        tmp = vma->vm_end;
        if (tmp > end)
            tmp = end;
        error = mseal_fixup(vmi, vma, &prev, nstart, tmp, newflags);
        if (error)
            return error;
        nstart = vma_iter_end(&vmi);
    }
    return 0;
}

为确保不需要的内存操作尊重此新标志,mseal 补丁集将 VM_SEALED 检查添加到以下文件:

1
2
3
4
5
 mm/madvise.c                                |   12 +
 mm/mmap.c                                   |   31 +-
 mm/mprotect.c                               |   10 +
 mm/mremap.c                                 |   31 +
 mm/mseal.c                                  |  307 ++++

例如,mprotectpkey_mprotect 在最终调用 mprotect_fixup 时将强制执行此检查:

1
2
3
4
5
6
7
8
9
int
mprotect_fixup(..., struct vm_area_struct *vma, ...)
{
    // ...
    if (!can_modify_vma(vma))
        return -EPERM;
    }
    // ...
}

为了确定系统调用是否应继续,can_modify_vma(在 mm/vma.h 中定义)将测试指定的 vm_area_struct 中是否存在 VM_SEALED

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
static inline bool vma_is_sealed(struct vm_area_struct *vma)
{
    return (vma->vm_flags & VM_SEALED);
}

/*
 * 检查 vma 是否被密封以进行修改。
 * 如果允许修改,则返回 true。
 */
static inline bool can_modify_vma(struct vm_area_struct *vma)
{
    if (unlikely(vma_is_sealed(vma)))
        return false;

    return true;
}

从其他内存管理系统调用的更改中,我们可以确定在 VMA 被密封后不允许的操作:

  • 使用 mprotectpkey_mprotect 更改权限位
  • 使用 munmap 取消映射
  • 使用 mmap(MAP_FIXED) 将密封的映射替换为另一个可变/未密封的映射
  • 使用 mremap 扩展或缩小其大小。缩小到零可能会创建一个可重新填充的孔,用于没有密封的新映射,因为它会触发完全取消映射。
  • 使用 mremap(MREMAP_MAYMOVE | MREMAP_FIXED) 迁移到新目标。请注意,密封检查同时施加在源和目标 VMA 上。此外,如果未提供 MREMAP_DONTUNMAP,源 VMA 将被取消映射,但 munmap 密封检查仍将适用。
  • 使用以下破坏性标志调用 madvise

目前,可以通过直接系统调用调用在 6.10+ 内核上调用 mseal。这是一个基本的包装器实现,帮助您开始:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
#include <sys/syscall.h>
#include <unistd.h>

#define MSEAL_SYSCALL 462

long mseal(unsigned long start, size_t len)
{
    int page_size;
    uintptr_t page_aligned_start;

    /* 我们系统上页面应该有多大(默认:4096 字节) */
    page_size = getpagesize();

    /* 页面对齐我们要密封的 VMA 范围 */
    page_aligned_start = start & ~(page_size - 1);
    return syscall(MSEAL_SYSCALL, page_aligned_start, len, 0);
}

mseal 有助于缓解哪些漏洞利用技术?

从不允许的操作中,我们可以辨别出内存密封将防止的两种特定漏洞利用场景:

  • 篡改 VMA 的权限。值得注意的是,不允许设置可执行权限可以阻止基于 shellcode 的攻击的复活。
  • 通过任意取消映射/重新映射内存区域进行“打孔”,缓解利用重新填充内存区域与攻击者控制的数据的数据仅利用。

让我们更详细地检查这些场景,以及开发者可以在其软件实现中采用的深度防御策略。

强化 NX

尽管持续存在像 ROP 这样的代码重用技术,攻击者可能更喜欢在利用期间获得 shellcoding 能力;这可以提供稳定且“轻松的胜利”,特别是在对 gadget 链施加约束时。以下是实现此目标的潜在工作流程:

  1. 通过某些目标功能,将 shellcode 喷洒到不可执行的堆栈/堆区域。
  2. 利用目标的错误启动初始 ROP 链,调用 mprotectPROT_EXEC 以目标持有 shellcode 的区域并关闭 NX 位。
  3. 跳转到它以复活老式的 shellcoding!

针对 Mikrotik RouterOS 的 SMB 守护程序的 CVE-2018-7445 漏洞利用是一个显著的例子。基于套接字的 shellcode 被喷洒到不可执行的堆上,并且来自堆栈溢出的精心制作的 ROP 链在执行 shellcode 之前修改堆内存权限。

内存密封的最直接用例是禁止 VMA 权限修改;一旦发生这种情况,想要利用传统 shellcode 的漏洞利用将无法切换可执行位。

如前所述,mseal 将在 glibc 2.41+ 中引入,其中动态加载器将在预定的 VMA 集上应用密封。然而,在撰写本文时,这不会自动为堆栈或堆完成。

这是预期的,因为这些区域可以在运行时扩展。例如,想要回收空间的堆分配器将调用 brk 系统调用,这可能调用 arch_unmap 并最终调用 do_vmi_unmap 来执行收缩。当然,这在密封下是不允许的,因此会完全破坏应用程序的动态内存分配。

因此,目前,软件开发者负责保护这些区域,因为他们有上下文来确定何时何地应适当应用密封。

让我们使用 mseal 来增强堆栈的老式 NX(不可执行)保护。这是一个简单的示例,模拟上述场景:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
int main(void)
{
    /* 表示现在包含我们以某种方式喷洒的 /bin/sh shellcode 的堆栈 */
    unsigned char exec_shellcode[] =
"\xe1\x45\x8c\xd2\x21\xcd\xad\xf2\xe1\x65\xce\xf2\x01\x0d\xe0\xf2"
"\xe1\x8f\x1f\xf8\xe1\x03\x1f\xaa\xe2\x03\x1f\xaa\xe0\x63\x21\x8b"
"\xa8\x1b\x80\xd2\xe1\x66\x02\xd4";

    // 漏洞触发,劫持指令指针

    /* ======= 我们的 ROP 链将做什么:======= */

    /* 计算 shellcode 的页面起始 */
    void (*exec_ptr)() =  (void(*)())&exec_shellcode;
    void *exec_offset = (void *)((int64_t) exec_ptr & ~(getpagesize() - 1));

    mprotect(exec_offset, getpagesize(), PROT_READ|PROT_WRITE|PROT_EXEC);

    /* 现在工作了! */
    exec_ptr();
    return 0;
}

正如我们所期望的,在 VMA 上设置 PROT_EXEC 允许 exec_shellcode 再次变为可执行:

1
2
3
~ gcc stack_no_sealing.c -o stack_no_sealing
~ ./stack_no_sealing
$

让我们在基于堆栈的 exec_offset VMA 范围上引入内存密封:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
int main(void)
{
    /* 表示现在包含我们以某种方式喷洒的 /bin/sh shellcode 的堆栈 */
    unsigned char exec_shellcode[] =
"\xe1\x45\x8c\xd2\x21\xcd\xad\xf2\xe1\x65\xce\xf2\x01\x0d\xe0\xf2"
"\xe1\x8f\x1f\xf8\xe1\x03\x1f\xaa\xe2\x03\x1f\xaa\xe0\x63\x21\x8b"
"\xa8\x1b\x80\xd2\xe1\x66\x02\xd4";

    /* 计算 shellcode 的页面起始 */
    void (*exec_ptr)() =  (void(*)())&exec_shellcode;
    void *exec_offset = (void *)((int64_t) exec_ptr & ~(getpagesize() - 1));

    /* 密封包含 shellcode 的堆栈页面! */
    if (mseal(exec_offset, getpagesize()) < 0)
        handle_error("mseal");

    // 漏洞触发,劫持指令指针

    /* ======= 我们的 ROP 链将做什么:======= */

    mprotect(exec_offset, getpagesize(), PROT_READ|PROT_WRITE|PROT_EXEC);
    /* 现在段错误,因为没有发生权限更改 */
    exec_ptr();
    return 0;
}

当调用 mprotect 时,上述 can_modify_vma 检查启动,防止权限更改发生,并且尝试 shellcode 现在失败:

1
2
3
~ gcc stack_with_sealing.c -o stack_with_sealing
~ ./stack_with_sealing
[1]    48771 segmentation fault (core dumped)  ./stack_with_sealing

适应现实世界软件的简单策略可能涉及谨慎引入宏化版本的 mseal 代码片段,并在可能驻留不受信任数据以进行利用的选定堆栈帧中迭代密封页面:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
#define SIMPLE_HARDEN_NX_SINGLE_PAGE(frame) \
  do { \
    void *frame_offset = (void *)((int64_t) &frame & ~(getpagesize() - 1)); \
    if (mseal(frame_offset, getpagesize()) == -1) { \
      handle_error("mseal"); \
    } \
  } while(0)

int frame_2(void)
{
    int frame_start = 0;
    unsigned char another_untrusted_buffer[1024] = { 0 };
    SIMPLE_HARDEN_NX_SINGLE_PAGE(frame_start);
    return 0;
}

int frame_1(void)
{
    unsigned char untrusted_buffer[1024] = { 0 };
    SIMPLE_HARDEN_NX_SINGLE_PAGE(untrusted_buffer);
    return frame_2();
}

即使密封的 VMA 被重新用作具有密封逻辑的另一个函数的帧,再次调用 mseal 将被视为无操作,因此不会出现错误。当然,开发者应注意边缘情况,如来自激进使用的自动堆栈扩展或自定义功能如堆栈拆分。

希望随着 mseal 集成到 glibc 的继续,我们将看到不需要任何手动使用系统调用的堆栈可调参数出现。LWN 邮件列表中的评论者渴望一种可以为更简单的应用程序切换的自动密封。

尽管说了这么多,如果攻击者不想完全 ROP 并坚持带回 shellcode 怀旧,他们总是可以使用其初始代码重用技术来 mmap 一个新的可执行区域。然而,这相当费力,因为它现在涉及将漏洞利用负载从可读区域复制到此新映射。

缓解基于取消映射的数据仅利用

禁止 mprotect 还防止密封区域变为可写,如果存在数据变量在修改时可能增强漏洞利用原语,则这很有价值。然而,在 mseal 的初始阶段,Chrome 维护者合理化了一种更简单且更强大的技术,具有绕过 CFI(控制流完整性)的额外好处。他们确定,如果攻击者可以将损坏的指针传递给取消映射/重新映射系统调用,他们可以在内存中“打孔”,可以用攻击者控制的数据重新填充。这不会违反 CFI 保证,因为前向和后向边缘 CFI 仅覆盖被篡改的控制流转换(例如,堆栈返回地址和函数指针)。

这对于实现 JIT 编译器的浏览器来说非常诱人。V8 的 Turbofan 可以创建在 RW 和 RX 之间切换的区域,帮助重新填充过程和更改权限。因此,攻击者可以利用 JIT 编译过程,将可执行代码从热路径 JavaScript 发射到未映射区域以覆盖关键数据,然后利用修改来产生代码执行。

我们认为这是一种数据仅利用技术,因为它不涉及直接劫持控制流或需要泄漏指针,而是篡改内存中的特定数据,以

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计