За время существования ядра Linux в нем появилось множество механизмов защиты от эксплуатации уязвимостей, которые могут обнаружиться как в самом ядре, так и в приложениях пользователей. Это, в частности, механизмы ASLR и stack canary, противодействующие эксплуатации уязвимостей в приложениях. В данной статье мы внимательно рассмотрим реализацию ASLR в ядре текущей версии (4.15-rc1) и проблемы, позволяющие частично или полностью обойти эту защиту.
Вместе с описанием проблем мы предложили ряд исправлений и разработали специальную утилиту, позволяющую продемонстрировать найденные недостатки. Анализируя механизм реализации ASLR, мы также проанализировали часть библиотеки GNU Libc (glibc) и нашли серьезные проблемы с реализацией stack canary. Удалось обойти защиту stack canary и запустить произвольный код через утилиту ldd.
Все проблемы рассматриваются в контексте архитектуры x86-64, хотя для большинства архитектур, поддерживаемых ядром Linux, они также актуальны.
Илья Смит, выступая от лица компании Positive Technologies, представил доклад по этой теме на конференции OffensiveCon, которая прошла 16 февраля 2018 года. На сайте конференции можно ознакомиться и с другими брифами выступлений.
ASLR (address space layout randomization) — это технология, созданная для усложнения эксплуатации некоторого класса уязвимостей, применяется в нескольких современных операционных системах. Основной принцип данной технологии заключается в устранении заведомо известных атакующему адресов адресного пространства процесса. В частности, адресов, необходимых для того, чтобы:
Впервые технология была реализована для Linux в 2005 году. В Microsoft Windows и Mac OS реализация появилась в 2007 году. Хорошее описание реализации ASLR в Linux дается в статье.
За время существования ASLR были созданы разные методики обхода этой технологии, среди которых можно выделить следующие типы:
Стоит отметить, что в разных ОС реализации ASLR очень сильно различаются и развиваются. Последние изменения связаны с работой Offset2lib, представленной в 2014 году. В ней были раскрыты слабости реализации, позволяющие обходить ASLR из-за близкого расположения всех библиотек к образу бинарного ELF-файла программы. В качестве решения было предложено выделить образ ELF-файла приложения в отдельный случайным образом выделенный регион.
В апреле 2016 года создатели Offset2lib раскритиковали также текущую реализацию, выделив недостаточную энтропию при выборе адреса региона в работе ASLR-NG. Однако с тех пор патч не был опубликован.
Рассмотрим результат работы ASLR в Linux на текущий момент.
Для первоначального опыта возьмем Ubuntu 16.04.3 LTS (GNU/Linux 4.10.0-40-generic x86_64) с установленными последними на текущий момент обновлениями. Результат не сильно будет зависеть от дистрибутива Linux и версии ядра начиная с 3.18-rc7. Если выполнить less /proc/self/maps
в командной строке Linux, можно увидеть примерно следующее.
На примере видно:
/bin/less
) выбран как 5627a82bf000;ld-2.23.so
, libtinfo.so.5.9
, libc-2.23.so
расположены подряд.Если применить вычитание к соседним регионам памяти, можно заметить: существенна разница между бинарным файлом, кучей, стеком и младшим адресом local-archive и старшим адресом ld. Между загруженными библиотеками (файлами) нет ни одной свободной страницы.
Если повторить процедуру много раз, картина сильно не изменится: разность между страницами будет отличаться, однако библиотеки и файлы будут одинаково расположены друг относительно друга. Этот факт и стал опорной точкой для данной статьи.
Рассмотрим, как работает механизм выделения виртуальной памяти процесса. Вся логика находится в функции ядра do_mmap, реализующей выделение памяти как со стороны пользователя (syscall mmap), так и со стороны ядра (при выполнении execve). Она разделяется на два действия — сначала выбор свободного подходящего адреса (get_unmapped_area), потом отображение страниц на выбранный адрес (mmap_region). Нам будет интересен первый этап.
В выборе адреса возможны варианты:
Если все прошло успешно, по выбранному адресу будет выделен необходимый регион памяти.
В основе менеджера виртуальной памяти процесса лежит структура vm_area_struct (далее просто vma):
struct vm_area_struct { unsigned long vm_start; /* Our start address within vm_mm. */ unsigned long vm_end; /* The first byte after our end address within vm_mm. */ ... /* linked list of VM areas per task, sorted by address */ struct vm_area_struct *vm_next, *vm_prev; struct rb_node vm_rb; ... pgprot_t vm_page_prot; /* Access permissions of this VMA. */ ... };
Эта структура описывает начало региона виртуальной памяти, конец региона и флаги доступа к входящим в регион страницам.
vma организованы в двусвязный список (Doubly linked list) по возрастанию адресов начала региона. И в расширенное красно-черное дерево (Bayer, Rudolf. Symmetric binary B-Trees: Data structure and maintenance algorithms), также по возрастанию адресов начала региона. Хорошее обоснование этому решению дается самими разработчиками ядра (Lespinasse, Michel. Mm: use augmented rbtrees for finding unmapped areas).
Расширением красно-черного дерева является величина свободной памяти для рассматриваемого узла. Величина свободной памяти узла определяется как максимум:
Выбранная структура позволяет быстро (за O(log n)) находить vma, соответствующий искомому адресу, или выбирать свободный диапазон определенной длины.
При выборе адреса вводятся также две важные границы — минимально возможное нижнее значение и максимально возможное верхнее. Нижнее определяется архитектурой как минимальный допустимый адрес или как минимальное разрешенное администратором системы. Верхнее — mmap_base — выбирается как stack – random, где stack — это выбранный максимальный адрес стека, random — некоторое случайное значение с энтропией от 28 до 32 бит в зависимости от соответствующих параметров ядра. Ядро Linux не может выбрать адрес выше mmap_base. В адресном пространстве процесса адреса, превышающие mmap_base, либо соответствуют стеку и специальным системным регионам — vvar и vdso, либо не используются никогда, если только не будут явно выделены с флагом MMAP_FIXED.
Во всей схеме неизвестны адрес начала стека главного потока, базовый адрес загрузки бинарного файла приложения, начальный адрес кучи приложения и mmap_base — стартовый адрес выделения памяти с помощью mmap.
Можно обозначить несколько проблем, которые следуют из описанного алгоритма выделения памяти.
Во время работы приложение использует виртуальную оперативную память. Распространенные примеры использования приложением памяти — это куча, код и данные (.rodata, .bss) загруженных модулей, стеки потоков, подгруженные файлы. Любая ошибка обработки данных, лежащих в этих страницах, может затронуть и близлежащие данные. Чем больше разнородных страниц находятся рядом, тем больше поверхность атаки и выше вероятность успешной эксплуатации.
Примеры таких ошибок — ошибки с обработкой границ (out-of-bounds), переполнения (целочисленные (Integer Overflow or Wraparound) или буфера (Classic Buffer Overflow), ошибки обработки типов (Incorrect Type Conversion or Cast).
Частный случай этой проблемы — уязвимость для Offset2lib-атаки. Вкратце: проблема заключалась в том, что базовый адрес загрузки программы не выделялся отдельно от библиотек, а выбирался ядром как mmap_base. Если в приложении была уязвимость, эксплуатация упрощалась близким расположением образов загруженных библиотек к образу загруженного бинарного приложения.
Очень хорошим примером, демонстрирующим данную проблему, была уязвимость в PHP (CVE-2014-9427), позволяющая читать или изменять соседние регионы памяти.
В разделе 5 будет несколько примеров.
Динамические библиотеки в ОС Linux загружаются почти полностью без обращения к ядру Linux. За это отвечает библиотека ld (из GNU Libc). Единственное участие ядра — через функцию mmap (open/stat и прочие файловые операции мы пока не учитываем): это нужно для загрузки кода и данных библиотеки в адресное пространство процесса. Исключение составляет сама библиотека ld, которая обычно прописана в исполняемом ELF-файле программы как интерпретатор для загрузки файла. Сам же интерпретатор грузится ядром.
Итак, если в качестве интерпретатора используется ld из GNU Libc, то происходит загрузка библиотек примерно следующим образом:
Из этого алгоритма следует, что порядок загрузки всегда определен и может быть повторен, если известны все необходимые библиотеки (их бинарные файлы). Это позволяет восстановить адреса всех библиотек, если известен адрес хотя бы одной из них:
Если библиотека была загружена во время работы программы (например, с помощью функции dlopen), ее положение относительно других библиотек может быть неизвестным злоумышленнику в некоторых случаях. Например, если были вызовы mmap с неизвестными злоумышленнику размерами выделяемых регионов памяти.
При эксплуатации уязвимостей знание адресов библиотек очень сильно помогает, например в поиске «гаджетов» при построении ROP-цепочек. Кроме того, любая уязвимость в любой из библиотек, позволяющая читать (писать) значения относительно адреса этой библиотеки, будет легко проэксплуатирована из-за того, что библиотеки идут друг за другом.
Большинство дистрибутивов Linux содержат скомпилированные пакеты с наиболее распространенными библиотеками (например, libc). Благодаря этому можно узнать длину библиотек и построить часть картины распределения виртуального адресного пространства процесса в описанном выше случае.
Теоретически можно построить большую базу, например для дистрибутива Ubuntu, содержащую версии библиотек ld, libc, libpthread, libm и так далее, причем для каждой версии одной из библиотек можно определить множество версий библиотек, для нее необходимых (зависимости). Таким образом можно построить возможные варианты карт распределения части адресного пространства процесса при известном адресе одной из библиотек.
Примерами подобных баз служат базы libcdb.com и libc.blukat.me, используемые для определения версий libc по смещениям до известных функций.
Из всего описанного следует, что детерминированный порядок загрузки библиотек представляет собой проблему безопасности приложений, и значение ее увеличивается вместе с описанным ранее поведением mmap. В ОС Android эта проблема исправлена с 7-й версии (Security Enhancements in Android 7.0, Implement Library Load Order Randomization).
Рассмотрим следующее свойство программ: существует пара определенных точек в потоке выполнения программы, между которыми состояние программы в интересующих нас данных определено. Например, когда клиент соединяется с сетевым сервисом, последний выделяет для клиента некоторые ресурсы. Часть этих ресурсов может быть выделена из кучи приложения. При этом взаимное расположение объектов на куче определено в большинстве случаев.
Это свойство используется во время эксплуатации приложений при построении необходимого состояния программы. Назовем его детерминированным порядком выполнения.
Частный случай этого свойства есть некоторая определенная точка в потоке выполнения программы, состояние которой (точки) с начала выполнения программы, от запуска к запуску, идентично за исключением отдельных переменных. Например, до выполнения функции main программы интерпретатор ld должен загрузить и инициализировать все библиотеки и выполнить инициализацию программы. Расположение библиотек друг относительно друга, как было отмечено в разделе 4.2, будет всегда одинаковым. Отличия на момент выполнения функции main будут в конкретных адресах загрузки программы, библиотек, стека, кучи и выделенных к этому моменту в памяти объектов. Различия обусловлены рандомизацией, описанной в разделе 6.
Благодаря этому свойству злоумышленник может получить информацию о взаимном расположении данных программы. Это расположение не будет зависеть от рандомизации адресного пространства процесса.
Единственная возможная на этом этапе энтропия может быть обусловлена конкуренцией потоков: если программа создаст несколько потоков, их конкуренция при работе с данными может вносить энтропию в расположение объектов. В рассматриваемом примере создание потоков до выполнения main возможно из глобальных конструкторов программы или необходимых ей библиотек.
Когда программа начнет использовать кучу и выделять память в ней (обычно с помощью new/malloc), расположение объектов в куче друг относительно друга также до определенного момента будет постоянным для каждого запуска.
В некоторых случаях расположение стеков потоков и куч, созданных для них, будет также предсказуемо относительно адресов библиотек.
При необходимости можно получить эти смещения, чтобы использовать при эксплуатации. Например, выполнив strace -e mmap
для данного приложения два раза и сравнив разницу в адресах.
Если приложение после выделения памяти через mmap освобождает некоторую ее часть, могут появляться «дырки» — свободные регионы памяти, окруженные занятыми регионами. Проблемы могут возникнуть, если эта память (дырка) будет снова выделена для уязвимого объекта (объекта, при обработке которого в приложении есть уязвимость). Это снова приводит к проблеме близкого расположения объектов в памяти.
Хороший пример создания таких дырок был обнаружен в коде загрузки ELF-файла в ядре Linux. Во время загрузки ELF-файла ядро сначала считывает полный размер загружаемого файла и пытается отобразить файл целиком с помощью do_mmap. После успешной загрузки файла целиком вся память после первого сегмента освобождается. Все следующие сегменты загружаются по фиксированному адресу (MAP_FIXED), полученному относительно первого сегмента. Это нужно для того, чтобы можно было загрузить весь файл по выбранному адресу и разделить сегменты по правам и смещениям в соответствии с их описаниями в ELF-файле. Такой подход позволяет порождать дырки в памяти, если они были определены в ELF-файле между сегментами.
При загрузке же ELF-файла интерпретатором ld (GNU Libc) — в такой же ситуации — не вызывает unmap, а меняет разрешения на свободные страницы (дырки) на PROT_NONE, обеспечивая тем самым запрет доступа процесса к этим страницам. Этот подход более безопасный.
Для устранения проблемы загрузки ELF-файла, содержащего дырки, ядром Linux был предложен патч, реализующий логику как в ld из GNU Libc (см. раздел 7.1).
TLS (Thread Local Storage) — это механизм, с помощью которого каждый поток в многопоточном процессе может выделять расположения для хранения данных (Thread-Local Storage). Реализация этого механизма различна для разных архитектур и операционных систем, в нашем же случае это реализация glibc под x86-64. Для x86 разница будет несущественная для рассматриваемой проблематики mmap.
В случае с glibc для создания TLS потока также используется mmap. Это означает, что TLS потока выбирается уже описанным образом и при близком расположении к уязвимому объекту может быть изменен.
Чем интересен TLS? В реализации glibc на TLS указывает сегментный регистр fs (для архитектуры x86-64). Его структуру описывает тип tcbhead_t, определенный в исходных файлах glibc:
typedef struct { void *tcb; /* Pointer to the TCB. Not necessarily the thread descriptor used by libpthread. */ dtv_t *dtv; void *self; /* Pointer to the thread descriptor. */ int multiple_threads; int gscope_flag; uintptr_t sysinfo; uintptr_t stack_guard; uintptr_t pointer_guard; ... } tcbhead_t;
Этот тип содержит поле stack_guard, хранящее так называемую «канарейку» — некоторое случайное (или псевдослучайное) число, позволяющее защищать приложение от переполнений буфера на стеке (One, Aleph. Smashing The Stack For Fun And Profit).
Защита работает следующим образом: при входе в функцию на стек кладется «канарейка», которая берется из tcbhead_t.stack_guard. В конце функции значение на стеке сравнивается с эталонным значением в tcbhead_t.stack_guard, и, если оно не совпадает, приложение будет завершено с ошибкой.
Известны следующие методы обхода:
Из описанного следует важность защиты TLS от чтения или перезаписи злоумышленником.
Во время данного исследования была обнаружена проблема в реализации TLS у glibc для потоков, созданных с помощью pthread_create. Для нового потока необходимо выбрать TLS. Glibc после выделения памяти под стек инициализирует TLS в старших адресах этой памяти. В рассматриваемой архитектуре x86-64 стек растет вниз, а значит, TLS оказывается в вершине стека. Отступив некоторое константное значение от TLS, мы получим значение, используемое новым потоком для регистра стека. Расстояние от TLS до стек фрейма функции, переданной аргументом в pthread_create, меньше одной страницы. Злоумышленнику уже необязательно угадывать или «подглядывать» значение «канарейки», он попросту может перезаписать эталонное значение вместе со значением в стеке и обойти эту защиту полностью. Подобная проблема была найдена в Intel ME (Maxim Goryachy, Mark Ermolov. HOW TO HACK A TURNED-OFF COMPUTER, OR RUNNING).
При использовании malloc в некоторых случаях glibc применяет mmap для выделения новых участков памяти — если размер запрашиваемой памяти больше некоторой величины. В случае выделения памяти с помощью mmap адрес после выделения будет находиться «рядом» с библиотеками или другими данными, выделенными mmap. В этих случаях внимание злоумышленника привлекают ошибки обработки объектов на куче, такие как переполнение кучи, use after free и type confusion.
Интересное поведение библиотеки glibc было замечено, когда программа использует pthread_create. При первом вызове malloc из потока, созданного pthread_creaete, glibc вызовет mmap, чтобы создать новую кучу для этого потока. В этом случае все выделенные с помощью malloc адреса в потоке будут находиться недалеко от стека этого же потока. Подробнее этот случай будет рассмотрен в разделе 5.7.
Некоторые программы и библиотеки используют mmap для отображения файлов в адресное пространство процесса. Эти файлы могут быть использованы, например, как кеш или для быстрого сохранения (изменения) данных на диске.
Абстрактный пример: пусть приложение загружает MP3-файл с помощью mmap. Адрес загрузки назовем mmap_mp3. Дальше оно считывает из загруженных данных смещение до начала звуковых данных offset. Пусть в приложении присутствует ошибка проверки длины полученного значения. Тогда злоумышленник может подготовить специальным образом MP3-файл и получить доступ к региону памяти, расположенному после mmap_mp3.
В мануале mmap для флага MAP_FIXED написано следующее:
MAP_FIXED
Don’t interpret addr as a hint: place the mapping at exactly that address. addr must be a multiple of the page size. If the memory region specified by addr and len overlaps pages of any existing mapping(s), then the overlapped part of the existing mapping(s) will be discarded. If the specified address cannot be used, mmap() will fail. Because requiring a fixed address for a mapping is less portable, the use of this option is discouraged.
Если запрашиваемый регион с флагом MAP_FIXED перекрывает уже существующие регионы, результат успешного выполнения mmap перепишет существующие регионы.
Таким образом, если программист допускает ошибку в работе с MAP_FIXED, возможно переопределение регионов памяти.
Интересный пример такой ошибки был найден в контексте данной работы как в ядре Linux, так и в glibc.
Есть требование к ELF-файлам: сегменты ELF-файла должны следовать в заголовке Phdr в порядке возрастания адресов vaddr:
PT_LOAD
The array element specifies a loadable segment, described by p_filesz and p_memsz. The bytes from the file are mapped to the beginning of the memory segment. If the segment’s memory size (p_memsz) is larger than the file size (p_filesz), the “extra” bytes are defined to hold the value 0 and to follow the segment’s initialized area. The file size may not be larger than the memory size. Loadable segment entries in the program header table appear in ascending order, sorted on the p_vaddr member.
Однако это требование не проверяется. Текущий код загрузки ELF-файла таков:
case PT_LOAD: struct loadcmd *c = &loadcmds[nloadcmds++]; c->mapstart = ALIGN_DOWN (ph->p_vaddr, GLRO(dl_pagesize)); c->mapend = ALIGN_UP (ph->p_vaddr + ph->p_filesz, GLRO(dl_pagesize)); ... maplength = loadcmds[nloadcmds - 1].allocend - loadcmds[0].mapstart; ... for (const struct loadcmd *c = loadcmds; c < &loadcmds[nloadcmds]; ++c) ... /* Map the segment contents from the file. */ if (__glibc_unlikely (__mmap ((void *) (l->l_addr + c->mapstart), maplen, c->prot, MAP_FIXED|MAP_COPY|MAP_FILE, fd, c->mapoff)
Алгоритм обработки всех сегментов следующий:
Это дает злоумышленнику возможность сделать ELF-файл, один из сегментов которого может полностью переопределить существующий регион памяти, например стек потока, кучу или код библиотеки.
Примером уязвимого приложения может служить утилита ldd, с помощью которой проверяется наличие в системе необходимых библиотек. Утилита использует интерпретатор ld. Благодаря найденной проблеме с загрузкой ELF-файлов удалось выполнить произвольный код, используя ldd:
blackzert@crasher:~/aslur/tests/evil_elf$ ldd ./main root:x:0:0:root:/root:/bin/bash daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin bin:x:2:2:bin:/bin:/usr/sbin/nologin sys:x:3:3:sys:/dev:/usr/sbin/nologin sync:x:4:65534:sync:/bin:/bin/sync games:x:5:60:games:/usr/games:/usr/sbin/nologin man:x:6:12:man:/var/cache/man:/usr/sbin/nologin lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin mail:x:8:8:mail:/var/mail:/usr/sbin/nologin blackzert@crasher:~/aslur/tests/evil_elf$
В данном случае был прочитан файл /etc/passwd
. Нормальный же запуск выглядит примерно следующим образом:
blackzert@crasher:~/aslur/tests/evil_elf$ ldd ./main linux-vdso.so.1 => (0x00007ffc48545000) libevil.so => ./libevil.so (0x00007fbfaf53a000) libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fbfaf14d000) /lib64/ld-linux-x86-64.so.2 (0x000055dda45e6000)
В ознакомительных целях исходный код этого примера приводится в папке evil_elf.
Вопрос о MAP_FIXED также был поднят в сообществе Linux в (Hocko, Michal. mm: introduce MAP_FIXED_SAFE), однако на данный момент предложенный патч не принят.
В glibc также существует множество разных кешей, среди которых есть два наиболее интересных в контексте ASLR — кеш для стека создаваемого потока и кеш для кучи. Кеш для стека работает следующим образом: по завершении потока память стека не будет освобождена, а будет помещена в соответствующий кеш. При создании стека потока glibc сначала проверяет кеш и, если в нем есть регион необходимой длины, использует этот регион. В этом случае обращения к mmap не последует и новый поток будет использовать ранее используемый регион, имеющий те же самые адреса. Если злоумышленнику удалось получить адрес стека потока и он может контролировать создание и удаление потоков программой, то он может использовать полученное знание адреса для эксплуатации соответствующей уязвимости. Кроме того, если приложение содержит неинициализированные переменные, их значения также могут быть подконтрольны злоумышленнику, что в некоторых случаях может приводить к эксплуатации.
Кеш для кучи потока работает следующим образом: после завершения потока созданная для него куча отправляется в соответствующий кеш. При следующем создании кучи для нового потока сначала проверяется кеш, и, если в нем есть регион, он будет использован. Тогда также справедливо все сказанное для стека.
Рандомизацию адресного пространства можно обойти разными способами. В чём главная причина?
Загрузка ...
Возможно, mmap используется и в других случаях. А значит, обнаруженная проблема приводит к целому классу потенциально уязвимых приложений.
Можно выделить несколько примеров, наглядно показывающих найденные проблемы.
Материалы из последних выпусков можно покупать отдельно только через два месяца после публикации. Чтобы продолжить чтение, необходимо купить подписку.
Подписка позволит тебе в течение указанного срока читать ВСЕ платные материалы сайта. Мы принимаем оплату банковскими картами, электронными деньгами и переводами со счетов мобильных операторов. Подробнее о подписке
1 год6290 р. Экономия 1400 рублей! |
1 месяц720 р. 25-30 статей в месяц |
Уже подписан?
Читайте также
Последние новости