SUCTF 2019 的 EasySQL 算是我印象里很典型的一道“名字简单、内核不简单”的 Web 题。网上虽然有很多 Writeup,但不少直接把 Payload 一贴就结束了,新手看完还是不知道为什么要输入1;set sql_mode=PIPES_AS_CONCAT;select 1。这篇文章我会从零开始,完整走一遍这道题的解题链路,包括基础探测、堆叠注入原理、MySQL 里||的冷门语义、Payload 的构造思路,以及我实际调试时踩过的坑。适合刚接触 SQL 注入、准备打 CTF Web 方向,或者想彻底弄懂堆叠注入和 SQL Mode 的读者。
1. 开局先别急着背 Payload,把回显规律摸清楚
1.1 题目是什么样子的
打开靶场环境,页面上没有复杂的导航,只有一个输入框加一个提交按钮,旁边写着类似“Let's check the SQL!”的提示。提交的字段名是query,这意味着后端大概率会把这一整段内容直接带进 SQL 语句。很多 CTF Web 新手一看到输入框就习惯性地先丢一个单引号,或者直接上' or 1=1 -- -,但在这道题上,这些常规套路基本都不好用。我建议第一步做的不是猜过滤规则,而是老老实实把最基本的输入都点一遍,先看回显特征。
题目地址通常是动态生成的容器,环境可能隔一段时间失效,所以拿到题目先确认容器还活着,再开始下一步。页面本身没有多余的功能点,登录、注册、注入点都只有一个输入框,这种题目往往越简单越容易在 SQL 结构上做文章。当时我先把1、2、3都提交了一圈,很快发现一个规律:返回的内容跟我输入的数字一模一样。
1.2 从“输入 1 返回 1”能推断出什么
输入数字 1,页面返回一个数组结构,里面只有一个元素 1。输入 2,返回 2;输入 3,返回 3。这个现象很关键:它说明查询结果被原样渲染到页面里,而且输入值本身似乎被当成了查询结果的一部分。如果是一张普通用户表里的 id 字段,输入 1、2、3 返回的应该是对应行的用户名或密码,而不是数字本身。这里返回的数字等于我输入的数字,更像是在执行类似select 1或者select 1 || 字段这样的语句。
继续试单引号,页面没有明显报错;试1',页面依然返回类似 1;试1'#',也没有预期中的注释效果。这说明输入点根本不在where条件里,而是被拼到了select后面的表达式部分。到这一步,至少可以假设后端不是常规的select * from users where id='$input',更像是一条把输入直接放在查询列表位置的语句。有了这个假设,后面的测试方向就会清晰很多。
1.3 常规 SQL 注入为什么在这里失灵
一开始我对这道题的态度是:先查字段数、试 union 注入,然后爆库名表名。但试完发现,union、and、or、order by、limit这些关键字一旦出现在输入里,页面会直接返回“Hacker”之类的提示,说明存在黑名单过滤。更麻烦的是,即使绕过了黑名单,常规注入也需要闭合前半句,比如构造1' union select...。但这里的 SQL 上下文并不是常见的select * from xxx where id='$input',而是直接把输入拼进了查询列表里,所以union很难找到合适的拼接点。
这时候就需要换个思路:如果没法在一条语句里读完数据,那就看一看能不能在分号后面再开一条新语句,也就是堆叠注入。很多新手踩着这道题翻车,就是因为思维还停留在“必须闭合单引号、必须用 union”的阶段。实际上,当一条查询语句的上下文足够特殊时,分号反而是更值得关注的字符。
2. 堆叠注入与 SQL Mode:这道题真正的考点
2.1 堆叠注入是怎么工作的
正常的 SQL 查询是一条语句,页面执行完后把结果返回。堆叠注入的思路是在原始语句后面用分号结束,然后继续写第二条、第三条语句。比如后端语句是select $query;,你输入1;select version(),最终执行的就是:
select 1; select version();这样第二条语句的结果也能被处理。不过这里有个硬前提:后端的数据库连接必须支持多语句执行。PHP 里的mysqli_multi_query或者 PDO 中开启多语句支持后,才会出现这种情况;如果后端用的是预编译方式,通常连堆叠注入的机会都没有。CTF 里经常出现堆叠注入,就是因为出题人故意使用了支持多语句的数据库接口。
堆叠注入和 union 注入最大的差别在于:union 只能把多条查询结果纵向拼接,而且要求前后字段数一致;堆叠注入则可以执行任意 SQL 语句,包括set、show、insert、update、delete等。当然,能执行多条语句并不代表每条语句的结果都能回显到页面,很多堆叠注入题只让你看到最后一条语句的输出,或者干脆只看得到成功与失败。利用这一点,解题思路往往不是直接读数据,而是先改变数据库的会话变量,让原本不可读的数据变得可读。
2.2 MySQL 里的 || 运算符:是“或”还是“拼接”
这道题最容易被忽略的知识点是 MySQL 里||运算符的语义。在很多数据库里,||是字符串连接符,比如 Oracle、PostgreSQL 都是这样。但在 MySQL 默认的 sql_mode 下,||表示逻辑或,等价于OR,只有开启了PIPES_AS_CONCAT模式,||才会变成字符串连接符。这个冷知识平时写业务代码基本遇不到,因为大家写字符串拼接习惯用CONCAT(),但在 SQL 注入的题目里,模式不同会直接影响结果。
你可以把sql_mode理解为 MySQL 运行时的“性格设定”,同一个运算符在不同性格下会有完全不同的表现。MySQL 5.7 和 8.0 的默认sql_mode里都不包含PIPES_AS_CONCAT,所以默认情况下select 1 || flag from flag;是逻辑或运算,返回的是 1,而不是字符串拼接。这个点一旦想通,后面那个经典 Payload 就很好理解了。
2.3 由回显逆推后端 SQL 结构
现在把前面两个现象结合起来:输入 1 返回 1,说明查询结果里一定存在一个能算出 1 的表达式。网上很多 Writeup 复现的源码里,关键语句是:
$sql = "select ".$query."||flag from flag;";如果把$query替换成 1,语句就变成:
select 1 || flag from flag;默认 sql_mode 下||是逻辑或,而 flag 列的内容是flag{...}这类字符串,MySQL 在数值比较时会把它转成 0,所以1 OR 0的结果就是 1。正是因为这个原因,不管输入什么数字,只要 flag 列非空,结果都可能是 1。这就完美解释了我们看到的现象。后端把用户输入直接拼进select的列表位置,还预置了一个|| flag,这明显是出题人故意留下的伏笔。
3. 两种拿 Flag 的 Payload 推导
3.1 思路 A:用通配符 * 直接读取所有列
既然输入被拼在select的列表位置,那最简单的思路就是让整个查询变成select *, ... from flag。输入*,1,后端语句会变成:
select *,1||flag from flag;MySQL 在执行时会先展开*,把表的全部字段查出来,其中就包含 flag 字段本身;后面的1||flag只是一个附加列,不影响*展开。页面回显所有列时,我们就能直接看到 flag。实测这个 Payload 在过滤规则里没有触发黑名单,因为*和,都不在常见黑名单里。
需要注意的是,这个解法成立依赖一个前提:题目的回显逻辑会把查询出来的所有字段都打印出来。如果后端只取结果集的第一个字段,*直读就会失败,但很多 CTF 题目的回显是直接把整行数据渲染出来的,所以这招经常有效。在网络上也有人用这个 Payload 直接秒杀,不过它更像是一种“巧解”,而不是出题人预先设计的标准路径。
3.2 思路 B:用分号开启堆叠,修改 sql_mode 做字符串拼接
*,1虽然快,但很多玩家提到这道题时第一反应是另一个 Payload:
1;set sql_mode=PIPES_AS_CONCAT;select 1它分三段看:先执行select 1,再用set sql_mode=PIPES_AS_CONCAT修改当前会话的模式,最后执行select 1||flag from flag。因为第三条语句执行时||已经被设置成字符串连接符,所以它会返回1flag{...}这样的拼接结果。为什么前面要带一个1?因为原语句是select $query || flag from flag,如果$query为空,SQL 语法就错了,所以必须给一个合法表达式;选 1 或 0 都可以,只要能让语句成立。
整个执行过程可以写成:
select 1; set sql_mode=PIPES_AS_CONCAT; select 1 || flag from flag;第三条语句在逻辑或模式下会返回 1,在字符串连接模式下会返回1flag{...}。实际提交后,响应中会出现1flag{...},把前面的 1 去掉就是真正的内容。如果你用0替代1,结果就是0flag{...},原理完全一样。
3.3 为什么这两个 Payload 能绕过黑名单
通常做 SQL 注入题,最怕的是过滤了select、union、information_schema这些关键词。这道题的黑名单也确实过滤了不少东西,比如flag本身、prepare、hex、order by、limit、#和--注释符等等。但我们的两个 Payload 里都没有出现这些词,*、;、set、sql_mode、select都顺利通过。
尤其是思路 B,整条 Payload 里连flag都不需要写,因为flag这个表名和列名是后端自动带上去的。这个设计也说明出题人想考察的是对 SQL 结构和 MySQL 特性的理解,而不是单纯考验绕过 WAF 的手速。很多新手在flag被过滤后就觉得题目做不了,实际上你根本不需要把flag作为关键字提交。
3.4 抓包与脚本化验证
手工在输入框里提交一次没问题,但拿 Flag 总归要一个稳定的提取过程。打开 Burp Suite,直接抓提交请求,可以看到 POST 参数就是query=1。改成:
POST /index.php HTTP/1.1 Host: your-target Content-Type: application/x-www-form-urlencoded query=1;set%20sql_mode=PIPES_AS_CONCAT;select%201放包后响应体里立刻多了一行1flag{...}。这种题目不必一条条手动复制,写个几行 Python 脚本更舒服。requests 发 POST 请求,re 正则提取 flag,几秒钟就能拿到结果。比如下面的脚本:
import requests import re url = "http://your-target/index.php" payload = "1;set sql_mode=PIPES_AS_CONCAT;select 1" r = requests.post(url, data={"query": payload}) text = r.text print(text) flag = re.findall(r"flag\{[^}]+\}", text) print(flag)这里建议用单引号包住 payload,避免 shell 里分号被当成命令分隔符。脚本逻辑很简单:提交,打印响应,再用正则匹配flag{...}格式的内容。如果 flag 格式不同,把正则改成SUCTF\{[^}]+\}即可。正则匹配的思路跟写 Crypto 题 Writeup 时从一堆密文里提取 Key 很像,都是先根据标志性前缀定位,再截取有效段,后面我会专门聊这一点。
4. 执行过程中的坑与排查方法
4.1 回显全是 1,没有 Flag 出现
我实际调试时遇到的第一个坑是:明明提交了1;set sql_mode=PIPES_AS_CONCAT;select 1,响应还是只有 1。排查后发现是我把 payload 写进了字段名而不是 value,或者编码后分号被浏览器处理了。如果你也遇到这种情况,先确认 POST 请求体里 query 的值是否原样包含分号;其次确认环境是不是每次请求都会新建连接,如果连接被复用但 sql_mode 设置失败,第三条语句依然会按逻辑或来算。
还有一个可能的原因是数据库里 flag 字段本身是空的,1||flag在连接模式下会得到 1,表现和逻辑或差不多。所以可以先试试1;set sql_mode=PIPES_AS_CONCAT;select 0,看有没有出现0flag,用来帮助判断到底是 Payload 没生效,还是数据存储有问题。
4.2 堆叠注入被拦截
如果输入的分号被 WAF 拦住,或者页面提示 Hacker,说明当前环境对分号、set或select做了更严格的处理。这时候可以先检查过滤针对的是完整关键字还是子串。比如用SeT大小写混淆,或者在内联注释里拆开关键字,例如sel/**/ect;但要注意很多后端过滤规则会把/**/也一起查掉。
另外,MySQL 里改 SQL Mode 不一定只有set一种路径,也可以试试通过prepare和execute构造动态语句来绕过,不过这道题黑名单里明显有prepare,所以不是首选。对于 CTF 题,如果某个 Payload 被拦,最优先的是换一种不触发黑名单的写法,而不是死磕绕过。像这道题,*,1和1;set sql_mode=PIPES_AS_CONCAT;select 1是两条完全不同的路,一条不行就换另一条。
4.3 拿到一串拼接结果怎么清洗
用预期解拿到的是1flag{...}这种带前缀的结果,不要急着提交,先把前面的 1 去掉。如果 flag 里本身含有数字和下划线,肉眼提取很容易看错,正规做法是用正则匹配。如果你在终端里跑脚本,建议把响应体存成文件再处理,避免控制台编码问题。
这里分享一个习惯:我通常会先用grep -o 'flag{[^}]*}'做一次粗提取,再手动核对。这个过程特别像在 Crypto 题里拿到一段被填充过的密文,需要剥离填充再还原明文,核心思路都是“找到边界,去除干扰”。有时候 flag 会出现在 HTML 标签或者数组调试信息中间,正则匹配能帮你快速定位。
4.4 常见问题速查表
| 你看到的现象 | 可能原因 | 下一步操作 |
|---|---|---|
| 输入 1 回显 1,输入 2 回显 2 | 输入拼进了 select 表达式 | 优先试*,1或堆叠注入 |
输入1'也没报错 | 注入点不在 where 中 | 不要继续测单引号闭合,改测分号 |
提交1;show tables;没反应 | show 被过滤或只显示第一条结果 | 别在不必要的命令上浪费精力 |
提交*,1回显一堆字段但看不到 flag | 回显可能只打印第一列 | 换堆叠注入改 sql_mode |
| 提交预期 Payload 后只回显 1 | sql_mode 没生效或 flag 为空 | 抓包检查 payload 编码,试select 0 |
响应里有1flag{...} | 拼接模式生效了 | 提取 flag,去掉前缀 |
这张表基本覆盖了容易卡住的位置。写 Writeup 时我也习惯把现象和结论分开记录,因为真正有价值的不是最后那一串 Payload,而是“什么现象对应什么判断”。
5. 从 EasySQL 延伸出去的一些想法
5.1 题目为什么这样设计
SUCTF 2019 的 EasySQL 被放在 Web 方向,难度却不算低,因为它没有走“查库名、查表名、查字段名”的标准 SQL 注入流程,而是把一个很小众的 MySQL 配置特性当成解题关键。出题人希望选手看到||时,不只是想到“或运算”,还能想到PIPES_AS_CONCAT这个模式;看到过滤了flag关键字时,也不慌,因为真正的表结构由后端自动写好,不需要手动输入。这种出题风格对训练思维很有帮助:先推断 SQL 上下文,再考虑数据库特性,最后才考虑绕过。
5.2 堆叠注入在真实场景里的启示
堆叠注入虽然这个题里用来改sql_mode,但真实开发中更危险的是“分号后跟任意语句”。如果后端使用mysqli_multi_query或拼接 SQL 的方式执行用户输入,攻击者不仅能读数据,还能删表、改数据,甚至通过LOAD_FILE读服务器文件。这也是为什么现在主流框架都强制使用预编译参数化查询,而不是简单拼接字符串。
遇到 SQL 注入题时,可以先区分是不是堆叠注入场景:如果普通 union 不成立,但分号后面还能执行,那就要马上把“多条语句”纳入攻击面。判断方法很简单,输入一个带分号的语句,观察响应是否有第二个结果,或者是否触发更严格的过滤。这道题就是一个典型例子,分号允许执行,只是结果回显方式比较隐蔽。
5.3 处理 Flag 响应时跟 Crypto 题 Writeup 的共性
最后说一点跨方向的心得:很多 Crypto 题目的 Writeup 里会有“把密文按某种格式切分,去掉填充,提取 flag”的步骤;这道 EasySQL 最后从1flag{...}里提取 flag,本质上也是一样的流程。先找到特征前缀,再用正则或脚本提取有效内容,最后核对格式。所以我不太建议把 Web 题和 Crypto 题分开学,分析问题和写 Writeup 的底层能力是通用的。
比如看到1flag{...},你可以马上想到正则flag\{[^}]+\};看到一段 base64,你也会想到先解码再观察特征。这种模式化处理思路,练得越多,做题越快。写 Writeup 本身也不是流水账,而是把当时的判断依据和踩坑过程记录下来,方便以后复盘。
实际上,在我个人做这道题的时候,印象最深的不是最后拿到 flag 那一瞬间,而是中间卡了快半小时才发现||在 MySQL 里还有另一种语义。如果你也是刚接触 SQL 注入的新手,遇到“输入什么回显都像个常量”的题目,先别急着怀疑题目坏了,换个角度想想:也许出题人想让你看见的不是数据,而是数据库的“性格”。把这种题亲手复现一遍,之后再碰到类似场景,你自然会多留一个心眼。这也算是我通过 EasySQL 拿到的最有价值的东西。