Речь в этой статье пойдёт о небольшой security-фиче, добавленной в GNU ld к релизу 2.30 в декабре 2018 года. На русском языке это улучшение упоминалось на opennet с такой аннотацией:
режим "-z separate-code", повышающий защищённость исполняемых файлов ценой небольшого увеличения размера и потребления памяти
Давайте разберёмся. Чтобы объяснить, о какой проблеме безопасности идёт речь и в чём состоит решение, начнём с общих черт эксплойтов бинарных уязвимостей.
Проблемы перехвата потока управления в эксплойтах
Атакующий может передавать данные в программу и манипулировать ей таким образом с помощью различных уязвимостей: записи по индексу за границу массива, небезопасного копирования строк, использования объектов после освобождения. Такие ошибки характерны для программного кода языков C и C++ и могут приводить к повреждениям памяти при определённых входных данных для программы.
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 уязвимостей — это перезапись указателя в памяти. Указатель далее будет использован программой для передачи управления на другой код: для вызова метода класса или функции из другого модуля, для возврата из функции. А поскольку указатель был перезаписан, то управление будет перехвачено атакующим — то есть, будет исполнен подготовленный им код. Если вам интересны вариации и детали этих техник, рекомендуем почитать документ.
Этот общий момент работы подобных эксплойтов известен, и здесь для атакующего уже давно расставлены преграды:
- Проверка целостности указателей перед передачей управления: stack cookies, control flow guard, pointer authentication
- Рандомизация адресов сегментов с кодом и данными: address space layout randomization
- Запрет исполнения кода за пределами кодовых сегментов: 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
. Действие этого флага состоит в том, что любая читаемая память становится также исполняемой. Флаг может быть установлен вашему процессу по нескольким причинам:
- Может быть явно запрошен legacy софтом флагом в заголовке ELF для реализации весьма интересного механизма: трамплина на стеке (1, 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
(загружаемых в память) сегмента — как и на листинге выше:
-
Сегмент с флагами
RE
1.1. Исполняемый код в ELF: секции
.init
,.text
,.fini
1.2. Неизменяемые данные в ELF: секции
.symtab
,.rodata
-
Сегмент с флагами
RW
2.1. Изменяемые данные в ELF: секции
.plt
,.got
,.data
,.bss
Если обратить внимание на состав первого сегмента и его флаги доступа, то становится ясно, что такая компоновка расширяет пространство для поиска гаджетов для code reuse техник. В крупных ELF, таких как libcrypto, служебные таблицы и другие неизменяемые данные могут занимать до 40% исполняемого сегмента. Наличие в этих данных чего-то похожего на кусочки кода подтверждается попытками дизассемблировать такие бинарные файлы с большим количеством данных в исполняемом сегменте без таблиц секций и символов. Каждая последовательность байт в этом едином исполняемом сегменте может быть рассмотрена как полезный для атакующего фрагмент машинного кода и трамплин — будь эта последовательность байт хоть куском строки отладочного сообщения из программы, частью имени функции в таблице символов или числом-константой криптографического алгоритма…
Исполняемые заголовки и таблицы в начале первого сегмента образа ELF напоминают ситуацию с Windows около 15 лет назад. Был ряд вирусов, заражавших файлы, вписывая свой код в их заголовок PE, который там также был ещё исполняемым. Мне удалось откопать такой семпл в архиве:
Как видно, тело вируса втиснуто сразу после таблицы секций в зоне расположения заголовков 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.
Как известно, структура выходного 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