EnglishРусский Map

Сброс привилегий в Go

title
Сброс привилегий в Go
type
summary
summary
Самоограничение Go-программы при запуске - chroot, setrlimit, pledge/unveil, seccomp, Landlock - через x/sys/unix
tags
security, sandboxing, go, systems-programming
created
2026-07-21
updated
2026-07-21
lang
ru
translation_of
go-privdrop
source_updated
2026-07-21
translated
2026-09-01
translator
lllm/antigravity/gemini-3.7-flash-medium

Большинство программ работают с куда большими привилегиями, чем требует их задача. Чат-клиент, запущенный обычным пользователем, может прочитать его приватный SSH-ключ, хотя программе там делать нечего. Если в клиенте найдётся уязвимость, специально сформированное сообщение превратит этот доступ в утечку данных. В статье oxzi (go-privdrop) разбирается, как Go-программа может сбросить ненужные привилегии прямо на старте, чтобы последующий эксплойт нанёс гораздо меньший ущерб. Общий принцип описан в privilege-dropping; эта страница - практическое руководство именно для Go.

Подход отлично ложится на кодовую базу Go, поскольку все механизмы доступны через golang.org/x/sys/unix - без cgo и без вызовов внешних утилит. Две Linux-библиотеки, не входящие в x/sys/unix (seccomp и Landlock), также написаны на чистом Go.

Базовая модель

Привилегии, от которых вы отказались, вернуть нельзя. Если программа запретила себе доступ к файловой системе, открыть файл заново уже не получится - вызова для отмены этого действия не существует. Это ограничение диктует строгий порядок шагов: сначала запросить всё необходимое, затем последовательно сужать доступ. Метафора oxzi - перевёрнутый конус: начинаем сверху со всеми привилегиями, сбрасываем их по пути и продолжаем работу с абсолютным минимумом.

Канонический пример - демон, которому нужен привилегированный порт. Он стартует от root'а, биндит порт (сохраняя файловый дескриптор) и переключается на непривилегированного пользователя. Он продолжает принимать соединения на уже открытом порту, но больше не может слушать другие привилегированные порты. Опасная возможность была использована один раз и тут же выброшена.

chroot + смена пользователя (любая POSIX-система)

Классическая схема существует с начала 1990-х и работает на любой POSIX-совместимой ОС. Вызов chroot(2) меняет корень процесса: после Chroot("/var/empty") и последующего Chdir("/") попытка прочитать /etc/passwd на самом деле пойдёт по пути внутри /var/empty. Сам по себе chroot не является границей безопасности (злоумышленник может выбраться из голой chroot-среды), но служит базовым кирпичиком. Затем setresuid/setresgid выставляют реальный, эффективный и сохранённый ID в непривилегированного пользователя, а setgroups урезает список дополнительных групп.

uid, gid, _ := uidGidForUserGroup("demoworker", "demoworker") // lookup FIRST
unix.Chroot("/var/empty")
unix.Chdir("/")
unix.Setgroups([]int{gid})
unix.Setresgid(gid, gid, gid)
unix.Setresuid(uid, uid, uid)

Здесь есть две ловушки с порядком действий. Поиск пользователя и группы читает /etc/passwd и /etc/group, поэтому он обязан произойти до chroot'а: если сначала уйти в /var/empty, поиск завершится ошибкой "no such file or directory". Кроме того, пакет os/user в Go кэширует текущего пользователя без возможности сбросить этот кэш: user.Current() всегда возвращает того, кто запустил процесс изначально, поэтому полагаться на него после смены пользователя нельзя. Всей схеме на старте нужны права root'а, что подходит для демонов, но делает её неприменимой для пользовательских GUI-приложений.

setrlimit - ограничения ресурсов

Даже сбросив root, процесс всё ещё может сжигать ресурсы. Парсер, атакованный через billion laughs, способен наглухо загрузить процессорное ядро или съесть всю память. Вызов setrlimit(2) задаёт жёсткие лимиты: RLIMIT_CPU ограничивает процессорное время в секундах, а RLIMIT_DATA - размер сегмента данных. При превышении обоих процесс аварийно завершается. В демо-примере RLIMIT_DATA ограничен 10 МиБ. Рекомендация простая: выставлять лимиты достаточно высокими, чтобы штатная работа в них никогда не упиралась - тогда они служат страховкой, а не функциональным ограничением.

OpenBSD: pledge и unveil

pledge(2) - это фильтр системных вызовов в виде строки обещаний: разделённые пробелами ключевые слова вроде stdio, rpath (файловая система только на чтение), exec. Набор обещаний можно только сужать, но никогда не расширять: повторный вызов с более коротким списком отбирает лишние права. При нарушении ядро убивает процесс, если в списке обещаний нет error - с ним запрещённый вызов просто вернёт ошибку. Механизм доступен в x/sys/unix через Pledge / PledgePromises / PledgeExecpromises, а команда GOOS=openbsd go doc -all golang.org/x/sys/unix покажет документацию даже под Linux.

unix.PledgePromises("stdio rpath error")
// ... read the input file ...
unix.PledgePromises("stdio error") // tighten: file reads no longer needed

unveil(2) задаёт список разрешённых путей в файловой системе. Каждый вызов открывает один путь с указанными правами (read/write/exec/create); финальный вызов с двумя пустыми аргументами фиксирует набор. Если открыть доступ только к Download, попытка обхода путей вроде ../.ssh/id_ed25519 провалится, даже если файл существует - ровно то, что требовалось чат-клиенту из примера в начале.

unix.Unveil("Download", "rwc")
unix.UnveilBlock()

Linux: seccomp BPF и Landlock

Linux seccomp BPF передаёт ядру программу на языке Berkeley Packet Filter, которая решает, какие системные вызовы разрешены, ориентируясь на их номер и часть аргументов. Механизм гранулярный, но составлять такие фильтры вручную мучительно: точный набор системных вызовов меняется при обновлении Go или зависимостей и зависит от процессорной архитектуры. Пакет на чистом Go github.com/elastic/go-seccomp-bpf берёт сборку фильтров на себя. Поверх него oxzi написал github.com/oxzi/syscallset-go, переносящий синтаксис групп SystemCallFilter из systemd (@system-service, @basic-io) в строковый API, похожий на pledge. В отличие от pledge, группы error здесь нет: любое нарушение сразу убивает процесс.

syscallset.LimitTo("@system-service")
// ... read input ...
syscallset.LimitTo("@basic-io")

Landlock - это ответ Linux на unveil, дополненный сетевой изоляцией; в Go он реализован через github.com/landlock-lsm/go-landlock. Он ограничивает доступ к путям файловой системы, а в свежих версиях - ещё и входящий/исходящий TCP по портам. Landlock версионируется и расширяется с релизами ядра, поэтому V5.BestEffort() откатывается к тому, что реально поддерживает текущее ядро.

landlock.V5.BestEffort().RestrictPaths(landlock.RWDirs("Download"))
landlock.V5.BestEffort().RestrictNet(landlock.ConnectTCP(443)) // 80 denied, 443 allowed

landlock.BindTCP ограничивает порты, которые можно слушать - страховка на случай, если злоумышленник попытается запустить шелл, слушающий сеть.

Вне рамок материала

Linux cgroups, capsicum(4) из FreeBSD и фильтрация сети через cgroup eBPF в исходной статье не рассматриваются. Исходники примеров опубликованы на codeberg.org/oxzi/go-privsep-showcase.

Главный вывод oxzi: для большинства программ вопрос звучит не взломают ли их, а когда. Сброс привилегий решает, сколько контроля получит атакующий, когда это произойдёт.

См. также

  • privilege-dropping - общий принцип наименьших привилегий при старте, который реализуют эти механизмы
  • sandboxing-ai-agents - те же уровни (изоляция файловой системы, фильтрация системных вызовов) для ограничения агентов написания кода
  • bubblewrap-dev-env - изоляция в Linux на базе namespace'ов: внешняя обёртка, делающая то же самое снаружи процесса
  • syscall-binary-rewriting - более тяжёлая техника перехвата системных вызовов, чем seccomp BPF
  • ephemeral-credentials - дополняющая защита: обесценивание утёкшего секрета, а не только запрет доступа к нему
  • oxzi-blog - блог автора