PHP теряет сессию после апгрейда: звёздочка в имени cookie превращается в %2A
Короткий ответ: если в session.name есть символ вне [0-9A-Za-z-._], PHP отправляет его в Set-Cookie в URL-кодированном виде (* → %2A), а обратно принимает имя как есть, без декодирования. Имена перестают совпадать, сервер не находит session-id, стартует пустую сессию — пользователь навсегда гость. Ломается это ровно в окне версий 7.2.34, 7.3.23–7.3.33, 7.4.11–7.4.16 и 8.0.1–8.0.3: в этих релизах декодирование имён уже убрали (фикс CVE-2020-7070), а кодирование ещё не убрали. Лечится одной строкой — убрать спецсимвол из имени cookie.
Легаси-портал на Yii2 переехал со старого хоста в Docker. Вход у него только через внешний SSO, своей формы логина нет. После переезда вход перестал доходить до конца: пользователь логинится на SSO, возвращается — и снова видит форму входа. Код приложения при этом не трогали ни строкой.
Часа два ушло на детектив, из них полтора — на версии, которые оказались мимо. Разгадка была в одной строке заголовка с самого начала.
Коротко (TL;DR)
- Причина:
php_url_encode()применяется кsession.nameпри отправке, но имена входящих cookie с некоторого патча больше не декодируются. Round-trip имени разорван. - Симптом: сессия на диске корректная, идентити внутри есть, storage читается — а пользователь всё равно гость. Классический рефлекс «чини хранилище» здесь бесполезен.
- Проверка за минуту: взять один валидный session-id и дёрнуть закрытую страницу дважды — с именем cookie как в конфиге и как в
Set-Cookie.200против302— диагноз. - Фикс: убрать из
session.nameвсё, кроме[0-9A-Za-z-._]. Одна строка в конфиге, рантайм остаётся свежим. - Грабли:
php:7.3-fpm-alpine— это «последний 7.3», а не «тот же 7.3». Пинмажор.минорне защищает от смены поведения. - Грабли: диагностируя руками, не подставляйте
PHPSESSIDпо привычке — при кастомномsession.nameвы получите302и ложный вывод.
Как это выглядит снаружи
Снаружи это выглядит как сломанный SSO, хотя SSO ни при чём. Флоу входа обычный для самодельного single sign-on:
- Гость идёт на закрытую страницу — редирект на внешний SSO.
- Логинится там.
- SSO возвращает его обратно с
?session_key=…. - Приложение проверяет ключ через API SSO, находит пользователя, вызывает
login(), редиректит на профиль. - Профиль под сессией отдаёт
200.
После переезда шаг 5 не наступает никогда: профиль всегда отвечает 302 обратно на вход. Круг замыкается. За пару суток после переезда в логе не нашлось ни одного успешного входа — только редиректы.
Причём часть сигналов выглядела обнадёживающе, и это сбивало. Поле updated_at у пользователей обновлялось — приложение пишет данные до финального login(), так что «свежий вход» в базе означал только «нас коснулись». API SSO на запросы приложения отвечало 200: проверка ключа проходила. Оба сигнала намекали «всё почти работает» — и оба уводили от места поломки.
Пять версий, которые оказались мимо
Все пять первых гипотез указывали наружу или в инфраструктуру, и ни одна не подтвердилась. Выписываю их не для красоты: каждая закрывает целое направление поиска, и если у вас похожий симптом — эти ветки можно не проходить заново.
Версия 1: сломался внешний SSO. В браузере кнопка «Войти» на форме SSO вела себя странно, будто не отправляла запрос. Логично обвинить чужой сервис. Отверг: на сервере SSO ничего не менялось, процессы подняты давно и не перезапускались, а его API на запросы приложения отвечало 200. Значит разрыв — после возврата, уже на нашей стороне.
Версия 2: кончился диск. Лог приложения SSO застыл на позавчерашней записи — классический признак заполненного раздела. Отверг: места и инодов свободно с запасом. Застывший лог оказался отдельной болячкой ротации и к входу отношения не имел.
Версия 3: чужая ручка отдаёт 500. В цепочке входа приложение дёргает у SSO ещё один эндпоинт, и тот стабильно отвечал 500. Выглядело как то, что рушит флоу до login(). Отверг сравнением логов до и после переезда: эта ручка отдавала 500 и раньше, когда вход работал. Давний безобидный дефект, не регрессия.
Версия 4: login() не отрабатывает. Раз профиль считает гостем — может, идентити не находится: в Yii findIdentity() фильтрует по статусу, и неактивный пользователь вернёт null. Отверг: на диске нашлись session-файлы с идентити внутри — условно __id|i:1234. Значит login() отрабатывает и сессию пишет, а пользователи из этих сессий в базе активны.
Версия 5: сломано хранилище сессий. Сессия пишется, но на следующем запросе не читается — похоже на разъехавшийся session.save_path или эфемерный volume. Отверг временным скриптом: session_id($known); session_start(); спокойно прочитал сессию с идентити. Хранилище работает.
Вот на пятой версии я и налажал сам. Проверяя «читается ли сессия по cookie», я слал запрос с заголовком Cookie: PHPSESSID=<sid>, получал 302 и чуть было не записал это в улики как «сессия не подхватывается». А приложение использует кастомное имя session-cookie — мой PHPSESSID сервер просто игнорировал. Тест был бессмысленным с самого начала.
Урок дешёвый, но я его оплатил: воспроизводя сессию руками, сперва посмотри реальный session.name, а не подставляй PHPSESSID по привычке.
Что на самом деле: две половинки, которые перестали сходиться
Развязку дал не поиск по симптому, а буквальное сравнение того, что сервер пишет в Set-Cookie, с тем, что он потом ждёт во входящем Cookie. Когда storage отпал, непроверенным осталось ровно одно звено — само имя.
Имя session-cookie в конфиге приложения содержало звёздочки — условно myapp-front***end-s. Смотрим, что реально уходит клиенту:
set-cookie: myapp-front%2A%2A%2Aend-s=aa5db984...; path=/; HttpOnly
%2A%2A%2A — это URL-кодированные ***. Сервер сам, своей рукой, записал имя в закодированном виде. А при чтении входящего заголовка он ищет сессию по исходному имени, со звёздочками. Имена не совпадают → session-id не найден → стартует новая пустая сессия → пользователь гость → редирект → круг.
Легко прочитать %2A%2A%2A в заголовке как «это браузер так прислал». Нет: это сервер так отдал. Смотреть надо обе стороны round-trip.
Решающая проверка — один и тот же валидный session-id, два написания имени:
| |
200 против 302 на одной и той же сессии, при одном и том же session-id. Содержимое сессии ни при чём — ломается именно совпадение имён.
Почему это сломал именно security-фикс
Вход держался на уязвимости, и его убил патч, который эту уязвимость закрыл. Механизм состоит из двух независимых половинок в разных частях php-src.
Половинка первая — отправка. Расширение session прогоняет session.name через php_url_encode(). В ext/session/session.c ветки PHP-7.3 это сделано с говорящим комментарием:
| |
А php_url_encode() из ext/standard/url.c — это обычный urlencode, не знающий ничего про cookie. Он оставляет как есть только цифры, латинские буквы, -, . и _; пробел превращает в +, всё прочее — в %XX. Звёздочка (0x2A) под исключения не попадает и уезжает как %2A.
Половинка вторая — чтение. Раньше PHP декодировал имена входящих cookie, и это ровно компенсировало кодирование: %2A%2A%2A на входе превращалось обратно в ***, имя совпадало с session.name, всё работало. Два перекоса гасили друг друга.
Именно это декодирование и оказалось дырой. В security-баге #79699 репортёр описал проблему так: «The PHP cookie parser parses the HTTP_COOKIE string percent decoding the entire string» — и дальше показал, что cookie с именем __%48ost- превращается сервером в __Host-, обходя браузерную защиту префиксов. Его рекомендация была прямой: «I would not percent decode the names of cookies».
Так и сделали. В официальном changelog PHP 7 для версии 7.3.23 от 1 октября 2020 года запись дословно такая:
Fixed bug #79699 (PHP parses encoded cookie names so malicious
__Host-cookies can be sent). (CVE-2020-7070)
В коде это выглядит как одно условие в main/php_variables.c — имя декодируется для чего угодно, кроме cookie:
| |
Компенсация исчезла, кодирование осталось. Round-trip разорвался.
Приложение при этом ничего не нарушало: по RFC 6265 имя cookie — это token, а звёздочка входит в разрешённые символы token и сепаратором не является. Имя было легальным. Просто PHP кодировал его generic-функцией, которая про RFC 6265 не знает.
В каких версиях PHP баг живёт
Баг живёт в узком окне: между релизом, где убрали декодирование, и релизом, где убрали кодирование. Обе даты я определил по тегам php-src, сравнивая ext/session/session.c и main/php_variables.c.
Кодирование имени из session_send_cookie убрали позже — в 7.4.18 и 8.0.5 (обе от 29 апреля 2021). Вместо него появилась валидация: имя не должно содержать =,; \t\r\n\013\014, иначе E_WARNING с текстом «session.name cannot contain any of the following». Звёздочка в этот запрет не входит, так что с 7.4.18 она в имени работает нормально — её и не кодируют, и не декодируют.
| Ветка | Декодирование убрано (CVE-2020-7070) | Кодирование имени убрано | Окно, где вход ломается |
|---|---|---|---|
| 7.2 | 7.2.34 (01.10.2020) | не успели, ветка закрыта | 7.2.34 |
| 7.3 | 7.3.23 (01.10.2020) | не успели, ветка закрыта | 7.3.23 — 7.3.33 |
| 7.4 | 7.4.11 (01.10.2020) | 7.4.18 (29.04.2021) | 7.4.11 — 7.4.16 |
| 8.0 | 8.0.1 (07.01.2021) | 8.0.5 (29.04.2021) | 8.0.1 — 8.0.3 |
| 8.1 и новее | — | уже без кодирования | не затронуты |
Две оговорки по таблице, обе проверяемые. Версий 7.4.17 и 8.0.4 не существует вовсе — их нет ни среди тегов php-src, ни в changelog: после 7.4.16 и 8.0.3 от 4 марта 2021 сразу идут 7.4.18 и 8.0.5 от 29 апреля. И отдельной changelog-записи про снятие кодирования с session.name я не нашёл — изменение видно по коду, но в списке исправлений оно не поименовано.
Практический вывод из таблицы: хуже всего веткам 7.2 и 7.3. Они закрылись, не дождавшись второй половины изменения, поэтому любой последний патч 7.2 или 7.3 ломает спецсимвол в имени session-cookie навсегда. Наш случай ровно такой: старый хост работал на 7.3.16, официальный образ php:7.3-fpm-alpine принёс 7.3.33 — то есть перепрыгнул фикс 7.3.23, стоящий ровно посередине.
Отсюда же понятно, почему готового ответа на этот симптом почти не найти. Окно узкое, а совпасть должны три вещи разом: спецсимвол в session.name, версия из окна и апгрейд через границу 7.3.23.
Как проверить это у себя за минуту
Проверка не требует ни стенда, ни доступа к коду — хватит curl и одного валидного session-id.
- Посмотрите, что сервер пишет в имя cookie:
curl -s -D - -o /dev/null https://portal.example/ | grep -i set-cookie - Найдите в выводе
%внутри имени (до знака=).%2A— звёздочка,%21— восклицательный знак,%24— доллар. Если проценты только в значении — вы не здесь, ищите дальше. - Сравните имя из
Set-Cookieс тем, что лежит вsession.name(php -i | grep session.nameили конфиг фреймворка). Расходятся — диагноз подтверждён. - Добейте round-trip’ом: одну и ту же закрытую страницу с одним и тем же
sid, но двумя написаниями имени. Разные коды ответа — это оно. - Проверьте версию:
php -v. Попадает в окно из таблицы выше — сомнений не осталось.
Шаг 2 — самый дешёвый и отсекает почти всё. Минута против пары часов гадания про save_path, volume и права на каталог.
Чем чинить: четыре варианта
Правильный вариант один — убрать спецсимвол из имени, остальные три либо консервируют дыру, либо усложняют схему.
| Вариант | Что делает | Почему не выбран |
|---|---|---|
Сменить session.name | убрать всё, кроме [0-9A-Za-z-._] | выбран: одна строка, рантайм остаётся свежим |
| Закрепить старую патч-версию | пин 7.3.16 в Dockerfile | откатывает рантайм на релиз с незакрытыми уязвимостями, включая саму CVE-2020-7070 |
| Отключить кодирование в PHP | — | нечего отключать: это внутренняя логика отправки, параметром не управляется |
| Переписывать имя в nginx | подменять %2A на * на лету | хрупко и непрозрачно; следующий человек будет искать это полдня |
Сам фикс — переопределение компонента сессии в локальном конфиге, который не лежит в git:
| |
Дальше docker compose restart php. Все текущие сессии при смене имени обнуляются, но терять было нечего: залогиненных не было вовсе — все и так висели гостями.
Звёздочки в имени cookie изначально были сомнительной практикой, так что фикс заодно убрал и её. Что осталось не сделанным: исходное имя со звёздочками всё ещё лежит в конфиге в репозитории, локальный override его лишь перебивает. Кто поднимет окружение с чистого git — наступит снова. Чинить надо в апстриме, и это долг.
Итог
Когда сессия «не помнит» пользователя, первым делом сравните имя в Set-Cookie с тем, которое сервер ждёт во входящем Cookie. Если в имени есть хоть один символ вне [0-9A-Za-z-._] — это главный подозреваемый, и проверка занимает минуту. Ветка «сломано хранилище» отсекается тем, что сессия на диске корректна: запись и чтение — два независимых шага, и ломается именно второй, на уровне имени, а не содержимого.
Второй вывод — про пины. php:7.3-fpm-alpine означает «последний 7.3», а не «тот же 7.3, что был на старом сервере». Между 7.3.16 и 7.3.33 лежит security-фикс, меняющий поведение, и приложение, завязанное на тонкости рантайма, переживёт такой прыжок не всегда. Если тащите легаси в контейнер — пиньте патч-версию и прогоняйте регресс-тест входа, а не только composer install.
И третий, менее приятный: работавший вход опирался на баг. Кодирование при отправке и декодирование при чтении гасили друг друга годами, и никто об этом не знал, потому что снаружи всё выглядело исправно. Такие связки вскрываются ровно в тот момент, когда одну половину чинят по соображениям безопасности.
Первоисточники: security-баг #79699 в трекере PHP, changelog PHP 7 (записи 7.2.34, 7.3.23, 7.4.11 от 01.10.2020), ext/session/session.c и main/php_variables.c в php-src, RFC 6265 §4.1.1 про допустимые символы в имени cookie.
<< Previous Post
|
Next Post >>