Linux arm64 set_memory_ro/rw函数

一、函数简介

1.1 简介

// linux-5.4.18/arch/arm64/mm/pageattr.c

int set_memory_ro(unsigned long addr, int numpages)
{
	return change_memory_common(addr, numpages,
					__pgprot(PTE_RDONLY),
					__pgprot(PTE_WRITE));
}

int set_memory_rw(unsigned long addr, int numpages)
{
	return change_memory_common(addr, numpages,
					__pgprot(PTE_WRITE),
					__pgprot(PTE_RDONLY));
}

arm64架构下 set_memory_ro 和 set_memory_rw 用来更改内核内存区域的可读可写属性,只用于更改由 vmalloc 和 vmap分配的内存区间。 两者都是调用 change_memory_common 函数。

1.2 change_memory_common

static int change_memory_common(unsigned long addr, int numpages,
				pgprot_t set_mask, pgprot_t clear_mask)
{
	unsigned long start = addr;
	//计算要更改内存地址的大小
	unsigned long size = PAGE_SIZE*numpages;
	unsigned long end = start + size;
	struct vm_struct *area;
	int i;

	//检查addr是否未对齐到页面边界。如果未对齐,则将start地址调整为前一个页面边界,并相应地更新end
	if (!PAGE_ALIGNED(addr)) {
		start &= PAGE_MASK;
		end = start + size;
		WARN_ON_ONCE(1);
	}

	/*
	 * Kernel VA mappings are always live, and splitting live section
	 * mappings into page mappings may cause TLB conflicts. This means
	 * we have to ensure that changing the permission bits of the range
	 * we are operating on does not result in such splitting.
	 *
	 * Let's restrict ourselves to mappings created by vmalloc (or vmap).
	 * Those are guaranteed to consist entirely of page mappings, and
	 * splitting is never needed.
	 *
	 * So check whether the [addr, addr + size) interval is entirely
	 * covered by precisely one VM area that has the VM_ALLOC flag set.
	 */
	//来查找覆盖指定地址范围的虚拟内存区域area,area由vmalloc or vmap 分配
	area = find_vm_area((void *)addr);
	//如果找不到这样的区域,或者范围的结束地址超过了区域的边界,或者该区域没有设置VM_ALLOC标志,函数将返回错误代码-EINVAL
	if (!area ||
	    end > (unsigned long)area->addr + area->size ||
	    !(area->flags & VM_ALLOC))
		return -EINVAL;

	if (!numpages)
		return 0;

	/*
	 * If we are manipulating read-only permissions, apply the same
	 * change to the linear mapping of the pages that back this VM area.
	 */
	//我手中机器配置没有rodata_full标志
	if (rodata_full && (pgprot_val(set_mask) == PTE_RDONLY ||
			    pgprot_val(clear_mask) == PTE_RDONLY)) {
		for (i = 0; i < area->nr_pages; i++) {
			__change_memory_common((u64)page_address(area->pages[i]),
					       PAGE_SIZE, set_mask, clear_mask);
		}
	}

	/*
	 * Get rid of potentially aliasing lazily unmapped vm areas that may
	 * have permissions set that deviate from the ones we are setting here.
	 */
	//以删除任何可能存在的与当前函数中设置的权限不同的潜在别名延迟取消映射的VM区域
	vm_unmap_aliases();

	//调用__change_memory_common,传入调整后的start地址、大小和set_mask、clear_mask,以对指定的内存范围应用权限更改
	return __change_memory_common(start, size, set_mask, clear_mask);
}

change_memory_common函数是一个用于更改指定内存页面范围权限。

函数change_memory_common接受四个参数:

addr:内存范围的起始地址。
numpages:内存范围中的页面数量。
set_mask:要设置的保护掩码,表示要设置的权限位。
clear_mask:要清除的保护掩码,表示要清除的权限位。

函数首先根据地址addr计算起始地址start和结束地址end,并计算需要更改权限的内存大小size。如果起始地址addr不是页对齐的,会将start调整为页对齐,并重新计算end。同时,会发出一个警告(WARN_ON_ONCE(1))。

接下来,函数检查要操作的内存范围是否完全位于一个由vmalloc(或vmap)创建的虚拟内存区域中。函数调用find_vm_area来查找覆盖指定地址范围的虚拟内存区域。如果找不到这样的区域,或者范围的结束地址超过了区域的边界,或者该区域没有设置VM_ALLOC标志,函数将返回错误代码-EINVAL。这个检查确保指定范围完全被一个具有VM_ALLOC标志的VM区域覆盖,该标志表示该区域完全由页面映射组成,不需要进行分割。

如果numpages为0,表示没有需要更改权限的页,直接返回0。

如果设置了rodata_full标志,并且set_mask或clear_mask指示了只读权限(PTE_RDONLY),函数将遍历VM区域的页面,并调用__change_memory_common,将相同的权限更改应用于页面的线性映射。这一步确保相应的线性映射权限与VM区域权限保持一致。

我手中机器配置没有rodata_full标志:

# CONFIG_RODATA_FULL_DEFAULT_ENABLED is not set
bool rodata_full __ro_after_init = IS_ENABLED(CONFIG_RODATA_FULL_DEFAULT_ENABLED);

在对指定的内存范围应用权限更改之前,函数调用vm_unmap_aliases,以删除任何可能存在的与当前函数中设置的权限不同的潜在别名延迟取消映射的VM区域。

最后,函数调用__change_memory_common,传入调整后的start地址、大小和set_mask、clear_mask,以对指定的内存范围应用权限更改。

1.3 __change_memory_common

/*
 * This function assumes that the range is mapped with PAGE_SIZE pages.
 */
static int __change_memory_common(unsigned long start, unsigned long size,
				pgprot_t set_mask, pgprot_t clear_mask)
{
	//page_change_data结构体用于存储设置和清除的保护掩码
	struct page_change_data data;
	int ret;

	data.set_mask = set_mask;
	data.clear_mask = clear_mask;

	ret = apply_to_page_range(&init_mm, start, size, change_page_range,
					&data);

	flush_tlb_kernel_range(start, start + size);
	return ret;
}

__change_memory_common的函数,用于更改指定内存页面范围的内存权限。

函数__change_memory_common接受四个参数:

start:内存范围的起始地址。
size:内存范围的大小(以字节为单位)。
set_mask:要设置的保护掩码,表示要设置的权限位。
clear_mask:要清除的保护掩码,表示要清除的权限位。

函数调用apply_to_page_range,对init_mm(内核初始化的内存描述符)中指定的内存范围应用给定的函数change_page_range。change_page_range函数被用于处理页范围内的每个页面,并根据data中的保护掩码设置和清除页面的权限。apply_to_page_range函数返回一个整数值,表示操作的结果。

struct page_change_data {
	pgprot_t set_mask;
	pgprot_t clear_mask;
};

static int change_page_range(pte_t *ptep, unsigned long addr, void *data)
{
	struct page_change_data *cdata = data;
	pte_t pte = READ_ONCE(*ptep);

	pte = clear_pte_bit(pte, cdata->clear_mask);
	pte = set_pte_bit(pte, cdata->set_mask);

	set_pte(ptep, pte);
	return 0;
}

名为change_page_range的函数,作为apply_to_page_range函数的参数,函数使用clear_pte_bit宏和set_pte_bit宏分别对pte进行清除和设置操作,根据cdata中的保护掩码指定的权限位。这些宏用于修改页表项的权限位,以实现更改页面权限的目的。

函数调用flush_tlb_kernel_range来刷新内核页表中指定范围的TLB(转换查找缓冲器)。TLB是用于加速虚拟地址到物理地址转换的硬件缓存。刷新TLB确保最新的内存权限设置生效。

二、apply_to_page_range函数

2.1 apply_to_page_range

// linux-5.4.18/mm/memory.c

/*
 * Scan a region of virtual memory, filling in page tables as necessary
 * and calling a provided function on each leaf page table.
 */
int apply_to_page_range(struct mm_struct *mm, unsigned long addr,
			unsigned long size, pte_fn_t fn, void *data)
{
	pgd_t *pgd;
	unsigned long next;
	unsigned long end = addr + size;
	int err;

	if (WARN_ON(addr >= end))
		return -EINVAL;

	pgd = pgd_offset(mm, addr);
	do {
		next = pgd_addr_end(addr, end);
		err = apply_to_p4d_range(mm, pgd, addr, next, fn, data);
		if (err)
			break;
	} while (pgd++, addr = next, addr != end);

	return err;
}
EXPORT_SYMBOL_GPL(apply_to_page_range);

pply_to_page_range的函数,用于扫描虚拟内存的一个区域,并在需要时填充页表,同时在每个叶子页表上调用提供的函数。在这里我们提供的函数是change_page_range,修改page属性。

函数apply_to_page_range接受五个参数:

mm:指向mm_struct结构体的指针,表示进程的内存描述符。 -- 这里传递的是 init_mm
addr:内存范围的起始地址。
size:内存范围的大小(以字节为单位)。
fn:指向函数的指针,该函数将在每个叶子页表上调用。 -- 这里传递的是change_page_range
data:传递给fn函数的数据。

(1)通过调用pgd_offset函数,根据给定的进程内存描述符mm和起始地址addr获取页全局目录项(PGD)的指针,并将其存储在pgd中。

struct mm_struct init_mm = {
	.pgd		= swapper_pg_dir,
};

这里页全局目录项(PGD)的指针就是指内核页表页全局目录项swapper_pg_dir。

(2)使用一个循环来遍历地址范围内的每个页全局目录项。在每次迭代中,通过调用pgd_addr_end函数计算下一个要处理的地址next,根据当前的addr和end。

/*
 * When walking page tables, get the address of the next boundary,
 * or the end address of the range if that comes earlier.  Although no
 * vma end wraps to 0, rounded up __boundary may wrap to 0 throughout.
 */

#define pgd_addr_end(addr, end)						
({	unsigned long __boundary = ((addr) + PGDIR_SIZE) & PGDIR_MASK;	
	(__boundary - 1 < (end) - 1)? __boundary: (end);		
})

该宏用于计算页全局目录项(PGD)的下一个边界地址。它接受两个参数,addr表示当前地址,end表示范围的结束地址。宏的工作如下:

首先,它将当前地址addr加上页全局目录的大小(PGDIR_SIZE),然后与页全局目录的掩码(PGDIR_MASK)进行按位与操作,得到一个边界地址__boundary。

接下来,宏检查(__boundary - 1 < (end) - 1)是否为真。这里通过使用减一操作来避免了可能发生的边界溢出问题。

如果条件为真,则返回__boundary,否则返回end。

(4)然后,调用apply_to_p4d_range函数,传递进程内存描述符mm、当前的页全局目录项pgd、当前地址addr、下一个地址next、提供的函数fn和数据data。该函数的作用是在页全局目录项范围内递归地处理页中间目录项(P4D)。

2.2 apply_to_p4d_range

static int apply_to_p4d_range(struct mm_struct *mm, pgd_t *pgd,
				     unsigned long addr, unsigned long end,
				     pte_fn_t fn, void *data)
{
	p4d_t *p4d;
	unsigned long next;
	int err;

	p4d = p4d_alloc(mm, pgd, addr);
	if (!p4d)
		return -ENOMEM;
	do {
		next = p4d_addr_end(addr, end);
		err = apply_to_pud_range(mm, p4d, addr, next, fn, data);
		if (err)
			break;
	} while (p4d++, addr = next, addr != end);
	return err;
}

arm64最大为四级页表,p4d一般只有x86_64架构下才有,这里不予讨论。

2.3 apply_to_pud_range

static int apply_to_pud_range(struct mm_struct *mm, p4d_t *p4d,
				     unsigned long addr, unsigned long end,
				     pte_fn_t fn, void *data)
{
	pud_t *pud;
	unsigned long next;
	int err;

	pud = pud_alloc(mm, p4d, addr);
	if (!pud)
		return -ENOMEM;
	do {
		next = pud_addr_end(addr, end);
		err = apply_to_pmd_range(mm, pud, addr, next, fn, data);
		if (err)
			break;
	} while (pud++, addr = next, addr != end);
	return err;
}

函数的执行流程如下:
首先,通过调用pud_alloc函数为给定的P4D分配内存空间,并将返回的PUD指针赋值给pud变量。

进入一个循环,该循环将在PUD范围内进行迭代处理。

在循环的每一次迭代中,首先调用pud_addr_end宏来计算下一个边界地址next。这个宏将根据当前地址addr和范围结束地址end计算出下一个PMD(Page Middle Directory)的边界地址。

然后,调用apply_to_pmd_range函数来对PMD范围内的页面应用函数fn。该函数将处理mm、pud、addr和next之间的页面,并使用fn函数进行处理。

如果没有出现错误,继续进行下一次迭代。在每次迭代中,通过递增pud指针、更新addr为next,并检查addr是否等于end来确定是否继续循环。

当addr等于end时,表示已经处理完整个范围。

这段代码的作用是在给定的PUD范围内对页面应用函数进行处理,通过逐个PMD范围进行迭代,并在每个PMD范围内调用给定的函数进行处理。

2.4 apply_to_pmd_range

static int apply_to_pmd_range(struct mm_struct *mm, pud_t *pud,
				     unsigned long addr, unsigned long end,
				     pte_fn_t fn, void *data)
{
	pmd_t *pmd;
	unsigned long next;
	int err;

	BUG_ON(pud_huge(*pud));

	pmd = pmd_alloc(mm, pud, addr);
	if (!pmd)
		return -ENOMEM;
	do {
		next = pmd_addr_end(addr, end);
		err = apply_to_pte_range(mm, pmd, addr, next, fn, data);
		if (err)
			break;
	} while (pmd++, addr = next, addr != end);
	return err;
}

函数的执行流程如下:
首先,使用BUG_ON宏检查给定的PUD是否是巨页(huge page)。如果是巨页,会引发一个bug检查(bug-on),表示代码中出现了不应该出现的情况。

调用pmd_alloc函数为给定的PUD分配一个PMD,并将返回的PMD指针赋值给pmd变量。如果内存分配失败,函数将返回错误码-ENOMEM。

进入一个循环,该循环将在PMD范围内进行迭代处理。

在循环的每一次迭代中,首先调用pmd_addr_end宏来计算下一个边界地址next。这个宏将根据当前地址addr和范围结束地址end计算出下一个PTE(Page Table Entry)的边界地址。

然后,调用apply_to_pte_range函数来对PTE范围内的页面应用函数fn。该函数将处理mm、pmd、addr和next之间的页面,并使用fn函数进行处理。如果在处理过程中出现错误,将返回一个非零的错误码err。

如果err不为零,表示在处理过程中出现了错误,函数将跳出循环。

如果没有出现错误,继续进行下一次迭代。在每次迭代中,通过递增pmd指针、更新addr为next,并检查addr是否等于end来确定是否继续循环。

当addr等于end时,表示已经处理完整个范围,函数将返回err。

这段代码的作用是在给定的PMD范围内对页面应用函数进行处理,通过逐个PTE范围进行迭代,并在每个PTE范围内调用给定的函数进行处理。

2.5 apply_to_pte_range

static int apply_to_pte_range(struct mm_struct *mm, pmd_t *pmd,
				     unsigned long addr, unsigned long end,
				     pte_fn_t fn, void *data)
{
	pte_t *pte;
	int err;
	spinlock_t *uninitialized_var(ptl);

	pte = (mm == &init_mm) ?
		pte_alloc_kernel(pmd, addr) :
		pte_alloc_map_lock(mm, pmd, addr, &ptl);
	if (!pte)
		return -ENOMEM;

	BUG_ON(pmd_huge(*pmd));

	arch_enter_lazy_mmu_mode();

	do {
		err = fn(pte++, addr, data);
		if (err)
			break;
	} while (addr += PAGE_SIZE, addr != end);

	arch_leave_lazy_mmu_mode();

	if (mm != &init_mm)
		pte_unmap_unlock(pte-1, ptl);
	return err;
}

函数的执行流程如下:

根据mm是否等于&init_mm,选择不同的方式分配PTE。如果mm等于&init_mm,表示当前是内核线程,使用pte_alloc_kernel函数为给定的PMD和地址分配一个PTE;否则,使用pte_alloc_map_lock函数为给定的进程mm、PMD和地址分配一个PTE,并将分配过程中获取的自旋锁地址保存在ptl中。如果无法分配PTE,则返回错误码-ENOMEM。

使用BUG_ON宏检查给定的PMD是否是巨页(huge page)。如果是巨页,会引发一个bug检查(bug-on),表示代码中出现了不应该出现的情况。

调用arch_enter_lazy_mmu_mode函数,进入延迟MMU模式。这个函数是架构相关的,用于在某些架构上进入延迟更新MMU页表的模式。

进入一个循环,该循环将在PTE范围内进行迭代处理。

在循环的每一次迭代中,首先调用给定的函数fn来处理当前的PTE,传递pte、addr和data作为参数。如果在处理过程中出现错误,将返回一个非零的错误码err。

如果err不为零,表示在处理过程中出现了错误,函数将跳出循环。

如果没有出现错误,继续进行下一次迭代。在每次迭代中,通过递增pte指针、更新addr为addr + PAGE_SIZE,并检查addr是否等于end来确定是否继续循环。

完成PTE范围的处理后,调用arch_leave_lazy_mmu_mode函数,离开延迟MMU模式。

如果mm不等于&init_mm,表示当前是用户进程,调用pte_unmap_unlock函数来解除之前映射的PTE页框,并释放自旋锁。这个函数用于解除映射并解锁页表。

三、hook系统调用

set_memory_ro/rw函数只用于更改由 vmalloc 和 vmap分配的内存区间,而系统调用表不是由vmalloc 或者vmap分配的,系统调用表位于内核的只读数据区,在arm64架构中,在Linux 4.6 中 将内核镜像移到vmalloc的区域了,虽然不是由vmalloc 或者vmap分配的,但是是在vmalloc区间,而set_memory_ro/rw函数在调用change_memory_common函数时,只是判断内核地址是否在 vmap_area – kernel virtual area 区间,然后检查是否有VM_ALLOC标志,因此我们还是可以通过set_memory_ro/rw函数来更改系统调用表的页表属性。

static int change_memory_common(unsigned long addr, int numpages,
				pgprot_t set_mask, pgprot_t clear_mask)
{
	struct vm_struct *area;
	
	.....
	 /*
	 * Let's restrict ourselves to mappings created by vmalloc (or vmap).
	 * Those are guaranteed to consist entirely of page mappings, and
	 * splitting is never needed.
	 *
	 * So check whether the [addr, addr + size) interval is entirely
	 * covered by precisely one VM area that has the VM_ALLOC flag set.
	 */
	area = find_vm_area((void *)addr);
	if (!area ||
	    end > (unsigned long)area->addr + area->size ||
	    !(area->flags & VM_ALLOC))
		return -EINVAL;
	
	......
	/*
	 * Get rid of potentially aliasing lazily unmapped vm areas that may
	 * have permissions set that deviate from the ones we are setting here.
	 */
	vm_unmap_aliases();

	return __change_memory_common(start, size, set_mask, clear_mask);
}

这样我们可以自己给该地址的vmap_area区间加上VM_ALLOC标志即可,内核镜像是在kernel virtual area 区间的,如下所示:

    area = my_find_vm_area((void *)addr);
    if(!area){
        printk("no find vm arean");
        return -1;
    }

    area->flags |= VM_ALLOC;

完整的hook代码如下:

#include <linux/init.h>
#include <linux/kernel.h>
#include <linux/module.h>
#include <linux/kallsyms.h> 
#include <linux/syscalls.h>
#include <linux/vmalloc.h>
#include <asm/unistd.h>
#include <asm/ptrace.h> 

int (*my_set_memory_ro)(unsigned long addr, int numpages);
int (*my_set_memory_rw)(unsigned long addr, int numpages);

struct vm_struct *(*my_find_vm_area)(const void *addr);

static unsigned long *__sys_call_table;

typedef long (*syscall_fn_t)(const struct pt_regs *regs);

#ifndef __NR_mkdirat
#define __NR_mkdirat 34
#endif

//用于保存原始的 mkdir 系统调用
static syscall_fn_t orig_mkdir;

asmlinkage long mkdir_hook(const struct pt_regs *regs)
{
    printk("hook mkdir sys_calln");

    // return orig_mkdir(regs);

    return 0;
}

static unsigned long addr;

static int __init lkm_init(void)
{
    struct vm_struct *area;

    my_set_memory_ro = (void *)kallsyms_lookup_name("set_memory_ro");
    my_set_memory_rw = (void *)kallsyms_lookup_name("set_memory_rw");

    my_find_vm_area = (void *)kallsyms_lookup_name("find_vm_area");

    __sys_call_table = (unsigned long *)kallsyms_lookup_name("sys_call_table");

    printk("__sys_call_table = %lxn", __sys_call_table);
    
    //保存原始的系统调用:mkdir
	orig_mkdir = (syscall_fn_t)__sys_call_table[__NR_mkdirat];

    addr = (unsigned long)(__sys_call_table + __NR_mkdirat);

    addr &= PAGE_MASK;

    area = my_find_vm_area((void *)addr);
    if(!area){
        printk("no find vm arean");
        return -1;
    }

    area->flags |= VM_ALLOC;
    printk("area->addr = %p, area->size = %lxn", area->addr, area->size);    

	//hook 系统调用表表项:sys_call_table[__NR_mkdirat]
    my_set_memory_rw(addr, 1);
    __sys_call_table[__NR_mkdirat] = (unsigned long)mkdir_hook;
    my_set_memory_ro(addr, 1);

    printk("lkm_initn");

	return 0;
}

static void __exit lkm_exit(void)
{
	//模块卸载时恢复原来的mkdir系统调用
	my_set_memory_rw(addr, 1);
    __sys_call_table[__NR_mkdirat] = (unsigned long)orig_mkdir;
    my_set_memory_ro(addr, 1);

    printk("lkm_exitn");
}

module_init(lkm_init);
module_exit(lkm_exit);

MODULE_LICENSE("GPL");

参考资料

Linux 5.4.18