
打CTF的Web方向时SUCTF 2019 EasySQL 1这道题我一直印象很深。标题里挂着EasySQL几个字实际却把不少选手卡在明知道是SQL注入但就是拿不到flag的尴尬位置上。它是一道很典型的MySQL注入题核心考点是堆叠注入、sql_mode会话变量以及字符串拼接行为的理解。今天这篇Writeup我会从最开始的探测讲到最后一条Payload把每条命令为什么这么发、每次回显说明了什么都拆开揉碎。新手可以按着流程完整走一遍有经验的朋友也能当一次查漏补缺。1. 题目概览与考点分析1.1 题目界面与第一印象打开题目后页面非常简单一个输入框加一个提交按钮没有多余的提示。题目描述基本等于没有只告诉我们这是一道SQL注入题。这种界面在CTF里很常见很多时候真正有用的信息只能靠手工探测去拿。用Burp Suite抓一下提交请求能看到POST参数是query提交的内容原样放进这个参数里。比如在输入框里填1抓到的请求就是POST /index.php HTTP/1.1 Host: 靶机地址 Content-Type: application/x-www-form-urlencoded query1只要确认了交互方式后面所有测试都可以用Burp的Repeater或者直接写curl命令来做。页面回显也很有特点提交1之后返回的是Array ( [0] 1 )这是一个关键信息。说明后端不是简单地返回查询成功或查询失败而是把SQL查询结果的第一列以PHP数组的形式打印出来了。也就是说我们输入的字符串确实被拼进了SQL语句并且查询结果会直接反映在页面上。再加上前面抓包拿到的参数名query几乎可以确定后端是把我们输入的内容拼进了一条SELECT语句里。1.2 核心考点定位这道题官方给的名字是EasySQL但实际考点并不止万能密码那种级别。抛开题目难度不谈它考察了三个核心点堆叠注入也就是一条请求里通过分号执行多条SQL语句。这要求PHP端使用了类似mysqli_multi_query的接口而不仅仅是mysqli_query。MySQL的sql_mode会话变量尤其是PIPES_AS_CONCAT参数它决定||到底是逻辑或还是字符串拼接符。多语句执行时结果集的显示机制题目往往只回显第一条SELECT语句的结果后面的语句虽然执行了但看不到输出。这三个点组合在一起构成了这道题最有意思的地方就算你知道可以堆叠注入也不能像普通注入那样直接union select拿数据而是需要利用会话状态被改变这个副作用来配合下一步操作。在做题之前先记住一个基本结论SQL注入不只是 or 11和union select所有能影响SQL执行行为的点都可能成为突破口。2. 信息收集与注入探测2.1 第一轮手动探测拿到一个SQL注入题我最习惯的顺序是先摸清楚输入位置、后端SQL拼接方式、过滤规则再决定用什么注入手法。直接上Payload往往容易翻车。第一步先测最基础的整形闭合。输入query1回显Array ( [0] 1 )输入query1页面没有正常回显也没有输出Array说明单引号确实影响了SQL语句的正常执行。不过需要注意的是题目可能对报错信息做了处理所以光靠报错看不出来后端具体语句长什么样。接着测一个经典逻辑表达式query1 or 11这个请求大概率会被拦截。SUCTF这道题对很多关键字做了过滤常见的select、where、union等都在黑名单里。但也别急着下结论因为过滤规则不是一刀切的有些关键字能用有些不能。再测一下分号query1;show tables;这个请求在不同Writeup里表现不完全一样有的环境会回显空白有的会报错。但有一点可以确定分号本身没有被直接干掉。我在本地复现时1;show tables;确实没有返回表名这是因为题目只打印第一条SELECT的结果集后面的show tables结果虽然执行了却没有被输出。这一步的结论很重要堆叠注入大概率可用但常规的堆叠查询想看什么就看什么在这个题目里不成立。2.2 后端结构与过滤规则推断结合前面的现象我们可以反推后端的SQL拼接逻辑。题目几乎可以确定是把query参数直接拼进了一条语句里类似$query $_POST[query]; $sql select $query from flag;;输入1时实际执行的是select 1 from flag;所以结果集第一列是1。输入1||flag时实际执行的是select 1||flag from flag;这里涉及MySQL的运算符行为后面会详细讲。从回显Array可以判断PHP拿到结果后大概用了fetch_array或者fetch_row之类的函数把每行结果的第一列打印出来。这种结构对注入来说非常友好因为只要SELECT的第一列内容可控页面就会直接显示。再来看过滤规则。我在测试过程中发现输入select、update、delete、insert、where等关键字会返回一个类似Nonono的提示说明这些单词被拉进了黑名单。但关键是分号、set、||、flag这些内容都没有被过滤否则后面的解法完全不成立。很多人在这一步会陷入一个误区既然过滤了select联合注入岂不是用不了对联合注入很难用因为如果输入1 union select 1拼接到后端会变成select 1 union select 1 from flag语法上就已经不对了同时select关键字还会被拦。所以必须换一条路。3. 核心突破堆叠注入与sql_mode3.1 分号到底能不能用先做一个非常关键的实验输入query1;set sql_modePIPES_AS_CONCAT;select 1页面返回Array ( [0] 1 )表面上看起来和直接输入1没什么区别但实际上发生了三件事第一条SQL语句执行了select 1 from flag产生结果集1。第二条SQL语句执行了set sql_modePIPES_AS_CONCAT修改了当前会话的sql_mode变量。第三条SQL语句执行了select 1 from flag又产生结果集1。因为题目只展示了第一个结果集所以我们只看到了1。但第二条set语句被成功执行了这就是突破口。这里需要先明确一个常识**PHP的mysqli_multi_query允许一次执行多条SQL语句但页面是否显示每一条语句的结果取决于开发者怎么遍历结果集。**如果开发者在拿到第一个结果集之后就直接break了后面的结果集我们自然看不到。但看不到不代表没执行数据库状态的变化是真实存在的。这也是堆叠注入和普通注入最大的区别。普通注入只影响一条SQL语句堆叠注入则可以在一次请求里执行完全不同类型语句比如set、show、drop等等只要后端接口支持。3.2 读懂MySQL的||和sql_mode这一节是整个题目的核心。很多人对||的理解停留在逻辑或这一个层面但在MySQL里||到底是什么含义取决于当前会话的sql_mode。MySQL默认的sql_mode没有PIPES_AS_CONCAT所以||是逻辑或运算符。例如select 1 || 0;结果不是字符串10而是数字1因为1 OR 0为真。再看这道题select 1 || flag from flag;flag字段里存的是flag字符串比如flag{abc}。在逻辑或的语义下MySQL会把字符串转换成数字。flag{abc}这种字符串转成数字时如果开头不是数字结果通常是0。于是1 || 0结果是1。这正好解释了为什么直接输入1||flag时页面只会返回1看不到flag内容。但如果把sql_mode加进PIPES_AS_CONCAT||就从逻辑或变成了字符串连接符作用和CONCAT()函数一样。再执行select 1 || flag from flag;结果就变成了1flag{abc}此时flag字段的真实内容就跟着1一起被拼出来了。用生活化的类比来说默认情况下||是个判断题只告诉你真还是假加了PIPES_AS_CONCAT之后||变成了拼接器把两段文字直接粘在一起。我们要做的就是先想办法打开这个拼接器。3.3 关键Payload的构造逻辑既然1||flag拿不到flag那就先通过堆叠注入修改sql_mode再让||变成拼接符。第一个Payload1;set sql_modePIPES_AS_CONCAT;select 1这个Payload的作用是先让第一条语句正常回显1顺便把当前会话的sql_mode改掉。select 1放在最后是为了保证即使脚本只取第一个结果集也能正常走完流程不报错。第二个Payload1||flag在第一次请求已经成功修改sql_mode的前提下这条语句会被解析成select 1 || flag from flag;由于||已经变成字符串拼接符结果就是1flag{......}把Array ( [0] 1flag{......} )中的花括号内容拿出来就是flag。这里有个很关键的前提**第二次请求必须沿用第一次请求修改过的数据库会话。**也就是说两次请求之间连接不能断开否则sql_mode会被重置回默认值。我在本地搭建环境复现时第一次请求完成后连接仍然被复用所以第二次请求确实能收到flag。如果遇到第二次请求仍然只回显1的情况优先检查数据库连接是否为长连接或者是否每次请求都新建了连接。这个坑在第五部分会专门讲。4. 完整实操与结果解读4.1 第一次请求设置会话变量直接用curl复现整个流程最直观。假设靶机地址是http://target第一步curl -s -X POST http://target/index.php \ --data-urlencode query1;set sql_modePIPES_AS_CONCAT;select 1--data-urlencode会把query的值做URL编码分号和空格都会变成%3B、%20之类服务端接收后会自动解码不会影响分号分隔多条语句。这一步如果成功页面应该回显Array ( [0] 1 )看到这个结果不要急着以为什么都没发生此刻数据库会话里的sql_mode已经被改成了PIPES_AS_CONCAT。可以用下面的SQL验证一下这个变量在MySQL里的表现-- 设置前 select 1 || abc; -- 结果1 -- 设置后 set sql_modePIPES_AS_CONCAT; select 1 || abc; -- 结果1abc如果把这两条SQL放在同一个MySQL连接里执行就能直观感受到||行为的变化。4.2 第二次请求把flag拼接出来第一步执行成功后紧接着发第二个请求curl -s -X POST http://target/index.php \ --data-urlencode query1||flag这时候页面回显就会变成Array ( [0] 1flag{...} )flag就藏在里面。如果你使用的是Burp Suite操作流程也完全一样。第一次在Repeater里发送1;set sql_modePIPES_AS_CONCAT;select 1第二次修改query为1||flag再发送即可。这里有一个很重要的心理建设第一次请求返回的1和直接输入1返回的1看起来一模一样但它背后的数据库状态已经不同了。做SQL注入题不能只看页面回显脑子里一定要模拟整条SQL语句实际执行的每一个步骤。4.3 备选思路用*,1直接探测字段可能有人会问题目都说了最终SQL是select $query from flag为什么不直接输入*,1让语句变成select *,1 from flag;如果flag表里只有flag这一列那么查询结果的第一列就是完整flag第二列是1。实际测试中输入query*,1页面确实可能回显Array ( [0] flag{...} [1] 1 )这个思路在部分环境里可以直接打通。它不需要设置sql_mode原理上也更简单*展开成表的所有列后面跟着数字1作为额外的一列。但为什么会有人绕一大圈用PIPES_AS_CONCAT原因很可能在于题目对星号或者逗号做了过滤又或者后端处理字段时还有其他限制。从稳扎稳打的角度我建议优先掌握set sql_modePIPES_AS_CONCAT这条主流路线因为它不依赖*的展开行为对表结构也不敏感。*,1可以作为第二条路去试试通了就少绕弯子试不通再回头走主流解法。5. 常见问题、踩坑记录与扩展5.1 按现象定位问题做题时最烦的就是Payload发出去结果和预期不一样。这一节整理几个最常见的坑都是我实际踩过或者看别人踩过的。现象可能原因处理方式第一步执行1;set sql_modePIPES_AS_CONCAT;select 1后回显空白或Nononoset或分号被过滤或者后端不允许堆叠查询换个环境确认题目原版检查是否还有别的过滤规则尝试URL编码分号看是不是传输层被处理了第二步执行1flag仍然只回显1*,1回显非预期结果星号或逗号被过滤或者flag表里有多个字段导致第一列不是flag换回PIPES_AS_CONCAT方案先用堆叠注入探测表结构再调整payload输入1直接一片空白单引号破坏了SQL语法同时报错被屏蔽不要死磕报错注入优先确认闭合方式和堆叠注入可用性union select被拦黑名单过滤了select和union换方向用堆叠注入、修改sql_mode或布尔盲注其中数据库连接是否复用是最容易忽略的点。CTF环境为了模拟真实服务很多题目容器会保持PHP进程和MySQL连接的生命周期所以两次请求之间sql_mode能保留。如果你在本地自己搭了一个最简单的PHPMySQL环境默认情况下每个请求都可能新建连接第一次设置的会话变量第二次就失效了。遇到这种情况可以在测试时把两步合成一条请求看看后端是否遍历所有结果集或者调整本地代码模拟连接复用。5.2 这类思路还能用在哪些地方PIPES_AS_CONCAT这个技巧不只适用于这一道题。凡是遇到堆叠注入可用但只能看到第一个结果集的MySQL环境都可以考虑用类似思路修改sql_mode让||变成拼接符从而在SELECT语句里直接拼接字段输出数据。修改sql_mode为ANSI因为ANSI模式下也包含PIPES_AS_CONCAT可以一试。如果set被过滤可以尝试set 变量名...或者set 会话变量...但要看权限和过滤规则。如果单纯改sql_mode不行还可以考虑用堆叠注入去修改表数据再配合后端的SELECT回显来拿到flag。比如新建一张表插入自己的内容再让后端查询。但这要求各种关键字都没被过滤条件比较苛刻。更深一层看这道题训练的是理解副作用的能力。堆叠注入不只是帮你执行额外的SELECT语句还可以执行一些改变数据库会话、数据库状态的语句。这些改变可能不会立刻体现在回显里但只要靶场环境允许它们就能成为后续攻击的跳板。另外MySQL不同版本的默认sql_mode不完全一样。5.7与8.0对||的默认处理其实都还是逻辑或除非手动加上PIPES_AS_CONCAT。如果你在另一个靶场里遇到类似题目先测一下select 1||1;看看返回1还是11就能马上判断当前环境的sql_mode。最后再分享一点个人体会SUCTF 2019 EasySQL 1这道题真正巧妙的地方不在于堆叠注入本身而在于如何利用不可见的副作用来改变下一次查询的结果。以后做题遇到那些看起来什么都回显不出来的注入点别急着放弃。先试试能不能执行set、prepare这类语句再想想能不能通过改变会话状态来影响后续查询。很多时候解题思路就是这么被一步步逼出来的。希望这篇Writeup能帮你在下一次比赛里少走一点弯路。