
1. 项目概述一次典型的CTF SQL注入实战复盘最近在带新人入门CTFCapture The Flag网络安全竞赛发现很多朋友对SQL注入这类基础但核心的漏洞原理和利用手法理解得还是不够透彻。正好之前带大家复盘过一道经典的题目——BUUCTF平台上的“[极客大挑战 2019]BabySQL”。这道题虽然名字叫“Baby”但对于理解SQL注入的绕过技巧、联合查询的构造以及信息获取的完整链条有着非常高的教学价值。它不是那种一个万能密码就能搞定的简单登录框而是需要你一步步分析过滤规则、构造有效载荷最终从数据库中“拖”出flag。今天我就以这道题为蓝本结合我这些年打CTF和做渗透测试的经验把SQL注入从手动测试到自动化工具辅助的完整思路和实操细节给大家掰开揉碎了讲清楚。无论你是刚接触安全的新手还是想巩固基础的同行相信这篇近万字的复盘都能让你有所收获。2. 靶场环境与初步侦察2.1 目标分析与入口点判断拿到任何一道Web题目尤其是CTF题第一步永远不是急着上工具而是“看”。打开题目链接我们通常面对的是一个简单的Web界面可能是一个登录框、一个搜索框或者一个显示文章列表的页面。对于“BabySQL”这道题它呈现的就是一个最经典的注入点一个登录表单。用户名和密码两个输入框一个提交按钮。这种场景下注入点往往就在这两个输入参数里。我的习惯是先用最基础的测试payload探探路。比如在用户名处输入一个单引号‘然后观察页面的回显。如果页面报错了出现了数据库的错误信息比如MySQL的“You have an error in your SQL syntax...”那几乎可以断定这里存在SQL注入漏洞并且是错误可回显的这为我们后续利用提供了极大的便利。如果页面只是登录失败没有具体错误那可能是盲注。在“BabySQL”中我们输入单引号后页面直接返回了SQL语句的报错信息这非常好说明后端没有对错误信息进行屏蔽我们可以利用这些错误信息来获取数据库结构。注意在实际的渗透测试或CTF中这种直接显示数据库错误的情况已经比较少了生产环境通常会关闭错误回显。但作为入门学习这种环境能让你清晰地看到自己构造的SQL语句是如何被拼接和执行的是理解原理的绝佳方式。2.2 理解后端SQL逻辑与过滤机制通过输入admin‘或1‘等payload看到报错信息后我们可以尝试还原后端的SQL查询语句。典型的登录验证SQL可能是SELECT * FROM users WHERE username‘输入的用户名‘ AND password‘输入的密码‘当我们输入admin‘时语句就变成了SELECT * FROM users WHERE username‘admin‘‘ AND password‘...‘由于多了一个单引号破坏了语法所以数据库报错。下一步我们需要判断注入的类型。是字符型还是数字型通常如果参数被单引号包裹就是字符型如果没有就是数字型。我们可以用and 11和and 12来测试。在“BabySQL”中我们发现输入admin‘ and ‘1‘‘1可以正常返回或绕过登录而admin‘ and ‘1‘‘2则失败。这证实了是字符型注入并且and这个关键字似乎没有被过滤。但是当我们尝试使用更强大的union select进行联合查询时发现页面返回了异常提示某些关键字不见了。这立刻引起了我的警觉题目存在关键字过滤。经过一番手动测试比如输入unionunion selectselect看是否返回union select我发现题目过滤了or、and、select、union、where等一些常见关键字并且是简单的删除处理。例如你输入union select它会把中间的union和select字符串删掉导致你的语句失效。这种过滤虽然简单粗暴但足以拦住只会用模板payload的新手。我们需要做的就是双写绕过比如把union写成ununionion把select写成selselectect这样当系统删除中间的union和select后剩下的字符恰好又能组合成新的union和select。3. 核心注入技巧绕过过滤与信息获取3.1 双写绕过与联合查询构造知道了过滤规则是“删除特定关键字”双写绕过就是我们的核心技巧。但这要求我们对SQL语句的构造有清晰的理解。我们的目标是利用union select查询来获取数据库中的数据。union操作符用于合并两个或多个SELECT语句的结果集前提是每个SELECT语句必须拥有相同数量的列且列的数据类型也要相似。所以第一步是判断当前查询的列数。这里通常使用order by或union select null,null,...来逐次增加列数进行测试。由于order by可能也被过滤我们优先使用union select。因为有关键字过滤我们的payload需要写成这样admin‘ ununionion selselectect 1,2,3 --这里解释一下admin‘闭合前面的用户名引号。ununionion双写绕过最终生效的是union。selselectect双写绕过最终生效的是select。1,2,3这是我们猜测的列数从1开始逐渐增加直到页面正常显示不报错。如果列数是3那么union select 1,2,3就能和原查询成功合并。--或#这是SQL注释符用于注释掉原SQL语句中剩下的部分比如AND password‘xxx‘避免语法错误。--是SQL注释在URL中通常代表空格。有时也需要用%23#的URL编码。通过测试我们确定了原查询的列数。假设是3列。那么在页面回显中1,2,3这些数字的位置可能会被显示出来。这至关重要因为它告诉我们我们可以把想要查询的数据放在这些会被显示出来的列的位置上。比如如果数字“2”在页面上显示出来了那么我们把2替换成database()就能查看到当前数据库名。3.2 系统信息获取与数据库结构探查一旦确定了回显点信息获取就变得有章可循。我们可以像爬楼梯一样一步步获取所需信息。一个标准的SQL注入信息获取顺序如下当前数据库名selselectect 1,database(),3数据库版本selselectect 1,version(),3当前数据库用户selselectect 1,user(),3在MySQL中所有的数据库、表、列信息都存储在名为information_schema的元数据库中。这是我们的“藏宝图”。接下来我们要获取当前数据库中有哪些表。获取所有表名ununionion selselectect 1,group_concat(table_name),3 frfromom information_schema.tables whwhereere table_schemadatabase()注意from和where也需要双写绕过。group_concat()函数非常好用它能把查询到的多行结果合并成一个字符串用逗号分隔方便我们一次性查看。table_schemadatabase()这个条件限定了只查询当前数据库下的表。获取指定表的所有列名 假设我们上一步查到一个名为users的表看起来像是存放用户信息的flag也可能在里面或者在一个叫flag的表里。我们需要知道这个表有哪些列。ununionion selselectect 1,group_concat(column_name),3 frfromom information_schema.columns whwhereere table_name‘users‘ and table_schemadatabase()这里我们给table_name传递了字符串‘users‘所以要注意闭合引号。如果‘被过滤可以尝试用十六进制编码比如0x7573657273‘users‘的十六进制。拖取目标数据 知道了表名例如users和列名例如id,username,password我们就可以直接查询数据了。ununionion selselectect 1,group_concat(username, ‘:‘, password),3 frfromom users这条语句会把users表里的用户名和密码以用户名:密码的格式合并显示出来。如果存在一个flag列直接selselectect flag frfromom flag即可。在“BabySQL”这道题中就是按照这个流程最终在某个表的某个列里找到了flag。这个过程看似繁琐但每一步都建立在坚实的SQL语法和数据库知识之上。实操心得手动构造这些payload时很容易因为单引号闭合、空格、注释符等问题导致失败。我的经验是准备一个文本编辑器先把payload写好检查关键字绕过、引号闭合、注释符然后再粘贴到浏览器中。浏览器的地址栏可能会对特殊字符进行URL编码要注意观察。使用Burp Suite的Repeater模块来做这件事会更方便可以清晰地看到请求和响应。4. 工具辅助与效率提升SQLMap的针对性使用虽然手动注入能加深理解但在CTF比赛或者时间有限的测试中合理使用工具能极大提升效率。SQLMap是自动化SQL注入检测和利用的神器。但对于像“BabySQL”这种有关键字过滤的题目直接跑默认的SQLMap很可能失败因为它生成的payload会被过滤掉。4.1 使用Tamper脚本应对过滤SQLMap的强大之处在于它的tamper脚本机制。我们可以编写或使用现有的tamper脚本来对payload进行混淆、编码以绕过过滤。对于“双写绕过”这种过滤SQLMap虽然没有现成的专门脚本但我们可以通过其他方式配合。一种思路是我们先手动测试确定过滤的关键字列表如union, select, from, where, and, or等以及绕过方式双写。然后我们可以使用--tamper参数指定多个脚本来处理。例如space2comment脚本将空格替换为/**/有时可以绕过空格过滤。但针对双写可能需要更定制化的方法。实际上对于这种简单删除过滤最有效的方法是让SQLMap使用我们已知的有效payload。我们可以这样做使用--string或--regexp参数在手动测试时找到一个页面正常回显和错误回显时有明显区别的字符串。例如当注入成功时页面包含“Login success”字样失败时包含“Error”。我们可以用--string“Login success“来告诉SQLMap如何判断注入是否成功。使用--tampercharencode这个脚本会对payload进行URL编码有时可以绕过简单的字符串匹配过滤。降低检测等级和风险--level2 --risk2。提高level和risk会让SQLMap尝试更多类型的payload和测试参数但也会更慢、更可能触发WAF。对于CTF题目可以适当调高。最重要的指定注入点和技术如果我们已经手动确认了是字符型注入并且有错误回显可以明确告诉SQLMap--dbmsmysql --techniqueE。--techniqueE指定使用基于错误的注入技术这样SQLMap会专注于生成能触发错误信息的payload效率更高。4.2 实战中的SQLMap命令示例假设我们手动测试发现在登录表单的username参数存在基于错误的字符型注入且需要双写绕过union和select。我们无法直接让SQLMap自动双写但我们可以先让它识别注入点然后利用它的--sql-shell或--os-shell功能进行交互式查询吗这通常很难因为过滤会破坏它自动生成的复杂payload。在这种情况下更实用的方法是将SQLMap作为验证和辅助信息收集工具而非全自动利用工具。例如我们可以先用一个非常简单的payload验证注入点sqlmap -u “http://target.com/login.php“ --data“usernameadmin*password1“ -p username --dbmsmysql --techniqueE --flush-session如果它能识别出注入点我们就可以让它枚举数据库名、表名。但到了获取表数据那一步由于双写过滤它生成的UNION SELECTpayload可能会失效。这时我们可以结合手动构造的payload。例如用SQLMap跑出数据库名和表名后手动构造双写payload去查列和数据。一个更高级的用法是使用--tamper自定义脚本。我们可以写一个简单的Python脚本将payload中的union替换为ununionionselect替换为selselectect。下面是一个极简的示例脚本babybypass.py的思路#!/usr/bin/env python import re def tamper(payload, **kwargs): # 双写绕过特定关键字 bypass_dict { ‘union‘: ‘ununionion‘, ‘select‘: ‘selselectect‘, ‘from‘: ‘frfromom‘, ‘where‘: ‘whwhereere‘, ‘and‘: ‘anandd‘, ‘or‘: ‘oorr‘ } for key, value in bypass_dict.items(): payload payload.replace(key, value) return payload然后在SQLMap命令中加上--tamperbabybypass.py。这样SQLMap内部生成的所有payload都会先经过这个脚本处理实现自动双写绕过。这需要你对SQLMap的tamper脚本编写有一定了解。注意事项在CTF比赛中如果题目明确是“Baby”级别考察的就是手注和基础绕过能力通常不鼓励直接上SQLMap“秒杀”那样失去了学习意义。但在实际渗透测试中了解如何配置SQLMap应对各种过滤是必备技能。另外SQLMap的流量特征明显在有WAF的生产环境中要慎用或配合代理和延迟参数--delay使用。5. 防御视角从攻击中学习安全编码我们分析了这么久如何攻击现在换个角度如果你是开发者如何防止这样的“BabySQL”漏洞这才是学习的最终目的。5.1 SQL注入的根本原因与防御原则SQL注入的根本原因在于将用户输入的数据直接拼接到了SQL语句中并且数据被当成了代码指令来执行。防御的核心原则就是将数据与代码分离。使用预编译语句Prepared Statements与参数化查询这是最有效、最根本的防御手段。它的原理是SQL语句的模板结构预先被数据库编译用户输入的数据之后才以参数的形式传入。参数会被严格视为数据而不会被解析为SQL代码。无论是Java的PreparedStatementPython的cursor.execute(“SELECT * FROM users WHERE username%s“, (username,))还是PHP的PDObindParam其本质都是一样的。这样即使用户输入admin‘ or ‘1‘‘1这个字符串也会整体作为username字段的值去查询而不会改变SQL语句的结构。对输入进行严格的过滤与转义如果因为历史遗留问题等原因无法使用参数化查询那么必须对输入进行严格的检查。但要注意“黑名单”过滤过滤select,union,or等永远不是可靠的方法因为绕过方式太多双写、大小写、编码、注释符分割等。相对好一些的是“白名单”验证比如用户名只允许字母数字就用正则表达式^[a-zA-Z0-9]$严格限制。对于无法白名单的复杂输入可以使用数据库驱动提供的转义函数如MySQL的mysqli_real_escape_string()。但转义函数并非万能且依赖于数据库字符集容易被“宽字节注入”等手法绕过因此优先级低于参数化查询。最小权限原则连接数据库的应用程序账号不应该拥有DROP,CREATE,ALTER等高级权限通常只赋予SELECT,INSERT,UPDATE等必要权限。这样即使发生注入攻击者也无法删除表或执行系统命令能将损失降到最低。避免详细的错误信息像“BabySQL”这样直接返回数据库错误详情的情况是攻击者的“指路明灯”。生产环境必须关闭或重定向数据库错误回显给用户返回统一的、模糊的错误页面如“系统内部错误”。5.2 现代开发框架中的最佳实践现在的Web开发框架如Spring Boot (Java)、Django/Flask (Python)、Laravel (PHP)、ASP.NET Core (C#) 等其ORM对象关系映射组件或数据库访问层几乎都内置并强烈推荐使用参数化查询。Spring Boot JPA/Hibernate使用Query注解配合命名参数或位置参数或者直接使用方法名派生查询框架会自动处理参数绑定完全杜绝拼接。Django使用ORM的filter(usernameusername)或者原始查询User.objects.raw(‘SELECT * FROM users WHERE username %s‘, [username])都是安全的。PHP PDO$stmt $pdo-prepare(“SELECT * FROM users WHERE username :username“); $stmt-execute([‘:username‘ $username]);关键在于开发者要有安全意识永远不要使用字符串拼接的方式来构造SQL语句。代码审查中看到“SELECT * FROM table WHERE id“ id或“...WHERE name‘“ . $name . “‘“这样的代码必须立刻亮红灯。6. 拓展思考从CTF到真实世界的SQL注入CTF题目是理想化的、无害的沙箱而真实世界的SQL注入则复杂和危险得多。过滤与WAF的对抗升级真实网站可能有Web应用防火墙WAF它会基于规则、语义分析甚至机器学习来拦截恶意请求。绕过WAF需要更高级的技巧比如混淆编码使用URL编码、十六进制编码、Unicode编码、HTML实体编码等多种编码方式嵌套。等价替换用代替AND用||代替OR用like代替用mid()、substr()代替substring()。注释符分割利用/**/,/*!...*/(MySQL特有注释)-- -,#等注释符来分割关键字如un/**/ion sel/**/ect。HTTP参数污染HPP、请求方式转换GET/POST、多部分表单数据等技巧来绕过对单个参数的检测。二次注入这是一种更隐蔽的注入。数据在存入数据库时被正确转义了但后来从数据库中被取出并再次用于拼接SQL查询时如果这次没有转义就会发生注入。防御二次注入要求在任何一次将数据放入SQL语句时都必须进行参数化处理。盲注与时间盲注当页面没有错误回显也没有数据回显点时就需要盲注。通过页面返回的真/假布尔盲注或者通过响应时间的差异时间盲注来逐位推断数据。这是一个极其耗时但有效的过程通常需要借助自动化脚本如SQLMap的--techniqueB或--techniqueT。堆叠查询与后续利用如果数据库驱动支持多语句查询如PHP的mysqli_multi_query攻击者可能利用注入点执行; DROP TABLE users;这样的堆叠查询造成毁灭性打击。或者利用UNION SELECT配合INTO OUTFILE将Webshell写入服务器需要绝对路径和FILE权限从而获取服务器控制权。复盘“BabySQL”这样的入门题就像是学习武术的扎马步。它训练了你对SQL语法、数据库结构、HTTP请求/响应的基本感觉。掌握了这些基础你才能更好地理解更复杂的绕过技术、自动化工具的原理以及最终如何从防御者的角度去构建更安全的系统。安全是一个攻防对抗、不断演进的过程而清晰的原理和扎实的基本功是你能在这个领域走得更远的唯一依靠。