深入解析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_create和memfd_secret允许创建RAM支持的匿名文件作为将内容存储到tmpfs的替代方案,memfd_secret更进一步确保只有持有文件描述符的进程才能访问内存区域。这使开发者能够创建“安全飞地”式的用户空间映射,保护敏感的内存中数据。
mseal与Linux上先前的内存保护方案不同,因为它是一个专门为漏洞缓解而设计的系统调用,针对寻求代码执行的远程攻击者,而不是可能试图窃取内存中敏感秘密的本地攻击者。
要理解mseal的安全缓解措施,我们必须首先研究其实现以了解其操作方式。幸运的是,mseal易于理解,因此让我们看看它如何在内核中工作!
内部机制一探
mseal具有简单的函数签名:
1
|
int mseal(unsigned long start, size_t len, unsigned long flags)
|
start和len表示要密封的有效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
|
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。此系统调用将在其上操作的关键字段是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 ++++
|
例如,mprotect和pkey_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被密封后不允许的操作:
- 使用
mprotect和pkey_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的攻击的复活。
- 通过任意取消映射/重新映射内存区域进行“打孔”,缓解利用用攻击者控制的数据重新填充内存区域的数据only漏洞利用。
让我们更详细地检查这些场景,以及开发者可以在其软件实现中采用的深度防御策略。
强化NX
尽管像ROP这样的代码重用技术持续存在,攻击者可能更喜欢在漏洞利用期间获得shellcoding能力;这可以提供稳定且“轻松的胜利”,特别是在对gadget链施加约束的情况下。这是一个可能的工作流程来实现这一点:
- 通过某些目标功能,将shellcode喷洒到不可执行的堆栈/堆区域。
- 利用目标的错误启动初始ROP链,调用
mprotect与PROT_EXEC以目标持有shellcode的区域并关闭NX位。
- 跳转到它以复活老式的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一个新的可执行区域。然而,这相当费力,因为它现在涉及将漏洞利用负载从可读区域复制到此新映射。
缓解基于取消映射的数据only漏洞利用
禁止mprotect还防止密封区域变为可写,这在有数据变量在修改时可能增强漏洞利用原语时很有价值。然而,在mseal的初始阶段,Chrome维护者合理化了一种更简单、更强大的技术,具有绕过CFI(控制流完整性)的额外好处。他们确定,如果攻击者可以将损坏的指针传递给取消映射/重新映射系统调用,他们可以在内存中“打孔”,可以用攻击者控制的数据重新填充。这不会违反CFI保证,因为前向和后向边缘CFI仅覆盖被篡改的控制流转换(例如,堆栈返回地址和函数指针)。
这对于实现JIT编译器的浏览器来说非常诱人。V8的Turbofan可以创建在RW和RX之间切换的区域,帮助重新填充过程和更改权限。因此,攻击者可以利用JIT编译过程,从热路径JavaScript发出可执行代码到未映射区域以覆盖关键数据,然后利用修改来产生代码执行。
我们认为这是一种数据only漏洞利用技术,因为它不涉及直接劫持控制流或需要泄漏指针,而是篡改内存中的特定数据,以影响控制流到攻击者的喜好。在像CFI这样的缓解措施时代,这已成为漏洞利用期间相当强大的技术。因此,内存密封可以通过禁止打孔场景来防止这些特定的数据only技术。
这种特定的数据only技术不仅适用于具有JIT编译器的浏览器!类似的技术将是用户空间堆漏洞利用的House of Muney。正如Max Dulin在他的帖子中指出的,Qualys使用这种技术对Qmail中的一个古老错误执行了真实世界的漏洞利用。
这种技术依赖于这样一个事实:对于大分配块(大于M_MAP_THRESHOLD可调参数),malloc和free将分别直接调用mmap和munmap,没有中间空闲列表缓存任何释放的块(这大大简化