5. Path traversal
Введение: выход за границы виртуальной файловой системы
В Unix-подобных операционных системах всё является файлом, а доступ к файловой системе строится вокруг единого древовидного графа inode. Когда бэкенд-приложение реализует функции загрузки аватаров, отдачи статических отчетов, экспорта PDF или чтения локализованных шаблонов, разработчик почти всегда оперирует концепцией «базовой доверенной директории» (например, /var/app/uploads/).
Но что произойдет, если пользователь вместо безобидного имени файла report.pdf передаст строку ../../../../etc/passwd?
Если сервис склеит переданную строку с базовым путем без исчерпывающей проверки, возникает критическая уязвимость Path Traversal (Обход путей каталога).
На уровне ядра операционной системы ядро не знает о концепции «бизнес-изоляции» вашего приложения. Когда процесс выполняет системный вызов open(), подсистема виртуальной файловой системы (VFS) ядра Linux последовательно обходит кэш записей каталогов (dentry cache). Встречая компоненты . (текущий каталог) и .. (родительский каталог), ядро добросовестно переходит на шаг вверх по дереву каталогов, разрешает символические ссылки и проверяет только стандартные права доступа POSIX. Если прав процесса (www-data или app-user) достаточно для чтения файла, ядро вернет дескриптор, игнорируя границы папки /uploads/.
flowchart TD
subgraph Client["Клиентский запрос"]
A["1 - Входящий HTTP запрос"] --> B["2 - Извлечение URL параметра"]
end
subgraph Validation["Валидация и нормализация в Go"]
B --> C["3 - URL декодирование<br/>удаление %2e %2f"]
C --> D["4 - filepath.Clean<br/>лексическая нормализация"]
D --> E["5 - Проверка префикса<br/>базовой директории"]
E --> F["6 - Проверка симлинков<br/>os.Lstat"]
end
subgraph OS["Ядро Linux VFS & I/O"]
F --> G["7 - os.Open<br/>системный вызов open() / openat2()"]
G --> H["8 - Чтение данных<br/>io.Copy в сокет"]
end
style Client fill:#0f172a,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
style Validation fill:#1e293b,stroke:#10b981,stroke-width:1px,color:#f8fafc
style OS fill:#0f172a,stroke:#f59e0b,stroke-width:1px,color:#f8fafc
style E fill:#1e293b,stroke:#ffd93d,stroke-width:2px,color:#f8fafc
style F fill:#1e293b,stroke:#ff6b6b,stroke-width:2px,color:#f8fafc
style H fill:#1e293b,stroke:#4ecdc4,stroke-width:2px,color:#f8fafc
Механика в Go: filepath.Clean, filepath.Join и их ограничения
Стандартная библиотека Go предоставляет функции filepath.Clean и filepath.Join. Начинающие разработчики часто ошибочно полагают, что вызов filepath.Clean(userInput) или filepath.Join(baseDir, userInput) гарантирует защиту от выхода за пределы каталога.
Это опасное заблуждение. Функции пакета path/filepath выполняют исключительно лексическую нормализацию текстовой строки:
- Убирают повторяющиеся избыточные разделители (
//->/). - Сворачивают относительные сегменты
./и../до канонического строкового вида. - Удаляют завершающие слэши.
- Не разрешают символические ссылки (symlinks) на реальной файловой системе.
- Не проверяют физическое существование файла или прав доступа.
- Не защищают от обхода через двойное кодирование (
%2e%2e%2fили устаревшие UTF-8 оверлонги вроде..%c0%af), если декодирование произведено некорректно.
Если базовая папка равна /var/app/data, а пользователь передал ../../etc/passwd, то filepath.Join("/var/app/data", "../../etc/passwd") честно вычислит лексический результат: /var/etc/passwd. Защита не сработала.
Идиоматичный шаблон безопасного хендлера файлов в Go
Чтобы надежно запереть чтение файлов внутри целевого каталога, алгоритм обязан включать лексическую нормализацию, получение абсолютных путей, строгую проверку префикса и запрет символических ссылок:
package filesec
import (
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
)
// SecureFileHandler безопасно отдаёт файлы из строго ограниченной директории
func SecureFileHandler(baseDir string) http.HandlerFunc {
// 🔒 Абсолютный путь к базовой директории для надежных побайтовых сравнений
absBaseDir, err := filepath.Abs(baseDir)
if err != nil {
panic(fmt.Sprintf("invalid base dir: %v", err))
}
return func(w http.ResponseWriter, r *http.Request) {
requestPath := r.URL.Query().Get("file")
if requestPath == "" {
http.Error(w, "missing file parameter", http.StatusBadRequest)
return
}
// 1 - Валидация нулевого байта: в Си/VFS null-байт обрезает строку пути
if strings.Contains(requestPath, "\x00") {
http.Error(w, "invalid characters", http.StatusBadRequest)
return
}
// 2 - Лексическая очистка пути
cleanPath := filepath.Clean(requestPath)
// 3 - Сборка полного абсолютного пути
fullPath := filepath.Join(absBaseDir, cleanPath)
// 4 - 🔒 Жёсткая проверка префикса. Это ключевой этап программной защиты.
// Добавляем PathSeparator, чтобы предотвратить атаку совпадения префикса имени папки:
// /var/app/uploads_secret не должен матчиться на базу /var/app/uploads!
expectedPrefix := absBaseDir + string(os.PathSeparator)
if !strings.HasPrefix(fullPath, expectedPrefix) && fullPath != absBaseDir {
http.Error(w, "forbidden path", http.StatusForbidden)
return
}
// 5 - Проверка символических ссылок и метаданных через os.Lstat (не переходит по симлинкам!)
info, err := os.Lstat(fullPath)
if err != nil {
if os.IsNotExist(err) {
http.Error(w, "file not found", http.StatusNotFound)
return
}
http.Error(w, "access denied", http.StatusForbidden)
return
}
// Запрещаем открытие симлинков, ведущих за пределы песочницы
if info.Mode()&os.ModeSymlink != 0 {
http.Error(w, "symlinks not allowed", http.StatusForbidden)
return
}
// Запрещаем прямой листинг директорий
if info.IsDir() {
http.Error(w, "directories not allowed", http.StatusForbidden)
return
}
// 6 - Безопасная отдача файла клиенту
http.ServeFile(w, r, fullPath)
}
}
Под капотом: syscalls, dentry cache и проблема TOCTOU
Даже идеальная пользовательская проверка путей через filepath.Clean и os.Lstat страдает от фундаментальной уязвимости многозадачных ОС: Time-of-Check to Time-of-Use (TOCTOU).
Представьте временной промежуток между двумя системными вызовами:
- Шаг проверки: Сервер вызывает
os.Lstat("/var/uploads/avatar.png")и убеждается, что это обычный файл. - Интервал времени: Процесс Go уступает тред операционной системы другому процессу.
- Атака гонки: Параллельный процесс атакующего (или эксплуатация race condition) удаляет
avatar.pngи мгновенно создает симлинк с тем же именем, указывающий на/etc/shadow. - Шаг использования: Сервер вызывает
os.Open("/var/uploads/avatar.png")и передает содержимое суперсекретного файла наружу!
Решение на уровне ядра: Linux 5.6+ и системный вызов openat2
В современных ядрах Linux для решения проблемы TOCTOU был разработан специализированный системный вызов openat2 с набором флагов разрешения путей RESOLVE_*. Флаг RESOLVE_BENEATH заставляет ядро VFS проверять границы пути атомарно, прямо во время обхода dentry cache: если любой компонент пути пытается выйти выше переданного дескриптора базового каталога (включая попытки побега через симлинки), системный вызов немедленно завершается с ошибкой EXDEV.
В Go доступ к этим возможностям ядра реализуется через пакет golang.org/x/sys/unix:
package filesec_unix
import (
"fmt"
"os"
"path/filepath"
"golang.org/x/sys/unix"
)
// OpenSecure открывает файл атомарно на уровне ядра без уязвимости к TOCTOU
func OpenSecure(baseDir, requestPath string) (*os.File, error) {
absBase, err := filepath.Abs(baseDir)
if err != nil {
return nil, err
}
// 1. Открываем файловый дескриптор базовой доверенной директории
dirFd, err := unix.Openat(unix.AT_FDCWD, absBase, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
if err != nil {
return nil, fmt.Errorf("open base dir: %w", err)
}
defer unix.Close(dirFd)
// 2. 🔒 Настраиваем флаги openat2 для атомарного удержания процесса внутри dirFd.
// Поддерживается ядром Linux 5.6+.
var how unix.OpenHow
how.Flags = unix.O_RDONLY | unix.O_CLOEXEC
// RESOLVE_BENEATH запрещает выход за пределы dirFd
// RESOLVE_NO_SYMLINKS блокирует любые символические ссылки
// RESOLVE_NO_MAGICLINKS блокирует магические ссылки (/proc/self/...)
how.Resolve = unix.RESOLVE_BENEATH | unix.RESOLVE_NO_SYMLINKS | unix.RESOLVE_NO_MAGICLINKS
// 3. Ядро атомарно резолвит путь относительно dirFd без риска гонки TOCTOU
fd, err := unix.Openat2(dirFd, requestPath, &how)
if err != nil {
return nil, fmt.Errorf("openat2 failed: %w", err)
}
return os.NewFile(uintptr(fd), filepath.Base(requestPath)), nil
}
Сравнение подходов: Go, PHP, C/C++
Взглянем на то, как архитектура языков программирования влияет на обработку путей:
| Аспект | Go | PHP | C / C++ |
|---|---|---|---|
| Базовая защита | filepath.Clean + strings.HasPrefix |
realpath() + strpos() |
realpath() или openat() |
| Символические ссылки | Требует явной проверки os.Lstat или RESOLVE_NO_SYMLINKS |
realpath автоматически разрешает |
O_NOFOLLOW в системном вызове open() |
| TOCTOU mitigation | x/sys/unix.Openat2 с RESOLVE_BENEATH |
Слабая (только в пространстве пользователя) | openat() + fchdir() или openat2 |
| Аллокации | filepath.Clean создаёт новые строки в куче |
Строки неизменяемы, аллоцируются при конкатенации | strdup, ручное управление памятью |
В Go преимущество заключается в строгой типизации и отсутствии «магии» вроде include или require с автоматическим резолвингом путей. Однако ответственность за валидацию полностью ложится на разработчика.
Итог
- Суть уязвимости: Path Traversal возникает, когда непроверенные пути передаются в
os.Openилиhttp.FileServer. Подсистема VFS ядра операционной системы слепо следует указаниям..и разрешает символические ссылки. - Ограниченность лексической нормализации: Функции
filepath.Cleanиfilepath.Joinне защищают от выхода за пределы корневой папки. Они лишь приводят строковое представление пути к каноническому виду. - Пятиступенчатая валидация: Надежная программная защита требует цепочки: проверка нулевого байта
\x00->filepath.Clean->filepath.Abs-> проверкаstrings.HasPrefixс разделителем пути -> проверка симлинков черезos.Lstat. - Решение проблемы TOCTOU: Гонка между проверкой файла и его открытием на уровне ядра Linux решается с помощью системного вызова
openat2с флагомRESOLVE_BENEATHчерез пакетgolang.org/x/sys/unix. - Механическое сочувствие: Нормализация путей создает аллокации строк в куче. В высоконагруженных сервисах кэшируйте валидированные пути в
sync.Mapи используйте пулы буферов для снижения нагрузки наGC.
Что происходит, когда сервис восстанавливает сложные объекты из бинарных или текстовых потоков данных, полученных из недоверенных источников?
В следующей лекции мы разберем: 6. Deserialization уязвимости.