
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.
Это доказало, что проблема не ограничивалась процессом забытого пароля/сброса.
Она затрагивала и обычную аутентифицированную смену пароля.
Одного воспроизведения уже было бы достаточно, чтобы показать проблему.
Но подтверждение обоих процессов было важно по двум причинам.
Это показало, что ошибка не изолирована в одном пограничном пути восстановления.
Одно и то же свойство безопасности нарушалось и при:
Это затруднило списание проблемы как случайной бизнес-логики.
Это явно была более широкая слабость управления сессиями:
Это придало проблеме гораздо больший вес с точки зрения безопасности.
Я также протестировал процесс сброса на учётной записи с включённым TOTP, потому что хотел выяснить, не ослабляет ли и не обходит ли сброс пароля ожидания, связанные с 2FA.
Что я подтвердил:
Это было полезной проверкой границы.
Она правильно сузила проблему.
Уязвимость заключалась не в том, что:
Реальной проблемой оставалось:
Это более чёткий и более защитимый вывод.
Эта проблема была обоснованно классифицирована как High (высокая).
Ключевое воздействие здесь — устойчивый несанкционированный доступ после действий по восстановлению безопасности учётной записи.
Классификация в бюллетене:
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:NЭто логично.
Утверждение не в том, что злоумышленник может войти без учётных данных на пустом месте. Утверждение в том, что после получения злоумышленником действительной аутентифицированной сессии жертва не может полностью прекратить этот доступ, выполнив именно те действия безопасности, которые предназначены для восстановления учётной записи, а именно сброс и смену пароля.
Это реальная и защитимая уязвимость управления сессиями.
Некоторые недооценивают ошибки сохранения сессий, полагая, что кража сессии — это уже «game over».
Это слишком упрощённо.
Настоящий вопрос — что происходит после того, как жертва замечает, что что-то не так, и предпринимает действия.
Если:
то восстановление учётной записи неполно.
Это не просто неудобное поведение. Это сбой безопасности в модели восстановления.
Особенно на платформе, ориентированной на администраторов, это значимая проблема с сильным влиянием на конфиденциальность.
Мейнтейнер исправил проблему в коммите:
db82035
Основное направление исправления — ровно то, что было нужно этой ошибке:
Это правильное устранение, потому что оно нацелено на реальное нарушенное свойство безопасности:
прежнее доверие должно умирать при изменении учётных данных
Хорошее исправление для этого класса ошибок — не про изменение проверки пароля. Оно про отзыв ранее активного состояния сессии, привязанного к учётной записи.
Именно это восстанавливает реальное восстановление доступа.
Об этой проблеме было сообщено конфиденциально через поток отчётов о безопасности GitHub.
Мейнтейнер:
CVE-2026-34828
Во время обработки бюллетеня всплыл один момент — охват.
Первоначальный отчёт включал оба случая:
GitHub изначально рассматривал их как независимо исправимые проблемы для целей присвоения CVE. Это полезное напоминание о том, что охват бюллетеня имеет значение, даже когда базовая уязвимость концептуально схожа.
Итоговым результатом стал CVE-2026-34828.
Главный урок здесь прост:
изменения учётных данных недостаточно, если прежнее аутентифицированное доверие всё ещё живо.
Многие разработчики мыслят в терминах:
Эти вещи важны.
Но реальная граница безопасности шире:
когда происходит событие высокого риска для учётной записи, какое ранее доверенное состояние должно перестать быть доверенным?
В данном случае ответ должен был быть:
И listmonk этого не делал.
Вот в чём настоящий вывод.
Эта уязвимость — не про изощрённые полезные нагрузки или хитрые трюки с парсерами.
Она про то, чтобы задать правильный вопрос о границе доверия.
В listmonk пароль изменился.
Действие по восстановлению завершилось.
Но старая сессия злоумышленника всё ещё жила.
Именно поэтому это стало CVE-2026-34828.
