احصل على العلم في WordCamp Europe 2022

نشرت: 2022-06-13

خلال WordCamp Europe 2022 ، أجرينا مسابقة WordPress Capture The Flag (CTF) عبر أربعة تحديات.

أردنا تعريف الناس بعالم الإدمان الخاص بـ CTF ، والسماح للناس بتجربة كيفية تعامل الباحثين الأمنيين مع البحث عن الأخطاء ، مثل البحث عن الشذوذ في الكود والجمع بينهم للقيام بأشياء غريبة ، وأحيانًا غير بديهية.

التحدي الأول - هل أنت محظوظ؟

التحدي رقم 2 - تجاوز قائمة الحظر؟

التحدي # 3 - رخصة للاستيلاء على العلم

التحدي # 4 - ترخيص CTF: الجزء 2

إذا كنت مهتمًا بتجربته ، فلا يزال بإمكانك الحصول على ملفات التحدي هنا:

hackismet-docker.zip تحميل

تحدي # 1 - هل أنت محظوظ؟ (250 نقطة)

مقتطفات التعليمات البرمجية ذات الصلة

 register_rest_route( 'hackismet', '/am-i-lucky', [
     'methods' => WP_Rest_Server::READABLE,
     'callback' => 'hackismet_am_i_lucky',
     'permission_callback' => '__return_true',
 ]);

 function hackismet_am_i_lucky( $request ) {
     $flag = get_option( 'secret_hackismet_flag_2' );
     if( hash_equals( crypt( $request['payload'] . $flag . random_bytes(32), '$1$sup3r_s3kr3t_s4lt' ), $request['hash'] ) ) {
        return rest_ensure_response( $flag );
     }
     return rest_ensure_response(false);
 }

كيف يمكن حلها؟

قدم هذا التحدي نقطة نهاية REST API التي يمكن الوصول إليها عبر المسار /wp-json/hackismet/am-i-lucky . تم تصميمه لتلقي معلمة طلب الحمولة والطلب المتسلسل request['payload'] إلى العلامة وسلسلة من 32 بايت عشوائي آمن مشفرًا ، ومقارنة التجزئة الناتجة request['hash'] .

عند قراءة وثائق الدالة crypt () ، يمكن للمرء أن يجد أن هذه الوظيفة ليست آمنة ثنائية (حتى الآن!) ، مما يعني أنه يمكن استخدام بايت فارغ (٪ 00) لاقتطاع السلسلة المراد تجزئتها قبل العلامة مباشرة و 32 بايت عشوائي. هذا لأن التنفيذ الحالي لهذه الوظيفة في PHP هو في الأساس مجرد اسم مستعار لوظيفة C الأساسية التي تحمل نفس الاسم ، وتنتهي سلاسل C ببايت فارغ.

للحصول على علامتك ، كل ما عليك فعله هو حساب تجزئة بالرسالة التي تتحكم فيها وملح التشفير المستخدم في رمز المكون الإضافي ، واستخدام التجزئة الناتجة في معلمة "التجزئة" ، ووضع رسالتك في "الحمولة" المعلمة ، متسلسلة مع بايت فارغ (٪ 00).

إليك ما بدا عليه استغلال ناجح:

/wp-json/hackismet/am-i-lucky?payload=lel%00&hash=$1$sup3r_s3$sThhFzCqsprSVMNFOAm5Q/

التحدي رقم 2 - تجاوز قائمة الحظر؟ (250 نقطة)

مقتطفات التعليمات البرمجية ذات الصلة

 register_rest_route( 'hackismet', '/get-option/(?P<option_key>\w+)', [
     'methods' => WP_Rest_Server::READABLE,
     'callback' => 'hackismet_get_option',
     'permission_callback' => 'hackismet_validate_option',
 ]);

 function hackismet_validate_option( $request ) {
     $option_key = trim( strtolower( $request['option_key'] ) );
     if( empty( $option_key ) ) {
        return false;
     }
 
     if( ! preg_match( '/^hackismet_/i', $option_key) ) {
        return false;
     }
 
     if( $option_key == 'hackismet_flag_1' ) {
        return false;
     }
     return true;
 }

 function hackismet_get_option( $request ) {
    $option_key = trim( strtolower( $request['option_key'] ) );
    return rest_ensure_response( get_option( $option_key ) );
 }

كيف يمكن حلها؟

قدم هذا التحدي نقطة نهاية REST API التي يمكن الوصول إليها عبر /wp-json/hackismet/get-option/option_key_you_want .

كان الهدف بسيطًا جدًا: حاول تسريب خيار "hackismet_flag_1".

لسوء الحظ ، فإن رد اتصال الإذن لنقطة النهاية هذه قام أيضًا ببعض الأشياء لمنعك من الحصول على أي خيارات على الموقع:

  • تم التحقق من أن مفتاح الخيار بدأ بـ "hackismet_".
  • تضمن أيضًا أن أي خيار كنت تنوي استرداده لم يكن hackismet_flag_1 ، حيث تم وضع العلم.
  • لجعل الأمور تبدو أكثر صعوبة ، حدد مسار API الأحرف التي يمكن أن تجعلها في معلمة مسار option_key ، مما يسمح فقط بالسلاسل التي تطابق \w+ regex.

تستخدم وظيفة رد الاتصال "hackismet_validate_option" أيضًا وظيفتي "strtolower" و "trim" في محاولة لتطبيع المعلمة "option_key". كان هذا لإحباط محاولات استخدام سلوكيات موثقة جيدًا من ترتيب "utf8mb4_unicode_ci" في MySQL ، مثل حقيقة أن مقارنات السلسلة ليست حساسة لحالة الأحرف ، وأنها لا تهتم بالمسافات اللاحقة في أعمدة VARCHAR أيضًا.

حيل التجميع الأخرى

لحل هذا التحدي ، كان على المرء أن يجد خصائص أخرى في الطريقة التي تقوم بها "utf8mb4_unicode_ci" بإجراء عمليات بحث سلسلة لتجاوز عمليات التحقق في مكانها ، وكانت هناك طريقتان على الأقل للقيام بذلك.

حساسية اللكنة

كما هو مذكور في وثائق MySQL الرسمية:

بالنسبة لأسماء الترتيب غير الثنائية التي لا تحدد حساسية التمييز ، يتم تحديدها من خلال حساسية حالة الأحرف.

ضع باختصار: حساسية اللكنة شيء. يستخدم الترتيب الافتراضي لـ WordPress المكون "_ci" (لـ "غير حساس لحالة الأحرف") ، مما يعني أن الترتيب غير حساس أيضًا .

وبالتالي ، فإن اجتياز "hackismet_flag_1" من شأنه تجاوز عمليات التحقق في hackismet_validate_option .

الأوزان الجاهلة

تصف خوارزمية Unicode Collation ، التي يتم استخدامها من خلال تجميع utf8mb4_unicode_ci الخاص بـ MySQL لمقارنة وفرز سلاسل Unicode ، مفهوم "الأوزان القابلة للتجاهل" على النحو التالي:


يتم تمرير الأوزان غير الصالحة من خلال القواعد التي تنشئ مفاتيح الفرز من تسلسل عناصر الترتيب. وبالتالي ، فإن وجودهم في عناصر الترتيب لا يؤثر على مقارنة السلاسل باستخدام مفاتيح الفرز الناتجة . يعتبر التخصيص الحكيم للأوزان الجاهلة في عناصر التجميع مفهومًا مهمًا لـ UCA.

باختصار ، تحسب الخوارزمية وزنًا لكل عنصر ترتيب (أحرف) ، ويتم تعريف بعضها على أنها ذات وزن افتراضي يساوي صفرًا ، مما يجعل الخوارزمية تتجاهلها بشكل فعال عند إجراء مقارنات بين السلاسل.

كانت هناك طرق متعددة لاستخدام هذا السلوك للتغلب على التحدي ، بما في ذلك:

  • إضافة وحدات بايت فارغة في مكان ما داخل السلسلة (على سبيل المثال hackismet_fl%00ag_1 )
  • إدخال تسلسلات UTF-8 غير صالحة داخل السلسلة (على سبيل المثال hackismet_fl%c2%80ag_1 )

يمكنك العثور على الكثير من التركيبات الأخرى في تطبيق MySQL لـ UCA.

تجاوز "option_key" تقييد حرف المعامل

تم تعريف متغير المسار "option_key" لعدم السماح لأي شيء آخر غير \ w + بالمرور. كانت تلك مشكلة. تتعامل PHP مع كل سلسلة على أنها سلسلة من البايتات بدلاً من أحرف unicode مثل MySQL ، لذا فإن إرسال طلب إلى "/ wp-json / hackismet / get-option / hackismet_flag_1" أو "/ wp-json / hackismet / get-option / hackismet_fla ٪ 00g_1 ”لن يعمل.

لتجاوز ذلك ، ساعدت الوثائق الرسمية لـ WordPress حول كتابة نقاط نهاية REST API قليلاً ، وتحديداً السطر الذي يقول:

بشكل افتراضي ، تتلقى المسارات جميع الوسائط التي تم تمريرها من الطلب. يتم دمجها في مجموعة واحدة من المعلمات ، ثم إضافتها إلى كائن الطلب ، والذي يتم تمريره كمعامل أول إلى نقطة النهاية الخاصة بك

ما يعنيه ذلك عمليًا هو أنه عند زيارة /wp-json/hackismet/get-option/test?option_key=hackismet_fla%00g_1 ، فإن معلمة option_key ستحتوي على “hackismet_fla٪ 00g_1” ، وليس “test” ، والذي من شأنه أيضًا أن يفرض البرنامج المساعد لإعطائك العلم.

التحدي # 3 - رخصة الاستيلاء على العلم (500 نقطة)

مقتطفات التعليمات البرمجية ذات الصلة

 register_rest_route( 'hackismet', '/generate-license/(?P<session_id>[0-9a-f\-]+)/(?P<rounds>\d+)', [
     'methods' => WP_Rest_Server::READABLE,
     'callback' => 'hackismet_generate_license',
     'permission_callback' => '__return_true',
     'args' => [
         'session_id' => [
            'required' => true,
            'type' => 'string',
            'validate_callback' => 'wp_is_uuid'
         ]
     ]
 ]);

 register_rest_route( 'hackismet', '/access-flag-3/(?P<session_id>[0-9a-f\-]+)/(?P<rounds>\d+)', [
     'methods' => WP_Rest_Server::READABLE,
     'callback' => 'hackismet_access_flag_3',
     'permission_callback' => 'hackismet_validate_license',
     'args' => [
         'session_id' => [
            'required' => true,
            'type' => 'string',
            'validate_callback' => 'wp_is_uuid'
         ]
     ]
 ]);

 register_rest_route( 'hackismet', '/delete-license/(?P<session_id>[0-9a-f\-]+)', [
     'methods' => WP_Rest_Server::READABLE,
     'callback' => 'hackismet_delete_license',
     'permission_callback' => '__return_true',
     'args' => [
         'session_id' => [
            'required' => true,
            'type' => 'string',
            'validate_callback' => 'wp_is_uuid'
         ]
     ]
 ]);

 function hackismet_generate_license( $request ) {
     // 128 bits of entropy should be enough to prevent bruteforce.
     $license_key = bin2hex( random_bytes(40) );
     // Here for added security
     for($i = $request['rounds']; $i > 0; $i--) {
         $license_key = str_rot13($license_key);
     }
     // Reset it.
     update_option( 'secret_hackismet_license_key_' . $request['session_id'], bin2hex( random_bytes( 64 ) ) );
     return rest_ensure_response('License successfully generated!');
 }

 function hackismet_delete_license( $request ) {
     // Remove existing key.
     delete_option('secret_hackismet_license_key_' . $request['session_id']);
     return rest_ensure_response('License successfully deleted!');
 }

 function hackismet_validate_license( $request ) {
    // Ensure a key has been set
    if( ! get_option( 'secret_hackismet_license_key_' . $request['session_id'] ) ) {
        return new WP_Error('no_license', 'No license exists for this session_id!');
    }
    $license_key = $request['key'];
     // Here for added security
     for($i = $request['rounds']; $i > 0; $i--) {
         $license_key = str_rot13($license_key);
     }
    if( $license_key == get_option( 'secret_hackismet_license_key_' . $request['session_id'] ) ) {
        return true;
    }
    return false;
 }

 function hackismet_access_flag_3( $request ) {
     return rest_ensure_response( get_option( 'secret_hackismet_flag_3' ) );
 }

كيف يمكن حلها؟

كانت الفكرة من وراء هذا التحدي هي محاكاة نظام إدارة الترخيص والتحقق منه (للغاية).

في حين أن هذا التحدي كان يهدف إلى السماح للمشاركين باستغلال ضعف حالة العرق الباطنية ، إلا أن الإشراف الدقيق من مصمم التحدي جعله قابلاً للحل باستخدام حل غير مقصود وأقل غرابة.

قدم التحدي ثلاث نقاط نهاية ، على الرغم من أن هناك حاجة إلى نقطتين فقط للحصول على العلم:

  • / hackismet / create-License / (؟ P <session_id> [0-9a-f \ -] +) / (؟ <rounds> \ d +)
  • / hackismet / access-flag-3 / (؟ P <session_id> [0-9a-f \ -] +) / (؟ <rounds> \ d +)
  • / hackismet / delete-license / (؟ P <session_id> [0-9a-f \ -] +)

ملأت نقطة نهاية generate-license مفتاح ترخيص خاص بالجلسة ، والذي سيتم التحقق من صحته باستخدام رد اتصال إذن hackismet_validate_license الخاص بنقطة النهاية الخاصة بوصول access-flag-3 . لسوء الحظ ، نظرًا لأنك لم تعرف مطلقًا ما هو مفتاح الترخيص الذي تم إنشاؤه فعليًا ، كان عليك إيجاد طريقة لتجاوز فحص الترخيص تمامًا من أجل الحصول على العلم.

    $license_key = $request['key'];
     // Here for added security
     for($i = $request['rounds']; $i > 0; $i--) {
         $license_key = str_rot13($license_key);
     }
    if( $license_key == get_option( 'secret_hackismet_license_key_' . $request['session_id'] ) ) {
        return true;
    }

إحدى طرق القيام بذلك كانت أن يحتوي $request['key'] على قيمة منطقية لـ "true" ، $request['rounds'] بقيمة صفر. من خلال القيام بذلك ، تأكدت من أن $request['key'] لم يتم تعديله من خلال استدعاءات متعددة إلى str_rot13 ، وبما أن التحقق من صحة الترخيص يتم باستخدام عامل المقارنة غير المحكم في PHP ، فإن المقارنة ستعود دائمًا إلى القيمة الصحيحة.

ومع ذلك ، لا يمكنك فعل ذلك باستخدام معلمات GET أو POST العادية ، حيث إنها تحتوي فقط على سلاسل أو مصفوفات. لحسن الحظ ، تسمح لك واجهة برمجة تطبيقات WordPress REST بإرسال نص طلب JSON ، حتى على نقاط النهاية التي تم تسجيلها فقط لاستخدام طريقة GET HTTP. نتيجةً لذلك ، فإن إرسال الطلبات التالية يمنحك علامة التحدي:

curl –url 'https://ctfsite.com/wp-json/generate-license/$your_session_id/1234'
curl –url 'https://ctfsite.com/wp-json/access-flag-3/$your_session_id/0' -X GET –data '{"key":true}' -H 'Content-Type: application/json'

التحدي # 4 - ترخيص CTF: الجزء 2 (500 نقطة)

مقتطفات التعليمات البرمجية ذات الصلة

register_rest_route( 'hackismet', '/access-flag-4/(?P<session_id>[0-9a-f\-]+)/(?P<rounds>\d+)', [
    'methods' => WP_Rest_Server::READABLE,
    'callback' => 'hackismet_access_flag_4',
    'permission_callback' => 'hackismet_validate_license',
    'args' => [
        'session_id' => [
           'required' => true,
           'type' => 'string',
           'validate_callback' => 'wp_is_uuid'
        ],
        'key' => [
            'required' => true,
            'type' => 'string'
        ]
    ]
]);

 function hackismet_access_flag_4( $request ) {
     return rest_ensure_response( get_option( 'secret_hackismet_flag_4' ) );
 }

// (... and basically every other code snippets from Challenge #3! )

كيف يمكن حلها؟

قدم هذا التحدي ثلاث نقاط نهاية (وتطلب بالفعل استخدام الثلاثة لحلها!):

  • / hackismet / create-License / (؟ P <session_id> [0-9a-f \ -] +) / (؟ P <rounds> \ d +)
  • / hackismet / delete-license / (؟ P <session_id> [0-9a-f \ -] +)
  • / hackismet / access-flag-4 / (؟ P <session_id> [0-9a-f \ -] +) / (؟ P <rounds> \ d +)

كما ترى ، هذه هي نفس نقاط النهاية مثل التحدي الأخير ، والفرق الوحيد الآن هو أننا نضمن أن $request['key'] عبارة عن سلسلة لمنع مشكلة type-juggling التي ذكرناها في التحدي الآخر.

قام مسار delete-license الموضح ذاتيًا بما تتوقعه بالضبط: إزالة الترخيص الحالي من قاعدة البيانات. وبالمثل ، فإن access-flag-4 أعاد ببساطة العلم ، بافتراض أن رد نداء الإذن الخاص به hackismet_validate_license سمح بحدوث ذلك.

كما ترون من مقتطف رمز hackismet_validate_license ، استدعاء الإذن يسمى get_option مرتين ، مرة للتحقق من مفتاح الترخيص تم تعيينه والآخر لمقارنته فعليًا بالمفتاح الذي نقدمه. يتم فصل كلا النداءين بواسطة حلقة str_rot13 يتم تشغيلها لعدد من الجولات كما هو محدد في متغير المسار $request['rounds'] .

هذا جعل من الممكن حدوث حالة سباق عن طريق إرسال عدد كبير في متغير الجولات لتأخير الطلب لفترة كافية حتى نصل إلى نقطة نهاية /hackismet/delete-license ، وحذف الترخيص بشكل فعال قبل مقارنته مع ترخيصنا.

إن حقيقة أن get_option() تتخلف عن إرجاع قيمة منطقية خطأ إذا لم تجد خيارًا معينًا هي الكرز الموجود على الكعكة. نظرًا لأن الوظيفة لا تتحقق أبدًا مما إذا كان $request['key'] فارغًا أم لا ، و false == "" عند المقارنة بين الأنواع المختلفة في PHP ، فإن هذا سيسمح لنا بتجاوز فحوصات الأمان تمامًا.

لكن هذا من الناحية النظرية فقط!

التخزين المؤقت لانقاذ!

كما يتضح من الكود المصدري للوظيفة ، get_option يخزن نتيجة أي خيار يتم استرداده مؤقتًا ، لذا فإن أي طلب إضافي لهذا الخيار في نفس طلب HTTP لن يرسل استعلامات SQL إضافية منفصلة. هذا وحده يمنع هجوم حالة العرق لدينا من العمل. حتى إذا قام طلب آخر بحذف خيار الترخيص أثناء قيامنا بتكرار جميع مكالمات str_rot13 ، فلن يعرف get_option بسبب تخزين النتيجة مؤقتًا لهذا الطلب بالفعل!

مرة أخرى ، بالنظر إلى الكود المصدري ، يبدو أن الطريقة الوحيدة لمنع حدوث ذلك هي إذا كان wp_installing يعود ... صحيحًا؟ كما اتضح ، يمكننا أن نجعلها تفعل ذلك.

هل تم تثبيت WordPress بعد؟

تعتمد وظيفة wp_installing على ثابت WP_INSTALLING لتحديد ما إذا كان WordPress يقوم حاليًا بتثبيت أو تحديث نفسه. يؤدي البحث عن الأماكن التي يتم فيها تعريف هذا الثابت إلى نتائج قليلة جدًا ، وأكثرها إثارة للاهتمام في حالتنا هو wp -active.php:

<?php
/**
 * Confirms that the activation key that is sent in an email after a user signs
 * up for a new site matches the key for that user and then displays confirmation.
 *
 * @package WordPress
 */
 
define( 'WP_INSTALLING', true );
 
/** Sets up the WordPress Environment. */
require __DIR__ . '/wp-load.php';
 
require __DIR__ . '/wp-blog-header.php';
 
if ( ! is_multisite() ) {
    wp_redirect( wp_registration_url() );
    die();
}

ما يجعله مناسبًا بشكل خاص لغرضنا هنا ، هو أن أحد الأشياء الأولى التي يقوم بها هو تشغيل require() على wp-blog-header.php.

قصة قصيرة طويلة: الكود الذي يطلق بالفعل خادم REST API parse_request ، لذلك سيكون متاحًا فقط عندما يقوم WordPress داخليًا بإعداد متغيرات الاستعلام اللازمة لـ The Loop للقيام بعملها.

يحدث هذا فقط إذا تم استدعاء وظيفة wp () كما هو الحال في wp-blog-header.php.

نظرًا لأن WordPress يستخدم المعامل rest_route داخليًا لمعرفة المسار المطلوب تحميله ، فإن إضافة هذه المعلمة إلى عنوان URL هي كل ما يتطلبه الأمر لتشغيل واجهة برمجة التطبيقات أثناء زيارة /wp-activate.php.

على هذا النحو ، بدا الهجوم الأخير كالتالي:

  1. أرسل طلبًا إلى /wp-activate.php?rest_route=/hackismet/access-flag-4/$session_id/$rounds حيث يكون $rounds عددًا كبيرًا جدًا لجعل هذا الطلب يعمل لفترة كافية للسماح لك بتنفيذ الخطوة رقم 2.
  2. أرسل طلبًا إلى /wp-json/hackismet/delete-license/$session_id أثناء حظر طلبك الأول في حلقة str_rot13 .
  3. انتظر حتى ينتهي طلبك الأول ، واحصل على علمك.

استنتاج

نأمل أن تكون قد استمتعت كثيرًا بالمشاركة في هذه النسخة الأولى من مسابقة Jetpack Capture The Flag كما كنا نديرها. نحن نتطلع إلى القيام بذلك مرة أخرى في وقت ما في المستقبل. لمعرفة المزيد حول CTF ، راجع CTF101.org

الاعتمادات

مصمم التحدي: مارك مونباس

شكر خاص لـ Harald Eilertsen لنشر الكلمة شخصيًا في WordCamp Europe وفريق Jetpack Scan للحصول على تعليقات ومساعدة وتصحيحات.