Введение в Landlock
В предыдущей статье мы показали, как можно сделать приложения безопаснее с помощью seccomp, который позволяет ограничивать список системных вызовов, доступных процессу или потоку. В этот раз разберём, как использовать Landlock LSM, чтобы ещё сильнее поднять уровень безопасности приложения.
Вступление: что такое Landlock?
Landlock - это модуль безопасности Linux, который даёт возможность ограничивать доступ к файловой системе. Если обратиться к документации, там говорится:
Цель Landlock - дать возможность ограничивать «окружающие» права (например, глобальный доступ к файловой системе) для набора процессов. Поскольку Landlock - стекуемый LSM, он позволяет создавать безопасные песочницы как дополнительные уровни защиты поверх существующих системных механизмов контроля доступа. Такие песочницы помогают уменьшить влияние уязвимостей или неожиданных/вредоносных действий в пользовательских приложениях. Любой процесс, включая непривилегированный, может самостоятельно и безопасно сузить свои права.
Если сказать проще, Landlock позволяет пользовательскому приложению создать набор правил, который будет ограничивать его доступ к файловой системе. Чтобы воспользоваться Landlock, приложение сначала должно сформировать ruleset - набор правил, описывающий, что ему разрешено. Доступные типы разрешений и их смысл разобраны в документации.
// Define a new ruleset
struct landlock_ruleset_attr ruleset_attr = {
.handled_access_fs =
LANDLOCK_ACCESS_FS_EXECUTE |
LANDLOCK_ACCESS_FS_WRITE_FILE |
LANDLOCK_ACCESS_FS_READ_FILE |
LANDLOCK_ACCESS_FS_READ_DIR |
LANDLOCK_ACCESS_FS_REMOVE_DIR |
LANDLOCK_ACCESS_FS_REMOVE_FILE |
LANDLOCK_ACCESS_FS_MAKE_CHAR |
LANDLOCK_ACCESS_FS_MAKE_DIR |
LANDLOCK_ACCESS_FS_MAKE_REG |
LANDLOCK_ACCESS_FS_MAKE_SOCK |
LANDLOCK_ACCESS_FS_MAKE_FIFO |
LANDLOCK_ACCESS_FS_MAKE_BLOCK |
LANDLOCK_ACCESS_FS_MAKE_SYM |
LANDLOCK_ACCESS_FS_REFER |
LANDLOCK_ACCESS_FS_TRUNCATE,
};
// Call into the kernel to create the ruleset
int ruleset_fd = syscall(SYS_landlock_create_ruleset,
&ruleset_attr, sizeof(ruleset_attr));
Теперь можно начинать добавлять правила в этот ruleset, чтобы ограничить доступ к файловой системе. Для этого сначала создаётся структура landlock_path_beneath_attr, в которой задаются разрешения, которые вы готовы выдать процессу, и файловый дескриптор родительского каталога. После этого правило добавляется в ruleset с помощью системного вызова landlock_add_rule.
Пример:
struct landlock_path_beneath_attr path_beneath = {
.allowed_access =
LANDLOCK_ACCESS_FS_READ_FILE |
LANDLOCK_ACCESS_FS_READ_DIR,
};
path_beneath.parent_fd = open("/my_app_data", O_PATH | O_CLOEXEC);
err = landlock_add_rule(ruleset_fd, LANDLOCK_RULE_PATH_BENEATH,
&path_beneath, 0);
close(path_beneath.parent_fd);
Дальше остаётся только загрузить ruleset в ядро - и всё готово.
// forbid this thread from getting new privileges prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)); landlock_restrict_self(ruleset_fd, 0)); close(ruleset_fd);
Пример уязвимого Go-приложения
Теперь посмотрим, как можно применить Landlock, чтобы защитить уязвимое Go-приложение. Допустим, у нас есть такая программа (это просто учебный пример, не предназначенный ни для продакшена, ни как образец для собственных приложений):
package main
import (
"io"
"log"
"net/http"
"os"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
log.Println(r.RequestURI)
path := r.URL.Query().Get("path")
f, err := os.Open(path)
if err != nil {
log.Println(err)
w.WriteHeader(http.StatusNotFound)
return
}
defer f.Close()
buf, err := io.ReadAll(f)
if err != nil {
log.Print(err)
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Write(buf)
})
http.ListenAndServe(":9999", nil)
}
Это приложение просто читает значение параметра path из запроса, открывает соответствующий файл и отдаёт клиенту его содержимое. Проблема очевидна: можно прочитать любой файл в системе. Например:
$ curl http://localhost:9999/?path=/etc/passwd root:x:0:0:root:/root:/bin/bash daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin ...
Смягчаем уязвимость с помощью Landlock
Теперь мы собираемся подпилить приложение так, чтобы оно использовало Landlock и имело доступ только к каталогу, где лежат его данные. Для простоты это будет текущий каталог, из которого мы его запускаем.
func main() {
err := landlock.V3.BestEffort().RestrictPaths(
landlock.RODirs("."),
)
if err != nil {
log.Fatal(err)
}
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
log.Println(r.RequestURI)
path := r.URL.Query().Get("path")
f, err := os.Open(path)
if err != nil {
log.Println(err)
w.WriteHeader(http.StatusNotFound)
return
}
defer f.Close()
buf, err := io.ReadAll(f)
if err != nil {
log.Print(err)
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Write(buf)
})
http.ListenAndServe(":9999", nil)
}
Сначала мы выбираем последнюю версию ABI Landlock, то есть V3, а затем вызываем BestEffort, который вернёт максимально строгую конфигурацию для этой ABI. После этого вызываем RestrictPaths, который добавит в ruleset подходящее правило, ограничивающее доступ текущим каталогом.
Попробуем снова запустить приложение:
$ curl -v http://localhost:9999/?path=/etc/passwd * Uses proxy env variable no_proxy == 'localhost,127.0.0.0/8,::1' * Trying 127.0.0.1:9999... * Connected to localhost (127.0.0.1) port 9999 (#0) > GET /?path=/etc/passwd HTTP/1.1 > Host: localhost:9999 > User-Agent: curl/8.0.1 > Accept: */* > < HTTP/1.1 404 Not Found < Date: Mon, 12 Jun 2023 11:28:10 GMT < Content-Length: 0 < * Connection #0 to host localhost left intact
В другой консоли, где наше приложение пишет логи, видим:
2023/06/12 11:20:19 /?path=/etc/passwd 2023/06/12 11:20:19 open /etc/passwd: permission denied
Как видно, приложение больше не может прочитать файл /etc/passwd. Landlock неявно смягчил уязвимость. Вместе seccomp и Landlock могут быть очень эффективной комбинацией мер безопасности для снижения рисков в приложениях, и для обоих существуют биндинги под разные языки.