Как незаметная indirect-зависимость в Go дописала ручку в ваш HTTP-сервер

Как незаметная indirect-зависимость в Go дописала ручку в ваш HTTP-сервер

Как незаметная indirect-зависимость в Go дописала ручку в ваш HTTP-сервер

Все примеры из статьи лежат в репозитории github.com/korableg/init-injection-example . Код «вредоноса» написан в учебных целях — чтобы показать класс проблемы, а не дать готовый инструмент. Запускайте только в песочнице.

Все примеры из статьи лежат в репозитории github.com/korableg/init-injection-example . Код «вредоноса» написан в учебных целях — чтобы показать класс проблемы, а не дать готовый инструмент. Запускайте только в песочнице.

История простая: у нас есть аккуратный сервис на net/http с единственной ручкой /time . Мы обновляем одну библиотеку через go get , ничего не меняя в своём коде. После рестарта в сервисе появляется ручка /__injected , которая отдаёт строки из памяти процесса. Мы её не регистрировали . Более того — пакет, который её зарегистрировал, формально в сервисе не используется .

Дальше — разбор, как такое вообще возможно, шаг за шагом: от модели зависимостей Go и функции init до сканирования кучи и unsafe.Pointer . И, конечно, как от этого защищаться.

Часть 1. Как Go видит зависимости

Чтобы понять атаку, нужно держать в голове три факта о модульной системе Go. Если вы уверенно читаете go.mod — можно пролистать до части 2.

Команда создаёт в корне go.mod . Путь модуля бывает двух видов:

для библиотеки — полный импортируемый путь ( github.com/org/lib ), по которому её будут тянуть другие;

для библиотеки — полный импортируемый путь ( github.com/org/lib ), по которому её будут тянуть другие;

для сервиса — может быть просто уникальным идентификатором, наружу его никто не импортирует.

для сервиса — может быть просто уникальным идентификатором, наружу его никто не импортирует.

Если сервис должен экспортировать пакеты (например, сгенерированные protobuf-контракты), под них заводят отдельный external-модуль с полным именем — его и импортируют соседи.

go get — универсальный способ затянуть любую зависимость: библиотеку, инструмент, обновление версии. Именно эта команда в нашей истории и станет точкой входа атаки.

Шесть элементов:

Путь модуля — разобрали выше.

Путь модуля — разобрали выше.

Версия языка — минимальная версия Go, чьи языковые фичи разрешено использовать в модуле; компилятор отвергнет всё, что появилось позже. За конкретный тулчейн отвечает отдельная директива toolchain (если её нет — берётся версия из строки go ).

Версия языка — минимальная версия Go, чьи языковые фичи разрешено использовать в модуле; компилятор отвергнет всё, что появилось позже. За конкретный тулчейн отвечает отдельная директива toolchain (если её нет — берётся версия из строки go ).

replace — подменяет зависимость на форк или на локальную директорию.

replace — подменяет зависимость на форк или на локальную директорию.

Прямая зависимость — то, что вы сами добавили через go get .

Прямая зависимость — то, что вы сами добавили через go get .

Indirect-зависимость (я называю их «теневыми») — то, что вы явно не импортируете, но что тянут используемые вами пакеты. Помечается комментарием // indirect .

Indirect-зависимость (я называю их «теневыми») — то, что вы явно не импортируете, но что тянут используемые вами пакеты. Помечается комментарием // indirect .

exclude — запрет конкретной версии.

exclude — запрет конкретной версии.

Запомните пункт 5 — // indirect . Вся интрига статьи держится на одном вопросе: что Go выполняет в indirect-зависимостях, которые ваш код напрямую не вызывает?

Часть 2. init — тихий вход

init — функция без аргументов и без возвращаемого значения:

Особенности:

располагать её можно где угодно в пакете (не обязательно сверху);

располагать её можно где угодно в пакете (не обязательно сверху);

в одном пакете может быть несколько функций init — хоть в каждом файле.

в одном пакете может быть несколько функций init — хоть в каждом файле.

Неявный вызов. init выполняется автоматически, до любого вашего кода, до main .

Неявный вызов. init выполняется автоматически, до любого вашего кода, до main .

Порядок. Несколько init в пакете выполняются в порядке объявления. А init зависимых пакетов выполняются до init вашего пакета — в порядке импорта.

Порядок. Несколько init в пакете выполняются в порядке объявления. А init зависимых пакетов выполняются до init вашего пакета — в порядке импорта.

Назначение. Классически init используют для настройки глобального состояния, регистрации обработчиков и прочей подготовки до старта основной логики.

Назначение. Классически init используют для настройки глобального состояния, регистрации обработчиков и прочей подготовки до старта основной логики.

Драйверы БД в Go регистрируются именно через init . Стандартная библиотека предоставляет sql.Register :

Поэтому драйверы и подключают «пустым» импортом:

Вы не вызываете из пакета ни одной функции — но его init уже зарегистрировал драйвер. Удобно. И ровно здесь зарыта мина.

Первые три пункта — про читаемость и тестируемость. А вот четвёртый — главный герой статьи:

init выполняется даже в indirect-зависимости, которую ваш код фактически не использует. Достаточно, чтобы пакет попал в граф сборки.

init выполняется даже в indirect-зависимости, которую ваш код фактически не использует. Достаточно, чтобы пакет попал в граф сборки.

Дальше покажу, что это значит на практике.

Часть 3. Подопытный сервис

Смоделируем реалистичную ситуацию. Есть сервис example-service с одной ручкой /time :

HTTP-сервер мы оборачиваем во внутреннюю библиотеку lib — представьте «общую обвязку» с единым конфигом, которую переиспользуют все сервисы компании:

Конфиг библиотеки — это удобная композитная структура: настройки REST, базы данных и некоего сервиса foo (он тут для массовки):

Обратите внимание на поле DB *db.Config . Сервис example-service не работает с базой . Он отдаёт таймстамп. Но его «общий конфиг» из библиотеки ссылается на тип db.Config — а значит, пакет db попадает в граф сборки.

Смотрим go.mod сервиса:

db помечен как // indirect . Сервис его напрямую не импортирует — он пришёл транзитивно через lib/config .

Запускаем сервис — и в логах появляется:

init() из пакета db выполнился, хотя ни одной функции из db мы не вызывали. Просто потому что тип db.Config упомянут в структуре конфига. Пока что безобидно — зарегистрировался драйвер. Но это доказательство концепции: чужой init уже исполняется в адресном пространстве нашего процесса.

Часть 4. Обновление, которое всё меняет

Теперь смоделируем то, что происходит в реальной жизни постоянно: мы делаем go get и обновляем db до новой версии. В код сервиса мы не заглядываем — «там же только конфиг базы». А в новой версии init теперь выглядит так:

Заглянем в inject . И тут начинается самое интересное — функция оперирует unsafe.Pointer . Зачем? Разберёмся по частям.

Часть 5. Краткий ликбез по unsafe.Pointer

Чтобы понять эксплойт, нужно понимать два типа.

Обычный указатель в Go типизирован: *int указывает на int , и компилятор это контролирует. unsafe.Pointer — это как void * в C: его можно преобразовать в указатель на любой тип , и компилятор не проверяет безопасность типов .

unsafe.Pointer — настоящий указатель, garbage collector его отслеживает.

unsafe.Pointer — настоящий указатель, garbage collector его отслеживает.

uintptr — беззнаковое целое размером с указатель. Над ним доступна арифметика (над unsafe.Pointer — нет). Но GC его не видит .

uintptr — беззнаковое целое размером с указатель. Над ним доступна арифметика (над unsafe.Pointer — нет). Но GC его не видит .

Из-за сборщика мусора. Как только адрес «сполз» в uintptr , GC перестаёт считать объект живым и может его переместить или собрать. Превратите uintptr обратно в указатель позже — и попадёте на освобождённую/перемещённую память. В лучшем случае — segfault.

Валидно (всё в одном выражении — компилятор и GC видят связь):

Невалидно (адрес «полежал» в переменной uP между выражениями):

Такие конструкции ловят линтеры, go vet и checkptr . Но есть «легальный» обход — unsafe.Add , который умеет арифметику над unsafe.Pointer без перехода в uintptr :

Именно unsafe.Add использует inject , чтобы спокойно делать арифметику над адресами и не привлекать внимания инструментов.

Часть 6. Как inject находит и захватывает HTTP-роутер

Теперь, вооружившись знанием про unsafe , разберём inject целиком. Общая идея:

Чтобы найти роутер в памяти, inject снимает дамп всей кучи штатным средством runtime/debug :

debug.WriteHeapDump принимает один аргумент — файловый дескриптор. Поэтому сначала создаётся временный файл, в него пишется дамп, затем он парсится библиотекой heaputil в список объектов. Все адреса в дампе хранятся как беззнаковые целые. (Парсинг формата дампа — отдельная большая тема, см. ссылку в коде.)

Зная, что любой адрес — это смещение от начала виртуального адресного пространства, можно прибавить адрес объекта к нулевому указателю и получить рабочий unsafe.Pointer :

Дальше нужно понять, какой из объектов кучи — это http.ServeMux . Для этого в пакете заранее объявлены структуры, повторяющие внутреннюю разметку приватных типов из net/http :

Поскольку unsafe.Pointer можно привести к любому типу, а в памяти эти структуры лежат байт-в-байт как оригиналы из net/http , можно «надеть» их на сырой адрес и прочитать.

Распознавание идёт по нескольким опорным признакам.

Опорное значение №1 — реальный размер ServeMux в памяти. Здесь хитрость: объект в куче занимает не unsafe.Sizeof , а размер ближайшего класса аллокации (size class) Go — рантайм округляет вверх. Размер класса вычисляют красивым хаком:

Берём n -байтный слайс, аппендим в пустой безразмерный — рантайм подгоняет capacity под класс памяти. cap и есть реальный размер. (Классы памяти — см. runtime/sizeclasses.go .)

Опорные значения №2 — смещения и количество полей под конкретную разметку ServeMux . Собираем всё вместе:

Объект-кандидат проверяется по трём признакам: число полей == 10, первое поле == 24, размер == ожидаемому size class. Затем по смещению routingIndexOffset достаётся routingIndex и проверяется, что в нём есть зарегистрированные маршруты. Совпало — перед нами живой *http.ServeMux нашего сервиса.

Параллельно inject складывает в упорядоченный TreeMap[uintptr, string] весь валидный UTF-8-контент объектов кучи:

Это — конфиги, токены, DSN-ы, любые строки, которые сервис держит в памяти.

handleFunc просто отдаёт собранную мапу адрес → строка :

Запускаем сервис, идём на /__injected — и получаем дамп строкового содержимого процесса. Ручку никто из нас не регистрировал; её дописал init пакета, который в сервисе даже не используется напрямую.

И отдельно про инструменты. Обычная сборка ( go build ) и go vet тревогу не поднимают — код компилируется и спокойно работает. Гонки здесь тоже нет: в чистом race-репорте пусто, потому что inject не пишет в общую память конкурентно. Но есть нюанс, важный для CI: флаг -race неявно включает checkptr — рантайм-проверку корректности арифметики указателей. И вот она этот эксплойт ловит — сборка с -race падает с фатальной ошибкой ровно на unsafe.Add(unsafe.Pointer(nil), obj.Address) :

То есть незаметным эксплойт остаётся только для «ванильного» пайплайна. Стоит собрать или прогнать тесты с -race (или явно с checkptr ) — и трюк с «нулевой базой» вскрывается. Это, кстати, готовый аргумент гонять -race / checkptr в CI (вернёмся к этому в части 7.2).

Дальше включайте фантазию: вместо «вывести строки» можно подменить хендлеры, проксировать трафик, утащить секреты.

Часть 7. Как с этим бороться

Корень проблемы — композитный конфиг в библиотеке . Поле DB *db.Config втащило весь пакет db (с его init ) в сервис, которому база не нужна.

Правило: в библиотеках не делайте структур-агрегатов из чужих пакетов . Пусть будет отдельный rest.Config , отдельный db.Config , и каждый сервис подключает только то, что реально использует. Это не спасёт сервисы, которым база нужна по-настоящему, но как минимум вынесет проблему из тех, кому она вообще ни к чему.

Что делает

vendoring ( go mod vendor )

Кладёт копии всех зависимостей в vendor/ . При обновлении изменения видны прямо в git diff — чужой inject() не проедет незамеченным в ревью.

deadcode

Ищет недостижимый код. В отличие от unused из golangci-lint, проверяет и экспортные методы, и используемые только в тестах (поэтому не годится для библиотек).

Упорядочивает импорты по заданным правилам — дисциплина в импортах помогает замечать лишнее.

govulncheck

Сканирует проект на известные уязвимости (NVD, GHSA, база Go vuln). Есть веб-интерфейс — пакет можно проверить до подключения.

checkptr

Рантайм-проверки корректности арифметики указателей. Именно она роняет наш эксплойт на unsafe.Add(unsafe.Pointer(nil), addr) . Включается флагом -race , поэтому достаточно гонять тесты/сборку с -race в CI.

Предыдущие меры — про то, чтобы зловред вообще не попал в сборку. Но можно поставить ещё один рубеж — на уровне ОС, исходя из того, что эксплойту физически нужно для работы .

Вспомним часть 6.1: inject не умеет читать кучу напрямую. Ему нужно снять дамп через debug.WriteHeapDump(f.Fd()) , а эта функция требует записываемый файловый дескриптор (по документации — обычный файл или сокет, не pipe). Поэтому эксплойт сначала зовёт os.CreateTemp(...) , который под капотом делает системный вызов openat(2) с флагом O_CREAT :

Если процесс не может создать файл в нужном месте — os.CreateTemp вернёт ошибку, objectsFromHeap отвалится с err , и inject просто молча выйдет (он глотает ошибку: if err != nil { return } ). Дампа кучи нет — ServeMux не найден — ручка не зарегистрирована. Цепочка рвётся на самом первом «грязном» сисколле.

Большинству сервисов запись на диск во время работы вообще не нужна (логи идут в stdout, состояние — в БД). Значит, можно отобрать у процесса право создавать файлы — либо целиком, либо разрешив запись только в один заранее известный путь.

Важно делать это снаружи , на уровне оркестратора, а не из кода сервиса. Соблазн «включить ограничение в начале main » не сработает: init indirect-зависимостей выполняется до main , так что любую защиту, поднятую из своего же кода, зловред в init просто опередит. Ограничение, навешенное контейнером/ядром ещё до старта процесса, этой дыры лишено.

seccomp-BPF — фильтр системных вызовов

seccomp-BPF режет по самим сисколлам. Вы описываете BPF-фильтр (обычно по allowlist-принципу, как в дефолтном профиле Docker) и запрещаете процессу вызывать openat / creat / open с флагами создания. Тогда любая попытка создать файл — включая os.CreateTemp из inject — упрётся в EPERM / EACCES ещё на уровне ядра.

Применяют это на уровне оркестратора:

Docker — --security-opt seccomp=profile.json с кастомным профилем без файлосоздающих сисколлов;

Docker — --security-opt seccomp=profile.json с кастомным профилем без файлосоздающих сисколлов;

Kubernetes — seccompProfile в securityContext пода;

Kubernetes — seccompProfile в securityContext пода;

плюс ортогональные меры — readOnlyRootFilesystem: true и emptyDir -том только туда, где запись реально нужна.

плюс ортогональные меры — readOnlyRootFilesystem: true и emptyDir -том только туда, где запись реально нужна.

readOnlyRootFilesystem сам по себе уже ломает наш эксплойт: os.TempDir() по умолчанию указывает в /tmp , и если корневая ФС только для чтения — создать там временный файл не выйдет.

Чем это отличается от мер выше

Гранулирование зависимостей, вендоринг и govulncheck не дают зловреду попасть в сервис. Ограничение сисколлов исходит из обратного допущения — «предположим, он уже внутри» — и отбирает у него инструменты. Это defense in depth: даже если вредонос проскочил ревью, без права создать файл он не снимет дамп кучи и не доберётся до ServeMux .

Из истории вытекает несколько практик, которые стоит закрепить в команде:

читайте диф зависимостей при обновлении — особенно если в нём появляется unsafe , runtime/debug , os.CreateTemp , работа с дескрипторами;

читайте диф зависимостей при обновлении — особенно если в нём появляется unsafe , runtime/debug , os.CreateTemp , работа с дескрипторами;

держите vendor/ под контролем ревью;

держите vendor/ под контролем ревью;

прогоняйте govulncheck в CI и проверяйте новые пакеты на vuln.go.dev до подключения;

прогоняйте govulncheck в CI и проверяйте новые пакеты на vuln.go.dev до подключения;

не тащите в библиотеки композитные конфиги — гранулируйте;

не тащите в библиотеки композитные конфиги — гранулируйте;

в проде запускайте сервисы с readOnlyRootFilesystem и урезанным seccomp-профилем — это дёшево и ломает целый класс атак, завязанных на запись файлов.

в проде запускайте сервисы с readOnlyRootFilesystem и урезанным seccomp-профилем — это дёшево и ломает целый класс атак, завязанных на запись файлов.

Цепочка атаки оказалась короткой и абсолютно «легальной» с точки зрения компилятора:

Библиотека держит композитный конфиг с полем DB *db.Config .

Библиотека держит композитный конфиг с полем DB *db.Config .

Тип db.Config тащит весь пакет db в граф сборки сервиса — как indirect -зависимость.

Тип db.Config тащит весь пакет db в граф сборки сервиса — как indirect -зависимость.

init пакета db выполняется , хотя сервис не вызывает из db ни строчки.

init пакета db выполняется , хотя сервис не вызывает из db ни строчки.

init снимает дамп кучи, через unsafe.Pointer находит http.ServeMux по разметке памяти и дописывает свою ручку.

init снимает дамп кучи, через unsafe.Pointer находит http.ServeMux по разметке памяти и дописывает свою ручку.

Обычная сборка и go vet тревогу не поднимают — спасает только сборка с checkptr (в том числе через -race ), которая роняет эксплойт на арифметике указателей.

Обычная сборка и go vet тревогу не поднимают — спасает только сборка с checkptr (в том числе через -race ), которая роняет эксплойт на арифметике указателей.

init и unsafe — мощные и полезные механизмы. Но ровно их «удобство» — неявность init и бесконтрольность типов у unsafe — превращает невинное обновление зависимости в RCE-подобный сценарий. Защита строится в два эшелона. Первый — не дать зловреду попасть в сборку: гранулируйте зависимости, читайте дифы, вендорите и сканируйте. Второй — на случай, если он всё же проскочил: урежьте процессу права на уровне ОС снаружи, через оркестратор. Конкретно этому эксплойту для работы нужно создать файл под дамп кучи — запретите создание файлов (seccomp-профиль, readOnlyRootFilesystem ), и цепочка порвётся на первом же сисколле.

Код для самостоятельного изучения — github.com/korableg/init-injection-example . Раскомментируйте inject() в db/drv.go , поднимите example-service и сходите на /__injected .

Спасибо за внимание.

← Cybersecurity