Случай #1:
Как известно, при вызове exec происходит завершение всех нитей вызывающего кроме той, что вызвала system. При этом открытые дескрипторы клонируются в новый процесс (если у них не указан флаг FD/SOCK_CLOEXEC).
Представим следующую гипотетическую ситуацию:
У нас есть некая программа, которая получает от кого-то строку, в которой содержатся какие-то команды, которые нужно запустить. Пусть, к примеру, это будет какое-то подобие супервизора.
Положим, что так как эта строка может содержать переменные среды, какие-то выражения подстановки и shell-related команды и синтаксис программист, написавший эту программу посчитал, что самым оптимальным образом будет запускать эту строку с помощью команды system, которая не требует списка аргументов, как семейство exec*. Плюс, так как system «под капотом» вызывает sh -c ...
, то автоматически подставятся переменные, а shell-related команды и синтаксис будут работать как нужно. Так вот, глянем исходник system, например, для glibc:
<Сначала хотел показать исходник glibc, но тут слишком дофига получается кода, поэтому сжато: clone + exec + waitpid, кстати ман говорит про fork + exec + waitpid, что не совсем то же самое>
При вызове clone/fork происходит копирование открытых дескрипторов, которые наследует процесс, который запустил system. Предположим, что кто-то передал для запуска строку, содержащую запуск программы (назовем ее evil), выполняющей следующие действия:
if (fork() != 0)
exit(0);
Что превращает ее в отвязанного от родителя демона. Однако этот fork тоже наследует все открытые хэндлеры, что приводит нас к тому, что этот демонутый процесс evil получил копию всех открытых хендлеров супервизора, однако стал от него отвязан (system вернул 0, супервизор считает, что работа выполнена).
Теперь предположим, что это супервизор с управлением по сети. То есть супервизор открывает серверный сокет и слушает команды, которые ему приходят извне. Не будем обсуждать защищенность передачи или канала связи, всё можно сломать, суть сейчас не в этом. Суть в том, что на момент запуска system супервизор владел открытым серверным сокетом (!). Да, по совести и по правилам при его создании ему нужно было выставить SOCK_CLOEXEC, но скажем честно, кто своим серверным сокетам выставляет флаг, о котором даже в мануалах на socket из 153 строк отдано 2 (!), т.е. всего 1.3%?
Таким образом, пройдя через всю цепочку форков копия серверного сокета оказывается у демонутого evil:
super 9053 alex 3u IPv4 11458560 0t0 TCP *:34002 (LISTEN)
super 9053 alex 4u IPv4 11464960 0t0 TCP localhost:34002->localhost:41308 (ESTABLISHED)
evil 9257 alex 3u IPv4 11458560 0t0 TCP *:34002 (LISTEN)
Положим, что процесс супервизора по какой-то причине завершился. Ну, например, выполнив свою работу или по ошибке. И теперь начинается самая мякотка. Ведь в системе уже открыт слушающий на порту 34002 сокет! И принадлежит он программе evil! Таким образом она может прикинуться сервером, а супервизор, в свою очередь, не сможет вернуть свой сокет, поэтому система не даст ему открыть второй сокет на том же порту без REUSEPORT:
SO_REUSEPORT (since Linux 3.9)
Permits multiple AF_INET or AF_INET6 sockets to be bound to an identical socket address. This option must be set on each socket (including the first socket) prior to calling bind(2) on the socket. To prevent port hijacking, all of the pro‐
cesses binding to the same address must have the same effective UID. This option can be employed with both TCP and UDP sockets.
В общем случае, когда какая-то программа-сервер запускается и получает ALREADYINUSE то надо бы сказать мол, «не не могу работать, наверное другая копия меня уже запущена» и выключиться (ну или ожидать его освобождения).
Мы же не хотим port hijacking, верно? А вот все равно профукали. Ну и теперь evil может делать что угодно. Может получать данные от клиентов, может им даже что-то отвечать,может получать список команд для запуска и запускать вместо них что угодно, сообщая наверх что всё хорошо.
Собственно, вопрос #1: почему на серверный сокет автоматически при создании не вешается FD_CLOEXEC?
Случай #2:
Мы - супервизор, запускающий программы, забудем про сетевое взаимодействие, мы сами знаем, что нам запускать. Запускают нас от root, для того, чтобы мы могли делать какую-то очень важную вещь, ну, например, читать какой-то root-owned файл:
-rw------- 1 root root 7 июн 5 23:51 rootonly
Изобразим супервизор упрощенно:
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.h>
#include <errno.h>
#include <pwd.h>
static void dowork(void) {
int pid = fork();
int status;
switch (pid) {
case -1 : exit(EXIT_FAILURE);
case 0 :
if (setuid(1000) == -1) {
printf("u:%u\n", errno);
}
if (seteuid(1000) == -1) {
printf("e:%u\n", errno);
}
execl("./hijack_evil.elf", "./hijack_evil.elf", NULL);
printf("cannot run\n");
exit(EXIT_FAILURE);
default :
wait(&status);
}
}
int main(void) {
FILE * fileroot = fopen("/tmp/rootonly", "r");
printf("I am %s, #%u/%u\n", getpwuid(getuid())->pw_name, geteuid(), getuid());
dowork();
fclose(fileroot);
return 0;
}
gcc hijack_root.c -o hijack_root.elf
И программу, которой нельзя давать рутовые права:
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.h>
#include <pwd.h>
int main(void) {
printf("I am %s, #%u/%u\n", getpwuid(getuid())->pw_name, geteuid(), getuid());
// my stdin is 0, stdout is 1, stderr is 2, therefore...
char buf[1024];
read(3, buf, 1024);
printf("%s\n", buf);
return 0;
}
gcc hijack_evil.c -o hijack_evil.elf
Проверяем, что просто так файл прочитать нельзя:
alex@ThinkPad-L560:~$ cat /tmp/rootonly
cat: /tmp/rootonly: Отказано в доступе
alex@ThinkPad-L560:~$ echo $USER/$UID
alex/1000
И запускаем супервизор:
$ sudo ./hijack_root.elf
I am root, #0/0
I am alex, #1000/1000
SECRET
При этом мы не можем ставить FD_CLOEXEC на каждый хендлер, открываемый в супервизоре, потому что он может понадобиться для целей супервизора в его форках. Соответственно вопрос #2: почему внешние программы, запускаемые через exec наследуют хендлеры? Ведь очевидно, что это может быть использовано только для «угона» файлов