从CTF EasySQL题解析SQL注入攻防:核心原理与实战绕过技巧
1. 从一道经典CTF题看SQL注入的攻防博弈
做安全研究或者打CTF的朋友,对[SUCTF 2019]EasySQL这个题目肯定不会陌生。它一度是各大CTF平台和练习靶场的热门题目,甚至衍生出了[极客大挑战 2019]EasySQL等变体。这道题之所以经典,不在于它用了多么高深莫测的过滤手段,而在于它用一种非常“直白”的方式,考验了选手对SQL查询语句本质的理解,以及对不同数据库特性、尤其是MySQL的“黑魔法”的掌握程度。很多新手在这里折戟沉沙,不是因为技术多难,而是思维被常规的注入姿势给框住了。今天,我就带大家彻底拆解这道题,不仅复现解题过程,更深入分析其背后的设计思路、可能的多种解法,以及我们能从中汲取哪些关于代码审计和防御的经验。
简单来说,这道题呈现给用户的是一个极其简单的输入框,通常预期是输入一个数字,后端会执行类似SELECT * FROM table WHERE id = ‘用户输入’的查询。但题目真正的“考点”藏在后端如何处理你的输入,以及它预设的“正确”查询逻辑是什么。很多人在尝试了1‘ or ‘1’=‘1、union select等常规payload无果后,就会陷入僵局。这道题的关键在于跳出“注入”的思维定式,去思考“查询语句本身可能是什么结构”。
2. 题目环境搭建与初步侦察
2.1 模拟题目环境分析
虽然我们无法拿到原题的后端代码,但根据通用的出题模式和解题writeup,可以高度还原其环境。题目大概率使用PHP+MySQL搭建。前端的界面通常简洁到只有一个输入框和一个提交按钮。
第一步永远是信息收集。我们尝试最基本的探测:
- 输入数字:输入
1,页面可能返回Flag表(或某个预设表)中id=1的数据,比如返回一行信息。 - 输入字符串或特殊字符:输入一个单引号
‘。这是测试注入点的黄金法则。如果页面返回了数据库错误信息(如You have an error in your SQL syntax...),那说明存在注入点且错误信息未被屏蔽。但在EasySQL中,更常见的情况是页面返回一个非预期的、固定的回显,比如返回0、返回空,或者一个统一的错误提示(如“查询失败”)。这提示我们,后端可能对输入做了处理,或者查询逻辑本身就很特殊。 - 尝试逻辑测试:输入
1 and 1=1和1 and 1=2。观察页面回显是否不同。如果不同,说明布尔盲注的条件成立。但在本题中,这两种输入可能返回相同的结果,这又是一种干扰。
根据众多解题报告,一个关键线索是:当你输入1时,返回正常数据;输入除1以外的任何数字或字符,返回的结果都一样。这强烈暗示,后端的查询逻辑并非我们想象的SELECT ... FROM ... WHERE id = $_GET[‘input’]。
2.2 核心查询逻辑的逆向推测
如果WHERE条件查询不成立,那会是什么?一个非常经典的CTF套路是:后端将用户的输入直接拼接到了SELECT的查询字段中。
也就是说,真实的查询语句可能是:
SELECT $_GET[‘input’] FROM flag;或者是一个带有固定WHERE条件的查询,但输入被放在了字段位置:
SELECT $_GET[‘input’] FROM table WHERE id = 1;如果是第一种情况SELECT $_GET[‘input’] FROM flag;,那么:
- 输入
1时,语句为SELECT 1 FROM flag;。这会返回一个所有行都是1的结果集。如果后端代码简单地取第一行第一列数据回显,那么你就会看到1。如果flag表里有一行数据,且代码逻辑是“如果查询有结果就显示某字段”,那么这里可能会显示1(因为查询了常量1),也可能显示flag(如果代码逻辑是显示查询结果的第一列,而第一列是我们输入的1)。 - 输入非数字字符,比如
abc,语句变成SELECT abc FROM flag;。这时,MySQL会认为abc是一个列名。如果flag表中不存在abc这个列,查询就会报错。如果后端配置为不显示错误,就可能返回空或者一个固定值。
但题目名为EasySQL,解法通常不会涉及复杂的列名猜测。更进一步的线索来自于尝试输入*。如果输入*,查询变为SELECT * FROM flag;,这就会成功查询并返回flag表中的所有列!这很可能就是题目的预期解。
然而,直接输入*在很多复现环境中会被过滤或转义。这就引出了本题最核心的一个考点:后端对输入进行了过滤,但过滤的方式可能很简单,比如str_replace或者preg_match,只过滤了特定的关键词,而*本身并不在过滤名单中。
3. 深入解析:多种解题思路与Payload构造
基于“输入被直接用作查询字段”这一核心假设,我们可以衍生出多种攻击路径。
3.1 预期解:利用*直接查询所有列
这是最直接的解法。Payload就是:
*原理:当后端查询为SELECT $_GET[‘input’] FROM flag时,输入*使得最终语句变为SELECT * FROM flag。这会直接泄露flag表中的所有数据,flag通常就在其中。
注意:在实际做题时,可能需要查看网页源代码(Ctrl+U),因为flag可能被输出在了HTML注释里,或者前端通过某些方式隐藏了。这是CTF的常见操作。
3.2 进阶解:当*被过滤时
如果题目稍微升级,过滤了*号,我们该怎么办?这就需要利用MySQL的字符串处理函数和堆叠查询(如果支持)来“无列名”查询。
思路一:利用1或0进行布尔判断即使输入被用作字段,我们也可以输入一个子查询。例如,猜测后端语句是SELECT $_GET[‘input’] FROM flag。 我们可以输入:
(SELECT 1)这依然会返回1。但我们可以让这个子查询变得有条件。例如,猜测flag列名为flag,我们可以尝试:
(SELECT flag FROM flag)但如果不知道列名呢?这就需要用到无列名查询技术。
思路二:无列名查询技术详解在MySQL中,当使用UNION联合查询时,如果不想知道原表的列名,可以通过给查询结果设置别名来访问。 假设原查询是SELECT $_GET[‘input’] FROM flag,我们无法直接UNION。但如果后端支持堆叠查询(即mysqli_multi_query),我们可以尝试闭合前一个查询,然后执行自己的查询。
更常见的场景是,题目可能是SELECT * FROM news WHERE id = $_GET[‘id’],但过滤了or、and、union等关键词,却唯独没有过滤*。这时,*的用法就变了。但EasySQL的经典之处在于它跳出了这个框架。
对于“输入即字段”的情况,如果*被过滤,一个巧妙的方法是使用数字作为字段名。在MySQL中,SELECT 1是合法的,SELECT 1,2,3也是合法的。这些数字会被当作常量列输出。我们可以通过UNION(如果可用)来探测数据。
例如,构造Payload:
1,2,3如果后端查询是SELECT $_GET[‘input’] FROM flag,这就变成了SELECT 1,2,3 FROM flag。如果flag表只有一行,且后端代码依次回显这三个字段,我们就能在页面上的三个位置看到1、2、3。这证明了我们的可控点是多个字段。
接下来,我们可以将其中一个数字替换为子查询:
1,(SELECT group_concat(table_name) FROM information_schema.tables WHERE table_schema=database()),3这样,我们就能在第二个字段的位置上看到数据库的所有表名。进而找到flag表,再查询其内容。
3.3 利用MySQL特性:字符串与数字的隐式转换
这是本题另一个非常精妙的考点。MySQL有一个“特性”:在需要数字的上下文中,字符串会被强制转换为数字。转换规则是从字符串开头读取数字,直到遇到非数字字符为止。
考虑这个查询:SELECT ‘abc’ = 0;在MySQL中,结果是1(TRUE)。因为‘abc’被转成数字0,0=0成立。 同理,SELECT ‘123abc’ = 123;也是1。
如何利用?假设后端查询逻辑是:
SELECT * FROM table WHERE id = $_GET[‘input’]但做了严格过滤,单引号、or、and、union、空格都被过滤了。我们输入任何非数字字符,都会被这个隐式转换影响。 如果我们输入0‘ or ’1’=‘1的变形,比如0||1(假设||未被过滤,在MySQL中||是逻辑OR,但需要设置PIPES_AS_CONCAT模式,通常默认是OR),可能会失败。
但如果我们输入的就是一个非数字字符串,比如abc。查询变成:
SELECT * FROM table WHERE id = abc由于abc不是列名(在这个上下文中它被当作值),MySQL会尝试将‘abc’转换为数字,得到0。所以查询等价于:
SELECT * FROM table WHERE id = 0如果表中没有id=0的数据,则返回空。如果id=1有我们想要的数据(比如flag),我们就需要让条件为真。怎么能让id = abc这个条件为真呢?除非id的值也能被转换为字符串‘abc’,这通常不可能。
这个特性的利用在EasySQL中更可能体现在另一种场景:后端代码对查询结果进行了数字判断。例如,代码逻辑可能是:
$result = mysqli_query($conn, $sql); $row = mysqli_fetch_array($result); if ($row[0] == 1) { echo $flag; } else { echo $row[0]; }在这种情况下,查询SELECT $_GET[‘input’] FROM flag,如果我们输入1,$row[0]就是1,触发if条件,输出flag。如果我们输入abc,$row[0]会是0(因为SELECT ‘abc’返回字符串‘abc’,但在与数字1比较时,PHP会进行松散比较,字符串‘abc’在松散比较中可能不等于1,具体取决于PHP版本和设置)。这种设计就迫使我们去输入一个能让查询结果在PHP中等于1的值。
为了让SELECT ‘我们输入’的结果在PHP中等于1,我们需要输入一个在PHP松散比较中与数字1相等的字符串。在PHP中,字符串“1”、“1abc”等在与数字1比较时都会返回true。但在MySQL的SELECT中,SELECT ‘1abc’返回的就是字符串‘1abc’。只要这个字符串在PHP那边能被当成1就行。
这其实将漏洞从SQL注入转移到了PHP的类型混淆漏洞上。虽然EasySQL原题可能没这么复杂,但这种思路在CTF中非常常见,体现了出题人对语言特性深度结合的理解。
4. 实战操作:手把手复现与Flag获取
我们假设一个最接近原题的场景进行复现。后端关键代码如下(模拟):
<?php include(‘config.php‘); $input = $_GET[‘query‘]; // 模拟一个简单的过滤,只过滤了union、select、from等关键词,但过滤不全 $filter = array(‘union‘, ‘select‘, ‘from‘, ‘where‘, ‘or‘, ‘and‘, ‘ ‘); $input = str_ireplace($filter, ‘‘, $input); // 不区分大小写替换为空 // 关键查询逻辑 $sql = “SELECT “ . $input . “ FROM flag“; $result = mysqli_query($conn, $sql); if($result) { $row = mysqli_fetch_array($result); echo $row[0]; } else { echo “Error!“; } ?>复现步骤:
- 测试过滤规则:输入
union select,发现页面返回Error!或空,因为关键词被替换为空后,输入变成了空字符串,查询为SELECT FROM flag,语法错误。 - 测试
*:输入*。由于*不在过滤列表中,查询SELECT * FROM flag成功执行。如果flag表存在且有一列数据,这列数据(即flag)就会被$row[0]取出并回显。成功获取Flag。 - 如果
*被过滤:我们在过滤数组中加入‘*‘。此时输入*会被移除,查询又变成SELECT FROM flag,报错。 - 绕过过滤:我们需要构造一个能表达“所有列”但又不用
*的方法。在MySQL中,可以使用反引号包裹的表名.*,但这里需要表名。如果我们不知道表名(虽然这里我们知道是flag),可以尝试利用注释和字符串拼接。- 尝试输入:
/*!32302 1/0*/。这是一个MySQL版本特有的注释语法,在某些情况下可以执行表达式。但这里可能不适用。 - 更可靠的方法是:如果过滤函数是
str_replace,且只执行一次,我们可以双写关键词绕过。例如,过滤select,我们输入selselectect,过滤函数把中间的select去掉,剩下的部分拼起来正好又是select。但本题过滤的是输入内容本身,不是SQL语句。我们需要让$_GET[‘input’]的值在经过过滤后,变成一个有效的SQL字段表达式。 - 假设我们想让最终字段是
*,但*被过滤。我们可以尝试输入**。如果过滤是简单的str_replace(‘*‘, ‘‘, $input),那么输入**会被移除所有*,变成空。不行。 - 换个思路:我们能否不用
*,而是直接查询出flag列?我们需要知道列名。如果支持堆叠查询,我们可以先执行;show columns from flag;,但分号和show可能被过滤。 - 利用无列名查询的终极方法:如果后端支持
UNION且过滤不严,我们可以尝试让原查询变成SELECT 1,然后通过UNION注入。但本题原查询结构特殊,UNION可能难以直接拼接。
- 尝试输入:
对于这个特定过滤的模拟环境,如果*被过滤,最简单的绕过方法其实是利用过滤函数的一个缺陷:它把空格也过滤了。这看起来很严格,但实际上破坏了很多语法。然而,在SELECT字段列表的位置,可以没有空格。例如SELECT*FROM flag是合法的SQL。但我们的输入是$_GET[‘input’],它被放在SELECT和FROM之间。如果我们的输入本身包含*,它会被过滤掉。我们无法绕过对*本身的过滤。
此时,我们必须放弃使用*,转而使用数字常量作为字段,并通过子查询获取数据。但这里有一个巨大障碍:select、from关键词也被过滤了。我们的输入(select flag from flag)会被过滤成( flag flag),无效。
突破点:str_ireplace是顺序替换且多次执行的吗?不,str_ireplace对数组中的每个搜索值,在目标字符串中全部替换一次。但它不是递归的。也就是说,如果过滤数组是[‘select‘, ‘from‘],输入selselectect,它会找到中间的select并替换为空,结果是sel ect(中间有个空格),而不是select。因为替换只发生一次,不会对替换后的结果再次扫描select。
那么,有没有不被过滤的、能执行子查询的方法?在MySQL中,有一种语法叫做SELECT (SELECT ...),即标量子查询。我们可以尝试: 输入:(select(flag)from(flag))注意,这里用括号代替了空格。在SQL中,某些情况下括号可以起到分隔作用。select(flag)from(flag)在MySQL中是否合法?测试一下:SELECT (SELECT flag FROM flag)是合法的标量子查询。但SELECT (SELECT(flag)FROM(flag))呢?SELECT(flag)FROM(flag)缺少空格,语法错误。所以此路不通。
由此可见,原题[SUCTF 2019]EasySQL之所以“Easy”,很可能就是因为它的过滤非常简单,甚至没有过滤*,旨在引导选手发现“输入即字段”这一核心点。一旦选手尝试了*,题目即告破解。而它的变体或加强版,才会引入上述复杂的过滤和绕过。
5. 防御视角:从题目漏洞看安全编码
这道题虽然简单,但暴露的编程误区却非常典型。
漏洞根源:
- 将用户输入直接拼接进SQL语句结构(非值部分):这是最致命的。永远不应该让用户控制
SELECT、UPDATE、WHERE等关键字之后的字段名、表名、关键字本身。用户输入只应作为查询的值,并且必须使用参数化查询(预处理语句)来处理。 - 过滤机制存在缺陷:使用简单的黑名单替换(
str_replace)是极其不可靠的。攻击者可以通过双写、大小写混淆、使用等价函数或语法(如||for OR、&&for AND)、利用注释、编码等方式轻松绕过。 - 错误信息处理:虽然本题可能屏蔽了错误信息,但很多初级开发会暴露SQL错误,这给了攻击者宝贵的调试信息。
正确的防御措施:
- 使用参数化查询(预处理语句):这是防止SQL注入的银弹。无论是PDO还是MySQLi,都支持预处理。将用户输入作为参数绑定,数据库会严格区分代码和数据。
// PDO 示例 $stmt = $pdo->prepare(“SELECT * FROM news WHERE id = :id“); $stmt->execute([‘id‘ => $user_input]); // MySQLi 示例 $stmt = $conn->prepare(“SELECT * FROM news WHERE id = ?“); $stmt->bind_param(“i“, $user_input); // ‘i‘ 表示整数类型 $stmt->execute(); - 如果必须动态拼接SQL结构(如动态表名、字段名),请使用白名单:确保用户输入的值只能在一个预定义的、安全的集合中选择。
$allowed_columns = [‘id‘, ‘title‘, ‘content‘]; $column = $_GET[‘sort‘]; if (!in_array($column, $allowed_columns)) { $column = ‘id‘; // 默认值 } $sql = “SELECT * FROM news ORDER BY “ . $column; - 最小权限原则:数据库连接用户不应拥有
DROP、FILE、GRANT等高级权限,仅赋予其应用所需的最小权限(通常是SELECT、INSERT、UPDATE、DELETE)。 - 关闭错误回显:在生产环境中,确保
display_errors设置为Off,将错误记录到日志中,而不是展示给用户。
从这道“简单”的SQL注入题中,我们学到的远不止一个Payload。它更像一个警示:安全是一个整体,任何一个环节的想当然,都可能打开潘多拉魔盒。对于开发者,要时刻对用户输入保持敬畏;对于安全研究者,则要不断打破思维定式,从代码执行的根本逻辑上去寻找突破口。这道题的价值,正在于它用最朴素的形式,揭示了SQL注入中最本质的“数据与指令混淆”问题。
