Московское электронное голосование проверяет, открывается ли у избирателя YouTube
Сайт голосования elec.mos.ru перед входом заставляет браузер избирателя загрузить картинку с YouTube. В России YouTube заблокирован. Если картинка загрузилась, у избирателя работает VPN. Результат браузер отправляет на сервер голосования. Через секунду избиратель входит через mos.ru под своим именем.
Как это работает
- Браузер открывает страницу входа и получает от сервера список адресов для проверки. Пять адресов — сервисы самого голосования, они должны отвечать. Шестой —
https://i.ytimg.com/vi/mqdefault.jpg, картинка с YouTube. Он должен не отвечать. - Браузер запрашивает все шесть адресов. К каждому запросу добавлен один и тот же случайный идентификатор проверки.
- Через пять секунд браузер отправляет серверу отчёт: идентификатор проверки и результат по каждому адресу. У избирателя без VPN в отчёте стоит
youtube=connection timeout. У избирателя с VPN — код ответа YouTube, любой, включая 404. - Если сервисы голосования доступны, браузер переходит на вход через mos.ru. Сервер получает фамилию, имя и отчество избирателя.
Отчёт отправляется при каждом входе. Он уходит с того же IP-адреса и в той же сессии, что и вход через mos.ru: сессионный cookie сайт ставит ещё до проверки. В записи реальной сессии 18 сентября 2026 года между отчётом и входом прошло 0,2 секунды.
Результат проверки не мешает голосовать. Ошибку «подключение блокирует VPN» сайт показывает, только когда не отвечают сервисы голосования, то есть когда VPN выводит за границу, откуда mos.ru недоступен. Российский VPN проверку проходит.
Что это значит
Оператор голосования получает по каждому избирателю ответ на вопрос «пользуется ли он VPN» и в ту же секунду узнаёт, кто этот избиратель. Для этого не нужно ничего взламывать: два запроса лежат рядом в одном журнале. Зачем нужен отчёт и как долго он хранится, из кода сайта не видно.
Связать признак VPN с содержанием голоса сложнее. Между входом по имени и бюллетенем стоит анонимайзер, который должен разрывать связь. Доказательств, что связь сохраняется, у нас нет. Но все места, где она существует, находятся у оператора: он выдаёт анонимный идентификатор бюллетеня, он присваивает «адрес транзакции», и в блокчейн попадает не тот шифртекст, который создал браузер избирателя, а перепакованный сервером. Проверить снаружи, что связь разорвана, нельзя.
Для технарей: как это реализовано
Где лежит код
Фронтенд шлюза — приложение gatekeeper версии 1.6.6 (React, сборка rolldown). Бандл gatekeeper/assets/index-DyWqi8Ek.js (468 КБ) отдаётся с cdn-deg.mos.ru/static/gatekeeper/; его подключает страница elec.mos.ru/gatekeeper. Копия бандла от 18.09.2026 сохранена.
Последовательность запросов
POST /api/elect-gatekeeper-service/hello, тело{}. Ответ:{bitsComplexity: 15, task: <JWT>, votingStatus: "IN_PROGRESS", startDateTime, endDateTime}. Заголовокx-send-stats. Сервер ставит два cookie:session-cookie(Max-Age 86400, Path=/) иX-Check-Browser(HttpOnly, SameSite=Strict, Path=/api/elect-gatekeeper-service/check).- Клиент решает proof-of-work: hashcash v1 на SHA-1, 15 ведущих нулевых бит. Штамп
1:15:YYYYMMDDHHMMSS:<task>::<rand base64 8 байт>:<base64(counter)>.task— JWT HS256 сexp = iat + 30 с, то есть штамп надо успеть за 30 секунд; на практике это 0,3–1 с. Реализация — модульmintс CryptoJS SHA-1 иLongдля счётчика. POST /api/elect-gatekeeper-service/check, тело{solution: <штамп>}, с cookieX-Check-Browser. Ответ:sudirUrl(OAuth-ссылка наlogin.mos.ru/sps/oauth/ae,client_id=elec.mos.ru,redirect_uri=…/sudir/catch,scope=openid+profile,state) иcheckConnectionConfig.- Функция проверки сети (в бандле —
gc): генерируетguid = crypto.randomUUID(), для каждого элементаmustAvailableиshouldUnavailableвызываетfetch(url + "?guid=" + guid, {credentials: "omit", method: "GET", signal})с таймаутомconnectionTimeoutInMs(5000 мс, черезAbortController). Доступность:status < 500. ДляmustAvailable— доmaxRetries(2) повторов; дляshouldUnavailable— без повторов. Ошибка сети записывается как текст:"connection timeout"при аборте, иначеe.messageили"connection error". - Отчёт (функция
hc):GET reportUrl?guid=…&<systemName>=<reportValue>для всех хостов, тожеcredentials: "omit", тем же таймаутом, ошибки отправки игнорируются. Отправляется всегда, до любого ветвления. - Ветвление:
if (!mustAvailable) navigate("/gatekeeper/not-available/" + (vpnDetected ? 1010 : 1011)); else location.href = sudirUrl. Тексты: 1010 — «Возможно, подключение блокирует VPN. Пожалуйста, отключите VPN и откройте страницу заново», 1011 — то же плюс «или проверьте стабильность интернет-соединения». - После SUDIR:
GET /sudir/catch?code=…→ 302 на/gatekeeper/queue?ticketId=…→POST /wait {ticketId}. Ответ содержитregistrationUrl,registrationTokenиprofile {lastName, firstName, middleName}. Дальшеregistration(SMS-код),get_ballot → anonUrl, анонимайзер,ballot/decrypt,ballot/vote.
Что может связать сервер
- IP-адрес: проверочные запросы, отчёт и SUDIR-обмен идут с одного адреса в пределах секунд.
session-cookie, выставленный наhello: отчёт уходит без cookie (credentials: "omit"), но/waitи всё дальше — с ним; cookie и отчёт связаны через IP и время.guidпроверки известен серверу из пяти запросов к его собственнымcheck_connection/checkи из отчёта; в ответах наcheck_connection/checkвозвращаетсяOK, сервер может логировать guid вместе с IP.
Никаких скрытых каналов не требуется: достаточно журнала запросов nginx на elec.mos.ru.
Как проверить самому
- Открыть
https://elec.mos.ru/gatekeeperс российского адреса, в DevTools вкладка Network, фильтрcheck_connection. - Увидеть шесть GET-запросов с одним
guidи через 5 секунд GET наcheck_connection/reportс параметромyoutube=…. - Скачать бандл с
cdn-deg.mos.ru/static/gatekeeper/assets/index-DyWqi8Ek.jsи найти строкиshouldUnavailable,vpnDetected,reportUrl.
Код проверки (сокращённая расшифровка бандла)
dc = (url, timeout) => { const c = new AbortController(); setTimeout(() => c.abort(), timeout);
return fetch(url, {credentials: "omit", method: "GET", signal: c.signal}) }
fc = (url, guid) => { const u = new URL(url, location.href); u.searchParams.set("guid", guid); return u.toString() }
pc = (url, t) => dc(url, t).then(r => ({available: r.status < 500, reportValue: String(r.status)}))
.catch(e => ({available: false, reportValue: lc(e)})) // "connection timeout" | message
mc = async (host, guid, t, retries) => { for (i = 0; i <= retries; i++) { r = await pc(fc(host.url, guid), t); if (r.available) break }
return {...r, systemName: host.systemName} }
hc = async (cfg, guid, results, t) => { u = new URL(cfg.reportUrl, location.href); u.searchParams.set("guid", guid);
results.forEach(r => u.searchParams.set(r.systemName, r.reportValue)); await dc(u, t).catch(() => {}) }
gc = async cfg => { guid = crypto.randomUUID();
[must, should] = await Promise.all([ Promise.all(cfg.mustAvailable.map(h => mc(h, guid, 5000, 2))),
Promise.all(cfg.shouldUnavailable.map(h => mc(h, guid, 5000, 0))) ]);
await hc(cfg, guid, [...must, ...should], 5000);
return {mustAvailable: must.every(r => r.available), vpnDetected: should.some(r => r.available)} }
sendCheck(hello).then(async ({checkConnectionConfig, sudirUrl}) => {
if (checkConnectionConfig) { r = await gc(checkConnectionConfig);
if (!r.mustAvailable) return navigate("/gatekeeper/not-available/" + (r.vpnDetected ? 1010 : 1011)) }
location.href = sudirUrl })
Ответ сервера на /check, 18.09.2026
"checkConnectionConfig": {
"mustAvailable": [
{"url": "https://elec.mos.ru/registration/check_connection/check", "systemName": "elect-registration-ui"},
{"url": "https://elec.mos.ru/api/elect-registration-service/check_connection/check", "systemName": "elect-registration-service"},
{"url": "https://elec.mos.ru/api/elect-anonymizer-service/check_connection/check", "systemName": "elect-anonymizer-service"},
{"url": "https://elec.moscow/ballot/check_connection/check", "systemName": "elect-ballot-ui"},
{"url": "https://elec.moscow/api/elect-ballot-service/check_connection/check", "systemName": "elect-ballot-service"}
],
"shouldUnavailable": [
{"url": "https://i.ytimg.com/vi/mqdefault.jpg", "systemName": "youtube"}
],
"reportUrl": "/api/elect-gatekeeper-service/check_connection/report",
"connectionTimeoutInMs": 5000,
"maxRetries": 2
}
Запись сессии голосования, 18.09.2026, Москва (HAR)
Идентификаторы и персональные данные сокращены.
14:25:47.974 POST elec.mos.ru/api/elect-gatekeeper-service/hello → 200, Set-Cookie: session-cookie (24 ч), X-Check-Browser
14:25:48.260 POST …/check {solution: "1:15:20260918112548:<jwt>::zZGLtTxrlaQ=:NTExNA=="} → sudirUrl + checkConnectionConfig
14:25:48.326 GET elec.mos.ru/registration/check_connection/check?guid=e8a8… → 200 (15 мс)
14:25:48.328 GET elec.mos.ru/api/elect-registration-service/check_connection/… → 200 (15 мс)
14:25:48.329 GET elec.mos.ru/api/elect-anonymizer-service/check_connection/… → 200 (15 мс)
14:25:48.330 GET elec.moscow/ballot/check_connection/check?guid=e8a8… → 200 (52 мс)
14:25:48.330 GET elec.moscow/api/elect-ballot-service/check_connection/… → 200 (30 мс)
14:25:48.331 GET i.ytimg.com/vi/mqdefault.jpg?guid=e8a8… → нет ответа
14:25:53.336 GET elec.mos.ru/api/elect-gatekeeper-service/check_connection/report
?guid=e8a8…&elect-registration-ui=200&elect-registration-service=200
&elect-anonymizer-service=200&elect-ballot-ui=200&elect-ballot-service=200
&youtube=connection timeout
14:25:53.501 GET …/sudir/catch?code=… → 302 → /gatekeeper/queue?ticketId=…
14:25:53.899 POST …/wait {ticketId} → registrationToken, profile {lastName, firstName, middleName}