RWCTF2021 Mop 0day Writeup

在刚刚结束的RW上,今年又出现了一道2019年类似的master of php,刚开始我还不知道,在看完orange的议题之后,主持人讲解的时候我才知道今年又有一道mop,orange的议题也是带来的关于php的web和binary结合,似乎对web选手们越来越不友好了...

回到正题,2021的mop是一道0day,但是很多人应该拿非预期的spldoublelink秒了,我也不例外,今天不讲它,这里我只想分享一下我是如何利用作者预期的0day.

作者给出的0day还是比较有趣的,绕过了php里面type hint,从而导致的type confusion. 我们具体来看一下作者给的trigger poc

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php  
//author: https://github.com/wupco/
//realworldctf 2021 MoP https://realworldctf.com/challenge
class A{
function __construct(){
// str_ireplace could be replaced by other functions which have "reference args" such as similar_text etc.
$this->{'str_ireplace'} = "call_user_func_array";
}
}

$type_confusion ="AAAAAAAAA";

//arguments list
$args = array("a","b","a",$type_confusion);
array_walk(new A(), "call_user_func", $args);
print_r($type_confusion);
?>

php里面的type hint是个什么东西呢? php本身是个弱类型的脚本语言,但是它也提供typed机制,例如你在定义函数的时候,你如果在参数前面标识上它的类型,这个类型在这里就是一个type hint,如果你传递参数的时候不满足它的类型定义,会直接抛出异常。

但是有趣的是,如果你调用php函数的时候走的是zend_call_function,它也会检查type hint,但是不会抛异常,只会抛一个warning,并且在args no separation的情况下它不会对你传的参数做任何的其他的事,直接的给你传到目标函数上. 如果你的目标函数没有很好二次check参数类型的时候,就可能会发生一些意想不到的情况. 上面的str_ireplace就是一个很典型的例子,它的第4个参数需要的是一个引用类型,在non-debug模式下,它是不会二次检查这个参数类型的,从而导致了类型混淆.如果利用这个type hint的bypass加到fuzzer上,应该也会有一些不错的发现.

我们来看一下这个类型混淆,到底能给我们提供哪些帮助?

1
2
3
4
5
6
7
8
9
10
11
12
13
#define _ZEND_TRY_ASSIGN_LONG(zv, lval, is_ref) do { \
zval *_zv = zv; \
if (is_ref || UNEXPECTED(Z_ISREF_P(_zv))) { \
zend_reference *ref = Z_REF_P(_zv); \
if (UNEXPECTED(ZEND_REF_HAS_TYPE_SOURCES(ref))) { \
zend_try_assign_typed_ref_long(ref, lval); \
break; \
} \
_zv = &ref->val; \
} \
zval_ptr_dtor(_zv); \
ZVAL_LONG(_zv, lval); \
} while (0)

上面zv变量被误认为reference类型进行assign的过程,我们简单看一下reference类型的结构

1
2
3
4
5
struct _zend_reference {
zend_refcounted_h gc;
zval val;
zend_property_info_source_list sources;
};

assign的过程,分为两步:先把原变量的内容尝试释放掉,再把新的值赋给这个变量,你用这个逻辑去看上面的代码,会很快明白这里在干什么。值得注意的是如果reference里面的sources字段不为空的话,会进入一个漫长的判断过程,我们肯定不希望它进去的,因为我们要为此做很多额外的工作.

到了这一步,我的想法是要用这个assign的过程第一步造一个UAF出来,但是非常无奈的是这里似乎没有任何leak的方式,没有一个有效内存块的地址,我们拿什么释放?这里地方我想了一会儿,我想了一个比较有趣的方式,无法leak,我就给它造一个地址出来!

怎么造呢? php有自己独特的内存管理,但是这个内存管理安全性到不怎么样,是因为经常可以通过它实现任意写的效果. 在申请大内存的时候指超过2M的时候,会直接mmap一块内存,这就非常有用了,如果我能再去读一下/proc/self/maps,这块内存地址我们是可以轻易拿到的,然后我们尝试在上面造一个小内存块出来,把它利用上面的洞,再释放掉,丢到php的main heap bins storage上,这样UAF的效果就实现了。

具体怎么做呢? 首先申请一个比较大的内存块 , 然后从这个 里面再切一块大小为2M的chunk出来,因为heap bins storage操作对象是一个chunk,我们把这个fake chunk修剪修剪让它变成一个看起来不错的chunk,再在其上面划分bins,利用类型混淆释放掉再丢到main heap bins storage上. 常规的UAF就是拿到一个可控的closure,然后把其内部的函数handler修改成zif_system,所以我们释放一个closure大小的bin就是比较好的.

我们可以总结一下这个过程:

  1. 申请一个比较大的内存块 ,用maps拿到其地址.
  2. 上在划一个正常的2M的chunk出来,你需要修剪一下,让它成为一个合法的chunk,这里面包括一些比较重要的字段,比如page maps,main heap.
  3. 还是在 上剩下的内存上构造一个fake reference类型结构出来,让它能释放指定内存.
  4. 我们这里最开始用long类型来制造类型混淆,因为long可以直指我们的fake reference.
  5. 最后trigger UAF, alloc closure, motify closure.... 这都是常规思路.

原题只需要通过ini_alter绕一下open_basedir,是有机会读maps的,所以可以造一个有地址的fake chunk出来,还是比较有趣的. 详细的exploit见.

那么不读maps,应该如何leak呢?想一想这个问题...

旭哥儿,最近在筹备我们论坛新年一个小小的趣味ctf活动,让我出一个题,我也想出一个php上的UAF,它不同于功能性造成的UAF,它是php编译优化里面的一个小小东西,是我曾经写出来一个bug... 我觉得php8的进步在于他的optimizer,往这方面研究研究应该是非常有趣的. 不知道大家有没有兴趣....