欢迎光临
我们一直在努力

sqlmap 项目剖析(II)

 

0x00 TL;DR

上文笔者从使用层面将 sqlmap 的基础用法展示出来,本文则通过代码层面对 sqlmap 在进行注入测试的前置步骤进行分析,这些步骤包括:

  • 全局变量初始化
  • 命令行参数解析
  • 全局变量赋值
  • 环境检查
  • 目标连通性检查
  • WAF 探测
  • 页面动态内容提取
  • 参数动态性检测
  • 启发式测试

 

0x01 初始化流程

接下来将从代码层面了解到 sqlmap 在进行注入前的一系列初始化流程。

main 函数简化后的代码:

def main():
    \"\"\"
    Main function of sqlmap when running from command line.
    \"\"\"

    try:
        dirtyPatches()
        resolveCrossReferences()
        checkEnvironment()
        setPaths(modulePath())
        banner()

        args = cmdLineParser()
        cmdLineOptions.update(args.__dict__ if hasattr(args, \"__dict__\") else args)
        initOptions(cmdLineOptions)

        init()
        start()

1.0 versioncheck

在调用 sqlmap.py 时首先会加载 versioncheck 模块,此模块将判断当前环境中所使用的 PYTHON 版本是否小于2.6,并且判断一系列的基础模块的加载是否正常。

# /lib/utils/versioncheck.py

PYVERSION = sys.version.split()[0]

if PYVERSION < \"2.6\":
    sys.exit(\"[%s] [CRITICAL] incompatible Python version detected (\'%s\'). To successfully run sqlmap you\'ll have to use version 2.6, 2.7 or 3.x (visit \'https://www.python.org/downloads/\')\" % (time.strftime(\"%X\"), PYVERSION))

errors = []
extensions = (\"bz2\", \"gzip\", \"pyexpat\", \"ssl\", \"sqlite3\", \"zlib\")
for _ in extensions:
    try:
        __import__(_)
    except ImportError:
        errors.append(_)

if errors:
    errMsg = \"[%s] [CRITICAL] missing one or more core extensions (%s) \" % (time.strftime(\"%X\"), \", \".join(\"\'%s\'\" % _ for _ in errors))
    errMsg += \"most likely because current version of Python has been \"
    errMsg += \"built without appropriate dev packages\"
    sys.exit(errMsg)

1.1 dirtyPatches

dirtyPatches 函数对当前 python 环境中的某些变量进行修改,进行这一系列的修改是为了保证不同环境下都能够正常的使用 sqlmap,这些修改包括:

  1. 修改 HTTP Client 的最大长度为 1024 * 1024
  2. 修改 HTTP Client 默认的 __send_output 方法
  3. 修改 HTTP Client 默认的 LineAndFileWrapper 方法
  4. 修改默认的 os.urandom 方法
def dirtyPatches():
    \"\"\"
    Place for \"dirty\" Python related patches
    \"\"\"
    _http_client._MAXLINE = 1 * 1024 * 1024

    if six.PY3:
        if not hasattr(_http_client.HTTPConnection, \"__send_output\"):
            _http_client.HTTPConnection.__send_output = _http_client.HTTPConnection._send_output

        def _send_output(self, *args, **kwargs):
            if conf.get(\"chunked\") and \"encode_chunked\" in kwargs:
                kwargs[\"encode_chunked\"] = False
            self.__send_output(*args, **kwargs)

        _http_client.HTTPConnection._send_output = _send_output

    if IS_WIN:
        from thirdparty.wininetpton import win_inet_pton

    codecs.register(lambda name: codecs.lookup(\"utf-8\") if name == \"cp65001\" else None)

    if hasattr(_http_client, \"LineAndFileWrapper\"):
        def _(self, *args):
            return self._readline()

        _http_client.LineAndFileWrapper._readline = _http_client.LineAndFileWrapper.readline
        _http_client.LineAndFileWrapper.readline = _

    thirdparty.chardet.universaldetector.MINIMUM_THRESHOLD = 0.90

    match = re.search(r\" --method[= ](\\w+)\", \" \".join(sys.argv))
    if match and match.group(1).upper() != PLACE.POST:
        PLACE.CUSTOM_POST = PLACE.CUSTOM_POST.replace(\"POST\", \"%s (body)\" % match.group(1))

    try:
        os.urandom(1)
    except NotImplementedError:
        if six.PY3:
            os.urandom = lambda size: bytes(random.randint(0, 255) for _ in range(size))
        else:
            os.urandom = lambda size: \"\".join(chr(random.randint(0, 255)) for _ in xrange(size))

1.2 resolveCrossReferences

resolveCrossReferences 函数修改原函数的指针。

def resolveCrossReferences():
    \"\"\"
    Place for cross-reference resolution
    \"\"\"

    lib.core.threads.isDigit = isDigit
    lib.core.threads.readInput = readInput
    lib.core.common.getPageTemplate = getPageTemplate
    lib.core.convert.filterNone = filterNone
    lib.core.convert.isListLike = isListLike
    lib.core.convert.shellExec = shellExec
    lib.core.convert.singleTimeWarnMessage = singleTimeWarnMessage
    lib.core.option._pympTempLeakPatch = pympTempLeakPatch
    lib.request.connect.setHTTPHandlers = _setHTTPHandlers
    lib.utils.search.setHTTPHandlers = _setHTTPHandlers
    lib.controller.checks.setVerbosity = setVerbosity
    lib.utils.sqlalchemy.getSafeExString = getSafeExString
    thirdparty.ansistrm.ansistrm.stdoutEncode = stdoutEncode

这些原函数都是没有实际代码体的(虽然我不太清楚这样做的目的是什么:

1.3 checkEnvironment

checkEnvironment 函数会对当前的环境进行初步检查,包括:

  1. sqlmap 存放的绝对路径中是否包含非 ascii 字符
  2. sqlmap 的版本是否小于1.0(新环境运行着旧脚本)
  3. 全局变量的初始化(当 sqlmap 被作为包导入时)
def checkEnvironment():
    try:
        os.path.isdir(modulePath())
    except UnicodeEncodeError:
        errMsg = \"your system does not properly handle non-ASCII paths. \"
        errMsg += \"Please move the sqlmap\'s directory to the other location\"
        logger.critical(errMsg)
        raise SystemExit

    if LooseVersion(VERSION) < LooseVersion(\"1.0\"):
        errMsg = \"your runtime environment (e.g. PYTHONPATH) is \"
        errMsg += \"broken. Please make sure that you are not running \"
        errMsg += \"newer versions of sqlmap with runtime scripts for older \"
        errMsg += \"versions\"
        logger.critical(errMsg)
        raise SystemExit

    # Patch for pip (import) environment
    if \"sqlmap.sqlmap\" in sys.modules:
        for _ in (\"cmdLineOptions\", \"conf\", \"kb\"):
            globals()[_] = getattr(sys.modules[\"lib.core.data\"], _)

        for _ in (\"SqlmapBaseException\", \"SqlmapShellQuitException\", \"SqlmapSilentQuitException\", \"SqlmapUserQuitException\"):
            globals()[_] = getattr(sys.modules[\"lib.core.exception\"], _)

1.4 setPaths

setPaths 函数会将 sqlmap 的路径信息存入 paths 这个全局变量中。

def setPaths(rootPath):
    \"\"\"
    Sets absolute paths for project directories and files
    \"\"\"

    paths.SQLMAP_ROOT_PATH = rootPath

    # sqlmap paths
    paths.SQLMAP_DATA_PATH = os.path.join(paths.SQLMAP_ROOT_PATH, \"data\")
    paths.SQLMAP_EXTRAS_PATH = os.path.join(paths.SQLMAP_ROOT_PATH, \"extra\")
    paths.SQLMAP_SETTINGS_PATH = os.path.join(paths.SQLMAP_ROOT_PATH, \"lib\", \"core\", \"settings.py\")
    paths.SQLMAP_TAMPER_PATH = os.path.join(paths.SQLMAP_ROOT_PATH, \"tamper\")

    paths.SQLMAP_PROCS_PATH = os.path.join(paths.SQLMAP_DATA_PATH, \"procs\")
    paths.SQLMAP_SHELL_PATH = os.path.join(paths.SQLMAP_DATA_PATH, \"shell\")
    paths.SQLMAP_TXT_PATH = os.path.join(paths.SQLMAP_DATA_PATH, \"txt\")
    paths.SQLMAP_UDF_PATH = os.path.join(paths.SQLMAP_DATA_PATH, \"udf\")
    paths.SQLMAP_XML_PATH = os.path.join(paths.SQLMAP_DATA_PATH, \"xml\")
    paths.SQLMAP_XML_BANNER_PATH = os.path.join(paths.SQLMAP_XML_PATH, \"banner\")
    paths.SQLMAP_XML_PAYLOADS_PATH = os.path.join(paths.SQLMAP_XML_PATH, \"payloads\")

    # sqlmap files
    paths.COMMON_COLUMNS = os.path.join(paths.SQLMAP_TXT_PATH, \"common-columns.txt\")
    paths.COMMON_FILES = os.path.join(paths.SQLMAP_TXT_PATH, \"common-files.txt\")
        ...

最后封装完毕的路径信息如下:

{
    \"SQLMAP_ROOT_PATH\": \"/workspace/code-audit/debug/sqlmap-debug\",
    \"SQLMAP_DATA_PATH\": \"/workspace/code-audit/debug/sqlmap-debug/data\",
    \"SQLMAP_EXTRAS_PATH\": \"/workspace/code-audit/debug/sqlmap-debug/extra\",
    \"SQLMAP_SETTINGS_PATH\": \"/workspace/code-audit/debug/sqlmap-debug/lib/core/settings.py\",
    \"SQLMAP_TAMPER_PATH\": \"/workspace/code-audit/debug/sqlmap-debug/tamper\",
    \"SQLMAP_PROCS_PATH\": \"/workspace/code-audit/debug/sqlmap-debug/data/procs\",
    \"SQLMAP_SHELL_PATH\": \"/workspace/code-audit/debug/sqlmap-debug/data/shell\",
    \"SQLMAP_TXT_PATH\": \"/workspace/code-audit/debug/sqlmap-debug/data/txt\",
    \"SQLMAP_UDF_PATH\": \"/workspace/code-audit/debug/sqlmap-debug/data/udf\",
    \"SQLMAP_XML_PATH\": \"/workspace/code-audit/debug/sqlmap-debug/data/xml\",
    \"SQLMAP_XML_BANNER_PATH\": \"/workspace/code-audit/debug/sqlmap-debug/data/xml/banner\",
    \"SQLMAP_XML_PAYLOADS_PATH\": \"/workspace/code-audit/debug/sqlmap-debug/data/xml/payloads\",
    \"COMMON_COLUMNS\": \"/workspace/code-audit/debug/sqlmap-debug/data/txt/common-columns.txt\",
    \"COMMON_FILES\": \"/workspace/code-audit/debug/sqlmap-debug/data/txt/common-files.txt\",
    \"COMMON_TABLES\": \"/workspace/code-audit/debug/sqlmap-debug/data/txt/common-tables.txt\",
    \"COMMON_OUTPUTS\": \"/workspace/code-audit/debug/sqlmap-debug/data/txt/common-outputs.txt\",
    \"SQL_KEYWORDS\": \"/workspace/code-audit/debug/sqlmap-debug/data/txt/keywords.txt\",
    \"SMALL_DICT\": \"/workspace/code-audit/debug/sqlmap-debug/data/txt/smalldict.txt\",
    \"USER_AGENTS\": \"/workspace/code-audit/debug/sqlmap-debug/data/txt/user-agents.txt\",
    \"WORDLIST\": \"/workspace/code-audit/debug/sqlmap-debug/data/txt/wordlist.tx_\",
    \"ERRORS_XML\": \"/workspace/code-audit/debug/sqlmap-debug/data/xml/errors.xml\",
    \"BOUNDARIES_XML\": \"/workspace/code-audit/debug/sqlmap-debug/data/xml/boundaries.xml\",
    \"QUERIES_XML\": \"/workspace/code-audit/debug/sqlmap-debug/data/xml/queries.xml\",
    \"GENERIC_XML\": \"/workspace/code-audit/debug/sqlmap-debug/data/xml/banner/generic.xml\",
    \"MSSQL_XML\": \"/workspace/code-audit/debug/sqlmap-debug/data/xml/banner/mssql.xml\",
    \"MYSQL_XML\": \"/workspace/code-audit/debug/sqlmap-debug/data/xml/banner/mysql.xml\",
    \"ORACLE_XML\": \"/workspace/code-audit/debug/sqlmap-debug/data/xml/banner/oracle.xml\",
    \"PGSQL_XML\": \"/workspace/code-audit/debug/sqlmap-debug/data/xml/banner/postgresql.xml\",
    \"SQLMAP_HOME_PATH\": \"/.local/share/sqlmap\",
    \"SQLMAP_OUTPUT_PATH\": \"/.local/share/sqlmap/output\",
    \"SQLMAP_DUMP_PATH\": \"/.local/share/sqlmap/output/%s/dump\",
    \"SQLMAP_FILES_PATH\": \"/.local/share/sqlmap/output/%s/files\",
    \"SQLMAP_HISTORY_PATH\": \"/.local/share/sqlmap/history\",
    \"API_SHELL_HISTORY\": \"/.local/share/sqlmap/history/api.hst\",
    \"OS_SHELL_HISTORY\": \"/.local/share/sqlmap/history/os.hst\",
    \"SQL_SHELL_HISTORY\": \"/.local/share/sqlmap/history/sql.hst\",
    \"SQLMAP_SHELL_HISTORY\": \"/.local/share/sqlmap/history/sqlmap.hst\",
    \"GITHUB_HISTORY\": \"/.local/share/sqlmap/history/github.hst\"
}

1.5 banner

banner 函数会输出 sqlmap 内置的 banner。

def banner():
    \"\"\"
    This function prints sqlmap banner with its version
    \"\"\"

    if not any(_ in sys.argv for _ in (\"--version\", \"--api\")) and not conf.get(\"disableBanner\"):
        result = BANNER

        if not IS_TTY or any(_ in sys.argv for _ in (\"--disable-coloring\", \"--disable-colouring\")):
            result = clearColors(result)
        elif IS_WIN:
            coloramainit()

        dataToStdout(result, forceOutput=True)

1.6 cmdLineParser

cmdLineParser 函数用于对命令行中的参数进行解析,使用的是 optparse 这个库,sqlmap 这里使用的是分组(add_argument_group)的方式对参数进行解析,在参数众多的情况下对具有相同用途的参数进行分组是一个不错的选择。

下面是分组后输出帮助信息的示例:

可以发现,此处的 Techniques、Fingerprint等都是一个组,这样能够帮助使用者尽量直观的了解参数的类别。

除此之外,cmdLineParser 还会对 --shell--gui 两个参数进行特殊处理,比如传递了 --gui 参数时会打开一个 GUI 视图:

除了命令行参数的解析外,这里还涉及到部分参数的赋值,比如 conf.skipThreadCheckconf.verbose等。

在参数解析完毕后会将所有的参数合并到 cmdLineOptions 这个全局变量中供后续使用。

1.7 initOptions

initOptions 函数会对几个全局变量进行初始化的操作。

def initOptions(inputOptions=AttribDict(), overrideOptions=False):
    _setConfAttributes() # 初始化 conf 中的参数
    _setKnowledgeBaseAttributes() # 初始化 kb 中的参数
    _mergeOptions(inputOptions, overrideOptions) # 初始化 mergedOptions 中的参数

1.7.0 _setConfAttributes

_setConfAttributes 函数会对全局变量 conf 中的部分参数进行初始化:

def _setConfAttributes():
    \"\"\"
    This function set some needed attributes into the configuration
    singleton.
    \"\"\"

    debugMsg = \"initializing the configuration\"
    logger.debug(debugMsg)

    conf.authUsername = None
    conf.authPassword = None
    conf.boundaries = []
    conf.cj = None
    conf.dbmsConnector = None
    conf.dbmsHandler = None
    conf.dnsServer = None
    conf.dumpPath = None
    conf.hashDB = None
    conf.hashDBFile = None
    conf.httpCollector = None
    conf.httpHeaders = []
    conf.hostname = None
    conf.ipv6 = False
    conf.multipleTargets = False
    conf.outputPath = None
    ...

1.7.1 _setKnowledgeBaseAttributes

_setKnowledgeBaseAttributes 函数会对全局变量 kb 中的部分参数进行初始化(赋初值):

def _setKnowledgeBaseAttributes(flushAll=True):
    \"\"\"
    This function set some needed attributes into the knowledge base
    singleton.
    \"\"\"

    debugMsg = \"initializing the knowledge base\"
    logger.debug(debugMsg)

    kb.absFilePaths = set()
    kb.adjustTimeDelay = None
    kb.alerted = False
    kb.aliasName = randomStr()
    kb.alwaysRefresh = None
    kb.arch = None
    kb.authHeader = None
    kb.bannerFp = AttribDict()
    kb.base64Originals = {}
    kb.binaryField = False
    kb.browserVerification = None

    kb.brute = AttribDict({\"tables\": [], \"columns\": []})
    ...

在此函数中也有一些变量会被赋予随机值,这些变量在后续注入测试中会使用到:

1.7.2 _mergeOptions

_mergeOptions 函数会根据命令行中是否传入了 -c 参数来判断是否需要解析 conf 文件,接着将命令行的解析结果与 conf 文件的解析结果合并。

随后将解析结果合并到全局变量 conf 中,并将当前系统中以 SQLMAP_ 开头的环境变量合并到全局变量 conf中,最后将 conf 的所有参数合并到全局变量 mergedOptions 中(后续代码分析时并没有怎么看到使用到这个变量。

def _mergeOptions(inputOptions, overrideOptions):
    if inputOptions.configFile:
        configFileParser(inputOptions.configFile)

    if hasattr(inputOptions, \"items\"):
        inputOptionsItems = inputOptions.items()
    else:
        inputOptionsItems = inputOptions.__dict__.items()

    for key, value in inputOptionsItems:
        if key not in conf or value not in (None, False) or overrideOptions:
            conf[key] = value

    if not conf.api:
        for key, value in conf.items():
            if value is not None:
                kb.explicitSettings.add(key)

    for key, value in defaults.items():
        if hasattr(conf, key) and conf[key] is None:
            conf[key] = value

            if conf.unstable:
                if key in (\"timeSec\", \"retries\", \"timeout\"):
                    conf[key] *= 2

    if conf.unstable:
        conf.forcePartial = True

    lut = {}
    for group in optDict.keys():
        lut.update((_.upper(), _) for _ in optDict[group])

    envOptions = {}
    for key, value in os.environ.items():
        if key.upper().startswith(SQLMAP_ENVIRONMENT_PREFIX):
            _ = key[len(SQLMAP_ENVIRONMENT_PREFIX):].upper()
            if _ in lut:
                envOptions[lut[_]] = value

    if envOptions:
        _normalizeOptions(envOptions)
        for key, value in envOptions.items():
            conf[key] = value

    mergedOptions.update(conf)

1.8 init

init 函数会对命令行中的部分参数进行处理,并为全局变量的部分参数赋实际值。

def init():
    _useWizardInterface() # 启动引导模式
    setVerbosity() # 设置默认的日志输出详细度
    _saveConfig()  # 保存当前扫描的配置
    _setRequestFromFile() # 解析 request file 的文件内容
    _cleanupOptions() # 为 conf 中的参数赋初值
    _cleanupEnvironment() 
    _purge() # 清空 sqlmap 相关信息
    _checkDependencies() # 检查是否缺失依赖
    _createHomeDirectories() # 创建 output、history 目录
    _createTemporaryDirectory() # 创建临时目录
    _basicOptionValidation() # 验证部分参数值是否符合预期
    _setProxyList() # 解析 proxy file 的文件内容
    _setTorProxySettings() # 设置 tor 代理
    _setDNSServer() # 创建 DNS 服务器
    _adjustLoggingFormatter() # 初始化日志格式化工具
    _setMultipleTargets() # 解析 burp log 的文件内容
    _listTamperingFunctions() # 输出 tamper 的详细信息
    _setTamperingFunctions() # 设置后续要调用的 tamper
    _setPreprocessFunctions() # 设置处理请求的函数
    _setPostprocessFunctions() # 设置处理响应的函数
    _setTrafficOutputFP() # 创建 trafficFile 并获取文件句柄
    _setupHTTPCollector() # 创建 HAR 文件
    _setHttpChunked() # 设置 chunked 
    _checkWebSocket() # 检查 websocket 环境是否正常

    parseTargetDirect() # 解析数据库链接

    if any((conf.url, conf.logFile, conf.bulkFile, conf.requestFile, conf.googleDork, conf.stdinPipe)):
        _setHostname() # 设置 conf 中的 hostname
        _setHTTPTimeout() # 设置请求最大超时时间
        _setHTTPExtraHeaders() # 设置请求的 headers
        _setHTTPCookies() # 设置请求的 cookies
        _setHTTPReferer() # 设置请求的 referer
        _setHTTPHost() # 设置请求的 host
        _setHTTPUserAgent() # 设置请求的 UA
        _setHTTPAuthentication() # 设置请求的认证信息
        _setHTTPHandlers() # 设置对应的请求处理类
        _setDNSCache() # 设置 dns 缓存
        _setSocketPreConnect() 
        _setSafeVisit()
        _doSearch() # 处理 Google Dork 解析
        _setStdinPipeTargets() # 从 pipeline 中获取 targets
        _setBulkMultipleTargets() # 从文本中获取 targets
        _checkTor() # 检查 tor 代理
        _setCrawler() # 设置爬虫信息
        _findPageForms() # 寻找页面中的表单
        _setDBMS() # 设置 DBMS
        _setTechnique() # 设置检测类型

    _setThreads() # 设置线程数
    _setOS() # 设置操作系统类型
    _setWriteFile() # 设置文件写入信息
    _setMetasploit() # 设置 MSF 信息
    _setDBMSAuthentication() # 设置 DBMS 的认证信息
    loadBoundaries() # 加载 Boundaries
    loadPayloads() # 加载 Payloads
    _setPrefixSuffix() # 设置新的 prefix 和sufix
    update() # 更新 sqlmap
    _loadQueries() # 加载 queries

不要看这里调用了一大堆的函数就被吓到了,实际上这里只是会对命令行传入的参数进行一个初步的解析,大部分函数的调用流程是走不进去的。

下面记录一下各个函数对应到命令行中的参数(按调用顺序):

--wizard:_useWizardInterface

-v:setVerbosity

--safe:_saveConfig

-r:_setRequestFromFile

--purge:_purge

--dependencies:_checkDependencies

--tmp-dir:_createTemporaryDirectory

--proxy-file:_setProxyList

--tor:_setTorProxySettings

--dns-domain:_setDNSServer

-l:_setMultipleTargets

--list-tampers:_listTamperingFunctions

--tamper:_setTamperingFunctions

--preprocess:_setPreprocessFunctions

--postprocess:_setPostprocessFunctions

-t:_setTrafficOutputFP

--har:_setupHTTPCollector

--chunked:_setHttpChunked

-d:parseTargetDirect

--timeout:_setHTTPTimeout

--headers:_setHTTPExtraHeaders

--cookie:_setHTTPCookies

--referer:_setHTTPReferer

--host:_setHTTPHost

-A:_setHTTPUserAgent

--auth-*:_setHTTPAuthentication

--safe-*:_setSafeVisit

-g:_doSearch

-m:_setBulkMultipleTargets

--tor*:_checkTor

--crawl-*:_setCrawler

--forms:_findPageForms

--dbms:_setDBMS

--technique:_setTechnique

--threads:_setThreads

--os:_setOS

--file-write:_setWriteFile

--os-pwn/--os-smb/--os-bof:_setMetasploit

--dbms-cred:_setDBMSAuthentication

--prefix/--suffix:_setPrefixSuffix

由于这里只是进行赋值的初始化,并没有涉及什么核心功能,因此对某个参数的初始化流程感兴趣的同学可以直接看对应函数,就不进行分析了。

 

0x02 注入前的准备

在上述 sqlmap 的初始化流程执行完毕后,会在 main 函数中进行一系列的判断后最终进入 start 函数的调用,这部分判断主要是判断是否需要进行爬虫,这个就不进行分析了,单刀直入看看 start 函数中的前置流程。PS:start 函数的代码较长,大概有500行左右,就不留在文中了,读者可以对着代码阅读文章。

start 函数首先会对 --crack-d 两个参数作特殊处理,这两个参数与后续的注入流程无关,一个是暴力破解哈希的,一个是直链数据库进行操作的,因此不进行分析。接着会封装相关数据到 kb.targets 中,这里会处理使用了 -c 参数却没有解析到 target 的情况:

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)

接下来会遍历 kb.targets 中的数据,对每一组数据进行 SQL 注入的测试,下面介绍循环体内的代码实现。

首先当使用了 --check-internet 参数时会调用 checkInternet 函数进行本地网络环境的检查,此函数会请求 http://ipinfo.io/json,如果超过了最大重试次数依旧没办法链接成功,则退出程序。

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 中的部分参数重新赋值(这里不太明白,因为有的数据并没有变化):

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 [])

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

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:])]

接下来会调用 initTargetEnv 函数用于初始化当前 target 的基本环境(重新为 kb 赋值(?)、重新调用了一次 _setDBMS(?)、确认了 data 是否需要 URLENCODE、确认了 CUSTOM_INJECTION_MARK_CHAR 也就是 *的位置)并调用 parseTargetUrl 函数解析了一遍 conf.url(?)。

initTargetEnv()
parseTargetUrl()

接下来会根据 url 中是否存在参数以及是否存在 data 来确定 paramKey 的值:

testSqlInj = False

if PLACE.GET in conf.parameters and not any((conf.data, conf.testParameter)):
    for parameter in re.findall(r\"([^=]+)=([^%s]+%s?|\\Z)\" % (re.escape(conf.paramDel or \"\") or DEFAULT_GET_POST_DELIMITER, re.escape(conf.paramDel or \"\") or DEFAULT_GET_POST_DELIMITER), conf.parameters[PLACE.GET]):
        paramKey = (conf.hostname, conf.path, PLACE.GET, parameter[0])

        if paramKey not in kb.testedParams:
            testSqlInj = True
            break
else:
    paramKey = (conf.hostname, conf.path, None, None)
    if paramKey not in kb.testedParams:
        testSqlInj = True

接下来会判断当前扫描的域名是否在已确认存在漏洞的域名列表中,如果在则询问用户是否需要继续进行扫描:

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

接着会有一大长串代码涉及到对多目标的处理,目前分析了没什么意义,笔者暂且先搁置:

if conf.multipleTargets:
    if conf.forms and conf.method:
        message = \"[%d/%s] Form:\\n%s %s\" % (targetCount, len(kb.targets) if isListLike(kb.targets) else \'?\', conf.method, targetUrl)
    else:
        message = \"[%d/%s] URL:\\n%s %s\" % (targetCount, len(kb.targets) if isListLike(kb.targets) else \'?\', HTTPMETHOD.GET, targetUrl)

    if conf.cookie:
        message += \"\\nCookie: %s\" % conf.cookie

    if conf.data is not None:
        message += \"\\n%s data: %s\" % ((conf.method if conf.method != HTTPMETHOD.GET else None) or HTTPMETHOD.POST, urlencode(conf.data or \"\") if re.search(r\"\\A\\s*[<{]\", conf.data or \"\") is None else conf.data)

    if conf.forms and conf.method:
        if conf.method == HTTPMETHOD.GET and targetUrl.find(\"?\") == -1:
            continue

        message += \"\\ndo you want to test this form? [Y/n/q] \"
        choice = readInput(message, default=\'Y\').upper()

        if choice == \'N\':
            continue
        elif choice == \'Q\':
            break
        else:
            if conf.method != HTTPMETHOD.GET:
                message = \"Edit %s data [default: %s]%s: \" % (conf.method, urlencode(conf.data or \"\") if re.search(r\"\\A\\s*[<{]\", conf.data or \"None\") is None else conf.data, \" (Warning: blank fields detected)\" if conf.data and extractRegexResult(EMPTY_FORM_FIELDS_REGEX, conf.data) else \"\")
                conf.data = readInput(message, default=conf.data)
                conf.data = _randomFillBlankFields(conf.data)
                conf.data = urldecode(conf.data) if conf.data and urlencode(DEFAULT_GET_POST_DELIMITER, None) not in conf.data else conf.data

            else:
                if \'?\' in targetUrl:
                    firstPart, secondPart = targetUrl.split(\'?\', 1)
                    message = \"Edit GET data [default: %s]: \" % secondPart
                    test = readInput(message, default=secondPart)
                    test = _randomFillBlankFields(test)
                    conf.url = \"%s?%s\" % (firstPart, test)

            parseTargetUrl()

    else:
        if not conf.scope:
            message += \"\\ndo you want to test this URL? [Y/n/q]\"
            choice = readInput(message, default=\'Y\').upper()

            if choice == \'N\':
                dataToStdout(os.linesep)
                continue
            elif choice == \'Q\':
                break
        else:
            pass

        infoMsg = \"testing URL \'%s\'\" % targetUrl
        logger.info(infoMsg)

接下来会调用 setupTargetEnv 函数设置 target 环境中某些参数的实际值,除了 _setRequestParams 外其它的基本都和文件、缓存有关,不进行分析:

def setupTargetEnv():
    _createTargetDirs()
    _setRequestParams()
    _setHashDB()
    _resumeHashDBValues()
    _setResultsFile()
    _setAuthCred()
    _setAuxOptions()

这里重点看看 _setRequestParams 函数,此函数的作用就是将所有参数转换为字典后封装到 conf.paramDictconf.parameters中,代码比较长不展开分析,记住它的核心作用即可。

对于 GET 型的参数它会直接转换为字典后存入上面两个变量中,对于 POST 型的参数则会根据正则匹配参数类型(JSON、JSON-LIKE、ARRAY-LIKE、XML、MULTIPART)并将解析结果存储到上面两个变量中(有的情况下会存储到 conf.data 中),做完 GET 与 POST 的解析后会开始处理 headers 相关的参数,此处主要是用于处理注入标记符 * 的,暂且不管。

跳出 setupTargetEnv 函数,接下来会调用 checkConnection 函数对目标发起首次链接,用于确认目标是否可正常访问,在这里会将此次请求的结果作为原始信息,用于后续进行比对,主要赋值代码如下:

kb.originalPage = kb.pageTemplate = threadData.lastPage
kb.originalCode = threadData.lastCode

同时还会调用 checkString 以及 checkRegexp 两个函数结合 --string--regexp 进行页面内容的判断,这里比较让我疑惑的是 sqlmap 继续发请求而不是使用之前 checkConnection 的结果进行判断(?),而且此处永真,也就是说这部分的代码只是用来输出一些警告信息的…这里确实是一个问题,在笔者提交了 issue 后已在最新的 commit 中进行了更改。

接下来会调用 checkWaf 函数检查目标是否有可能存在 WAF,这部分主要是发送一个超长的恶意 payload,组成如下:

payload = \"%d %s\" % (randomInt(), IPS_WAF_CHECK_PAYLOAD)

最终的 payload 如下:

randomInt() + 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 都会选择进行拦截,sqlmap 会新增一个参数并将 payload 作为此参数的值新发一个请求,比较此请求与原始请求的页面相似度,当页面相似度小于0.5(IPS_WAF_CHECK_RATIO)时认为存在WAF。

当没有设置 --string--not-string--regexp 并且 BOOLEAN 在 --technique 中时调用 checkStability 函数进行页面的动态性检测,匹配页面动态内容便于后续更加精准的进行相似度比对。

if not any((conf.string, conf.notString, conf.regexp)) and PAYLOAD.TECHNIQUE.BOOLEAN in conf.technique:
    # NOTE: this is not needed anymore, leaving only to display
    # a warning message to the user in case the page is not stable
    checkStability()

接下来会通过当前注入的位置(PLACE)以及 --level 参数的设置来判断是否需要跳过当前位置的注入:

这里所谓的 PLACE 指的是前文介绍的参数解析中解析出来的各类参数,包括 URL、GET、JSON、ARRAY 等。

接下来会遍历当前PLACE的所有参数,通过一系列的判断来确认当前的参数是否需要进行注入:

这里的判断包括:

  1. 判断是否已经检测过此参数
  2. 判断是否为 --csrf-token 指定的参数
  3. 判断是否在 --randomsize 指定的参数中
  4. 判断是否为 --param-exclude 指定的参数

如果上面几个判断的任何一项不满足,则不会进入后续的 SQL 注入检测流程。

接下来会根据 BOOLEAN 是否在 --technique 来判断是否需要调用 checkDynParam 函数进行参数的动态性检测:

elif PAYLOAD.TECHNIQUE.BOOLEAN in conf.technique or conf.skipStatic:
    check = checkDynParam(place, parameter, value)

    if not check:
        warnMsg = \"%sparameter \'%s\' does not appear to be dynamic\" % (\"%s \" % paramType if paramType != parameter else \"\", parameter)
        logger.warn(warnMsg)

        if conf.skipStatic:
            infoMsg = \"skipping static %sparameter \'%s\'\" % (\"%s \" % paramType if paramType != parameter else \"\", parameter)
            logger.info(infoMsg)

            testSqlInj = False
    else:
        infoMsg = \"%sparameter \'%s\' appears to be dynamic\" % (\"%s \" % paramType if paramType != parameter else \"\", parameter)
        logger.info(infoMsg)

kb.testedParams.add(paramKey)

如果检测出来参数是静态的并且设置了 --skip-static 则会跳过当前参数的 SQL注入检测。

接下来会进行最后一项前置流程”启发性测试”,启发性测试会通过搜集页面错误信息来在实际注入前尝试获取目标的部分信息以及测试其余漏洞是否有可能存在。

check = heuristicCheckSqlInjection(place, parameter)

if check != HEURISTIC_TEST.POSITIVE:
    if conf.smart or (kb.ignoreCasted and check == HEURISTIC_TEST.CASTED):
        infoMsg = \"skipping %sparameter \'%s\'\" % (\"%s \" % paramType if paramType != parameter else \"\", parameter)
        logger.info(infoMsg)
        continue

当启发性测试返回 False (没有搜集到信息)时,会判断是否设置了 --smart 参数,如果设置了则会跳过当前参数的检测。

 

0x03 核心函数实现

3.0 页面动态内容提取

sqlmap 使用 checkStability 函数实现了页面动态性的检测:

此函数首先会调用 queryPage 函数发起请求,并将此请求的响应与原响应进行对比:

secondPage, _, _ = Request.queryPage(content=True, noteResponseTime=False, raise404=False)

kb.pageStable = (firstPage == secondPage)

此时是直接判断两个页面的内容是否相同,如果不同则会询问用户下一步的操作,使用者可以根据 S、R 两个操作分别设置 --string--regex 的值,如果设置了则后续会使用这两作为布尔判断的标准,不会继续使用页面相似度了。

如果使用者选择的是 C 这个操作,则会进入到 checkDynamicContent 函数流程中,并将 firstPage 与 secondPage 传入:

此函数首先会对 firstPage 与 secondPage 进行判空和判断是否超出了最大检测长度的前置步骤,随后会通过 difflib.SequenceMatcher 进行相似度比对,如果相似度小于0.98(UPPER_RATIO_BOUND),则调用 findDynamicContent 函数寻找页面中的动态内容,后面的 while 循环代表不断的寻找,直到相似度不小于0.98为止。

findDynamicContent 函数同样利用的也是 difflib.SequenceMatcher ,通过 get_matching_blocks 函数获取两个字符串中相同的部分,遍历每个 block,如果相同部分的长度小于40(2 * DYNAMICITY_BOUNDARY_LENGTH)则去除它。

以函数注释中给的例子为例,get_matching_blocks 可以匹配到这两个字符串中存在三个相同的部分:

其中的 a、b 分别代表了字符串一和字符串二的 startIndex,而 size 则代表相同部分的长度,对于示例中的字符串来说,它们[0:115]的部分是相同的,随后字符串一[115:115+118]的部分与字符串二[145:145+118]的部分是相同的。

经过上面的匹配与去除后,最终可以确定字符串二[115:145]的部分是动态的:

随后 sqlmap 会将动态内容的前20个字符与动态内容的后20个字符存入 kb 中,后续在进行测试时会首先通过正则去除前缀与后缀之间的内容以确保后相似度检测的准确率。

不过这个算法实际上是有一定的缺陷的,在 JSON 的场景下,也许动态内容的前缀与后缀的长度不到20个,此时 sqlmap 则无法正确匹配到前缀与后缀,因为它设的最低长度为20(DYNAMICITY_BOUNDARY_LENGTH),后续可以修改这个值以适配更多情况。(如果要自己写 sql 注入的扫描插件,肯定是不能直接套这个算法的,在部分情况下的效果并不是特别好

3.1 参数动态性检测

在进行布尔注入测试之前会先对当前注入的参数进行动态性检测,如果检测结果为参数不具备动态性,则根据用户是否设置了 --skip-static 参数来判断当前参数是否依旧需要进行注入测试。

参数的动态性检测是通过 checkDynParam 函数实现的:

此函数首先创建了一个随机整数,后续调用 queryPage 方法将此随机整数替换为实际参数值后发起请求,如果请求结果与原响应的页面相似度大于0.98则认为参数是静态的,反之认为参数是动态的。

上面就是 checkDynParam 函数的整体逻辑,从代码上可以看出其实核心逻辑都在 queryPage 函数中,这个函数在 sqlmap 中具有相当重要的地位,它会根据请求参数的不同来返回不同的返回值,这也是 sqlmap 耦合度太高的具体体现,下面笔者将重点分析 queryPage 函数的实现。

3.1.0 queryPage

queryPage 的代码量较大,下面只记录笔者认为具有分析意义的代码。(按序)

  1. 调用 tamper 修改 payload
  2. 处理 -hpp 的逻辑
  3. 发起请求
  4. 处理 --second-url 的逻辑
  5. 根据入参的不同返回不同的结果

从上述分析中可以看出,如果存在 getRatioValue 但是不存在 content、response 这两个入参的情况下会调用 comparison 函数进行相似度比对,于是接着分析 comparison 函数实现。

3.1.1 comparison

comparison 的函数实现如下:

def comparison(page, headers, code=None, getRatioValue=False, pageLength=None):
    _ = _adjust(_comparison(page, headers, code, getRatioValue, pageLength), getRatioValue)
    return _

这个函数的返回值最外层套了个 _adjust 函数进行处理,先来看看这个函数作了什么:

def _adjust(condition, getRatioValue):
    if not any((conf.string, conf.notString, conf.regexp, conf.code)):
        retVal = not condition if kb.negativeLogic and condition is not None and not getRatioValue else condition
    else:
        retVal = condition if not getRatioValue else (MAX_RATIO if condition else MIN_RATIO)

    return retVal

当不存在 --string--not-string--regexp--code 的设置时,会根据 negativeLogic 的值决定返回相似度判断的结果还是非相似度判断的结果。

接下来看看 _comparison 函数的实现:

此函数首先会对 --string--not-string--regexp 三个设置进行处理:

if any((conf.string, conf.notString, conf.regexp)):
    rawResponse = \"%s%s\" % (listToStrValue(_ for _ in headers.headers if not _.startswith(\"%s:\" % URI_HTTP_HEADER)) if headers else \"\", page)

    # String to match in page when the query is True
    if conf.string:
        return conf.string in rawResponse

    # String to match in page when the query is False
    if conf.notString:
        if conf.notString in rawResponse:
            return False
        else:
            if kb.errorIsNone and (wasLastResponseDBMSError() or wasLastResponseHTTPError()):
                return None
            else:
                return True

    # Regular expression to match in page when the query is True and/or valid
    if conf.regexp:
        return re.search(conf.regexp, rawResponse, re.I | re.M) is not None

随后对 --code 设置进行处理:

if conf.code:
   return conf.code == code

随后会调用 removeDynamicContent 函数去除当前请求响应与原请求响应的动态值(结合前面的页面动态性检测算法),并将原请求响应设置为 seq1:

if not kb.nullConnection:
    page = removeDynamicContent(page)
    seqMatcher.set_seq1(removeDynamicContent(kb.pageTemplate))

接下来会对两个响应进行编码转换(一致性):

if isinstance(seqMatcher.a, six.binary_type) and isinstance(page, six.text_type):
    page = getBytes(page, kb.pageEncoding or DEFAULT_PAGE_ENCODING, \"ignore\")
elif isinstance(seqMatcher.a, six.text_type) and isinstance(page, six.binary_type):
    seqMatcher.a = getBytes(seqMatcher.a, kb.pageEncoding or DEFAULT_PAGE_ENCODING, \"ignore\")

接下来会对 --title 设置进行处理:

if conf.titles:
    seq1 = extractRegexResult(HTML_TITLE_REGEX, seqMatcher.a)
    seq2 = extractRegexResult(HTML_TITLE_REGEX, page)

如果不存在 --title 的设置则通过 getFilteredPageContent 函数去除页面中的所有 HTML 标签(不太明白这一步是为什么?)。

最后将两个页面重新分别设置为 seq1 与 seq2,计算其相似度并保留小数点后三位小数:

seqMatcher.set_seq1(seq1)
seqMatcher.set_seq2(seq2)

if key in kb.cache.comparison:
    ratio = kb.cache.comparison[key]
else:
    ratio = round(seqMatcher.quick_ratio() if not kb.heavilyDynamic else seqMatcher.ratio(), 3)

接下来作一个判断,当相似度处于0.98到0.02这个区间并且临界值不存在时,会将当前计算的相似度作为临界值(matchRatio):

if kb.matchRatio is None:
  if ratio >= LOWER_RATIO_BOUND and ratio <= UPPER_RATIO_BOUND:
    kb.matchRatio = ratio
    logger.debug(\"setting match ratio for current parameter to %.3f\" % kb.matchRatio)

接下来会根据相似度比对的结果与 getRatioValue 的值决定返回值:

if getRatioValue:
  return ratio

elif ratio > UPPER_RATIO_BOUND: # 0.98
  return True

elif ratio < LOWER_RATIO_BOUND: # 0.02
  return False

elif kb.matchRatio is None:
  return None

else:
  return (ratio - kb.matchRatio) > DIFF_TOLERANCE # 0.05

这里有三种情况:

  1. 当相似度大于0.98时认为页面与原页面相似
  2. 当相似度小于0.02时认为页面与原页面不相似
  3. 当相似度与临界值之差大于0.05时认为页面与原页面相似

3.2 启发性测试

sqlmap 调用 heuristicCheckSqlInjection 函数实现启发性测试:

其执行步骤如下:

  1. 如果设置了 --skip-heuristics 则不进行启发性测试
  2. 生成一个包含了 \'\\\"().的 payload 添加到原参数值后,发起请求用于一个新的响应
  3. 调用 wasLastResponseDBMSError 函数来判断是否存在与数据库有关的 ERROR 在响应中,如果存在则认为可能存在 SQL 注入。
  4. 调用 parseFilePaths 函数获取响应中的绝对路径并将结果存放至 kb.absFilePaths 中。
  5. 判断 FORMAT_EXCEPTION_STRINGS 中的字符串是否包含在响应中,这里主要是为了检测是否存在类型转换错误,如果字符串包含在页面中则认为存在类型转换错误,此时会询问用户是否需要继续进行注入测试。
  6. 生成一个包含了\'\"<>\'的 payload 添加到原参数值后,发起请求获取一个新的响应,判断\'\"<>\'是否在响应中,如果存在则认为可能存在 XSS
  7. 通过 FI_ERROR_REGEX 这个正则匹配页面,如果匹配成功则认为可能存在 FI 相关的漏洞。

从上面的分析可以得出一个结论,sqlmap 的启发性测试实际上就是一个漏洞验证的大杂烩,它能够通过响应内容结合正则判断是否存在 SQL 注入、XSS、文件读取(包含)等漏洞,同时还能预先判断是否存在 Casting 的类型转换问题。

 

0x04 请求记录

此处记录 sqlmap 的前置流程发送的请求以及触发请求发送的代码。

  1. lib/controller/checks.py#checkConnection
    GET /?id=1 HTTP/1.1
    Cache-control: no-cache
    User-agent: sqlmap/1.5.11.10#dev (https://sqlmap.org)
    Host: www.qq.com
    Accept: */*
    Accept-encoding: gzip,deflate
    Connection: close
    
  2. lib/controller/checks.py#checkWaf
    GET /?id=1&cKUY=8796%20AND%201%3D1%20UNION%20ALL%20SELECT%201%2CNULL%2C%27%3Cscript%3Ealert%28%22XSS%22%29%3C%2Fscript%3E%27%2Ctable_name%20FROM%20information_schema.tables%20WHERE%202%3E1--%2F%2A%2A%2F%3B%20EXEC%20xp_cmdshell%28%27cat%20..%2F..%2F..%2Fetc%2Fpasswd%27%29%23 HTTP/1.1
    Cache-control: no-cache
    User-agent: sqlmap/1.5.11.10#dev (https://sqlmap.org)
    Host: www.qq.com
    Accept: */*
    Accept-encoding: gzip,deflate
    Connection: close
    
  3. lib/controller/checks.py#checkStability
    GET /?id=1 HTTP/1.1
    Cache-control: no-cache
    User-agent: sqlmap/1.5.11.10#dev (https://sqlmap.org)
    Host: www.qq.com
    Accept: */*
    Accept-encoding: gzip,deflate
    Connection: close
    
  4. lib/controller/checks.py#checkDynParam
    GET /?id=9536 HTTP/1.1
    Cache-control: no-cache
    User-agent: sqlmap/1.5.11.10#dev (https://sqlmap.org)
    Host: www.qq.com
    Accept: */*
    Accept-encoding: gzip,deflate
    Connection: close
    
  5. lib/controller/checks.py#heuristicCheckSqlInjection
    GET /?id=1%27ZHPFYY%3C%27%22%3EezBiMJ HTTP/1.1
    Cache-control: no-cache
    User-agent: sqlmap/1.5.11.10#dev (https://sqlmap.org)
    Host: www.qq.com
    Accept: */*
    Accept-encoding: gzip,deflate
    Connection: close
    
    GET /?id=1%29%20AND%208713%3D6292%20AND%20%281351%3D1351 HTTP/1.1
    Cache-control: no-cache
    User-agent: sqlmap/1.5.11.10#dev (https://sqlmap.org)
    Host: www.qq.com
    Accept: */*
    Accept-encoding: gzip,deflate
    Connection: close
    

 

0x05 Reference

赞(2) 打赏
未经允许不得转载:黑客技术网 » sqlmap 项目剖析(II)
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

微信扫一扫打赏