# Predis: Redis command injection and denial of service via CRLF smuggling in pipelined commands on aggregate connections

Predis re-parses serialized pipelines by splitting on CRLF, so a CRLF-bearing key or value smuggles Redis commands, wiping cluster shards or crashing replication connections.

- Payout: Research
- Severity: critical
- Program: Predis
- Found by: s2x
- Disclosed: 2026-09-08
- CVE: CVE-2026-84372
- Write-up quality: 9/10
- Original: https://github.com/advisories/GHSA-w6f5-v2h6-g786
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-w6f5-v2h6-g786
- Tags: area:web3 domain:oss impact:dos impact:integrity kind:research platform:ghsa sev:critical tech:php tech:redis technique:parser-differential vuln:command-injection vuln:crlf-injection vuln:dos

**Target:** PHP Redis client library (predis/predis) pipeline over cluster/replication connections
**Root cause:** AbstractAggregateConnection::write() re-parses the already-serialized pipeline buffer with explode("\r\n") instead of honoring RESP length prefixes, so attacker-controlled CRLF sequences in a key or value are treated as command boundaries by the client-side parser.
**Steps:**
1. Deploy two redis:8 containers as shards and a PHP app using Predis 3.2.0 with `['cluster' => 'predis']` calling `$client->pipeline()->get("slug:$slug")->execute()`.
2. Seed keys via `/?seed`.
3. Send `curl 'http://127.0.0.1:8080/?slug=PAD4%0D%0A*1%0D%0A%247%0D%0AFLUSHDB'`.
4. Retry with PAD0, PAD1, ... until the slug's first line hashes to a different shard than fake key 'key' (hit probability (N-1)/N).
5. Observe dbsize drop across shards (100 -> 62) as the smuggled FLUSHDB executes on the node serving slot('key').
**How found:** Noticed the aggregate connection re-parses the serialized pipeline buffer with explode("\r\n") rather than RESP length prefixes, creating a parser differential between client and server; traced routing via deserializeCommand() and ClusterStrategy::getFakeKey() to confirm smuggled commands reach a clean node stream.
**Exploitation:** Attacker controls any pipelined argument (e.g. URL slug used as cache key). The value `PAD\r\n*1\r\n$7\r\nFLUSHDB` is one bulk string to the server but a full FLUSHDB command to the re-parser. On cluster connections chunks route to different nodes by slot, so the smuggled command lands on a clean stream and executes; FLUSHDB is routable because ClusterStrategy::getFakeKey() hardcodes 'key'. Same-slot GET/SET/DEL enable key theft, cache poisoning and targeted destruction; INFO leaks config; CLUSTER FLUSHSLOTS can down a node; junk-key floods cause OOM. On replication connections any value with \r\n throws an uncaught UnexpectedValueException, a repeatable unauthenticated DoS.
**Escalation:** Multi-shard clusters raise per-attempt success to (N-1)/N; same-slot commands allow key theft and cache poisoning beyond the initial wipe; key floods exhaust node memory.
**Insight:** When a client library re-parses its own serialized protocol buffer (e.g. splitting on CRLF instead of honoring length prefixes), look for a parser differential: any attacker-controlled field containing the delimiter becomes a command boundary. Check aggregate/sharded connections where chunks route to different sockets, since the split stream lets the injected command execute cleanly on another node.

## Proof of concept (copied exactly from the original report)

**curl request smuggling FLUSHDB via slug**

```
curl 'http://127.0.0.1:8080/?slug=PAD4%0D%0A*1%0D%0A%247%0D%0AFLUSHDB'
```

**Vulnerable PHP app with pipelined GET on slug**

```
<?php
    require __DIR__ . '/vendor/autoload.php';

    $nodes  = ['tcp://127.0.0.1:6391', 'tcp://127.0.0.1:6392'];
    $client = new Predis\Client($nodes, ['cluster' => 'predis',
                'parameters' => ['read_write_timeout' => 2]]);

    if (isset($_GET['seed'])) {
        for ($i = 1; $i <= 100; $i++) { $client->set("user:$i", "data$i"); }
        exit('seeded');
    }

    $slug = $_GET['slug'] ?? '';
    try {
        [$doc] = $client->pipeline()->get("slug:$slug")->execute();
        echo $doc ?: 'no such slug';
    } catch (Throwable $e) {
        http_response_code(500);
        echo get_class($e);
    }
```

