后台管理系统、单页应用、一堆没有文档的接口——这种组合在授权测试里太常见了。那次我在一个查询参数上确认了存储型XSS,弹窗出来那一刻队友已经在收工具了,我却盯着控制台里那串document.cookie发呆:既然这段 JS 是跑在目标域里的,那同源下的 JS 打包文件、sourcemap、前端配置文件,是不是都能直接拉下来?顺着这个念头往下走,最后拿到的不是一张"弹窗截图",而是几乎完整的前端工程结构和一批藏在代码里的接口路径。这就是利用XSS读取源码的实际价值——它把一次看起来低危的脚本注入,变成了情报收集的放大器。
先把边界划清楚。这里的"源码"指的是浏览器同源策略允许访问到的资源:打包后的 JS、.map文件、服务端渲染页面里的隐藏字段、同源可请求的静态配置。它不是服务器文件系统里的任意文件。很多人对这点有误解,以为 XSS 能读/etc/passwd之类的东西,那是文件包含或者命令执行的事,跟 XSS 没有关系。把这条线守住,后面的操作才不会跑偏,写出来的报告也经得起推敲。
这篇适合谁看:手里已经能做基础 XSS 验证、想把危害评估做得更扎实的测试人员;做前端或后端开发、想知道自己项目里哪些东西正在被"顺走"的同学;还有刚入门、分不清反射型和存储型区别的朋友。下面按"能读到什么—怎么读—读完怎么用"这个顺序铺开讲。
1. 先把"能读到什么"讲明白
1.1 同源策略:拦得住跨域,拦不住自己人
同源策略的判定是三要素:协议、域名、端口,三者完全一致才算同源。它存在的意义是防止 A 站的脚本去读 B 站的数据,但它对"自己读自己"毫无限制。XSS 的本质是把攻击者的 JS 注入到目标页面里执行,这段 JS 的"源"就是目标站本身,它发起的所有请求,和页面自己的业务代码发起的请求,在浏览器眼里没有任何区别。
这一点理解透了,后面的操作就顺理成章。你不需要什么特殊技巧去"绕过"同源策略,因为你压根就在同源里面。页面能fetch到的资源,你的注入代码也能fetch到;页面能读的localStorage,你也能读。所谓"XSS 的危害边界",从来不是"弹个窗",而是"以受害者的身份,在这个域里做任何前端能做的事"。
反过来说,如果目标做了严格的 CSP、关键接口加了二次校验、敏感数据放在 HttpOnly 且不落前端,那 XSS 能拿走的东西就会大打折扣。这也是为什么同一套利用思路,在不同站点上的结果可能天差地别——不是手法不行,是环境不同。
1.2 前端源码泄露的三个层级
我在实际项目里把"能读到的源码"分成三层,越往下价值越高,也越依赖运气。
| 层级 | 具体内容 | 获取方式 | 情报价值 |
|---|---|---|---|
| 第一层 | 打包压缩后的 bundle 文件 | 直接同源 fetch | 能看到接口路径、参数名、部分常量 |
| 第二层 | sourcemap 文件与原始工程结构 | 从 bundle 末尾找sourceMappingURL | 几乎等于拿到前端源码,含注释和目录结构 |
| 第三层 | 运行时配置、初始状态、接口响应 | 读全局变量、调同源接口 | 可能包含环境地址、开关配置、数据结构 |
第一层几乎必然能拿到,因为页面要运行就必须加载 JS。第二层取决于构建配置,很多团队在开发阶段开着 sourcemap,上线时忘了关,或者为了排查线上问题故意保留,这就是白送的。第三层最看运气,但往往最致命——初始状态里塞了什么,只有打开才知道。
顺带提一句,源码泄露的影响范围不限于"看到代码"。真实的价值在于:接口清单暴露了未在前端页面出现的隐藏功能;鉴权逻辑暴露了前端是怎么判断权限的,从而知道哪些接口只做了前端校验;注释里可能留着测试环境的地址、临时账号、内部人员的 TODO;硬编码的常量里偶尔能看到不该出现的密钥。这些东西单独看都不算漏洞,串起来就是一条完整的攻击路径。
1.3 这次测试的整体推进路线
整个流程我梳理成五步,后面每一节都是围绕其中一步展开的。
第一步,确认注入点并判断它处在什么上下文里。这一步决定了载荷怎么构造,构造错了后面全白搭。第二步,用最小载荷验证代码确实能执行,别急着上大家伙。第三步,在能执行的前提下,写读取逻辑,把同源资源拉进内存。第四步,解析拿到的内容,找 sourcemap、还原工程、整理接口清单。第五步,交叉验证——把从源码里看到的接口,实际请求一遍确认存在,避免把废弃代码当成有效资产。
这五步里最容易出问题的是第一步和第四步。第一步错在"看到弹窗就以为成功了",其实弹窗只能证明 HTML 注入生效,不能证明你的代码在正确的执行上下文里。第四步错在"拿到一堆文件就以为结束了",不做验证的源码只是文本,验证过的才是资产。
2. 注入点的定位与上下文判断
2.1 输入点分类:别一上来就丢载荷
新手最常见的动作是随便找个框,输入<script>alert(1)</script>,没反应就换下一个。这个习惯很浪费时间和请求量。我的做法是先给输入点分类,再决定测什么。
按数据流向分,输入点无非三类。第一类是立即回显的,参数提交后马上出现在响应里,这类最好测,改一个字符刷新一次就能观察到变化。第二类是延迟回显的,提交后要等别人打开某个页面才触发,比如留言、昵称、工单标题,这类往往是存储型,危害更大但也更难验证,因为你不知道它最终渲染在哪儿。第三类是不回显但被前端消费的,比如 URL 里的 hash 参数、postMessage的消息体,这类需要看代码才知道数据去了哪里。
按渲染位置分,就回到了经典的上下文问题:数据落在 HTML 标签之间、落在某个属性值里、落在<script>块或 JSON 里,三种情况的处理方式完全不同。分类的目的不是学术,而是让你在动手之前就知道该往哪个方向试。
2.2 三种上下文的处理差异
标签之间是最舒服的情况。数据直接进入 HTML 正文,你可以闭合前面的标签再插入自己的,也可以直接用一个带事件属性的标签。这种上下文里,<img src=x onerror=...>这类载荷的成功率最高,因为它不依赖标签是否被过滤,只要属性没被拦就行。
属性内部要复杂一些。你得先判断是双引号包裹、单引号包裹还是完全没引号。双引号包裹时,闭合引号再补一个新属性是常规思路;如果引号被转义了,就得看能不能用反引号或者不加引号的形式。有些框架会把"转成",这时候硬刚引号没意义,得换思路,比如利用已有属性的二次解析。
脚本块或 JSON 内部最考验功底。数据被序列化进<script>里时,常规的标签注入全部失效,你要做的是跳出字符串上下文。如果后端做了 JSON 转义但漏了</script>的闭合处理,那就能提前结束脚本块;如果序列化用了不安全的拼接,可能直接构造出可执行的表达式。这类场景我在实际项目中遇到过两次,都是服务端渲染的老系统,模板引擎的默认转义策略没覆盖到脚本上下文。
判断上下文最笨也最有效的办法:提交一个不可能自然出现的字符串,比如zzq1x9,然后在响应里搜它,看它前后是什么字符。别嫌土,比凭感觉猜准得多。
2.3 用最小载荷验证可执行性
确认上下文之后,先别写读取逻辑。用一个最小载荷证明"代码确实执行了",比如把弹窗换成往页面标题里写个标记,或者往控制台打一行日志。
// 最小验证载荷,只证明执行,不做任何数据操作 document.title = 'xss-probe-1';为什么不用alert?因为弹窗会打断流程,而且有些环境里弹窗被浏览器策略拦了(比如非用户手势触发的弹窗),你会误判成"没执行"。改标题、改 DOM、写 console 这三个动作更安静,也更容易批量观察。
验证通过之后再考虑:这段代码是在谁的浏览器里执行的?如果是自己提交自己看,那只是反射型,价值有限;如果是存进数据库、等管理员打开后台才触发,那就是存储型,能碰到的东西完全不是一个量级。这一步的判断直接决定了后面要不要投入精力去做源码读取——反射型做这件事的性价比很低,因为你得诱导目标点击,而存储型是"等着就行"。
提示:验证阶段的所有载荷都要留痕,记录提交时间、参数位置、触发条件。后面写报告时这些是复现步骤的骨架,临时回忆很容易漏。
3. 把同源资源拉下来:几种读取方式对比
3.1 fetch、XHR、iframe、script 怎么选
同样是读同源资源,四种方式的能力边界差别很大,选错了会卡在莫名其妙的地方。
| 方式 | 能否读内容 | 是否自动带 Cookie | 主要限制 | 适用场景 |
|---|---|---|---|---|
fetch | 能 | 需显式设置credentials | 受 CSPconnect-src限制 | 首选,读文本、JSON 都方便 |
XMLHttpRequest | 能 | 同源默认带 | 写法啰嗦,异步回调 | 老环境兼容 |
iframe | 同源可读 DOM | 同源默认带 | 受X-Frame-Options影响 | 需要读渲染后的 DOM |
<script>标签 | 不能直接读 | 会带 | 只能执行,不能取源码文本 | 加载 JSONP 或特定格式 |
fetch是我的默认选择,代码短、Promise 好串、读文本读 JSON 都顺手。要注意的是,读取同源资源时如果希望带上当前会话的身份,需要显式写credentials: 'same-origin',虽然同源请求默认就会带 Cookie,但不同浏览器版本在细节上有差异,写清楚更稳。
iframe的用处常被低估。有些页面的关键信息不在静态文件里,而在服务端渲染出来的 DOM 中,比如隐藏的表单字段、注释节点、>// 读取主 bundle 并提取 sourceMappingURL fetch('/static/js/app.js', { credentials: 'same-origin' }) .then(r => r.text()) .then(txt => { const m = txt.match(/\/\/[#@]\s*sourceMappingURL=([^\s'"]+)/); console.log('bundle 长度:', txt.length); console.log('map 地址:', m ? m[1] : '未找到'); });
正则里之所以要同时匹配//#和//@,是因为不同构建工具的注释格式不一样,老版本用@,新版本用#。地址可能是相对路径,也可能是完整 URL,甚至可能是data:开头的内联 base64——内联的情况更省事,直接解码就行,不用再发一次请求。
找不到这条注释,不代表没有 sourcemap。有些构建配置会把注释去掉但文件仍然部署在服务器上,这种情况可以按常见命名去猜:app.js.map、main.js.map、chunk-vendors.js.map。我在一个项目里就是这么碰上的,注释被去掉了,但.map文件安安静静躺在同目录下,直接请求就下来了。
还有一种情况是注释被保留了,但 map 文件被服务端拦了,返回 403 或者一个 HTML 错误页。这时候要看响应内容判断,别看状态码——有些 CDN 会把 404 也返回 200 加一个自定义错误页。判断标准很简单:r.headers.get('content-type')里有没有json,或者内容开头是不是{"version":。
3.3 解析 map 文件还原原始工程结构
map 文件本身是个 JSON,结构不算复杂。真正决定你能不能拿到"源码"的,是里面有没有sourcesContent字段。
// 解析 map 文件,优先使用 sourcesContent 还原 fetch('/static/js/app.js.map', { credentials: 'same-origin' }) .then(r => r.json()) .then(map => { const { sources = [], sourcesContent = [], sourceRoot = '' } = map; console.log('源文件数量:', sources.length); console.log('是否内嵌源码:', sourcesContent.length > 0); sources.forEach((s, i) => { if (sourcesContent[i]) { console.log('===== ' + sourceRoot + s + ' ====='); console.log(sourcesContent[i].slice(0, 500)); } }); });sourcesContent有内容,说明构建时把原始代码整段塞进了 map,你等于直接拿到了工程源码,连注释和空行都在。sourcesContent为空但sources有值,说明只保留了路径映射,你能看到目录结构和文件名,但看不到文件内容——这种情况下文件名本身就是情报,src/views/admin/userManage.vue这种路径能告诉你后台有哪些功能模块。
sources里的路径通常带前缀,比如webpack:///./src/,处理的时候把前缀剥掉再拼sourceRoot,得到的才是干净的相对路径。这一步不难,但不处理的话,后面整理文件树会很乱。
如果源文件数量很多,比如几百个,一次性全打出来会让控制台卡住。我的做法是先只输出路径列表,人工扫一遍,挑出看起来有价值的(路由、接口封装、权限判断、配置文件),再单独提取这几个文件的内容。
3.4 顺带拿到的配置与接口清单
源码还原之后,真正的活儿才开始。我会按四个方向去翻。
第一个方向是路由表。单页应用的路由配置里通常写死了所有页面路径和对应的组件,能直接看出这个系统有哪些功能模块,哪些模块在前端菜单里藏起来了。有些系统会把"仅管理员可见"的路由也打包进去,靠前端权限控制显示与否,这就等于把功能清单交出来了。
第二个方向是接口封装层。绝大多数项目会把请求统一封在一个文件里,比如api/index.js或者utils/request.js。这里面能看到 baseURL、请求拦截逻辑、统一的错误处理,更重要的是能看到所有接口的相对路径和参数结构。把这些整理成表,比手工点页面效率高一个数量级。
第三个方向是权限判断逻辑。前端怎么判断当前用户是不是管理员?是读某个字段,还是调某个接口?如果是读字段,这个字段存在哪儿、能不能被篡改?这些问题在源码里都有答案。
第四个方向是常量与配置文件。config.js、env.js、constants.js这类文件名很显眼,里面可能有环境地址、超时时间、开关标记。我见过一个项目把测试环境的接口地址直接写在了生产包里,虽然不算高危,但足以说明构建流程有问题。
注意:从源码里看到的接口必须实际验证。废弃代码、注释掉的逻辑、只在特定分支生效的路径都可能误导你。验证方式很简单,用同源请求打一下,看返回结构是否和代码里的预期一致。
4. 完整实操记录:从一个弹窗到一份源码
4.1 前置准备与测试环境
这次测试是走正规授权流程的,测试范围、时间窗口、禁止操作项都写在授权书里,其中明确要求"不得对生产数据做写操作"。所以整个过程中我只做读取,不做任何增删改。
环境准备上,我用的是浏览器开发者工具加一个本地的小脚本。浏览器这边主要用控制台和网络面板,前者跑读取逻辑,后者观察请求是否真的发出去了、响应是什么。本地脚本的作用是整理数据,把导出的源码按目录结构落盘,方便后面用编辑器全局搜索。工具没什么特别的,关键是把每一步的输入输出都记下来,别指望事后回忆。
还有一个容易被忽略的准备:确认当前登录态是什么角色。如果注入点触发时是普通用户,那读到的所有东西都是普通用户能看的,价值有限;如果是管理员,那同源请求带过去的会话就是管理员会话,能读到的资源范围完全不同。这个信息必须在动手前搞清楚,否则后面的结论会站不住脚。
4.2 分步操作与关键参数
第一步,确认注入点。目标是一个管理后台的搜索框,输入内容会被存下来并在列表页原样渲染。我提交了一个带标记的字符串,刷新列表页,标记原样出现在 DOM 里,位置在<td>标签之间,没有被转义。
第二步,验证执行。换成带事件属性的载荷,把页面标题改掉。
// 提交内容(简化示意,实际测试中会根据过滤规则调整) // <img src=x onerror="document.title='probe-ok'">刷新后标题确实变了,说明代码在列表渲染时执行。这里有个细节:列表页是管理后台的首页,也就是说只要管理员打开后台,载荷就会触发——这是典型的存储型,不需要任何诱导。
第三步,写读取逻辑。目标是主 bundle。我先用fetch把文件拉下来,看大小和末尾的注释。
fetch('/static/js/app.js', { credentials: 'same-origin' }) .then(r => r.text()) .then(t => { console.log('size:', t.length); console.log('tail:', t.slice(-300)); });响应长度三兆多,末尾确实有sourceMappingURL注释,指向同目录下的app.js.map。
第四步,拿 map 并检查内容。直接请求 map 文件,先只打印结构不打印内容,避免控制台卡死。
fetch('/static/js/app.js.map', { credentials: 'same-origin' }) .then(r => r.json()) .then(m => { console.log('version:', m.version); console.log('files:', m.sources.length); console.log('hasContent:', Array.isArray(m.sourcesContent) && m.sourcesContent.some(Boolean)); });结果files是 217,hasContent为 true。这一步基本就宣告"源码到手"了。
第五步,导出。导出方式我试了三种,各有取舍。直接在控制台打印再手动复制,最安全但最累;用 Blob 加a.download触发下载,效果最好但受浏览器下载策略影响,可能需要用户手势;搭一个接收端把数据发出去,效率最高但要谨慎处理数据落地。这次我选的是第二种,在本地测试机上操作,把 map 文件整个下载下来,然后用脚本按sourcesContent拆分还原成文件树。
拆分脚本的核心逻辑不复杂:遍历sources,把路径里的webpack:///./前缀去掉,遇到..做一次规范化,然后按路径创建目录写文件。跑完之后,src/views、src/api、src/utils三个目录一目了然。
4.3 结果整理与交叉验证方法
还原出来的工程有 217 个文件,全看一遍不现实。我的筛选顺序是:先看src/router和src/api,再看src/utils/request,最后看src/config。前两个决定了系统的功能面和接口面,后两个决定了请求是怎么发出去的、有没有统一的鉴权头。
整理接口清单的时候,我用了个笨办法:在还原出来的目录里全局搜url:和'/',把命中的行提取出来,人工去重。两百多个文件大概花了一个多小时,最后整理了六十多个接口,其中二十多个是在页面上没见过的——包括一个批量导出接口和一个用户角色变更接口,这两个后来在报告里被单独列了出来,因为它们的权限校验只做了前端判断。
交叉验证的方式是逐个发同源请求,看返回。判断"接口存在"的标准不是状态码,而是响应结构是否和源码里的预期一致。比如源码里写的是res.data.list,那响应里就应该有data.list;如果返回的是统一的"无权限"提示,说明接口存在但当前角色不够,这也算确认存在。这一步看起来繁琐,但能避免把已经下线的接口写进报告,减少后期扯皮。
5. 踩过的坑与排查速查表
5.1 常见失败场景与对策
| 现象 | 可能原因 | 排查方式 | 处理办法 |
|---|---|---|---|
| 载荷提交后毫无反应 | 被转义或过滤 | 看响应里字符变成了什么 | 换上下文、换标签、换事件 |
| 代码执行了但 fetch 报错 | CSP 限制connect-src | 看控制台的 CSP 报错 | 改用img、form等不受限的方式 |
| map 请求返回 200 但内容是 HTML | 被重写或 CDN 自定义错误页 | 检查 content-type 和开头字符 | 判定为不可用,改猜其他命名 |
sourcesContent全为空 | 构建用了nosources-source-map | 检查数组长度和元素 | 只能看路径,靠文件名推断 |
| 下载没触发 | 浏览器限制自动下载 | 看是否有安全提示 | 改为控制台输出或接收端 |
| 读到的接口全部 404 | 代码是废弃分支或旧版本 | 对比 bundle 哈希与线上版本 | 换最新的 chunk 再试 |
这张表是我自己攒的,每一条都对应一次真实的卡壳。最坑的是第三条,CDN 把 404 包装成 200 返回一个 HTML 错误页,你如果不看内容只看状态码,会以为文件存在,然后对着报错信息查半天。
5.2 几个反直觉的经验
第一个,别小看检查 element 面板。有些页面的敏感信息不在响应源码里,而在 DOM 的属性上,比如服务端渲染时把用户信息写进了>