The mission webpage hackers

The Mission - Nahamcon CTF 2025

Publié le 31/05/2025, 16:30:00

The Mission

Solved by Starry-lord

Pour la version anglaise de cet article: The White Circle | Open Cyber Security Community

The mission description

Ce défi en 6 parties, réalisé par Adam Langley de HackingHub, visait à identifier le dernier rapport de bug de Stök comme un doublon, afin que le nôtre soit validé et accepté. Il occupe une place particulière dans mon cœur, car c'était la première fois que je remportais un « first blood » dans une compétition de hacking! 🩸 Comme je voulais que quelques lecteurs moins techniques puissent se faire une idée, je vais essayer d'être aussi démonstratif que possible.

Flag 1

Comme souvent, nous découvrons une page web présentant quelques fonctionnalités. L'objectif est de les tester toutes et de déduire leur implémentation, afin d'accéder à des éléments inaccessibles ou de les pirater. Nous obtenons également un ensemble d'identifiants et une liste de mots que nous pouvons utiliser pour éviter un nombre important de requêtes lors de l'utilisation de "listes de mots" publiques avec nos outils de fuzzing. Voici le site web :

The mission webpage

Il s'agit d'une plateforme de bug bounty avec un classement et une page de connexion. La page des meilleurs hackers contient de précieuses informations, car elle nous indique les utilisateurs potentiels que nous pourrions avoir dans cette base de données.

The mission webpage hackers

Avant de me connecter, j'aime bien vérifier si le site Web a quelque chose à cacher aux robots d'exploration Web, entre autres choses, et c'est là que nous trouvons notre premier Flag :

The mission robots.txt

Flag 2

Ici, le site web souhaite masquer la page /internal-dash aux robots d'exploration, ce qui est une bonne raison pour que je m'y intéresse. Il s'agit d'un portail d'administration qui attend une combinaison classique de nom d'utilisateur et de mot de passe que nous n'avons pas, dans une requête POST.

The mission Internal Dash

Il est maintenant temps d'accéder à /login pour nous connecter au site web avec les identifiants fournis : hacker:password123 (dans le monde réel, il y aurait probablement une page /register quelque part). On y trouve un tableau de bord, avec une page « Paramètres » et « Déconnexion ». J'ai alors opté pour BurpSuite, un outil de test d'intrusion courant permettant de capturer l'intégralité du trafic web provenant du site, ce qui a révélé quelques éléments intéressants.

The mission login-api endpoint

Dès que nous nous connectons au tableau de bord, un appel à /api/v2/reports est lancé avec un paramètre user_id, qui renvoie la liste des rapports de notre utilisateur à son dashboard. Cela pourrait être intéressant pour interroger d'autres rapports d'autres utilisateurs si nous obtenons d'autres user_ids plus tard. La page des paramètres est également très intéressante, car elle révèle un point de terminaison graphql, une autre opportunité potentielle d'interroger des éléments auquels on ne devrait pas avoir accès. Plus d'informations à ce sujet dans Flag 4.

The mission dashboard settings

Nous pouvons maintenant commencer à vérifier si nous pouvons identifier davantage de pages ou dossiers sur le site avec la liste de mots fournie:

Fuzzing-dirs

On retrouve toutes les pages que nous avons déjà vues jusqu'à présent, les dossiers uploads et assets renvoyant une réponse 403 « Interdit ». Nous avons également reçu un résultat positif pour une version 1 de l'API, qui n'était plus censée être utilisée.

The mission api-v1

« Veuillez ne pas l'utiliser » sonne à mes oreilles comme une invitation. C'était l'occasion idéale d'essayer d'interroger des points de terminaison intéressants pour des applications web courantes basées sur un openjdk, comme ceux-ci : /jmx-console (JMX) /manager/html (Tomcat Manager) /actuator (Points de terminaison Spring Boot Actuator) /console (H2, Derby ou autres bases de données embarquées)

J'ai eu de la chance avec /actuator, ce qui m'a confirmé que nous sommes sur le framework Java Spring Boot.

the mission actuator WAF

Contourner le Web Application Firewall n'a pas été la tâche la plus difficile, car un simple encodage URL l'a trompé et a révélé le drapeau 2, que j'ai été le premier à trouver dans la compétition !

The mission flag 2 actuator

Flag 3

Nous pouvons voir une référence à /api/v1/actuator/heapdump directement dans /actuator. Ce fichier est un vidage mémoire contenant une requête POST effectuée par l'utilisateur inti, avec un en-tête JSON Web Token Bearer, permettant d'interroger un point de terminaison /api/v1/internal-dashboard/token.

contenu du fichier /api/v1/actuator/heapdump :

0x00007f9b3c1a2e80: 6a 61 76 61 2e 6c 61 6e 67 2e 53 74 72 69 6e 67  java.lang.String
0x00007f9b3c1a2e90: 40 35 63 39 65 66 61 34 0a 5b 52 75 6e 74 69 6d  @5c9efa4.[Runtime
0x00007f9b3c1a2ea0: 43 6c 61 73 73 65 73 5d 20 64 65 62 75 67 20 6d  Classes] debug m
0x00007f9b3c1a2eb0: 6f 64 65 20 65 6e 61 62 6c 65 64 0a 00 70 72 6f  ode enabled..pro
0x00007f9b3c1a2ec0: 63 65 73 73 4d 61 6e 61 67 65 72 3a 20 6a 61 76  cessManager: jav
0x00007f9b3c1a2ed0: 61 2e 75 74 69 6c 2e 63 6f 6e 63 75 72 72 65 6e  a.util.concurrent
0x00007f9b3c1a2ee0: 74 2e 46 6f 72 6b 4a 6f 69 6e 50 6f 6f 6c 24 57  t.ForkJoinPool$W
0x00007f9b3c1a2ef0: 6f 72 6b 65 72 40 31 61 66 66 38 66 66 0a 0a 70  orker@1aff8ff..p
0x00007f9b3c1a2f00: 70 6f 73 74 20 2f 61 70 69 2f 76 31 2f 69 6e 74  post /api/v1/int
0x00007f9b3c1a2f10: 65 72 6e 61 6c 2d 64 61 73 68 62 6f 61 72 64 2f  ernal-dashboard/
0x00007f9b3c1a2f20: 74 6f 6b 65 6e 20 48 54 54 50 2f 31 2e 31 0d 0a  token HTTP/1.1..
0x00007f9b3c1a2f30: 41 75 74 68 6f 72 69 7a 61 74 69 6f 6e 3a 20 42  Authorization: B
0x00007f9b3c1a2f40: 65 61 72 65 72 20 65 79 4a 68 62 47 63 69 4f 69  earer eyJhbGciOi
0x00007f9b3c1a2f50: 4A 49 55 7A 49 31 4E 69 49 73 49 6E 52 35 63 43  JIUzI1NiIsInR5cC
0x00007f9b3c1a2f60: 49 36 49 6B 70 58 56 43 4A 39 2E 65 79 4A 31 63  I6IkpXVCJ9.eyJ1c
0x00007f9b3c1a2f70: 32 56 79 62 6D 46 74 5A 53 49 36 49 6D 6C 75 64  2VybmFtZSI6Imlud
0x00007f9b3c1a2f80: 47 6B 69 66 51 2E 59 65 71 76 66 51 37 4C 32 35  GkifQ.YeqvfQ7L25
0x00007f9b3c1a2f90: 6F 68 68 77 42 45 35 54 70 6D 71 6F 32 5F 35 4D  ohhwBE5Tpmqo2_5M
0x00007f9b3c1a2fa0: 68 71 79 4F 43 58 45 37 54 39 62 47 38 39 35 55  hqyOCXE7T9bG895U
0x00007f9b3c1a2fb0: 6B 0d 0a 48 6F 73 74 3A 20 69 6E 74 65 72 6E 61  k..Host: interna
0x00007f9b3c1a2fc0: 6C 2D 74 65 73 74 69 6E 67 2D 61 70 70 73 0d 0a  l-testing-apps..
0x00007f9b3c1a2fd0: 43 6f 6e 74 65 6e 74 2d 54 79 70 65 3a 20 61 70  Content-Type: ap
0x00007f9b3c1a2fe0: 70 6c 69 63 61 74 69 6f 6e 2f 6a 73 6f 6e 0d 0a  plication/json..
0x00007f9b3c1a2ff0: 43 6f 6e 6e 65 63 74 69 6f 6e 3a 20 6b 65 65 70  Connection: keep
0x00007f9b3c1a3000: 2d 61 6c 69 76 65 0d 0a 0d 0a 7b 22 75 73 65 72  -alive....{"user
0x00007f9b3c1a3010: 6e 61 6d 65 22 3a 22 69 6e 74 69 22 7d 00 6a 61  name":"inti"}..ja
0x00007f9b3c1a3020: 76 61 2e 6e 65 74 2e 55 52 4c 43 6c 61 73 73 2e  va.net.URLClass.
0x00007f9b3c1a3030: 63 6f 6d 2f 65 78 61 6d 70 6c 65 2f 41 70 70 6c  com/example/Appl

En reproduisant cette demande pour moi-même, nous avons réussi à obtenir un token de l'API !

The mission api token

C'était clairement la voie à suivre ; il ne restait plus qu'à comprendre comment utiliser ce jeton. Il s'agissait clairement d'un moyen de contourner la page de connexion /internal-dash que nous avions trouvée au début, mais la manière exacte d'y parvenir a été la partie la plus difficile pour moi. Ce jeton aurait pu être deux choses différentes :

  1. Un secret de jeton web JSON permettant de créer un jeton JWT pour d'autres utilisateurs et de le transmettre comme en-tête (mais rien de ce que j'ai fait n'a fonctionné) ;
  2. Une valeur de cookie pour l'authentification dans /internal-dash.

C'est alors que thewhiteh4t a eu la brillante idée d'interroger la page /internal-dash/logout, qui nous a révélé le nom du cookie :

The mission int-token

Grâce à cela, nous avons pu contourner la connexion au tableau de bord interne sans publier ni deviner les informations d'identification ! La transmission du jeton récupéré sous forme de cookie, directement dans la requête de connexion nous a donné le Flag 3 int-token=<token>.

The mission flag 3 guess cookie name

Flag 4

Nous pouvons désormais rechercher des rapports par leur ID. Nous avions trouvé notre ID de rapport dans notre tableau de bord en utilisant les identifiants fournis, mais en essayant, nous avons découvert que nous n'avions pas l'autorisation d'utiliser cette fonctionnalité.

The mission lookup permission issue

Nous y reviendrons plus tard dans le Flag 5, afin de conserver l'ordre des indicateurs. Vous souvenez-vous de ce point de terminaison graphql que nous avons vu en visitant la page Paramètres depuis le tableau de bord utilisateur ? C'est le moment idéal pour l'examiner.

The mission graphql

Nous avons même la requête d'introspection directement dans le backend, ce qui simplifie encore davantage la détermination des fonctions utilisables avec ce serveur Apollo Graphql.

La page présente quelques requêtes différentes que nous pourrions exécuter sans authentification :

The mission graphql2

La première requête nécessitait de passer une variable userId, donc passer le nôtre l'a fait fonctionner :

The mission graphql 3

Mais la deuxième requête ne demandait rien, ce qui nous a permis de récupérer la liste complète des identifiants d'utilisateur, ainsi que le Flag 4 :

The mission graphql 4 flag 4

Flag 5

Nous disposions désormais de quelques éléments pour que cela fonctionne, mais il nous manquait toujours l'autorisation d'utiliser la fonction de recherche dans le tableau de bord interne du Flag 3. En jouant avec la requête POST avec notre répéteur BurpSuite, nous avons finalement pu obtenir des messages révélateurs de ce qui se passait en coulisses :

The mission telling error

J'ai ajouté un caractère / avec un caractère aléatoire à cet identifiant de rapport, et j'ai reçu ce message d'erreur concernant un point de terminaison inconnu, ce qui indiquait clairement que cette valeur d'identifiant était traitée comme une URL interne.

L'envoi d'un octet nul codé en URL nous donne une réponse null, indiquant que le backend n'a probablement pas été conçu pour analyser correctement ce message, ce qui constitue un deuxième indicateur que nous étions censés trouver des points de terminaison.

The mission urlencoded

La troisième et dernière confirmation définitive m'a été donnée en injectant un nullbyte unicode \u0000 à l'ID du rapport.

The mission unicode null byte

Cela confirme défintivement que nous travaillons avec cURL en appelant http://127.0.0.1 donc nous pouvons certainement essayer d'autres points de terminaison. Essayer /etc/passwd n'a pas marché, mais essayer l'endoint /api/ a révélé d'autres méthodes internes !

The missions api methods

En interne, il appelait http://127.0.0.1/my-reports/<report-id> donc nous pouvions essayer le point de terminaison de recherche :

The mission api search

l'envoi de q=<report-id> a fonctionné et nous a permis de récupérer le hash de modification permettant de modifier les statuts des rapports, comme illustré dans le script ci-dessous (disponible dans le code HTML de la page internal-dash). Nous avons pu utiliser le point de terminaison /api/v2/reports?user_id=<user_id> pour obtenir l'identifiant du rapport de Stök, publié avant le nôtre. Nous disposions de tous les ID utilisateur du serveur Apollo au point de terminaison graphql, ce qui nous a permis de récupérer les ID des rapports souhaités.

The mission change hash success

Une belle victoire ! Il était temps d'obtenir également le rapport « En attente » de Stök et d'essayer de modifier leurs statuts comme décrit dans le code ci-dessous.

Contenu de la logique du script /internal-dash :

<script>
    // Store the change_hash globally
    let currentChangeHash = null;

    document.getElementById('reportForm').addEventListener('submit', async (e) => {
        e.preventDefault();

        const reportId = document.getElementById('reportId').value;
        const errorMessage = document.getElementById('errorMessage');
        const reportCard = document.getElementById('reportCard');

        // Reset previous states
        errorMessage.classList.remove('visible');
        reportCard.classList.remove('visible');
        currentChangeHash = null; // Reset change_hash

        try {
            const response = await fetch('/internal-dash/api/report', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify({ id: reportId })
            });

            if (response.status === 404) {
                throw new Error('Report not found');
            } else if (response.status === 403) {
                throw new Error('You do not have access to this report');
            } else if (!response.ok) {
                throw new Error('An error occurred while fetching the report');
            }

            const report = await response.json();

            // Store the change_hash
            currentChangeHash = report.change_hash;

            // Update the report card with the data
            document.getElementById('reportIdDisplay').textContent = report.id;
            document.getElementById('reportTitle').textContent = report.title;
            document.getElementById('reportCompany').textContent = report.company;
            document.getElementById('reportStatusDetail').textContent = report.status;
            document.getElementById('reportPaid').textContent =
                new Intl.NumberFormat('en-US', {
                    style: 'currency',
                    currency: 'USD'
                }).format(report.paid);

            // Set status class and select current status
            const statusElement = document.getElementById('reportStatus');
            statusElement.textContent = report.status;
            statusElement.className = 'status ' + report.status.toLowerCase();

            // Set the current status in the dropdown
            document.getElementById('statusSelect').value = report.status;

            // Show the report card
            reportCard.classList.add('visible');

        } catch (error) {
            errorMessage.textContent = error.message;
            errorMessage.classList.add('visible');
        }
    });

    // Handle status updates
    document.getElementById('updateStatus').addEventListener('click', async () => {
        const statusSelect = document.getElementById('statusSelect');
        const newStatus = statusSelect.value;

        if (!newStatus) {
            alert('Please select a status');
            return;
        }

        if (!currentChangeHash) {
            const errorMessage = document.getElementById('errorMessage');
            errorMessage.textContent = 'Invalid authorization. Please reload the report.';
            errorMessage.style.backgroundColor = '#fff5f5';
            errorMessage.style.color = '#c53030';
            errorMessage.classList.add('visible');
            return;
        }

        const reportId = document.getElementById('reportIdDisplay').textContent;

        try {
            const response = await fetch('/internal-dash/api/report/status', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify({
                    id: reportId,
                    status: newStatus,
                    change_hash: currentChangeHash
                })
            });

            if (response.status === 403) {
                throw new Error('Unauthorized to update status');
            } else if (!response.ok) {
                throw new Error('Failed to update status');
            }

            // Update the status display
            const statusElement = document.getElementById('reportStatus');
            const statusDetail = document.getElementById('reportStatusDetail');

            statusElement.textContent = newStatus;
            statusElement.className = 'status ' + newStatus.toLowerCase();
            statusDetail.textContent = newStatus;

            // Show success message
            const errorMessage = document.getElementById('errorMessage');
            errorMessage.textContent = 'Status updated successfully';
            errorMessage.style.backgroundColor = '#f0fff4';
            errorMessage.style.color = '#2f855a';
            errorMessage.classList.add('visible');

            // Hide success message after 3 seconds
            setTimeout(() => {
                errorMessage.classList.remove('visible');
            }, 3000);

        } catch (error) {
            const errorMessage = document.getElementById('errorMessage');
            errorMessage.textContent = error.message;
            errorMessage.style.backgroundColor = '#fff5f5';
            errorMessage.style.color = '#c53030';
            errorMessage.classList.add('visible');
        }
    });

Nous pouvons y voir un appel vers un nouveau point de terminaison, plus précisément une requête POST vers /internal-dash/api/report/status avec les variables JSON suivantes :

  1. id

  2. status (statut cible pour la modification du rapport)

  3. change_hash pour le hash de modification du rapport ciblé

    const response = await fetch('/internal-dash/api/report/status', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ id: reportId, status: newStatus, change_hash: currentChangeHash }) });

The mission Stok duplicated

Cet ID est celui du rapport de Stök, car il n'était pas possible de mettre à jour le nôtre directement. Le marquer comme doublon a donc permis de marquer le nôtre comme accepté !

The mission hacker report accepted

De retour sur notre tableau de bord utilisateur, nous avons finalement trouvé le Flag 5 accompagné d'une grosse prime ! 🤑

The mission Accomplished flag 5

Mission accomplie !

Bonus : Flag 6

Un chatbot IA était présent en bas à droite du tableau de bord, avec lequel nous pouvions discuter.

Voici comment cela s'est passé pour moi :

The missions 6

The mission 6 Adam Langley

😎

starlord-profile

Star-Lord - Développeur d'entreprises en lignes


Synthweb.ch - création de site web en Suisse, LinkedIn, Instagram