Skip to main content
HDWSec
Pentest SécuPentestOWASPCyberFuite de donnéesHacking · 8 min read

Mauvaise gestion des erreurs : quand faire planter votre sécurité suffit à la contourner

Sans jeton, l'API refuse. Avec un jeton invalide, elle refuse. Avec un jeton malformé, elle renvoie les 48 219 expéditions de toutes les entreprises clientes. Retour d'audit chez un éditeur de logiciel de transport sur la dernière catégorie du Top 10 OWASP.

TR
Thomas RABIET Ingénieur cybersécurité
Mauvaise gestion des erreurs : quand faire planter votre sécurité suffit à la contourner

Confidentialité. Cet audit a bien eu lieu. Pour des raisons évidentes de confidentialité, le nom du client, sa pile technique, ses volumes et les détails de la mission ont été modifiés, et ce que vous lisez en est une version romancée. Les défauts décrits, eux, sont ceux qu’on a trouvés.

Trois requêtes, trois réponses

# Sans jeton, l'API refuse proprement
$ curl -s https://api.transportys.fr/v2/shipments
{"error":"Authentification requise"}

# Avec un jeton bricolé, elle refuse aussi
$ curl -s -H "Authorization: Bearer abc123" https://api.transportys.fr/v2/shipments
{"error":"Jeton invalide"}

# Avec un jeton émis par la plateforme elle-même, mais expiré hier
$ curl -s -H "Authorization: Bearer $JETON_EXPIRE" https://api.transportys.fr/v2/shipments | jq '.total'
48219

Deux refus corrects, et une troisième requête qui renvoie 48 219 expéditions. Toutes les expéditions, de toutes les entreprises clientes de la plateforme.

Et le jeton de cette troisième requête n’a rien d’exotique. C’est un vrai jeton, émis par la plateforme, simplement arrivé à échéance.

Le client : Transportys, un éditeur français de logiciel pour le transport de marchandises. Le métier mérite deux phrases, parce qu’il n’a rien d’évident vu de l’extérieur. Quand une entreprise doit envoyer des palettes de son entrepôt vers un magasin, elle ne possède presque jamais les camions. Elle passe par des transporteurs, avec qui elle a négocié des tarifs à l’avance.

Le logiciel de Transportys est ce qui orchestre tout ça : il choisit le transporteur pour chaque envoi, applique le bon tarif, édite les documents de transport et suit la marchandise jusqu’à la livraison. Dans le métier, on appelle ça un TMS, pour Transport Management System.

Sa base contient donc les adresses de livraison, les plans de tournée, les contrats, et surtout les tarifs négociés entre chaque entreprise et chaque transporteur. Dans ce secteur, connaître la grille tarifaire d’un concurrent vaut bien plus cher qu’un fichier d’adresses.

Ce qu’on a exploité ici relève de la catégorie A10:2025 Mishandling of Exceptional Conditions, la dernière du Top 10 OWASP et l’une des deux nouveautés de l’édition 2025. Elle couvre les situations où un programme ne sait ni prévenir, ni détecter, ni réagir correctement à l’imprévu.

Autrement dit, la faille ne vit pas dans le chemin qui marche, ni dans le chemin d’erreur propre. Elle vit dans le chemin où c’est le contrôle de sécurité lui-même qui casse.

Le cas en bref

  • Secteur : éditeur français d’un TMS en SaaS pour le transport et la logistique
  • Catégorie OWASP : A10:2025 Mishandling of Exceptional Conditions
  • Format : pentest en boîte grise, à partir d’un compte de démonstration
  • Défaut 1 : le contrôle d’accès s’efface quand il rencontre une erreur, au lieu de refuser
  • Défaut 2 : une mise à jour interrompue en cours de route laisse le système à moitié modifié
  • Défaut 3 : deux messages d’erreur différents permettent d’énumérer les numéros d’expédition valides
  • Donnée exposée : 48 219 expéditions, toutes entreprises clientes confondues, tarifs négociés inclus
  • Correctif : décider explicitement, pour chaque contrôle, ce qui se passe quand il échoue

Fail open, fail closed

Prenez le portique d’accès d’un entrepôt. Coupure de courant. Il s’ouvre, ou il se ferme ?

La question n’a pas de bonne réponse universelle, et c’est tout l’intérêt.

Une porte coupe-feu doit s’ouvrir quand le courant saute, pour que les gens à l’intérieur puissent sortir. S’ouvrir quand ça casse, c’est le fail open.

Le portique d’entrée, lui, doit rester verrouillé, sinon il ne protège plus la marchandise et n’importe qui entre depuis l’extérieur. Rester fermé quand ça casse, c’est le fail closed.

Il n’y a aucune contradiction là-dedans : c’est la même coupure de courant, et deux directions opposées. On laisse sortir, on ne laisse pas entrer.

Le vrai défaut n’est donc pas d’avoir choisi le mauvais. C’est de n’avoir rien choisi du tout, et d’avoir laissé un bloc de code décider à votre place. Chez Transportys, personne n’a jamais pris la décision d’ouvrir l’API en cas de panne d’authentification. Elle s’est prise toute seule, un jour, dans un bloc catch.

Trois défauts, dans l’ordre où on les trouve

Défaut 1. Le contrôle d’accès s’efface quand il rencontre une erreur. C’est le défaut principal, celui de la démonstration en tête d’article, et il correspond à la CWE-636, Not Failing Securely. Le CWE, c’est le catalogue public des types de faiblesses logicielles tenu par le MITRE, dans lequel chaque défaut connu porte un numéro.

Voilà à quoi ressemble le cœur du problème :

public function listShipments(Request $request): Response
{
    $token = $request->headers->get('Authorization');

    // deux refus propres, écrits par l'équipe et couverts par les tests
    if ($token === null) {
        return $this->json(['error' => 'Authentification requise'], 401);
    }
    if (!$this->isWellFormed($token)) {
        return $this->json(['error' => 'Jeton invalide'], 401);
    }

    try {
        $user = $this->authService->verify($token);
    } catch (\Throwable $e) {
        // le service d'authentification est instable, on ne bloque pas les expéditions
        $this->logger->warning('auth unavailable', ['err' => $e->getMessage()]);
        $user = null;
    }

    // l'exécution reprend ici, exactement comme si la validation avait réussi

    return $this->json(
        $this->shipments->forClient($user?->clientId)
    );
}

Les deux if du haut ne sont pas décoratifs. Ce sont de vrais refus, écrits par l’équipe et couverts par les tests. Ils rejettent ce que le contrôleur peut juger tout seul, un en-tête absent ou une chaîne qui n’a pas la forme d’un jeton.

Le problème commence en dessous, quand il faut vraiment vérifier quelque chose. Voilà où atterrit chaque cas :

Ce qu’on envoieCe qui se passeRésultat
Rien du toutrejeté par le premier ifrefus 401
abc123rejeté par le second ifrefus 401
Un jeton valide et non expiréverify() renvoie l’utilisateurses propres expéditions
Un jeton expiréverify() lève ExpiredExceptiontoute la base
Un jeton forgé, signature fausseverify() lève SignatureInvalidExceptiontoute la base
Un jeton bien formé, contenu illisibleverify() lève UnexpectedValueExceptiontoute la base
Le service d’authentification injoignableverify() lève ConnectExceptiontoute la base

Sept situations, un seul accès légitime, deux refus corrects, et quatre fuites.

Regardez où passe la frontière. Tout ce que le contrôleur pouvait trancher seul est refusé proprement. Tout ce qui demandait une vraie vérification, la signature, la date d’expiration, la disponibilité du service, arrive sous forme d’exception, et toutes les exceptions mènent au même endroit. Le contrôle refuse donc ce qu’il pouvait juger seul, et laisse passer ce qu’il était censé réellement vérifier.

Il y a même une ironie là-dedans. Raccourcir la durée de vie des jetons est une bonne pratique, recommandée partout. Ici, elle aggrave la fuite, puisque plus les sessions expirent souvent, plus le catch se déclenche.

Trois ingrédients se combinent pour en arriver là, et pris séparément aucun ne poserait problème.

D’abord, le catch est trop large. \Throwable attrape absolument tout. Or la bibliothèque de jetons lève des exceptions de natures très différentes : une signature invalide veut dire « ce jeton est faux, refuse », tandis qu’un délai réseau dépassé veut dire « le service n’a pas répondu ». En les mettant dans le même sac, on répond « laisse passer » aux deux. S’ils n’avaient attrapé que l’erreur réseau, en laissant remonter l’erreur de signature, la faille n’existerait pas.

Ensuite, l’exception n’est jamais relancée. Le catch écrit une ligne de journal, met $user à null, et la méthode poursuit son chemin. Ce cas précis a son entrée au catalogue, la CWE-390, Detection of Error Condition Without Action.

Enfin, ce null ne déclenche aucun refus, il se propage. Et il arrive jusqu’ici :

public function forClient(?int $clientId): array
{
    $qb = $this->createQueryBuilder('s');

    // filtre optionnel : sans identifiant client, la condition n'est jamais ajoutée
    if ($clientId !== null) {
        $qb->andWhere('s.clientId = :id')->setParameter('id', $clientId);
    }

    return $qb->getQuery()->getResult();
}

C’est le pattern du filtre optionnel, qu’on a tous écrit. Sans identifiant client, la condition WHERE n’est jamais ajoutée, donc la requête renvoie la table entière. « Pas de client » a été traduit par « aucune restriction » au lieu de « aucun résultat ». Voilà les 48 219 lignes.

Défaut 2. Une mise à jour qui s’arrête au milieu laisse le système à moitié modifié. Changer le destinataire d’un envoi demande deux écritures qui se suivent : enregistrer le nouveau destinataire, puis recalculer qui a le droit de suivre la marchandise. Si la seconde échoue, la première reste en place. L’envoi part désormais chez quelqu’un d’autre, mais c’est toujours l’ancien destinataire qui peut le suivre, parfois pendant des mois.

En base de données, on appelle transaction un enchaînement d’écritures qui doivent toutes réussir, ou toutes être annulées ensemble. Ici il n’y en avait pas : les deux écritures étaient indépendantes, donc l’échec de la seconde laissait tranquillement la première derrière elle. C’est le scénario de corruption d’état que l’OWASP cite explicitement dans cette catégorie.

Défaut 3. Deux messages d’erreur différents suffisent à énumérer. Demander une expédition qui n’existe pas renvoie « expédition introuvable ». En demander une qui existe mais ne vous appartient pas renvoie « accès refusé ». La différence entre les deux réponses est une information en soi, et elle permet de cartographier les numéros valides sans jamais accéder à leur contenu. C’est la CWE-209, un message d’erreur qui en dit plus qu’il ne devrait.

Comment y remédier

  • Décider explicitement du comportement en cas d’erreur, contrôle par contrôle. Fail closed par défaut pour tout ce qui protège une donnée, et fail open réservé aux cas où une vie humaine en dépend.
  • Attraper l’exception précise, jamais la classe racine. Un défaut de signature et une panne réseau n’appellent pas la même réponse, donc ils ne doivent pas passer par le même bloc.
  • Faire de l’absence d’utilisateur un refus explicite. Pas un état neutre qui continue son chemin.
  • Bannir le filtre optionnel sur une donnée cloisonnée. Sans identifiant client, une requête doit renvoyer zéro ligne, jamais toutes.
  • Rendre les transactions atomiques. Si le recalcul des droits échoue, la mise à jour qui le précède doit être annulée avec lui.
  • Tester le chemin d’erreur du contrôle lui-même. Un test automatisé qui coupe le service d’authentification et vérifie que l’API refuse bien vaut tous les audits de code.
  • Faire remonter ces erreurs en alerte, pas en warning****. Une ligne de journal que personne ne lit n’est pas une détection.

Et la règle qui résume tout : un contrôle de sécurité qui tombe doit faire échouer la requête, pas disparaître.

Ce que ça coûte

Cette fois, le sujet n’est pas d’abord la donnée personnelle, donc on sort du terrain habituel du RGPD. Le texte à regarder ici s’appelle NIS2.

NIS2 est une directive européenne publiée fin 2022. Son objectif n’est pas de protéger les données des personnes, c’est le rôle du RGPD, mais de faire en sorte que les services dont dépend l’économie continuent de fonctionner et résistent aux attaques. Concrètement, elle impose aux organisations concernées de gérer activement leurs risques cyber et de signaler rapidement leurs incidents graves.

En France, elle ne s’applique pas encore. Le texte doit d’abord être intégré au droit français, et la loi qui doit le faire est toujours en discussion au Parlement. Mais son contenu est connu, donc on peut déjà poser la question : si NIS2 s’appliquait à Transportys, qu’est-ce qu’on pourrait lui reprocher ?

À peu près tout ce qu’on a trouvé. L’article 21 de la directive liste les mesures minimales attendues, et quatre d’entre elles tombent pile sur nos défauts :

  • La gestion des incidents. Chaque contournement du contrôle d’accès produisait une ligne de journal en warning, sans alerte, sans seuil, sans destinataire. Or l’article 23 impose une alerte dans les 24 heures après avoir eu connaissance d’un incident important, et on ne peut pas alerter sur ce qu’on n’a jamais vu.
  • La sécurité du développement des systèmes. Un contrôle d’accès qui s’efface dès qu’il rencontre une erreur est exactement le genre de défaut que ce point vise.
  • Les politiques de contrôle d’accès. Le texte les cite nommément, et chez Transportys le contrôle disparaissait précisément au moment où il aurait dû tenir.
  • L’évaluation de l’efficacité des mesures. Aucun test ne couvrait le cas où la vérification elle-même échoue, donc personne ne pouvait démontrer que le contrôle fonctionnait vraiment.

Et les amendes prévues par la directive se comptent en millions d’euros, ou en pourcentage du chiffre d’affaires mondial.

Reste à savoir si un éditeur comme Transportys serait directement concerné, et la réponse n’est pas évidente. Le secteur transport de NIS2 vise d’abord des opérateurs, comme les compagnies aériennes, ferroviaires ou maritimes et les gestionnaires de ports ou d’aéroports. Et la directive ne s’applique en principe qu’aux entreprises de taille moyenne ou plus. Pour savoir si on est soi-même concerné, l’ANSSI propose un test en ligne.

Mais même hors du périmètre, l’éditeur n’y échappe pas vraiment. Parmi ses entreprises clientes, certaines y seront soumises, un industriel de l’agroalimentaire ou un fabricant de machines par exemple. Celles-là devront assurer la sécurité de leur chaîne d’approvisionnement, ce qui veut dire contrôler leurs fournisseurs. Transportys en tête.

Et le coût qui mord dès aujourd’hui, lui, ne figure dans aucun texte. Pour un éditeur de TMS, la grille tarifaire d’une entreprise cliente est le secret le mieux gardé de la relation commerciale. Une fuite ne se solde pas par une amende, elle se solde par des contrats qui partent à la concurrence.

Pourquoi cette faille survit

Les tests couvrent tout ce qui était prévu. On vérifie que ça marche avec un bon jeton, on vérifie que les deux refus fonctionnent. Ce que personne n’écrit, c’est le test où la vérification elle-même échoue, parce qu’il ne figure dans aucun cahier des charges.

Le catch large est enseigné comme une bonne pratique. On apprend à ne pas laisser une exception remonter jusqu’à l’utilisateur. C’est un bon réflexe pour l’affichage, c’est un désastre pour une décision d’autorisation.

Ce bloc est né d’un vrai incident. Un jour, le service d’authentification est tombé, les expéditions ont été bloquées, et il a fallu une intervention en urgence pour rétablir le service. Le catch a été écrit le lendemain, pour de bonnes raisons, et personne n’y est jamais revenu.

L’erreur ne fait aucun bruit. Une faille de contrôle d’accès classique finit par se voir dans les données. Celle-ci se déclenche en silence, plusieurs fois par jour, à chaque session qui arrive à échéance, et la seule trace qu’elle laisse est une ligne warning que personne ne lit.

Comment savoir si vous êtes exposé

Un geste que votre équipe peut faire cet après-midi, sur votre propre dépôt :

grep -rn "catch" --include="*.php" src/ | grep -iE "auth|token|permission|acl"

Adaptez l’extension à votre langage. Pour chaque résultat, une seule question compte : qu’est-ce qui se passe juste après ce bloc ? Si l’exécution reprend son cours normal sans refus explicite, vous avez le même défaut que Transportys.

Côté référentiels, l’ASVS de l’OWASP traite la gestion des erreurs et l’exigence de refus par défaut, et le Web Security Testing Guide détaille les tests de gestion d’erreurs. C’est sur ces référentiels que s’appuie notre test d’intrusion web et API.

On avait déjà raconté une faille de contrôle d’accès chez une PropTech et une préproduction oubliée sur Internet, deux autres retours d’audit de la même série.

Conclusion

Chez Transportys, personne n’a décidé d’ouvrir l’API à qui saurait faire planter l’authentification. La décision s’est prise toute seule, dans un bloc catch écrit pour de bonnes raisons après un vrai incident.

C’est ce qui rend cette catégorie difficile à voir. Elle ne se trouve ni dans le chemin nominal, que tout le monde teste, ni dans le refus propre, que tout le monde teste aussi. Elle vit dans le troisième chemin, celui où le contrôle casse, et celui-là n’est écrit nulle part.

C’est d’ailleurs une bonne partie de notre travail : prendre une API, la parcourir point par point, et vérifier ce qu’elle répond dans tous les cas, y compris ceux que personne n’avait prévus.

Si vous opérez une application qui protège des données derrière un contrôle d’accès, la question n’est pas de savoir si ce contrôle fonctionne. Il fonctionne, sinon vous l’auriez vu. Elle est plus dérangeante : qu’est-ce qu’il fait le jour où il tombe ?

Si personne dans votre équipe ne peut répondre, c’est qu’un bloc catch a déjà répondu à sa place.

→ Notre test d’intrusion web et API

→ Demandez un pentest

TR
Written by Thomas RABIET Ingénieur cybersécurité

Ready to test your security?

Our experts conduct penetration tests tailored to your scope and challenges, with a clear report and actionable recommendations.