Содержание статьи
Вторжение в адресное пространство чужого процесса — вполне типичная задача, без которой не обходятся ни черви, ни вирусы, ни шпионы, ни распаковщики, ни… даже легальные программы! Возможных решений много, а способов противостояния еще больше. Чтобы не завязнуть в этом болоте, мыщхъ решил обобщить весь накопленный опыт в одной статье, относящейся главным образом к Linux'у и различным кланам
BSD.
Введение в историческую ретроспективу
Еще в стародавние времена в *nix существовала игра «Дарвин» (чем-то напоминающая морской бой), где в
раздельных адресных пространствах ползали черви, периодически наносящие удары друг по другу. К концу восьмидесятых игры кончились, а потребность в легальных средствах межпроцессорного взаимодействия осталась. В *nix все процессы выполняются в
независимых и невидимых друг для друга адресных пространствах, похожих по своему устройству на параллельные миры, знакомые нам по фантастическим фильмам. Вот только в реальной жизни, в отличие от сказок, параллельным пространствам приходится как-то взаимодействовать, обмениваясь друг с другом данными.
Просто так взять и залезть в адресное пространство чужого процесса нельзя — политика безопасности не позволяет! А интерес к атакам на *nix все растет и растет. К сожалению (или к счастью — смотря по какую сторону баррикады стоять), с правами непривилегированного пользователя под *nix'ами практически ничего хорошего сделать нельзя. И хотя периодически появляются сообщения о новых дырах, способных предоставить любому пользователю абсолютный контроль над системой, бреши довольно быстро затыкаются.
Ptrace – белые начинают и проигрывают
Ptrace – древнейший механизм межпроц… стоп!
Межадресного взаимодействия. Чтобы можно было продолжить, придется сделать небольшое лирическое отступление, пробившись через бурелом терминологической путаницы. Средства межпроцессорного взаимодействия охватывают широкий круг механизмов, включающий в себя пайпы, сокеты и другой ширпотреб. Для
внедрения в чужое адресное пространство они непригодны, если, конечно, процесс-жертва «добровольно» не установит обработчик на пайп/сокет, позволяющий читать/писать содержимое принадлежащей ему памяти, плюс отсутствуют ошибки переполнения. Ну, скажем, обработчик - это безумие (по имени back-door), а вот ошибки переполнения достаточно часто встречаются, однако, увы, довольно быстро затыкаются, и, хотя на их место приходят другие, все это неуниверсально и неинтересно.
Сосредоточимся на подклассе средств межпроцессорного взаимодействия, рассматривая лишь механизмы, работающие непосредственно с физической или виртуальной памятью целевого процесса, к которым принадлежит вышеупомянутая библиотека ptrace. «Библиотека» — потому что изначально она была реализована как обособленный модуль, много позже интегрированный в ядро. Поэтому теперь более правильно говорить о наборе функций семейства ptrace, реализованных как на прикладном, так и на ядерном уровне.
Собственно, на прикладном уровне доступна всего одна функция: ptrace((int _request, pid_t _pid, caddr_t _addr, int _data)), принимающая кучу аргументов и позволяющая решать кучу задач: трассировать процесс, приостанавливая или возобновляя его выполнение; читать/писать содержимое виртуальной памяти; обращаться с контекстом регистров и т.д.
Формально ptrace реализована на всех *nix-подобных системах, но особенности реализации добавляют программистам много хлопот, и, прежде чем составить переносимый код, придется изрядно потрудиться.
Алгоритм внедрения, работающий на всех платформах, в общем случае выглядит так:
- запускаем отладочный процесс-жертву вызовом fork()/exec()/ptrace(PTRACE_TRACEME [в BSD — PT_TRACE_ME, в дальнейшем BSD-объявления приводятся через слеш]) или подключаемся к уже запущенному через ptrace(PTRACE_ATTACH/PT_ATTACH, pid, 0, 0);
- процессу-жертве автоматически посылается SIGSTOP, приводящий к его остановке, момент которой легко определяется функцией
wait(); - читаем содержимое контекста регистров общего назначения вызовом ptrace(PTRACE_GETREGS/PT_GETREGS, pid, 0, *data), находим среди них регистр $PC (на x86-платформе он зовется EIP) и запоминаем его;
- читаем содержимое памяти под *$PC: ptrace(PTRACE_PEEKTEXT/PT_READ_I, pid, addr, 0), запоминая его в своем внутреннем буфере;
- вызовом ptrace(PTRACE_POKETEXT/PT_WRITE_I, pid, addr, *data) внедряем поверх *$PC свой собственный shell-код, обеспечивающий загрузку остального хакерского кода (например, можно выделить память из кучи, не забыв присвоить ей атрибуты исполняемой, так как с поддержкой флагов ‘NX’/’XD’ исполнение кода в области данных стало невозможным, как вариант еще можно загрузить свою динамическую библиотеку);
- возобновляем работу процесса-жертвы: ptrace(PTRACE_CONT/PT_CONTINUE, pid, 0/1, 0), давая shell-коду некоторое время на выполнение всех ранее запланированных действий, какими бы коварными они не были;
- восстанавливаем оригинальное содержимое модифицированной памяти вызовом ptrace(PTRACE_POKETEXT/PT_WRITE_I, pid, addr, *data), при этом о восстановлении регистров shell-код должен позаботиться сам (вообще-то это можно сделать и через ptrace, но через shell-код технически проще);
- отсоединяемся от процесса-жертвы через ptrace(PTRACE_DETACH/PT_DEATACH, pid, 0, 0), оставляя глубоко в его чреве внедренный хакерский код.
Защита
Защититься от такого метода внедрения процессу-жертве проще простого. Поскольку функция ptrace нереентерабельна, то есть не допускает вложенного выполнения, процессу-жертве достаточно сделать ptrace()… самому себе! Это никак не повлияет на производительность, но вторжение предотвратит. Впрочем, вместе с вторжением отвалится и отладка. За исключением небольшого количества отладчиков (таких, например, как Linice), весь остальной конгломерат (включающий и могущественный gdb) работает именно через ptrace, и попытка отладки защищенного процесса накрывается медным тазом.
Процесс-жертва может легко очиститься от хакерского кода повторным вызовом exec() самому себе! Системный загрузчик перечитает исходный образ ELF-файла с диска, и все изменения в кодовом сегменте будут потеряны. Правда, вместе с ними будут потеряны и оперативные данные, которые в этом случае процессу придется хранить в разделяемой области памяти. Это существенно затруднит программирование, однако затраченные усилия стоят того, поскольку атаки через ptrace (в силу их известности и простоты реализации) самые популярные из всех на сегодняшний день и в обозримом будущем снижение их активности не ожидается.
Псевдоустройство /dev/mem, или рокировка наоборот
Практически во всех никсах имеется файл /dev/mem, представляющий собой образ физической оперативной памяти компьютера (не путать с виртуальной!). Поскольку в операционных системах со страничной организацией оперативная память используется как кэш, одни и те же физические страницы в различное время могут соответствовать
различным виртуальным адресам, поэтому код процесса-жертвы (вместе с подходящим местом для внедрения) приходится искать по заранее выделенной сигнатуре.
При этом нас подстерегают следующие проблемы. Первая (и самая главная): при недостатке физической оперативной памяти наименее нужные страницы
виртуального адресного пространства выгружаются на диск, и в их число могут попасть и страницы, принадлежащие нашему процессу-жертве, причем не все сразу, а так… частями. Следовательно, а) внедряться нужно в часто используемые участки кода, вероятность вытеснения которых минимальна; б) если с сигнатурным поиском в /dev/mem произошел облом, не паникуем, а просто ждем некоторое время и повторяем операцию сканирования вновь - рано или поздно виртуальные страницы считаются операционной системой в память.
Вторая проблема заключается в том, что соседние виртуальные
страницы адресного пространства зачастую оказываются в различных частях файла /dev/mem, поэтому: а) размер внедряемого shell-кода не может превышать размеров одной страницы, а это 1000h байт на x86; б) базовые адреса виртуальных страниц при вытеснении на диск всегда кратны их размеру, то есть мы можем внедрить 200h байт shell-кода, начиная с адреса XXXX1000h, но не можем сделать то же самое с XXXX1EEEh.
Остается только определиться с местом внедрения. А внедряться предпочтительнее всего в начало часто вызываемых функций. Если это будут «внутренние» функции процесса-жертвы, то наш хакерский код окажется привязанным к конкретной версии исполняемого файла. После выхода новой версии или даже компиляции старой версии другим компилятором (или с иными ключами), все смещения неизбежно изменятся.
Гораздо перспективнее внедряться в библиотечные функции. Такие, например, как printf(), расположенные в разделяемой области памяти и позволяющие определить свой адрес штатными средствами операционной системы без всякого дизассемблера. Естественно, внедрение в разделяемую функцию затронет все процессы, ее использующие, и потому писать shell-код следует очень аккуратно. Но задумаемся, что произойдет, если в момент внедрения разделяемая функция уже выполняется каким-то процессом?! Правильно! С процессом произойдет крах! Зато при внедрении в разделяемые функции проблема загрузки виртуальных страниц с диска решается их простым вызовом. Короче говоря, нет худа без добра!
Следующий листинг демонстрируют технику чтения/записи ядерной памяти с прикладного уровня:
#include <fcntl.h>
#define PAGE_SIZE 0x1000
int fd;
char buf[PAGE_SIZE];//
открываем /dev/mem на чтение и запись
if ((fd = open("/dev/mem", O_RDWR, 0)) == -1)return printf("/dev/mem open error\n");
//
чтение данных из /dev/mem
static inline int rkm(int fd, int offset, void *buf, int size){
if (lseek(fd, offset, 0) != offset) return 0;
if (read(fd, buf, size) != size) return 0; return size;
}//
запись данных в /dev/mem
static inline int wkm(int fd, int offset, void *buf, int size){
if (lseek(fd, offset, 0) != offset) return 0;
if (write(fd, buf, size) != size) return 0; return size;
}Замечание: под FreeBSD 4.5 (более свежие версии мыщъх не проверял) функция read() всегда возвращает позитивный результат, даже если файл /dev/mem уже закончился.
Универсальный вариант кода, работающий на всех платформах, выглядит так:
// читаем 0x1000 байт в буфер if (read(fd, buf, 0x1000) != 0x1000) return printf("/dev/mem read error\n");Защита
На прикладном уровне у процесса-жертвы никаких защитных средств в оборонительном арсенале, в общем-то, и нет («в общем-то», потому что процесс может использовать динамическую шифровку кода, контроль целостности библиотечных функций перед их вызовом и т.д., но это уже явный перебор). На уровне ядра создание файла /dev/mem блокируется элементарно, но вместе с этим блокируются и многие полезные программы (в частности, X'ы), так что остается только разграничение доступа к /dev/mem с ведением списка «доверенных» лиц, которые к нему могут обращаться, что отчасти реализовано в
OpenBSD.Тем не менее, в общем случае надежной защиты от внедрения через /dev/mem нет и не будет! Успокаивает лишь тот факт, что доступ к нему имеет только
root.dl_load – мат в три хода
Практически все приложения (за исключением небольшого круга системных утилит) используют динамически загружаемые библиотеки, которые также могут быть использованы для
внедрения в чужое адресное пространство. Самое простое — взять готовую библиотеку и подменить ее своей, но это слишком заметно, да и как-то по-пионерски. Это не наш метод.Поэтому обратимся к странице справочного руководства ld.so (в Linux) или ld (в FreeBSD). Оттуда мы узнаем, что порядок поиска динамических библиотек — очень интересная штука, и в Linux системный загрузчик, сосредоточенный в файлах ld.so и ld-linux.so*, в общем случае поступает так (а в не общем - как ему скажет утилита ldconfig, смотри man ldconfig):
- если в ELF-файле присутствует секция DT_RPATH с именем/путем к динамической библиотеке и такая библиотека по данному пути действительно обнаруживается, то подключается именно она, в противном случае осуществляется поиск в директории DT_RUNPATH (если есть);
- если атрибуты setuid/setgid сброшены, анализируется переменная окружения LD_LIBRARY_PATH, содержащая пути к динамическим библиотекам, которые там могут быть или… не быть;
- если же их там нет, загрузчик как последнее средство использует пути по умолчанию: /lib, а затем
/usr/lib; - если требуемой библиотеки нет ни в одном из вышеперечисленных мест, то это облом!
Для ускорения поиска загрузчик использует файл /etc/ld.so.cache, содержащий таблицу хинтов (от английского «hint» – «подсказка», «наводка»), или, попросту говоря, перечень путей к ранее найденным библиотекам. Это не текстовый формат, да к тому же доступный для модификации одному лишь root'у, так что не будем на нем подробно останавливаться. Лучше посмотрим на файл /etc/ld.so.config, который задает порядок поиска динамических библиотек и в свежеустановленном Knoppix выглядит так: /lib, /usr/lib, /usr/X11R6/lib, /usr/i486-linuxlibc1/lib, /usr/local/lib,
/usr/lib/mozilla.
Разумеется, модифицировать файл /etc/ld.so.config может только root, зато читать его может любой желающий, а для успешной атаки большего и не надо! В частности, чтобы похачить Mozilla, достаточно поместить библиотеку-спутник (термин пришел из MS-DOS) в одну из вышележащих директорий. Тогда она будет загружена первой, и спутнику остается только похозяйничать внутри
чужого адресного пространства, после чего благополучно ретироваться, загрузив оригинальную библиотеку и передав ей управление.
Вот только на этом пути нас ждут две большие проблемы. Первая заключается в том, что создать новые файлы в каталогах /lib, /user/lib и т.д. может только root, а его еще как-то заполучить надо. Однако анализ показывает, что файл /etc/ld.so.config зачастую содержит пути к несуществующим каталогам (в данном случае это /usr/i486-linuxlibc1/lib), которые может создавать кто угодно, помещая в них что угодно!
Прежде чем открывать на радостях пиво, следует решить вторую проблему — скорректировать или очистить кэш в лице файла /etc/ld.so.cache, к которому опять-таки имеет доступ только root. Однако кэш на то и кэш, чтобы хранить не все, а лишь последние найденные библиотеки. Что мы делаем: грузим все библиотеки, которые только установлены в системе (за исключением «нашей»), в результате чего «нашей» библиотеки в /etc/ld.so.cache очень скоро уже не окажется, она будет взята не из /usr/lib/mozilla, а из
/usr/i486-linuxlibc1/lib!
Но что делать, если в /etc/ld.so.config отсутствуют несуществующие пути?! Добывать root'а любой ценой и размещать «свою» библиотеку в /lib или /usr/lib. Во всяком случае, это намного менее заметно, чем прямая модификация атакуемой библиотеки на диске (то есть ее «заражение»).
Все сказанное выше относится главным образом к Linux. У BSD-систем порядок поиска динамических библиотек немного отличается, хотя суть остается той же:
- анализируется переменная окружения
LD_RUN_PATH; - если нужной библиотеки там нет, анализируется переменная
LD_LIBRARY_PATH; - при наличии секций DT_RUNPATH/DT_RPATH поиск происходит в них, причем DT_RUNPATH имеет приоритет перед
DT_RPATH; - библиотеки ищутся в общепринятых каталогах: сначала в /lib, потом в
/usr/lib; - если существует файл /etc/ld.so.conf, загрузчик просматривает все упомянутые в нем каталоги.
Изменение переменных окружения — еще один возможный способ атаки, но, увы, доступный одному лишь root'у, да к тому же слишком заметный. Но в некоторых случаях он просто незаменим (если все остальные попытки атаки закончились крахом).
Защита
Защититься от атак этого типа очень просто. Достаточно убедиться, что во все «библиотечные» каталоги писать может только root и что файл /etc/ld.so.conf не содержит путей к несуществующим каталогам. Тем не менее, несмотря на кажущуюся простоту, достаточно многие системы в конфигурации по умолчанию могут быть легко атакованы.
Заключение
За рамками статьи осталось множество интересных
способов внедрения (в частности, директория /proc и ее содержимое), однако одним хвостом всего ведь не охватишь, верно? Главное для нас было не собрать огромную коллекцию способов внедрения (многие из которых быстро устаревают, превращаясь в антиквариат), а дать толчок к новым идеям, показать, что *nix-системы защищены намного слабее, чем это принято считать, и что, несмотря на тщательно продуманную политику безопасности, концептуальные дыры в ней все-таки есть.
|