Aller au contenu principal
own2pwn
appsec/vibe-coding-securite.tsx

Vibe coding et sécurité : j'ai démonté une app générée par IA sans écrire une ligne d'exploit

Vibe coding : le front React généré par IA était blindé, le backend grand ouvert. Clé d'API exposée dans le bundle, aucune Row Level Security, fonctions RPC sans autorisation. Récit d'une compromission totale par l'API, et pourquoi l'IA code toujours pareil.

own2pwn··13 min de lecture

Un samedi soir, une idée, un prompt. Trois quarts d'heure plus tard, une plateforme complète tourne en ligne : comptes utilisateurs, connexion, classement, tableau de bord. Zéro ligne de code écrite à la main. C'est ça, le vibe coding : vous décrivez, l'IA construit, ça marche. Le lundi, je l'ai attaquée. En quatre minutes, sans écrire un seul exploit, j'avais tous les secrets de la base, mon score à deux milliards et le pouvoir de bannir n'importe qui. Voici comment, et pourquoi ça arrive à presque toutes les applications générées de cette façon.

Cadre : mes propres applications
Tout ce qui suit s'est déroulé sur des applications que j'ai moi-même générées et hébergées, pour comprendre ce que produit réellement un assistant de code laissé sans supervision. Les cibles, clés, jetons et identifiants réels ont été caviardés ou remplacés. Attaquer un système dont vous n'êtes ni propriétaire ni mandaté pour le tester est illégal. Ici, on démonte le nôtre pour en tirer une leçon d'architecture.

Le front tient. On applaudit (trop vite)

Réflexe de pentester : on commence par là où tout le monde tape. Un champ de pseudo, un champ « pays », une bio. On y glisse les classiques du Top 10 des risques web, en visant le XSS stocké :

text
<script>alert(document.domain)</script>
<img src=x onerror=alert(1337)>
"><svg/onload=alert(document.cookie)>
javascript:alert(1)

Rien. Chaque charge s'affiche telle quelle, en texte, sur le profil. On teste le DOM (fragments d'URL, location.hash), on cherche un dangerouslySetInnerHTML avec de la donnée utilisateur, une open redirect, un eval traînant : rien n'accroche. La raison est structurelle et plutôt rassurante : le front est en React, et React échappe automatiquement toute expression rendue dans du JSX. Votre <script> devient &lt;script&gt; avant même d'atteindre le DOM. Pour sortir du contexte HTML, il faudrait que le développeur ait explicitement désactivé cette protection. L'IA ne l'a pas fait. Bon point.

Ce n'est pas un hasard
Le XSS est la faille la plus documentée du web. Un modèle entraîné sur des millions de composants React a parfaitement intégré le pattern « on affiche {username} dans une balise ». Ce qui est visible, testable au clic et omniprésent dans les tutoriels, l'IA le maîtrise. Retenez cette phrase : elle explique aussi la suite.

À ce stade, un audit superficiel signerait « RAS côté client » et passerait à autre chose. C'est exactement le piège. La surface qui compte n'est pas celle qu'on regarde.

Puis on ouvre l'onglet Réseau

L'application est un single-page React qui parle à un backend hébergé (le fameux backend-as-a-service : base de données, authentification et API REST auto-générée, le tout clé en main). Le navigateur doit bien s'authentifier auprès de cette API. Donc la clé d'accès est forcément dans le bundle JavaScript. On l'y trouve en dix secondes :

bash
# la clé « anon » et l'URL du projet, en clair dans le JS livré au navigateur
$ curl -s https://cible.exemple.app/assets/index-*.js | grep -oE 'https://[a-z0-9]+\.backend\.co|eyJ[A-Za-z0-9_-]{20,}'
https://xxxxxxxx.backend.co
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ...REDACTED...role":"anon"...

Ici, une confusion fréquente : cette clé est censée être publique. Elle n'est pas un secret, et l'exposer n'est pas la faille. La clé publique n'est qu'un ticket d'entrée vers l'API : ce qu'elle vous autorise à faire une fois entré dépend entièrement des règles côté serveur. Et c'est là que tout s'effondre.

chaine-attaque
Attaquant
XSS, injection, DOM
Tout est bloqué par React. Le front donne une fausse impression de solidité.
Attaquant
Onglet Réseau du navigateur
Récupère la clé publique et l'URL de l'API, en clair dans le bundle.
Backend
API REST auto-générée
Aucune règle d'autorisation ne filtre les lignes. La base répond tout, à tout le monde.
Attaquant
Contrôle total
Secrets lus, scores réécrits, comptes bannis. Sans une ligne d'exploit.
Le front bloque à l'entrée principale. Mais la porte de service (l'API) n'a aucun videur.

Pas de Row Level Security : la base répond à tout le monde

Sur ce type de backend, une table n'est protégée que si l'on active la Row Level Security (RLS) et qu'on écrit des policies : des règles SQL qui décident, ligne par ligne, qui a le droit de lire ou d'écrire quoi. Par défaut, sur beaucoup de projets, la RLS est désactivée. Sans elle, la clé publique donne un accès en lecture (et souvent en écriture) à toutes les lignes de toutes les tables exposées. Y compris la table des défis, avec leurs réponses :

bash
# lire la table des challenges, colonne "flag" comprise, avec la clé publique
$ curl -s 'https://xxxxxxxx.backend.co/rest/v1/challenges?select=title,points,flag' \
    -H "apikey: eyJ...REDACTED"
[
  {"title":"Warmup",     "points":50,  "flag":"CTF{r3d4ct3d_0001}"},
  {"title":"SQLi 101",   "points":200, "flag":"CTF{r3d4ct3d_0002}"},
  {"title":"Crypto boss","points":500, "flag":"CTF{r3d4ct3d_0003}"}
  ...
]

Les douze réponses de la plateforme, extraites en une requête, sans en résoudre une seule. Ce n'est ni du XSS, ni de l'injection SQL : c'est du contrôle d'accès cassé (broken access control), la première catégorie de l'OWASP Top 10. La donnée n'était protégée par aucune règle. L'IA a créé les tables, les a exposées via l'API, et n'a jamais écrit la moindre policy.

Les fonctions RPC sans videur

On monte d'un cran. L'IA avait généré des fonctions serveur (RPC) pour la logique métier : ajouter des points, bannir un tricheur. Le front les appelle. Mais rien ne vérifie qui les appelle ni pour qui. La signature est en clair dans le JavaScript ; il suffit de la rejouer :

bash
# s'attribuer le score maximal d'un entier 32 bits signé (2 147 483 647)
$ curl -s 'https://xxxxxxxx.backend.co/rest/v1/rpc/add_points_to_user' \
    -X POST -H "apikey: eyJ...REDACTED" -H 'content-type: application/json' \
    -d '{"p_user_id":"<mon-uuid>","p_points":2147483647}'

# bannir n'importe quel compte : la fonction admin n'exige aucun rôle admin
$ curl -s 'https://xxxxxxxx.backend.co/rest/v1/rpc/admin_ban_user' \
    -X POST -H "apikey: eyJ...REDACTED" -H 'content-type: application/json' \
    -d '{"p_user_id":"<uuid-du-1er-au-classement>","p_reason":"nope"}'

À partir de là, la plateforme m'appartient. Je mets mon score à INT_MAX, je passe tous les concurrents en négatif, je bannis le haut du classement, je réécris les pseudos. Le préfixe admin_ sur la fonction était le seul « contrôle » : un nom, pas une barrière. Une fonction qui s'appelle admin mais que tout anonyme peut exécuter, c'est le résumé parfait du problème.

pile-vibe-codee
Socle · invisible
Base de données · RLS désactivée
Toutes les lignes lisibles et modifiables avec la clé publique. La faille réelle est ici.
Logique · invisible
Fonctions RPC sans autorisation
admin_ban_user exécutable par n'importe qui. Le nom fait office de sécurité.
API · semi-visible
REST auto-générée
Expose chaque table telle quelle. Fait confiance au client.
Front · visible
React (échappement automatique)
Soigné, testé au clic, résistant au XSS. La partie que tout le monde regarde.
L'IA soigne la couche visible et laisse le socle grand ouvert. Survolez pour écarter les plans.

Deuxième cas : un proxy qui n'a jamais demandé la clé

Même famille de faille, autre décor. Un petit service généré pour relayer des appels vers une API de modèle de langage (un LLM). Le genre d'outil qu'on assemble en vingt minutes pour « partager un accès ». Sauf qu'il relaie les requêtes vers l'API coûteuse sans jamais vérifier d'authentification : une clé bidon est acceptée sans broncher.

bash
# aucune clé valide requise : "Bearer peu-importe" passe
$ curl -s https://proxy.exemple.app/v1/messages \
    -H 'authorization: Bearer nimportequoi' \
    -H 'content-type: application/json' \
    -d '{"model":"...","messages":[{"role":"user","content":"coucou"}]}'
{"id":"msg_...","role":"assistant","content":[{"type":"text","text":"Bonjour !"}]}

C'est de l'authentification cassée à l'état pur (OWASP A07) : un endpoint qui coûte de l'argent à chaque appel, exposé au monde entier, sans porte. N'importe qui trouvant l'URL consomme le quota (et la facture) du propriétaire. Là encore, l'IA avait bien construit le relais, la fonctionnalité demandée. Personne ne lui avait demandé le videur, alors il n'y en a pas.

Pourquoi l'IA code toujours comme ça

Le fil rouge des deux cas est le même, et il n'a rien d'un accident :

  • L'IA optimise ce qui est prouvable au clic. « Fais un classement » produit un classement qui s'affiche. Que n'importe qui puisse le réécrire par l'API ne se voit pas dans le navigateur : rien, dans le prompt ni dans le rendu, ne pousse le modèle à s'en soucier.
  • Le défaut non sécurisé passe inaperçu. Activer la RLS, écrire des policies, vérifier un rôle dans une fonction : ce sont des étapes invisibles, souvent désactivées par défaut. L'IA suit le chemin qui marche le plus vite, et le chemin par défaut mène à une base ouverte.
  • L'autorisation ne s'apprend pas dans les tutoriels. Les millions d'exemples d'entraînement montrent « comment afficher des données », rarement « comment décider qui a le droit de les modifier ». Le modèle reproduit ce qu'il a le plus vu.
  • Le vibe coding retire le seul garde-fou : la relecture. Quand on ne lit pas le code généré (c'est tout le principe), personne ne remarque la policy manquante. La faille naît silencieuse et part en prod telle quelle.

La morale tient en une phrase : l'IA sécurise ce qui se voit, et laisse béant ce qui compte. Le front, visible et testable, est propre. L'autorisation, les secrets, la logique d'accès, tout l'invisible, reste ouvert. Ce n'est pas une question de modèle plus ou moins bon : c'est la structure même de la démarche qui produit ce biais.

Ce qu'un audit aurait vu en une passe

Aucune de ces failles n'est subtile. Un test d'accès basique les lève toutes : rejouer les appels d'API sans session, tenter une écriture sur la ligne d'un autre utilisateur, appeler une fonction admin_* en anonyme. C'est le pain quotidien d'un contrôle d'accès sérieux, et c'est précisément le genre de vérification qu'on automatise mal quand on se fie au seul rendu. Sur la frontière entre ce qu'une machine détecte et ce qu'un humain doit trancher, voyez pentest automatisé vs pentest humain, et pour la manière dont l'IA change le tri du bruit en analyse de code, le comparatif SAST, DAST, IAST à l'heure de l'IA.

Le vibe coding n'est pas le problème ; l'absence de relecture de sécurité, si. Générer vite est une force, à condition qu'une passe de contrôle systématique cherche la policy manquante, la fonction sans rôle, la clé qui autorise trop. C'est exactement ce que fait notre plateforme AppSec pilotée par IA : repérer, dans le code généré, l'endroit précis où l'IA a oublié le videur.

À retenir

  • Une app générée par IA a souvent un front solide (React échappe le XSS) et un backend grand ouvert : ne jugez jamais sa sécurité au navigateur.
  • La clé publique d'un backend-as-a-service n'est pas un secret ; ce qui protège vos données, c'est la Row Level Security et ses policies, trop souvent désactivées par défaut.
  • Une fonction nommée admin_* mais exécutable par un anonyme n'a de contrôle d'accès que le nom : le broken access control reste la faille n°1.
  • Un relais d'API sans authentification, c'est votre facture ouverte au monde : l'authentification cassée guette tout service assemblé à la va-vite.
  • L'IA optimise le visible ; l'autorisation est invisible. Sans relecture de sécurité, le vibe coding envoie ce biais directement en production.

Vous avez du code généré par IA quelque part en production ? Il y a de bonnes chances que le videur manque à un endroit précis. On sait où regarder : parlez-en avec un humain via la page contact, ou découvrez notre approche de l'AppSec native IA.

Articles liés