20 KiB
15|自动化注入神器(二):sqlmap的设计架构解析
你好,我是王昊天。
在上节课中,我们认识了一款自动化注入测试工具sqlmap,并对它的初始化过程有了深入了解。在完成了初始化之后,大部分软件就会开始进入正式的工作流程了,而这节课,我们就将开始学习sqlmap的工作流程。
在介绍sqlmap的工作流程之前,你可以先思考一个问题,我们平时是如何进行SQL注入测试的?如果让你来设计一个自动化注入测试工具,将平时手动实现的SQL注入测试步骤转化为机器的自动化实现,会遇到什么困难吗?
自动化注入的主要难点与人工注入会有一些差异,比如,人工很容易判断目标是否受到waf保护,也可以更好地观测注入结果,而让机器做同样的事情,则是一件不容易的事情。
对于人工而言,你可以发送一个容易被waf拦截的payload,通过这样的方式来观察页面的响应,进而判断waf是否存在。可是机器要如何实现呢?相信学完这篇课程,你可以解决这个问题。
在上一节课中,我们对sqlmap.py中的main函数进行了拆解,具体分析了init函数的主要功能,而init函数之后,就是start函数 。所以在这节课程中,我们会接着上一节课的内容,继续分析sqlmap.py中的main函数,主要讲解start函数实现的功能和方法。
start函数
在系统运行完sqlmap的初始化流程后,就会进入到start函数中,也就是我们这节课需要学习的主要内容。为了方便大家理解,可以将它主要分为四个部分,即准备工作、循环遍历目标、处理输入参数,以及判断waf的存在。
这是我绘制的一幅start函数拆解图,图中解释了四个部分分别做了哪些工作。
为了让你更好的理解start函数的功能,下面我们一起来看看start函数的内容。
@stackedmethod
def start():
"""
This function calls a function that performs checks on both URL
stability and all GET, POST, Cookie and User-Agent parameters to
check if they are dynamic and SQL injection affected
"""
# 这个配置并没有体现在命令行上,属于测试功能,可忽略。
if conf.hashFile:
crackHashFile(conf.hashFile)
if conf.direct:
initTargetEnv()
setupTargetEnv()
action()
return True
# 这个配置设定url和爬虫深度。
if conf.url and not any((conf.forms, conf.crawlDepth)):
kb.targets.add((conf.url, conf.method, conf.data, conf.cookie, None))
if conf.configFile and not kb.targets:
errMsg = "you did not edit the configuration file properly, set "
errMsg += "the target URL, list of targets or google dork"
logger.error(errMsg)
return False
if kb.targets and isListLike(kb.targets) and len(kb.targets) > 1:
infoMsg = "found a total of %d targets" % len(kb.targets)
logger.info(infoMsg)
targetCount = 0
initialHeaders = list(conf.httpHeaders)
for targetUrl, targetMethod, targetData, targetCookie, targetHeaders in kb.targets:
# 这个配置输出目标数量信息。
targetCount += 1
try:
if conf.checkInternet:
infoMsg = "checking for Internet connection"
logger.info(infoMsg)
这里你可以结合代码中的注释进行阅读,接下来我们会详细展开start函数的每一部分,因此这里你只需要对start函数的行为有个大概了解即可。
准备工作
start函数首先会进行一些针对目标的配置工作,配置结束之后,程序将开始利用for循环对每一个目标进行特定的操作,包括,检测网络的连通性,检测是否使用随机UA信息、是否配置post数据、提取检测参数、以及过滤用户排除的目标等。
下面让我们逐一观察它们对应的代码结构,来帮助你加深理解。
循环遍历目标
首先,是用for循环处理每一个目标的代码,可以看到for循环处理目标的代码中,包含了对网络连通性的测试。
for targetUrl, targetMethod, targetData, targetCookie, targetHeaders in kb.targets:
targetCount += 1
try:
# 网络连通性测试
if conf.checkInternet:
infoMsg = "checking for Internet connection"
logger.info(infoMsg)
if not checkInternet():
warnMsg = "[%s] [WARNING] no connection detected" % time.strftime("%X")
dataToStdout(warnMsg)
valid = False
for _ in xrange(conf.retries):
if checkInternet():
valid = True
break
else:
dataToStdout('.')
time.sleep(5)
if not valid:
errMsg = "please check your Internet connection and rerun"
raise SqlmapConnectionException(errMsg)
else:
dataToStdout("\n")
conf.url = targetUrl
conf.method = targetMethod.upper().strip() if targetMethod else targetMethod
conf.data = targetData
conf.cookie = targetCookie
conf.httpHeaders = list(initialHeaders)
conf.httpHeaders.extend(targetHeaders or [])
接下来系统会开始提取一系列数据,这些数据会在HTTP请求中用到,包括请求的网址、cookies信息等。
conf.url = targetUrl
conf.method = targetMethod.upper().strip() if targetMethod else targetMethod
conf.data = targetData
conf.cookie = targetCookie
conf.httpHeaders = list(initialHeaders)
conf.httpHeaders.extend(targetHeaders or [])
完成了数据提取,系统会检查请求参数,这个步骤会分为3个子步骤,分别是配置随机的User-Agent信息、判断用户是否指定了用POST方式上传的数据、以及对目标的url进行合理性检查。
# 配置随机UA信息
if conf.randomAgent or conf.mobile:
for header, value in initialHeaders:
if header.upper() == HTTP_HEADER.USER_AGENT.upper():
conf.httpHeaders.append((header, value))
break
# ...
# 判断是否指定了POST数据
if conf.data:
# Note: explicitly URL encode __ ASP(.NET) parameters (e.g. to avoid problems with Base64 encoded '+' character) - standard procedure in web browsers
conf.data = re.sub(r"\b(__\w+)=([^&]+)", lambda match: "%s=%s" % (match.group(1), urlencode(match.group(2), safe='%')), conf.data)
conf.httpHeaders = [conf.httpHeaders[i] for i in xrange(len(conf.httpHeaders)) if conf.httpHeaders[i][0].upper() not in (__[0].upper() for __ in conf.httpHeaders[i + 1:])]
# ...
# URL合理性检查
initTargetEnv()
parseTargetUrl()
完成这部分工作之后,sqlmap会有一个魔法操作,如果你理解了sqlmap的工作原理,就可以很容易理解这个魔法操作了,但如果你不理解,它一定会带给你不少痛苦,这个魔法操作就是缓存检查。
sqlmap会判断当前的查询在缓存中是否存在,如果存在,就说明sqlmap之前已经进行过同样的检查了,这时它就会跳过当前的检查目标;如果当前查询不存在,才会执行SQL注入攻击。我就曾经对同一目标执行多次SQL注入攻击,然后陷入了这个问题中,排查了很久才得以脱身。
if testSqlInj and conf.hostname in kb.vulnHosts:
if kb.skipVulnHost is None:
message = "SQL injection vulnerability has already been detected "
message += "against '%s'. Do you want to skip " % conf.hostname
message += "further tests involving it? [Y/n]"
kb.skipVulnHost = readInput(message, default='Y', boolean=True)
testSqlInj = not kb.skipVulnHost
if not testSqlInj:
infoMsg = "skipping '%s'" % targetUrl
logger.info(infoMsg)
continue
处理输入参数
此时,sqlmap会进入start函数内部的第四个步骤,也就是处理输入参数。除了设置一些存储信息和配置结果文件,还会针对性地处理一些请求数据,这部分的处理过程,会在setRequestParams函数中进行。
为了大家更好的理解_setRequestParams()
这个函数,我在下面列出了它的部分代码,其中包括了它对请求参数get、post、注入点标记、cookie、header以及csrf-token的处理过程,大家可以结合代码中的注释,更加深入地理解这个函数,看看它是如何处理请求参数的。
def _setRequestParams():
# ...
# 检查请求的get参数,若有将它存储起来,供测试时使用。
if conf.parameters.get(PLACE.GET):
parameters = conf.parameters[PLACE.GET]
paramDict = paramToDict(PLACE.GET, parameters)
if paramDict:
conf.paramDict[PLACE.GET] = paramDict
testableParameters = True
# 检查请求的post参数,若有将它存储起来,供测试使用。
if conf.method == HTTPMETHOD.POST and conf.data is None:
logger.warn("detected empty POST body")
conf.data = ""
if conf.data is not None:
conf.method = conf.method or HTTPMETHOD.POST
# ...
conf.parameters[PLACE.POST] = conf.data
# ...
# 检查是否有get参数、post参数。
if re.search(URI_INJECTABLE_REGEX, conf.url, re.I) and not any(place in conf.parameters for place in (PLACE.GET, PLACE.POST)) and not kb.postHint and kb.customInjectionMark not in (conf.data or "") and conf.url.startswith("http"):
# 若没有找到get参数和post参数,系统会发出警告信息。
warnMsg = "you've provided target URL without any GET "
warnMsg += "parameters (e.g. 'http://www.site.com/article.php?id=1') "
warnMsg += "and without providing any POST parameters "
warnMsg += "through option '--data'"
logger.warn(warnMsg)
message = "do you want to try URI injections "
message += "in the target URL itself? [Y/n/q] "
# ...
# 循环检查目标是否有注入点标记参数。
for place, value in ((PLACE.URI, conf.url), (PLACE.CUSTOM_POST, conf.data), (PLACE.CUSTOM_HEADER, str(conf.httpHeaders))):
if place == PLACE.CUSTOM_HEADER and any((conf.forms, conf.crawlDepth)):
continue
_ = re.sub(PROBLEMATIC_CUSTOM_INJECTION_PATTERNS, "", value or "") if place == PLACE.CUSTOM_HEADER else value or ""
if kb.customInjectionMark in _:
# ...
# 找到了注入点标记参数就将它存储在字典中,供后面测试使用。
conf.paramDict[place]["%s #%d%s" % (header, i + 1, kb.customInjectionMark)] = "%s,%s" % (header, "".join("%s%s" % (parts[j], kb.customInjectionMark if i == j else "") for j in xrange(len(parts))))
# 检查是否有cookie参数,若有就将它存储起来,供后面测试使用。
if conf.cookie:
conf.parameters[PLACE.COOKIE] = conf.cookie
paramDict = paramToDict(PLACE.COOKIE, conf.cookie)
if paramDict:
conf.paramDict[PLACE.COOKIE] = paramDict
testableParameters = True
# ...
# 检查是否有header参数,若有就将它存储起来,供后面测试使用。
if conf.httpHeaders:
for httpHeader, headerValue in list(conf.httpHeaders):
# Url encoding of the header values should be avoided
# Reference: http://stackoverflow.com/questions/5085904/is-ok-to-urlencode-the-value-in-headerlocation-value
if httpHeader.upper() == HTTP_HEADER.USER_AGENT.upper():
conf.parameters[PLACE.USER_AGENT] = urldecode(headerValue)
# ...
# 检查csrf token参数。
#当csrf token参数存在。
if conf.csrfToken:
# 检查get、post、cookie、header values参数中是否有anti-csrf token参数。(anti-csrf token是一个用来防止跨站请求伪造设置的参数。)
if not any(re.search(conf.csrfToken, ' '.join(_), re.I) for _ in (conf.paramDict.get(PLACE.GET, {}), conf.paramDict.get(PLACE.POST, {}), conf.paramDict.get(PLACE.COOKIE, {}))) and not re.search(r"\b%s\b" % conf.csrfToken, conf.data or "") and conf.csrfToken not in set(_[0].lower() for _ in conf.httpHeaders) and conf.csrfToken not in conf.paramDict.get(PLACE.COOKIE, {}) and not all(re.search(conf.csrfToken, _, re.I) for _ in conf.paramDict.get(PLACE.URI, {}).values()):
errMsg = "anti-CSRF token parameter '%s' not " % conf.csrfToken._original
errMsg += "found in provided GET, POST, Cookie or header values"
# 如果这些参数中都没有anti-csrf token参数,那么系统会报错。
raise SqlmapGenericException(errMsg)
# 当csrf token参数不存在。
else:
for place in (PLACE.GET, PLACE.POST, PLACE.COOKIE):
if conf.csrfToken:
break
# 判断注入点标记的参数是否需要csrf token信息。
for parameter in conf.paramDict.get(place, {}):
if any(parameter.lower().count(_) for _ in CSRF_TOKEN_PARAMETER_INFIXES):
message = "%sparameter '%s' appears to hold anti-CSRF token. " % ("%s " % place if place != parameter else "", parameter)
message += "Do you want sqlmap to automatically update it in further requests? [y/N] "
if readInput(message, default='N', boolean=True):
class _(six.text_type):
pass
# 设置csrf token参数。
conf.csrfToken = _(re.escape(getUnicode(parameter)))
conf.csrfToken._original = getUnicode(parameter)
break
检测waf
在完成上述步骤之后,sqlmap就完成了针对注入测试目标的参数配置工作。配置完参数后,sqlmap就可以开始连通性的检测了,通过这一步来判断目标是否可以访问。如果该目标无法连接上,那么sqlmap就会跳过对当前目标的检测;如果可以连接到目标,那么sqlmap就会开始判断该目标是否有waf保护。这是因为waf的存在会对sqlmap的SQL注入测试有很大的影响,所以sqlmap会在注入测试前,判断waf是否存在。
# 逐个目标判断。
for targetUrl, targetMethod, targetData, targetCookie, targetHeaders in kb.targets:
# ...
setupTargetEnv()
# 如果连接不上,跳过当前测试目标。
if not checkConnection(suppressOutput=conf.forms):
continue
# ...
# 如果可以连接上,判断目标是否存在waf。
checkWaf()
进入到checkWaf函数之后,大家可以结合我写的注释,对这个函数进行理解和学习。我们会发现,程序首先会从准备好的文件中,获取容易引起waf响应的代码片段组,然后结合之前设置的注入位置信息,将它组合成一个payload发送给目标。这样就可以获取到该payload响应的值,我们可以将这个值和正常的响应做比较,计算出页面相似度的值。
# 判断waf是否存在。
def checkWaf():
# ...
# 默认设置为没有waf,并且配置容易引起waf拦截的payload。
retVal = False
payload = "%d %s" % (randomInt(), IPS_WAF_CHECK_PAYLOAD)
# 根据注入点的位置,决定payload插入的位置,然后发送测试请求,获取响应的返回值。
if PLACE.URI in conf.parameters:
place = PLACE.POST
value = "%s=%s" % (randomStr(), agent.addPayloadDelimiters(payload))
else:
place = PLACE.GET
value = "" if not conf.parameters.get(PLACE.GET) else conf.parameters[PLACE.GET] + DEFAULT_GET_POST_DELIMITER
value += "%s=%s" % (randomStr(), agent.addPayloadDelimiters(payload))
# ...
try:
# 判断retVal即页面相似度和预设的阈值大小比较关系。
retVal = (Request.queryPage(place=place, value=value, getRatioValue=True, noteResponseTime=False, silent=True, raise404=False, disableTampering=True)[1] or 0) < IPS_WAF_CHECK_RATIO
except SqlmapConnectionException:
retVal = True
finally:
kb.matchRatio = None
# ...
if retVal:
# ...
message = "are you sure that you want to "
message += "continue with further target testing? [Y/n] "
# ...
return retVal
通过比较页面相似度的值和设定的阈值,sqlmap可以判定目标是否被waf保护。如果小于设定的阈值,则代表这两个页面的内容差别很大,sqlmap就会认定目标被waf保护,否则就会认为目标没有waf保护。
这里我们看到了另外一个非常重要的函数Request.queryPage
和一个非常重要的概念,页面相似度。接下来,我们就来一起学习一下什么是页面相似度,而关于Request.queryPage
的功能和页面相似度算法,我们会在下一讲详细学习。
页面相似度
页面相似度,简单来讲,就是两个页面内容相似程度的衡量系数。在sqlmap中,计算页面相似度主体使用的是difflib
模块中的SequenceMatcher
功能,该功能用于比较可哈希类型的序列的相似程度。可哈希类型序列指的是,不可变的数据结构例如字符串、元组等。
这里,我们用一个轻松的小例子,来加深你对页面相似度的理解。
import difflib
a='abcd'
b='ab123'
seq=difflib.SequenceMatcher(None,a,b)
d=seq.ratio()
print(d)
# d=0.44444444...
在这个例子里,我们用SequenceMatcher函数计算了字符串a和字符串b的相似度,计算的结果为“0.4444…”。
这个值是用“2_M/T”这个表达式计算出来的,要想得出结果,我们需要获得变量M和T的值。其中M为a和b相同部分的长度,在这个例子中,因为a
和b
相同部分为ab
,所以M的 值就是2。T则是a和b的长度之和,所以T的值为9。因此,计算结果为“2_2/9=0.4444…”。
相信通过这个例子的学习,你已经可以掌握SequenceMatcher函数的用法,下面让我们趁热打铁,进入到实战训练中,来巩固我们对页面相似度的理解。
实战训练
通过刚才的学习我们知道,sqlmap会运用页面相似度来判断waf存在。为了让你有更加直观的感受,我们可以打开谜团中的“安全狗4.0靶场”进行实战测试。
打开靶场后,我们访问靶场80
端口下的inject.php
路径,这是一个有waf保护的网站,我们需要通过get方式,上传一个名为id
的参数。
我们首先将id
参数的值设为1,正常获得的响应内容如下:
随后我们将id
的参数值设为容易被waf拦截的payload。
*1' and 1=2 union select database(),2 --+*
这样它就会被waf拦截
我们将这两个响应的内容进行记录,然后计算它们的页面相似度。
# 正常响应
talent sec<br /><br/>SELECT first_name,last_name FROM users WHERE user_id = '1';
# waf拦截的响应
您的请求带有不合法参数,已被网站管理员设置拦截!可能原因:您提交的内容包含危险的攻击请求。
计算发现他们的页面相似度为零,这符合我们的预期,即存在waf的拦截,那么使用payload前后的页面相似度就会较低。
总结
这节课,我们学习了sqlmap在工作流程中调用的一个重要的函数start,了解了它的功能和对应的实现方法。
秉持着“知其然,还要知其所以然”的理念,我们除了要知道sqlmap的使用方法,更要了解它的设计思想和工作原理。我们分析了它的源代码,了解到它具有很多功能,这些功能包括,循环处理针对目标配置、测试网络连通性、配置HTTP请求信息、以及判断waf是否存在。
由于判断waf是否存在较难理解,并且存在一个较为生僻的概念,页面相似度,所以我给你介绍了sqlmap是如何判断waf是否存在的。经过分析我们发现,sqlmap会使用易于引起waf拦截的payload来获取响应,并且将它和不使用payload的正常响应进行比较,通过它们的相似度来判断waf存在。如果页面相似度高,就认为目标没有waf的保护,否则我们就认为有waf的保护。最后,我们在实战中验证了这个想法,证实了sqlmap通过页面相似度判断waf存在的可行性。
截止到目前,你已经了解了sqlmap的初始化流程,和针对每个测试目标的配置和检测步骤,下节课,我们将会更加深入地剖析sqlmap的页面相似度算法,并且会正式为你讲解SQL注入测试之前的必经步骤–启发式注入测试。
思考
页面相似度判断的阈值应该与哪些因素相关呢?
欢迎在评论区留下你的思考,我们下节课再见。