☰
前端JavaScript安全漏洞利用实战:从XSS到原型链污染
2026/10/18 18:17:42 网站建设 项目流程

我最近在整理一份安全学习系列v2023的学习笔记,第六十五讲正好落在JS应用安全漏洞利用这个主题上。说实话,这一讲的内容跟我平时做前端代码审查时的场景非常吻合:不需要等下一条注入请求,而是从浏览器里那堆看似平常的JavaScript文件里,把隐藏的接口、配置、鉴权逻辑和可控渲染点一个个翻出来。这篇笔记我会延续自己的整理思路,先聊攻击面,再拆解高频漏洞类型,最后用一个本地模拟项目走一遍完整的前端漏洞利用与修复过程。

1. 为什么JS应用安全值得单独记一讲

1.1 前端JavaScript已经不只是“页面脚本”

早年很多Web应用的JavaScript只负责表单校验、弹窗提示、简单轮播,后端才是业务核心。这几年情况完全不同,前后端分离几乎成了默认架构,React、Vue这类框架把路由、状态管理、接口调用、用户会话全搬到了浏览器里。前端JavaScript实际上变成了一个“半公开的客户端程序”,里面装着接口地址、权限判断逻辑、加密算法、甚至内部管理后台的入口。

攻击者拿到的JS代码和被开发者写出来的几乎一致,顶多是被压缩混淆过。很多人觉得压缩混淆能提高安全性,实际上只能增加一点静态阅读成本,代码逻辑还是要在浏览器里执行的。只要把压缩后的文件格式化,再通过开发者工具动态调试,逻辑迟早会浮现。更重要的是,前端代码里经常能找到后端没有做二次校验的功能点,比如把“是否是管理员”的判断放在localStorage里,这种脆弱设计就是JS漏洞利用最典型的入口。

1.2 这一讲适合谁,需要哪些基础

如果你做Web渗透测试,只盯着Burp Suite里抓到的HTTP请求,往往会漏掉很多前端逻辑漏洞。而这类漏洞用传统扫描器很难发现,因为它们不一定产生独立的攻击请求,而是通过修改浏览器内存对象、覆盖全局变量、改变前端的判断条件来实现。所以这一讲对做渗透测试的人非常实用。

如果你是前端开发或者全栈开发,看这讲的价值在于反向自检。很多前端安全问题不是不会修,而是根本没意识到那行代码会成为问题。比如在模板里用v-html渲染用户评论,比如把后端返回的某个配置对象直接挂到window上,再比如为了方便调试把Token打印到控制台。这些操作在开发时“感觉没问题”,但在安全视角下全是坑。基础要求不高:懂基础的HTML和JavaScript语法,能打开浏览器控制台,理解HTTP请求和响应的基本流程就够了。

2. JS应用攻击面梳理:从点击按钮到请求发出

2.1 源码与接口暴露:前端代码是信息泄露重灾区

一打开网站,先看JS文件列表。现在大部分站点都会把代码打包成几个甚至几十个chunk文件,很多业务模块是按路由懒加载的。攻击者的习惯不是逐个文件读,而是先搜索关键词。

我在实际测试中常用的搜索列表大概是:api/、/v1/、/admin/、token、secret、appid、Authorization、password、upload、download。这些关键词能帮你在最短时间内定位接口前缀、认证方式、敏感配置。很多人以为把接口地址藏在某个子目录就安全,但前端代码把所有地址都交代了,只要在格式化后的代码里搜一遍就能找到。

比接口地址更危险的是硬编码密钥。比如微信小程序AppSecret、地图SDK的Key、短信平台密钥,甚至云存储的临时凭证。这类信息如果出现在前端JS里,基本等同于公开。我见过不止一个项目,把对象存储的AccessKey直接写在构建配置里,结果整个存储桶都能被下载。这个问题在后端接口里可能看不出异常,但在JS文件中一眼就能发现。

2.2 请求封装与鉴权逻辑:前端能不能直接决定谁登录?

前端现在普遍会封装一个请求模块,统一处理URL前缀、请求头、Token注入、错误码。这个模块看起来只是基础设施,但里面经常藏着安全缺陷。

比较常见的是把错误码401自动清除localStorage并跳回登录页,把403当成“无权限”并弹提示。听起来很合理,问题在于判断权全在前端。攻击者并不依赖前端弹窗,他会直接看请求逻辑,找出哪些接口没有被请求封装拦截,或者绕过封装直接发起请求。更直接的做法是打开控制台,手动修改Token过期时间、手动把角色字段从user改成admin,然后观察下一个请求是否带上了伪造的权限信息。

如果后端接口没有对每个请求做独立的身份和权限校验,那么前端无论怎么做防护都是白搭。所以我在测试中常做一个小实验:登录普通账号,在控制台里把本地存储里的角色改成管理员,再看管理接口是否返回数据。如果返回了,那就说明权限判断只发生在浏览器侧,这个问题值得写进报告。

2.3 路由、状态管理与第三方依赖:身份判断藏在哪里

前端路由也有类似问题。有的系统会在前端路由表里配置meta: { requiresAdmin: true },然后通过全局守卫判断用户角色。这种模式本身没问题,但判断数据往往来自Vuex、Redux或本地存储,这些数据在浏览器端都是可改的。利用方式很简单:先用普通账号登录,打开控制台修改状态管理里的角色字段,然后调用router.push('/admin'),如果页面能正常加载并请求到管理接口,就是一个典型的前端越权漏洞。

第三方依赖是另一个攻击面。很多项目会加载大量npm包、CDN脚本、统计脚本。供应链攻击一旦发生,前端所有敏感信息都可能被窃取。虽然普通渗透测试不会真的去攻击第三方依赖,但在梳理攻击面时要把外链脚本列出来,看看它们是否具备全站DOM访问权限,是否引用了可疑域名。如果站点存在CSP,还要检查script-src是否允许了不安全域名。

3. 高频漏洞类型拆解与利用链设计

3.1 XSS的三大出口:innerHTML、eval和动态加载

XSS虽然老,但依然是JS漏洞利用的重头戏。在新框架里,直接写innerHTML的机会少了,但总有例外。比如富文本编辑器、Markdown渲染、动态导入组件、模板字符串拼接。

反射型和存储型XSS的关键是根据输出位置选择不同的编码方式。如果输入回显在<script>标签内部,需要闭合标签;如果回显在HTML属性里,可能需要绕过引号过滤;如果回显在事件属性里,可以考虑图片标签的onerror。但前端框架通常会转义文本节点,所以更值得关注的是DOM型XSS。

DOM型XSS的典型链路是:参数从location.search进入,经过某个处理函数,最终到达sink。常见sink包括document.write、element.innerHTML、eval、Function、setTimeout、setInterval、insertAdjacentHTML、outerHTML。比如某个搜索页把URL参数keyword直接塞进document.getElementById('result').innerHTML,那就可以构造一个<img src=x onerror=alert(1)>触发。

在实际利用时,还需要考虑浏览器自带的一些安全机制。现代浏览器对javascript:协议和某些XSS payload做了拦截,所以单纯弹窗不一定能证明漏洞可利用。更务实的做法是尝试窃取数据:用fetch把当前页面里的敏感信息发到攻击者服务器,或者修改页面DOM诱导用户操作。但一定要注意,所有测试只能在授权靶场或自己的实验环境里做。

3.2 DOM clobbering:用HTML标签覆盖全局变量

DOM clobbering是我这几年越来越关注的一个点。它的原理很朴素:浏览器会把带有id或name属性的元素自动挂到window或document上。也就是说,如果页面里有一个<div id="config">,那么window.config就会指向这个DOM元素,而不是原来声明的JavaScript对象。

这种覆盖会导致什么?假设业务代码这么写:

var config = { admin: false, api: '/api/user' }; function showConfig() { if (config.admin) { loadAdminPanel(); } }

如果攻击者能在页面中注入一个<form id="config"><input name="admin" value="true"></form>,那么window.config就被这个表单覆盖了,config.admin会变成字符串"true"。由于非空字符串在JavaScript判断里为真,loadAdminPanel()就会被执行。

这个漏洞的巧妙之处在于它不一定需要XSS,只要能往HTML里注入一个带id的元素就行。有些站点编程式创建DOM时会把某条用户数据拼进HTML片段,数据里的标签名会受到一定限制,但允许使用id属性,这就给了DOM clobbering存活空间。我在测试时会重点检查代码里有没有像window.xxx、document.getElementById('xxx')之类的全局依赖。

3.3 原型链污染:一次JSON解析就可能让所有对象变成管理员

原型链污染属于偏逻辑但破坏力很大的前端漏洞。核心原因是JavaScript对象的原型继承机制:每个对象都有一个内部属性指向它的原型对象,读取属性时会沿原型链向上查找。

常见触发点集中在对象合并、深拷贝、配置覆盖等场景。如果代码里有一个递归merge函数,把用户输入的JSON合并到某个目标对象上,并且没有过滤__proto__和constructor.prototype,就可能污染Object.prototype。

例子:

function merge(target, source) { for (var key in source) { if (typeof source[key] === 'object' && source[key] !== null) { merge(target[key], source[key]); } else { target[key] = source[key]; } } }

攻击者提交{"__proto__":{"isAdmin":true}},merge函数遍历到__proto__时,会把它当成普通对象处理,最终把isAdmin挂到所有对象共享的原型上。之后业务代码里只要判断某个用户对象的isAdmin属性,因为读取不到自有属性,就会沿着原型链找到true。

前端原型链污染的影响范围取决于污染了哪个属性。如果污染Object.prototype.isAdmin,可能直接影响前端权限;如果污染Object.prototype.type,可能改变上传逻辑里的文件类型判断。防御思路是在merge函数里排除__proto__、constructor和prototype这几个键,同时使用Object.create(null)创建没有原型的纯净对象。

3.4 前端加密与签名逻辑绕过

不少系统为了“防篡改”,会在前端对参数做MD5、AES、RSA加密,或者拼一个签名串。这里有个很常见的误区:前端加密防的是普通用户随意篡改,但攻击者本来就能拿到前端代码。加密密钥和算法都摆在那里,最多只是增加了一点时间成本。

测试这类逻辑时,我不需要从零逆算法。用浏览器开发者工具在加密函数处打断点,观察入参和返回值,然后直接在Console里调用暴露出来的加密方法,生成一份合法的签名就可以了。更快的办法是把请求体复制出来,用脚本语言重放并替换参数,再看服务端是否校验参数的完整性。如果服务端只校验签名是否存在、不校验签名和数据的绑定关系,那么前端加密逻辑就能绕过。

给开发同学的修复建议也很简单:任何敏感操作必须依赖服务端二次校验,前端加密可以预防意外误操作,但不能当作安全边界。真正需要防篡改的场景,应该使用HTTPS配合服务端签名,而不是把密钥埋在JS里。

4. 实际操作:一次模拟项目X的JS漏洞利用全过程

4.1 信息收集:控制台、网络面板和源码美化

我搭了一个本地模拟项目X来演示,这是一个前后端分离的跨平台管理系统,有登录页、用户列表、订单查询和后台管理四个模块。正常登录后,网络面板能看到一串JS文件:app.js、vendor.js、api.js。这些文件已经压缩过,第一行特别长,直接当成文本读不现实。

先把app.js复制出来,用在线格式化工具或者IDE的格式化功能展开。格式化之后,搜索关键词api,能看到一个统一的请求前缀,再搜索token,会发现Token被存在localStorage。继续搜索admin,在一个工具函数里看到:

function checkPermission() { if (window.userConfig.admin === true) { renderAdminMenu(); } }

这就是一个很典型的可被前端篡改的逻辑点。我决定继续跟踪window.userConfig在哪里赋值,最后找到了启动时从/api/config拉取配置的代码。

4.2 定位可控点:从搜索参数到innerHTML

在订单查询页,发现页面会把URL参数keyword读取出来,用于在结果区域显示“您搜索的是:xxx”。开发者为了方便,直接在HTML里拼了字符串:

var keyword = new URLSearchParams(location.search).get('keyword'); document.getElementById('search-result').innerHTML = '您搜索的是:' + keyword;

这个位置属于DOM XSS。正常思路是提交一个<img src=x onerror=alert(document.domain)>验证弹窗。但我没有急着弹窗,而是观察这个页面里有哪些全局对象可被利用。因为页面加载了userConfig,并且在渲染菜单前会判断它。

4.3 用DOM clobbering构造越权利用链

我尝试在keyword参数里注入一个表单元素来覆盖userConfig。由于浏览器会把带id的元素挂到window上,我构造了这样一段:

<form id="userConfig"> <input name="admin" value="true"> </form>

当innerHTML把这串内容插入DOM后,window.userConfig就被这个表单覆盖了。原有的配置对象不再生效,而表单里的admin输入框会被DOM clobbering规则映射成userConfig.admin,值就是字符串"true"。

在JavaScript里,"true"本身不是true,但在条件判断window.userConfig.admin === true中用的是严格相等,所以这里不会直接通过。不过如果业务代码用的是if (window.userConfig.admin)或者if (userConfig.admin == true),字符串"true"会被转换为布尔值,最终判断成立。

模拟项目X的具体代码里用的恰好是宽松判断:

if (window.userConfig.admin == true) { renderAdminMenu(); }

所以注入成功后,管理菜单被渲染出来,页面也会请求后台管理接口。配合前面发现的接口路径,一个普通用户就能看到本不该出现的后台功能。整条利用链不用改后端请求,只是在URL参数里加了一段HTML片段,就造成了前端越权。

当然,这个漏洞能成功的前提是页面存在XSS点、全局对象被覆盖、权限判断只在前端。如果服务端在管理接口上做了真实鉴权,后端会拒绝普通用户访问,DOM clobbering带来的影响就只是菜单显示异常,不会扩大为越权。这也说明了为什么服务端权限校验永远不能省。

4.4 修复建议与安全配置

针对模拟项目X里的问题,修复方案分两层。第一层是消除XSS:不要把keyword直接拼进innerHTML,应该使用textContent,或者在框架模板里用{{ }}插值。如果确实需要渲染富文本,建议用白名单过滤的DOM清理库,而不是简单调用innerHTML。

第二层是修正前端权限逻辑:不能把userConfig挂到window,更不能由前端决定菜单和接口的可见性。正确做法是每次请求都携带Token,服务端根据会话解析用户角色,接口层面做权限控制。前端菜单隐藏只是体验优化,不是安全边界。额外可以做的是给页面加上CSP,限制script-src为可信来源,并配置trusted-types来拦截不安全的DOM操作。

5. 常见问题与排查技巧实录

5.1 压缩混淆后的JS怎么快速看

压缩后的JS看着头疼,其实有固定套路。第一步是格式化,第二步是启动一个本地静态服务器,用浏览器的开发者工具打开这个JS文件,这样可以直接打断点,不需要等线上环境触发。

如果代码里加了混淆变量名,比如_0x3f2a这种,不要硬读。优先搜索字符串常量,字符串通常不会被混淆成完全乱码。接口路径、错误提示、字段名都是很好的搜索切入点。比如搜"api"可能会跳到所有接口定义的位置,搜"password"能看到登录相关的逻辑。高级的混淆器会把字符串也编码,但这种情况相对少见,遇到时可以在Console里手动调用解码函数,或者借助浏览器环境自动解密。

还有一点值得注意:很多项目会在构建时生成Source Map文件,如果线上保留了.map,开发者工具里就能直接看到原始源码。虽然这不是一个漏洞,但它会显著降低分析门槛,在报告中可以建议线上环境禁用Source Map。

5.2 外链脚本的漏洞为什么很难直接利用

很多站点会加载第三方统计脚本、客服系统脚本、广告脚本。如果这些脚本本身存在漏洞,攻击者理论上可以通过供应链方式影响站点。但在实际渗透测试中,直接利用外链脚本漏洞通常很难,因为第三方脚本的功能、输出位置都是不可控的。

更实际的排查方向是查看外链脚本是否与页面有同源信任关系。有的站点会将第三方脚本的integrity属性省略,也没有CSP约束,一旦第三方域名被劫持或者脚本被篡改,整个页面都会被操控。测试时我习惯把所有外链脚本的域名列出来,检查是否和主站同源、是否使用了Subresource Integrity,再决定是否把这个风险写进报告。

5.3 我这几年总结的几个前端排查习惯

第一个习惯是先从API配置入手,而不是从页面功能入手。前端代码里所有接口路径和请求方式都会集中在某个模块里,找到它等于拿到一份“接口地图”。第二个习惯是打开控制台前先看Network面板里的脚本加载顺序,可以快速知道哪个文件是主业务、哪个文件是第三方依赖。第三个习惯是搜索本地存储操作,凡是出现setItem、getItem、removeItem的地方,都要想一想这些数据是否影响权限判断。

调试时我喜欢在可疑函数上打断点,刷新页面,看调用栈。调用栈能告诉我这个函数是被谁触发的,数据从哪个变量来,最后又流向了哪个DOM操作。这套流程对定位DOM XSS和原型链污染都非常有效。

5.4 利用失败时先检查这三件事

有时候payload看上去没问题,但就是不触发。先检查是不是HTML实体编码了,如果后端或者框架把<转义成&lt;,需要换一个不需要尖括号的payload。再检查是不是输出点被框架包裹在文本节点里,这时候innerHTML不会执行,但href、style等属性可能被利用。最后检查CSP,如果页面限制了script-src,内联事件和javascript:协议可能会被拦截。

DOM clobbering利用失败也很常见。一个原因是现代前端框架会在编译时对模板中的表达式做更严格的求值,比如Vue中直接访问userConfig会被当作组件属性,不一定走window。另一个原因是某些浏览器对id和name挂载全局对象的规则有限制,比如不是所有元素都会挂载,或者已有变量优先级更高。遇到这种情况,尝试改用<a id="userConfig">,或者把name属性放到表单控件上,再重新测试。

我个人在实际操作中的体会是,JS应用安全漏洞利用最考验的不是对某个payload的记忆,而是对前端代码执行模型的理解。只要你能在脑子里模拟出浏览器解析、布局、执行脚本的完整过程,很多看起来很隐蔽的漏洞都能变成一条清晰的利用链。踩过几次坑之后,我也会把这部分内容再整理成一份自查清单,以后写代码的时候把window全局变量、innerHTML、递归merge这些关键字当成敏感词来审查。这条路没有捷径,多读真实的前端代码,多做本地靶场实验,比死记硬背漏洞库有用得多。

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

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

立即咨询