· 4 min read · 770 words · Research

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

本期推荐

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

前置知识

先简单的了解一下bottle框架

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

简单的起一个bottle

# 导入本地的 bottle.py 文件
from bottle import route, run

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

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

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

基本上和flask一样,

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

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

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

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

SSTI的斜体字绕过

lmtx正版讲解

原理

何为斜体字

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

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

斜体字为何可以被输入

直接去看bottle.template

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)

简单看一下处理的部分

adapter = kwargs.pop('template_adapter', SimpleTemplate)

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

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

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

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

    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

    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,这里再跟进去看一下

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

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

    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

    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)

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里去看

unicode = str

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

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

接下来接着看self.code

code = parser.translate()

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

    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中出现了问题

    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)
parts.append(self.process_inline(m.group(1).strip()))

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

去看看process_inline

    @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函数这些,我们来看:

    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()。

escape_func=html_escape,

看定义在全局空间的html_escape()

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{{sb}}会变成

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

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

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执行的全局空间里的打印函数
那并没有什么软用

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

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

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

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/