1. 从href说起:超链接XSS的核心机制
做Web安全的人都有个共识,XSS的payload入口看似千变万化,但真正绕来绕去,很多攻防点都收缩到一个非常朴素的问题上——开发者敢不敢信任用户输入的一段URL。早些年我在给某电商系统做渗透测试的时候,发现后台“自定义链接”功能可以直接把用户输入的地址渲染到前端<a>标签的href属性里,当时第一反应就是:这不就是超链接XSS的标准入口吗?
超链接XSS这个名字看上去像是XSS的一个子类,实际上它描述的是攻击向量(attack vector)的载体形态。简单说,只要用户可控数据最终被塞进href、src、action这类带URL语义的HTML属性里,我们就需要警惕javascript:、data:、vbscript:这些伪协议被当作合法“链接地址”执行。很多开发者对<script>标签、<img onerror>已经形成了肌肉记忆,但一遇到<a href>、<iframe src>就放松了警惕,觉得“这不过是个链接而已,点了才会跳转”。
正是这个认识误区,给了超链接XSS极大的生存空间。
它和普通的存储型、反射型XSS最大的区别在于触发方式。普通反射型XSS往往需要通过URL参数直接构造注入,浏览器加载页面时自动触发;而超链接XSS更像是一个“钓鱼陷阱”——攻击者把恶意payload拼接到一个看似正常的链接里,受害者只有点击这个链接,攻击代码才会被解析执行。这个特点决定了它非常适合伪装成二维码、短链接、富文本编辑框里的超链接,也常被用来配合鱼叉式钓鱼。
理解超链接XSS的底层机制,必须回到浏览器解析HTML标签时的“潜规则”。浏览器在处理<a href="...">时,并不是简单地把它当作字符串塞给地址栏,而是会先走一遍URL解析流程。而URL解析器对javascript:协议是“网开一面”的——它不认为javascript:是一个需要拦截的非法协议,因为在Web标准的历史演进中,javascript:是被定义为“合法的URL scheme”的。于是,一个href属性值遇到javascript:alert(document.cookie)时,浏览器会老老实实地把它当作一段JavaScript代码来执行,而不是触发地址跳转。
这里有个关键细节值得单独拎出来讲:href中的javascript:伪协议执行时机,发生在用户点击行为之后,且执行时的执行上下文(execution context)依然是当前页面的全局作用域。这意味着payload里访问cookie、localStorage、DOM节点时,和页面内联脚本拥有完全相同的权限边界。很多站长会问:“我的页面明明启用了HttpOnly,为什么XSS还能拿到会话?”答案很简单,HttpOnly只能挡住document.cookie的直接读取,而攻击者完全可以通过XSS发起带认证态的跨站请求,或者篡改页面内容诱导进一步输入。
我在研究这个方向时做了一个小型实验来验证执行上下文的问题:在一个没有任何业务逻辑的空白测试页里插入<a href="javascript:console.log(window.location.href)">测试链接</a>,点击后在控制台看到的输出,和直接在页面内联脚本里执行console.log(window.location.href)完全一致。这足以说明,href里的伪协议并不是在“新环境”里跑的,它就在当前页面上下文中跑,攻击面自然也就被放大到了当前页面的全部能力范围。
2. 超链接XSS的变体与特性
2.1 href属性中几种伪协议的真实面貌
先说说javascript:伪协议。这是最经典、也是最容易被安全扫描器查出来的形式,比如<a href="javascript:alert(1)">详情</a>。但在真实攻击中,直接这样写的场景并不多,因为后端只要做了一步简单的关键字过滤,这种裸奔形态就会被拦下来。于是攻击者开始研究编码层面的“变形”。
URL编码是绕过滤的常用手段。比如javascript:alert(1)可以改写成javascript%3Aalert%281%29,在某些后端只做“摘出关键字比对”的过滤逻辑下,%3A和%28都不是:和(的原始形态,因此能骗过字符串匹配。但这里有个坑:浏览器在解析href属性时,会先把HTML实体(比如j这类字符实体)解码,再进行URL解码,最后才交给JavaScript引擎。解码顺序不正确、层级不对,payload就无法触发。我起初做测试时总觉得“编码都对了为什么还不弹窗”,后来一查文档才明白,浏览器的解码层级多,必须保证每一层解出来的结果都符合下一层的语法要求。
data:协议就没那么简单了。data:text/html,<script>alert(1)</script>确实可以塞到iframe的src里,但在<a href>里点击它,浏览器表现会因为版本和策略差异而有所不同。现代浏览器对data:协议在顶层导航中做了不少限制,不过在iframe的src属性里,data:协议仍然是可用的,这也是为什么超链接XSS不一定只指<a>标签,它往往还连着<iframe>、<object>、<embed>这些标签一起看。
还有一种容易被忽视的是vbscript:伪协议,这玩意儿只在老版本浏览器环境中有意义,现在基本可以忽略。不过从攻防演进的角度,它告诉我们一个规律:浏览器的每一个历史残留特性,都可能成为攻击链上的一环。
2.2 点击类XSS与自动触发XSS的行为差异
很多刚接触XSS的人会把超链接XSS和“必须有用户交互的XSS”画等号,这个理解大体不错,但不够精准。如果把攻击面拉宽,我们会发现有些“超链接XSS”实际上是可以自动触发的,只是载体标签不同。
<a href>这种标签,无论href里塞什么恶意代码,都必须等用户点击。原因在于<a>本身不是自动加载资源的标签,它只是一个“导航锚点”。而<iframe src>、<link href>、<img src>就不同了,这些标签一旦出现在DOM里,浏览器立刻会对src发请求或解析内容,不需要任何交互。
所以我在给某后台系统做测试时,经常会在富文本编辑器里提交这样一段:
<iframe src="javascript:parent.document.location='http://evil/'"></iframe>这段代码只要被渲染进页面,就会自动执行跳转,根本不需要谁来点一下。这就解释了为什么富文本类的XSS漏洞往往比普通链接类更加严重——存储型+自动触发+可批量利用,三个特征叠加起来,危害半径要大得多。
还有一类值得关注的是属性事件类,比如<a href="#" onclick="alert(1)">。这种情况本质上是事件属性触发的XSS,但它寄生在<a>标签上,很多过滤算法会针对href做协议检查,而对onclick这类属性缺乏同等力度的审查,于是攻击者就搭了“href白名单+onclick黑马”的便车。对付这类问题,光靠过滤标签属性名是不够的,得在HTML解析层做事件属性的整体管控。
2.3 浏览器解析顺序:HTML实体、URL解码与JS执行
想要真正攻防都心里有底,必须吃透这一段编码链路。浏览器拿到一个<a href="...">后,解析顺序大致是:
- HTML解析器先对标签属性值做HTML实体解码,
&还原为&,j还原为j; - 属性值随后作为URL进行解析。如果这个URL的scheme是
javascript:、data:这类非HTTP协议,则会走对应的协议处理分支; - 对于
javascript:协议,浏览器会把冒号后面的内容视作JavaScript代码,交由其JavaScript引擎执行。
这里最容易踩坑的就是编码层级。有的开发者以为做了HTML实体编码就万事大吉,结果攻击者在属性值里塞javascript:alert(1),HTML实体解码后依然变成了javascript:alert(1)。反过来,有些过滤函数只做了javascript:字符串的全局替换,却漏掉了大小写混合、Tab制表符插入、换行符插入等等“隐形字符夹带”的情况。
举一个实际测试中能跑通的payload:
<a href="javascript:alert(1)">点我</a>这里的s是's'的十六进制实体,HTML实体解码后得到javascript:alert(1),如果后端的过滤逻辑没有在“HTML实体解码之后”的文本上做协议检查,只是对原始输入做关键字拦截,那这条payload就能安全落地。这一点非常关键:过滤器的位置和时机决定了它的有效性。写在实体解码之前的过滤,和写在实体解码之后的过滤,效果天差地别。
3. 构造一个完整的超链接XSS攻击链
3.1 打开window.name与postMessage的隐蔽链路
在纯前端构造攻击链时,我比较偏好利用window.name这个"天然的跨页签数据通道"。默认情况下,一个页面跳转到另一个页面时,如果新页面没有显式覆盖window.name,那么上一个页面的window.name会被保留下来。这听上去有点冷门,但确实是超链接XSS里很有杀伤力的一环。
攻击思路大致是这样:攻击者先在自己的域名下构造一个恶意页面,执行
window.name = "javascript:alert(document.cookie)"然后诱导受害者点击一个超链接,跳转到存在XSS注入点的目标页面。如果目标页面的某个<a href>能被注入并渲染为window.name的值,那么点击这个链接时,浏览器解析到的URL就是javascript:alert(document.cookie),一个完整的XSS触发链路就形成了。
当然,实际操作中目标页面的注入点本身就很关键,window.name只是解决了“payload从哪里来”的问题。如果目标页面已经存在<a href="用户可控">的注入,那攻击者完全可以不依赖window.name直接写死payload。但window.name的优势在于它的隐蔽性——很多WAF和安全扫描器会检查请求参数、请求头、cookie,却很少检查window.name,因为它在HTTP层面根本“看不见”。
类似的思路还有postMessage。很多站点会在前端写一个消息监听器,用于接收其他页面传递过来的消息,如果在监听器里没有校验消息来源(event.origin),攻击者就能构造一个恶意页面,通过postMessage向目标页面发送一个构造好的payload,目标页面的某个<a href>一旦把消息内容当作URL值渲染出来,同样会以点击的形式触发XSS。
我搭建过一个模拟环境来验证这条链路。页面A(攻击者控制)通过postMessage向页面B(目标站点)发送消息javascript:alert(document.domain),页面B的代码是这样写的:
window.addEventListener("message", function(e){ var link = document.createElement("a"); link.href = e.data; link.innerHTML = "下載文件"; document.body.appendChild(link); });实测结果是点击后成功弹窗,说明postMessage的origin校验在这个场景下是缺失的。这个案例也给开发者提了个醒:消息监听器里一定要校验e.origin,并且对href做协议白名单校验,两者缺一不可。
3.2 富文本场景下的超链接注入实例
富文本编辑器是超链接XSS的重灾区,因为业务上用户需要“插入链接”这个能力,而很多编辑器默认输出的HTML结构里,链接天然就是用<a href>渲染的。我在做某内容管理项目的测试时,发现编辑器前端在做链接插入时会弹出一个对话框,要求输入URL,而后端保存时只做了一个非常粗糙的过滤:把"javascript:"字符串替换为空字符串。
这个过滤问题很大,因为替换是单次的。攻击者可以提交javascript:alert(1),经过一次"javascript:"被替换为空之后,变成alert(1),此时href变成alert(1),不会执行。但如果攻击者构造嵌套变形,比如:
javascripjavascript:talert(1)过滤掉中间那个javascript:后,剩下的字符串重新拼合为javascript:alert(1),过滤器里只有一次替换逻辑,自然就失效了。这就是典型的“单次替换”漏洞。正确的替换应该是循环替换直到不再变化,或者直接在协议白名单层面做判断。
另一个高发场景是富文本编辑器在粘贴内容时的“净化”策略不完整。很多编辑器会把用户粘贴的HTML先经过一个清洗函数,这个清洗函数通常是一堆正则规则。正则写得不严的时候,攻击者就能钻进空子。举个例子,我见过某个清洗规则是/javascript:/gi,全局忽略大小写替换,看起来没啥问题,但正则没有做“单词边界”限定,于是攻击者提交:
<a href="javascript:alert(1)">click</a>HTML实体解码发生在浏览器渲染时,而清洗函数是运行在JavaScript里的,如果清洗发生在“明文”阶段而不是“HTML解析后”阶段,那么s根本不会被还原,清洗正则自然也匹配不到。这是一个非常经典的时机错位问题,清洗函数没有模拟浏览器的解码过程,必然会有绕过空间。
在这个项目里,我最后给的修复方案是三步走:第一步,存储时对URL做协议解析,只允许http:、https:、mailto:开头的值;第二步,渲染前对<a>标签的href做一次“实体解码后的协议校验”;第三步,加入CSP的navigate-to限制(如果浏览器支持)或通过service worker拦截非法导航。三层下来,即使某一层被绕过,后面还有兜底。
3.3 短链接与二维码:容易被忽视的传递载体
超链接XSS的实际攻击场景里,payload往往不是直接出现在浏览器的地址栏里,而是被包装成短链接或者二维码,让受害者无意识地点击。我见过比较典型的一个案例是“登录失效”钓鱼:攻击者把<a href="javascript:...">注入到一个在线文档的评论区,再把链接压缩成短链接,然后在聊天群里发给目标用户,用“查看文档详情”的话术诱导点击。
短链接的作用是把恶意URL的表层特征掩盖起来,而二维码则进一步模糊了人眼的判断。用户扫一个二维码,本质上就是让手机浏览器打开一个链接,如果这个链接是javascript:伪协议且手机浏览器未做拦截,malicious payload就能在WebView中执行。
从防御角度看,与其试图教育用户不要点击不明链接,不如从根上避免可控输入进入href。服务端强制URL白名单、前端在渲染时统一走“安全链接组件”而不是直接渲染用户原始输入,这两件事才是真正能落地的防御手段。
4. 超链接XSS的防御清单
4.1 服务端输入过滤的正确姿势
服务端过滤必须是“解析驱动”,不能是“正则驱动”。意思是先把输入当成URL去解析,检查scheme部分,而不是拿一串黑名单正则去替换字符串。我在多个项目里推过一个“保守通过”策略:
- 接受一个URL字符串;
- 用
new URL()(后端对应语言的原生URL解析器)尝试解析; - 检查解析结果的
protocol是否在[http:、https:、mailto:]白名单内; - 检查解析结果的
hostname是否满足域名规范(防止http://==这类畸形结构绕过); - 如果不满足,直接拒绝该URL并提示用户。
这个策略简单粗暴但有效,因为它不是在跟攻击者比谁的正则写得更花哨,而是直接利用浏览器同源的解析结果来做判断。如果后端解析出来的protocol是http:,那么浏览器拿到的也必然是http:;如果后端解析出来protocol是javascript:,浏览器也会认为是javascript:。两者的判断标准完全一致,自然不存在“解析差异”导致的绕过。
唯一的坑是,后端语言的URL解析器和浏览器前端可能在某些边缘情况(比如http:example.com、//example.com)上出现差异,所以建议在前后端同时做校验,双保险。
下面是后端处理时的一个典型伪代码:
def is_safe_url(url): try: parsed = urlparse(url) except Exception: return False if parsed.scheme not in ["http", "https", "mailto"]: return False if not parsed.hostname: return False return True记得一定要检查hostname是否存在。像http:alert(1)这样的字符串,urlparse解析出来的scheme是http,但hostname为空,如果不拦下来,浏览器在解析时可能会把它当作javascript:的同义写法,照样能执行。
4.2 CSP在超链接XSS中的承重作用
服务端过滤做得再好,也挡不住上游供应链被污染、第三方富文本组件自身带洞的情况。CSP在这里的意义不是“替代输入过滤”,而是“兜底”。
要引起足够重视的是,浏览器对CSP的支持里有一个和导航相关的navigate-to指令。虽然navigate-to的浏览器兼容性一般,但在Chrome系浏览器里可以限制页面允许导航到的URL。对<a href>的点击跳转,如果能全局加一个navigate-to http: https:,那即使某一个href被注入成javascript:...,浏览器在导航阶段也会直接拦掉。
但要小心,CSP的script-src指令对javascript:协议的URL也有控制作用。如果页面设置了script-src,且没有显式允许'unsafe-inline',那么javascript:协议中的代码是否执行就取决于具体的CSP策略版本。早期CSP1对javascript:协议是直接禁止的,后来某些场景下放宽了,但总体上说,设置script-src并关闭'unsafe-inline'对超链接XSS是有明显抑制作用的。不过完全阻断还是要靠navigate-to,因为script-src只管代码执行,不管导航发生。
团队里有些前端同学会担心:CSP设得太死会不会影响业务?比如内联脚本、第三方统计脚本全被拦了。这个问题的解法是用nonce或hash来放行可信内联脚本,而不是直接开'unsafe-inline'。前期配置繁琐一些,但换来的是整个页面面对未知XSS时的韧性,划算。
4.3 前端渲染时的安全链接组件
如果服务端历史包袱太重、一时改不完,前端也可以在渲染时补一层关卡。具体做法是封装一个“安全链接”组件,所有需要渲染超链接的地方,不再直接使用服务端返回的原始字符串,而是经过组件的协议检查后,才输出到href属性里。我在某信息门户项目里推广过这个做法,组件大致是这样的:
function safeLink(url) { if (/^https?:/i.test(url) || /^mailto:/i.test(url)) { return url; } return "#"; // 无效或不安全的链接统一降级为#,并记日志 }有个要点得说明:正则/^https?:/i只是前端兜底,它本身可以被类似http://javascript:这样奇怪的字符串绕过,但因为http://之后的路径里如果带上javascript:,浏览器只会当作HTTP URL的一部分,而不会当作JS执行,所以单纯从“执行危险代码”的角度,这层正则已经能挡住绝大多数实际风险。但严谨起见,前端依然要走new URL()做协议解析,而不是只做正则。
在实施时还要注意一个问题:很多企业网站存在“外链中转”需求,即把外链包装成/redirect?target=...的形式再跳转。这种场景如果不对target参数做协议校验,会变成开放重定向+超链接XSS的组合漏洞。我的建议是在中转页接收参数后,同样做协议白名单判断,不要盲目用window.location = target。
5. 超链接XSS实战中踩过的坑
5.1 过滤函数位置不对,等于没过滤
有不少团队会在前端做一大坨过滤函数,但后端只存不验。这种情况一旦遇到有人绕过前端直接构造请求(比如用命令行curl提交),整个过滤就形同虚设。我做测试时,特意养成了先“绕前端”的习惯:不打开浏览器,直接构造POST包提交payload。大部分时候,要么后端直接放行,要么后端虽然校验但逻辑和前端不一致,总能找到突破口。
合理的做法是,前端的过滤只是为了用户体验(早点提示用户输入不合法),后端的过滤才是安全边界。绝对不要把安全信任建立在客户端代码上。我见过某些项目的注释里写着“前端已对URL做了白名单校验”,然后后端完全没有校验,这种情况相当于把家门钥匙挂在门外。
5.2 事件属性与href属性的双重风险
只检查href是不够的。攻击者完全可以构造
<a href="#" onmouseover="alert(document.cookie)">悬停查看</a>这种payload的漏洞点在onmouseover事件属性上,和href字段毫无关系。你的过滤如果只盯着href做协议白名单,事件属性就变成了一扇没上锁的侧门。
解决这类问题,常见的思路有两种:
- 在存储层把不可信HTML彻底结构化,只允许白名单标签和白名单属性;
- 在渲染层用DOMPurify这类库做净化。
这里提醒一句:DOMPurify不是万能的,它也可能出现绕过CVE,但总体而言比手写正则清洗要可靠得多。如果项目组不想引入第三方库,至少要做到“事件属性一律剔除、只保留href/src白名单属性”这一级。
5.3 扫描器测不出来不代表真实攻击测不出来
很多团队依赖自动化漏洞扫描器做安全检查,扫描器确实能发现一些特征明显的反射型XSS,但对超链接XSS这种“点击才触发”的类型,自动化盲测效果很差。原因在于扫描器很难模拟真实点击行为,也无法高效穷举各种编码组合。
所以在做超链接XSS测试时,我倾向于手动构造完整攻击链,重点关注三个位置:富文本内容的href属性、模板引擎的输出上下文、前端消息监听器处理后的DOM操作。这种通过上下文分析驱动的人工测试,覆盖率远高于常规扫描器。扫描器的输出可以作为线索,但不能作为漏洞是否存在的最终裁决。
5.4 别忽略模板输出上下文的差异
同样是渲染一个变量,在HTML实体上下文、URL属性上下文、JavaScript字符串上下文、事件属性上下文里,需要的编码和防御方式完全不同。很多超链接XSS之所以能绕过过滤,就是因为开发者以为“做了HTML编码就安全了”,但href属性是一个URL上下文,它需要的不是HTML编码,而是URL协议层面的校验。
举个例子,如果模板里这样写:
<a href="{{ user_url }}">我的主页</a>在服务端模板引擎里对user_url做HTML实体编码(&、<、>等)其实对防护XSS作用有限,因为javascript:里的字符都是字母和冒号,压根不在HTML实体编码的范围内。这就是上下文混淆的典型。必须明确这里的输出位置是URL属性,要走URL白名单逻辑。
同样的值,如果在普通文本节点里输出,HTML实体编码是有用的;在href里输出,则需要单独的处理函数。每种上下文,各有一套防御配方,混用必然出问题。
5.5 老系统迁移时的存量链接风险
一些运行了多年的老系统,数据库里已经堆积了大量历史链接,其中可能有不少是“当年没被拦住的恶意链接”。单纯在上线新防御策略后对新增内容做校验还不够,存量数据必须一并清洗。实际操作上,可以写一个离线脚本来扫描数据库中的URL字段,识别出带javascript:、data:、vbscript:协议的历史数据,能清理的清理,不能清理的先隔离标记,等业务确认后再处理。
在我处理过的某OA系统历史数据里,光是一个公告表就有上百条历史链接包含了javascript:伪协议,大部分是早年测试payload留下的,混合在正常业务数据里一直躺了五六年。如果不是专项排查,根本不会有人注意到。
6. 最后再分享两个实战技巧
先聊纯手工payload调试的武器。我经常在浏览器控制台里塞一段监听脚本,专门捕获所有创建<a>标签的调用和写入href属性的行为:
const observer = new MutationObserver(mutations => { mutations.forEach(m => m.addedNodes.forEach(n => { if (n.tagName === 'A') console.log('detected:', n.href); })); }); observer.observe(document.body, {childList: true, subtree: true});有了这层监听,测试时任何动态生成的超链接都能被我实时看到。配合在HTMLAnchorElement.prototype上挂一个setter钩子,连改href属性值的操作都能拦下来,这样抓漏洞特征非常方便。
然后再说说清理payload的收尾习惯。测试时往往会往目标页面注入大量payload,这些数据可能被持久化存储到业务库里,忘了清理会让客户背上“历史数据含恶意代码”的包袱。我会在测试计划里专门列一个“清理清单”,每轮测试结束后按注入点逐一确认payload是否已清除。安全测试的底线是:你发现了漏洞,但不要因为你的测试动作给系统留下二次污染。
超链接XSS这玩意儿,说难不难,说简单也绝不简单。它的核心在于理解浏览器解析URL的那套“潜规则”,以及敢于承认输入过滤不能只靠一层。每个项目情况不一样,但把“URL协议白名单”这五个字刻在脑子里,足以规避掉绝大多数超链接类型的XSS问题。