Всем привет, давненько мы не решали задачки на информационную безопасность web-серверов. Исправим это недоразумение. Сегодня задачка с портала root-me.org, называется "JWT - Revoked token". За решение задачки дают 25 баллов, ближе к среднему уровню.
Сразу скажу, сходу задачку мне не удалось решить, хотя она и не сложная, пришлось отложить на следующий день. Читайте 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
Ссылки
Решение
Для начала ознакомимся с условием задачи. Доступны две страницы:
- 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/
Здесь всё то же самое, что в условии задачи. Просто так пробую войти на страничку админа:
http://challenge01.root-me.org/web-serveur/ch63/admin
Требуют авторизацию:
{"msg":"Missing Authorization Header"}
Пробую войти на страничку логина:
http://challenge01.root-me.org/web-serveur/ch63/login
Метод 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\"}"}
Ну давайте явно напишем в заголовке, что мы передаём JSON.
curl -H "Content-Type: Application/json" -X POST -d '{"username":"admin","password":"admin"}' http://challenge01.root-me.org/web-serveur/ch63/login
Не помогло.
Странно, может, ему не нравятся одинарный кавычки? Да и в подсказке двойные кавычки экранированы. Пробуем так:
curl -H "Content-Type: Application/json" -X POST -d "{\"username\":\"admin\",\"password\":\"admin\"}" http://challenge01.root-me.org/web-serveur/ch63/login
Мы успешно залогинились и нам выдали токен:
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>'"}
Ну придираются же! Педантизм. Давайте пропишем заголовок так как они требуют:
curl -H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE2NTUzMjkwNjksIm5iZiI6MTY1NTMyOTA2OSwianRpIjoiODRjZTA1ZjMtOGEyNS00YzFhLTgwYjctNTgxOGViMTMxYTZhIiwiZXhwIjoxNjU1MzI5MjQ5LCJpZGVudGl0eSI6ImFkbWluIiwiZnJlc2giOmZhbHNlLCJ0eXBlIjoiYWNjZXNzIn0.ZAVVPn562XeFTmw6sm1h31gXFKEHiB3l0DAHTDiRiAY" http://challenge01.root-me.org/web-serveur/ch63/admin
{"msg":"Token has expired"}
Срок действия токена истёк. Хм, а какой там срок? Смотрим в исходный код:
access_token = create_access_token(identity=username,expires_delta=datetime.timedelta(minutes=3))
Срок жизни токена 3 минуты. Какой я не расторопный, шустрее нужно быть. Пишу быстрее. Получаю новый токен и пытаюсь с ним войти на страницу админа:
Вот оно:
{"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==", знаки равенства выполняют роль заполнителя, их можно не учитывать.
Попробуем дописать знак равенства к токену. У нас получится тот же токен, только не эквивалентный той строке, которая была помещена в чёрный список.
Флаг у нас в руках, валидируем:
Флаг подходит, зарабатываем 25 очков.
Нам даже не пришлось расшифровывать сам токен. Я бы давал за это задание больше баллов. А решение красивое, нужно добавить всего лишь один байт.
Безопасность
Что не нужно делать написано в тексте флага. Не работайте с BASE64 кодировкой токена, работайте с самими полями данных.