Process Identifier Preservation Society и Libvirt

Всем известно, что PID в системе крайне мало (см. cat /proc/sys/kernel/pid_max), и их надо беречь. Первый человек, который открыто стал заботиться о PID, был Lennart Poettering, в его первом анонсе systemd (см. "Keeping the First User PID Small"), и к его призыву присоединились и другие, в начале 2014 года организовав Process Identifier Preservation Society с рекомендациями для программистов о пидосбережении.

Пока кто-то посмеивался, привычно наматывая портянки из bash-скриптов, пришел shellshock, и народ, наконец-то, проняло. Конечно надо учесть, что конкретно у Bash проблемы в ДНК, и его разработка в одиночку ведется человеком, застрявшим даже не в 1990х, а в самом дремучем юниксвэе 1980х, даже без системы контроля версий, но тем не менее - рекомендации PID Preservation Society наполнились смыслом, до тех пор непонимаемым некоторыми коллегами-аналитиками.

К сожалению, когда вспоминают о том, как правильно запускать другие процессы из приложений, то в антипримеры первым делом всплывали пара известных программ. Ну, во-1, это Anaconda, и с ней все и так понятно. А вот во-2, что для нас всегда было грустно слышать, это Libvirt. Например, обратите еще раз внимание на седьмую страницу из уже приводимой в пример довольно радикальной презентации с Linux Plumber Conference 2012 нашего коллеги Andy Grover. Инженер Red Hat, Daniel P. Berrangé, расстроившись из-за того, что Libvirt одним махом записали в субоптимальный код, решил рассказать про историю того, как с процессами управляется Libvirt, и почему было сделано именно так, а не по-другому.

История API запуска процессов в Libvirt без использования shell

Если говорить о запуске дочерних процессов, то у Libvirt очень интересная история - давнее игнорирование стандартных методов и вызовов библиотечных функций ANSI C system / popen и использование fork / exec с помощью неких высокоуровневых самописных врапперов. Для этого было несколько причин, некоторые очевидные, некоторые нет:

  1. Отказ от использования shell. Командная строка, передаваемая в запускаемые программы, часто содержит данные, введенные пользователем. Правильно обработать ее, чтобы заблокировать вредоносный shell-код, это нетривиальная задача. Отказ от использования shell избавляет от целого класса таких атак, и был высокоприоритетной задачей.
  2. Потокобезопасная работа в файловыми дескрипторами. Обычно, это ошибка, когда дочерним процессам позволяется наследовать файловые дескрипторы. Из-за ошибки при дизайне Unix специфицирует, что файловые дескрипторы наследуются по умолчанию при exec, из-за чего требуется выставлять флаг O_CLOEXEC чтобы предотвратить наследование. Если не использовать нестандартные расширения Glibc, то установка O_CLOEXEC может вызвать race condition в многопоточных программах, использующих system / popen. Единственные переносимый вариант гарантировать на 100%, что не будет утечек файловых дескрипторов в дочерние процессы, это выполнить массовое закрытие всех файловых дескрипторов до fork / exec.
  3. Безопасная обработка сигналов. Команды system / popen не сбрасывают обработчики сигналов после fork(). Таким образом есть риск, что обработчики сигналов, зарегистрированные программой, будут вызваны между fork / exec, при выполнении внешних команд. В зависимости от того, что эти обработчики сигналов делают, это может быть серьезной проблемой.
  4. Предоставление лучшего API. Команды system / popen просто использовать в простых случаях, но они не помогут в более сложных сценариях. Например, сборка списка аргументов командной строки часто требует большого количества операций со строками и массивами.


Насчет shellshock

В свете проблемы с shellshok в bash мы довольно счастливы, что Libvirt в основном не использует system / popen. Есть лишь два места, где, как я помню, Libvirt использует shell.

Во-1, когда используется транспорт SSH для API удаленного доступа, то клиент Libvirt логинится на удаленном хосте, чтоб создать туннель до демона Libvirt, и это приводит к использованию shell. Это может лишь быть использовано для shellshock, если администратор попытался ограничить права пользователя с помощью опции в конфиге SSH ForceCommand. Для аккаунтов Libvirt это довольно непопулярное решение, т.к. предоставление доступа к демону Libvirt подразумевает привилегии, эквивалентные суперпользователю, и таким образом мало чего можно добиться ограничениями в SSH.

Во-2, служба libvirt-guests, которая запускается init-системой, чтобы приостановить гостевые системы при выключении, написана на shell-скриптах, и таким образом потенциально уязвима для shellshock. Риск может возникнуть, если непривилигированный пользователь будет иметь возможность менять имя гостя. Например, гость с названием “() { :;}; echo vulnerable”. К счастью тестирование до сих пор показывало, что невозможно использовать эксплойт в контексте выполнения libvirt-guests, даже при злонамеренно измененном имени гостевой системы, т.к. большинство операций использует UUID.

Всего несколько месяцев назад код в Libvirt, ответственный за network filtering, использовал автоматически сгенерированные огромные скрипты на shell чтобы запускать iptables. Мы не анализировали этот устаревший код, чтоб проверить уязвим ли он, но считается, что это маловероятно, т.к. единственные строки, вводимые пользователем, были имена цепочек iptables, а Libvirt их строго проверяет. В любом случае из актуальных версий Libvirt этот код уже удален, и вместо этого он общается с firewalld через DBus API.

Конечно, вполне возможно, что другие внешние программы, с которыми общается Libvirt, в свою очередь используют shell и могут быть уязвимы. Но как минимум, области, за которые ответственнен Libvirt, можно считать безопасными по отношению к shellshock.

График изменений API запуска процессов

После исключения system / popen, оставшиеся варианты запуска внешних программ, это fork / exec или, возможно, posix_spawn. API posix_spawn, на самом деле, довольно современно в смысле предоставляемой гибкости, но все еще обладает высокой ценой использования с точки зрения требуемых им аргументов функции. Таким образом за эти годы в Libvirt появился высокоуровневый API вокруг fork / exec, который сейчас используется везде в его кодовой базе. Нижеследующая история проливает свет на то, как API развился в его текущую форму и текущий функционал.

  • 2007.02 – qemudStartVMDaemon(). Когда драйвер QEMU был впервые добавлен в Libvirt, была добавлена и функция qemudStartVMDaemon(), чтобы упростить запуск QEMU с помощью fork / exec. Заметными действиями этого враппера были подключения stdio дочернего процесса к пайпам, и затем закрытие всех прочих дескрипторов. Это предотвращало опасное использование shell и утечку файловых дескрипторов. Реализация функции не допускала повторное использование, т.к. в ней был код для преобразования конфига QEMU в массив argv.
  • 2007.02 – qemudExec(). Когда в драйвер QEMU была добавлена возможность запуска dnsmasq, то qemudStartVMDaemon() была переписана в функцию qemudExec(). Это был первый в Libvirt повторно используемый враппер вокруг fork / exec. Получая массив параметров командной строки он запускал дочерний процесс, с присоединенным stdin к /dev/null и возвращал пару пайпов для чтения stdout и stderr. Это было наравне с popen() по удобству пользования, но гораздо более безопасней в использовании из-за исключения shell и более безопасного использования файловых дескрипторов. В будущем функция получила гораздо больше фич.
  • 2007.07 – _virExec(). С появлением драйвера OpenVZ, функция qemudExec() была перемещена из драйвера QEMU в модуль общих функций. Это был первый шаг в сторону совместного использования большого количества кода между различными драйверами виртуализации в Libvirt. Функционал остался тем же, что и в qemudExec().
  • 2008.08 – _virExec(). Было обнаружено race condition, возникающее при обработке сигналов, упомянутое выше. Обработчик сигналов зарегистрированный в родителе был настроен на запись в пайп, когда приходил сигнал, но _virExec закрывал дескриптор пайпа, и его номер порой был писпользован повторно, когда настраивался пайп для использования с stdio дочернего процесса. Исправление было в блокировке всех сигналов перед тем. как запускать fork(), и разблокировке их после fork(). Однако, перед разблокировкой дочерний процесс сбрасывал все обработчики сигналов в дефолт.
  • 2008.01 – virRun(). API _virExec() API был преднаначен для запуска долгоживущих дочерних процессов, так что он возвращал PID дочернего процесса и ожидал, что родитель будет вызывать функцию waitpid. Чтобы упросить запуск короткоживущих программ был представлен API virRun(), который просто запускал _virExec() и затем вызывал waitpid, передавая потом статус выхода дочернего приложения в родительское. Это было аналогично system() по простоте использования, но безопаснее из-за исключения shell и более безопасного использования файловых дескрипторов.
  • 2008.08 – _virExec(). API _virExec() изначально создавал пару пайпов, чтобы потом передавать данные stdout/stderr в родительское приложение. Позже было обнаружено, что более удобно передавать дескриптор уже открытого файла вместо создания нового пайпа. Поэтому API _virExec() был расширен.
  • 2008.08 – _virExec(). Изначально все запускаемые программы наследовали все переменные окружения, установленные в демоне Libvirtd. API _virExec() был расширен, чтобы позволить передачу произвольных переменных, и чтобы заменять установленные переменные. Если запрашивалась модификация переменныт, то вызывалась функция execve(), иначе использовалась execvp(). В этом же изменение также предлагался новый флаг для демонизации дочерней программы. В случае выставления этого флага дочернее приложение вновь вызывало fork(), и первый дочерний процесс выходил. Дочернему процессу дочернего процесса также выставлялась домашняя директория в “/”, и он становился session leader.
  • 2008.08 – _virExec(). Как было упомянуто выше, все файловые дескрипторы в дочернем процессе закрывались, и совершенно новый набор дескрипторов присоединялся к stdin/out/err. Для лучшего контроля API _virExec() был расширен чтобы позволить родителю передавать дополнительный набор файловых дескрипторов.
  • 2009.02 – _virExec(). С появлением поддержки sVirt / SELinux в драйвере QEMU возникла необходимость производить некоторые действия между вызовами fork() и exec(). Чтоб не хардкодить эти действия для каждого места, где вызывается _virExec(), была добавлена возможность добавить callback для этого. Этот callback запускался сразу перед exec() и использовался для установки меток SELinux для процесса QEMU.
  • 2009.05 – _virExec(). Многие дочерние процессы имеют возможность записать pid-файл, что полезно, когда они запускаются как демоны, т.к. нет надежного способа снаружи получить PID дочернего процесса после второго форка. Тут есть опасность race condition для родительского процесса, потому-что нет гарантий, что pid-файл уже существует в момент, когда _virExec() возвращает управление родителю. Поэтому пришлось расширить функционал _virExec(), чтобы позволить ему создавать pid-файлы при демонизации команд. Таким образом родителю гарантируется, что pid-файл уже существует когда _virExec() возвращает управление.
  • 2009.06 – _virExec(). Когда запускается привилегированный процесс, то он обычно наследует все capabilities родителя. Если известно. что программа не требует каких-то из них, то лучше удалить их. В virExec() API была добавлена поддержка libcap-ng, чтобы удалять capabilities дочерних процессов.
  • 2010.02 – _virExec(). Синхронизация с внутренним мьютексом журналирования. Если поток находился в процессе вызова сообщения для журнала, а другой поток вызывает virExec(), то мьютекс журналирования будет захвачет первым потоком. Любая попытка журналирования в дочернем процессе застрянет в дедлоке. Чтобы поправить это нужно захватить мьютекс журналирования перед форком нового процесса, и отпустить его сразу после форка. Такие веселые пляски приходится делать везде, где есть глобальный мьютекс, который нужно захватить во время работы пары fork / exec.
  • 2010.02 – virFork(). Есть пара случаев, когда Libvirt нуждается в возможности вызвать fork без последующего exec. Так что код для работы с fork() и сброса обработчиков сигналов был отделен отvirExec(), чтобы его можно было использовать независимо.
  • 2010.05 – virCommand. Список параметров для virExec() вырос больше, чем хотелось бы, так что пришлось создать новый объект - virCommand. Идея в том, чтобы заполнить этот объект, а уж потом передать его в функцию, которая и выполнит команду, собранную из него. Если вызывать virExec, то вызывающий сам должен собрать char **argv, а если использовать virCommand, то есть удобные функции-хелперы, которые упрощают процесс создания argv и делают его более надежным.
  • 2010.11 – virCommand. Обычно, кактолько запускается дочерний процесс, то он работает асинхронно от родительского. Однако, бывают случаи, когда им обоим нужно шагать в ногу друг с другом. Например, блокировке дисков в Libvirt нужно захватить хранилище перед тем, как запустится бинарник QEMU, но ему нужно знать PID запущенного процесса и, к сожалению, код захвата ресурса не может быть запущен в дочернем процессе (который-то уж PID знает наверняка). API virCommand был расширен функционалом взаимного подтверждения (handshake). Перед выполнением нового бинарника, дочерний процесс пошлет сообщение в родительский процесс, и будет ждать ответа перед продолжением.
  • 2012.01 – virCommand. API команды virCommand API позволяло дочернему процессу наследовать все capabilities, либо все их блокировать. Но есть случаи, когда необходим более тонкий контроль, например запуск LXC-контейнеров с ограниченными привилегиями. Пришлось расширить API virCommand чтобы добавлять указанные capabilities в дочерний процесс.
  • 2013.01 – virCommand. Ситуация, когда хочется изменить UID/GID запущенного процесса - не такая уж и редкость, особенно если нельзя доверять процессу делать это самому, ну или когда он уже не может изменить их из-за удаленных capabilities (удаленный флаг CAP_SETUID). API virCommand API был расширен, чтобы позволить изменять UID+GID запускаемому процессу. Это изменение вносится между fork / exec одновременно с изменением capabilities у процесса.
  • 2013.05 – virCommand. При запуске QEMU очень важно отрегулировать некоторые системные ограничения, например, поднять максимальное количество одновременно открытых файлов, причем независимо от ограничений, наложенных на сам демон Libvirt (libvirtd). Опять, API virCommand пришлось расширить, чтобы можно было устанавливать системные ограничения между fork / exec.
  • 2014.03 – virCommand. Во время unit-тестирования желательно предотвратить взаимодействие с системой, так что это непросто протестировать код, которые запускает внешние команды. Однако в API virCommand было легко включить тестовый режим, в котором команде можно предоставить callback-функцию вместо реальной команды. Эта функция возвращает необходимые данные в stdout/stderr, которые требуются для тестирования кода.
  • 2014.09 – virCommand. В UNIX, по умолчанию дочерний процесс наследует umask родительского процесса process, что не всегда желательно. Например, хотя Libvirtd может иметь umask 0077, желательно, чтобы QEMU получил umask 0007, чтобы можно было настроить разделяемые ресурсы группы. API virCommand получил новую опцию для указания umask, устанавливаемой между fork / exec.


Вышеприведенная хронология показывает, что собственный API Libvirt для запуска дочерних процессов получил довольно большой функционал за семь лет разработки. Он быстро превзошел system / popen по безопасности по отношению к разным серьезным проблемам, в тоже время сохраняя простоту использования. Это было достигнуто без необходимости выставлять весь низкоуровневый API наружу. Низкоуровевые детали изолированы в одном месте, и остальной код использует высокоуровневый API. В следующем блог-посте я приведу реальные примеры использования API virCommand.
В целом мы не услышали почти ничего нового, о чем бы многократно не говорил Lennart Poettering, но тем не менее, заметка получилась очень показательной. Мы надеемся, что те, кто думают, что запуск процессов супервизором, это легкотня, запросто реализуемая на bash-портянках, начнут в этом сомневаться.

И не забудьте прочитать вторую часть заметки, с практическими примерами!