一、前言
一年的考研复习终于过去,虽然没有按着自己想法走,但终是上了岸。因为有了时间,因此打算将以前一直想做的关于代码审计原理和实践总结给写出来,内容主要是通过分析Web漏洞的原理,结合CVE实例,来分析SQL漏洞、XSS漏洞、上传漏洞、执行漏洞等,由于篇幅较长,会分为一系列的文章。本系列文章仅作为自己学习笔记所用,有错误的地方欢迎共同讨论交流。
二、学习环境
PHP(主要为PHP,个别是Java)+ MySQL数据库 + macOS
三、SQL注入分类
SQL的种类很多,通过不同的标准来分类,有不同的注入类型。
如果按照注入点类型来分类,其中包括:数值型注入、字符型注入以及搜索型注入。
如果按照数据提交的方式来分类,其中包括:GET型注入、POST型注入、COOKIE注入、HTTP头部注入。
如果按照执行效果来分类,其中包括:联合注入、布尔型注入、时间型注入、报错型注入、宽字节注入、二次注入、实体注入等。
由于注入的种类繁多,原理大致类似,我也没有那么多时间精力,因此不会全部列举出这些分类的不同实例,仅挑选一些经典类型来学习。
四、联合注入
1、原理
联合注入主要是通过UNion联合查询来获取数据库的信息。在存在注入的页面中,PHP代码的主要功能是通过GET或POST获得到的参数拼接到SQL语句中,如果没有做任何的防护,就可以使用Union语句查询其他数据。下面是一个简单的包含联合注入漏洞的PHP代码:
<?php
include 'conn.php';
$id = $_GET['id'];
$sql = "select * from books where `id` = ".$id;
$re = mysqli_query($con,$sql);
$row = mysqli_fetch_arry($re);
echo $row['book_name'].":".$row['book_introduce'];
?>
正常请求参数
id = 1
此时的SQL语句为:
Select * from books where `id` = 1
数据库会正常返回id为1的数据库所有行列数据。
但若换成带有攻击性的参数:
id = 1 union select 1,username,3 from admin#
SQL语句也随之变成了:
Select * from books where `id` = 1 union select 1,username,3 from admin#
如我搭建的WordPress:
两个单独的SQL注入语句:
Sql_1 = select * from wp_users;
Sql_2 = select * from wp_terms;
回显如下:
如果使用联合查询:
Sql = select * from wp_terms where term_id = 1 union select 1,user_login,3,4 from wp_users;
回显如下:
这是MySQL数据库的基本知识,不在赘述。下面通过实例来分析。
2、实例(CVE-2019-9762)
该漏洞为PHPSHE的联合注入漏洞。漏洞文件为 include/plugin/payment/alipay/pay.php
主要内容如下:
<?php
……
$order_id = pe_dbhold($_g_id);
$order = $db->pe_select(order_table($order_id), array('order_id'=>$order_id));
……
?>
pe_dbhold函数:
//数据库安全
function pe_dbhold($str, $exc=array())
{
if (is_array($str)) {
foreach($str as $k => $v) {
$str[$k] = in_array($k, $exc) ? pe_dbhold($v, 'all') : pe_dbhold($v);
}
}
else {
//$str = $exc == 'all' ? mysql_real_escape_string($str) : mysql_real_escape_string(htmlspecialchars($str));
$str = $exc == 'all' ? addslashes($str) : addslashes(htmlspecialchars($str));
}
return $str;
}
该函数将传入的参数进行addslashes转义处理,但由于addslashes函数并不能完全防止SQL注入。
继续看order_table函数。
function order_table($id) {
if (stripos($id, '_') !== false) {
$id_arr = explode('_', $id);
return "order_{$id_arr[0]}";
}
else {
return "order";
}
}
将传入的id参数进行判断,如果参数中含有字符下划线(_)则从_开始分割传入的字符串为数组。然后通过pe_select函数执行SQL语句:
public function pe_select($table, $where = '', $field = '*')
{
//处理条件语句
$sqlwhere = $this->_dowhere($where);
return $this->sql_select("select {$field} from `".dbpre."{$table}` {$sqlwhere} limit 1");
}
执行时无任何过滤,导致了order_id成为可控参数,因而出现了SQL漏洞,且经过SQL拼接语句的判断,可以确定其为联合注入漏洞。
需要注意的是,该漏洞必须抓包才可以看到回显的数据,因为请求pay.php页面后,会自动跳转到阿里支付的页面。
POC:
/phpshe/include/plugin/payment/alipay/pay.php?id=pay`%20where%201=1%20union%20select%201,2,database(),user(),5,6,7,8,9,10,11,12%23_
五、布尔型注入
1、原理
Boolean注入主要是通过POST或GET传入的参数,拼接到SQL语句中查询,返回的界面只有两种情况,即TRUE和FALSE,这样说并不是很准确,因为SQL查询无非就这两种情况,应该说是布尔型注入的时候只能得到一个正常的页面或者是什么页面的不存在,甚至在查询表的记录过程也不会有显示。因此就可以通过简单的页面反馈来穷举猜测数据里面的相关信息。如下是一个简单的含有布尔型注入的代码:
<?php
include 'conn.php'
$id = $_GET['id'];
if(waf($id))
exit("ERROR ");
$sql = "select * from users where `id` = '".$id."'";
$result = mysqli_querry($sql);
$row = mysqli_fetch_array($result);
if(!$row)
exit("ERROR");
?>
代码的流程比较简单,首先通过GET方式获取id参数,然后通过waf函数判断传入字符的安全性,如果安全,则将其拼接到sql语句上。
此时如果传入的参数为 1,则SQL语句组合为:
Sql = select * from users where id = 1
若数据库中存在id为1的数据,则row为1,页面显示正常,如果不存在,那么row为0,返回错误页面。
但此时传入的参数为1’ or 1 = 1#,SQL语句就变成了:
Sql = select * from users where id = 1 or 1 = 1 #
这个SQL语句执行的结果与数据库中是否存在id为1的数据无关,因为任何执行语句和or 1 = 1结合,其结果永远为真。
与此类似,如果传入的参数为1’ and 1 = 2,那么其结果永远为假
了解这个我们就可以通过and的方式,来判断我们想获取的信息。
如:
Sql = select * from users where id = 1 and (length(database()))>10#
这样我们可以通过不同的语句、改变大于号后面的数值来判断数据库的长度、数据库名称、管理员账号和密码等敏感数据。
2、实例
由于此类型注入和 时间型注入比较类似,因此和时间型注入一起举例。
具体请见时间型注入中的实例。
六、时间型注入
1、原理
时间型注入和布尔型注入十分类似,时间型注入也是通过POST或GET传入的参数,拼接到SQL语句中查询,但与布尔型注入不同的是,布尔型注入会返回不同的结果——TRUE or FALSE,而时间型注入只会返回一种结果——TRUE,对于时间型注入来说,无论输入任何值,它的返回都会按正确的来处理,这也就导致了一种问题,我们无法通过页面的反馈来穷举猜测得到我们想要的数据。所以就引入了时间( sleep() )这个概念。只需要加入特定的时间函数,通过查看web页面返回的时间差来判断注入的语句是否正确。
时间型注入和布尔型注入的的简单代码类似,因此就不再重复赘述。
2、实例(CVE-2019-9053)
该漏洞为CMCMS的时间型注入漏洞,漏洞文件为 :/modules/News/action.default.php
主要内容如下:
<?php
……
$entryarray = array();
$query1 = "
SELECT SQL_CALC_FOUND_ROWS
mn.*,
mnc.news_category_name,
mnc.long_name,
u.username,
u.first_name,
u.last_name
FROM " .CMS_DB_PREFIX . "module_news mn
LEFT OUTER JOIN " . CMS_DB_PREFIX . "module_news_categories mnc
ON mnc.news_category_id = mn.news_category_id
LEFT OUTER JOIN " . CMS_DB_PREFIX . "users u
ON u.user_id = mn.author_id
WHERE
status = 'published'
AND
";
if( isset($params['idlist']) ) {
$idlist = $params['idlist'];
if( is_string($idlist) ) {
$tmp = explode(',',$idlist);
for( $i = 0; $i < count($tmp); $i++ ) {
$tmp[$i] = (int)$tmp[$i];
if( $tmp[$i] < 1 ) unset($tmp[$i]);
}
$idlist = array_unique($tmp);
$query1 .= ' (mn.news_id IN ('.implode(',',$idlist).')) AND ';
}
}
……
if( isset($params['showall']) ) {
// show everything irrespective of end date.
$query1 .= 'IF(start_time IS NULL,news_date <= NOW(),start_time <= NOW())';
}
else {
// we're concerned about start time, end time, and news_date
if( isset($params['showarchive']) ) {
// show only expired entries.
$query1 .= 'IF(end_time IS NULL,0,end_time < NOw())';
}
else {
$query1 .= 'IF(start_time IS NULL AND end_time IS NULL,news_date <= NOW(),NOw() BETWEEN start_time AND end_time)';
}
}
……
$dbresult = $db->SelectLimit( $query1,$pagelimit,$startelement );
……
?>
代码实例比较简单,params变量为重写的GET和POST请求,在这里idlist参数通过GET的方式获得传入的数值,经过字符判断,数组分割,再剔除无用数据,判断有无重复值,然后直接拼接到SQL语句query1中,最后在SelectLimit函数中执行。
SelectLimit函数如下:
public function &SelectLimit( $sql, $nrows = -1, $offset = -1, $inputarr = null )
{
$limit = null;
$nrows = (int) $nrows;
$offset = (int) $offset;
if( $nrows >= 0 || $offset >= 0 ) {
$offset = ($offset >= 0) ? $offset . "," : '';
$nrows = ($nrows >= 0) ? $nrows : '18446744073709551615';
$limit = ' LIMIT ' . $offset . ' ' . $nrows;
}
if ($inputarr && is_array($inputarr)) {
$sqlarr = explode('?',$sql);
if( !is_array(reset($inputarr)) ) $inputarr = array($inputarr);
foreach( $inputarr as $arr ) {
$sql = ''; $i = 0;
foreach( $arr as $v ) {
$sql .= $sqlarr[$i];
switch(gettype($v)){
case 'string':
$sql .= $this->qstr($v);
break;
case 'double':
$sql .= str_replace(',', '.', $v);
break;
case 'boolean':
$sql .= $v ? 1 : 0;
break;
default:
if ($v === null) $sql .= 'NULL';
else $sql .= $v;
}
$i += 1;
}
$sql .= $sqlarr[$i];
if ($i+1 != sizeof($sqlarr)) {
$false = null;
return $false;
}
}
}
$sql .= $limit;
$rs = $this->do_sql( $sql );
return $rs;
}
可以看到,该函数将传入进来的SQL语句,拆分并装入数组,根据数组内字符类型不同而进行不同的处理,最后再重新拼接起来,执行SQL语句。这样的处理就导致了普通的SQL注入无法进行,如上文中的联合注入(联合注入中的一些字符在进行分割时被丢弃了)和普通布尔型注入(这里的SQL语句仅仅是整个SQL语句中的片段,并不影响整个SQL语句执行结果的TRUE或者FALSE)。
如下图,为整个SQL查询的语句:
具体利用poc可见:
写这篇文章时,官网已经修复了漏洞,那么是怎么修复的呢?
可以看看新老文件的对比:
在新的文件中,最关键的一段代码就是
$val = (int)$tmp[$i];
将传入的值,强制int类型转换。
如以下代码:
$number = "hello!";
$number1 = "11hello";
$number2 = "23432";
$number3 = 33;
echo (int)$number;
echo (int)$number1;
echo (int)$number2;
echo (int)$number3;
输出的结果为:
0
11
23432
33
即将纯字符类型的数值转换成了0,将数字和字符结合的数值单留下了数字,而只是纯数字的数值不变。这也就导致了以前的注入语句只留下了数字,也就无法进行时间注入了。测试代码如下:
<?php
function olds( $idlist ){
if( is_string($idlist) ) {
$tmp = explode(',',$idlist);
for( $i = 0; $i < count($tmp); $i++ ) {
$tmp[$i] = (int)$tmp[$i];
if( $tmp[$i] < 1 ) unset($tmp[$i]);
}
$idlist = array_unique($tmp);
$query1 = ' (mn.news_id IN ('.implode(',',$idlist).')) AND ';
}
echo '最终语句:'.$query1;
}
function news ( $idlist ){
if( is_string($idlist) ) {
$tmp = explode(',',$idlist);
$idlist = [];
for( $i = 0; $i < count($tmp); $i++ ) {
echo "这是修复后待处理的字符".$i.":";
echo $tmp[$i];
echo "<br>";
$val = (int)$tmp[$i];
if( $val > 0 && !in_array($val,$idlist) ) {
$idlist[] = $val;
echo "<br>";
}
}
}
if( !empty($idlist) )
$query1 = ' (mn.news_id IN ('.implode(',',$idlist).')) AND ';
echo '最终语句:'.$query1;
}
$idlist = "News,m1_,default,0&m1_idlist=a,b,1,5))+and+(select+sleep(1)+from+cms_users+where+username+like+0x61646225+and+user_id+like+0x31)+--+";
news($idlist);
echo "<br>";
olds($idlist);
echo "<br>";
?>
效果如下图所示:
小结
审计路漫漫,吾等上下而求索