关于SQL注入,你应该知道的那些事

pfmm 9年前

英文出处:Troy Hunt 译文来自:伯乐在线
戴上你的黑帽,现在我们来学习一些关于SQL注入真正有趣的东西。请记住,你们都好好地用这些将要看到的东西,好吗?

SQL注入攻击因如下几点而是一种特别有趣的冒险:
1.因为能自动规范输入的框架出现,写出易受攻击的代码变得越来越难——但我们仍然会写差劲的代码。
2.因为你使用了存储过程或者ORM框架,你不一定很清楚的是(虽然你意识到SQL注入可能穿透他们,对吗) 我们在这些保护措施之下编写的代码依然是易受攻击的。
3.通过精心设计的爬取web搜寻易受攻击站点的自动化工具使这类站点更易远程检测出来。而我们依旧在发布它们(译注:指站点)。
SQL注入攻击因一个非常恰当的原因而被保留在OWASP(Open Web Application Security Project  开放Web应用安全项目) 的十大隐患列表中第一位——它特别常见,非常容易利用,而且影响十分剧烈。一个很微小的注入风险经常就能使整个系统中的所有数据都被泄漏——而我将要展示给你如何运用大量不同的技术自己来这样做。

我几年前写《 the OWASP Top 10 for .NET developers》时展示过如何防范SQL注入攻击,所以我不会专注在这些,这都是漏洞利用。受够了那些无聊的防御工具,让我们来攻击别的东西。

如果我们能攻破查询内容,你们的数据就都是我们的了

让我们对让SQL注入攻击成为可能的原因做一个快速概括。简而言之,这就是输入查询并解密数据。让我把所说的可视化给你:比如说你有一个包含有类似于“id=1”之类的字符串参数的URL,容纳后那个参数通过如下方式构造了一个SQL查询。


关于SQL注入,你应该知道的那些事

这整个URL可能和这个东西看起来很像:

关于SQL注入,你应该知道的那些事

这是挺基础的东西,而当你能掌控链接中的信息并改变传递给查询的值时会变得有趣。好了,把1变成2会给你另一个你期待的东西,但是如果你这样做呢?
http://widgetshop.com/widget/?id=1 or 1=1
那可能在数据库服务器中存留成这样的:
SELECT * FROM Widget WHERE ID = 1 OR 1=1

这告诉我们的是数据没有被净化——在上例中ID应该只是一个整数但“1 OR 1=1”的值也被接受。更重要的是,因为数据只是简单地被添加到查询中,它能够改变语句的功能。这个查询将能够选择所有的记录而不是单个记录,因为”1=1″语句是恒成立的。

或者,我们可以通过把“or 1=1”改成“and 1=2”来强制页面不返回任何记录,因为它一直都不成立所以没有结果返回。在这两个可选的方案中我们能方便地确定程序是否受注入攻击威胁。

这是SQL注入攻击的本质——通过不被信任的数据巧妙地操纵查询的执行——而在开发者做这样子事时发生。
query = "SELECT * FROM Widget WHERE ID = "+ Request.QueryString["ID"];  //Execute the query...  //执行查询...

当然他们做的是将不被信任的数据参数化,但本文中我不会过多叙述(如果想要了解防范措施,转回 part one of my OWASP series),而将更多谈论如何发动攻击。

好了,于是背景部分介绍了如何展示SQL注入风险存在,但你能拿它怎么办?让我们开始探寻一些普遍的注入模式。

抽丝剥茧:合并基于查询的注入

让我们举个例子,表示我们想要返回一堆记录的页面,在这里是一个有一堆带有“TypeId”1的小东西的URL。像这样:

http://widgetshop.com/Widgets/?TypeId=1

页面上的结果会像这样:

关于SQL注入,你应该知道的那些事

我们会期待这个查询进入到数据库时变成像这样的东西:
SELECT Name FROM Widget WHERE TypeId = 1

但是如果我们能应用我上述描绘的,也就是说我们可能能够给查询字符串中的数据添加SQL,我们可能会做出这样的东西:

http://widgetshop.com/Widgets/?TypeId=1 union all select name from sysobjects where xtype=’u’

然后它将产生一个如下的SQL查询:
SELECT Name FROM Widget WHERE TypeId = 1 union all select name from sysobjects where xtype='u'

现在记好了系统对象表列举数据库中所有对象,而在这个例子中我们用 xtype “u” 来筛选这个表,换言之,用户表。

当一个注入风险存在的时候将会有如下的输出:

关于SQL注入,你应该知道的那些事

这就是叫做合并基于查询的注入攻击,就像我们刚才简单地像原始结果添加一项,它直接到了HTML输出中——简单吧!既然我们已经知道有一个数据表叫“User”,我们可以做这样的事:

http://widgetshop.com/Widgets/?TypeId=1 union all select password from [user]

如果数据表中“user”不被中括号括起来,考虑到“user”这个词在数据库看来有其他含义,SQL服务器会变得不易控制。不管怎样,这是它返回的:

关于SQL注入,你应该知道的那些事

当然,UNION ALL语句只在第一个SELECT语句和第二个有相同的字段时起作用。这很容易被发现,你只需试试一些“union all select ‘a’”,如果它查询失败就试试“union all select ‘a’, ‘b’”之类的,以此类推。根本上你是在不断猜测列数直到你构造的查询发挥作用。

我们可以继续研究这个方面并揪出各种数据,但还是学习下一种攻击方式吧。有时一个基于合并查询的注入不会发挥作用,与输入格式、查询中添加的数据甚至结果如何显示都有关。为了绕开它我们需要变得更有创造性一些。

让程序自己泄密:基于错误信息的注入

http://widgetshop.com/widget/?id=1 or x=1

等一下,这不是一个合法的SQL语句,那个“x=1”不会被处理,至少在没有一个叫做x的列时不会被处理。那么它不会抛出一个异常吗?严格地说,事实上你将会看到像这样的异常:

关于SQL注入,你应该知道的那些事

这是一个ASP.NET的错误,而其他的框架也有类似的样式。但是重要的是这些错误信息暴露了内部的实现方式,换言之,这告诉我们数据库中没有叫做“x”的字段。为什么这很重要?从根本上说,这是因为你一旦确立了一个应用程序在泄漏SQL异常,你就可以做这样的事:
http://widgetshop.com/widget/?id=convert(int,(select top 1 name from sysobjects where id=(select top 1 id from (select top 1 id from sysobjects where xtype=’u’ order by id) sq order by id DESC)))


这有好多需要吸收理解,我等会将回来详细解释。更重要的是通过那条语句你能够在浏览器中得到这样的结果:

关于SQL注入,你应该知道的那些事

现在我们得到了,我们已经发现那数据库里有一个表单叫做“Widget”。你将经常能看到这中注入攻击因依赖于数据库内部的错误而被称作“基于错误信息的注入”。让我们解构URL中的这个查询:
convert(int, (      select top 1 name from sysobjects where id=(        select top 1 id from (          select top 1 id from sysobjects where xtype='u' order by id        ) sq order by id DESC      )    )  )

从最深层的开始理解,我们先按照ID的顺序从sysobjects表获取第一个有记录的ID。在那里,我们获取最后一个ID(这就是为什么它是按降序排列),并把它传递到第一个select语句。那个语句接下来只会将那个表单名称转换成一个整数。这个转换将大多数情况下失败(各位,不要用“1”或“2”或其他整数来命名数据表就是这个原因!),而这个异常暴露了UI中的表单名称。

为什么是三个select语句?因为这意味着我们可以进入最深层的那个并把“top1”改为“top2”,得到如下结果:

关于SQL注入,你应该知道的那些事

现在我们知道了这个数据库有一个数据表叫做“User”。利用这种方法我们可以发现各个表单的字段名称(只需向syscolumns表应用同样的思路)。我们可以更进一步扩展这个思路

关于SQL注入,你应该知道的那些事

在上一个截图中,我已经发现了叫做User的表单和名为Password的列,现在我需要做的就是把那个表单选出来(当然,你可以用嵌套的select语句来一个一个枚举所有的记录),并通过将字符串转换成整数来构造异常(你总是能够在数据后面通过加一个英文字符来看它到底是不是一个整数,之后尝试将整个字符串转换为整数时就会产生一个异常)。如果你想要进一步理解这可以有多简单,我去年录制了一个 我教3岁儿子用Havij来自动注入的视频,那里运用了这个技术。

但是这里有一个问题——它唯一能成功的可能是因为那个app有些淘气并将内部的错误信息展示给公众。事实上那个app差不多直接告诉了我们表单和列表的名字并当我们做出恰当询问时返回数据,那么如果那个app不这样做又会怎样呢?我的意思是,如果那个app设定恰当而没有泄漏内部的错误信息呢?

这就是我们运用“blind”SQL(多译为盲注)注入的地方,那真的是一个有趣的东西。

盲目地尝试注入

在上一个例子中(事实上也在很多成功的注入攻击先例中),攻击依赖于受攻击的app明确地将内部的细节,要么是合并表单,要么是将数据返回,要么将错误信息传回浏览器。泄漏内部的实现方法一直都是一键不好的事,因为正如你之前看到的那样,像这样不安全的错误处理可以促使不仅仅是应用程序的架构泄漏,更会使你极易从中获取数据。

一个恰当设定的app应当能够在得到一个未经处理的异常时返回一个和下面这个相似的错误信息:

关于SQL注入,你应该知道的那些事

这是新ASP.NET的app在处理自定义错误时的默认错误页面,但是类似的样式也在别的technology stacks中出现。现在这个页面已经和之前那个显示内部SQL异常的页面一模一样了,只不过是用一个有好的错误信息代替直接展示出来的异常。假如我们同时也不能实现一个基于合并查询的攻击,SQl注入风险就完全不存在了吗?不一定……

盲目地SQl注入攻击依赖于我们变得能够得到不言而喻的信息,换言之,我们能够通过观察app并没有直接告诉我们的表单名称或者在浏览器中直接显示的列表数据来下结论。当然问题来了——我们如何让app按照一个可以观察到的格式来揭示我们之前有的信息,而并不显式地告诉我们?

我们将去欣赏两种尝试:基于布尔值的和基于时间的。

去询问(APP),然后你将被回答:基于布尔值的注入

这只有你询问app正确的问题时成立。之前,我们能够明确地询问这样的问题,比如“你有什么表单?”或“每个表单中你有什么数据列?”,然后数据库会明确地告诉我们。现在我们需要稍微变换一线询问的方式,比如像这样:
http://widgetshop.com/widget/?id=1 and 1=2 Clearly this equivalency test can never be true – one will never be equal to two. How an app at risk of injection responds to this request is the cornerstone of blind SQLi and it can happen in one of two different ways.

显然这个相等测试永远不会成立——1永远都不等于2。那么一个app如何处理这样的查询决定了它的SQL注入风险,可能会有两种方式。

第一种,如果没有记录返回,它可能只抛回一个异常。通常开发者会假设那里存在一个与查询的字符串有关的记录,因为经常会是app自己产生那个链接并在另一个页面中获取数据。而当那里没有数据可以返回时,事情就不一样了。或者第二种,那个app可能抛出一个异常并同时不会展示记录,因为那个相等永远都是错的。不管怎样,那个app都会隐含地告诉我们数据库中没有记录被返回。

现在我们试试这个:
1 and(    select top 1 substring(name, 1, 1) from sysobjects where id=(      select top 1 id from (        select top 1 id from sysobjects where xtype='u' order by id      ) sq order by id desc   )  ) = 'a'

要记住用这整个语句块来替换刚才那个查询串的“?id=1”,这实际上是一个在前一个询问上做出的小变化,试图获取表单名称。事实上主要的区别在于现在不是试图通过将字符串转换为整数来构造异常,而是运用相等测试来检查是否有一个表单首字母为“a”(假设这里对大小写不敏感)。如果这个查询和“?id=1”给我们的信息一样,那么它就相当于向我们证实相等测试成立了,sysobjects里确实有一个首字母开头为“a”的表单。如果它给我们之前我们提到过的两种情景之一,那么我们就知道表单并没有以“a”开头,因为没有信息被返回。

现在我们得到的只有sysobjects中表单的第一个字母,当你想要得到第二个字母是substring语句需要变成现在这样:
select top 1 substring(name, 2, 1) from sysobjects where id=(

你能看到它现在从2开始而不是1.当然,这很费力:你在枚举sysobjects中所有表单后枚举了所有字母表中可能组成的词,直到你最后得到了结果,然后你又要表单名称的每一个字符重复这个过程。但是,有一种像这样的快捷方式:
1 and  (    select top 1 ascii(lower(substring(name, 1, 1))) from sysobjects where   id=(      select top 1 id from (        select top 1 id from sysobjects where xtype='u' order by id      ) sq order by id desc    )  ) > 109

这里有一个微妙但很重要的区别,它没有检查单个字符匹配,而是查找字符在ASCII表中的位置。事实上,它先将表单名称转换为小写字母,这样我们只需要处理26个字符(当然,假设命名中只有字母),然后它获取那个字母的ASCII值。在上一个例子中,它接着检查表单中是否有以在“m”(ASCII值为109)之后的字母开头的,然后相同的潜力成功描述了之前应用的(要么一个记录被返回要么没有)。主要的区别在于,没有进行26次尝试猜测字母(并连续进行26次HTTP请求),它现在将会在5次尝试中穷尽所有可能——你只需要不断将可能的ASCII值区间减半直到最后只有一种可能剩余。

比如,如果一个字符ASCII值比109大,那么它一定在“n”和“z”之间所以你分割(大致地)这个区间为一半,然后尝试大于115那个。如果那是错误的那么正确的字符就一定在“n”和“s”之间,所以你再将区间减半,然后尝试大于112的那个。那时正确的所以现在只有三个字符剩下了,所以你可以在至多两次尝试中将区间减小至长度为1。一句话就是至多26次猜测(平均起来13次),现在只需要5次,如果你只是简单地每次将答案区间减半。

通过构造恰当的询问app将依旧告诉你之前它通过明确的错误信息告诉你的东西,只不过它现在有些怕羞,你需要哄它才会得到答案。这经常被叫做“基于布尔值”的SQL注入,而它在之前演示过的“基于合并查询”的和“基于错误信息”的方案不好用时能够发挥作用。但这并非万无一失,让我们看看另一个途径,这回我们将要有一些耐心。

耐心等待泄漏:基于时间的盲目注入

所有实时的方案成功发挥作用都是基于一个假设:app会通过HTML输出来泄漏信息。在之前的例子中基于合并查询的和基于错误信息的尝试是在浏览器中给我们数据来明确地告诉我们对象名称和泄漏的内部数据。在盲目的基于布尔值的例子中,我们被隐含地告知同一份信息借助于HTML和基于真假相等测试得到的结果不同。那么当这份信息不能通过HTML泄漏时,不论是明确地还是隐含地,怎么办?

让我们想像有另一个攻击媒介是这个URl:
http://widgetshop.com/Widgets/?OrderBy=Name
在这个例子中很正常假设查询会被翻译成像这样的东西:
SELECT * FROM Widget ORDER BY Name

显然我们不能直接开始向ORDER BY语句直接加东西(尽管那里已经有其他角度你可以挂载一个基于布尔值的攻击),所以我们需要尝试另一种途径。一个很常见的SQL注入技巧是终止一个语句并随后附加一个语句,比如像这样:
http://widgetshop.com/Widgets/?OrderBy=Name;SELECT DB_NAME()

这是一个无害的语句(尽管在查找数据库的名字是可能会有用),一个更有害的途径可能会是类似于“DROP TABLE Widget”的东西。当然web app连接数据库所调用的帐号需要有这样的权限,问题在于一旦你开始将链接连接起来,它的潜力就开始发挥。

回到那个盲目的SQL注入攻击,现在我们需要做的是找到一个在附加语句中运用之前讨论到的基于布尔值的测试。要做到这点我们需要用WAITFOR DELAY语句来产生延时。试试这个,看看尺寸:

这和之前的例子只有一个微小的变化,之前是通过操纵WHERE语句改变返回的记录的书目,而现在只是用一个新的语句来查找sysobjects中是否存在一个表单以一个比“m”大的字母开头,并且如果存在,查询将稍微等待5秒钟。我们仍旧需要缩小表单名称的范围而且需要尝试表单名中的每一个字符而我们仍旧需要查询sysobjects中的其他表单(当然还要看看syscolumns并将数据提取出来),但所有这一切完全可以用一点时间。5秒钟可能比需要的有些长了或者它可能不够长,这一切都归结于应用程序的响应时间如何保持一致,因为最终这都被设计来操作一个能被观察到的行为——从开始查询到最后得到结果要经过多长时间。

这个攻击——还有之前那些——当然被可以完全地自动化,因为除了简单枚举和条件逻辑之外不剩别的了。当然它可能会占用一些时间,但那是一个相对的概念:如果一个正常的查询需要1秒钟,而5次尝试只有一半需要完成的话,你应该期待每17.5秒得到一个字符,比如有数据库中平均有10个字符的话,就是需要大概3分钟得到一个表单,而可能一个数据库中有20个表单,我们就认为大概一小时你就能得到系统中的每一个表单名称。而这是你用单线程方式做这些的情况。

到这里没有结束……

这是那些有一堆不同角度观点的话题,不只因为有太多的数据库、app框架、服务器的组成,更不要说一整个防御体系比如网络应用的防火墙。一个事情变得棘手的例子是如果你需要求 助于基于时间的攻击而数据库还没有支持延迟功能,比如一个Access数据库(是的,游戏而事实上在网站中用这些!)这里的一个途径是用叫做 heavy queries的方案,查询由于本身的性质会导致响应是缓慢的。

另一件关于SQL注入攻击值得一提的是攻击是否成功有两个关键因素:第一个是app在输入方面的规范,这决定了app最终会接收到什么字符并传给数据库。通常我们会看到很零零碎碎的途径,比如尖括号和引号被剥离,但其他一切是允许的。当这种情况出现时,攻击者需要变得有创意,考虑如何构造恰当的查询使得“路障”被避免。而这正是第二点——攻击者的SQl实力是至关重要的。这不是指你运用TSQL的SELECT FROM的能力,那些优秀的SQl注入者掌握大量能够绕过输入检测的窍门并从系统中选择数据而使它们能通过网页来检索。比如说,搜寻一个列的类型可以通过像这样的小技巧:
http://widgetshop.com/Widget/?id=1 union select sum(instock) from widget

在这个例子中,基于错误的注入攻击将在错误信息返回到UI时(当然,如果没有报错就是指它是整型的)会告诉你“InStock”列是什么类型的

关于SQL注入,你应该知道的那些事

或者一旦你完全厌倦了那个该死的易受攻击的站点仍然在网络上留存,试试这个:
http://widgetshop.com/Widget/?id=1;shutdown

但是注入攻击可以通过从HTTP中获取信息而更进一步,比如那里有能给攻击者机器脚本的载体或者试试另一个离题的——为什么不试试直接通过HTML获取那该死的东西?你就创建一个本地的SQL服务器并通过1433端口远程连接到SQL Server Management Studio!等一下,你会需要那个网页app用来链接数据库来创造用户的帐号,是吗?是的,而且大部分人都需要,事实上你 只需通过Google就能找到它们(译注:用度娘会告诉你找不到)(当然这种情况下SQL注入攻击就没有必要了,因为数据库此时已经能公开获取)

最后,如果关于SQl注入攻击及漏洞的流行和在当今软件行业的影响还有什么疑问,就在上周就有一篇 关于可以说是迄今为止最大的黑客方案之一的新闻,据称它造就了3亿损失

这起诉书也暗示那些黑客,在大多数情况下,没有部署很复杂的方案来进入企业网络。这篇报道也展示了在大多数情况下缺口是通过SQL注入漏洞的道德——这一威胁已经被彻底证明并领悟远超过十年了。

可能SQL注入攻击没有像某些人相信的那样被人理解。