WordCamp Europe 2022'de Bayrağı Yakalayın
Yayınlanan: 2022-06-13WordCamp Europe 2022 sırasında, dört zorlukta bir WordPress Bayrağı Yakala (CTF) yarışması düzenledik.
İnsanlara CTF'nin bağımlılık yaratan dünyasını tanıtmak ve insanların güvenlik araştırmacılarının koddaki tuhaflıkları aramak ve bunları tuhaf, bazen de mantıksız şeyler yapmak için birleştirmek gibi hata avına nasıl yaklaştıklarını deneyimlemelerini sağlamak istedik.
1. Zorluk – Şanslı mısınız?
2. Zorluk – Engellenenler Listesi Baypas Ediliyor mu?
3. Zorluk – Bayrağı Ele Geçirme Lisansı
4. Zorluk – CTF Lisansı: 2. Kısım
Denemekle ilgileniyorsanız, yine de meydan okuma dosyalarını buradan alabilirsiniz:
Meydan okumak #1 – Şanslı mısınız? (250 puan)
İlgili Kod Parçacıkları
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);
}
Nasıl Çözülebilir?
Bu zorluk, /wp-json/hackismet/am-i-lucky
yolu aracılığıyla erişilebilen bir REST API uç noktası sundu. Bir yük ve karma istek parametresi almak, request['payload']
bayrağa ve kriptografik olarak güvenli 32 rastgele baytlık bir dizeyi birleştirmek ve elde edilen hash'i request['hash']
ile karşılaştırmak için tasarlanmıştır.
crypt() işlevinin belgelerini okuduktan sonra, bu işlevin ikili olarak güvenli olmadığı (henüz!) bulunabilir, yani boş bir bayt (%00) karma oluşturulacak dizeyi bayraktan hemen önce kesmek için kullanılabilir ve 32 rastgele bayt. Bunun nedeni, bu işlevin PHP'deki mevcut uygulamasının temelde aynı adı taşıyan temel C işlevinin yalnızca bir takma adı olması ve C dizelerinin boş baytlarla sonlanmasıdır.
Bayrağınızı almak için tek yapmanız gereken, kontrol ettiğiniz mesaj ve eklentinin kodunda kullanılan kriptografik tuz ile bir hash hesaplamak, elde edilen hash'i “hash” parametresinde kullanmak ve mesajınızı “payload”a koymaktı. boş bayt (%00) ile birleştirilmiş parametre.
Başarılı bir istismar şöyle görünüyordu:
/wp-json/hackismet/am-i-lucky?payload=lel%00&hash=$1$sup3r_s3$sThhFzCqsprSVMNFOAm5Q/
2. Zorluk – Engellenenler Listesi Baypas Ediliyor mu? (250 puan)
İlgili Kod Parçacıkları
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 ) );
}
Nasıl Çözülebilir?
Bu zorluk, /wp-json/hackismet/get-option/option_key_you_want
aracılığıyla erişilebilen bir REST API uç noktası sundu.
Amaç oldukça basitti: “hackismet_flag_1” seçeneğini sızdırmaya çalışın.
Ne yazık ki, bu uç nokta için izin geri çağrısı, sitedeki herhangi bir seçeneği kolayca almanızı önlemek için birkaç şey yaptı:
- Seçenek anahtarının “hackismet_” ile başladığını doğruladı.
- Ayrıca, almayı düşündüğünüz seçeneğin bayrağın bulunduğu hackismet_flag_1 olmamasını da sağladı.
- İşlerin daha zor görünmesini sağlamak için API yolu, options_key route parametresinde hangi karakterlerin yapabileceğini sınırladı ve yalnızca
\w+
normal ifadesiyle eşleşen dizelere izin verdi.
"hackismet_validate_option" geri çağırma işlevi, "option_key" parametresini normalleştirme girişiminde "strtolower" ve "trim" işlevlerini de kullandı. Bu, MySQL'in "utf8mb4_unicode_ci" harmanlamasından iyi belgelenmiş davranışları kullanma girişimlerini engellemek içindi, örneğin, dize karşılaştırmalarının büyük/küçük harfe duyarlı olmaması ve VARCHAR sütunlarında boşluk bırakmanın umurunda olmaması gibi.
Diğer Harmanlama Püf Noktaları
Bu zorluğu çözmek için, kontrolleri atlamak için "utf8mb4_unicode_ci"nin dizi aramalarında başka tuhaflıklar bulmak zorundaydı ve bunu yapmanın en az iki yolu vardı.
Vurgu Duyarlılığı
Resmi MySQL belgelerinde belirtildiği gibi:
Aksan duyarlılığı belirtmeyen ikili olmayan harmanlama adları için büyük/küçük harf duyarlılığına göre belirlenir.
Kısaca söylemek gerekirse: vurgu hassasiyeti bir şeydir. WordPress'in varsayılan harmanlaması, "_ci" bileşenini ("Büyük/Küçük Harfe Duyarsız" için) kullanır; bu, harmanlamanın ayrıca Vurguya Duyarsız olduğu anlamına gelir.
Böylece, "hackismet_flag_1" geçmek, hackismet_validate_option
içindeki kontrolleri atlayacaktır.
Yok Sayılan Ağırlıklar
MySQL'in utf8mb4_unicode_ci harmanlaması tarafından Unicode dizelerini karşılaştırmak ve sıralamak için kullanılan Unicode Harmanlama Algoritması, "yoksayılabilir ağırlıklar" kavramını şu şekilde açıklar:
Göz ardı edilebilir ağırlıklar, harmanlama öğeleri dizilerinden sıralama anahtarları oluşturan kurallar tarafından iletilir. Bu nedenle, harmanlama öğelerindeki varlıkları, elde edilen sıralama anahtarlarını kullanarak dizelerin karşılaştırmasını etkilemez . Harmanlama öğelerinde göz ardı edilebilir ağırlıkların makul bir şekilde atanması, UCA için önemli bir kavramdır.
Kısaca, algoritma her harmanlama öğesi (karakterler) için bir ağırlık hesaplar ve bunlardan bazıları varsayılan olarak sıfır ağırlığa sahip olarak tanımlanır, bu da algoritmanın dize karşılaştırmaları yaparken bunları etkin bir şekilde yok saymasını sağlar.
Zorluğu yenmek için bu davranışı kullanmanın (ab) birden çok yolu vardı, bunlar arasında şunlar da vardı:
- Dize içinde bir yere boş bayt ekleme (örn
hackismet_fl%00ag_1
) - Dizenin içine geçersiz UTF-8 dizileri ekleme (ör
hackismet_fl%c2%80ag_1
)
MySQL'in UCA uygulamasında birçok başka kombinasyon bulabilirsiniz.
“option_key” Parametre Karakter Kısıtlamasını Atlama
“option_key” rota değişkeni, \w+ dışında hiçbir şeyin geçmesine izin vermeyecek şekilde tanımlandı. Bu bir sorundu. PHP, MySQL gibi unicode karakterler yerine her dizeyi bir bayt dizisi olarak ele alır, bu nedenle “/wp-json/hackismet/get-option/hackismet_flag_1” veya “/wp-json/hackismet/get-option/hackismet_fla”ya bir istek gönderir. %00g_1” işe yaramaz.
Bunu atlamak için, WordPress'in REST API uç noktaları yazmayla ilgili resmi belgeleri, özellikle şu satırda biraz yardımcı oldu:
Varsayılan olarak, rotalar istekten iletilen tüm bağımsız değişkenleri alır. Bunlar, tek bir parametre kümesinde birleştirilir , ardından uç noktanıza ilk parametre olarak iletilen İstek nesnesine eklenir.
Pratikte bunun anlamı, /wp-json/hackismet/get-option/test?option_key=hackismet_fla%00g_1
, options_key parametresinin "hackismet_fla%00g_1" içermesi ve "test" içermemesidir. size bayrak vermek için eklenti.
3. Zorluk – Bayrağı Ele Geçirme Lisansı (500 puan)
İlgili Kod Parçacıkları
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' ) );
}
Nasıl Çözülebilir?
Bu zorluğun arkasındaki fikir, (çok) bozuk bir lisans yönetimi ve doğrulama sistemini simüle etmekti.
Bu meydan okuma, katılımcıların oldukça ezoterik bir yarış koşulu güvenlik açığından yararlanmalarına izin vermeyi amaçlasa da, meydan okuma tasarımcısının ince bir gözetimi, amaçlanmayan, daha az egzotik bir çözüm kullanılarak çözülebilir hale getirdi.
Bayrağı almak için sadece iki tane gerekli olsa da, meydan okuma üç uç nokta sundu:
- /hackismet/generate-license/(?P<session_id>[0-9a-f\-]+)/(?<yuvarlaklar>\d+)
- /hackismet/access-flag-3/(?P<session_id>[0-9a-f\-]+)/(?<yuvarlaklar>\d+)
- /hackismet/delete-license/(?P<session_id>[0-9a-f\-]+)
generate-license
uç noktası, daha sonra access-flag-3
uç noktasının hackismet_validate_license
izin geri çağrısı kullanılarak doğrulanacak olan, oturuma özel bir lisans anahtarı doldurdu. Ne yazık ki, gerçek üretilen lisans anahtarının ne olduğunu asla göremediğiniz için, bayrağı almak için lisans kontrolünü tamamen atlamanın bir yolunu bulmanız gerekiyordu.
$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;
}
Bunu yapmanın bir yolu, $request['key']
öğesinin "true" boole değerini ve $request['rounds']
değerini sıfır içermesini sağlamaktı. Bunu yaparak, $request['key']
öğesinin str_rot13
yapılan birden fazla çağrıyla değiştirilmemesini sağladınız ve lisans doğrulaması PHP'nin gevşek karşılaştırma operatörü kullanılarak yapıldığından, karşılaştırma her zaman doğru olacaktır.
Ancak bunu normal GET
veya POST
parametreleriyle yapamazsınız, çünkü bunlar yalnızca diziler veya diziler içerir. Neyse ki, WordPress REST API, yalnızca GET HTTP yöntemini kullanmak için kayıtlı olan uç noktalarda bile bir JSON istek gövdesi göndermenize olanak tanır. Sonuç olarak, aşağıdaki istekleri göndermek size meydan okumanın bayrağını verir:
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. Zorluk – CTF Lisansı: 2. Bölüm (500 puan)
İlgili Kod Parçacıkları
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! )
Nasıl Çözülebilir?
Bu zorluk üç uç nokta sundu (ve aslında çözülmesi için üçünün de kullanılması gerekiyordu!):
- /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+)
Gördüğünüz gibi, bunlar son zorlukla aynı uç noktalardır, şimdi tek fark, diğer meydan okumada bahsettiğimiz tür hokkabazlığı sorununu önlemek için $request['key']
öğesinin bir dize olmasını sağlamamızdır.
Kendi kendini açıklayan delete-license
yolu, tam olarak beklediğiniz şeyi yaptı: mevcut lisansı veritabanından kaldırın. Benzer şekilde, access-flag-4
, izin geri çağrısının, hackismet_validate_license
bunun olmasına izin verdiğini varsayarak, bayrağı döndürdü.
hackismet_validate_license
kod parçacığından da görebileceğiniz gibi, get_option
adındaki izin geri çağrısı, bir kez bir lisans anahtarını doğrulamak için, diğeri ise onu gerçekten sağladığımızla karşılaştırmak için ayarlandı. Her iki çağrı da $request['rounds']
rota değişkeninde tanımlandığı kadar çok tur için çalışan bir str_rot13 döngüsü ile ayrılır.
Bu, isteği /hackismet/delete-license
uç noktasına ulaşmamıza yetecek kadar geciktirmek için round değişkeninde büyük bir sayı göndererek bir yarış koşulunun gerçekleşmesini mümkün kıldı ve lisansı bizimkiyle karşılaştırılmadan önce etkin bir şekilde sildi.
get_option()
, belirli bir seçeneği bulamazsa bir boolean false döndürmeye varsayılan olması, pastadaki kirazdır. İşlev asla $request['key']
öğesinin boş olup olmadığını ve false == ““ PHP'de farklı türleri gevşek bir şekilde karşılaştırırken kontrol etmediğinden, bu güvenlik kontrollerini tamamen atlamamıza izin verir.
Ama bu sadece teoride!
Kurtarmak için önbelleğe alma!
İşlevin kaynak kodundan da görülebileceği gibi, get_option
aldığı seçeneğin sonucunu önbelleğe alır, bu nedenle aynı HTTP isteğinde bu seçenek için başka istekler ek ayrı SQL sorguları göndermez. Bu tek başına yarış durumu saldırımızın çalışmasını engeller. Biz tüm bu str_rot13
çağrıları arasında dolaşırken başka bir istek lisans seçeneğini silmiş olsa bile, sonuç o istek için zaten önbelleğe alınmış olduğundan get_option bunu bilemez!
Yine, kaynak koduna bakıldığında, bunun olmasını engellemenin tek yolu wp_installing'in geri dönmesi… doğru mu? Görünüşe göre, bunu yapabiliriz .
WordPress henüz yüklenmedi mi?
wp_installing işlevi, WordPress'in şu anda kurulup kurulmadığını veya kendini güncelleyip güncellemediğini belirlemek için WP_INSTALLING sabitine dayanır. Bu sabitin tanımlandığı yerleri aramak çok az sonuca yol açar, bizim durumumuzda en ilginç olanı wp-activate.php'dir:
<?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();
}
Onu buradaki amacımız için özellikle uygun kılan şey, yaptığı ilk şeylerden birinin wp-blog-header.php üzerinde request require()
çalıştırmasıdır.
Uzun lafın kısası: REST API sunucusunu gerçekten başlatan kod, parse_request
eylemine bağlıdır, bu nedenle yalnızca WordPress, The Loop'un işini yapması için gerekli sorgu değişkenlerini dahili olarak ayarladığında kullanılabilir.
Bu, yalnızca wp() işlevi wp-blog-header.php'de olduğu gibi çağrılırsa gerçekleşir.
WordPress dahili olarak hangi rotanın yükleneceğini bilmek için rest_route parametresini kullandığından, /wp-activate.php adresini ziyaret ederken API'yi başlatmak için bu parametreyi URL'ye eklemek yeterlidir.
Bu nedenle, son saldırı şuna benziyordu:
-
/wp-activate.php?rest_route=/hackismet/access-flag-4/$session_id/$rounds
bir istek gönderin, burada$rounds
oldukça büyük bir sayıdır ve bu isteğin 2. adımı yapmanıza izin verecek kadar uzun sürmesini sağlar. - İlk isteğiniz
str_rot13
döngüsünde bloke edilirken/wp-json/hackismet/delete-license/$session_id
bir istek gönderin. - İlk isteğinizin bitmesini bekleyin ve bayrağınızı alın.
Çözüm
Jetpack Capture The Flag yarışmasının bu ilk baskısına katılırken, bizim onu yürüttüğümüz kadar eğlendiğinizi umuyoruz. Bunu gelecekte tekrar yapmak için sabırsızlanıyoruz. CTF hakkında daha fazla bilgi edinmek için CTF101.org'a göz atın
Kredi
Zorluk tasarımcısı: Marc Montpas
WordCamp Europe'da haberi bizzat yaydığı için Harald Eilertsen'e ve geri bildirim, yardım ve düzeltmeler için Jetpack Scan ekibine özel teşekkürler.