Тёмная сторона Go: разбор живых уязвимостей с продакшена и инструменты против них
- воскресенье, 21 июня 2026 г. в 00:00:05
Статья написана по мотивам доклада Георгия Фатеева (Application Security инженер, МТС Web Services) на конференции GolangConf.
Представьте обычный HTTP-обработчик. Пользователь передаёт имя файла через query-параметр, код подставляет это имя в shell-команду и зовёт ls -l. Выглядит безобидно. А теперь пользователь присылает вот такое значение:
/?filename=.;rm%20-rf%20/
Go аккуратно соберёт и выполнит команду:
sh -c "ls -l .;rm -rf /"
И снесёт систему по своей оси.

Если коротко, уязвимость - это слабое место в программном обеспечении, системе или сети, которое злоумышленник использует для доступа, кражи данных или причинения вреда. Определение книжное, нас же больше интересует происхождение.
Источников у уязвимостей четыре основных:
Ошибки программирования. Самый частый случай. Именно с ними команды безопасности работают чаще всего, причём в идеале превентивно, чтобы они не возникали вовсе.
Устаревшие компоненты и ПО. Вектор серьёзный и быстро набирающий обороты.
Цепочка поставок. Отдельная большая тема, на которую можно сделать самостоятельный доклад.
Неправильная настройка систем. Сюда же примыкает классика: слабые пароли и управление доступом.
Из этих четырёх дверей уязвимости и заходят в код. Дальше вопрос в том, готов ли разработчик их встречать.
Безопасность - это скорее синьорская история. Синьоры в безопасность, как правило, могут, и что особенно ценно, хотят. Тем, кто пока не сталкивался с этой темой плотно лучше вырабатывать Secure Mindset как привычку. Держится она на трёх опорах.

Первая опора - осведомлённость. Чтобы не допустить уязвимость, нужно понимать, как она устроена и как срабатывает. Невозможно писать код, не зная, от чего ты защищаешься. Вторая опора - это практика: знание превращается в привычку только через ежедневное применение. Третья опора - тестирование, включая автоматические и fuzz-тесты. Без них никуда.
Это главное правило в Application Security. По статистике больше 65% уязвимостей возникают из-за чрезмерного доверия к пользовательским данным. Цифра, с которой сложно спорить. Для тех, кто сидит на галёрке, Георгий повторяет ещё раз: пользовательскому вводу мы не доверяем никогда.
Что вообще считается пользовательским вводом, этим самым user input? Всё, что прилетает от взаимодействия пользователя с системой:
значения query-параметров;
значения URL;
значения заголовков;
данные форм;
файлы.
И всё это ведёт к целому классу уязвимостей, которые называют инъекциями.
Инъекции бывают разные, попроще и пострашнее, но встречаются они по сей день практически во всех компаниях и продуктовых командах. Вот основные подвиды.
URL, query и заголовки позволяют модифицировать HTTP-запрос, чтобы протащить в него вредоносные данные. SQL и NoSQL это вмешательство в запросы к базе. Командные инъекции дают выполнить или обойти команды ОС через пользовательский ввод, и это, по словам Георгия, просто ужас. Отдельный подкласс это межсайтовый скриптинг (XSS): отражённый XSS возвращается пользователю как отрендеренный JS, а самый опасный, хранимый XSS, попадает в базу и срабатывает у каждого, кто с ней взаимодействует. Сюда же примыкают небезопасные редиректы, когда логика перенаправления интерпретируется сервером и уводит пользователя туда, куда нужно злоумышленнику.
Разберём базовый пример. Имя пользователя приходит от клиента и склеивается в SQL-запрос конкатенацией строк.

Достаточно передать в username значение ' OR '1'='1, и итоговый запрос превратится в условие, истинное для всех строк. Как минимум это эксфильтрация базы, а дальше уже на что хватит фантазии у злоумышленника.
Лечится это просто: prepared statements, prepared statements и ещё раз prepared statements. Параметризованный запрос подставляет значение как данные, а не как часть SQL.

Казалось бы, инъекции это давно побеждённый класс. Но удивительно, что они выстреливают регулярно, едва ли не каждую вторую неделю. Уровень команды и зрелость процессов тут не спасают: кто-то где-то снова забыл параметризовать запрос. А ведь сделать это очень легко, нужно лишь взять за привычку.
С командной инъекцией мы уже познакомились во вступлении. Лечится она тоже без магии. Используем exec.Command с аргументами и без вызова shell:
cmd := exec.Command("ls", "-l", filename)
Здесь filename не интерпретируется как shell-команда, а воспринимается только как аргумент. Go сам экранирует аргументы корректно. Плюс к этому проверяем пользовательский ввод, то есть валидируем и очищаем его. И снова возвращаемся к парадигме: пользователю мы не доверяем.
Сам по себе интернет уязвимостью не является, но взаимодействие с пользователями приносит проблемы. В современных приложениях полно идентификаторов пользователей, заказов, товаров. Эти ID нужно как-то присваивать, и последовательные числовые идентификаторы прекрасно перебираются обычным циклом в shell.

Любые данные, которые можно перебрать, открывают злоумышленнику дорогу дальше. Решение - это UUID. В стандартных библиотеках есть всё необходимое: вместо числового ID присваиваем UUID. Вероятность коллизий крайне низкая, поймать одинаковый идентификатор можно один раз за всю жизнь.
HTTP request smuggling, или контрабанда запросов, это очень активно стреляющая сейчас уязвимость. Возникает она из-за несовпадения в трактовке границ HTTP-запросов между двумя компонентами цепочки обработки.
Представим, что перед Go-приложением стоит прокси, например Nginx или HAProxy. Прокси определяет конец запроса по заголовку Content-Length, а сам Go-сервер дополнительно учитывает Transfer-Encoding: chunked. Возникает рассогласование.

Если прокси трактует Content-Length: 13 как признак конца запроса, он передаёт вторую часть Go-серверу как новый запрос. Но Go-сервер может воспринять её как часть первого запроса. В этот зазор злоумышленник прячет скрытый второй запрос и попадает, например, на POST /admin в обход аутентификации.

Защищаемся комплексно:
Не пишем собственный HTTP-парсер. Стандартная библиотека Go безопаснее кастомных решений. Вообще всё, что связано с написанием своего велосипеда, лучше обходить стороной, особенно криптографию. Хорошие проверенные библиотеки уже написаны, нет смысла изобретать заново.
Обновляем Go до свежей версии. Эту проблему закрыли примерно в версии 1.20. На старых версиях её надо учитывать.
Фильтруем заголовки. Если присутствуют сразу Content-Length и Transfer-Encoding, один из них удаляем.
Изолируем сервер от прямого доступа хорошо сконфигурированным reverse-proxy и не принимаем нестандартные заголовки вроде Transfer-Encoding: chunked, gzip, которые ломают логику.
Этот пункт Георгий сначала сомневался включать, но после разговоров с коллегами понял: с логированием у многих беда. Сначала договоримся, что считать чувствительными данными: пароли, токены доступа (JWT, OAuth, API-ключи), персональные данные (email, телефон, имя, адрес), финансовую информацию (номера карт, банковские счета) и медицинские данные.
Дальше идёт набор практик.
Не логируем креды. При операциях с секретами логируем сам факт, что операция произошла, без значений. Вместо log.Printf("User password: %s", password) пишем log.Info("Password reset requested for user ID 12345").
Маскируем чувствительные данные. Простой механизм: показываем только края значения, середину прячем.
func MaskToken(token string) string { if len(token) < 8 { return "***" } return token[:4] + "****" + token[len(token)-4:] } log.Infof("Received token: %s", MaskToken(token))
Стандартизируем формат логов и гранулярно разбиваем уровни: DEBUG для отладки (в проде не включаем), INFO для ключевых событий, WARN для подозрительного поведения, ERROR для ошибок, требующих вмешательства, FATAL для необратимых ошибок. Структурированные логи легче фильтровать и анализировать, они проще интегрируются с SIEM, ELK, Prometheus. Это важно ещё и потому, что с логами работают коллеги по цеху из безопасности и SOC. Удобный формат сильно упростит им жизнь.
Не логируем пользовательский ввод напрямую. Ввод может содержать XSS, SQL-инъекции или escape-последовательности, которые повредят лог-файлы или дадут злоумышленнику маневр. Перед записью экранируем и очищаем данные, например через html.EscapeString.
Разделяем логи приложения и логи аудита. Логи приложения это повседневная работа: ошибки, запросы, состояние. Логи аудита фиксируют важные действия (аутентификацию, изменение конфигурации), должны быть неизменяемыми и храниться дольше. Этого требуют многие стандарты, среди них ISO 27001, а также GDPR, HIPAA, PCI DSS.
Защищаем доступ к логам. Утечка логов равна утечке данных, а штрафы за это сейчас серьёзные, государство уделяет теме много внимания. Есть и атака log forging, когда в логи вставляют поддельные строки, чтобы что-то скрыть или подменить.
Не логируем сериализованные объекты целиком. Структуры вроде req или user могут тащить за собой токены и cookie. Выбираем только нужные поля:
log.Infof("Request: method=%s, path=%s, user_agent=%s", req.Method, req.URL.Path, req.UserAgent())
Используем проверенные библиотеки. Стандартная log минималистична, logrus поддерживает JSON-логи, zapбыстрая и структурированная, zerolog компактная и производительная. Георгий советует zap.
Не перелогируем. Чрезмерная паранойя приводит к log flooding и log spamming: лог-файлы утяжеляются, важные сообщения тонут в шуме, мониторинг и алертинг страдают. Помогают rate-limiting логов и счётчики. И в конце всё это обязательно тестируем, потому что даже зная практики, проверять нужно всё.

В микросервисной архитектуре на Go проблемы часто возникают именно на стыке аутентификации и авторизации. Договоримся о терминах: аутентификация (AuthN) это проверка личности пользователя, авторизация (AuthZ) это определение того, что пользователь имеет право делать.
Перед реализацией стоит учесть несколько общих принципов. Используем стандартные методы и не изобретаем свой JWT, OAuth2 или OpenID Connect: всё это уже есть, и весь рынок процентов на 80 работает через Keycloak с готовыми адаптерами. Минимизируем хранение секретов и держим их в безопасных хранилищах вроде HashiCorp Vault. Все чувствительные данные храним и передаём только в зашифрованном виде, по HTTPS и TLS. Действует подход secure by default: по умолчанию все запросы запрещены, доступ разрешается явно.

JWT хорошо ложится на REST API и SPA: секрет в клиенте не храним, токен подписываем и обязательно проверяем подпись. OAuth2 и OpenID подходят для интеграций с внешними провайдерами, и здесь снова берём готовые библиотеки. Сессии в cookie работают для веб-приложений, флаги при этом не забываем. mTLS даёт высокую безопасность для микросервисов и gRPC, хотя поддерживать его сложнее.
При генерации токена кладём в claims идентификатор sub, срок жизни exp и время выпуска iat, а секрет держим вне кода. Самое важное это валидация.

При проверке токена смотрим на три вещи: срок действия exp, идентификатор sub и алгоритм подписи. На алгоритм Георгий обращает особое внимание. Очень много атак на JWT связано именно с ним: алгоритм забывают проверять, и злоумышленник подменяет его, чтобы использовать токен в своих целях.
Хранение токена тоже имеет значение. Для веба это HttpOnly cookie с флагами Secure и SameSite=Strict. Для SPA и API токен держим в памяти и не кладём в localStorage. Не забываем про многофакторную аутентификацию (TOTP через go-otp) и про лимит количества попыток входа, то есть rate limiting.
Для авторизации есть два классических подхода. RBAC опирается на роли и подходит для простых случаев вроде admin и user, проверка сводится к сравнению user.Role == "admin". ABAC опирается на атрибуты и учитывает контекст, например user.Department == resource.Owner. На практике чаще используется RBAC, реализуется он несложно. Из готовых инструментов пригодятся github.com/golang-jwt/jwt/v5, golang.org/x/oauth2, ory/kratos и ory/oathkeeper, а также мощная библиотека casbin/casbin с поддержкой политик для RBAC и ABAC.
Чек-лист по безопасной настройке короткий. Все эндпоинты по умолчанию закрыты. Логируем только безопасную информацию, без токенов и паролей. HTTPS включён всегда, сертификаты можно брать через Let's Encrypt. Токены и сессии в URL не храним: implicit flow с токеном в ?token= уже не приветствуется, но по незнанию его всё ещё встречают. На POST, PUT и DELETE проверяем CSRF, особенно для cookie-based аутентификации. И обязательно rate limiting, особенно на вход.

Плохая криптография. Сфера очень динамичная, всегда есть те, кто шифрует, и те, кто дешифрует. Если вы выбрали алгоритм пять лет назад и продолжаете тащить его по старой памяти, это плохо. Нужно оставаться в тренде и использовать современные алгоритмы.
Хранение паролей в базе. Если база утекла, не дайте злоумышленнику воспользоваться её содержимым. Присваиваем соль и перец, применяем Argon2 и Bcrypt: их очень сложно брутфорсить.
Цепочка поставок. Перед использованием делаем code review сторонних библиотек и смотрим, насколько активен репозиторий, чтобы не занести в проект что-то совсем плохое.
Куча уязвимостей и куча проблем, как с этим жить. Если смотреть со стороны разработки, для локальных проверок есть так называемый мушкетёрский набор из трёх инструментов: статический анализатор, композиционный анализатор и поиск секретов.

CodeQL в последнее время очень хайповая штука. Сначала он собирает проект и строит локальную базу, а потом обращается к ней QL-запросами и вытягивает возможные уязвимости. Его сильно можно кастомизировать, написав собственные правила, и это один из самых мощных инструментов анализа безопасности на рынке. Минус в том, что без кастомных правил он почти бесполезен, а специалистов, которые их пишут, единицы. Если у вашей команды CodeQL уже внедрён, можно поклониться команде безопасности.
Semgrep это универсальный комбайн, который работает как grep, но понимает структуру кода и поддерживает множество языков, включая Go. Он быстрый, легко настраивается, у него открытый код и гибкая настройка правил, отличный выбор для DevSecOps. Минус в том, что он больше подходит для регулярных быстрых проверок и хуже справляется с глубоким анализом больших кодовых баз.
govulncheck это инструмент от команды Go, и разработчику он будет ближе всего. Его удобно крутить локально. Он опирается на Go Vulnerability Database, проверяет и ваш код, и его зависимости, а главное делает анализ достижимости (reachability analysis).

Вывод показывает уязвимую функцию, конкретное место использования и ссылки на CVE или advisory. Анализ достижимости это его главное достоинство: инструмент не пугает мёртвыми уязвимостями. Наверняка вы видели отчёты композиционного анализа с десятками и сотнями уязвимостей в зависимостях, где разработчик резонно говорит, что уязвимая функция у него нигде не используется. govulncheck подсветит функцию, только если она реально достижима. Если функция не используется, она не считается уязвимостью до момента переиспользования, и вы будете об этом знать заранее. Из минусов: инструмент плохо интегрируется в CI и с другими системами, что усложняет автоматизацию. Для локального использования штука классная. Рядом стоят и другие SCA-инструменты: Dependency Track, Dependabot, Syft. Работают они по схожему принципу, собирают Software Bill of Material и анализируют компоненты на известные уязвимости.
Здесь всё просто: прогоняем кодовую базу популярными инструментами вроде Gitleaks и Trufflehog, вылавливаем захардкоженные секреты и выпиливаем их. Иначе потом команды безопасности страдают, потому что git помнит всё, и приходится танцевать с бубном, чтобы всё подчистить.
Это вечный вопрос на стыке безопасности и разработки. Кто прав, кто виноват и что делать.

Основа - это политика здравого смысла. Здесь важен баланс: стороны должны слышать друг друга и не перегибать палку.
Второй инструмент - это трешхолдирование, и в МТС его сейчас активно применяют. Суть в том, чтобы определить оптимальный объём сработок, который команда реально способна разобрать, и зафиксироваться на нём. Начинаем с критов, затем планомерно двигаемся в сторону high и дальше, наращивая объём по мере того, как растёт пропускная способность команды. Без этого отчёты безопасности превращаются для разработки в белый шум: когда заваливают тысячами уязвимостей, физически разобрать их невозможно. Как не заспамить команду разработки, это, по словам Георгия, ключевой момент и самая большая проблема, которую нужно решать.
Третий принцип - это взаимодействие. Противодействие здесь проигрышная стратегия для обеих сторон. Вместе мы сила, как бы странно это ни звучало для вечного противостояния безопасности и разработки. Стоит избегать формализма, когда у всех свои KPI и бизнес давит на сроки, и подходить к делу осмысленно.
В МТС всё это держится на платформенном подходе. Есть development platform, на её базе построена security platform, и проверки встроены прямо в пайплайны, которые крутятся регулярно. Минимальный набор инструментов присутствует у всех команд принудительно, но настроен он так, чтобы не заспамить разработку отчётами. В каждой команде есть ответственный за безопасность: это гибридная модель security-чемпионства. Работает она во многом благодаря зрелости команд, которые полнятся сеньорами, а сеньоры в безопасность могут и хотят. Если у ответственного возникают сложности с интерпретацией сработки или нужна валидация уязвимости на практике, подключается security engineer.
Golang не волшебная палочка. Он даёт контроль, но требует железной дисциплины. Да, язык структурный, понятный и неплохо защищён, и всё же безопасность это не только код, это культура. Начинайте с себя: пишите тесты, проверяйте зависимости, изучайте отчёты CVE. Здоровая паранойя в разумных дозах помогает, паника мешает.
И напоследок японская мудрость: спросить стыдно на одну минуту, а не знать стыдно на всю жизнь. Если что-то непонятно или есть сомнения в подходах к безопасности, проще задать вопрос своей команде. Для этого она и существует.
Материал подготовлен по мотивам доклада Георгия Фатеева (МТС Web Services) «Тёмная сторона Go: как избежать уязвимостей и писать безопасный код» на конференции GolangConf.