ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SUCTF EasySQL深度解析:堆叠注入与SQL Mode的巧妙利用

SUCTF EasySQL深度解析:堆叠注入与SQL Mode的巧妙利用 SUCTF 2019 的 EasySQL 算是我印象里很典型的一道“名字简单、内核不简单”的 Web 题。网上虽然有很多 Writeup但不少直接把 Payload 一贴就结束了新手看完还是不知道为什么要输入1;set sql_modePIPES_AS_CONCAT;select 1。这篇文章我会从零开始完整走一遍这道题的解题链路包括基础探测、堆叠注入原理、MySQL 里||的冷门语义、Payload 的构造思路以及我实际调试时踩过的坑。适合刚接触 SQL 注入、准备打 CTF Web 方向或者想彻底弄懂堆叠注入和 SQL Mode 的读者。1. 开局先别急着背 Payload把回显规律摸清楚1.1 题目是什么样子的打开靶场环境页面上没有复杂的导航只有一个输入框加一个提交按钮旁边写着类似“Lets check the SQL!”的提示。提交的字段名是query这意味着后端大概率会把这一整段内容直接带进 SQL 语句。很多 CTF Web 新手一看到输入框就习惯性地先丢一个单引号或者直接上 or 11 -- -但在这道题上这些常规套路基本都不好用。我建议第一步做的不是猜过滤规则而是老老实实把最基本的输入都点一遍先看回显特征。题目地址通常是动态生成的容器环境可能隔一段时间失效所以拿到题目先确认容器还活着再开始下一步。页面本身没有多余的功能点登录、注册、注入点都只有一个输入框这种题目往往越简单越容易在 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虽然快但很多玩家提到这道题时第一反应是另一个 Payload1;set sql_modePIPES_AS_CONCAT;select 1它分三段看先执行select 1再用set sql_modePIPES_AS_CONCAT修改当前会话的模式最后执行select 1||flag from flag。因为第三条语句执行时||已经被设置成字符串连接符所以它会返回1flag{...}这样的拼接结果。为什么前面要带一个1因为原语句是select $query || flag from flag如果$query为空SQL 语法就错了所以必须给一个合法表达式选 1 或 0 都可以只要能让语句成立。整个执行过程可以写成select 1; set sql_modePIPES_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 参数就是query1。改成POST /index.php HTTP/1.1 Host: your-target Content-Type: application/x-www-form-urlencoded query1;set%20sql_modePIPES_AS_CONCAT;select%201放包后响应体里立刻多了一行1flag{...}。这种题目不必一条条手动复制写个几行 Python 脚本更舒服。requests 发 POST 请求re 正则提取 flag几秒钟就能拿到结果。比如下面的脚本import requests import re url http://your-target/index.php payload 1;set sql_modePIPES_AS_CONCAT;select 1 r requests.post(url, data{query: payload}) text r.text print(text) flag re.findall(rflag\{[^}]\}, text) print(flag)这里建议用单引号包住 payload避免 shell 里分号被当成命令分隔符。脚本逻辑很简单提交打印响应再用正则匹配flag{...}格式的内容。如果 flag 格式不同把正则改成SUCTF\{[^}]\}即可。正则匹配的思路跟写 Crypto 题 Writeup 时从一堆密文里提取 Key 很像都是先根据标志性前缀定位再截取有效段后面我会专门聊这一点。4. 执行过程中的坑与排查方法4.1 回显全是 1没有 Flag 出现我实际调试时遇到的第一个坑是明明提交了1;set sql_modePIPES_AS_CONCAT;select 1响应还是只有 1。排查后发现是我把 payload 写进了字段名而不是 value或者编码后分号被浏览器处理了。如果你也遇到这种情况先确认 POST 请求体里 query 的值是否原样包含分号其次确认环境是不是每次请求都会新建连接如果连接被复用但 sql_mode 设置失败第三条语句依然会按逻辑或来算。还有一个可能的原因是数据库里 flag 字段本身是空的1||flag在连接模式下会得到 1表现和逻辑或差不多。所以可以先试试1;set sql_modePIPES_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_modePIPES_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 后只回显 1sql_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 拿到的最有价值的东西。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进