代码审计-BlueCMS
0x01 前言
写这篇文章之前,我还在纠结两个问题,是继续找网站做渗透积累经验和学习内网呢,还是开天辟地学习代码审计呢。后来想了想,内网对我来说一没有环境,二来风险有点大(自己菜)。所以之后会陆续对一些cms进行学习。
0x02 环境
lamp+BlueCMS v1.6 sp1
安装过程:将文件夹移动到www目录下,进入install目录进行安装,即可。
0x03 代码审计
虽然说是代码审计,但是对于这么庞大的一个工程,一句一句看的工作量肯定是很大的,这个也和习惯有关系,有的人喜欢阅读全文,有的人喜欢追踪数据流,还有的人从危险函数追踪回溯,还有的从功能点进行审计。
但是对于我这个审计新手来说,通篇阅读是很吃力的,所以我会从数据流起手。
网站结构概览
虽然说从数据流进行审计,但是该看的还是要看,比如网站结构,这里的文件名多数对应着功能和区块,所以先预览一下网站结构是必须的。
看完目录结构大概也知道它的功能区块了,比如admin肯定是后台,install肯定是网站安装模块。
那么从哪个文件开始看呢,我的习惯是从index.php开始看的。一边看代码,一边和网站联系起来。这样可以使代码具象化起来,以后做起来就会游刃有余。
审查元素配合代码审计
index.php
这里有一个很显眼的功能,那便是登陆,我之前说的审计数据流就是以这样的切入点开始的,由于from表单会将用户的输入传递到服务器的后台控制代码,所以我们可以直接审查元素找到控制输入的代码文件。
这里指向的是网站主目录下的user.php文件的 act=index_login 。所以我们直接看user.php文件。这里为什么没审计index.php的代码呢。因为大部分的index.php都是一些功能指向、介绍等等,我们只需找到数据流即可。
user.php
既然我们现在需要找的是user.php的act=index_login操作
所以直接在user.php文件中查找 act=index_login 即可
模块代码如下:
$user_name = !empty($_REQUEST['user_name']) ? trim($_REQUEST['user_name']) : '';
$pwd = !empty($_REQUEST['pwd']) ? trim($_REQUEST['pwd']) : '';
$remember = isset($_REQUEST['remember']) ? intval($_REQUEST['remember']) : 0;
if($user_name == ''){
showmsg('用户名不能为空');
}
if($pwd == ''){
showmsg('密码不能为空');
}
$row = $db->getone("SELECT COUNT(*) AS num FROM ".table('admin')." WHERE admin_name='$user_name'");
if($row['num'] == 1){
showmsg('系统用户组不能从前台登录');
}
$w = login($user_name, $pwd);
if(defined('UC_API') && @include_once(BLUE_ROOT.'uc_client/client.php')){
list($uid, $username, $password, $email) = uc_user_login($user_name, $pwd);
if($uid>0){
$password = md5($password);
if(!$w){
$db->query("INSERT INTO ".table('user')." (user_name, pwd, email, reg_time) VALUES ('$username', '$password', '$email', '$timestamp')");
$w = 1;
}
$ucsynlogin = uc_user_synlogin($uid);
}
elseif($uid === -1){
if($w == 1){
$user_info = $db->getone("SELECT email FROM ".table('user')." WHERE user_name='$user_name'");
$uid = uc_user_register($user_name, $pwd, $user_info['email']);
if($uid > 0) $ucsynlogin = uc_user_synlogin($uid);
}else $w = -1;
}
elseif($uid == -2){
showmsg('密码错误');
}
echo $ucsynlogin;
}
if($w == -1 || $w == 0){
showmsg('您输入的用户名和密码不正确');
}
elseif($w == 1){
update_user_info($user_name);
if($remember==1){
setcookie('BLUE[user_id]', $_SESSION['user_id'], time()+172800, $cookiepath, $cookiedomain);
setcookie('BLUE[user_name]', $user_name, time()+172800, $cookiepath, $cookiedomain);
setcookie('BLUE[user_pwd]', md5(md5($pwd).$_CFG['cookie_hash']), time()+172800, $cookiepath, $cookiedomain);
}
showmsg('欢迎您 '.$user_name.' 回来,现在将转到会员中心', 'user.php');
}
}
我们来看一下这个模块是怎么工作的。
首先是获取参数:username、pwd、remember并使用empty()函数检测是否为空,然后使用一个三目运算对获取的user_name、pwd变量使用trim()函数进行首尾去空操作。最后赋值。
关于变量remember,其值如果选取则为1,未选取则为NULL,使用intval()函数获取整数。
然后使用两个if判断用户名和密码是否为空。
这一句:
调用sql查询,查询admin表中admin_name="用户输入"的数据并返回到派生表num中,最后查询row数组是否为空,也就是nullor1,如果是1则代表用户输入的值在数据表admin中找到了,也就是说输入的用户是管理员,然后发出提示。
继续跟进,进入if语句
这里调用了UC_API,可以在网站主目录下找到,然后又包含了uc_client/client.php文件,并将两个结果进行与处理。
client.php中的部分函数被引用,
比如这个:接收$username, $password, $email参数,其他两个置为空。
返回值调用了call_user_func()函数,此函数判断用户提交信息是否正确,然后返回正确情况下的uid。
同样的,uc_user-login()方法也是client.php中定义的方法,也是返回uid。
下一个if,继续跟进
$password = md5($password);
if(!$w){
$db->query("INSERT INTO ".table('user')." (user_name, pwd, email, reg_time) VALUES ('$username', '$password', '$email', '$timestamp')");
$w = 1;
}
$ucsynlogin = uc_user_synlogin($uid);
}
$w是返回结果,它返回0、1或-1,1代表账号密码在数据库中可以查到。
然后elseif判断用户名是否存在于数据库中。不存在则将w置为-1
下一个elseif判断密码是否正确,不正确则置为-2.
下一个if,继续跟进,判断w是否为-1或0,如果是其中一个则代表用户名或者密码有一个是错误的
下一个elseif,如果w等于1,则设置cookie,并创建连接。跳转user页面。
但是一句一句分析是很繁琐的。
所以我个的审计思路,就是结合网页和参数。重点关心用户输入。
这种方法说的直白点,我就利用正则匹配,每个网页里面我都找到用户能控制的变量,一个一个的排查。优点就是不会像上面那种方法那样,漏掉一些页面。缺点就是,理清这个网站的结构有点麻烦。因为是追踪用户输入么,所以各种页面都要打开,很麻烦。
比如,在根目录下,我就看到了这些PHP文件,那么就挨个进去查有没有用户输入,没有就跳过。有就继续跟读。
ad_js.php-sql注入
我们第一个文件就是这个,因此审计代码:
define('IN_BLUE', true);
require_once dirname(__FILE__) . '/include/common.inc.php';
$ad_id = !empty($_GET['ad_id']) ? trim($_GET['ad_id']) : '';
if(empty($ad_id))
{
echo 'Error!';
exit();
}
$ad = $db->getone("SELECT * FROM ".table('ad')." WHERE ad_id =".$ad_id);
if($ad['time_set'] == 0)
{
$ad_content = $ad['content'];
}
else
{
if($ad['end_time'] < time())
{
$ad_content = $ad['exp_content'];
}
else
{
$ad_content = $ad['content'];
}
}
$ad_content = str_replace('"', '"',$ad_content);
$ad_content = str_replace("\r", "\\r",$ad_content);
$ad_content = str_replace("\n", "\\n",$ad_content);
echo "<!--\r\ndocument.write("".$ad_content."");\r\n-->\r\n";
?>
可以看到,这里的变量ad_id是用户可控的变量,且该变量未经过过滤便直接进行了sql查询,所以就构成了sql注入,
但是它查询完以后使用了正则判断可以显示什么
这种先斩后奏的行为很容易出问题。
我们跟进正则代码:
$ad_content = str_replace("\r", "\\r",$ad_content);
$ad_content = str_replace("\n", "\\n",$ad_content);
echo "<!--\r\ndocument.write("".$ad_content."");\r\n-->\r\n";
它将双引号和部分符号进行转义,过滤的是字符型的注入,但是之前的sql语句是有问题的,因为之前的sql语句是数字型的注入,都用不到双引号所以这些过滤也就相当于没有。
利用:
我们测试一下,当列数为7时,停止报错:
我们可以在源代码看到返回的信息
下面的注入就不讲了。
publish.php-任意文件删除
在这个文件中,可控参数有许多,所以我们一个一个来,这也是追踪数据流的一个缺点,毕竟代码功能越多参数就越多,这么多的参数带来的肯定是更大的工作量。
php中常见的参数类型有$_REQUEST、$_GET、$_POST、$_COOKIE。这几种
我们一个一个来。先来request
共五个结果,其实只有两个变量。
第一个变量act,是用来发布分类信息的,但是很奇怪的是这个功能无法正常使用。
所以我们先不看这个变量
还有一个变量便是id。它属于模块:$act == 'del_pic'
模块代码如下:
{
$id = $_REQUEST['id'];
$db->query("DELETE FROM ".table('post_pic').
" WHERE pic_path='$id'");
if(file_exists(BLUE_ROOT.$id))
{
@unlink(BLUE_ROOT.$id);
}
}
这里的id同样是用户可控的,而且对应操作为DELETE 操作。过滤是有的,在引用的文件:common.inc.php中已经对 $_REQUEST、$_GET、$_POST、$_COOKIE 进行了过滤。
但这个过滤仅仅是使用addslashes()函数,所以如果是数字型注入或者其他不需要单双引号空格的漏洞相当于无防护。且没有对$_SERVER过滤,由于没有对id参数进行限制后缀,所以可以删除任意文件。
比如文件robots.txt
利用:payload:http://192.168.150.131/bluecms/publish.php?act=del_pic&id=robots.txt
user.php-文件读取
这里在用户登陆成功时会将页面重定向,我们看一下showmsg()函数。
这里我想找函数 showmsg,但是由于嵌套引用文件,导致回溯起来特别麻烦,如果文件名命名方式比较好还能找到,比如common.fun.php这里就是common中定义的函数,这个函数也在此文件中。但是为了更高效的查找,我使用了seay源码审计的查找模块。看,多快。
showmsg()函数
{
global $smarty;
$smarty->caching = false;
$smarty->assign("msg",$msg);
$smarty->assign("gourl",$gourl);
$smarty->display("showmsg.htm");
if($is_write)
{
write_log($msg, $_SESSION['admin_name']);
}
exit();
}
此函数接收三个参数,其中第二个变量$gourl是由用户输入的。也就是from的值,函数前前后后调用了许多其他对象的方法,我追踪了一会儿搞得头都大了,所以我暂时不这么深入,我现在只需要知道,smarty对象的display方法可以跳转其他链接。
且user.php中的from变量是可控的。
这就导致我们可以随意更改from的值来控制输出,任意读取文件,这个漏洞应该属于url跳转,但是由于限制了url的前缀,所以并不能跳转到其他的域名,只能读取本地文件,且showmsg()函数有session机制,导致无法越权读取admin目录下的文件。总的来说就是很鸡肋...
利用:
由于user.php将from变量执行了base64decode操作,所以需要输入base64encode才能被正确解释。
这里我修改了代码让其显示showmsg()函数接收的第二个参数,也就是from变量。
可见from变量确实被我们控制,且成功跳转
user.php-反射型XSS
漏洞参数依然是变量from
我们查看$act=reg操作,也就是注册模块的代码
{
if (!empty($_SESSION['user_id']) && $_SESSION['user_id'] != 1)
{
showmsg('您已经登录,请先退出登录再注册!');
}
if (!isset($_SESSION['last_reg']))
{
$_SESSION['last_reg'] = 0;
}
elseif ($timestamp - $_SESSION['last_reg'] < 30)
{
showmsg('为防止恶意注册,请于30秒后再来注册!');
}
template_assign(array('current_act', 'from'), array('注册新用户', $from));
$smarty->display('reg.htm');
}
可见,当注册的时候,会将from的值渲染到浏览器,这就导致了xss漏洞。我们之前说过,common.inc.php会对部分超全局变量进行addslashes过滤,也就是转义单双引号斜杠和空格,但是在XSS中,转移这些字符是没用的。
依然是smarty对象的display方法
利用:
user.php-存储型xss
$user_id = intval($_SESSION['user_id']);
if(empty($user_id)){
return false;
}
$birthday = trim($_POST['birthday']);
$sex = intval($_POST['sex']);
$email = !empty($_POST['email']) ? trim($_POST['email']) : '';
$msn = !empty($_POST['msn']) ? trim($_POST['msn']) : '';
$qq = !empty($_POST['qq']) ? trim($_POST['qq']) : '';
$mobile_phone = !empty($_POST['mobile_phone']) ? trim($_POST['mobile_phone']) : '';
$office_phone = !empty($_POST['office_phone']) ? trim($_POST['office_phone']) : '';
$home_phone = !empty($_POST['home_phone']) ? trim($_POST['home_phone']) : '';
$address = !empty($_POST['address']) ? htmlspecialchars($_POST['address']) : '';
if (!empty($_POST['face_pic1'])){
if (strpos($_POST['face_pic1'], 'http://') != false && strpos($_POST['face_pic1'], 'https://') != false){
showmsg('只支持本站相对路径地址');
}
else{
$face_pic = trim($_POST['face_pic1']);
}
}else{
if(file_exists(BLUE_ROOT.$_POST['face_pic3'])){
@unlink(BLUE_ROOT.$_POST['face_pic3']);
}
}
if(isset($_FILES['face_pic2']['error']) && $_FILES['face_pic2']['error'] == 0){
$face_pic = $image->img_upload($_FILES['face_pic2'],'face_pic');
}
$face_pic = empty($face_pic) ? '' : $face_pic;
$sql = "UPDATE ".table('user')." SET birthday = '$birthday', sex = '$sex', face_pic = '$face_pic', email = '$email', msn = '$msn', qq = '$qq'," .
" mobile_phone = '$mobile_phone', office_phone = '$office_phone', home_phone = '$home_phone', address='$address' WHERE user_id = ".intval($_SESSION['user_id']);
$db->query($sql);
showmsg('更新个人资料成功', 'user.php');
}
其中,
这一堆参数都是未经过XSS过滤就放到数据库的,所以直接产生了存储型XSS
我们随便拿一个参数举例:face_pic
我们先看一下未插入XSS的数据表
在sex文本框插入XSS:
插入后的数据表
点击个人资料触发XSS
由于sex和birthday是 tinyint 和 data类型的数据,所以无法存储字符符号,也就没办法插入xss。
这个CMS应该还有个ip伪造导致注入的漏洞,这里暂时不写。
0x04 漏洞修复
SQL注入:
代码虽然有对参数做 addslashes 处理,但是由于注入点是数字型的,所以相当于没用,那怎么修复呢,也很简单,强制把数字型变为字符型即可。也就是使用单引号包裹参数。
这是原来的语句
我们把它稍微改一下
再试一下有没有sql注入
成功防御!
任意文件删除/读取:
增加对参数的过滤,就代码的功能来看,删除文件的功能是不应该存在的,所以可以增加对参数的后缀名过滤。或者直接对.进行转义。
原语句:
修改后:
试一下:
漏洞已修复,但是这只是一种思路,因为linux文件并不一定要后缀名,所以最好的解决方法其实是将变量id强制转化为数字。
XSS漏洞:
同样还是过滤,只需将传入的参数和输出的参数同时进行htmlspecialchars()函数处理即可。
0x05 结语
耜懔枨兮我峒耜懔馁篾这履耜懔羰履耜冁闶裾懒她豳甑闶鹧==