☰
SQL注入攻击与防御:从原理到预编译修复实践
2026/10/9 10:37:59 网站建设 项目流程

先说明一句:本文所有示例都基于本地自建靶场、CTF平台或你拥有的测试环境,千万不要对着线上真实系统试。SQL注入这类技能,会利用不等于会攻击,真正的价值在于你理解了攻击者的思路之后,能写出更不容易被攻破的代码。花了几个晚上手工测漏洞、写自动化脚本之后,我对“为什么预编译能防注入”这件事的理解彻底不一样了。这篇就把我整理过的原理、分类、实操和防护一起讲清楚。

1. 项目定位与核心思路拆解

1.1 SQL注入的本质:就是把“用户输入”和“SQL代码”混在了一起

很多新手第一次听到“SQL注入”的时候,脑子里会冒出“黑客往数据库里塞东西”的画面。实际上没有这么玄乎,它的本质就是一个非常简单的工程问题:你的程序把用户输入的内容直接拼进了SQL语句里,导致用户输入的一部分被当成SQL代码执行了。

我习惯用一个生活化的类比来解释。想象你要做一张员工名单表格,别人递给你一张便利贴,上面写着“张三”。你直接把便利贴贴到表格里,这张表就很干净。但如果便利贴上写的是“张三以及所有离职人员名单”,而你连看都没看就直接贴上去,那么这张表就混进了别人的东西。SQL注入就是这种情况——你接收了一个输入,没做任何加工,直接拼进数据库查询语句,输入里夹带的“SQL代码”就被数据库当成指令执行了。

这个问题之所以几十年来一直没绝迹,是因为早期Web开发几乎没有“输入校验”这个概念。你看看2008年前后的老项目,十有八九是下面这种写法:

$sql = "SELECT * FROM users WHERE username = '" . $_POST['username'] . "' AND password = '" . $_POST['password'] . "'";

用户输入一个admin' --,拼出来就变成了:

SELECT * FROM users WHERE username = 'admin' --' AND password = '...'

--在SQL里是注释开头,后面的内容全被忽略。于是密码校验形同虚设。这不是程序员蠢,而是那个年代确实没有统一的安全开发规范,而且很多业务逻辑就是“需要动态拼接SQL”才实现的。

1.2 攻击链全景:从一个输入框到整个数据库沦陷

理解了本质,你就能顺理成章地推演出一条完整的攻击链。我在实际测试中见过的最典型路径是这样的:

  1. 发现注入点——攻击者在登录框、搜索框、URL参数后面加一个单引号,页面报错或者行为异常,说明SQL语句结构被改变了。
  2. 确认注入类型——通过order by测字段数、通过and 1=1和and 1=2看页面差异,判断是联合查询注入还是盲注。
  3. 获取数据库信息——利用union select直接查version()、database()、user(),先摸清楚数据库类型和版本。
  4. 拖取敏感数据——查information_schema.tables找到表名,再查字段,最后把用户表、订单表、密码哈希全部拖走。
  5. 尝试提权或横向——如果数据库账号权限够大,直接写文件拿webshell,或者通过数据库的功能执行系统命令。

每一步都有对应的payload,但核心就一句话:攻击者把一个非预期的输入,变成了数据库能理解并执行的指令。以前很多甲方只给登录接口加验证码,以为能挡住注入,但实际上注入点常常藏在搜索、排序、导出等不起眼的参数里,验证码根本拦不住。

1.3 谁适合学这个,以及应该用什么心态学

我自己带过几个新人,总结下来,适合学SQL注入的主要有三类人:

  • Web后端工程师——写接口的时候不知道自己写的SQL有什么问题,学注入是为了写出安全的代码。
  • 安全测试初学者——想入门Web渗透测试,SQL注入是OWASP Top 10里最经典、最能训练逻辑思维的一类漏洞。
  • CTF选手——像bugku这类平台很多SQL注入题目,掌握了手工注入和脚本编写,解题速度会快很多。

还有一句话我必须说在前面:SQL注入本质是“测试代码健壮性”的技能,不是用来搞破坏的。所有练习都应该在你自己搭的靶场、内网测试环境或者CTF平台里进行。我见过有新人学了两节课就去测别人的网站,这不仅违法,而且一旦被溯源,下场很惨。安全圈子的底线是“先授权,后测试”。

2. 核心原理与常见分类

2.1 基础中的基础:联合查询注入

联合查询注入是理解SQL注入的门槛,也是所有后续变体的基础。它利用的是SQL里UNION SELECT可以把多条查询结果合并返回的特性。

比如一个查询语句长这样:

SELECT id, username, email FROM users WHERE id = 1;

如果我们把id参数改成1 union select 1, user(), version(),那么数据库会执行两条查询:

SELECT id, username, email FROM users WHERE id = 1 UNION SELECT 1, user(), version();

第一条查出正常用户,第二条直接返回数据库当前用户和版本号。页面会把两条结果一起渲染出来,攻击者就拿到了想要的信息。

但是这里有一个强制前提:前后两条查询的字段数必须一致。如果第一条查3列,第二条只查2列,数据库会直接报错。所以攻击者首先要判断原来的SQL语句查了几列,最常用的方法是order by:

?id=1 order by 1 正常 ?id=1 order by 2 正常 ?id=1 order by 3 正常 ?id=1 order by 4 报错

当order by 4报错时,说明这个查询只有3列。拿到列数之后,就能推导出union select该填几个字段。

我在bugku上做过一道很经典的入门题,流程就是:加单引号看报错 → 用order by测列数 → union select爆出数据库名 → 查表名 → 查字段 → 拿flag。整个过程一气呵成,每一步都能验证,非常适合用来建立手工注入的感觉。

2.2 万能密码绕过:为什么一句' or 1=1 --就能登录

“万能密码绕过”是SQL注入里名气最大的一个场景,网上搜SQL注入相关热词,这个基本必上榜。它看起来像是传说,但原理极其简单。

假设后端代码是这样写的:

sql = "SELECT * FROM users WHERE username = '%s' AND password = '%s'" % (username, password) user = db.execute(sql).fetchone() if user: # 登录成功 ...

用户名的输入框里填:

admin' or 1=1 --

拼出来的SQL就变成了:

SELECT * FROM users WHERE username = 'admin' or 1=1 --' AND password = '...'

这段SQL的逻辑变成:用户名是admin,或者1=1。1=1是恒真的,所以整条WHERE条件永远成立。--把后面的AND password = '...'注释掉,密码验证直接失效。数据库返回第一行用户记录,通常是管理员账号,登录就成功了。

同样经典的还有' or 1=1#和' or '1'='1这类变体,原理都一样:闭合前端引号,构造恒真条件,注释掉剩余语句。

那为什么现在的系统很少能这样绕过了?不是因为开发者变聪明了,而是因为大部分现代框架默认用参数化查询,后面第4章会细讲。但在一些老旧的PHP项目、自己拼SQL的系统中,这个绕过依然十分有效。我自己排查过一个客户遗留系统,运维说“有账号密码不知道怎么泄露的”,我一看代码,登录接口还在用字符串拼接,直接用' or 1=1 --就进去了。

2.3 布尔盲注与时间盲注:当页面不报错时,怎么拿数据

不是所有SQL注入都会在页面上直接显示查询结果,也很多场景下报错信息被屏蔽了,页面只返回“登录成功”或“查询无结果”这种固定状态。这时候就需要用到盲注。

布尔盲注的原理是:让SQL语句返回真或假,观察页面差异。例如:

?id=1 and 1=1 页面正常 ?id=1 and 1=2 页面为空或异常

只要这两个请求的响应有可观察的差异,就可以用它来逐字符“猜”数据。比如猜当前数据库名的第一个字符:

?id=1 and ascii(substr(database(),1,1))>100 页面正常 ?id=1 and ascii(substr(database(),1,1))>120 页面异常

通过不断调整阈值,二分法定位具体字符。这个阶段对应的热搜词“python sql注入原理”大多就是指写脚本做这种自动化的字符探测。

时间盲注是布尔盲注的进阶版,适用于页面完全没有差异的情况(比如报错被统一处理成同一个页面,查询结果也不影响渲染)。它的原理是利用数据库的延时函数:

?id=1 and if(ascii(substr(database(),1,1))>100, sleep(3), 0)

如果条件为真,数据库会执行sleep(3),整个请求耗时3秒以上;如果条件为假,立即返回。用响应时间作为“真/假”的判断依据,思路和布尔盲注一模一样,只是信号从“页面差异”变成了“响应时间差异”。

我自己写时间盲注脚本的时候踩过一个坑:sleep()函数在MySQL里是可以嵌套在if()里的,但在Oracle里就没有sleep()而是dbms_pipe.receive_message,在PostgreSQL里又是pg_sleep()。所以你要先去识别数据库类型再选延时函数,否则脚本跑半天全是超时。

2.4 报错注入:让数据库把错误信息当作“传输信道”

还有一种常见类型是报错注入,它在页面会输出数据库错误信息的情况下非常高效。原理是传入能让数据库函数报错的参数,报错信息里就带出了你想要的查询结果。

MySQL里最经典的写法是用updatexml()或extractvalue():

?id=1 and updatexml(1, concat(0x7e, (select database()), 0x7e), 1)

updatexml函数第二个参数期望是合法的XPath路径,当传入的内容包含~(0x7e)这种非法字符时,MySQL会抛出XPATH语法错误,错误信息里包含传入的完整字符串。于是select database()的查询结果就被放到了报错信息里返回给前端。

我第一次用报错注入的时候觉得这简直是“官方后门”,因为一条请求就能直接拿到数据库名,效率比盲注高太多了。但它有明确限制:报错信息长度有限(通常一次只能带出几十个字符),且必须看到数据库错误输出才有效。现在的生产系统一般不会把详细的数据库异常直接抛给用户,但开发环境、内网测试系统里经常能看到。

3. 实操过程与核心环节实现

3.1 搭建本地测试靶场:一个带漏洞的登录接口

为了讲清楚完整流程,我先把一个故意留着SQL注入漏洞的Flask登录接口写出来。你可以直接存成app.py,本地跑起来当靶场。

from flask import Flask, request, jsonify import sqlite3 app = Flask(__name__) def create_table(): conn = sqlite3.connect('test.db') c = conn.cursor() c.execute('CREATE TABLE IF NOT EXISTS users (id INT, username TEXT, password TEXT)') c.execute('INSERT INTO users (id, username, password) VALUES (1, "admin", "secret")') c.execute('INSERT INTO users (id, username, password) VALUES (2, "test", "123456")') conn.commit() conn.close() @app.route('/login', methods=['GET']) def login(): username = request.args.get('username', '') password = request.args.get('password', '') # 漏洞点:直接拼接SQL conn = sqlite3.connect('test.db') c = conn.cursor() sql = "SELECT * FROM users WHERE username = '%s' AND password = '%s'" % (username, password) c.execute(sql) user = c.fetchone() conn.close() if user: return jsonify({'status': 'success', 'user': user[1]}) return jsonify({'status': 'fail'}) if __name__ == '__main__': create_table() app.run(debug=True, port=5000)

启动之后,用浏览器访问:

http://127.0.0.1:5000/login?username=admin&password=secret

能正常登录。接下来用万能密码试一下:

http://127.0.0.1:5000/login?username=admin' or 1=1 --&password=anything

返回status: success,说明注入点存在。你在自己电脑上复制这段代码可以放一百个心,这是本地环境,不会有任何风险。

注意一个细节:SQLite支持--注释符,但注释符后面必须跟一个空格,URL编码里用--%20或直接--都可以。有些数据库要求不一样,MySQL是--或#,SQL Server是--,PostgreSQL是--。所以payload换数据库的时候要记得对应调整。

3.2 手工探测注入点:一个完整的现场记录

我用刚才的靶场走一遍手工探测流程,这个过程就是你在CTF或真实测试里最常用的一套动作。

第一步,正常访问:

/login?username=admin&password=secret 返回 success

第二步,加一个单引号,破坏SQL结构:

/login?username=admin'&password=secret 返回 fail 而且控制台能看到SQL语法错误

这可以确认单引号被带进了SQL语句且无法闭合,基本实锤有注入。

第三步,尝试闭合和注释:

/login?username=admin' --&password=secret 返回 success

单引号闭合了前面的字符串,--注释掉后面的密码条件。此时SQL变成:

SELECT * FROM users WHERE username = 'admin' -- AND password = 'secret'

登录成功。注意这一步能成功不代表就一定能被利用,只是说明结构被改变了。

第四步,猜解字段数。用order by配合UNION前的准备工作:

/login?username=admin' order by 1 -- 返回 success /login?username=admin' order by 2 -- 返回 success /login?username=admin' order by 3 -- 返回 fail

这说明users表只有2列(在这个靶场里就是id和username)。

第五步,union select爆数据:

/login?username=admin' union select 1, sql from sqlite_master --

这条在SQLite里可以列出所有表的建表语句。如果换成MySQL,可以查information_schema.tables。返回结果里如果带出了“users”的建表语句,后面就能针对性地拖数据。

整套流程的核心是:先确认注入存在 → 再确认闭合方式 → 然后测列数 → 最后爆数据。我见过很多人上来就甩一个超大payload,页面报错之后完全不知道从哪debug,就是因为跳过了前面的一步一步验证。

3.3 Python自动化脚本:二分法布尔盲注实现

手工注入一次只能看一个结果,效率太低。真实测试中数据是靠脚本一字符一字符“抠”出来的。下面这段是我常用的一段布尔盲注脚本,逻辑清晰,适合初学者读懂再改进:

import requests url = "http://127.0.0.1:5000/login" def is_true(condition): payload = f"admin' and {condition} -- " r = requests.get(url, params={"username": payload, "password": "x"}) return r.json().get("status") == "success" # 二分法获取字符 def get_char(sql, pos): low, high = 32, 126 while low < high: mid = (low + high) // 2 # 判断字符是否大于mid,真则向右半区继续 cond = f"ascii(substr(({sql}), {pos}, 1)) > {mid}" if is_true(cond): low = mid + 1 else: high = mid return chr(low) # 获取表名长度 sql = "SELECT name FROM sqlite_master WHERE type='table' LIMIT 1" length_cond = f"length(({sql})) > 5" print("长度大于5:", is_true(length_cond)) # 逐个字符读出第一个表名 name = "" for i in range(1, 10): ch = get_char(sql, i) if ch == chr(32): break name += ch print(f"第{i}个字符: {ch}") print("表名:", name)

这里关键点有两个:

第一,is_true()函数根据登录响应判断条件真假。因为万能密码导致的登录成功和正常登录成功在返回值上没有区别,所以只要“成功”就能说明条件为真。如果你的注入点不返回成功失败,而是返回不同的页面标题或状态码,就把判断条件改成对应的差异即可。

第二,get_char()用的是标准的二分查找,从ASCII码32到126(可打印字符范围)每次都把区间缩小一半。读一个字符最多8个请求,读10个字符总共80个请求左右,手工做这些请求会崩溃,但脚本几秒钟就能跑完。这就是为什么Python能显著提高测试效率。

如果你要处理的是基于时间的注入,只需要把is_true改成用响应时间判断,比如r.elapsed.total_seconds() > 2代表真,逻辑完全一致。

3.4 CTF实战建议:以bugku的注入题为例

bugku上有不少SQL注入题目,特别适合新手把上面这套方法论落地。我建议按照这个顺序做:先做“万能密码登录”这类送分题,再做“联合查询注入”的基础题目,最后尝试盲注和报错注入题。

练习的时候有一个习惯我很推荐:**不要直接看writeup,先自己拿BurpSuite或浏览器开发者工具观察请求结构,然后手动试payload,录下每一步的输出。**等做不出来再看题解,对比自己和别人的思路差异。我当初就是在bugku上做一道注入题时卡了很久,原因是题目过滤了空格,需要把空格替换成注释符/**/,从那以后我才意识到,注入不只是“会不会”,还要会跟过滤规则绕。

CTF题和真实测试最大的区别在于:CTF靶场通常有明确的“目标数据”和“终点”,但真实系统你不知道数据在哪,也不知道数据库结构,每一步都要靠信息收集推进。所以训练时建议有意识地去练习“无源码、仅有黑盒”情况下的信息收集能力。

4. 防护与修复方案

4.1 参数化查询:为什么它能从根本上消除注入

防护SQL注入的方法非常多,但业界公认的“银弹”是参数化查询,也叫预编译查询。它的核心思想是:在数据库端先把SQL语句结构编译好,再把用户输入当作纯数据“绑定”进占位符里,用户输入永远不可能改变SQL的结构。

拿刚才那个漏洞接口的修复版做个前后对比:

# 修复前 sql = "SELECT * FROM users WHERE username = '%s' AND password = '%s'" % (username, password) # 修复后 conn = sqlite3.connect('test.db') c = conn.cursor() sql = "SELECT * FROM users WHERE username = ? AND password = ?" c.execute(sql, (username, password))

修复后你再传admin' or 1=1 --,数据库拿到的是“一个值为admin' or 1=1 --的字符串参数”,用于跟username字段做等值比较,那个字符串里的一切都不会被当作SQL语法解析。单引号、注释符、or关键字全会变成普通字符。

这个原理用大白话讲就是:以前你是把用户的便利贴原封不动贴进Excel里的公式里,Excel会去执行公式。参数化查询是先画好公式的骨架,然后把用户的便利贴内容填进对应的格子,Excel永远只把它当文本处理。所有主流语言都支持:Python的?/%s,Java的PreparedStatement,Go的database/sql占位符,PHP的PDO::prepare。最重要的是,这是数据库驱动层面的能力,不是某个框架的独有功能。

4.2 输入校验:哪些地方真的需要校验,哪些地方是伪需求

很多人一说到防注入就想到“过滤关键字”,比如把or、and、select全筛掉。这类黑名单方案我建议直接放弃,理由很实在:你永远无法穷举所有可能的payload变形,而且过度过滤还会误伤正常业务数据。

真正有效的输入校验只有一种:类型约束 + 白名单约束。类型约束是针对输入本身的数据类型做的强校验:

if not re.fullmatch(r'\d+', user_id): raise ValueError("user_id 必须是数字")

白名单约束是针对枚举值场景做的校验,比如排序字段,只允许传id或created_time,其他值一律拒绝:

order_by = request.args.get('order_by', 'id') if order_by not in ('id', 'created_time'): order_by = 'id'

在个别场景下白名单校验比参数化查询更安全。比如排序字段和表名这种无法用占位符绑定的SQL组成部分,你必须用白名单来保证它们不会变成用户的任意输入。我见过一个案例,后端已经用了参数化查询,但动态表名直接拼进去,结果被表名位置的注入打穿了。最好的防御思路是:能参数化的尽量参数化,不能参数化的必须白名单校验。

4.3 数据库最小权限:就算被注入,损失也能控制到最小

纵深防御这句话说了无数次,但很多团队实际上只做了第一层。数据库账号权限隔离,就是被注入之后控制损失范围的关键手段。

我举一个最典型的配置:

  • Web应用连接数据库的账号:只给它SELECT权限,项目需要的话再加INSERT / UPDATE,不要给DROP、FILE、GRANT这类高危权限。
  • 备份账号:单独的账号,只允许读和导出。
  • 管理账号:只能在数据库服务器本地或者跳板机登录,禁止从应用服务器直连。

如果每个Web应用都只用root级别的数据库账号,那么一次注入就能直接读所有库的数据甚至写文件到服务器磁盘。而如果应用账号权限最小化,就算被注入,攻击者最多只能拖出这个库的数据,写webshell、跨库查询、执行系统命令全都做不了。

从实际操作角度,我的建议是:给每个应用创建一个独立账号,密码随机生成并定期轮换,并在数据库层面开启审计日志记录所有SQL操作。这些动作的成本很低,但发生安全事件之后就是“伤及皮毛”和“彻底沦陷”的区别。

4.4 修复前后效果对照:一张表看清差距

维度拼接SQL(有漏洞)参数化查询(已修复)
用户输入admin' or 1=1 --时被当作SQL语法执行被当作普通字符串比较
单引号作用闭合SQL字符串,改变结构无特殊含义,就是字符本身
报错信息暴露风险拼接错误直接抛数据库异常绑定参数阶段无法改变结构
SQL注入漏洞存在可能性有几乎没有
动态表名/字段如何处理不适用,仍需白名单兜底白名单校验后拼接

这张表基本能回答“为什么修好了还是被绕过”的问题。如果你的修复方案只是加了一段if "or" in username: return 403,那新变体稍微变形一下就能绕过;但你把execute(sql, (username, password))写对了,那些绕过技巧全部失效。

5. 常见问题与排查技巧实录

5.1 常见问题速查表

现象可能原因排查方向
加单引号后页面报500存在注入点,SQL语法被破坏看后端错误日志定位SQL语句
order by 1正常但order by 5报错字段数小于5逐步缩小,直到找边界
union select一直报“列数不一致”前后查询列数不对用order by确认列数再调整
万能密码登录不成功注释符不对或拼接方式不同尝试#、--、--+,确认闭合方式
布尔盲注所有请求页面都一样存在全面错误处理改用时间盲注
时间盲注脚本跑得很慢网络延迟干扰判断设置阈值(比如响应>1.5秒为真)并多次取样
同一套payload在MySQL有效、SQLite无效数据库方言差异先确认数据库类型再选对应函数

这张表是在我处理各种环境的过程中攒下来的,维度很实用。你练习的时候遇到问题,先对号入座,再去查具体细节,效率会高很多。

5.2 几个实操中的坑,我替你踩过了

第一个坑是空格被过滤。很多站点会拦截带空格的SQL关键词,常见的绕过方式是用注释符替代空格,比如/**/、--+。在MySQL里select/**/user()和select user()效果一样。还有一个技巧是用Tab(%09)和换行(%0a)替代空格。我在bugku上碰到过只过滤空格的题,用%0a就过了。

第二个坑是大小写绕过。某些简单过滤器只匹配小写select,但数据库是大小写不敏感的,SeLeCt一样能执行。如果你遇到过滤器,先试一下大小写混排,成本几乎为零,成功率还不低。

第三个坑是单引号被转义。有些代码使用mysql_real_escape_string或addslashes这类函数,单引号会被转义成\'。但宽字节注入(GBK编码场景)可以吃%bf%27这种组合绕过,编码问题通常需要你关注页面的字符集。遇到这类情况,我建议先确认应用的字符集再决定payload,而不是盲目堆测试。

第四个坑是盲注脚本超时。时间盲注里网络本身有延迟,如果你设置的阈值太低,真实条件和假条件判断很容易混。我的习惯是先发10个正常请求测一下基线延迟,然后取基线延迟的两倍以上做阈值。比如正常请求300毫秒,那我用 sleep(1) 做时间盲注,判断条件就是响应时间 > 1.2秒为真。

5.3 最后的进阶建议

说了这么多,如果你真的想把SQL注入这套东西内化,我的建议是:**从一个注入脚本开始,完成一次完整的从获取数据库版本到拖取数据的过程,然后尝试把这个过程整理成自动化工具,再尝试写防护代码来防御自己的工具。**很多年前我在本地靶场就是这么干的,那一次之后我对SQL执行流程的理解上了一个台阶,对“预编译为什么安全”的体验也彻底变成了肌肉记忆。

再补充一个我平时做安全测试的习惯:每次手工测试时,我会把BurpSuite的请求历史和响应码完整保存,并标注当时的注入类型和payload。一旦后面需要复盘流程,直接翻历史记录比重新跑一遍快得多。学习阶段养成这个习惯,工作后会受益很多。

这篇的核心内容就到这里。你要是卡在某个环节,比如order by测列数怎么都测不对,或者时间盲注脚本跑不出数据,大概率是闭合方式和数据库类型没确认好,回去把第2章和第3章对照着再顺一遍,问题基本能解决。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询