Writeup

KVMillésime - Mise en fût - BreizhCTF 2026

Un binaire derrière une licence protégée par un vmmcall custom. L'hyperviseur modifié compte les borrows pendant une soustraction bit à bit et renvoie ce compteur en télémétrie. Un side-channel discret, mais suffisant pour reconstruire la clé 128 bits en une soixantaine de requêtes.

CTF: BreizhCTF 2026
Catégorie: Crypto / Hardware
Date: 2026-05-22

Contexte

Celui-là m'a bien pris par surprise. D'habitude en CTF crypto, on a un service TCP avec un oracle clair. Ici, on avait un binaire ELF pour un service de trading, un diff de module noyau Linux (diff_svm.c), et le tout tournait dans une VM KVM avec un hyperviseur modifié.

J'ai commencé par lire le binaire. La commande admin_token est verrouillée derrière une licence. Cette licence passe par un vmmcall avec le magic 0x1337 - c'est donc l'hyperviseur qui valide. Et le binaire loggue ensuite une TRADING_LATENCY_PROFILE...

C'est en lisant attentivement le diff SVM que tout s'est éclairé. L'hyperviseur ne fait pas juste comparer la licence - il compte quelque chose pendant la comparaison et le renvoie au guest. Ce compteur, c'est notre oracle.

Flag

Flag

••••••••••••••••••••••••••••••••Cliquer pour afficher

1. Architecture du service

Le binaire expose trois commandes utiles :

Commandes disponibles

license     -> envoie 32 hex chars (16 bytes) comme tentative de licence
admin_token -> accessible seulement si la licence est valide
logs 3      -> affiche les 3 derniers logs (incluant la télémétrie)

Chaque tentative de licence produit trois lignes de log :

Logs après une tentative

License check begin
License check failed
TRADING_LATENCY_PROFILE: Inserted hardware wait state: N

Ce N dans la dernière ligne, c'est ce que l'hyperviseur renvoie via rdxaprès le vmmcall. La question est : qu'est-ce qu'il calcule vraiment ?

2. La fuite : ce que fait vraiment l'hyperviseur

La fonction constant_time_compare_128dans le diff est la clé de tout :

diff_svm.c - constant_time_compare_128 (extrait)

for (int i = 0; i < 128; i++) {
    bit_i = (input >> i) & 1;
    bit_s = (secret >> i) & 1;

    if (borrow) {
        ndelay(50);
        (*p_wait_state)++;   // <- on compte les borrows
    }

    int val = bit_i - bit_s - borrow;
    if (val < 0) borrow = 1;
    else         borrow = 0;
}
*p_wait_state = ((u32)(*p_wait_state/5))*5;  // <- arrondi au multiple de 5

C'est une soustraction bit à bit de notre input par le secret. À chaque bit où le borrow est actif, le compteur s'incrémente. À la fin, la valeur est arrondie au multiple de 5 inférieur et renvoyée comme télémétrie.

Ce n'est pas "constant time" malgré son nom : le compteur divulgue exactement combien de borrows ont été actifs pendant la soustraction. C'est un side-channel direct sur le secret.

3. Modèle de l'oracle

En notant q_i le bit i de ma requête, s_i le bit i du secret, et b_i le borrow entrant au bit i (avec b_0 = 0) :

Propagation du borrow

Si q_i = 0 : b_{i+1} = s_i OR  b_i
Si q_i = 1 : b_{i+1} = s_i AND b_i

Le second cas est la propriété clé : si je fixe tous les bits bas à 1, le borrow reste à 0 quel que soit le secret. Ça me permet d'isoler un segment de bits et de reconstruire le secret de haut en bas, bloc par bloc.

L'arrondi par 5 complique un peu les choses : l'oracle ne me donne pas le nombre exact de borrows, mais une approximation à 5 près. Ça laisse quelques ambiguïtés en fin de reconstruction que je dois résoudre par test direct.

4. Stratégie de reconstruction

J'ai reconstruit le secret par blocs de 5 bits, du bit le plus haut vers le plus bas. À chaque itération :

  1. Je maintiens un ensemble de candidats partiels pour les bits déjà déterminés.
  2. J'étends chaque candidat avec toutes les 32 combinaisons du prochain bloc de 5 bits.
  3. Je choisis une requête qui partitionne le mieux les candidats selon la métrique simulée localement.
  4. J'interroge le service, je lis logs 3, et je filtre les candidats incompatibles avec la réponse.
La simulation locale de oracle_metric() est indispensable : elle me permet de choisir la requête optimale sans tâtonner à l'aveugle. Ça divise drastiquement le nombre de connexions nécessaires.

À cause de l'arrondi par 5, je termine avec quelques candidats ambigus. Je les teste alors directement sur le service en essayant chaque clé candidate avec license.

5. Le script

Les fonctions importantes du solver :

solve.py (fonctions clés)

def oracle_metric(input_val, secret_val):
    """Simule localement la métrique de l'hyperviseur."""
    borrow, count = 0, 0
    for i in range(128):
        if borrow:
            count += 1
        val = ((input_val >> i) & 1) - ((secret_val >> i) & 1) - borrow
        borrow = 1 if val < 0 else 0
    return (count // 5) * 5

def choose_query(candidates):
    """Choisit la requête qui partitionne le mieux les candidats."""
    best_q, best_score = None, -1
    for q in sample_queries(candidates):
        metrics = Counter(oracle_metric(q, c) for c in candidates)
        score = max(metrics.values())  # on veut minimiser le max
        if best_q is None or score < best_score:
            best_q, best_score = q, score
    return best_q

def query_metric(conn, input_val):
    """Envoie une tentative au service et lit la télémétrie."""
    send(conn, f"license\n{input_val:032x}\n")
    send(conn, "logs 3\n")
    log = recv_until(conn, "TRADING_LATENCY_PROFILE:")
    return int(re.search(r"wait state: (\d+)", log).group(1))

Résultat

[*] Phase oracle...
[bloc  1/26] 3 candidats restants
[bloc  2/26] 1 candidat
...
[*] Oracle terminé en 75 requêtes
[*] 4 candidats finaux, test direct...
[+] Clé valide : ef73fe05edf268100da19addf35a0345
[+] admin_token : BZHCTF{N4m4st3_V1nc3_H0p3_M4r14_3nj0y3d_Th3_H0t_Y0g4}

Ce que j'en retiens

  • Le diff, c'est le challenge. Tout est dans diff_svm.c. Le binaire guest est presque un prétexte - le vrai puzzle c'est de comprendre ce que fait l'hyperviseur modifié.
  • Simuler l'oracle en local pour choisir les requêtes est essentiel. Sans ça, il faudrait des centaines de connexions au lieu de 75.
  • L'arrondi par 5 est une protection partielle : il force à lever les ambiguïtés par test direct en fin de reconstruction, mais ça n'empêche pas l'attaque - ça l'allonge juste un peu.