Ce write-up est écrit spécifiquement pour mes lecteurs non-techniques, en espérant que vous en apprécierez la lecture!
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
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.
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 :
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.
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 :
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.
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.
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
.
Nous pouvons maintenant commencer à vérifier si nous pouvons identifier davantage de pages ou dossiers sur le site avec la liste de mots fournie:
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.
« 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.
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 !
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 !
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 :
/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 :
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>
.
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é.
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.
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 :
La première requête nécessitait de passer une variable userId
, donc passer le nôtre l'a fait fonctionner :
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 :
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 :
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.
La troisième et dernière confirmation définitive m'a été donnée en injectant un nullbyte unicode \u0000
à l'ID du rapport.
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 !
En interne, il appelait http://127.0.0.1/my-reports/<report-id>
donc nous pouvions essayer le point de terminaison de recherche :
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.
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 :
id
status (statut cible pour la modification du rapport)
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 }) });
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é !
De retour sur notre tableau de bord utilisateur, nous avons finalement trouvé le Flag 5 accompagné d'une grosse prime ! 🤑
Mission accomplie !
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 :
😎
Cookies