
ctfshow 的 web 入门 SQL 注入模块前 170 题基本是“给一个注入点你负责闭合、联合查询、拿 flag”的直球题大家刷起来都挺顺。但从 171 题开始画风突然就变了明明是一个再普通不过的 select加个空格就查不出数据明明知道 flag 就躺在表里select 却被过滤得干干净净。这个区间的 43 道题说难不算特别难但它把 SQL 注入里真正常用的绕过思路全部串了一遍从报错注入、盲注、堆叠注入到无列名注入、文件读写几乎覆盖了 MySQL 注入的绝大部分考点。如果你正好卡在 171-213 之间或者刷完之后想回头梳理一遍“这些题到底在考什么”这篇博文应该能帮到你。我会按知识点体系来讲不打算一道题一道题贴答案。题目编号是死的人脑是活的搞清楚每道题背后的原理之后你会发现 171 到 213 不过就是几种绕过姿势的排列组合。1. 171-213 这批题到底在考什么先看清题型地图1.1 我刷完之后的主观分段ctfshow 的 web 入门 SQL 注入模块题目是按难度递增排的。171 往前大多是基础闭合和 union 注入从 171 开始题目里开始出现明确的过滤条件有的直接告诉你“过滤了空格”有的把 or、and 给禁了再往后走就是盲注、堆叠、无列名、文件读写这些稍微进阶一点的东西。我个人的主观分段大致是这样171-185 左右以报错注入为主重点考察 updatexml、extractvalue 这类函数的使用偶尔混着简单的空格过滤。186-195 左右进入过滤绕过区空格、注释符、关键字开始被批量禁用你需要掌握各种替代写法。196-205 左右盲注区查询结果既不回显也不报错只能靠页面真假或响应时间来判断。206-213 左右综合区堆叠注入、预处理语句、无列名注入、文件读写全都有可能上场一道题往往需要组合两三招才能打通。这只是一个基于刷题感受的粗略划分不是官方定义但没必要纠结具体题号的边界。真正的重点在于你想往下刷就必须把上面几类姿势全部吃透。1.2 环境准备浏览器插件、Burp、本地 MySQL刷这类题我建议不要裸用浏览器。不是不能用而是效率太低。我自己常年用 Firefox HackBar 的组合HackBar 可以快速改 URL 参数、直接 POST 数据、还能对 payload 做 URL 编码比手动一个字符一个字符改地址栏舒服太多了。Burp Suite 也是必备的很多题目真正的请求参数不是 GET 明文而是 POST 或者 JSON 格式。遇到热搜词里提到的“参数加密”类题目你得先抓包看请求参数长什么样再用 Burp 的 Repeater 去重放和篡改。没有 Burp 的话这类题基本没法下手。另外强烈建议在你自己的电脑上装一个本地 MySQL权限随便折腾专门用来验证 payload。有一次我卡在某个过滤空格和注释符的题目上怎么都绕不过去后来把题目环境里的 SQL 拿到本地一跑发现是我把/\*和/*的写法搞混了。本地验证 payload 的语法是否正确能帮你排除掉一大半“到底是题目过滤还是我 payload 写错”的困惑。1.3 第一次拿到题目时的“三步试探法”很多新手拿到题就急着往参数里塞 or 11#这不对。万能密码这类 payload 只在登录框和搜索框场景下有效而且它的本质是“闭合掉前面条件再用 or 让条件恒真”。如果连闭合方式都没确认你塞进去的 or 可能只是拼在字符串中间毫无意义。我自己拿到一道注入题前五分钟只做三件事探测闭合方式参数后面分别加、、直接改成数字、加)、加))看页面是否报错、是否回显异常。报错信息里如果出现了 SQL 片段那就等于题目直接把后端代码暴露给你了。测试过滤规则随便构造一个包含空格、or、select、#、--的请求逐个加进去观察有哪些被过滤、过滤后是直接空白还是被替换成其他内容。这一步决定你后面要不要做绕过以及用什么姿势绕过。判断注入点用途如果参数是用来查列表的比如id1优先试 union如果是登录框优先试万能密码和报错注入如果是搜索框优先试盲注。这几步做完题目基本已经走完一半了。2. 报错注入三兄弟的实战差异updatexml、extractvalue 与 floor2.1 报错函数为什么能把数据“带”出来报错注入最核心的原理不是让 SQL 报错而是让 MySQL 在执行函数的过程中把我们想查的数据当成“错误信息”输出到页面上。以 updatexml 为例select updatexml(1, concat(0x7e, (select database()), 0x7e), 1);updatexml 的第二个参数要求是一个合法的 XPath 路径如果格式不对MySQL 就会把实际传入的参数内容放进错误信息里返回。我们用concat把要查询的结果和0x7e也就是~符号拼在一起由于~在 XPath 里肯定不合法数据库就会把整串东西报出来。等于你给数据库出了一道“你查一下这个结果然后故意报错告诉我”的题目。extractvalue 也是一样的逻辑select extractvalue(1, concat(0x7e, (select group_concat(table_name) from information_schema.tables where table_schemadatabase()), 0x7e));floor 报错则完全不一样它利用的是count(*)group by时主键冲突产生的 duplicate entry 报错payload 长这样select count(*) from information_schema.tables group by concat(database(), floor(rand(0)*2));在 CTF 题目里updatexml 和 extractvalue 是出现频率最高的因为写法直观、报错输出干净。floor 报错在旧一点的 MySQL 版本里很好用但 8.0 之后部分场景下会被限制实际题目里用得越来越少。2.2 三种函数的 payload 写法和报错格式区别这里我给你整理一个可以直接套用的模板注意替换表名和列名函数标准写法报错格式主要限制updatexmlupdatexml(1,concat(0x7e,(select ...),0x7e),1)XPATH syntax error: ~flag~输出长度不超过 32 字符extractvalueextractvalue(1,concat(0x7e,(select ...),0x7e))XPATH syntax error: ~flag~同样 32 字符限制floorgroup by concat(...,floor(rand(0)*2))Duplicate entry flag1 for key group_key对表数据量和 MySQL 版本敏感坑点几乎都集中在 updatexml 和 extractvalue 上它们的报错信息有 32 字符限制超过就截断。如果 flag 字段值是 32 位以上CTF 的 flag 经常就 30 到 40 个字符你直接查整段就会少一截必须配合substr或mid分段取and updatexml(1,concat(0x7e,substr((select flag from flag),1,20),0x7e),1)-- and updatexml(1,concat(0x7e,substr((select flag from flag),21,40),0x7e),1)--我第一次刷到这类题时就是没注意这个限制一直以为是自己 payload 写错了查出来永远只有flag{...前半段后面全被截掉。后来习惯一律先length(flag)看长度再根据长度决定要分几次取。2.3 题目里最容易翻车的闭合与括号问题报错注入最常见的翻车点不是函数写错而是参数闭合没搞清楚。参数明明是admin你却只写一个去闭合SQL 语句就多出一个引号导致函数完全没执行。我自己习惯的做法是先在参数后面把闭合符补齐再用and或or把原条件接回来最后才写报错函数。比如后端语句是select * from users where username$name那我输入admin后还需要把后面的引号“消掉”才能在逻辑上插入内容admin and updatexml(1,concat(0x7e,database(),0x7e),1) and 11最后面的and 11才是关键它是为了和原本 SQL 末尾的配对否则语句尾部会多出一个未闭合的引号。很多人报错注入报出来的不是 XPath error而是 You have an error in your SQL syntax十有八九就是这里少拼了一个配对项。如果是数字型参数事情会简单很多直接id1 and updatexml(...)不需要考虑引号配对。所以第一步判断“字符型还是数字型”真的值得你多花一分钟。3. 空格与关键字被过滤时的替代方案注释符、括号与编码的适用边界3.1 注释符/**/替代空格的原理和坑进入 186 题之后你会发现最常见的过滤规则不是关键字而是空格。很多后端代码会用一句简单的preg_replace(/\s/, , $input)把你输入的所有空白字符全部干掉不管是空格、tab 还是换行。这时候最本能的替代就是注释符select/**/database(); select/**/flag/**/from/**/flag;/**/在 MySQL 里会被解析成一个空格所以上面的语句和带空格的版本完全等价。嵌套多层也行比如select/**/1,2,3/**/union/**/select/**/*/**/from/**/flag只要不过度复杂MySQL 都能正常解析。但这个方法有个致命问题如果过滤规则连/和*一起禁掉或者干脆把注释符整体替换成空字符串/**/就直接失效了。我在一些题里看到过滤逻辑是先把/*替换掉再校验空格那就只能换括号方案。3.2 括号替代空格的真正规则很多人不知道MySQL 里很多关键字和后面的参数之间可以用括号直接连接不需要空格。经典的例子select(database()); select(1),(2),(3);但注意这种写法仅适用于“函数调用”或“表达式列表”的场景不适用于所有地方。比如union select中间的空格很难用括号替代因为 union 和 select 之间不是调用关系from flag中间也一样。括号方案在报错注入、盲注、以及某些带where的拼接里非常好用但在 union 注入里常常失灵。所以我一向的做法是空格被过滤时先试/**/再试%0a换行、%09制表符、%0b垂直制表符这类 URL 编码后的空白字符最后才敢用括号去替换局部空格。不同题目的过滤正则不一样这几种总有一款能过。3.3 内联注释与编码写法的实测效果MySQL 支持一种特殊的内联注释/*!select*/它被 MySQL 解析时会当作select执行而不是普通注释。如果你遇到过滤规则恰好只写了select和union这些关键字但没处理/*! */注释符就可以这样绕过id1 union/*!select*/1,2,3--还有一种思路是 URL 编码或 hex 编码。%27对应单引号%20对应空格如果你是在 URL 地址栏里手工测试浏览器的自动解码行为有时会让后端拿到已经解码的内容过滤规则反而落到了原始字符串上。但具体情况必须抓包才能确认不能想当然。比较实用的是把关键字拆成多段再拼接这个我放在第 5 章讲堆叠注入时详细说因为在预处理语句里配合concat拆关键字是真正的常规操作。4. 布尔盲注与时间盲注从手工到脚本的效率跃迁4.1 判断盲注存在的几个关键信号当你发现参数无论怎么改页面返回的东西都长得一样既没有报错也没有查询结果回显先别急着放弃。盲注的几个信号是输入一个真条件和假条件页面内容有细微差别比如存在“查询成功”/“查询失败”标志、显示结果条数不同、甚至只是多了一个空白字符。输入 sleep 函数后页面响应时间明显变长。报错函数被过滤但注入确实存在执行报错会加载页面失败或变慢。布尔盲注最基础的就这一句id1 and ascii(substr(database(),1,1))100--如果页面正常返回结果说明database()第一个字符的 ASCII 码大于 100接下来二分法进一步缩小范围如果页面异常或空白就说明条件为假。这样一次只能确定一个“比较结果”所以必须靠自动化或至少是手动二分才能高效拿到完整数据。4.2 用二分法和 substring 快速提数据盲注最忌讳一个字符一个字符从 0 到 127 穷举太慢了。我用二分法每猜一个字符最多 7 次请求就能确定其 ASCII 码值。流程是这样先用length(database())确定数据库名长度。对第 i 个字符用ascii(substr(database(),i,1))和中间值 64、96、112... 依次比较把区间一分为二。确定完所有字符后再同理去查表名、列名、字段值。在 Python 里写个几十行的小脚本配合 requests 发送请求每轮判断页面是否存在某个特征字符串就能实现自动盲注。我自己的半自动脚本大概是这个思路import requests url http://target/query.php def check(condition): payload f1 and {condition}-- r requests.get(url, params{id: payload}) return 正常页面里出现的标志字符 in r.text def get_char(pos): left, right 32, 127 while left right: mid (left right) // 2 if check(fascii(substr(database(),{pos},1)){mid}): left mid 1 else: right mid return chr(left)注意requests 默认超时时间很短盲注题并发一多很容易超时建议requests.get(..., timeout8)。另外把空格换成/**/或%0a再做二分这样过滤空格也能跑。4.3 时间盲注中 sleep 换成 benchmark 的场景时间盲注是在你连“真假页面差异”都看不到的情况下最后的手段。基础 payloadid1 and if(ascii(substr(database(),1,1))100,sleep(3),0)--页面延迟 3 秒说明条件为真不延迟说明为假。这个方案简单粗暴但它有个明显弱点如果题目过滤了sleep或者把if也给禁了就要换等价写法。最常用的替代品是benchmarkid1 and if(ascii(substr(database(),1,1))100,benchmark(10000000,md5(a)),0)--benchmark会重复执行后面的表达式一百万次产生足够的 CPU 耗时来模拟 sleep 的效果。实测下来10000000次 md5 在本地大概会延迟两到三秒但不同机器性能差异很大你需要先在自己的环境里校准次数。时间盲注脚本里必须把阈值设得比正常延迟高一倍以上否则网络抖动会让你判断全错。4.4 参数加密类的盲注题怎么处理ctfshow 里还有一类题热搜词里也提到了“参数加密”其实盲注的 payload 本身没变化变的是传输层。你抓包会发现参数从id1变成了类似idZWQzMjE的字符串这时后端是先解密或解码参数再拿去拼 SQL。解题的思路是先尝试识别加密方式常见的有 base64、十六进制、AES、md5 后截断等。我遇到 base64 编码的题直接把 payload 编码成 base64 放进去就能通。遇到 AES 加密的题如果密钥写死在浏览器端 JS 里就打开控制台看前端代码找加密函数把 payload 用同样的加密流程处理后再提交。这一类题不是 SQL 注入本身难而是“你能不能想到参数被加工过”抓包永远是第一步。5. 堆叠注入与预处理语句不止是分号5.1 堆叠注入能用什么不能用什么大部分注入点都是把参数拼进一条 SQL 语句执行比如后端是select * from user where id$id但你输入的参数如果包含分号某些数据库连接方式允许你在同一个查询里执行多条语句这就给了堆叠注入的机会。比如id1;select sleep(3);--做题时最直观的判断方法是在参数后面加一句show tables或show databases如果页面能显示出表名或数据库名说明后端允许堆叠执行。如果不能那堆叠注入这条路就不通老老实实用 union 或盲注。但要注意堆叠注入在 CTF 里主要不是用来“查数据”的。因为即便能执行第二条语句第二条语句的结果通常也不会回显到页面上你执行select flag from flag也看不到任何东西。它的真正价值在于show、set、handler、create、update、delete这类用 union 无法实现的命令都可以通过堆叠去执行。5.2 set prepare execute绕过 select/where 过滤的组合拳堆叠注入最经典的进阶用法是预处理语句。如果题目过滤了select、union、where这些关键字普通注入想拿数据基本没戏但通过预处理语句你可以把过滤后的关键字用字符串拼接还原再动态执行。标准 payload 长这样1;set sqlconcat(sel,ect flag fr,om flag);prepare stmt from sql;execute stmt;--为什么中间要用concat拆字符串因为如果直接在set sqlselect flag from flag里写明文select会被过滤规则替换掉等执行时已经变成不完整语句了。而concat(sel,ect ...)在请求参数里不包含完整的select字符串过滤规则匹配不到等 set 执行完sql 变量里才是完整的 SQL。这种组合在 206-210 区间的题里特别常见。你可以把被过滤的表名、字段名也拆开比如fl,ag拼成flag。实测下来只要过滤逻辑是普通字符串替换而非词法分析这招基本都能过。5.3 handler 命令读取数据的另类姿势堆叠注入还能执行handler命令。handler 是 MySQL 提供的一种底层逐行访问数据的接口它不是select所以很多直接过滤 select 的题目不会拦它。完整链路是1;handler flag open;handler flag read first;--handler flag open等于打开名为 flag 的表handler flag read first读取第一行数据。如果数据有多行继续执行handler flag read next可以逐行往后取。配合 limit 或 where 做不到但拿到第一行往往已经足够出 flag 了。这个姿势的局限是 handler 在 MySQL 8.0 之后被移除了只在 5.7 及更老的版本及部分分支版本里可用。ctfshow 的题目环境大多比较经典所以它依然是一个值得记下来的绕过方案。遇到select被过滤得死死的题先别急着上盲注把 handler 往 payload 里塞一下往往比盲注省事十倍。6. 无列名注入与 join 报错当你想查的列名被藏起来6.1 无列名注入的核心原理派生表做到 209 之后有些题会把你查列名最常用的information_schema给过滤掉或者干脆让你在题目环境里无法访问系统库。这时候常规流程就断了你连 flag 表里有几个字段、字段都叫什么都不清楚怎么把它查出来无列名注入的思路是“不查列名直接用位置关系取数据”。SQL 里允许把一条 select 的结果当作一个临时表来用这个临时表叫派生表必须要有别名。派生表的列名可以直接由 select 里的常量自动生成比如select 1,2,3这个结果集的三列默认就叫1、2、3。如果把后面的查询结果和它 union 起来外面再用一个 select 去取就绕开了真实的列名select 1 from (select 1,2,3 union select * from flag)a;关键在于外面查询里用的是倒引号包起来的数字1而不是 flag 表的原始列名。union 的规则决定了前后两个 select 的列数必须一致所以select 1,2,3里的 3 是列数占位符列数不对就报错列数对上了第一列的数据就会被当作1这一列提取出来。6.2 三种无列名注入 payload 的演进我最早用的一版是上面那种“倒引号数字”写法在 MySQL 里很稳。但有些题目环境的过滤规则足够变态把反引号也给过滤了这时候还有替代方案select x from (select 1,2,3 union select * from flag)x;给派生表起个别名x然后直接select x。注意这里的x不是列别名而是指整个派生表MySQL 里允许把一行数据作为一个整体引用在某些场景下可以直接输出第一列的值。这招不如反引号版本通用但值得记着。再有就是“子查询嵌套”写法适用于外部查询不能直接 union 的情况select (select(1)from(select 1,2,3 union select * from flag)a limit 1);括号层级多了之后视觉上很乱我建议在本地 MySQL 里先把三层嵌套调通再往上套过滤拼接不然很容易漏括号。6.3 join 报错猜列名还有一种“另类”无列名玩法是 join 报错猜列名。MySQL 的join ... using(字段)语法要求两张表都必须包含 using 里指定的字段。如果你构造一个派生表和一个未知表做 join并指定一个猜测的列名如果猜错了数据库会直接报“using column does not exist”之类的错误页面就会给出反馈。payload 思路select * from (select * from flag a join flag b using(列名))c;如果报错消失说明这个列名存在。用这种方式可以逐个猜出 flag 表里所有列名。虽然效率不高但它是绕过 information_schema 过滤的最可靠思路之一。CTF 里 flag 表结构通常很简单列名可能就叫 flag、id、value用常见字典猜几轮基本能命中。7. 总结一份可复用的 SQL 注入解题 SOP附踩坑记录7.1 从 0 到 1 的完整探测链路把 171-213 刷完之后我把自己的解题流程压缩成了一条固定链路每次都是这样走闭合方式单引号、双引号、括号、数字类型四种都试一遍根据报错或回显差异确定闭合符。列数探测order by 1逐级增加直到报错确定列数。如果order by被过滤改用union select 1,2,...试探列数。过滤规则摸底把空格、注释符、or、and、union、select、sleep、information_schema 这些关键词分别提交一次看哪些被处理、处理成什么。泄露通道选择有回显位置优先 union没回显但报错可用优先 updatexml/extractvalue什么都没就用布尔或时间盲注分号能执行就考虑堆叠 预处理。信息收集顺序先database()拿库名再查表名再查列名最后查字段值。每一步都要先看上一步结果的长度避免输出截断导致误判。组合绕过如果单个姿势过不去就把过滤绕过方案套在前面 2 到 5 步上。比如被过滤了空格就把所有 payload 里的空格替换成/**/再用本地 MySQL 验证整体语法没问题。这套链路最大的价值是“不重复造轮子”。我做 171-180 的时候还在每次都从第一步慢慢走到 200 题以后基本就是闭着眼把链路过一遍快速定位到卡点。7.2 我在这个区间踩过的坑这里罗列几个我反复踩的坑每一个都真实浪费过我的时间第一报错注入 32 字符截断。flag 经常是flag{...}超过 32 字符一次取不完整必须分两次。判断方法是先length(flag)看总长度再用 substr 分段。第二把--和#用错。MySQL 里#是注释符--后面必须要跟一个空格才生效URL 里经常要写--。很多新手在参数后面只写了--后面直接接 payloadMySQL 根本没把它当注释导致整条 SQL 语法错误。我的习惯是优先用#或者统一写--。第三大小写和双写在同一个 payload 里混用。过滤规则如果先strtolower再替换双写里的大小写就白写了如果替换逻辑是循环执行的双写也可能被二次处理。你要根据第一次测试的结果判断过滤的先后顺序不要猜。第四盲注脚本里没有处理 URL 编码。payload 里有引号、空格、#时requests 库默认不会自动编码直接发出去后端收不到预期字符一直判断失败。这个坑浪费了我整整一个下午最后才发现是#被浏览器当作锚点吞掉了。第五时间盲注里 sleep 被过滤时不换 benchmark。这是 196-205 区间的典型卡点明明条件都判断对了但请求总是秒回说明 sleep 被过滤了。换成benchmark(10000000,md5(a))立刻就能通。7.3 防御视角怎么改代码让这些 payload 全部失效刷完题我有一种很强的感觉凡是这些 payload 能生效的地方后端几乎都是自己用字符串拼接 SQL并且把错误信息直接抛给了前端。真正让注入失效的办法不是加一个多复杂的 WAF而是从根上改掉拼接方式。最有效的就是参数化查询 / 预处理语句$stmt $pdo-prepare(select * from users where username ?); $stmt-execute([$username]);参数化之后无论你输入 or 11#还是1 and updatexml(...)数据库都只把它当成一个普通的字符串参数不会参与 SQL 解析。这是最彻底、也最简单的防御方案。其次是关掉报错回显。报错注入能成立依赖的就是数据库错误信息原样输出到页面上。后端把display_errors关掉再把异常统一封装成“请求错误”直接废掉 updatexml 和 extractvalue 这套打法。盲注虽然不依赖报错回显但只要参数化一上盲注也无从谈起。最后才是 WAF 或输入过滤。它适合作为纵深防御的一环但绝对不能当唯一的防线。我刷完这 43 道题的最大感触就是过滤规则永远是猫鼠游戏总有新的编码方式、注释方式或者等价函数能绕过真正值得信任的只有“参数不参与 SQL 解析”这个原则。做这些练习的时候也提醒一句这些 payload 只适合在靶场环境里验证和学习。CTF 题目的意义是帮你理解漏洞原理而不是用来测试任何线上系统的。把这套思路吃透你再看那些上传点、登录框、搜索框大概就能一眼判断出它有没有拼接 SQL、过滤规则又是怎么写的了。