0%

bottle框架中由斜体字引发的SSTI

本期推荐

不潮不用花钱 林俊杰
孤独患者 陈奕迅

前置知识

先简单的了解一下bottle框架

Bottle 是一个非常轻量级的 Python Web 框架,适合用于构建简单的 Web 应用和 RESTful API。Bottle 的最大特点之一是它的单文件设计,意味着你只需一个文件 bottle.py 即可使用整个框架,而不需要安装其他依赖

简单的起一个bottle

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 导入本地的 bottle.py 文件
from bottle import route, run

@rout('/')
def sb():
return "今天是七夕节,你在做什么呢"

# 定义路由及处理函数
@route('/dsb')
def hello():
return "Hello, World!"

# 启动应用
run(host='localhost', port=8080)

基本上和flask一样,

1
2
3
4
5
6
import bottle

@bottle.route('/index')
def index():
name = bottle.request.query.get('name', 'TG1u')
return f'{name}'

像这种请求我们在之前也见过了,所以不必多言

关于bottle的cookie机制在xyctf的那篇文章里我们也分析过了一遍了,也包括了如何去构造一个可以执行pickle反序列化的cookie

注意点:关于bottle的cookie机制

我在搜索的时候看到过这样一篇文章,关于在windows和Linux上的复现的生成的cookie的问题

原文链接
这位作者是通过起bottle服务的方式来得到cookie(但是我感觉在这题似乎是多此一举)
在复现时的一个要注意的地方:

在复现的时候发现linux起的服务会一直error,但是windows就不会。为了搞清楚问题所在,把main.py的try去掉使之报错,会报“No moudle named “nt”,众所周知nt是只有在windows中有的py库,那就很神奇了,bottle也没有调用,main也没有调用,怎么回事呢?

其实是因为得到恶意cookie需要起服务来拿cookie,而我是在win上起的,导致生成的cookie和linux上起服务是不一样的(大概python对于两个系统有做差分)。只要在linux上起cookie服务就能解决这个问题。或者考虑直接生成cookie而非利用服务来间接拿到cookie,前提是知道cookie生成的原理。

关于SSTI

1
2
3
4
5
总体上来说是和Flask差不多的
bottle默认的模板语法只有{{}}
但是我们自己都知道,还有很多种,比如<%%>,还有%

当{{}}被过滤时我们就可以用下列方法
1
%0a%%20print(7*7)

推荐可以完整阅读一遍这篇博客

SSTI的斜体字绕过

lmtx正版讲解

原理

何为斜体字

所谓的斜体字,实际上是指Decomposition后为同一个字符的字符集
比如在https://www.compart.com/里我们搜索一个a,就会蹦跶出一堆a

这些长得不一样的a统称为正版a的斜体字符集
alt text

斜体字为何可以被输入

直接去看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
adapter = kwargs.pop('template_adapter', SimpleTemplate)

这里默认了使用SimpleTemplate,也就是说如果没有自己通过kwargs传入自己要用的模板的话,就是使用SimpleTemplate

然后跟进去看
关于传入内容的处理主要就是

1
elif "\n" in tpl or "{" in tpl or "%" in tpl or '$' in tpl:

没看出有什么问题,接下来看到render,这个是在SimpleTemplate这个类中定义的成员方法

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)

检查了一下update,似乎没有什么问题,那么久去看看self.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

同样是被定义在SimpleTemplate这个类中的成员方法

看到一个exec,大家都知道exec是一个很敏感的函数,除此之外这里还有self.co,这里再跟进去看一下

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

有一个self.code还有self.filename
这里也是直接compile了一下,把这些内容都编译成可执行程序,然后给到上一步的exec进行执行

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

self.filename是写在basetemplate里的,这里应该是不起什么作用
看看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

看到了encoding的方法为utf8
还有个touni(source)

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)

return一个unicode
进到unicode里去看

1
unicode = str

这里的unicode指的就是str,而此处将str->touni->self.code->self.co->exec实现被执行的任务

似乎是对我们整个过程影响不大

接下来接着看self.code

1
code = parser.translate()

注意到在try语句之后来了个translate(),跟进去看一下translate

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)

可疑可疑真可疑,上面的丢给AI没什么动静,AI建议我重点关注一手flush_text和text_budder

看一手拉蒙特徐的博客,似乎是在flush中出现了问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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)

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

叽里咕噜加什么呢,看看腿

去看看process_inline

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

看不懂了,接下来的解释摘自拉蒙特徐

终于,出现了与转码有关的_escape函数。我们对照刚才回顾的exec执行的全局空间。我们看到:’_escape’: self._escape,。我们去找SimpleTemplate类的self._escape看看。还记得每一次进入SimpleTemplate都有一次初始化吗,就是prepare函数这些,我们来看:

1
2
3
4
5
6
7
8
9
10
11
12
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

可以看到初始化了self._escape = lambda x: escape_func(touni(x, enc))

touni()是老熟人了,看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编码函数

引用结束

重新回到flush_text
code那行会把我们的东西给变成另外一种更规范的形式,比如像sb会变成

1
code=_printlist(('sb', _escape(sb),))

跟进_printlist,就会发现是在env.update里面的内容

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__
})

所以这里的意思是_printlist只是一个在exec执行的全局空间里的打印函数
那并没有什么软用

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

关于如何传入,对于get传参只有a和o两个字符url编码后去掉%C2后传参可以起到等效于正常ascii字符的效果(不要问为什么要去掉%C2。自己打个断点去测试,是可以发现会自动补全的)

拉蒙特徐的get传参的exp(膜拜)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import re

def replace_unquoted(text):
pattern = r'(\'.*?\'|\".*?\")|([oa])'

def replacement(match):
if match.group(1):
return match.group(1)
else:
char = match.group(2)
replacements = {
'o': '%ba',
'a': '%aa',
}
return replacements.get(char, char)

result = re.sub(pattern, replacement, text)
return result

input_text = '' # payload
output_text = replace_unquoted(input_text)
print("处理后的字符串:", output_text)

关于upload一个文件进行渲染打斜体字绕过ssti

这个在不久的将来就会遇到这样子的题目,出生出题者ban了除了flag四个字母以外的所有字母以及一堆标点符号,到那个时候,就可以做到这样子的题目了

斜体字生成站

感谢拉蒙特徐
https://exotictext.com/zh-cn/italic/