
listmonk: сохранение сессии после сброса пароля и смены пароля
listmonk: сохранение сессий после сброса и смены пароля
Я обнаружил эту проблему при проверке listmonk — open-source менеджера новостных рассылок и списков рассылки, задавшись простым вопросом с точки зрения безопасности:
Когда пользователь меняет или сбрасывает пароль, действительно ли приложение завершает уже выданные сессии?
В данном случае ответ был — нет.
Ранее выданные аутентифицированные сессии оставались действительными и после:
Это означало, что украденный сессионный cookie мог пережить именно те события безопасности, на которые пользователи рассчитывают при восстановлении доступа к своей учётной записи.
Проблема была принята, и ей присвоен CVE-2026-34828.
Проект: listmonk на GitHub
CVE: CVE-2026-34828
Затронут listmonk — широко используемый проект с 5M+ скачиваний Docker.
stolen authenticated session → victim resets or changes password → old session remains valid → attacker retains account access after credential recovery
listmonk — это self-hosted менеджер списков рассылки и новостных рассылок.
Он предоставляет:
Это означает, что его модель сессий является реальной границей безопасности.
Важный вопрос здесь заключался не в том, поддерживает ли listmonk сброс пароля.
Настоящий вопрос был таков:
Действительно ли сброс или смена пароля отзывают сохранённый злоумышленником доступ, если сессия уже была украдена?
В данном случае — нет.
Многие обзоры безопасности слишком узко сосредоточены на обходе входа в систему и очевидных повышениях привилегий.
При этом упускается важный класс уязвимостей:
сбои восстановления доступа
Если пользователь меняет или сбрасывает пароль, это действие должно быть значимым.
Оно должно снижать доверие к прежним учётным данным и прежнему состоянию аутентификации.
Если у злоумышленника уже есть действительная сессия и эта сессия переживает событие восстановления, значит жертва на самом деле не восстановила учётную запись полностью.
Именно это и было здесь.
Это не ошибка проверки входа. Это не проблема криптографии. Это не сбой хеширования паролей.
Это был сбой жизненного цикла сессий:
Этого достаточно, чтобы возникла реальная уязвимость.
Я не подходил к listmonk, беспорядочно дёргая эндпоинты в надежде, что какой-нибудь сломается.
Более сильный путь — сначала определить самую ценную границу доверия.
Для программного обеспечения с интенсивной аутентификацией одна из лучших границ для тестирования такова:
Отзывают ли чувствительные к безопасности изменения учётной записи ранее доверенные сессии?
Обычно этот вопрос становится интересным вокруг:
В listmonk самый сильный сигнал исходил от первых двух пунктов.
Именно там проблема стала очевидной.
Ошибка заключалась не в том, что смена пароля не работала.
Ошибка заключалась в том, что сессии переживали смену пароля.
Из анализа исходного кода, процесс сброса пароля:
но видимого отзыва старых сессий не было.
Та же картина наблюдалась в процессе аутентифицированной смены пароля:
Это поведение в точности совпало с результатами живого тестирования.
Области кода, которые я просмотрел:
cmd/auth.go — поведение забытого пароля/сбросаcmd/users.go — аутентифицированные обновления профиляinternal/core/users.go — обработка обновления пароляПотому что кража сессии — это реальное условие атаки.
Как только злоумышленник получает действительный аутентифицированный сессионный cookie любым способом, например:
жертва должна иметь возможность прекратить сохранённый злоумышленником доступ, сменив или сбросив пароль.
Здесь она этого сделать не могла.
Цепочка атаки была прямолинейной:
В этом и заключается вся уязвимость.
Ключевое различие — сохранение доступа после восстановления.
Многие приложения рассматривают смену пароля как чисто событие на уровне учётных данных. Этого недостаточно.
Настоящий вопрос не в том:
«Изменилось ли значение пароля в хранилище?»
Настоящий вопрос в том:
«Были ли отозваны отношения доверия, связанные со старыми сессиями?»
В listmonk — нет.
Это превращает то, что могло быть обычным обслуживанием учётной записи, в неполное восстановление безопасности.
В этом разница между:
Я подтвердил проблему в двух отдельных процессах.
Сначала я создал обычного тестового пользователя и вошёл в систему, сохранив аутентифицированный сессионный cookie.
Затем я запустил процесс забытого пароля, перехватил ссылку для сброса и сбросил пароль.
После сброса:
Показательный проверочный запрос выглядел так:
GET /api/profile HTTP/1.1
Host: 127.0.0.1:9000
Cookie: session=<old_pre_reset_session>
И сервер по-прежнему возвращал:
HTTP/1.1 200 OK
Content-Type: application/json
с аутентифицированным профилем.
Это подтвердило основной тезис:
Затем я подтвердил тот же класс ошибки в процессе аутентифицированной смены пароля.
Я дважды вошёл в систему под одним и тем же пользователем и сохранил две действительные аутентифицированные сессии:
Используя сессию A, я сменил пароль через эндпоинт обновления профиля.
Пример запроса:
PUT /api/profile HTTP/1.1
Host: 127.0.0.1:9000
Cookie: session=<session_A>
Content-Type: application/json
{
"name":"victim1",
"email":"[email protected]",
"password":"VictimChanged123"
}
После этого:
Последующий запрос с использованием сессии B по-прежнему возвращал аутентифицированные данные из /api/profile.
Это доказало, что проблема не ограничивалась процессом забытого пароля/сброса.
Она затрагивала и обычную аутентифицированную смену пароля.
Одного воспроизведения уже было бы достаточно, чтобы показать проблему.
Но подтверждение обоих процессов было важно по двум причинам.
Это показало, что ошибка не изолирована в одном пограничном пути восстановления.