ld -z separate-code

в 6:49, , рубрики: compiler, exploit mitigation, linker, linux, security, Блог компании «Digital Security», информационная безопасность, Разработка под Linux

ld -z separate-code - 1

Речь в этой статье пойдёт о небольшой security-фиче, добавленной в GNU ld к релизу 2.30 в декабре 2018 года. На русском языке это улучшение упоминалось на opennet с такой аннотацией:

режим "-z separate-code", повышающий защищённость исполняемых файлов ценой небольшого увеличения размера и потребления памяти

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

Проблемы перехвата потока управления в эксплойтах

Атакующий может передавать данные в программу и манипулировать ей таким образом с помощью различных уязвимостей: записи по индексу за границу массива, небезопасного копирования строк, использования объектов после освобождения. Такие ошибки характерны для программного кода языков C и C++ и могут приводить к повреждениям памяти при определённых входных данных для программы.

Memory corruption уязвимости

CWE-20: Improper Input Validation
CWE-118: Incorrect Access of Indexable Resource ('Range Error')
CWE-119: Improper Restriction of Operations within the Bounds of a Memory Buffer
CWE-120: Buffer Copy without Checking Size of Input ('Classic Buffer Overflow')
CWE-121: Stack-based Buffer Overflow
CWE-122: Heap-based Buffer Overflow
CWE-123: Write-what-where Condition
CWE-124: Buffer Underwrite ('Buffer Underflow')
CWE-125: Out-of-bounds Read
CWE-126: Buffer Over-read
CWE-127: Buffer Under-read
CWE-128: Wrap-around Error
CWE-129: Improper Validation of Array Index
CWE-130: Improper Handling of Length Parameter Inconsistency
CWE-131: Incorrect Calculation of Buffer Size
CWE-134: Use of Externally-Controlled Format String
CWE-135: Incorrect Calculation of Multi-Byte String Length
CWE-170: Improper Null Termination
CWE-190: Integer Overflow or Wraparound
CWE-415: Double Free
CWE-416: Use After Free
CWE-476: NULL Pointer Dereference
CWE-787: Out-of-bounds Write
CWE-824: Access of Uninitialized Pointer

Классический элемент эксплойта подобных memory corruption уязвимостей — это перезапись указателя в памяти. Указатель далее будет использован программой для передачи управления на другой код: для вызова метода класса или функции из другого модуля, для возврата из функции. А поскольку указатель был перезаписан, то управление будет перехвачено атакующим — то есть, будет исполнен подготовленный им код. Если вам интересны вариации и детали этих техник, рекомендуем почитать документ.

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

  1. Проверка целостности указателей перед передачей управления: stack cookies, control flow guard, pointer authentication
  2. Рандомизация адресов сегментов с кодом и данными: address space layout randomization
  3. Запрет исполнения кода за пределами кодовых сегментов: executable space protection

Далее мы сфокусируемся на защите последнего типа.

executable space protection

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

Для обхода запрета исполнения кода в сегментах данных используются Code reuse техники. То есть, атакующий передаёт управление на фрагменты кода (далее — гаджеты), расположенные на исполняемых страницах. Такого рода техники бывают разного уровня сложности, в порядке возрастания:

  • передача управления в функцию, которая сделает то, что достаточно атакующему: в функцию system() с контролируемым аргументом для запуска произвольных shell команд (ret2libc)
  • передача управления в функцию или цепочку гаджетов, которая отключит защиту или сделает часть памяти исполняемой (например, вызов mprotect()), с последующим исполнением произвольного кода
  • исполнение всех желаемых действий с помощью длинной цепочки гаджетов

Таким образом, перед атакующим встаёт задача переиспользования существующего кода в том или ином объёме. Если это что-то сложнее возврата в одну функцию, то потребуется составление цепочки гаджетов. Для поиска гаджетов по исполняемым сегментам существуют инструменты: ropper, ropgadget.

Дыра READ_IMPLIES_EXEC

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

Листинг /proc/$pid/maps:

00008000-00009000 r-xp 00000000 08:01 10         /var/flash/dmt/nx_test/a.out
00010000-00011000 rwxp 00000000 08:01 10         /var/flash/dmt/nx_test/a.out
00011000-00032000 rwxp 00000000 00:00 0          [heap]
40000000-4001f000 r-xp 00000000 1f:02 429        /lib/ld-linux.so.2
4001f000-40022000 rwxp 00000000 00:00 0 
40027000-40028000 r-xp 0001f000 1f:02 429        /lib/ld-linux.so.2
40028000-40029000 rwxp 00020000 1f:02 429        /lib/ld-linux.so.2
4002c000-40172000 r-xp 00000000 1f:02 430        /lib/libc.so.6
40172000-40179000 ---p 00146000 1f:02 430        /lib/libc.so.6
40179000-4017b000 r-xp 00145000 1f:02 430        /lib/libc.so.6
4017b000-4017c000 rwxp 00147000 1f:02 430        /lib/libc.so.6
4017c000-40b80000 rwxp 00000000 00:00 0 
be8c2000-be8d7000 rwxp 00000000 00:00 0          [stack]

Здесь вы видите карту памяти процесса тестовой утилиты. Карта состоит из областей памяти — строк таблицы. Сначала обратите внимание на правую колонку — она разъясняет содержимое области (сегменты кода, данных библиотек функций или самой программы) или её тип (куча, стек). Слева, по порядку, представлен диапазон адресов, который занимает каждая область памяти и, далее, флаги прав доступа: r (read), w (write), x (execute). Эти флаги определяют поведение системы при попытках чтения, записи и исполнения памяти на этих адресах. При нарушении обозначенного режима доступа возникает исключение.

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

Почему такая ситуация возникла на современном устройстве, которое аппаратно поддерживает запрет исполнения кода на страницах с данными, от устройства зависит безопасность корпоративных и промышленных сетей, а озвученная проблема и её решение известны уже очень давно?

Эта картина определяется поведением ядра во время инициализации процесса (выделения стека, кучи, загрузки основного ELF и т.д.) и во время исполнения ядерных вызовов процесса. Ключевой атрибут, влияющий на это — personality флаг READ_IMPLIES_EXEC. Действие этого флага состоит в том, что любая читаемая память становится также исполняемой. Флаг может быть установлен вашему процессу по нескольким причинам:

  1. Может быть явно запрошен legacy софтом флагом в заголовке ELF для реализации весьма интересного механизма: трамплина на стеке (1, 2, 3)!
  2. Может быть наследован дочерними процессами от родительского.
  3. Может быть установлен ядром самостоятельно для всех процессов! Во-первых, если архитектура не поддерживает неисполняемую память. Во-вторых, на всякий случай для поддержки ещё каких-то древних костылей. Этот код есть в ядре 2.6.32 (ARM), которое имело очень долгий срок жизни. Это был как раз наш случай.

Пространство для поиска гаджетов в образе ELF

Библиотеки функций и исполняемые файлы программ имеют формат ELF. Компилятор gcc транслирует конструкции языка в машинный код и складывает его в одни секции, а данные, которыми оперирует этот код в другие секции. Секций много и они группируются компоновщиком ld в сегменты. Таким образом, ELF содержит в себе образ программы, который имеет два представляения: таблица секций и таблица сегментов.

$ readelf -l /bin/ls

Elf file type is EXEC (Executable file)
Entry point 0x804bee9
There are 9 program headers, starting at offset 52

Program Headers:
  Type           Offset   VirtAddr   PhysAddr   FileSiz MemSiz  Flg Align
  PHDR           0x000034 0x08048034 0x08048034 0x00120 0x00120 R E 0x4
  INTERP         0x000154 0x08048154 0x08048154 0x00013 0x00013 R   0x1
      [Requesting program interpreter: /lib/ld-linux.so.2]
  LOAD           0x000000 0x08048000 0x08048000 0x1e40c 0x1e40c R E 0x1000
  LOAD           0x01ef00 0x08067f00 0x08067f00 0x00444 0x01078 RW  0x1000
  DYNAMIC        0x01ef0c 0x08067f0c 0x08067f0c 0x000f0 0x000f0 RW  0x4
  NOTE           0x000168 0x08048168 0x08048168 0x00044 0x00044 R   0x4
  GNU_EH_FRAME   0x018b74 0x08060b74 0x08060b74 0x00814 0x00814 R   0x4
  GNU_STACK      0x000000 0x00000000 0x00000000 0x00000 0x00000 RW  0x10
  GNU_RELRO      0x01ef00 0x08067f00 0x08067f00 0x00100 0x00100 R   0x1

 Section to Segment mapping:
  Segment Sections...
   00     
   01     .interp 
   02     .interp .note.ABI-tag .note.gnu.build-id .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rel.dyn .rel.plt .init .plt .plt.got .text .fini .rodata .eh_frame_hdr .eh_frame 
   03     .init_array .fini_array .jcr .dynamic .got .got.plt .data .bss 
   04     .dynamic 
   05     .note.ABI-tag .note.gnu.build-id 
   06     .eh_frame_hdr 
   07     
   08     .init_array .fini_array .jcr .dynamic .got 

Здесь вы видите отображение секций на сегменты в образе ELF.

Таблица секций используется утилитами для анализа программ и библиотек, но не используется загрузчиками для проецирования ELF в память процесса. Таблица секций описывает структуру ELF более подробно, чем таблица сегментов. Несколько секций могут быть внутри одного сегмента.

Образ ELF в памяти создаётся загрузчиками ELF на основе содержимого таблицы сегментов. Для загрузки ELF в память таблица секций уже не используется.

Но для этого правила есть исключения

Например, в природе существует патч разработчиков Debian для загрузчика ELF ld.so для архитектуры ARM, который ищет специальную секцию ".ARM.attributes" типа SHT_ARM_ATTRIBUTES и бинари с отпиленной таблицей секций в такой системе не грузятся…

Сегмент ELF имеет флаги, которые определяют, какие права доступа будут к сегменту в памяти. Традиционно большая часть софта для GNU/Linux компоновалась так, что в таблице сегментов было объявлено два PT_LOAD (загружаемых в память) сегмента — как и на листинге выше:

  1. Сегмент с флагами RE

    1.1. Исполняемый код в ELF: секции .init, .text, .fini

    1.2. Неизменяемые данные в ELF: секции .symtab, .rodata

  2. Сегмент с флагами RW

    2.1. Изменяемые данные в ELF: секции .plt, .got, .data, .bss

Если обратить внимание на состав первого сегмента и его флаги доступа, то становится ясно, что такая компоновка расширяет пространство для поиска гаджетов для code reuse техник. В крупных ELF, таких как libcrypto, служебные таблицы и другие неизменяемые данные могут занимать до 40% исполняемого сегмента. Наличие в этих данных чего-то похожего на кусочки кода подтверждается попытками дизассемблировать такие бинарные файлы с большим количеством данных в исполняемом сегменте без таблиц секций и символов. Каждая последовательность байт в этом едином исполняемом сегменте может быть рассмотрена как полезный для атакующего фрагмент машинного кода и трамплин — будь эта последовательность байт хоть куском строки отладочного сообщения из программы, частью имени функции в таблице символов или числом-константой криптографического алгоритма…

Исполняемые заголовки PE

Исполняемые заголовки и таблицы в начале первого сегмента образа ELF напоминают ситуацию с Windows около 15 лет назад. Был ряд вирусов, заражавших файлы, вписывая свой код в их заголовок PE, который там также был ещё исполняемым. Мне удалось откопать такой семпл в архиве:

Virus.Win32.Haless.1127

Как видно, тело вируса втиснуто сразу после таблицы секций в зоне расположения заголовков PE. В проекции файла на виртуальную память здесь обычно имеется около 3Кб свободного места. После тела вируса идёт пустое пространство и далее начинается первая секция с кодом программы.

Впрочем, для Linux были гораздо более интересные произведения VX сцены: Retaliation.

Решение

  • Описанная выше проблема была известна давно.
  • Исправлено 12 января 2018: добавлен ключ `ld -z separate-code: "Create separate code "PT_LOAD" segment header in the object. This specifies a memory segment that should contain only instructions and must be in wholly disjoint pages from any other data. Don't create separate code "PT_LOAD" segment if noseparate-code is used."). Фича вошла в релиз 2.30.
  • Далее эта фича была включена по умолчанию в следующем релизе 2.31.
  • Присутствует в свежих пакетах binutils, например, в репозиториях Ubuntu 18.10. Многие пакеты же уже были собраны с этой новой фичей, с чем столкнулся и задокументировал исследователь ElfMaster

В результате изменений алгоритма компоновки получается новая картина ELF:

$ readelf -l ls

Elf file type is DYN (Shared object file)
Entry point 0x41aa
There are 11 program headers, starting at offset 52

Program Headers:
  Type           Offset   VirtAddr   PhysAddr   FileSiz MemSiz  Flg Align
  PHDR           0x000034 0x00000034 0x00000034 0x00160 0x00160 R   0x4
  INTERP         0x000194 0x00000194 0x00000194 0x00013 0x00013 R   0x1
      [Requesting program interpreter: /lib/ld-linux.so.2]
  LOAD           0x000000 0x00000000 0x00000000 0x01e6c 0x01e6c R   0x1000
  LOAD           0x002000 0x00002000 0x00002000 0x14bd8 0x14bd8 R E 0x1000
  LOAD           0x017000 0x00017000 0x00017000 0x0bf80 0x0bf80 R   0x1000
  LOAD           0x0237f8 0x000247f8 0x000247f8 0x0096c 0x01afc RW  0x1000
  DYNAMIC        0x023cec 0x00024cec 0x00024cec 0x00100 0x00100 RW  0x4
  NOTE           0x0001a8 0x000001a8 0x000001a8 0x00044 0x00044 R   0x4
  GNU_EH_FRAME   0x01c3f8 0x0001c3f8 0x0001c3f8 0x0092c 0x0092c R   0x4
  GNU_STACK      0x000000 0x00000000 0x00000000 0x00000 0x00000 RW  0x10
  GNU_RELRO      0x0237f8 0x000247f8 0x000247f8 0x00808 0x00808 R   0x1

 Section to Segment mapping:
  Segment Sections...
   00     
   01     .interp 
   02     .interp .note.ABI-tag .note.gnu.build-id .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rel.dyn .rel.plt 
   03     .init .plt .plt.got .text .fini 
   04     .rodata .eh_frame_hdr .eh_frame 
   05     .init_array .fini_array .data.rel.ro .dynamic .got .data .bss 
   06     .dynamic 
   07     .note.ABI-tag .note.gnu.build-id 
   08     .eh_frame_hdr 
   09     
   10     .init_array .fini_array .data.rel.ro .dynamic .got 

Граница между кодом и данными теперь более точная. Единственный исполняемый сегмент действительно содержит только кодовые секции: .init, .plt, .plt.got, .text, .fini.

Что именно было изменено внутри ld?

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

$ ld --verbose
GNU ld (GNU Binutils for Ubuntu) 2.26.1
* * *
using internal linker script:
==================================================
/* Script for -z combreloc: combine and sort reloc sections */
/* Copyright (C) 2014-2015 Free Software Foundation, Inc.
* * *

Многие другие скрипты для разных платформ и комбинаций опций размещены в директории ldscripts. Для опции separate-code были созданы новые скрипты.

$ diff elf_x86_64.x elf_x86_64.xe
1c1
< /* Default linker script, for normal executables */
---
> /* Script for -z separate-code: generate normal executables with separate code segment */
46a47
>   . = ALIGN(CONSTANT (MAXPAGESIZE));
70a72,75
>   . = ALIGN(CONSTANT (MAXPAGESIZE));
>   /* Adjust the address for the rodata segment.  We want to adjust up to
>      the same address within the page on the next page up.  */
>   . = SEGMENT_START("rodata-segment", ALIGN(CONSTANT (MAXPAGESIZE)) + (. & (CONSTANT (MAXPAGESIZE) - 1)));

Здесь видно, что была добавлена директива для объявления нового сегмента с read-only секциями, следующего после кодового сегмента.

Однако кроме скриптов изменения были внесены и в исходники компоновщика. А именно в функцию _bfd_elf_map_sections_to_segments — см. коммит. Теперь при выделении сегментов для секций будет добавляться новый сегмент, когда секция отличается по флагу SEC_CODE от предыдущей секции.

Вывод

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

Автор: d-t

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js