揭秘点击劫持攻击原理与前端防御实战
线上突然报警,用户说按钮点不动
周五下午本来准备摸鱼下班了,结果群里突然炸了:好几个用户反馈在某个页面点击购买按钮没反应。一开始以为是网络问题或者接口挂了,查了一圈发现接口正常,日志里也确实有请求打进来——但不是来自用户的主动点击,而是某些奇怪的 iframe 行为触发的。
后来翻监控看到异常流量来源都指向同一个第三方域名,再一查访问路径,好家伙,我们的支付页被套进一个透明 iframe 里了。典型的点击劫持(Clickjacking)攻击。
我第一反应是“这都 2024 年了还有人搞这个?” 但现实就是这么魔幻。赶紧先上防护,不然真有人误操作完成支付就完蛋了。
第一反应:X-Frame-Options
最简单的方案肯定是加 HTTP 头 X-Frame-Options,让浏览器直接拒绝被嵌套到 iframe 里。改 Nginx 配置几秒钟的事:
add_header X-Frame-Options "DENY" always;
或者如果是只允许同域嵌套,可以用 SAMEORIGIN:
add_header X-Frame-Options "SAMEORIGIN" always;
本地测了一下没问题,推到预发环境后 QA 回报说某个老系统里的内嵌功能崩了——原来内部另一个团队用 iframe 嵌了我们这边的一个表单页做流程集成……
这里我踩了个坑:以为 DENY 是万能解药,结果忘了业务耦合这回事。后来改成 SAMEORIGIN,但问题又来了:那个恶意站点是跨域的,而 SAMEORIGIN 只防不住完全跨域的 iframe 套娃,只要他们不在同源下就能继续作妖。
而且现代浏览器已经开始逐步废弃 X-Frame-Options,官方推荐转向 CSP 方案。所以这条路虽然快,但不够健壮,长期来看得换。
换个思路:Content Security Policy
于是转头研究 CSP(Content Security Policy)。它的 frame-ancestors 指令可以替代 X-Frame-Options,而且支持更细粒度控制。
最终配置是这样的:
add_header Content-Security-Policy "frame-ancestors 'self' https://trusted-partner.com;" always;
这样既能允许我们自己的域名和可信合作方嵌入,又能挡住其他所有来源。部署之后用下面这段 HTML 本地测试是否生效:
<iframe src="https://jztheme.com/payment"></iframe>
如果页面无法加载,说明防护成功。试了几个不同的 src,包括恶意域名和空 src,都没能绕过,心里踏实了些。
但这里注意我踩过好几次坑:CSP 的语法特别容易写错,比如少个引号、多个空格,整个策略就失效了。建议上线前一定用 Google 的 CSP Evaluator 工具扫一遍。
前端补防:JavaScript 检测 + 自动跳出
虽然服务器端已经上了双保险,但我还是不放心。毕竟有些旧浏览器根本不支持 CSP,或者用户开了代理把 header 给吞了。
所以最后加上一层 JS 防护,放在所有页面最顶部执行:
if (window.top !== window.self) {
// 当前处于 iframe 中
if (document.visibilityState === 'visible') {
// 如果页面可见,可能是被伪装成正常页面进行劫持
console.warn('检测到非预期嵌套,尝试跳出 iframe');
try {
window.top.location = window.self.location;
} catch (e) {
// 跨域时可能无法访问 top.location
document.body.innerHTML = '<h1>安全警告:请勿在非官方渠道使用本页面</h1>';
}
}
}
这个逻辑很简单:只要发现当前页面不是顶层窗口,并且用户能看到它(visibilityState 为 visible),那就极有可能是被拿来当点击劫持用了。
这里折腾了半天发现一个问题:有些浏览器出于安全限制,不允许子 frame 修改 top.location,会抛错。所以必须包一层 try-catch,防止脚本中断。
另外我还加了个降级方案:清空 body 并显示警告文案,至少不让用户真的误点了按钮。
顺手加固:按钮级防劫持
其实还有一种更精细的做法,是对关键按钮单独防护。比如给购买按钮加上鼠标事件校验:
let isTrustedClick = false;
document.addEventListener('mousedown', (e) => {
isTrustedClick = (e.clientX > 0 && e.clientY > 0);
}, true);
buyButton.addEventListener('click', (e) => {
if (!isTrustedClick) {
e.preventDefault();
alert('检测到异常操作,请刷新页面重试');
return;
}
// 正常处理逻辑
});
原理是利用了点击劫持通常依赖透明 iframe 覆盖,真实的点击坐标可能是 0,0 或负值。不过这个方法局限性很大,比如手机端 touch 事件就不一样,还得额外兼容,后来试了下发现兼容成本太高,就没全量上。
只在个别高风险操作页留着作为辅助检测,算是多一层心理安慰吧。
关于防御原理的一些碎碎念
点击劫持本质就是利用视觉欺骗:把你的真实页面藏在一个透明 iframe 里,然后诱导用户去点某个位置,实际上是在那个 iframe 里完成了操作。
常见套路比如:“恭喜中奖!点击领取”,背后其实是让你点了“确认转账”按钮。防的关键就是三点:
- 不让页面被嵌进未知来源的 iframe(主防线)
- 即使被嵌了也能自动跳出或提示(备用兜底)
- 对敏感操作增加可信性校验(精细化防控)
很多人觉得加个验证码就行,其实不对。验证码解决的是自动化问题,而不是视觉欺骗。用户自己亲手点的,验证码照样过。
还有一个误区是认为 HTTPS 就安全了,其实 HTTPS 和点击劫持根本不是一个维度的问题。哪怕你全站加密,照样能被 iframe 套进去。
改完后的小尾巴
现在整个系统算是三重防护了:Nginx 层 CSP 控制、JS 主动跳出、关键按钮事件校验。看起来挺稳,但也留下一点小问题。
比如那个 JS 跳出逻辑,在某些企业内网环境下会导致页面无限跳转——因为他们用了复杂的 iframe 嵌套结构,而我们的判断太粗暴了。后来加了个白名单开关,通过 URL 参数临时关闭检测,算是妥协方案。
还有就是移动端 Safari 对 window.top 的行为不太一致,偶尔会出现误判。目前没更好办法,只能靠监控日志观察异常上报。
总之,安全这事没有一劳永逸。这次解决了,下次可能又冒出新花样的变种。能做的就是层层设防,别指望一道墙挡住所有敌人。
以上是我踩坑后的总结
如果你有更好的方案欢迎评论区交流。尤其是那种既能兼容老旧系统、又能有效防御新型攻击的实践,我很想听听。这个技巧的拓展用法还有很多,后续也会继续分享这类实战记录。

暂无评论