由一道CTF引发的技术思考(wafbypass)
在最近举行的XYCTF 2025,有一道关于bottle注入的题目,但是waf的绕过手段,却非常的意外,此篇文章,会对其进行详细的分析以及解释
引言
在最近举行的XYCTF 2025,有一道关于bottle注入的题目,但是waf的绕过手段,却非常的意外,此篇文章,会对其进行详细的分析以及解释
前置
咱们先来看题目代码:
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) < 25 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)题目代码是非常简单的,就是在attack路由中,会把我们传入的payload进行渲染,然而,我们可以发现的是,payload的长度是被限制了,并且过滤掉了open函数,以及\\
这里我们不妨想一想,如果没有限制,即没有过滤,那正常的payload
{{open('/flag').read()}} #24个字符刚好那其实正常的payload,就是要用open去读取其下的文件,但是这里open被禁用,我们该如何绕呢?
正常的思维,大家可能会利用其他函数。例如:
http://127.0.0.1:5000/attack?payload={{dir()}}
hello ['__builtins__', '_escape', '_printlist', '_rebase', '_stdout', '_str', 'defined', 'get', 'include', 'rebase', 'setdefault']
其中的
include
rebase但是,在bottle的框架中,include用于在模板中嵌入其他模板文件。rebase也是一样。是读取不了服务器上的文件的。所以引入我们此篇文章的重点
斜体字符绕过
这里主要是指:一个字符的斜体字符集,即指的是Decomposition后为同一个字符的字符集字符编码特性
在 Unicode 字符集中,存在着一些特殊的斜体字符,它们在编码层面具有独特的特性。例如,斜体字符𝑜(U+1D46F)与普通字符 o 虽然在外观上有所不同,但具有相同的规范化分解结果。这是因为 Unicode 为了实现字符的标准化表示,定义了多种规范化形式,其中 NFC(Normalization Form C)和 NFD(Normalization Form D)是较为常用的两种。NFC 形式会将字符组合成规范的组合字符序列,而 NFD 则将字符分解为基字符和组合字符序列 。在这两种规范化形式下,斜体字符𝑜都会被处理为与普通字符 o 相同的形式。
同样,数学斜体字符ℴ(U+2134)在进行 NFC 或 NFD 规范化后,也会等价于普通字符 o。这是由于 Unicode 规范化的规则,它旨在消除字符编码的歧义性,确保不同平台和应用之间的字符表示一致性。在这种规则下,虽然斜体字符和普通字符在视觉上有明显区别,但从字符编码和规范化的角度来看,它们在某些情况下会被视为等同的字符。
比如:
其实用简单的话来说,在传入此类字符以后,在规范性分解以后,会把其指向同一个字符,例如
虽然 𝑎 看起来是斜体的 a,但它们在 Unicode 中被分配了不同的码点。这些字符的 规范化分解(Canonical Decomposition) 可能指向同一个基础字符--'a'
这也就是为什么我们能够绕过其中的限制,但是为什么在此能够被绕过呢?
原理
我们先看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)
mako_template = functools.partial(template, template_adapter=MakoTemplate)
cheetah_template = functools.partial(template,
template_adapter=CheetahTemplate)
jinja2_template = functools.partial(template, template_adapter=Jinja2Template)分析如下:
def template(*args, **kwargs):
tpl = args[0] if args else None
for dictarg in args[1:]:
kwargs.update(dictarg)args:
args[0]:模板标识符,可以是 模板名称(如 'index')、模板内容字符串 或 模板对象。
args[1:]:多个字典参数,合并到 kwargs 中作为模板变量。
kwargs:直接传递的模板变量,优先级高于字典参数。
adapter = kwargs.pop('template_adapter', SimpleTemplate)
lookup = kwargs.pop('template_lookup', TEMPLATE_PATH)
tplid = (id(lookup), tpl)
if tplid not in TEMPLATES or DEBUG:
# 加载模板并缓存模板适配器 (adapter):决定使用的模板引擎,默认为 SimpleTemplate。
模板查找路径 (lookup):默认从全局 TEMPLATE_PATH 加载模板文件。
缓存键 (tplid):基于 lookup 对象的内存地址和模板标识符,确保不同查找路径的模板独立缓存。
缓存条件:若处于 DEBUG 模式或模板未缓存,则重新加载。
if isinstance(tpl, adapter):
# 直接使用已有模板对象
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)模板内容检测:通过检查换行符或特定符号({, %, $),判断 tpl 是否为模板字符串。
文件系统加载:若 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)这了update一些变量以后,主要是来到此: self.execute(stdout, 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(self.co, env)这了即是模板的code的执行。
可以看到在exec的全局变量里定义了一个_escape和_printlist函数。
再次跟进co
def co(self):
return compile(self.code, self.filename or '<string>', 'exec')即是compile了self.code
再次跟进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可以看到:source, encoding = touni(source), 'utf8'
我们的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)将任意输入 s 转换为 Unicode 字符串,并且这是针对全体str,所以在我们上述的“斜体字符集”并不影响
通过上述的跟踪,我们可以得到:我们的输入,不论在不在{{}}里,经过唯一的编码检查就是对source的touni(),但是由于全局变量中的unicode在python3下是全体str,这就导致了我们可以输入斜体字符,并且通过转换 Unicode 字符串的时候,斜体字符会被映射到同一常见字符上,这就导致了绕过。
º (U+00BA)
º (U+00BA)
转换成URL编码
%C2%BA删除掉前面的%C2
我们能发现:

能够成功读取
当然了,本篇文章讨论的斜体字,大家也可以去考虑其他字符,例如艺术字,原理是一样的,都是会映射字符到常见字符集中。
例如此下代码:
import bottle
'''
flag in /flag
'''
@bottle.route('/')
def index():
return 'Hello, World!'
blacklist = [
'o', '\\', '\r', '\n', 'os', 'import', 'eval', 'exec', 'system', ' ', ';'
]
@bottle.route('/attack')
def attack():
payload = bottle.request.query.get('payload')
if payload and len(payload) < 25 and all(c not in payload for c in blacklist):
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)
和上述解法是一样的。
结语
除了斜体字符外,还有其他类型的字符可能被用于绕过检测。
艺术字符如ℴ(U+2134)、ᵒ(U+1D452)等,它们同样具有独特的视觉外观和 Unicode 编码。这些艺术字符在经过规范化处理后,也可能与普通字符产生混淆。例如,在某些字体渲染环境下,ℴ与普通字符 o 几乎难以区分,而在 Unicode 规范化过程中,它们可能被转换为相同的字符形式。这就使得攻击者有可能利用这些艺术字符构造恶意载荷,绕过基于字符匹配的检测机制。当 WAF 检测输入中是否包含敏感关键词时,由于艺术字符在原始输入中的特殊编码形式,WAF 可能无法准确识别其潜在的威胁,从而导致检测失败。
符号变形也是一种潜在的绕过方式。以 º(U+00BA)为例,在 URL 编码截断的情况下,它可能被解析为 a。这是因为在 URL 编码和解码的过程中,如果编码字符串被意外截断或处理不当,符号的原始意义可能会发生改变。利用这一特性,将恶意代码中的关键字符替换为类似 º 这样的符号,并通过精心构造 URL 编码,使得在传输和处理过程中,这些符号被错误解析为合法字符,从而绕过 WAF 的检测。当 WAF 对 URL 进行解析和检测时,由于截断后的符号被错误解析,WAF 无法识别出其中隐藏的恶意代码,使得攻击者能够成功实施攻击。