Як захищати процеси та розширення ядра в macOS

Привіт, Хабре! Сьогодні мені хотілося б поговорити про те, як можна захистити процеси від зазіхань зловмисників у macOS. Наприклад, це корисно для антивірусу або системи резервного копіювання, особливо у світлі того, що під macOS існує відразу кілька способів "вбити" процес. Про це та про методи захисту читайте під катом.

Як захищати процеси та розширення ядра в macOS

Класичний спосіб "вбити" процес

Всім відомий спосіб "вбити" процес - надіслати сигнал про 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. Зверну увагу на те, що вихідники наведені для старої версії 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 🙂

Як захищати процеси та розширення ядра в macOS

Як вирішувати проблему?

Найбільш прямолінійний спосіб заборонити вбивати процес - підмінити покажчик на функцію таблиці системних викликів. На жаль, цей спосіб є нетривіальним з багатьох причин

По-перше, символ, який відповідає за положення 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("%dn", 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 в даному випадку.

При спробі вивантаження драйвера із системи (команда 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 для неї.

Як захищати процеси та розширення ядра в macOS

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

Розглянемо спосіб побудови ДКА для цього набору префіксів. Ініціалізуємо курсори початку кожного префікса. Якщо всі курсори вказують на той самий символ, то збільшимо кожен курсор на один символ і запам'ятаємо, що довжина однакового рядка більше на одиницю. Якщо є два курсори, символи під якими різні, розділимо курсори на групи за символом, куди вони вказують і повторимо алгоритм кожної групи.

У першому випадку (усі символи під курсорами однакові) отримуємо стан ДКА, який має лише один перехід по однаковій сходинці. У другому випадку отримуємо таблицю переходів розміром 256 (кількість символів і максимальну кількість груп) у наступні стани, отримані при рекурсивному виклику функції.

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

Як захищати процеси та розширення ядра в macOS

При проходженні за станами ДКА може бути 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).

Як захищати процеси та розширення ядра в macOS

Все це можна зробити тільки в тому випадку, якщо утиліта була офіційно встановлена ​​в ядро. Тобто для зовнішнього та небажаного ПЗ немає таких лазівок. Однак, як ви бачите, навіть для захисту легітимних програм, таких як антивірус та система резервного копіювання, доводиться працювати. Але тепер нові продукти Acronis для macOS матимуть додатковий захист від вивантаження із системи.

Джерело: habr.com

Додати коментар або відгук