Cómo proteger procesos y extensiones del núcleo en macOS

¡Hola, Habr! Hoy quisiera hablar sobre cómo se pueden proteger los procesos de los ataques de los ciberdelincuentes en macOS. Por ejemplo, esto es útil para un antivirus o un sistema de copias de seguridad, especialmente considerando que en macOS existen varias maneras de 'matar' un proceso. Lee sobre esto y los métodos de protección a continuación.

Cómo proteger procesos y extensiones del núcleo en macOS

Método clásico para 'matar' un proceso

La forma bien conocida de 'matar' un proceso es enviar una señal SIGKILL al proceso. A través de bash, se puede ejecutar los comandos estándar 'kill -SIGKILL PID' o 'pkill -9 NAME' para realizar la operación. El comando 'kill' es conocido desde los tiempos de UNIX y está disponible no solo en macOS, sino también en otros sistemas similares a UNIX.

Al igual que en sistemas similares a UNIX, macOS permite interceptar cualquier señal enviada a un proceso, excepto dos: SIGKILL y SIGSTOP. En este artículo, abordaremos principalmente la señal SIGKILL, como la señal que provoca la eliminación de un proceso.

Especificidades de macOS

En macOS, la llamada al sistema kill en el núcleo XNU invoca la función psignal(SIGKILL,…). Intentemos observar qué otras acciones del usuario en el espacio de usuario pueden invocar la función psignal. Excluiremos las llamadas a la función psignal en los mecanismos internos del núcleo (aunque estas pueden no ser triviales, las dejaremos para otro artículo 🙂 — verificación de firma, errores de memoria, procesamiento de salida/terminación, violación de protección de archivos, etc.).

Comenzaremos la revisión con la función y la correspondiente llamada al sistema terminate_with_payload. Se observa que, además de la llamada clásica a kill, existe un enfoque alternativo que es específico del sistema operativo macOS y no se encuentra en BSD. Los principios de operación de ambas llamadas al sistema también son similares. Se trata de llamadas directas a la función del núcleo psignal. También notemos que antes de matar un proceso se realiza una verificación de 'cansignal': si el proceso puede enviar una señal a otro proceso, el sistema no permite que cualquier aplicación mate procesos del sistema, por ejemplo.

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() pendrá un SIGKILL en el hilo especificado o
		 * retornará si el hilo y/o tarea ya están terminando. De cualquier manera,
		 * el hilo actual no regresará al espacio de usuarios.
		 * /
		psignal_thread_with_reason(target_proc, current_thread(), SIGKILL, signal_reason);
	} else {
		psignal_with_reason(target_proc, SIGKILL, signal_reason);
	}
...
}

launchd

La forma estándar de crear demonios al inicio del sistema y controlar su tiempo de vida es mediante launchd. Cabe destacar que los códigos fuente presentados son para una versión antigua de launchctl antes de macOS 10.10, los ejemplos de código se presentan como ilustración. El launchctl moderno envía señales a launchd a través de XPC, la lógica de launchctl se ha trasladado a él.

Veamos cómo se detienen las aplicaciones. Antes de enviar la señal SIGTERM, se intenta detener la aplicación mediante la llamada al sistema 'proc_terminate'.

<launchctl src/core.c>
...
	error = proc_terminate(j->p, &sig);
	if (error) {
		job_log(j, LOG_ERR | LOG_CONSOLE, "No se pudo terminar el trabajo: %d: %s", error, strerror(error));
		job_log(j, LOG_NOTICE | LOG_CONSOLE, "Usando opción alternativa para terminar el trabajo...");
		error = kill2(j->p, SIGTERM);
		if (error) {
			job_log(j, LOG_ERR, "No se pudo enviar señal al trabajo: %d: %s", error, strerror(error));
		}
...
<>

Bajo el capó, proc_terminate, a pesar de su nombre, puede enviar no solo psignal con SIGTERM, sino también SIGKILL.

Asesinato indirecto: restricción de recursos

Se puede observar un caso más interesante en otra llamada al sistema process_policy. El uso estándar de esta llamada al sistema son las restricciones de recursos de las aplicaciones, por ejemplo, para un indexador, la limitación en la cuota de tiempo de CPU y memoria, para que el sistema no se ralentice significativamente por las acciones de almacenamiento en caché del archivo. Si una aplicación alcanza la limitación de recursos, como se puede ver en la función proc_apply_resource_actions, se envía una señal SIGKILL al proceso.

A pesar de que esta llamada al sistema puede potencialmente llevar a la muerte del proceso, el sistema no verificó adecuadamente los derechos del proceso que realiza la llamada. De hecho, la verificación existía, pero es suficiente usar el flag alternativo PROC_POLICY_ACTION_SET para eludir esta condición.

Desde aquí, si "limitar" la cuota de uso de CPU de una aplicación (por ejemplo, permitir que solo se ejecute 1 ns), se puede llevar a cabo la eliminación de cualquier proceso en el sistema. Así, un malware puede eliminar cualquier proceso en el sistema, incluido el del antivirus. También es interesante el efecto que se produce al eliminar un proceso con pid 1 (launchctl): pánico del kernel al intentar manejar la señal SIGKILL 🙂

Cómo proteger procesos y extensiones del núcleo en macOS

¿Cómo resolver el problema?

La forma más directa de prohibir la eliminación de un proceso es reemplazar el puntero a la función en la tabla de llamadas al sistema. Desafortunadamente, este método no es trivial por muchas razones.

Primero, el símbolo que corresponde a la posición sysent en la memoria no solo es un símbolo privado del núcleo XNU, sino que tampoco se puede encontrar en los símbolos del núcleo. Tendremos que utilizar métodos heurísticos para la búsqueda, como la desensambladura dinámica de la función y la búsqueda del puntero en ella.

En segundo lugar, la estructura de las entradas en la tabla depende de las banderas con las que se compiló el núcleo. Si se declara la bandera CONFIG_REQUIRES_U32_MUNGING, el tamaño de la estructura se verá alterado: se añadirá un campo adicional. sy_arg_munge32. Es necesario realizar una verificación adicional sobre con qué bandera se compiló el núcleo, como opción, comparar los punteros a funciones con los conocidos.

struct sysent {         
        /* tabla de llamadas al sistema */
        sy_call_t       *sy_call;       /* función de implementación */
#if CONFIG_REQUIRES_U32_MUNGING || (__arm__ && (__BIGGEST_ALIGNMENT__ > 4))
        sy_munge_t      *sy_arg_munge32; /* modificador de argumentos de llamada al sistema para procesos de 32 bits */
#endif
        int32_t         sy_return_type; /* tipos de retorno de llamadas al sistema */
        int16_t         sy_narg;        /* número de argumentos */
        uint16_t        sy_arg_bytes;   /* Tamaño total de argumentos en bytes para
                                         * llamadas al sistema de 32 bits
                                         */
};

Afortunadamente, en las versiones modernas de macOS, Apple proporciona una nueva API para trabajar con procesos. La API de Seguridad de Endpoint permite a los clientes autorizar muchas solicitudes a otros procesos. Así, se pueden bloquear cualquier señal a los procesos, incluida la señal SIGKILL mediante la API mencionada anteriormente.

#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;
}

De manera similar, en el núcleo se puede registrar una Política MAC, que proporciona un método de protección contra señales (policy proc_check_signal), sin embargo, la API no se admite oficialmente.

Protección de la extensión del núcleo

Además de la protección de los procesos en el sistema, es necesaria la protección de la propia extensión del núcleo (kext). macOS proporciona a los desarrolladores un marco para el desarrollo cómodo de controladores de dispositivos IOKit. Además de ofrecer herramientas para trabajar con dispositivos, IOKit proporciona métodos de apilamiento de controladores (driver stacking) mediante instancias de clases en C++. Una aplicación en el espacio de usuario podrá "encontrar" una instancia registrada de la clase para establecer una conexión kernel-espacio de usuario.

Para detectar la cantidad de instancias de clases en el sistema, existe la utilidad ioclasscount.

my_kext_ioservice = 1
my_kext_iouserclient = 1

Cualquier extensión del núcleo que desee registrarse en la pila de controladores debe declarar una clase heredada de IOService, por ejemplo, my_kext_ioservice en este caso. La conexión de aplicaciones de usuario provoca la creación de una nueva instancia de clase que hereda de IOUserClient, en este caso my_kext_iouserclient.

Al intentar descargar el controlador del sistema (comando kextunload), se llama a la función virtual "bool terminate(IOOptionBits options)". Basta con devolver false en la llamada a la función terminate al intentar descargar para prohibir kextunload.

bool Kext::terminate(IOOptionBits options)
{

  if (!IsUnloadAllowed)
  {
    // La descarga no está permitida, devolviendo false
    return false;
  }

  return super::terminate(options);
}

La bandera IsUnloadAllowed puede ser establecida por IOUserClient al cargarse. Al haber una restricción en la descarga, el comando kextunload devolverá la siguiente salida:

admin@admins-Mac drivermanager % sudo kextunload ./test.kext
Contraseña:
(kernel) No se puede eliminar kext my.kext.test; los servicios no se pudieron terminar - 0xe00002c7.
Fallo al descargar my.kext.test - (iokit/common) función no soportada.

Se debe realizar una protección similar para IOUserClient. Las instancias de clases se pueden descargar mediante la función IOKitLib del espacio de usuario "IOCatalogueTerminate(mach_port_t, uint32_t flag, io_name_t description);". Se puede devolver false en la llamada al comando "terminate" mientras la aplicación del espacio de usuario no "muera", es decir, hasta que se llame a la función "clientDied".

Protección de archivos

Para proteger los archivos, es suficiente usar la API Kauth, que permite restringir el acceso a los archivos. Apple proporciona a los desarrolladores notificaciones sobre diversos eventos en el scope; para nosotros, son importantes las operaciones KAUTH_VNODE_DELETE, KAUTH_VNODE_WRITE_DATA y KAUTH_VNODE_DELETE_CHILD. Es más sencillo restringir el acceso a los archivos según la ruta: utilizamos la API “vn_getpath” para obtener la ruta al archivo y realizamos la comparación del prefijo de la ruta. Cabe destacar que, para optimizar el renombramiento de las rutas de las carpetas con archivos, el sistema no autoriza el acceso a cada archivo, sino solo a la carpeta misma que ha sido renombrada. Es necesario comparar la ruta del padre y restringir KAUTH_VNODE_DELETE para ella.

Cómo proteger procesos y extensiones del núcleo en macOS

Un inconveniente de este enfoque puede ser el bajo rendimiento a medida que aumenta la cantidad de prefijos. Para que la comparación no sea O(prefix*length), donde prefix es el número de prefijos y length es la longitud de la cadena, se puede utilizar un autómata finito determinista (AFD) construido a partir de los prefijos.

Consideremos un método para construir un AFD para este conjunto de prefijos. Inicializamos los cursores al inicio de cada prefijo. Si todos los cursores apuntan al mismo símbolo, aumentamos cada cursor en uno y recordamos que la longitud de la cadena idéntica ha crecido en uno. Si hay dos cursores y los símbolos que apuntan son diferentes, dividimos los cursores en grupos según el símbolo al que están apuntando y repetimos el algoritmo para cada grupo.

En el primer caso (todas las letras bajo los cursores son iguales), obtenemos un estado del AFD que tiene solo una transición por la cadena idéntica. En el segundo caso, obtenemos una tabla de transiciones de tamaño 256 (número de símbolos y el máximo de grupos) hacia los siguientes estados, obtenidos en la llamada recursiva de la función.

Consideremos un ejemplo. Para el conjunto de prefijos (“/foo/bar/tmp/”, “/var/db/foo/”, “/foo/bar/aba/”, “foo/bar/aac/”), se puede obtener el siguiente AFD. En la ilustración, se indican solo las transiciones que conducen a otros estados; las otras transiciones no serán finales.

Cómo proteger procesos y extensiones del núcleo en macOS

Al atravesar los estados del AFD, pueden surgir 3 casos.

  1. Se ha alcanzado un estado final: el camino está protegido, restringimos las operaciones KAUTH_VNODE_DELETE, KAUTH_VNODE_WRITE_DATA y KAUTH_VNODE_DELETE_CHILD.
  2. No se alcanzó el estado final, pero el camino "terminó" (se alcanzó el terminador nulo) — el camino es padre, es necesario restringir KAUTH_VNODE_DELETE. Notemos que si el vnode es una carpeta, se debe agregar al final ‘/’, de lo contrario puede realizarse una restricción al archivo “/foor/bar/t”, lo cual es incorrecto.
  3. No se alcanzó el estado final, el camino no ha terminado. Ninguno de los prefijos coincide, no se introducen restricciones.

Conclusión

El objetivo de las soluciones de seguridad que se están desarrollando es aumentar el nivel de seguridad del usuario y sus datos. Por un lado, este objetivo se logra mediante el desarrollo del producto Acronis, que cierra aquellas vulnerabilidades donde el propio sistema operativo es "débil". Por otro lado, no se deben descuidar los aspectos de seguridad que se pueden mejorar en el lado del sistema operativo, ya que cerrar este tipo de vulnerabilidades aumenta nuestra propia resistencia como producto. La vulnerabilidad fue reportada al Apple Product Security Team y fue corregida en macOS 10.14.5 (https://support.apple.com/en-gb/HT210119).

Cómo proteger procesos y extensiones del núcleo en macOS

Todo esto solo se puede hacer si su utilidad ha sido oficialmente instalada en el núcleo. Es decir, para software externo y no deseado no hay tales lagunas. Sin embargo, como pueden ver, incluso para proteger programas legítimos, como antivirus y sistemas de respaldo, hay que esforzarse. Pero a partir de ahora, los nuevos productos de Acronis para macOS tendrán una protección adicional contra la descarga del sistema.

Fuente: habr.com

Compra un hosting fiable para sitios web con protección contra DDoS, servidores VPS VDS 🔥 Compra un hosting fiable para sitios web con protección contra DDoS, servidores VPS VDS | ProHoster