SQLMap是一款开源的基于命令行的渗透测试工具,可用于自动化检测和利用SQL注入漏洞,获取数据库服务器的权限。
支持 "Microsoft Access"、"IBM DB2"、"Firebird"、"SAP MaxDB"、"Microsoft SQL Server"、"MySQL"、"Oracle"、"PostgreSQL"、"SQLite"、"Sybase"、"Informix"、"HSQLDB"、"H2"、"MonetDB"、"Apache Derby"、"Vertica"、"Mckoi"、"Presto"、"Altibase"、"MimerSQL"、"CrateDB"、"Cubrid"、"InterSystems Cache"、"eXtremeDB"、"FrontBase" 等25种数据库管理系统。
能够检测和利用6种不同的SQL注入类型,包括:基于布尔的盲注、基于时间的盲注、基于错误信息的注入、基于联合查询的注入、堆叠式查询注入、内联查询注入。
本文通过解读sqlmap源代码分析一个SQL注入漏洞的完整检测流程(只关注检测流程,其他辅助功能大多忽略了)。
使用的软件版本如下:
Python 3.6.8
sqlmap 1.4.3.12
PyCharm Community 2019.3.4
首先在PyCharm中载入sqlmap项目接着浏览 "sqlmap.py" 文件。
在 "sqlmap.py" 文件的前90行中,有很多import语句,这里重点看一下第59行和第60行。
这两行代码从 "lib.core.data" 导入了 "conf" 和 "kb" 这两个全局变量。
这里可以看到这两个全局变量的类型定义为AttribDict对象。
对于AttribDict对象的定义,可以理解为是一种特殊的字典类型。
在后面的分析过程中,这两个全局变量可以说贯穿始终,简单了解一下他们的作用:
全局变量 "conf" 保存了程序运行前通过命令行和/或配置文件等方式传递给程序的选项和设置。
全局变量 "kb" 保存了目标测试对象的状态。
用一个通俗但是可能不够准确的说法来形容:
变量 "conf" 说明了要攻击的目标和一些定制选项,就像一个静态的说明书。
程序根据 "conf" 的内容构造出一个实际的攻击对象,该对象 "kb" 是一个动态实体,初始化完成时它保存的是一个初始状态,随着攻击测试的进行,它的状态可能会发生改变,例如增加一个表示是否存在注入的属性,一个说明注入点位置的属性,一个证明注入漏洞存在的有效载荷字符串的属性等等。
在开始动态分析之前,需要先做一项配置。
在PyCharm的菜单栏中,依次点击 "Run" => "Edit Configurations"
在 "Run/Debug Configurations" 窗口中,添加如图所示的配置。
这里使用的测试目标地址是Acunetix提供的一个专用于安全漏洞测试的虚拟线上商店网站。
在 "sqlmap.py" 文件的第518行处,即 "main()" 函数的入口处添加断点。
主函数开始执行时,会做一些初始化工作,包括检查运行环境,设置资源文件的路径,解析命令行和配置文件中给定的选项等操作。
这些我都不关注,继续往下运行。
第215行是 "start()" 函数的入口,该函数在 "lib.controller.controller" 中定义。
对一个目标的整个攻击测试过程就是在这个 "start()" 函数中定义的,通过10个函数调用来实现,他们都在 "lib.controller.checks" 中定义。
这10个函数不一定都会执行,其中有一些是可选的,还有一些是在符合特定条件时才会执行。
名称 | 功能 | 调用位置 | 可选性 |
checkInternet() | 检测Internet连接状态 | line 303~324 | 可选,默认不调用,可通过指定 "--check-internet" 命令行选项显式调用。 |
checkConnection() | 检测目标连接状态 | line 433 | |
checkString() | 从响应内容中检测指定字符串 | line 433 | |
checkRegexp() | 从响应内容中检测由指定正则表达式模式匹配的字符串 | line 433 | |
checkWaf() | 启发式检测目标WAF/IPS | line 445 | 可选,默认会调用,可通过指定 "--skip-waf" 命令行选项显式禁用。 |
checkNullConnection() | 在无可见的HTTP响应体的情况下检测响应数据长度 | line 447~448 | 可选,默认不调用,可通过指定 "--null-connection" 命令行选项显式调用。 |
checkStability() | 检测目标响应内容的稳定性 | line 455 | |
checkDynParam() | 检测请求参数是否为动态参数 | line 554 | |
heuristicCheckSqlInjection() | 启发式检测SQL注入漏洞 | line 577 | |
checkSqlInjection() | 检测SQL注入漏洞 | line 588 |
函数 "checkInternet()" 的实现很简单,向地址 "http://ipinfo.io/json" 发起HTTP请求,再试图从返回的JSON响应内容中提取 "ip" 字段的值。
如果这个操作成功,就认为当前拥有正常的网络连接环境。
在 "start()" 函数的第307到324行中,定义了,如果 "checkInternet()" 函数的检查结果为False的话,将会进行重试,次数可由 "--retries" 命令行选项来指定,默认为3次。
如果重试之后依然如故,则会提示用户检查自己的网络连接状态。
函数 "checkConnection()" 检测目标是否能够正常访问并返回响应。
函数 "checkString()" 和 "checkRegexp()" 用来在响应数据中搜索自定义内容,他们的特点是,当没有显式指定 "--string" 和 "--regexp" 命令行选项时,他们将会直接返回True
这个特性从逻辑上说有些奇怪,即是说,如果没有指定待搜索的目标字符串,就按照搜索成功处理。
结合 "start()" 函数的第433行会发现,这两个字符串搜索函数的奇怪特性造就了一个巧妙的效果。
这里的判断逻辑是这样的,当目标能够正常访问,且能够在响应内容中搜索到自定义的标志字符串时,就认为对目标的访问状态是正常的。
反之,如果显式指定了标志字符串,即用户认为这个字符串是必须存在的,但是在响应中却没有找到,就说明虽然与目标的连接正常,但是不能获得应有的响应数据,不具备进一步测试的条件。
由 "lib/controller/checks.py" 文件第1577行可知,函数成功执行之后,会将响应数据存储为模板,供后续检测过程使用。
函数 "checkWaf()" 检测WAF/IPS的技术原理可以参考如下NSE脚本:
https://seclists.org/nmap-dev/2011/q2/att-1005/http-waf-detect.nse
由 "lib/controller/checks.py" 文件第1386~1394行可知,函数生成一个Payload字符串,内容是一个随机整数加一个空格加一个敏感词字符串。
该敏感词字符串的定义在 "lib/core/settings.py" 文件的第620行可以找到,就是一些典型的攻击请求关键字,用来触发WAF/IPS拦截。
# Payload used for checking of existence of WAF/IPS (dummier the better) IPS_WAF_CHECK_PAYLOAD = "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')#"
之后,生成一个随机字符串作为参数名,将上一步生成的Payload字符串作为参数值,将他们插入到请求中发送给服务器。
接着,将服务器返回的响应数据,与之前 "checkConnection()" 函数获取的原始响应模板做比对,来判断是否存在WAF/IPS拦截。
这里要引入一个关于序列相似度的概念,sqlmap使用了Python标准库中 "difflib" 模块 "SequenceMatcher" 类的 "ratio()" 方法。
该方法会返回一个介于 0.0~1.0 之间的浮点数,用来表示两个序列的相似度。
假设T是两个序列中元素的总数量,M是匹配的数量,即 2.0 * M / T
如果两个序列完全相同则该值为 1.0
如果两个序列完全不同则该值为 0.0
try: 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
由 "lib/controller/checks.py" 文件第1405~1407行可知,如果定制了攻击关键字的请求获得的响应与原始请求获得的响应之间的ratio值小于0.5或者连接被阻断,都会被视为触发了WAF/IPS拦截。
函数 "checkNullConnection()" 可以用来优化基于布尔值的盲注测试。
技术原理可以参考如下文章:
http://www.wisec.it/sectou.php?id=472f952d79293
它通过使用 HEAD 方法或者配合了 Range 请求头的 GET 方法向目标发起HTTP请求,要求目标服务器在响应数据中包含 Content-Length 响应头的同时不要包含响应体内容,以期达到节省带宽和提升速度的目的。
有的时候,完全相同的请求数据,也可能会获得不完全相同的响应结果。
比如说,响应中包含当前时间戳,或者随机选择的广告数据之类的情况。
这种时候,两次相同请求获取的响应结果之间会存在一些差异,这种差异可能会影响程序的判断。
这里用稳定性的概念来表示此类差异的程度。
函数 "checkStability()" 引用了ratio值0.98作为分界线,参考 "lib.core.settings.UPPER_RATIO_BOUND" 的定义。
由 "lib/controller/checks.py" 文件第1203~1225行可知,如果两次相同请求所获响应之间的相似度小于等于0.98的话,就要启动进一步的处理。
也就是再重试多次请求,次数可由 "--retries" 命令行选项来指定,默认为3次。
如果结论仍然相同,就认为该请求的不稳定性比较强,也就是 "too dynamic" 状态,这时会提示用户需要通过 "--text-only" 等命令行选项显式指定判断依据。
否则的话,就认为该请求的不稳定性还可以接受,也就是 "heavily dynamic" 状态,这时会通过调用 "findDynamicContent()" 函数找出对判断起到干扰作用的动态内容并记录下来,后续在做页面比对时会屏蔽他们。
函数 "checkDynParam()" 通过随机化参数的值,检查其是否会对响应数据产生影响,判断其是否为动态参数。
可以通过显式指定 "--skip-static" 命令行选项使程序跳过对疑似非动态参数的测试,以提升检测速度。
# Strings for detecting formatting errors FORMAT_EXCEPTION_STRINGS = ("Type mismatch", "Error converting", "Please enter a", "Conversion failed", "String or binary data would be truncated", "Failed to convert", "unable to interpret text value", "Input string was not in a correct format", "System.FormatException", "java.lang.NumberFormatException", "ValueError: invalid literal", "TypeMismatchException", "CF_SQL_INTEGER", "CF_SQL_NUMERIC", " for CFSQLTYPE ", "cfqueryparam cfsqltype", "InvalidParamTypeException", "Invalid parameter type", "Attribute validation error for tag", "is not of type numeric", "<cfif Not IsNumeric(", "invalid input syntax for integer", "invalid input syntax for type", "invalid number", "character to number conversion error", "unable to interpret text value", "String was not recognized as a valid", "Convert.ToInt", "cannot be converted to a ", "InvalidDataException", "Arguments are of the wrong type")
函数 "heuristicCheckSqlInjection()" 通过检查响应页面是否包含明显的报错信息,来初步判断存在漏洞的可能性。
它尝试从报错信息中匹配不同的Web应用平台和DBMS的特征字符串,以优化后续的测试工作。
paramType = conf.method if conf.method not in (None, HTTPMETHOD.GET, HTTPMETHOD.POST) else place if value.lower() in (page or "").lower(): infoMsg = "heuristic (XSS) test shows that %sparameter '%s' might be vulnerable to cross-site scripting (XSS) attacks" % ("%s " % paramType if paramType != parameter else "", parameter) logger.info(infoMsg) for match in re.finditer(FI_ERROR_REGEX, page or ""): if randStr1.lower() in match.group(0).lower(): infoMsg = "heuristic (FI) test shows that %sparameter '%s' might be vulnerable to file inclusion (FI) attacks" % ("%s " % paramType if paramType != parameter else "", parameter) logger.info(infoMsg) break
它还对针对是否存在跨站脚本XSS和文件包含FI漏洞做简单的检查。
# Parse test's <request> comment = agent.getComment(test.request) if len(conf.boundaries) > 1 else None fstPayload = agent.cleanupPayload(test.request.payload, origValue=value if place not in (PLACE.URI, PLACE.CUSTOM_POST, PLACE.CUSTOM_HEADER) and BOUNDED_INJECTION_MARKER not in (value or "") else None) for boundary in boundaries: injectable = False # Skip boundary if the level is higher than the provided (or # default) value # Parse boundary's <level> if boundary.level > conf.level and not (kb.extendTests and intersect(payloadDbms, kb.extendTests, True)): continue # Skip boundary if it does not match against test's <clause> # Parse test's <clause> and boundary's <clause> clauseMatch = False for clauseTest in test.clause: if clauseTest in boundary.clause: clauseMatch = True break if test.clause != [0] and boundary.clause != [0] and not clauseMatch: continue # Skip boundary if it does not match against test's <where> # Parse test's <where> and boundary's <where> whereMatch = False for where in test.where: if where in boundary.where: whereMatch = True break if not whereMatch: continue # Parse boundary's <prefix>, <suffix> and <ptype> prefix = boundary.prefix if boundary.prefix else "" suffix = boundary.suffix if boundary.suffix else "" ptype = boundary.ptype # Options --prefix/--suffix have a higher priority (if set by user) prefix = conf.prefix if conf.prefix is not None else prefix suffix = conf.suffix if conf.suffix is not None else suffix comment = None if conf.suffix is not None else comment # If the previous injections succeeded, we know which prefix, # suffix and parameter type to use for further tests, no # need to cycle through the boundaries for the following tests condBound = (injection.prefix is not None and injection.suffix is not None) condBound &= (injection.prefix != prefix or injection.suffix != suffix) condType = injection.ptype is not None and injection.ptype != ptype # If the payload is an inline query test for it regardless # of previously identified injection types if stype != PAYLOAD.TECHNIQUE.QUERY and (condBound or condType): continue
函数 "checkSqlInjection()" 结合 "dataxml" 和 "dataxmlpayloads" 目录下的XML文件,使用"Boolean-based blind"、"Time-based blind"、"Error-based"、"UNION query-based"、"Stacked queries"、"Inline queries" 等多种技术构造注入测试语句对目标进行检测。