Como proteger processos e extensões de kernel no macOS

Olá Habr! Hoje eu gostaria de falar sobre como você pode proteger processos contra invasores no macOS. Por exemplo, é útil para um antivírus ou sistema de backup, especialmente tendo em conta que no macOS existem várias maneiras de "matar" o processo. Leia sobre isso e sobre os métodos de proteção abaixo de um gato.

imagem

A maneira clássica de matar um processo


A maneira bem conhecida de "matar" um processo é enviar um sinal sobre um processo SIGKILL. Através do bash, você pode chamar o padrão "kill -SIGKILL PID" ou "pkill -9 NAME" para matar. O comando kill é conhecido desde o UNIX e está disponível não apenas no macOS, mas também em outros sistemas semelhantes ao UNIX.

Como nos sistemas UNIX, o macOS permite interceptar qualquer sinal no processo, exceto dois - SIGKILL e SIGSTOP. Neste artigo, o sinal SIGKILL será considerado principalmente como um sinal que originou a morte de um processo.

Especificidades do MacOS


No macOS, a chamada do sistema kill no kernel XNU chama a função psignal (SIGKILL, ...). Vamos tentar ver o que outras ações do usuário no espaço do usuário podem chamar de função psignal. Eliminamos chamadas para a função psignal nos mecanismos internos do kernel (embora possam não ser triviais, vamos deixá-las para outro artigo :) - verificação de assinatura, erros de memória, processamento de saída / encerramento, violação de proteção de arquivos etc.

Iniciamos a visão geral com a função e a chamada de sistema correspondente terminate_with_payload. Pode-se observar que, além da chamada de interrupção clássica, há uma abordagem alternativa específica ao sistema operacional macOS e não encontrada no BSD. Os princípios operacionais de ambas as chamadas de sistema também estão próximos. São chamadas diretas para a função psignal do kernel. Observe também que, antes de encerrar um processo, uma verificação de “cansignal” é realizada - se o processo pode enviar um sinal para outro processo, o sistema não permite que nenhum aplicativo interrompa processos do sistema, por exemplo.

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


A maneira padrão de criar daemons na inicialização do sistema e controlar sua vida útil é iniciada. Vou chamar a atenção para o fato de o código fonte ser da versão antiga do launchctl antes do macOS 10.10, exemplos de código são fornecidos como uma ilustração. O launchctl moderno envia sinais launchd através do XPC, a lógica do launchctl é transferida para ele.

Vamos considerar como o aplicativo está parado. Antes de enviar um sinal SIGTERM, eles tentam parar o aplicativo usando a chamada do sistema 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));
		} 
...
<>

Sob o capô, proc_terminate, apesar de seu nome, pode enviar não apenas o sinal com o SIGTERM, mas também o SIGKILL.

Morte indireta - limite de recursos


Um caso mais interessante pode ser visto em outra chamada do sistema process_policy . O uso padrão dessa chamada do sistema são limites de recursos do aplicativo, por exemplo, para o indexador, há um limite no tempo do processador e na cota de memória para que o sistema não diminua significativamente devido a ações de armazenamento em cache do arquivo. Se o aplicativo atingiu o limite de recursos, como pode ser visto na função proc_apply_resource_actions, o sinal SIGKILL é enviado ao processo.

Embora essa chamada do sistema possa potencialmente matar um processo, o sistema não verificou adequadamente os direitos do processo que causou a chamada do sistema. De fato, uma verificação existia , mas é suficiente usar o sinalizador alternativo PROC_POLICY_ACTION_SET para ignorar essa condição.

Portanto, se você "limitar" a cota de uso da CPU pelo aplicativo (por exemplo, permitir que apenas 1 ns sejam executados), poderá eliminar qualquer processo no sistema. Portanto, o malware pode matar qualquer processo no sistema, incluindo o processo antivírus. Também é interessante o efeito que ocorre quando um processo é morto com o pid 1 (launchctl) - pânico do kernel ao tentar processar um sinal SIGKILL :)

imagem

Como resolver o problema?


A maneira mais direta de impedir que um processo seja morto é substituir o ponteiro de função na tabela de chamadas do sistema. Infelizmente, esse método não é trivial por vários motivos:

primeiro, o símbolo responsável pela posição do sysent na memória não é apenas um símbolo particular do kernel XNU, mas também não pode ser encontrado nos símbolos do kernel. Você precisará usar métodos de pesquisa heurística, por exemplo, desmontagem dinâmica de uma função e procurar um ponteiro nela.

Em segundo lugar, a estrutura das entradas na tabela depende dos sinalizadores com os quais o kernel foi construído. Se o sinalizador CONFIG_REQUIRES_U32_MUNGING for declarado, o tamanho da estrutura será alterado - um campo adicional sy_arg_munge32 será adicionado. É necessário realizar uma verificação adicional no sinalizador com o qual o kernel foi compilado, como opção, comparar os indicadores com as funções conhecidas.

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
                                         */
};

Felizmente, nas versões modernas do macOS, a Apple fornece uma nova API para trabalhar com processos. A API do Endpoint Security permite que os clientes autorizem muitas solicitações para outros processos. Portanto, você pode bloquear qualquer sinal nos processos, incluindo o sinal SIGKILL usando a API mencionada acima.

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

Da mesma forma, você pode registrar a Política MAC no kernel, que fornece um método de proteção de sinal (política proc_check_signal), mas a API não é oficialmente suportada.

Proteção de Extensão do Kernel


Além de proteger os processos no sistema, a proteção da própria extensão do kernel (kext) também é necessária. O macOS fornece uma estrutura para os desenvolvedores desenvolverem convenientemente drivers de dispositivo IOKit. Além de fornecer ferramentas para trabalhar com dispositivos, o IOKit fornece métodos de empilhamento de driver usando instâncias de classes C ++. Um aplicativo no espaço do usuário poderá "encontrar" uma instância registrada da classe para estabelecer uma conexão kernel-espaço do usuário.

Para detectar o número de instâncias de classe no sistema, o utilitário ioclasscount existe.

my_kext_ioservice = 1
my_kext_iouserclient = 1

Qualquer extensão do kernel que desejar registrar-se na pilha de drivers deve declarar uma classe herdada do IOService, por exemplo, my_kext_ioservice neste caso.Os aplicativos de usuário conectados criarão uma nova instância da classe que herda de IOUserClient, no exemplo my_kext_iouserclient.

Ao tentar descarregar o driver do sistema (comando kextunload), a função virtual “bool terminate (opções IOOptionBits)” é chamada. É suficiente retornar false na chamada para a função de término ao tentar descarregar para desativar o kextunload.

bool Kext::terminate(IOOptionBits options)
{

  if (!IsUnloadAllowed)
  {
    // Unload is not allowed, returning false
    return false;
  }

  return super::terminate(options);
}


O sinalizador IsUnloadAllowed pode ser definido por IOUserClient na inicialização. Quando o carregamento é limitado, o comando kextunload retornará a seguinte saída:

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.

Uma proteção semelhante deve ser feita para IOUserClient. As instâncias de classe podem ser descarregadas usando a função de espaço do usuário IOKitLib “IOCatalogueTerminate (mach_port_t, sinalizador uint32_t, descrição io_name_t);”. Você pode retornar false em uma chamada para o comando “terminar” até que o espaço do usuário desapareça o aplicativo, ou seja, não haja chamada para a função clientDied.

Proteção de arquivo


Para proteger arquivos, basta usar a API do Kauth, que permite restringir o acesso aos arquivos. A Apple fornece aos desenvolvedores notificações sobre vários eventos no escopo, as operações KAUTH_VNODE_DELETE, KAUTH_VNODE_WRITE_DATA e KAUTH_VNODE_DELETE_CHILD são importantes para nós. Restringir o acesso aos arquivos é mais fácil ao longo do caminho - usamos a API "vn_getpath" para obter o caminho para o arquivo e comparar o prefixo do caminho. Observe que, para otimizar a renomeação dos caminhos das pastas com arquivos, o sistema não autoriza o acesso a cada arquivo, mas apenas à própria pasta, que foi renomeada. É necessário comparar o caminho pai e restringir KAUTH_VNODE_DELETE para ele.

imagem

A desvantagem dessa abordagem pode ser o baixo desempenho com o aumento do número de prefixos. Para que a comparação não seja igual a O (prefixo * comprimento), onde prefixo é o número de prefixos, length é o comprimento da string, você pode usar uma DFA (máquina de estados finitos determinística) construída por prefixos.

Considere uma maneira de criar um DFA para esse conjunto de prefixos. Inicializamos os cursores no início de cada prefixo. Se todos os cursores apontam para o mesmo caractere, aumentamos cada cursor em um caractere e lembramos que o comprimento da mesma linha é mais um. Se houver dois cursores com símbolos diferentes, dividimos os cursores em grupos pelo símbolo para o qual eles apontam e repetimos o algoritmo para cada grupo.

No primeiro caso (todos os caracteres sob os cursores são iguais), obtemos o estado do DFA, que possui apenas uma transição na mesma linha. No segundo caso, obtemos uma tabela de transição do tamanho 256 (número de caracteres e o número máximo de grupos) nos estados subseqüentes, obtidos chamando a função recursivamente.

Considere um exemplo. Para um conjunto de prefixos (“/ foo / bar / tmp /”, “/ var / db / foo /”, “/ foo / bar / aba /”, “foo / bar / aac /”), você pode obter o seguinte DFA. A figura mostra apenas transições que levam a outros estados, outras transições não serão finais.

imagem

Ao passar pelos estados da CAD, pode haver três casos.

  1. O estado final foi alcançado - o caminho está protegido, restringimos as operações KAUTH_VNODE_DELETE, KAUTH_VNODE_WRITE_DATA e KAUTH_VNODE_DELETE_CHILD
  2. , “” ( -) — , KAUTH_VNODE_DELETE. , vnode , ‘/’, “/foor/bar/t”, .
  3. , . , .


O objetivo das soluções de segurança desenvolvidas é aumentar o nível de segurança do usuário e de seus dados. Por um lado, este objetivo é garantido pelo desenvolvimento do produto de software Acronis, que cobre vulnerabilidades em que o próprio sistema operacional é "fraco". Por outro lado, não devemos negligenciar o aprimoramento dos aspectos de segurança que podem ser aprimorados no lado do sistema operacional, especialmente porque o fechamento de tais vulnerabilidades aumenta nossa própria estabilidade como produto. A vulnerabilidade foi relatada pela equipe de segurança do produto Apple e foi corrigida no macOS 10.14.5 (https://support.apple.com/en-gb/HT210119).

imagem

Tudo isso pode ser feito apenas se o seu utilitário tiver sido oficialmente instalado no kernel. Ou seja, não existem brechas para software externo e indesejado. No entanto, como você pode ver, mesmo para proteger programas legítimos, como antivírus e sistemas de backup, você precisa trabalhar duro. Mas agora, os novos produtos Acronis para macOS terão proteção adicional contra o descarregamento do sistema.

All Articles