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可以高度还原其环境。题目大概率使用PHPMySQL搭建。前端的界面通常简洁到只有一个输入框和一个提交按钮。第一步永远是信息收集。我们尝试最基本的探测输入数字输入1页面可能返回Flag表或某个预设表中id1的数据比如返回一行信息。输入字符串或特殊字符输入一个单引号‘。这是测试注入点的黄金法则。如果页面返回了数据库错误信息如You have an error in your SQL syntax...那说明存在注入点且错误信息未被屏蔽。但在EasySQL中更常见的情况是页面返回一个非预期的、固定的回显比如返回0、返回空或者一个统一的错误提示如“查询失败”。这提示我们后端可能对输入做了处理或者查询逻辑本身就很特殊。尝试逻辑测试输入1 and 11和1 and 12。观察页面回显是否不同。如果不同说明布尔盲注的条件成立。但在本题中这两种输入可能返回相同的结果这又是一种干扰。根据众多解题报告一个关键线索是当你输入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通常就在其中。注意在实际做题时可能需要查看网页源代码CtrlU因为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如果可用来探测数据。例如构造Payload1,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_schemadatabase()),3这样我们就能在第二个字段的位置上看到数据库的所有表名。进而找到flag表再查询其内容。3.3 利用MySQL特性字符串与数字的隐式转换这是本题另一个非常精妙的考点。MySQL有一个“特性”在需要数字的上下文中字符串会被强制转换为数字。转换规则是从字符串开头读取数字直到遇到非数字字符为止。考虑这个查询SELECT ‘abc’ 0;在MySQL中结果是1TRUE。因为‘abc’被转成数字000成立。 同理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如果表中没有id0的数据则返回空。如果id1有我们想要的数据比如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注入中最本质的“数据与指令混淆”问题。