Aller au contenu
Tous les challenges
Misc Résolu Facile · 1 800 m

Peak Privilege

NeuralBadge : un badge adverse forgé par montée de gradient.

ID CTFd
13
Scripts
2 fichiers

Énoncé (résumé)

BlackAlps a remplacé clés et certificats par NeuralBadge, un contrôle d’accès par IA. Avec un badge employé et le logiciel récupéré sur un scanner déclassé, il faut convaincre le système qu’on est administrateur. Service : https://peak-privilege.ctf.blackalps.ch. Fichier : player.zip.

Reconnaissance

player.zip contient :

  • preprocess.py — spec d’entrée du scanner (aucun poids de modèle) ;
  • client.py — client HTTP (/verify une image, /batch jusqu’à 40) ;
  • employee_badge.png — un badge employé valide (256×176 RGB).

Pipeline d’entrée (preprocess.py) :

  1. Badge RGB 256×176 obligatoire.
  2. Crop PATTERN_BOX = (32,48,128,144) → patch 96×96 (le reste du badge est ignoré).
  3. Luminance (0.299R+0.587G+0.114B) puis pooling moyen 3×3 → entrée classifieur 32×32 float32.

Classes : EMPLOYEE / INVALID / ADMIN. Condition d’accès : p(ADMIN) ≥ 0.995.

Appel du service sur le badge employé :

EMPLOYEE=0.99999…  INVALID≈1e-13  ADMIN≈2e-23  access=DENIED

Point clé / vulnérabilité

  • Les poids ne sont pas fournis, mais le endpoint /batch renvoie les probabilités softmax complètes (haute précision) → oracle à perte exploitable.
  • Le pooling 3×3 signifie qu’on contrôle de fait une image 32×32 en niveaux de gris (chaque pixel = un bloc 3×3 uniforme, valeur k/255). Le reste du badge n’influe pas.
  • C’est une attaque adversariale boîte noire classique : trouver une entrée 32×32 classée ADMIN avec confiance ≥ 99.5 %.

La classe INVALID joue le rôle de détecteur « ce n’est pas un vrai badge » : les grilles constantes et les grands pas tombent dans INVALID. Il faut rester sur la variété des badges valides → montée de gradient projetée à petits pas.

Exploitation (reproductible)

  1. Point de départ = grille 32×32 du badge employé (point EMPLOYEE valide), obtenue via preprocess.preprocess_image(...) → x0.npy.
  2. Objectif f(x) = log p(ADMIN) (pénalise la dérive vers INVALID et EMPLOYEE).
  3. À chaque itération :
    • gradient par différences finies (1024 requêtes, δ=0.15, via /batch 40 à la fois) ;
    • line-search sur plusieurs tailles de pas, projection dans [0,1] ;
    • on garde le meilleur candidat.
  4. Convergence en 3 itérations :
    • it0 : ADMIN 0.10
    • it1 : ADMIN 0.91
    • it2 : ADMIN 0.9995 → access GRANTED, le flag est renvoyé.

Astuce d’accès à l’API CTFd (téléchargement du fichier) : ajouter le header Content-Type: application/json, sinon redirection vers /login.

Script final

  • attack.py — conversion grille 32×32 ↔ PNG 256×176 (round-trip exact vérifié) + oracle /batch.
  • pgd.py — montée de gradient projetée sur log p(ADMIN), écrit best.npy et imprime le flag.
  • Lancement :
    python3 -m venv .venv
    .venv/bin/pip install "Pillow>=11.3,<13" "numpy>=2.3,<3"
    .venv/bin/python -I pgd.py      # lit x0.npy, interroge le service, affiche le flag
  • Artefacts : x0.npy (départ), best.npy / admin_grid.npy (grille admin), admin_badge.png (badge adversarial 256×176 classé ADMIN à 99.95 %).

Chemins : ./{attack.py,pgd.py,SOLUTION.md}.

Code

#!/usr/bin/env python3
"""Iterative projected gradient ascent on log p(ADMIN), black-box via /batch.

Objective f(x) = log p(ADMIN)  (penalises drifting into INVALID or EMPLOYEE).
Each iteration: full forward-difference gradient (1024 queries) + line search.
"""
from __future__ import annotations
import sys, time, numpy as np
sys.path.insert(0, ".")
import attack

TARGET = 0.995


def obj(res: dict) -> float:
    return float(np.log(max(res["probabilities"]["ADMIN"], 1e-300)))


def fd_gradient(x: np.ndarray, f0: float, d: float = 0.15) -> np.ndarray:
    N = 32 * 32
    grids, deltas = [], np.zeros(N)
    for i in range(N):
        r, c = divmod(i, 32)
        step = d if x[r, c] + d <= 1.0 else -d
        deltas[i] = step
        g = x.copy(); g[r, c] += step
        grids.append(g.astype(np.float32))
    res = attack.eval_grids(grids)
    grad = np.zeros((32, 32))
    for i, rr in enumerate(res):
        r, c = divmod(i, 32)
        grad[r, c] = (obj(rr) - f0) / deltas[i]
    return grad


def main():
    try:
        x = np.load("best.npy").astype(np.float64)
        print("resuming from best.npy")
    except OSError:
        x = np.load("x0.npy").astype(np.float64)
    base = attack.eval_grids([x.astype(np.float32)])[0]
    f0 = obj(base)
    best_admin = base["probabilities"]["ADMIN"]
    print(f"start ADMIN={best_admin:.6f} f0={f0:.3f}")

    for it in range(60):
        g = fd_gradient(x, f0)
        gn = g / (np.abs(g).max() + 1e-12)
        alphas = [0.03, 0.06, 0.12, 0.2, 0.3]
        cands = [np.clip(x + a * gn, 0, 1).astype(np.float32) for a in alphas]
        res = attack.eval_grids(cands)
        scores = [obj(r) for r in res]
        j = int(np.argmax(scores))
        admins = [r["probabilities"]["ADMIN"] for r in res]
        print(f"it{it:02d} f0={f0:.3f} | " +
              " ".join(f"a{a}:{r['probabilities']['ADMIN']:.4f}"
                       for a, r in zip(alphas, res)) +
              f" -> pick a{alphas[j]} ADMIN={admins[j]:.6f} acc={res[j]['access']}")
        if res[j].get("flag"):
            print("FLAG:", res[j]["flag"]); np.save("best.npy", cands[j]); return
        if scores[j] <= f0 + 1e-4:
            # no improvement: shrink by trying tiny steps / random restart perturb
            print("  no improvement; trying smaller steps")
            alphas2 = [0.005, 0.01, 0.02]
            cands2 = [np.clip(x + a * gn, 0, 1).astype(np.float32) for a in alphas2]
            res2 = attack.eval_grids(cands2)
            sc2 = [obj(r) for r in res2]
            j2 = int(np.argmax(sc2))
            if sc2[j2] > f0 + 1e-6:
                x = cands2[j2].astype(np.float64); f0 = sc2[j2]
                best_admin = res2[j2]["probabilities"]["ADMIN"]
            else:
                print("  stuck, stop"); break
        else:
            x = cands[j].astype(np.float64); f0 = scores[j]
            best_admin = admins[j]
        np.save("best.npy", x.astype(np.float32))
        if best_admin >= TARGET:
            r = attack.eval_grids([x.astype(np.float32)])[0]
            print("REACHED", r["access"], "flag=", r.get("flag"))
            if r.get("flag"):
                print("FLAG:", r["flag"])
            return
    print("done, best ADMIN=", best_admin)


if __name__ == "__main__":
    main()