docker pull acgpiano/sqli-labs
docker run -dt --name sqli-lab -p [PORT]:80 acgpiano/sqli-labs:latest
0x01 前言
还是代码功底太差,所以想尝试阅读 sqlmap 源码一下,并且自己用 golang 重构,到后面会进行 ysoserial 的改写;以及 xray 的重构,当然那个应该会很多参考 cel-go 项目
0x02 环境准备
sqlmap 的项目地址:用 pycharm 打断点调试,因为 vscode 用来调试比较麻烦。
因为要动调,所以需要一个 sql 注入的靶场,这里直接选用的是 sql-labs,用 docker 起
-u "http://81.68.120.14:3333/Less-1/?id=1" -technique=E --dbs
最后还需要重新配置一下数据库,然后才能以 sqli-labs 为靶场进行测试。
这里也挂一下 sqlmap 对应的一些基础操作 ———— 直接在 pycharm 的 Debug 下进行调试,设置参数如下,开始调试
0x03 sqlmap 源码阅读
在开始之前我们有必要确认一下sqlmap 运行的流程图,很重要!这样有助于我们进一步分析源码。
- 初始化
在sqlmap.py的 main 函数下断点,开始调试
在没有对 URL 进行发包/探测的时候 sqlmap 会先对一些环境、依赖、变量来做一些初始化的处理
往下,通过cmdLineParser()获取参数,cmdLineParser()通过argparse库进行 CLI 的打印与获取,类似的一个小项目我之前也有接触过
往下initOptions(cmdLineOptions)解析命令行参数
init函数:初始化
在init()函数中通过调用各种函数进行参数的设置、payload 的加载等,有兴趣的师傅可以点进去阅读一下。
loadBoundaries() // 加载闭合符集合
loadPayloads() // 加载 payload 集合
_loadQueries() // 加载查询语句,在检测到注入点之后后续进行数据库库名字段名爆破会用到的语句
其中这三个相对比较重要,是用来加载 payload 的 ————loadBoundaries()、loadPayloads()、_loadQueries(),
下个断先点调试一下loadBoundaries()函数
首先,会去加载paths.BOUNDARIES_XML,也就是data/xml/boundaries.xml
接着进入解析 XML 文件的部分,跟进parseXmlNode(root)
最终添加到 conf 对象的 tests 属性里
此时我们还可以看一下conf是什么
conf 属性中主要存储了一些目标的相关信息(hostname、path、请求参数等等)以及一些配置信息,init 加载的 payload、请求头 header、cookie 等
init()函数执行完毕后,就会来到start()函数进行项目的正式运行。
初始化功能点小结
简单概括一下初始化部分的代码做了什么事
- URL 处理
f8 下来,先到的是threadData = getCurrentThreadData(),继续往下走,到result = f(*args, **kwargs)代码块,跟进一下
代码逻辑此时来到了/lib/controller/controller.py下,往下走,是不会进到conf.direct和conf.hashFile中的,会直接进入到kb.targets.add()的代码逻辑里面。
此处的 kb 变量的作用是共享一些对象,其实本质上是保存了注入时的一些参数。kb.targets添加了我们输入的参数,如图
往下看,大体上是做了一些类似类似打印日志、赋值、添加 HTTP Header 等工作,这一部分代码我们就不看了,直接看最关键的这一部分代码parseTargetUrl()。
if re.search(r"://[.+]", conf.url) and not socket.has_ipv6
if not re.search(r"^(http|ws)s?://", conf.url, re.I):
跟进
一开始先进行了这一判断
判断的开头形式是否正确,以及 socket 是否为 ipv6 协议,如果为 ipv6 协议,那么 sqlmap 并不支持。
接着判断
判断是 http 开头还是 https 开头,又或者是否是 ws/wss 开头,如果没有这些开头,则就从端口判断,这里我认为或许可以加上 80 与 8080 端口。
继续往下看,进行了 url 的拆分、host 的拆分,并将这些内容保存到 conf 里面的对应属性,后续也是一些基础的判断与赋值,这里不再赘述。
总而言之是在对 URL 进行剖析与拆解,最后这些东西都是放到conf里面的
- 如果这个网站已经被注入过,生成注入检测的payload
核心代码在controller.py的第 434 行,需跟进;此处我们可以设置对kb.injections的变量监测。先跟进setupTargetEnv()函数
我们跟进最主要的_resumeHashDBValues()函数,首先调用了hashDBRetrieve()函数,设置检索
出来,到第 476 行,这一次又调用了hashDBRetrieve()函数,传参是HASHDBKEYS.KBINJECTIONS,意思就是以KBINJECTIONS作为 KEY 进行检索。跟进发现函数先将需要注入的 URL 信息放到了这个变量中,并将基础信息用|符号隔开。
跟进retrieve()函数,这个函数做了生成 payload 的工作,具体是怎么生成的我们继续往下看
第 95 行,这里很重要,执行了 SQL 语句,并通过 Hash 加密,加密方式是 base64Pickle 序列化
最终反序列化解密 Payload,说实话这里没看懂是怎么生成的,看上去仅仅是执行了一个 SQL 语句,后面看其他师傅的文章的时候并没有把这一段单独拉出来说,payloads 其实都放在 xml 当中。
接着再循环一次,生成一个 payload
在生成完所有 payload 之后会先对目标进行一次探测,如果 Connection refused 则返回 False
这里生成的 payload 只是很基础的一部分,并非是
- WAF 检测
解析完 URL 之后对目标进行探测,往下看,位置是controller.py的第 439 行,第 448 行有checkWaf()的函数,很明显就是要做 WAF 检测的功能。
先会判断这一目标是否存在 WAF,如果存在 WAF 的话,会进行字符的相关 fuzz,当然此处建议对一个存在 WAF 的目标进行测试。值得注意的是,如果这个目标你已经探测过存在 waf,且已知 waf 归属厂商的情况下,就不会走到 payload 那一段代码逻辑当中去,相关的业务代码在hashDBRetrieve()下,此处不再展开,比较容易。
如果存在 WAF,则会生成用于 fuzz 的 payload,这个 payload 是基于这个 NMAP 的http-waf-detect.nse———— 设置 payload 类似于”9283 AND 1=1 UNION ALL SELECT 1,NULL,”,tablename FROM informationschema.tables WHERE 2>1–/**/; EXEC xp_cmdshell(‘cat ../../../etc/passwd’)#”,如果没有 WAF,页面不会变化,如果有 WAF,因为 payload 中有很多敏感字符,大多数时候页面都会发生改变。
接下来的conf.identifyWaf代表 sqlmap 的参数–identify-waf,如果指定了此参数,就会进入identifyWaf()函数,主要检测的 waf 都在 sqlmap 的 waf 目录下。不过新版的 sqlmap 已经将这一参数的功能自动放到里面了,无需再指定参数
在经过很长一段的数据处理与判断代码后,我们到第 1531 行,如图,跟进;getPage()函数的作用是获取界面的一些信息,如 url,ua,host 等,通过输出比对 payload,为判断 waf 类型提供信息。
这些基础信息最后都会保存在 response 系列的 message 当中
getPage()函数中调用了processResponse()函数做响应结果的处理,跟进
往下看,到 401 行开始,后续的代码进行了 Waf 的识别
跟进identYwaf.nonblindcheck(),是通过正则表达式来对页面进行匹配,对应的规则在thirdparty/identywaf/data.json中
同时 sqlmap 不光通过规则库来进行判断,也会通过页面相似度来判断是否存在waf/ips
[INFO] testing if the target URL content is stable
如果相似度小于设定的 0.5 那么就判定为有 waf 拦截
WAF 注入总结
总结一下就是两点,一种方法是通过正则匹配的检测,另外一种方法是根据页面相似度来检测,我自己应该很难写出来 waf 检测的东西;届时再做尝试。
- 注入检测之启发式注入
从checkWaf()函数里面出来,先到第 457 行,检测网站是否稳定(因为有些网站一测试可能就炸了)对应此 info
继续往下走到第 471 行,会先判断参数是否可以注入,这里与命令的参数 ——–level挂钩
在前文环境准备的时候我们采用的方式是报错注入,如果不这么做,直接指定参数–dbs,无法进入到启发式注入里面。我们接着看代码,往下直到第 581 行,调用的heuristicCheckSqlInjection()函数,意思是启发性注入。
1、数据库版本的识别2、绝对路径获取3、XSS 的测试
数据库版本的识别
首先会从HEURISTICCHECKALPHABET中随机抽取10个字符出现构造 Payload,当然里面的都不是些普通的字符,而且些特殊字符,当我们进行 SQL 注入测试的时候会很习惯的在参数后面加个分号啊什么的,又或者是其他一些特殊的字符,出现运气好的话有可能会暴出数据的相关错误信息,而那个时候我们就可以根据所暴出的相关错误信息去猜测当前目标的数据库是什么。并且最后生成的这个 payload 是能够闭合的
实际找个网站测试,如图,这就是报出的 SQL 数据库错误
判断在lib/request/connect.py的 1532 行
接着跟进processResponse()函数,这里和 waf 对比用的同一种方式,不再详细说明
其中processResponse()会调用到./lib/parse/html.py中的htmlParser()函数,这一个函数就是根据不同的数据库指纹去识别当前的数据库究竟是什么。
最终实现这一功能的其实是HTMLHandler这个类,errors.xml文件内容如图
这一配置文件的比较简单,其实也就是一些对应数据库的正则。sqlmap 在解析errors.xml的时候,然后根据regexp中的正则去匹配当前的页面信息然后去确定当前的数据库。这一步和 WAF 比对类似。
到此 sqlmap 就可以确定数据的版本了,从而选择对应的测试 Payload,后续我们会看到这是根据莫索引将 payloads 排序,然后选取对应数据库信息的 payloads 进行测试。减少 sqlmap 的扫描时间。
获取绝对路径与 XSS 探测
相比指纹识别,获取绝对路径的功能模块相对简单,利用正则匹配寻找出绝对路径。
XSS 的探测也比较简单,这里就不作代码分析了
- 注入检测之正式注入
从启发式注入里面出来,到第 592 行,进行正式的注入检测,跟进
到第 130 行,获取所有的 payload,后续会根据数据库的信息构建索引,将符合索引的 payload 拿去攻击
往下走,先判断有没有做数据库信息的获取,如果有则跳过,如果没有就先进行上一步的启发式注入
接着根据通过报错得到的数据库信息建立索引,将对应最有效的 payload 拿出来。这些 payloads 会进行 while 循环
第 370 行,通过cleanupPayload()函数对 payload 进行处理,主要功能其实是做了 payload 的标签替换
"AND (SELECT 2*(IF((SELECT * FROM (SELECT CONCAT('qbpxq',(SELECT (ELT(9125=9125,1))),'qxkvq','x'))s), 8446744073709551610, 8446744073709551610)))"
prefix + payload + suffix
最后替换过的 payload 长这样
在 sqlmap 中将payload 分为了三部分,上面生成的 fstpayload 就是中间那部分 prefix 和 suffix 就是对应的,闭合前面的结合以及注释后面的结构,这两个属性主要是从 boundary 中进行获取的,boundary 就是前面加载的boundaries.xml配置文件,用来闭合的,所以这里作为了 prefix 和 suffix
最后的拼接
并分别对 prefix 和 suffix 进行 clean,然后进行组合,组合之后的 payload 就是 reqPayload,然后进行请求
请求完毕的结果经过queryPage()函数来获取界面,但是页面结果是由kb.chars.start和kb.chars.stop包裹着的
当第一次的注入不成功的时候,会不断变更 prefix,suffix,当 prefix 和 suffix 都变更完毕但还是无法注入时,才会变更 payload,取出另一个 payload 出来,直至injectable变量为 true,同时output=1
并且injectable=true
- 爆数据库等操作
经过上一步正式注入的判断,得到的injectable=true参数,才能进行下一步的爆数据库操作. 爆库阶段主要是先经过四个函数处理数据后,再调用action()函数,跟进。
这里已爆库为例,先看–dbs参数有关的这一块,核心函数是getDbs()
先根据后台数据库信息,输出日志
第 133 行,queries就是存放之前初始化queries.xml的变量
首先通过count(schema_name)来获取数据库的个数,然后再通过limit num,1来依次获取数据库名,从queries变量中获取语句之后就会传递到getValue函数
跟进,前面做了一些基础的设置和 payload 的处理与赋值,比如第 401 行的cleanQuery()函数,将语句转换为大写,这里我就不跟进了。直接看关键语句,第 451 行,errorUse()函数
在errorUse()中首先通过正则将 payload 中的各个部分都进行了获取 ,保存到了对应的field当中,最终经过一系列处理,取出了 payload 中的schema_name
跳出getFields()函数,往下,将expression的值经过 replace 操作,赋值给了countedExpression,最终得到的值是’SELECT COUNT(schemaname) FROM INFORMATIONSCHEMA.SCHEMATA’
第 337 行,跟进_oneShotErrorUse()函数,在这一个函数中,sqlmap 对目标网站发包,使用的 payload 为countedExpression,目的是探测数据库个数(count)
具体业务发包在这里
最后将结果传入extractRegexResult()函数中进行正则提取
多线程的方式进行注入,而runThreads()函数调用了errorThread()函数,最终的注入业务还是由errorThread()函数来完成的
跟进一下_errorFields()函数,将每一个表进行 while 循环操作,再通过limitQuery()函数设置最后的Limit语句
最后成功–dbs sqlmap 流程分析结束
0x04 小结
sqlmap 的流程分析需要非常重视这张图,当感觉代码看不下去的时候看一下这张图可以事半功倍。
在审计开始之前也可以看一下utils文件夹下的 python 文件,总体来说流程并不难,看正则的时候其实挺吃力的。
0x05 Reference
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