Как защищать процессы и расширения ядра в macOS

    Привет, Хабр! Сегодня мне хотелось бы поговорить о том, как можно защитить процессы от посягательств злоумышленников в macOS. Например, это полезно для антивируса или системы резервного копирования, особенно в свете того что под macOS существует сразу несколько способов “убить” процесс. Об этом и о методах защиты читайте под катом.

    image

    Классический способ “убить” процесс


    Всем известный способ “убить” процесс — послать сигнал об SIGKILL процессу. Через bash можно вызвать стандартные “kill -SIGKILL PID” или “pkill -9 NAME” для убийства. Команда “kill” известна еще со времен UNIX и доступна не только в macOS, но и на других UNIX-like системах.

    Также как и в UNIX-like системах, macOS позволяет перехватить любые сигналы к процессу кроме двух — SIGKILL и SIGSTOP. В этой статье будет в первую очередь рассматриваться сигнал SIGKILL, как сигнал, порождающий убийство процесса.

    Специфика macOS


    В macOS системный вызов kill в ядре XNU вызывает функцию psignal(SIGKILL,...). Попробуем посмотреть, какие еще действия пользователя в userspace может вызвать функцию psignal. Отсеим вызовы функции psignal в внутренних механизмах ядра (хотя и они могут быть нетривиальными, но оставим их для другой статьи :) — проверка подписи, ошибки памяти, обработка exit/terminate, нарушение защиты файлов и т.п.

    Начнем обзор с функции и соответствующего системного вызова terminate_with_payload. Видно, что помимо классического вызова kill существуют альтернативный подход, который специфичен для операционной системы macOS и не встречается в BSD. Принципы работы обоих системных вызовов также близки. Они представляют собой прямые вызовы функции ядра psignal. Также обратим внимание, что перед убийством процесса производится проверка “cansignal” – может ли процесс отправить сигнал другому процессу, система не допускает любому приложению убивать системные процессы например.

    static int
    terminate_with_payload_internal(struct proc *cur_proc, int target_pid, uint32_t reason_namespace,
    				uint64_t reason_code, user_addr_t payload, uint32_t payload_size,
    				user_addr_t reason_string, uint64_t reason_flags)
    {
    ...
    	target_proc = proc_find(target_pid);
    ...
    	if (!cansignal(cur_proc, cur_cred, target_proc, SIGKILL)) {
    		proc_rele(target_proc);
    		return EPERM;
    	}
    ...
    	if (target_pid == cur_proc->p_pid) {
    		/*
    		 * psignal_thread_with_reason() will pend a SIGKILL on the specified thread or
    		 * return if the thread and/or task are already terminating. Either way, the
    		 * current thread won't return to userspace.
    		 */
    		psignal_thread_with_reason(target_proc, current_thread(), SIGKILL, signal_reason);
    	} else {
    		psignal_with_reason(target_proc, SIGKILL, signal_reason);
    	}
    ...
    }
    

    launchd


    Стандартный способ создания демонов на запуске системы и контролировать их время жизни — launchd. Обращу внимание на то, что исходники приведены для старой версии launchctl до macOS 10.10, примеры кода приведены в качестве иллюстрации. Современный launchctl отправляет сигналы launchd через XPC, логика launchctl перенесена в него.

    Рассмотрим как именно производится остановка приложений. Перед отправкой сигнала SIGTERM, приложение пытаются остановить при помощи системного вызова “proc_terminate”.

    <launchctl src/core.c>
    ...
    	error = proc_terminate(j->p, &sig);
    	if (error) {
    		job_log(j, LOG_ERR | LOG_CONSOLE, "Could not terminate job: %d: %s", error, strerror(error));
    		job_log(j, LOG_NOTICE | LOG_CONSOLE, "Using fallback option to terminate job...");
    		error = kill2(j->p, SIGTERM);
    		if (error) {
    			job_log(j, LOG_ERR, "Could not signal job: %d: %s", error, strerror(error));
    		} 
    ...
    <>
    

    Под капотом proc_terminate, несмотря на свое название, может отправлять не только psignal c SIGTERM, но и SIGKILL.

    Косвенное убийство — ограничение на ресурсы


    Более интересный случай можно увидеть в другом системном вызове process_policy. Стандартное использование этого системного вызова — ограничения ресурсов приложений, например для индексера ограничение на квоту процессорного времени и памяти, чтобы система не существенно замедлялась от действий кэширования файла. Если приложение достигло ограничения на ресурсы, как можно увидеть из функции proc_apply_resource_actions, то процессу отправляется сигнал SIGKILL.

    Несмотря на то, что данный системный вызов может потенциально производить убийство процесса, система не проверяла адекватно права процесса, вызывающего системный вызов. На самом деле проверка существовала, но достаточно использовать альтернативный флаг PROC_POLICY_ACTION_SET для обхода этого условия.

    Отсюда если “ограничить” квоту использования CPU приложением (например разрешить выполняться только 1 ns),, то можно произвести убийство любого процесса в системе. Так, зловред может убить любой процесс на системе, в том числе и процесс антивируса. Также интересен эффект, который получается при убийстве процесса с pid 1 (launchctl) — kernel panic при попытке обработать сигнал SIGKILL :)

    image

    Как решать проблему?


    Самый прямолинейный способ запретить убивать процесс — подменить указатель на функцию в таблице системных вызовов. К сожалению, данный способ является нетривиальным по многим причинам

    Во-первых, символ, который отвечает за положение sysent в памяти, не только является приватным символа ядра XNU, но и не может быть найден в символах ядра. Придется использовать эвристические методы поиска, например динамическое дизассемблирование функции и поиск указателя в ней.

    Во-вторых, структура записей в таблице зависит от флагов, с которыми было собрано ядро. Если объявлен флаг CONFIG_REQUIRES_U32_MUNGING, то размер структуры будет изменен — добавлено дополнительное поле sy_arg_munge32. Необходимо производить дополнительную проверку на то, с каким флагом было скомпилировано ядро, как вариант сверять указатели на функции с известными.

    struct sysent {         /* system call table */
            sy_call_t       *sy_call;       /* implementing function */
    #if CONFIG_REQUIRES_U32_MUNGING || (__arm__ && (__BIGGEST_ALIGNMENT__ > 4))
            sy_munge_t      *sy_arg_munge32; /* system call arguments munger for 32-bit process */
    #endif
            int32_t         sy_return_type; /* system call return types */
            int16_t         sy_narg;        /* number of args */
            uint16_t        sy_arg_bytes;   /* Total size of arguments in bytes for
                                             * 32-bit system calls
                                             */
    };
    

    К счастью, в современных версиях macOS Apple предоставляет новое API для работы с процессами. Endpoint Security API позволяет клиентами авторизировать многие запросы к другим процессам. Так, можно заблокировать любые сигналы к процессы, в том числе сигнал SIGKILL при помощи вышеупомянутого API.

    #include <bsm/libbsm.h>
    #include <EndpointSecurity/EndpointSecurity.h>
    #include <unistd.h>
    
    int main(int argc, const char * argv[]) {
        es_client_t* cli = nullptr;
        {
            auto res = es_new_client(&cli, ^(es_client_t * client, const es_message_t * message) {
                switch (message->event_type) {
                    case ES_EVENT_TYPE_AUTH_SIGNAL:
                    {
                        auto& msg = message->event.signal;
                        auto target = msg.target;
                        auto& token = target->audit_token;
                        auto pid = audit_token_to_pid(token);
                        printf("signal '%d' sent to pid '%d'\n", msg.sig, pid);
                        es_respond_auth_result(client, message, pid == getpid() ? ES_AUTH_RESULT_DENY : ES_AUTH_RESULT_ALLOW, false);
                    }
                        break;
                    default:
                        break;
                }
            });
        }
    
        {
            es_event_type_t evs[] = { ES_EVENT_TYPE_AUTH_SIGNAL };
            es_subscribe(cli, evs, sizeof(evs) / sizeof(*evs));
        }
    
        printf("%d\n", getpid());
        sleep(60); // could be replaced with other waiting primitive
    
        es_unsubscribe_all(cli);
        es_delete_client(cli);
    
        return 0;
    }
    

    Аналогично в ядре можно зарегистрировать MAC Policy, который предоставляет метод защиты от сигналов (policy proc_check_signal), однако API не поддерживается официально.

    Защита расширения ядра


    Помимо защиты процессов в системе обязательно необходима и защита самого расширения ядра (kext). macOS предоставляет для разработчиков фреймворк для удобной разработки драйверов устройств IOKit. Помимо предоставления средств работы с устройствами, IOKit обеспечивает методы стекирования драйверов (driver stacking) при помощи экземпляров классов C++. Приложение в userspace сможет “найти” зарегистрированный экземпляр класса для установления связи kernel-userspace.

    Для обнаружения количества экземпляров классов в системе существует утилита ioclasscount.

    my_kext_ioservice = 1
    my_kext_iouserclient = 1
    

    Любое расширение ядра, которое желает зарегистрироваться в стеке драйверов, обязано объявить класс, унаследованный от IOService, например, my_kext_ioservice в данном случае.Подключение пользовательских приложений вызывает создание нового экземпляра класса, который наследуется от IOUserClient, в примере my_kext_iouserclient.

    При попытке выгрузки драйвера из системы (команда kextunload) вызывается виртуальная функция “bool terminate(IOOptionBits options)”. Достаточно вернуть false на вызове функции terminate при попытке выгрузки, чтобы запретить kextunload.

    bool Kext::terminate(IOOptionBits options)
    {
    
      if (!IsUnloadAllowed)
      {
        // Unload is not allowed, returning false
        return false;
      }
    
      return super::terminate(options);
    }
    
    

    Флаг IsUnloadAllowed может быть выставлен IOUserClient при загрузке. При ограничении на загрузку команда kextunload вернет следующий вывод:

    admin@admins-Mac drivermanager % sudo kextunload ./test.kext
    Password:
    (kernel) Can't remove kext my.kext.test; services failed to terminate - 0xe00002c7.
    Failed to unload my.kext.test - (iokit/common) unsupported function.
    

    Аналогичную защиту необходимо произвести и для IOUserClient. Экземпляры классов можно выгрузить при помощи userspace функции IOKitLib “IOCatalogueTerminate(mach_port_t, uint32_t flag, io_name_t description);”. Можно возвращать false на вызове команды “terminate” пока userspace приложение не “умрет”, то есть не будет вызов функции “clientDied”.

    Защита файлов


    Для защиты файлов достаточно использовать Kauth API, который позволяет ограничивать доступ к файлам. Apple предоставляет разработчикам нотификации о различных событиях в scope, для нас важны операции KAUTH_VNODE_DELETE, KAUTH_VNODE_WRITE_DATA и KAUTH_VNODE_DELETE_CHILD. Ограничивать доступ к файлам проще всего по пути — используем API “vn_getpath” для получения пути к файлу и производим сравнение префикса пути. Заметим, что для оптимизации переименования путей папок с файлами, система не авторизирует доступ к каждому файлу, но только к самой папке, которую переименовали. Необходимо производить сравнение родительского пути и ограничивать KAUTH_VNODE_DELETE для нее.

    image

    Недостатком данного подхода может стать низкая производительность при возрастании количества префиксов. Для того, чтобы сравнение не было равно O(prefix*length), где prefix — количество префиксов, length — длина строки, можно использовать детерминированный конечный автомат (ДКА), построенный по префиксам.

    Рассмотрим способ построения ДКА для данного набора префиксов. Инициализируем курсоры на начало каждого префикса. Если все курсоры указывают на один и тот же символ, то увеличим каждый курсор на один символ и запомним, что длина одинаковой строчки больше на единицу. Если существует два курсора, символы под которыми разные, разделим курсоры на группы по символу, на которые они указывают и повторим алгоритм для каждой группы.

    В первом случае (все символы под курсорами одинаковые) получаем состояние ДКА, которое имеет только один переход по одинаковой строчке. Во втором случае, получаем таблицу переходов размером 256 (кол-во символов и максимальное количество групп) в последующие состояния, полученные при рекурсивном вызове функции.

    Рассмотрим пример. Для набора префиксов (“/foo/bar/tmp/”, “/var/db/foo/”, “/foo/bar/aba/”, “foo/bar/aac/”) можно получить следующий ДКА. На рисунке указаны только переходы, ведущие в другие состояния, другие переходы не будут являться конечными.

    image

    При прохождении по состояниям ДКА может оказаться 3 случая.

    1. Было достигнуто финальное состояние — путь является защищенным, ограничиваем операции KAUTH_VNODE_DELETE, KAUTH_VNODE_WRITE_DATA и KAUTH_VNODE_DELETE_CHILD
    2. Не было достигнуто финальное состояние, но путь “кончился” (был достигнут ноль-терминатор) — путь является родительским, необходимо ограничивать KAUTH_VNODE_DELETE. Заметим, что если vnode является папкой, нужно добавить в конец ‘/’, в противном случае может производиться ограничение к файлу “/foor/bar/t”, что неверно.
    3. Не было достигнуто финальное состояние, путь не кончился. Ни один из префиксов не соответствует данном, не вводим ограничения.

    Заключение


    Целью разрабатываемых секьюрити-решений является повышение уровня безопасности пользователя и его данных. С одной стороны эта цель обеспечивается разработкой программного продукта Acronis, закрывающего те уязвимости, где «слаба» сама операционная система. С другой стороны не следует пренебрегать и усилением тех аспектов безопасности, которые можно улучшить на стороне OS, тем более что закрытие подобных уязвимостей повышает нашу собственную устойчивость как продукта. Уязвимость была сообщена Apple Product Security Team и была исправлена в macOS 10.14.5 (https://support.apple.com/en-gb/HT210119).

    image

    Все это можно сделать только в том случае, если ваша утилита была официально установлена в ядро. То есть для внешнего и нежелательного ПО нет таких лазеек. Однако, как вы видите, даже для защиты легитимных программ, таких как антивирус и система резервного копирования, приходится потрудиться. Но зато теперь новые продукты Acronis для macOS будут иметь дополнительную защиту от выгрузки из системы.
    Acronis
    Компания

    Комментарии 4

      +1
      Команда “kill” известна еще со времен UNIX и доступна не только в macOS, но и на других дистрибутивах Linux.

      Это такой тонкий троллинг? Можно подумать, что в Solaris или в любом варианте семейства BSD эта команда недоступна.
        0
        Я скорее пытался сказать, что решаемая проблема появляется не только в macOS, но и в многих других операционных системах. Вы правильно заметили, что в Solaris и BSD также kill присутствует.
          +1
          Этот текст в данной формулировке почти явно утверждает, что macOS является дистрибутивом Linux, что не соответствует действительности, мягко говоря
        0

        Статья очень интересная, но все впечатление от ее чтения убивает язык (выше по поводу "других дистрибутивов Линукс" уже отписались), куча опечаток, спорные места (например, анализ сложности обхода дерева файлов O(prefix*length)).


        Я уж не говорю о том, что действительно круто, что отрепортили о проблемах в саму Эппл — это реально здорово, но вот коммититься на использование этого вот всего для защиты… Ну… Эм… Я уже поел говнеца за всю свою жизнь начиная от StarForce и кончая конфликтами антивирусов и файрволлов и каких-нибудь программ. Поэтому — нет, спасибо, для меня использование каких-либо недокументированных возможностей в софте, запущенном 100% времени — это практически черная метка для эксплуатации. Поживем как-нибудь и без антивирей, и без бекапов (благо работа это позволяет и личную гигиену никто не отменял, а от 0day целенаправленной атаки ничего не спасет)

        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

        Самое читаемое