Перейти к основному содержанию

CTF — JWT - Revoked token

CTF

Всем привет, давненько мы не решали задачки на информационную безопасность web-серверов. Исправим это недоразумение. Сегодня задачка с портала root-me.org, называется "JWT - Revoked token". За решение задачки дают 25 баллов, ближе к среднему уровню.

ctf

Сразу скажу, сходу задачку мне не удалось решить, хотя она и не сложная, пришлось отложить на следующий день. Читайте RFC :)

Из названия становится понятно, что работать будем с JWT, а слабым местом будет отозванный токен.

Нам уже попадались задачка с JWT, так что базовые понятия нам уже знакомы:

CTF — JSON Web Token (JWT) - Introduction

CTF — JSON Web Token (JWT) - Weak secret

JSON Web Token (JWT) — это токен доступа для аутентификации в клиент-серверных приложениях. Представляет собой строку вида "header.payload.signature". Где:

  • header — заголовок. JSON объект в формате Base64, в котором передаются данные для описания самого токена, например, {"typ":"JWT","alg":"HS512"}.
  • payload — полезная нагрузка. JSON объект в формате Base64, в котором передаются данные пользователя, например, {"username":"v.pupkin","state":"superadmin"}.
  • signature — подпись. В формате Base64. Вычисляется из header + payload + secret, где secret — секретный ключ известный web серверу и серверу аутентификации.

JWT выглядит так:

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6Imd1ZXN0In0.OnuZnYMdetcg7AWGV6WURn8CFSfas6AQej4V9M13nsk

Ссылки

Base64 encode/decode

Трюки curl

Решение

Для начала ознакомимся с условием задачи. Доступны две страницы:

  • POST: /web-servur/ch63/login
  • GET: /web-servur/ch63/admin

Требуется получить доступ к странице администратора. Пока всё понятно, ясно что будем работать только с двумя URL, попасть нужно на второй.

Ещё нам дали исходный код:

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from flask import Flask, request, jsonify
from flask_jwt_extended import JWTManager, jwt_required, create_access_token, decode_token
import datetime
from apscheduler.schedulers.background import BackgroundScheduler
import threading
import jwt
from config import *
 
# Setup flask
app = Flask(__name__)
 
app.config['JWT_SECRET_KEY'] = SECRET
jwtmanager = JWTManager(app)
blacklist = set()
lock = threading.Lock()
 
# Free memory from expired tokens, as they are no longer useful
def delete_expired_tokens():
    with lock:
        to_remove = set()
        global blacklist
        for access_token in blacklist:
            try:
                jwt.decode(access_token, app.config['JWT_SECRET_KEY'],algorithm='HS256')
            except:
                to_remove.add(access_token)
       
        blacklist = blacklist.difference(to_remove)
 
@app.route("/web-serveur/ch63/")
def index():
    return "POST : /web-serveur/ch63/login <br>\nGET : /web-serveur/ch63/admin"
 
# Standard login endpoint
@app.route('/web-serveur/ch63/login', methods=['POST'])
def login():
    try:
        username = request.json.get('username', None)
        password = request.json.get('password', None)
    except:
        return jsonify({"msg":"""Bad request. Submit your login / pass as {"username":"admin","password":"admin"}"""}), 400
 
    if username != 'admin' or password != 'admin':
        return jsonify({"msg": "Bad username or password"}), 401
 
    access_token = create_access_token(identity=username,expires_delta=datetime.timedelta(minutes=3))
    ret = {
        'access_token': access_token,
    }
   
    with lock:
        blacklist.add(access_token)
 
    return jsonify(ret), 200
 
# Standard admin endpoint
@app.route('/web-serveur/ch63/admin', methods=['GET'])
@jwt_required
def protected():
    access_token = request.headers.get("Authorization").split()[1]
    with lock:
        if access_token in blacklist:
            return jsonify({"msg":"Token is revoked"})
        else:
            return jsonify({'Congratzzzz!!!_flag:': FLAG})
 
 
if __name__ == '__main__':
    scheduler = BackgroundScheduler()
    job = scheduler.add_job(delete_expired_tokens, 'interval', seconds=10)
    scheduler.start()
    app.run(debug=False, host='0.0.0.0', port=5000)

Я не силён в питоне, но видно, что это код обеих страниц, вернёмся к нему позже. Да, логин у нас admin и пароль тоже admin, понятно, что угадывать их не нужно.

Переходим на страницу задания:

http://challenge01.root-me.org/web-serveur/ch63/

ctf

Здесь всё то же самое, что в условии задачи. Просто так пробую войти на страничку админа:

http://challenge01.root-me.org/web-serveur/ch63/admin

ctf

Требуют авторизацию:

{"msg":"Missing Authorization Header"}

Пробую войти на страничку логина:

http://challenge01.root-me.org/web-serveur/ch63/login

ctf

Метод GET на этой страничке не разрешён, хотя это и так было понятно из исходного кода. Больше в браузере нам делать нечего, я привык работать с POST через curl.

Пробую попасть на страничку логина:

curl -X POST -d '{"username":"admin","password":"admin"}' http://challenge01.root-me.org/web-serveur/ch63/login

Облом:

{"msg":"Bad request. Submit your login / pass as {\"username\":\"admin\",\"password\":\"admin\"}"}

ctf

Ну давайте явно напишем в заголовке, что мы передаём JSON.

curl -H "Content-Type: Application/json" -X POST -d '{"username":"admin","password":"admin"}' http://challenge01.root-me.org/web-serveur/ch63/login

Не помогло.

ctf

Странно, может, ему не нравятся одинарный кавычки? Да и в подсказке двойные кавычки экранированы. Пробуем так:

curl -H "Content-Type: Application/json" -X POST -d "{\"username\":\"admin\",\"password\":\"admin\"}" http://challenge01.root-me.org/web-serveur/ch63/login

ctf

Мы успешно залогинились и нам выдали токен:

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE2NTUzMjkwNjksIm5iZiI6MTY1NTMyOTA2OSwianRpIjoiODRjZTA1ZjMtOGEyNS00YzFhLTgwYjctNTgxOGViMTMxYTZhIiwiZXhwIjoxNjU1MzI5MjQ5LCJpZGVudGl0eSI6ImFkbWluIiwiZnJlc2giOmZhbHNlLCJ0eXBlIjoiYWNjZXNzIn0.ZAVVPn562XeFTmw6sm1h31gXFKEHiB3l0DAHTDiRiAY

Это классический JWT. Пробуем войти с этим токеном на страничку админа методом GET:

curl -H "Authorization:eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE2NTUzMjkwNjksIm5iZiI6MTY1NTMyOTA2OSwianRpIjoiODRjZTA1ZjMtOGEyNS00YzFhLTgwYjctNTgxOGViMTMxYTZhIiwiZXhwIjoxNjU1MzI5MjQ5LCJpZGVudGl0eSI6ImFkbWluIiwiZnJlc2giOmZhbHNlLCJ0eXBlIjoiYWNjZXNzIn0.ZAVVPn562XeFTmw6sm1h31gXFKEHiB3l0DAHTDiRiAY" http://challenge01.root-me.org/web-serveur/ch63/admin

Упс:

{"msg":"Bad Authorization header. Expected value 'Bearer <JWT>'"}

ctf

Ну придираются же! Педантизм. Давайте пропишем заголовок так как они требуют:

curl -H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE2NTUzMjkwNjksIm5iZiI6MTY1NTMyOTA2OSwianRpIjoiODRjZTA1ZjMtOGEyNS00YzFhLTgwYjctNTgxOGViMTMxYTZhIiwiZXhwIjoxNjU1MzI5MjQ5LCJpZGVudGl0eSI6ImFkbWluIiwiZnJlc2giOmZhbHNlLCJ0eXBlIjoiYWNjZXNzIn0.ZAVVPn562XeFTmw6sm1h31gXFKEHiB3l0DAHTDiRiAY" http://challenge01.root-me.org/web-serveur/ch63/admin
{"msg":"Token has expired"}

ctf

Срок действия токена истёк. Хм, а какой там срок? Смотрим в исходный код:

access_token = create_access_token(identity=username,expires_delta=datetime.timedelta(minutes=3))

Срок жизни токена 3 минуты. Какой я не расторопный, шустрее нужно быть. Пишу быстрее. Получаю новый токен и пытаюсь с ним войти на страницу админа:

ctf

Вот оно:

{"msg":"Token is revoked"}

Я вижу слово revoked, мы подошли к сути задания. Похоже, что токен отозван и находится в чёрном списке. Возможно, нужно успеть воспользоваться токеном до того, как он попадёт в чёрный список? Посмотрим исходный код:

    access_token = create_access_token(identity=username,expires_delta=datetime.timedelta(minutes=3))
    ret = {
        'access_token': access_token,
    }
   
    with lock:
        blacklist.add(access_token)

Получается, что токен попадает в чёрный список сразу же, как только создаётся. Шансов втиснуться нет. Значит, нам требуется валидный токен, но не тот же самый, который помещается в чёрный список. А в чёрный список пихают даже не сам токен, а его BASE64 представление.

Но у нас нет другого токена! А имеющийся мы не можем изменить, потому что изменится подпись, её мы не сможем сгенерировать без секретного ключа. Однако, мы можем изменить BASE64 представление того же самого токена. На этом этапе я и споткнулся первый раз. Речь про RFC 4648, пункт 3.2 и 3.3, а именно про такое понятие как "заполнение в кодированных данных". Согласно RFC 4648: "В некоторых случаях заполнение (=) для base-представления данных не требуется и не используется" и "спецификации могут игнорировать символ заполнения (=)".

Base64 — Википедия (wikipedia.org)

В некоторых случаях в конце BASE64 строки может стоять один или два знака "=". Слово "текст" кодируется в "0YLQtdC60YHRgg==", знаки равенства выполняют роль заполнителя, их можно не учитывать.

Попробуем дописать знак равенства к токену. У нас получится тот же токен, только не эквивалентный той строке, которая была помещена в чёрный список.

ctf

Флаг у нас в руках, валидируем:

ctf

Флаг подходит, зарабатываем 25 очков.

Нам даже не пришлось расшифровывать сам токен. Я бы давал за это задание больше баллов. А решение красивое, нужно добавить всего лишь один байт.

Безопасность

Что не нужно делать написано в тексте флага. Не работайте с BASE64 кодировкой токена, работайте с самими полями данных.

Теги

 

Похожие материалы

CTF — Flask - Unsecure session

Приветствую юных белых хакеров. Продолжаем решать задачки по информационной безопасности web-серверов. Сегодня задачка с портала root-me.org, называется "Flask - Unsecure session". За решение задачки дают 20 баллов, лёгкий уровень.

Теги