sqlmap 源码阅读与流程分析

0x01 前言

还是代码功底太差,所以想尝试阅读 sqlmap 源码一下,并且自己用 golang 重构,到后面会进行 ysoserial 的改写;以及 xray 的重构,当然那个应该会很多参考 cel-go 项目

0x02 环境准备

sqlmap 的项目地址:https://github.com/sqlmapproject/sqlmap 用 pycharm 打断点调试,因为 vscode 用来调试比较麻烦。

因为要动调,所以需要一个 sql 注入的靶场,这里直接选用的是 sql-labs,用 docker 起

docker pull acgpiano/sqli-labs
docker run -dt --name sqli-lab -p [PORT]:80 acgpiano/sqli-labs:latest

最后还需要重新配置一下数据库,然后才能以 sqli-labs 为靶场进行测试。

这里也挂一下 sqlmap 对应的一些基础操作 ———— https://www.cnblogs.com/hongfei/p/3872156.html

22c9dfe2e08f0595d1e80dbf8dcfa4eb.png

直接在 pycharm 的 Debug 下进行调试,设置参数如下,开始调试

-u "http://81.68.120.14:3333/Less-1/?id=1" -technique=E --dbs

0x03 sqlmap 源码阅读

在开始之前我们有必要确认一下 sqlmap 运行的流程图,很重要!这样有助于我们进一步分析源码。

68391b0f3369173c1796f7611e26a8ce.png

1. 初始化

在 sqlmap.py 的 main 函数下断点,开始调试

在没有对 URL 进行发包/探测的时候 sqlmap 会先对一些环境、依赖、变量来做一些初始化的处理

0a1377646877f78ed20161e9e725e230.png

往下,通过 cmdLineParser() 获取参数,cmdLineParser() 通过 argparse 库进行 CLI 的打印与获取,类似的一个小项目我之前也有接触过 https://github.com/Drun1baby/EasyScan

往下 initOptions(cmdLineOptions) 解析命令行参数

init 函数:初始化

在 init() 函数中通过调用各种函数进行参数的设置、payload 的加载等,有兴趣的师傅可以点进去阅读一下。

b08641c1441a59687afb51dec7d6216a.png

其中这三个相对比较重要,是用来加载 payload 的 ———— loadBoundaries()loadPayloads()_loadQueries()

loadBoundaries()  // 加载闭合符集合
loadPayloads()    // 加载 payload 集合
_loadQueries()    // 加载查询语句,在检测到注入点之后后续进行数据库库名字段名爆破会用到的语句

下个断先点调试一下 loadBoundaries() 函数

2ff0888a1b418ebbec7c798206767fb0.png

首先,会去加载 paths.BOUNDARIES_XML,也就是 data/xml/boundaries.xml

56abd0b85ae099aa77e0f66291c95ffb.png

接着进入解析 XML 文件的部分,跟进 parseXmlNode(root)

dddab984f34364a501fa400545dff3b7.png

最终添加到 conf 对象的 tests 属性里

bef534103991f3f073c5709c8411b170.png
  • • loadPayloads() 函数与 _loadQueries() 函数大体上也是如此,都是做了解析 xml 文件的工作,再将内容保存到 conf 对象的 tests 属性里。像 loadPayloads() 函数,最后在 conf.tests 里面可以很清晰的看到 payloads

061c065fd9754424019706aab4f784f9.png

此时我们还可以看一下 conf 是什么

290a192f2cf2a01ceb87805d9c8a8794.png

conf 属性中主要存储了一些目标的相关信息(hostname、path、请求参数等等)以及一些配置信息,init 加载的 payload、请求头 header、cookie 等

init() 函数执行完毕后,就会来到 start() 函数进行项目的正式运行。

dd0f7345d3856ebbdc43b49127307175.png

初始化功能点小结

简单概括一下初始化部分的代码做了什么事

  • • 获取命令行参数并处理

  • • 初始化全局变量 conf 以及 kb

  • • 获取并解析几个 xml 文件,完成闭合工作、payloads 加载工作

  • • 设置 HTTP 相关配置,如 HTTP Header,UA,Session 等

2. URL 处理

f8 下来,先到的是 threadData = getCurrentThreadData(),继续往下走,到 result = f(*args, **kwargs) 代码块,跟进一下

cc145704f890ee2ee164286848effb40.png

代码逻辑此时来到了 /lib/controller/controller.py 下,往下走,是不会进到 conf.direct 和 conf.hashFile 中的,会直接进入到 kb.targets.add() 的代码逻辑里面。

e242d3d63dc259155b2208453cd9de91.png

此处的 kb 变量的作用是共享一些对象,其实本质上是保存了注入时的一些参数。kb.targets 添加了我们输入的参数,如图

6760f1a0dee8430d9b93027d67e8cc0e.png

往下看,大体上是做了一些类似类似打印日志、赋值、添加 HTTP Header 等工作,这一部分代码我们就不看了,直接看最关键的这一部分代码 parseTargetUrl()

a93a83e7c1cf43a397948117485ea6e5.png

跟进

8d7fa2562800c3af6c0e87267b9a2071.png

一开始先进行了这一判断

if re.search(r"://\[.+\]", conf.url) and not socket.has_ipv6

判断 http:// 的开头形式是否正确,以及 socket 是否为 ipv6 协议,如果为 ipv6 协议,那么 sqlmap 并不支持。

接着判断

if not re.search(r"^(http|ws)s?://", conf.url, re.I):

判断是 http 开头还是 https 开头,又或者是否是 ws/wss 开头,如果没有这些开头,则就从端口判断,这里我认为或许可以加上 80 与 8080 端口。

677466e70410a2ea882bed83652c67ba.png

继续往下看,进行了 url 的拆分、host 的拆分,并将这些内容保存到 conf 里面的对应属性,后续也是一些基础的判断与赋值,这里不再赘述。

5866f75f8bd1c6405678debc8870da94.png

总而言之是在对 URL 进行剖析与拆解,最后这些东西都是放到 conf 里面的

3. 如果这个网站已经被注入过,生成注入检测的payload

核心代码在 controller.py 的第 434 行,需跟进;此处我们可以设置对 kb.injections 的变量监测。先跟进 setupTargetEnv() 函数

04915f7c84fe75e1b8afba0432029b21.png
  • • setupTargetEnv() 函数调用了如下图所示的七个函数

3a0c62b2e3b603508cb275fda70fd9c6.png

我们跟进最主要的 _resumeHashDBValues() 函数,首先调用了 hashDBRetrieve() 函数,设置检索

37e895c0f7e459f0dd812a46c5444740.png

出来,到第 476 行,这一次又调用了 hashDBRetrieve() 函数,传参是 HASHDB_KEYS.KB_INJECTIONS,意思就是以 KB_INJECTIONS 作为 KEY 进行检索。跟进发现函数先将需要注入的 URL 信息放到了 _ 这个变量中,并将基础信息用 | 符号隔开。

0941fb61d483ff5e3ffe59c3f11679f8.png

跟进 retrieve() 函数,这个函数做了生成 payload 的工作,具体是怎么生成的我们继续往下看

d1a26844feddb95863507042e0dab6ec.png

第 95 行,这里很重要,执行了 SQL 语句,并通过 Hash 加密,加密方式是 base64Pickle 序列化

f29f494656f4ebb4eddb9e0610b86803.png

最终反序列化解密 Payload,说实话这里没看懂是怎么生成的,看上去仅仅是执行了一个 SQL 语句,后面看其他师傅的文章的时候并没有把这一段单独拉出来说,payloads 其实都放在 xml 当中。

99bf1366374cd11becc8429ef8c96283.png

接着再循环一次,生成一个 payload

在生成完所有 payload 之后会先对目标进行一次探测,如果 Connection refused 则返回 False

b2baa2d2832c55a1312b34b441543580.png

这里生成的 payload 只是很基础的一部分,并非是

4. WAF 检测

解析完 URL 之后对目标进行探测,往下看,位置是 controller.py 的第 439 行,第 448 行有 checkWaf() 的函数,很明显就是要做 WAF 检测的功能。

3f89a68c27ef77e18176f2ae49a8290a.png

先会判断这一目标是否存在 WAF,如果存在 WAF 的话,会进行字符的相关 fuzz,当然此处建议对一个存在 WAF 的目标进行测试。值得注意的是,如果这个目标你已经探测过存在 waf,且已知 waf 归属厂商的情况下,就不会走到 payload 那一段代码逻辑当中去,相关的业务代码在 hashDBRetrieve() 下,此处不再展开,比较容易。

62843c0979d1a8b7fb6b59157dd1c89c.png

如果存在 WAF,则会生成用于 fuzz 的 payload,这个 payload 是基于这个 NMAP 的 http-waf-detect.nse ———— https://seclists.org/nmap-dev/2011/q2/att-1005/http-waf-detect.nse

设置 payload 类似于 "9283 AND 1=1 UNION ALL SELECT 1,NULL,'<script>alert("XSS")</script>',table_name FROM information_schema.tables WHERE 2>1--/**/; EXEC xp_cmdshell('cat ../../../etc/passwd')#",如果没有 WAF,页面不会变化,如果有 WAF,因为 payload 中有很多敏感字符,大多数时候页面都会发生改变。

接下来的 conf.identifyWaf 代表 sqlmap 的参数 --identify-waf,如果指定了此参数,就会进入 identifyWaf() 函数,主要检测的 waf 都在 sqlmap 的 waf 目录下。不过新版的 sqlmap 已经将这一参数的功能自动放到里面了,无需再指定参数

  • • 这里的 payload 先经过处理后赋值给 value,再将 value 作为参数传入 queryPage() 请求中,跟进

be19088fd17e8a59d961a83bb625e206.png

在经过很长一段的数据处理与判断代码后,我们到第 1531 行,如图,跟进;getPage() 函数的作用是获取界面的一些信息,如 url,ua,host 等,通过输出比对 payload,为判断 waf 类型提供信息。

789781fb29fb8ad64a6cbe679793ff60.png
  • • 获取基本信息

bc7980e235abd3ce6d28070791e1ff86.png

这些基础信息最后都会保存在 response 系列的 message 当中

6146dc51154ef2da4015b764e6b06c05.png

getPage() 函数中调用了 processResponse() 函数做响应结果的处理,跟进

56882e3341fb2c5457a322c6f45ba385.png

往下看,到 401 行开始,后续的代码进行了 Waf 的识别

32d85165e11351b53525fcc84292af0a.png

跟进 identYwaf.non_blind_check(),是通过正则表达式来对页面进行匹配,对应的规则在 thirdparty/identywaf/data.json 中

fdb74f80b4fcf33ca12c30cd22121f08.png eb0cfbf3c983eeb9944e7f15f3218caa.png

同时 sqlmap 不光通过规则库来进行判断,也会通过页面相似度来判断是否存在 waf/ips

310686810dfdc8882d617b8d7c90d37a.png

如果相似度小于设定的 0.5 那么就判定为有 waf 拦截

7c83376e74965058cc944a7aa20e9676.png

WAF 注入总结

总结一下就是两点,一种方法是通过正则匹配的检测,另外一种方法是根据页面相似度来检测,我自己应该很难写出来 waf 检测的东西;届时再做尝试。

5. 注入检测之启发式注入

从 checkWaf() 函数里面出来,先到第 457 行,检测网站是否稳定(因为有些网站一测试可能就炸了)对应此 info

[INFO] testing if the target URL content is stable
212a63ab705c8de885c29d7b4f1580b7.png

继续往下走到第 471 行,会先判断参数是否可以注入,这里与命令的参数 —— --level 挂钩

eda8bac77e743f1448ed5ffe7c8c867d.png

在前文环境准备的时候我们采用的方式是报错注入,如果不这么做,直接指定参数 --dbs,无法进入到启发式注入里面。我们接着看代码,往下直到第 581 行,调用的 heuristicCheckSqlInjection() 函数,意思是启发性注入。

b0499ef336c84037cc3976a215f2df6e.png
  • • 启发式注入做了哪些工作

1、数据库版本的识别 2、绝对路径获取 3、XSS 的测试

数据库版本的识别

首先会从 HEURISTIC_CHECK_ALPHABET 中随机抽取10个字符出现构造 Payload,当然里面的都不是些普通的字符,而且些特殊字符,当我们进行 SQL 注入测试的时候会很习惯的在参数后面加个分号啊什么的,又或者是其他一些特殊的字符,出现运气好的话有可能会暴出数据的相关错误信息,而那个时候我们就可以根据所暴出的相关错误信息去猜测当前目标的数据库是什么。

并且最后生成的这个 payload 是能够闭合的

ab15e89b9001db285f072c7c49e850d2.png

实际找个网站测试,如图,这就是报出的 SQL 数据库错误

82ab0f03225e50ef53b7b65a0182cd3c.png

判断在 lib/request/connect.py 的 1532 行

81c588ef7143487973015090932c091a.png

接着跟进 processResponse() 函数,这里和 waf 对比用的同一种方式,不再详细说明

64edd9e609b30ad98e368ae87e0d03da.png

其中 processResponse() 会调用到 ./lib/parse/html.py 中的 htmlParser() 函数,这一个函数就是根据不同的数据库指纹去识别当前的数据库究竟是什么。

4b366421f0f43c42273e48f1d04af260.png 901f040bcdfb37d2ba5fe43346affe2d.png

最终实现这一功能的其实是 HTMLHandler 这个类,errors.xml 文件内容如图

374d8b84cbcc5e46a0fbf7b0dfb82116.png

这一配置文件的比较简单,其实也就是一些对应数据库的正则。sqlmap 在解析 errors.xml 的时候,然后根据 regexp 中的正则去匹配当前的页面信息然后去确定当前的数据库。这一步和 WAF 比对类似。

到此 sqlmap 就可以确定数据的版本了,从而选择对应的测试 Payload,后续我们会看到这是根据莫索引将 payloads 排序,然后选取对应数据库信息的 payloads 进行测试。减少 sqlmap 的扫描时间。

  • • 最后这个 DBMS 探测对应的是这一段信息

511f6dc88f93db0b8529a67cb4e4934a.png

获取绝对路径与 XSS 探测

相比指纹识别,获取绝对路径的功能模块相对简单,利用正则匹配寻找出绝对路径。

8ab63c778c25748776cc0df45a98198e.png

XSS 的探测也比较简单,这里就不作代码分析了

26527e7afc08cf22ecbc9c955521381e.png

6. 注入检测之正式注入

从启发式注入里面出来,到第 592 行,进行正式的注入检测,跟进

b492b80a60bc43fb61afa43e276f7df8.png

到第 130 行,获取所有的 payload,后续会根据数据库的信息构建索引,将符合索引的 payload 拿去攻击

3acee0bd5dcba05a0ea592a1dabd47b2.png

往下走,先判断有没有做数据库信息的获取,如果有则跳过,如果没有就先进行上一步的启发式注入

da9b0a9e6811a827677281d88847cc6f.png

接着根据通过报错得到的数据库信息建立索引,将对应最有效的 payload 拿出来。这些 payloads 会进行 while 循环

2641f53577d8869168b023543f869591.png

第 370 行,通过 cleanupPayload() 函数对 payload 进行处理,主要功能其实是做了 payload 的标签替换

33fc04df485e904deb2b85ee957084bb.png

最后替换过的 payload 长这样

"AND (SELECT 2*(IF((SELECT * FROM (SELECT CONCAT('qbpxq',(SELECT (ELT(9125=9125,1))),'qxkvq','x'))s), 8446744073709551610, 8446744073709551610)))"

在 sqlmap 中将payload 分为了三部分,上面生成的 fstpayload 就是中间那部分

prefix + payload + suffix

prefix 和 suffix 就是对应的,闭合前面的结合以及注释后面的结构,这两个属性主要是从 boundary 中进行获取的,boundary 就是前面加载的 boundaries.xml 配置文件,用来闭合的,所以这里作为了 prefix 和 suffix

d7acb7cee8551289db90c209109d851d.png

最后的拼接

a62b458204738ca97056f6c476fc62db.png

并分别对 prefix 和 suffix 进行 clean,然后进行组合,组合之后的 payload 就是 reqPayload,然后进行请求

6cf55185d7480689bad6dbd96012cd42.png
  • • 发出请求最终还是通过 request.queryPage() 来实现的

请求完毕的结果经过 queryPage() 函数来获取界面,但是页面结果是由 kb.chars.start 和 kb.chars.stop 包裹着的

240aae9825824303b65dbe33c4f50f13.png c0386d4b8049dc08bece93087647a63a.png

当第一次的注入不成功的时候,会不断变更 prefix,suffix,当 prefix 和 suffix 都变更完毕但还是无法注入时,才会变更 payload,取出另一个 payload 出来,直至 injectable 变量为 true,同时 output=1

e4166bea9a1fcd05a4de1409463cfc94.png

并且 injectable=true

6f8e462297ff1d17309e3f0ca3d9af0c.png

7. 爆数据库等操作

经过上一步正式注入的判断,得到的 injectable=true 参数,才能进行下一步的爆数据库操作.

爆库阶段主要是先经过四个函数处理数据后,再调用 action() 函数,跟进。

64ea09a0002341e2b5cc3e482b7e0261.png

这里已爆库为例,先看 --dbs 参数有关的这一块,核心函数是 getDbs()

13a3192cba20924f55052626687dc969.png

先根据后台数据库信息,输出日志

f5736b250800d3e422db65048e9564a9.png

第 133 行,queries 就是存放之前初始化 queries.xml 的变量

133cefcea51b6b246cf3c91884d3b080.png

首先通过 count(schema_name) 来获取数据库的个数,然后再通过 limit num,1 来依次获取数据库名,从 queries 变量中获取语句之后就会传递到 getValue 函数

ecee730922a5b740620f96994ae8752a.png

跟进,前面做了一些基础的设置和 payload 的处理与赋值,比如第 401 行的 cleanQuery() 函数,将语句转换为大写,这里我就不跟进了。直接看关键语句,第 451 行,errorUse() 函数

e8ba74718845b11d508c8cc9ce324649.png

在 errorUse() 中首先通过正则将 payload 中的各个部分都进行了获取 ,保存到了对应的 field 当中,最终经过一系列处理,取出了 payload 中的 schema_name

d9df586e729e9e02698b95e318d653cf.png

跳出 getFields() 函数,往下,将 expression 的值经过 replace 操作,赋值给了 countedExpression,最终得到的值是 'SELECT COUNT(schema_name) FROM INFORMATION_SCHEMA.SCHEMATA'

b79e9682ee8c28fe9ec46f4921f83afd.png

第 337 行,跟进 _oneShotErrorUse() 函数,在这一个函数中,sqlmap 对目标网站发包,使用的 payload 为 countedExpression,目的是探测数据库个数(count)

cb6911430bba012bc91a08b425599ea0.png

具体业务发包在这里

87339e0000e6da3f87b39edb770b01d3.png

最后将结果传入 extractRegexResult() 函数中进行正则提取

bdb45b645cbe0fa4e0e696210216fd81.png

多线程的方式进行注入,而 runThreads() 函数调用了 errorThread() 函数,最终的注入业务还是由 errorThread() 函数来完成的

a0965208303810bb071850ef5c3177f4.png 6c8ddd3cd23b958ec8fa3f6f4f6f4c01.png

跟进一下 _errorFields() 函数,将每一个表进行 while 循环操作,再通过 limitQuery() 函数设置最后的 Limit 语句

d19334ecb2b0a5699cc3e35c1a6033a5.png

最后成功 --dbs

64748ee2e43970cc4a8dd9ee86c1d036.png

sqlmap 流程分析结束

0x04 小结

sqlmap 的流程分析需要非常重视这张图,当感觉代码看不下去的时候看一下这张图可以事半功倍。

8cc42bb59ab74410c37579d71a55ff1d.png

在审计开始之前也可以看一下 utils 文件夹下的 python 文件,总体来说流程并不难,看正则的时候其实挺吃力的。

0x05 Reference

http://wjlshare.com/archives/1733 https://wooyun.js.org/drops/SQLMAP%E6%BA%90%E7%A0%81%E5%88%86%E6%9E%90Part1.%E6%B5%81%E7%A8%8B%E7%AF%87.html

猜你喜欢

转载自blog.csdn.net/qq_38154820/article/details/129605485