Теория изоляции и лимитирования процессов в linux userspace

Этот пост за­ду­мы­вал­ся по­яс­ни­тель­ны­ми ком­мен­та­ри­я­ми к лис­тин­гу го­то­вой про­грам­мы, но ме­ня по­че­му-то в са­мый по­след­ний мо­мент по­про­си­ли уда­лить ре­по­зи­та­рий из гит­ха­ба, хо­тя откры­тость ко­да бы­ла ого­во­ре­на за­ра­нее. Вот так тео­рия и прак­ти­ка в за­го­лов­ке ста­тьи превра­ти­лась про­сто в тео­рию. Впро­чем, я по­ста­ра­юсь до­ста­точ­но де­таль­но расска­зать о двух основ­ных спосо­бах изо­ля­ции и ли­ми­ти­ро­ва­ния ис­пол­ня­е­мо­го про­цес­са: basic, ко­то­рый не тре­бу­ет осо­бых зна­ний, пере­сбор­ки ядра, а необ­хо­ди­мый функ­ци­о­нал уже при­сут­ству­ет во всех совре­мен­ных дис­три­бу­ти­вах и advanced.

Пе­ре­до мной бы­ла по­став­ле­на сле­ду­ю­щая за­да­ча - на­пи­сать бы­струю и по­строен­ную не на инстру­мен­тах аппа­рат­ной или про­грамм­ной вир­ту­а­ли­за­ции среду запус­ка по­тен­ци­аль­но небез­опас­но­го поль­зо­ва­тель­ско­го ко­да. Что­бы вам ста­ло по­нят­ней, мо­же­те вз­гля­нуть на Codepad или Ideone - са­мые обыч­ные па­сте­би­ны, поз­во­ля­ю­щие со­би­рать и вы­пол­нять на­пи­сан­ный неиз­вест­ным Ва­си­ли­ем код, воз­вра­щая стан­дарт­ный вы­вод про­цес­са. По­доб­ные за­да­чи - не мое про­филь­ное направ­ле­ние, поэто­му про­шу ме­ня про­стить, если я вдруг кое-где оши­бусь в тео­рии или бу­ду го­во­рить откро­вен­ные глу­по­сти.

Пре­жде всего, су­ще­ству­ю­щие ре­ше­ния ко­неч­но же есть. Я не бу­ду сей­час пере­чис­лять все на­ко­лен­ные по­дел­ки в этой об­ла­сти, большинство из них на­пи­са­ны для сво­ей кон­крет­ной за­да­чи и нам не под­хо­дят. Бе­зу­слов­но, ра­бо­та­ю­щую си­сте­му мож­но по­строить на осно­ве SELinux или AppArmor, оба проек­та име­ют ба­зо­вую под­держ­ку сэнд­бок­син­га, пусть и на­хо­дя­щу­ю­ся в со­сто­я­нии “в ран­ней раз­ра­ботке” уже очень дол­го вре­мя. Оста­нав­ли­вать­ся на этой те­ма­ти­ке мы не бу­дем, нас ин­тере­сует свое соб­ствен­ное ре­ше­ние, ин­фор­ма­цию о пе­соч­ни­це в SELinux мож­но по­чи­тать в ЖЖ раз­ра­бот­чи­ка. Про AppArmor aa-sandbox то­же ни­че­го ска­зать не мо­гу, раз­ве что син­так­сис кон­фи­гу­ра­ци­он­ных файлов по­нят­ней крас­но­гла­зо­го се­ли­нук­са.

Пи­сать мы бу­дем на си­шеч­ке. По­че­му, я ду­маю, объ­яс­нять не нуж­но. По­чти все, что мы бу­дем ис­поль­зо­вать, уже есть в ядре и libc. Ос­нов­ной ал­го­ритм ра­бо­ты со­сто­ит в сле­ду­ю­щем: мы запус­каем свой сэнд­бокс, ко­то­рый фор­кает небез­опас­ный про­цесс, за­го­ня­ет его в пе­соч­ни­цу и вы­пол­ня­ет, воз­вра­щая stdout. Де­ла­ет­ся это эле­мен­тар­но с по­мо­щью clone() или fork() с даль­ней­шим запус­ком че­рез execve(). Для нас пред­по­чти­тель­ным спосо­бом яв­ляет­ся ис­поль­зо­ва­ние clone(), ко­то­рый поз­во­ля­ет де­таль­но ука­зать контекст ро­ди­те­ля, ко­то­рым мы бу­дем де­лить­ся с до­чер­ним про­цес­сом. По­дроб­нее о раз­личи­ях этих двух си­стем­ных вы­зо­вов мож­но по­чи­тать тут, напри­мер. По­доб­но­го эф­фек­та мож­но до­бить­ся и с fork-ом, если вам так удоб­ней, ис­поль­зуя unshare(). В частно­сти, для изо­ля­ции про­цес­са нам необ­хо­ди­мы сле­ду­ю­щие бит­мас­ки CLONE_NEWIPC, CLONE_NEWNET (са­мый про­стой способ от­резать ис­пол­ня­е­мый про­цесс от се­ти, мож­но еще восполь­зо­вать­ся iptables c ipt_owner), CLONE_NEWNS (это нам по­на­до­бит­ся чуть ни­же, когда мы дой­дем до cgroups), CLONE_NEWPID, SIGCHLD (ина­че wait() не бу­дет по­лу­чать сиг­на­лы от до­чер­не­го про­цес­са). Что­бы бы­ло бо­лее ме­нее по­нят­но, я на­бро­сал не­большой ша­блон. Код про­стой, но мы до­би­лись же­ла­е­мо­го. Пре­жде всего мы за­гна­ли про­цесс в отдель­ные Network, Mount, IPC и PID неймспей­сы и при­ми­тив­но огра­ни­чи­ли мак­си­маль­ное ре­аль­ное вре­мя ис­пол­не­ния с по­мо­щью alarm() и со­от­вет­ству­ю­ще­го об­ра­бот­чи­ка SIGALRM, уби­ва­ю­ще­го до­чер­ний про­цесс. Обра­щаю ва­ше вни­ма­ние, что CLONE_NEWPID, ко­то­рый ли­ша­ет на­шу пе­соч­ни­цу воз­мож­но­сти посы­лать kill() или ptrace() про­цес­сам вне изо­ли­ро­ван­но­го окру­же­ния, до­сту­пен в ядре на­чи­ная с 2.6.24, ко­то­рое долж­но быть со­бра­но с оп­ци­ей CONFIG_PID_NS. Вы мо­же­те ис­поль­зо­вать сле­ду­ю­щий код для про­верки его до­ступ­но­сти, ли­бо же cat /boot/config-uname -r | grep CONFIG_PID_NS.

Огра­ни­чив ба­зо­вый до­ступ, мож­но на­чи­нать огра­ни­чи­вать вы­де­ля­е­мые ре­сур­сы. Де­ла­ет­ся это с по­мо­щью вы­зо­ва setrlimit(). В на­шем слу­чае нас бу­дут ин­тере­со­вать зна­че­ния RLIMIT_AS (ли­мит раз­ме­ра ад­рес­но­го про­странства про­цес­са в байтах), RLIMIT_CPU (ли­мит CPU time в се­кун­дах), RLIMIT_FSIZE (мак­си­маль­ный раз­мер со­зда­ва­е­мо­го про­цес­сом файла в байтах, за­щи­та от ду­ра­ка, лю­бя­ще­го что-то вро­де dd if=/dev/zero of=becausefuckyouthatswhy bs=1g count=10000), RLIMIT_NICE (мак­си­маль­ное зна­че­ние для nice() и setpriority(), од­но­го nice() перед exec() бу­дет недо­ста­точ­но, без это­го ли­ми­та про­цесс все­гда мо­жет под­нять зна­че­ние), RLIMIT_NOFILE (мак­си­маль­ное ко­ли­че­ство файло­вых дескрип­то­ров, ко­то­рое мо­жет открыть про­цесс), RLIMIT_NPROC (единствен­ный ли­мит, ко­то­рый дол­жен уста­нав­ли­вать­ся не­по­сред­ствен­но по­сле setuid(), огра­ни­чи­ва­ет мак­си­маль­ное ко­ли­че­ство про­цес­сов, со­зда­ва­е­мых для UID вы­зы­ва­ю­ще­го про­цес­са).

К бо­лее гиб­ко­му ли­ми­ти­ро­ва­нию мы еще вер­нем­ся, когда бу­дем го­во­рить о cgroups. Наш про­цесс уже изо­ли­ро­ван от пря­мо­го вме­ша­тель­ства в си­сте­му, оста­лось лишь огра­ни­чить до­ступ к файло­вой си­сте­ме. Де­лать мы это бу­дем с по­мо­щью ста­ро­го до­бро­го chroot(). За го­ды ра­бо­ты с сер­ве­ра­ми я вы­ра­бо­тал пра­ви­ло запус­ка со­мни­тель­ных и по­тен­ци­аль­но небез­опас­ных при­ло­же­ний вро­де PHP и про­че­го гов­на ис­клю­чи­тель­но в отдель­ном root environment, со­здан­ном с по­мо­щью debootstrap ли­бо лю­бо­го па­ке­та в за­ви­си­мо­сти от це­ле­вой ОС. В по­след­нее вре­мя, все ча­ще поль­зу­юсь бо­лее на­деж­ным и без­опас­ным LXC но раз­го­вор о дру­гом. По­сле вы­зо­ва chroot() дол­жен обя­за­тель­но сле­до­вать вы­зов setuid() что­бы не до­пу­стить воз­мож­но­сти по­бе­га из тюрь­мы. В на­шем слу­чае бу­дет иде­аль­ным ис­поль­зо­ва­ние не­су­ще­ству­ю­ще­го ран­дом­ного UID-а на­подо­бие int new_uid = MAX_EXISTING_UID + random_in_range (1, 99). Вот и все, пра­виль­но вы­став­лен­ный chmod() на ра­бо­чую ди­рек­то­рию про­цес­са в со­че­та­нии со всем вы­ше­на­пи­сан­ным га­ран­ти­ру­ет до­ста­точ­ную изо­ля­цию га­де­ны­ша в пе­соч­ни­це. Я по­вто­рюсь - до­ста­точ­ную, но не иде­аль­ную, дан­ный способ не спа­са­ет от privilege escalation атак или про­чих уяз­ви­мо­стей ядра, свя­зан­ных с memory allocation and management, напри­мер.

Го­во­ря об advanced ме­то­дах ли­ми­ти­ро­ва­ния и изо­ля­ции, мы рассмот­рим при­ме­ры ис­поль­зо­ва­ния cgroups, ptrace, seccomp и seccomp-filters.

Cgroups или Control Groups - ме­ха­низм объеди­не­ния про­цес­сов в груп­пы с опре­де­лен­ным по­ве­де­ни­ем и ли­ми­та­ми. Дан­ный функ­ци­о­нал был на­пи­сан инже­не­ра­ми Google, вклю­чен в мейн­лайн ядра на­чи­ная с вер­сии 2.6.24 и име­ет до­ста­точ­но хо­ро­шую до­ку­мен­та­цию. А еще я на­шел очень по­нят­ный пост на рус­ском язы­ке по этой те­ма­ти­ке и раз­же­ван­ное опи­са­ние в до­ку­мен­та­ции openSUSE. Как все­гда, про­ве­ря­ем необ­хо­ди­мые нам оп­ции с по­мо­щью zcat /proc/config.gz или cat /boot/config-uname -r | grep CGROUP. Cgroups име­ют свое API, но мы бу­дем ис­поль­зо­вать бо­лее про­стой способ, осно­ван­ный на cgroup sysfs. Пре­жде всего, нуж­но разобрать­ся в иерар­хии это­го мо­ду­ля. Ко­рень для cgroups (обыч­но /sys/fs/cgroup/, но тут ни­ка­ко­го пра­ви­ла нет) - обыч­ная ди­рек­то­рия, мо­же­те под­монти­ро­вать ее как tmpfs. В кор­не­вую ди­рек­то­рию монти­ру­ют­ся под­си­сте­мы cpuacct, cpuset, devices, freezer, memory и про­чие. Весь спи­сок мож­но по­смот­реть в до­ку­мен­та­ции. При­монти­ро­ван­ная под­си­сте­ма име­ет влия­ние на все про­цес­сы дан­но­го UID, спи­сок PID-ов мож­но по­смот­реть в /sys/fs/cgroup/devices/tasks, как при­мер. Из­ме­ня­е­мые па­ра­мет­ры под­си­сте­мы - файлы, до­ступ­ные как для чте­ния, так и на за­пись. От­дель­ные груп­пы - про­стые пап­ки в ди­рек­то­рии под­си­сте­мы.

На­вер­ное, я объ­яс­няю слиш­ком то­пор­но. Что­бы бы­ло по­нят­ней, я на­пи­сал не­большой bash скрипт по­ка­зы­ва­ю­щий осно­вы ра­бо­ты. Если подоб­ный способ вза­и­мо­дей­ствия вам ка­жет­ся не до­ста­точ­но низ­ко­уров­не­вым, мо­же­те обра­тить вни­ма­ние на libcg - аб­страк­ции во­круг control groups API, прав­да до­ку­мен­та­ция там мяг­ко го­во­ря гов­ни­стая, а нор­маль­но­го при­ме­ра ис­поль­зо­ва­ния так и не уда­лось найти. Кста­ти, Linux Containers осно­ва­ны имен­но на ба­зе cgroups.

Ка­за­лось бы, че­го еще бо­ять­ся и пы­тать­ся изо­ли­ро­вать. Тут нуж­но вспо­мнить тео­рию ор­га­ни­за­ции и функ­ци­о­ни­ро­ва­ния совре­мен­ных опе­ра­ци­он­ных си­стем, ис­поль­зу­ю­щих system calls для вза­и­мо­дей­ствия с ядром ОС. Это если со­всем гру­бо, чуть де­таль­нее с во­про­сом мож­но озна­комить­ся в на удив­ле­ние не­пло­хой ста­тье в ви­ки­пе­дии. К при­ме­ру, мы зна­ем, что /bin/cat ис­поль­зу­ет­ся сле­ду­ю­щие syscall-ы для вы­во­да со­дер­жи­мо­го файла: mmap для вы­де­ле­ния па­мя­ти, fstat для про­верки су­ще­ство­ва­ния файла и прав до­сту­па к не­му, open для откры­тия файло­во­го дескрип­то­ра, read для чте­ния, write для вы­во­да со­дер­жи­мо­го тек­ста в stdout и close для за­кры­тия всех откры­тых ра­нее дескрип­то­ров. И мы уве­ре­ны, что /bin/cat уж точ­но не дол­жен вы­зы­вать execv(), bind() или там при cat test.txt пы­тать­ся открыть /etc/shadow. Бо­роть­ся с этим мож­но медлен­ным, но про­ве­рен­ным ptrace(); бы­стрым, но слож­ным seccomp; ли­бо же оп­ти­маль­ным, но при­сут­ству­ю­щим толь­ко в по­след­них вер­си­ях ядра, seccomp-filters.

Как уже бы­ло ска­за­но вы­ше, ptrace - способ при­сут­ству­ю­щий по­чти на всех Linux си­сте­мах, но за­мет­но за­медля­ю­щий ско­рость ис­пол­не­ния. Все это свя­за­но со спе­ци­фи­кой его ра­бо­ты и при­мене­ния. Ptrace ис­поль­зу­ет­ся ис­клю­чи­тель­но для от­лад­ки, где не нуж­на большая ско­рость - это раз, во-вто­рых по­сле каж­до­го си­стем­ного вы­зо­ва до­чер­ний про­цесс при­оста­нав­ли­ва­ет­ся, а мы об­ра­ба­ты­ва­ем SIGTRAP сиг­нал, по­лу­ча­ем int код си­стем­ного вы­зо­ва, его па­ра­мет­ры, ре­ша­ем, что с этим де­лать и посы­ла­ем PTRACE_CONT. Ра­зу­ме­ет­ся, это бу­дет ра­бо­тать бо­лее чем медлен­но. При­мер ко­да я при­во­дить не бу­ду, мо­же­те про­сто по­смот­реть ис­ход­ный код strace. Су­ще­ству­ют де­сят­ки аб­страк­ций во­круг ptrace, я же мо­гу по­со­ве­то­вать pinktrace - хо­ро­шие до­ки и про­стой функ­ци­о­нал де­ко­ди­ро­ва­ния ар­гу­мен­тов основ­ных си­стем­ных вы­зо­вов из ко­роб­ки.

Seccomp был до­бав­лен в яд­ро Linux с вер­сии 2.6.12 и компи­ли­ру­ет­ся с оп­ци­ей CONFIG_SECCOMP в .config. По су­ти, его дей­ствие сво­дит­ся к посыл­ке SIGKILL-у про­цес­су при по­пыт­ке вы­пол­нить лю­бые syscall-ы кро­ме exit(), read(), write(), sigreturn(). При этом, read и write мо­гут ис­поль­зо­вать ис­клю­чи­тель­но дескрип­то­ры, ко­то­рые уже бы­ли откры­ты ро­ди­тель­ским про­цес­сом, что нам из­на­чаль­но это не под­хо­дит - мы не по­де­ли­лись этим контек­стом, не пере­дав CLONE_FILES в clone flags. Имен­но seccomp ис­поль­зу­ют раз­ра­бот­чи­ки Google Chrome для со­зда­ния пе­соч­ни­цы про­цес­сов брау­зе­ра. При этом, все запре­щен­ные си­стем­ные вы­зо­вы мо­гут быть ре­а­ли­зо­ва­ны в ко­де. К при­ме­ру, вы мо­же­те на­пи­сать свой или ис­поль­зо­вать сто­ронний memory manager и так да­лее. При­мер ре­а­ли­за­ции sandbox на осно­ве seccomp в Chromium мо­же­те по­смот­реть по ссыл­ке чуть вы­ше ли­бо же в проек­те seccomp-nurse. Это без­опас­ный и до­ста­точ­но бы­стрый способ изо­ля­ции, но слиш­ком слож­ный для ре­а­ли­за­ции.

Но есть и аде­кват­ное усред­нен­ное ре­ше­ние под на­зва­ни­ем seccomp-filters, поз­во­ля­ю­щее обозна­чить blacklist или whitelist си­стем­ных вы­зо­вов и по­ве­де­ние про­грам­мы при сов­па­де­нии вы­зо­ва и, что очень важ­но, его па­ра­мет­ров с пер­вым ли­бо же со вто­рым списком. Seccomp filters или seccomp mode 2 по­яви­лись в ядре в вер­сии 3.5, ко­то­рое долж­но быть со­бра­но с па­ра­мет­ром CONFIG_SECCOMP_FILTER. Исполь­зо­вать со­ве­тую не напря­мую че­рез prctl(), а с по­мо­щью биб­лио­те­ки libseccomp. Ка­ждый ме­тод име­ет свой де­таль­ный man 2, а в ин­тер­не­тах мож­но найти опи­са­ние и немного уста­рев­ший при­мер ис­поль­зо­ва­ния.