Странная причудливость псевдофайла /proc/*/mem заключается в его «пробивной» семантике. Операции записи через этот файл будут успешными даже если целевая виртуальная память помечена как недоступная для записи. Это сделано намеренно, и такое поведение активно используется проектами вроде компилятора Julia JIT или отладчика rr.
Но возникают вопросы: подчиняется ли привилегированный код разрешениям виртуальной памяти? До какой степени оборудование может влиять на доступ к памяти ядра?
Мы постараемся ответить на эти вопросы и рассмотрим нюансы взаимодействия между операционной системой и оборудованием, на котором она исполняется. Изучим ограничения процессора, которые могут влиять на ядро, и узнаем, как ядро может их обходить.
Как выглядит эта пробивная семантика? Рассмотрим код:
#include <fstream>
#include <iostream>
#include <sys/mman.h>
/* Write @len bytes at @ptr to @addr in this address space using
* /proc/self/mem.
*/
void memwrite(void *addr, char *ptr, size_t len) {
std:fstream ff("/proc/self/mem");
ff.seekp(reinterpret_cast<size_t>(addr));
ff.write(ptr, len);
ff.flush();
}
int main(int argc, char **argv) {
// Map an unwritable page. (read-only)
auto mymap =
(int *)mmap(NULL, 0x9000,
PROT_READ, // <<<<<<<<<<<<<<<<<<<<< READ ONLY <<<<<<<<
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (mymap == MAP_FAILED) {
std::cout << "FAILED\n";
return 1;
}
std::cout << "Allocated PROT_READ only memory: " << mymap << "\n";
getchar();
// Try to write to the unwritable page.
memwrite(mymap, "\x40\x41\x41\x41", 4);
std::cout << "did mymap[0] = 0x41414140 via proc self mem..";
getchar();
std::cout << "mymap[0] = 0x" << std::hex << mymap[0] << "\n";
getchar();
// Try to writ to the text segment (executable code) of libc.
auto getchar_ptr = (char *)getchar;
memwrite(getchar_ptr, "\xcc", 1);
// Run the libc function whose code we modified. If the write worked,
// we will get a SIGTRAP when the 0xcc executes.
getchar();
}
Здесь /proc/self/mem используется для записи в две недоступные для записи страницы памяти. Первая содержит сам код, а вторая принадлежит libc (функции getchar). Последняя часть вызывает больший интерес: код записывает байт 0xcc (точка прерывания в приложениях под x86-64), который в случае своего исполнения заставит ядро предоставить нашему процессу SIGTRAP. Это буквально меняет исполняемый код libc. И если при следующем вызове getchar мы получим SIGTRAP, то будем знать, что запись была успешной.
Вот как это выглядит при запуске программы:
Работает! Посередине выводятся выражения, которые доказывают, что значение 0x41414140 было успешно записано и считано из памяти. Последний вывод показывает, что после патчинга наш процесс получил SIGTRAP в результате нашего вызова getchar.
На видео:
Мы увидели, как эта возможность работает с точки зрения пользовательского пространства. Давайте копнём глубже. Чтобы полностью разобраться в том, как это работает, нужно посмотреть, как оборудование накладывает ограничения на память.
На платформе x86-64 есть две настройки процессора, которые управляют возможностью ядра обращаться к памяти. Они применяются модулем управления памятью (MMU).
Первая настройка — бит защиты от записи, Write Protect bit (CR0.WP). Из руководства Intel (том 3, раздел 2.5) мы знаем:
Это не позволяет ядру записывать в защищённые от записи страницы, что, естественно, по умолчанию разрешено.
Вторая настройка — предотвращение доступа в режиме супервизора, Supervisor Mode Access Prevention (SMAP) (CR4.SMAP). Полное описание, приведённое в томе 3, в разделе 4.6, многословное. Если вкратце, то SMAP полностью лишает ядро способности записывать или читать из памяти пользовательского пространства. Это предотвращает эксплойты, которые наполняют пользовательское пространство зловредными данными, которые ядро должно прочитать в ходе исполнения.
Если код ядра использует для доступа к пользовательскому пространству только одобренные каналы (copy_to_user и т.д.), то SMAP можно смело игнорировать, эти функции автоматически задействуют его до и после обращения к памяти. А что насчёт защиты от записи?
Если CR0.WP не задан, то реализация /proc/*/mem в ядре действительно сможет бесцеремонно записывать в защищённую от записи память пользовательского пространства.
Однако CR0.WP задаётся при загрузке и обычно живёт в течение всего времени работы систем. В этом случае при попытке записи будет выдаваться сбой страницы. Это, скорее, инструмент для копирования при записи (Copy-on-Write), чем средство защиты, поэтому не накладывает на ядро никаких реальных ограничений. Иными словами, требуется неудобная обработка сбоев, которая не обязательна при заданном бите.
Давайте теперь разберёмся с реализацией.
/proc/*/mem реализован в fs/proc/base.c.
Структура file_operations содержит функции обработчика, а функция mem_rw() полностью поддерживает обработчик записи. mem_rw() использует для операций записи access_remote_vm(). А access_remote_vm() делает вот что:
Эта реализация полностью обходит вопрос о способности ядра записывать в незаписываемую память пользовательского пространства! Контроль ядра над подсистемой виртуальной памяти позволяет полностью обойти MMU, позволяя ядру просто записывать в свое собственное адресное пространство, доступное для записи. Так что обсуждения CR0.WP становится неактуальным.
Рассмотрим каждый из этапов:
get_user_pages_remote()
Для обхода MMU ядру нужно, чтобы в приложении было вручную выполнено то, что MMU делает аппаратно. Сначала необходимо преобразовать целевой виртуальный адрес в физический. Этим занимается семейство функций get_user_pages(). Они проходят по таблицам страниц и ищут фреймы физической памяти, которые соответствуют заданному диапазону виртуальных адресов.
Вызывающий предоставляет контекст и с помощью флагов меняет поведение get_user_pages(). Особенно интересен флаг FOLL_FORCE, который передаётся mem_rw(). Флаг запускает check_vma_flags (логику проверки доступа в get_user_pages()), чтобы игнорировать запись в незаписываемые страницы и продолжить поиск. «Пробивная» семантика полностью относится к FOLL_FORCE (комментарии мои):
static int check_vma_flags(struct vm_area_struct *vma, unsigned long gup_flags)
{
[...]
if (write) { // If performing a write..
if (!(vm_flags & VM_WRITE)) { // And the page is unwritable..
if (!(gup_flags & FOLL_FORCE)) // *Unless* FOLL_FORCE..
return -EFAULT; // Return an error
[...]
return 0; // Otherwise, proceed with lookup
}
get_user_pages() стоже соблюдает семантику копирования при записи (CoW). Если определяется запись в таблицу незаписываемой страницы, то эмулируется сбой страницы с помощью вызова handle_mm_fault, основного обработчика страничных ошибок. Это запускает соответствующую подпрограмму обработки копирования при записи посредством do_wp_page, которая при необходимости копирует страницу. Так что если записи через /proc/*/mem выполняются приватным разделяемым маппингом, например, libc, то они видимы только в рамках процесса.
kmap()
После того, как найден физический фрейм, его нужно отобразить в виртуальное адресное пространство ядра, доступным для записи. Делается это с помощью kmap().
На 64-битной платформе x86 вся физическая память отображается через область линейного отображения виртуального адресного пространства ядра. В этом случае kmap() работает очень просто: ему лишь нужно добавить начальный адрес линейного отображения в физический адрес фрейма, чтобы вычислить виртуальный адрес, в который отображён этот фрейм.
На 32-битной платформе x86 линейное отображение содержит подмножество физической памяти, так что функции kmap() может потребоваться отобразить фрейм с помощью выделения highmem-памяти и манипулирования таблицами страницы.
В обоих случая линейное отображение и highmem-отображение выполняются с защитой PAGE_KERNEL, которая позволяет запись.
copy_to_user_page()
Последний этап — выполнение записи. Это делается с помощью copy_to_user_page(), по сути — memcpy. Это работает, поскольку целью является записываемое отображение из kmap().
Итак, сначала ядро с помощью таблицы страницы памяти, принадлежащей, программе, преобразует целевой виртуальный адрес в пользовательском пространстве в соответствующий физический фрейм. Затем ядро отображает этот фрейм в собственное виртуальное пространство с возможностью записи. И наконец записывает с помощью простого memcpy.
Поразительно, что здесь не используется CR0.WP. Реализация элегантно обходит этот момент, пользуясь тем, что она не обязана обращаться к памяти через указатель, полученный из пользовательского пространства. Поскольку ядро целиком контролирует виртуальную память, оно может просто переотобразить физический фрейм в собственное виртуальное адресное пространство с произвольными разрешениями и делать с ним что угодно.
Важно отметить: разрешения, которые защищают страницу памяти, связаны с виртуальным адресом, используемым для доступа к этой странице, а не с физическим фреймом, относящимся к странице. Нотаций разрешений по работе с памятью относится исключительно к виртуальной памяти, а не к физической.
Изучив подробности «пробивной» семантики в реализации /proc/*/mem мы можем отразить взаимосвязи между ядром и процессором. На первый взгляд, способность ядра писать в недоступную для записи память вызывает вопрос: до какой степени процессор может влиять на доступ ядра к памяти? В руководстве описаны механизмы управления, которые могут ограничивать действия ядра. Но при внимательном рассмотрении оказывается, что ограничения в лучшем случае поверхностны. Это простые препятствия, которые можно обойти.
Источник статьи: https://habr.com/ru/company/mailru/blog/559322/
Но возникают вопросы: подчиняется ли привилегированный код разрешениям виртуальной памяти? До какой степени оборудование может влиять на доступ к памяти ядра?
Мы постараемся ответить на эти вопросы и рассмотрим нюансы взаимодействия между операционной системой и оборудованием, на котором она исполняется. Изучим ограничения процессора, которые могут влиять на ядро, и узнаем, как ядро может их обходить.
Патчим libc с помощью /proc/self/mem
Как выглядит эта пробивная семантика? Рассмотрим код:
#include <fstream>
#include <iostream>
#include <sys/mman.h>
/* Write @len bytes at @ptr to @addr in this address space using
* /proc/self/mem.
*/
void memwrite(void *addr, char *ptr, size_t len) {
std:fstream ff("/proc/self/mem");
ff.seekp(reinterpret_cast<size_t>(addr));
ff.write(ptr, len);
ff.flush();
}
int main(int argc, char **argv) {
// Map an unwritable page. (read-only)
auto mymap =
(int *)mmap(NULL, 0x9000,
PROT_READ, // <<<<<<<<<<<<<<<<<<<<< READ ONLY <<<<<<<<
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (mymap == MAP_FAILED) {
std::cout << "FAILED\n";
return 1;
}
std::cout << "Allocated PROT_READ only memory: " << mymap << "\n";
getchar();
// Try to write to the unwritable page.
memwrite(mymap, "\x40\x41\x41\x41", 4);
std::cout << "did mymap[0] = 0x41414140 via proc self mem..";
getchar();
std::cout << "mymap[0] = 0x" << std::hex << mymap[0] << "\n";
getchar();
// Try to writ to the text segment (executable code) of libc.
auto getchar_ptr = (char *)getchar;
memwrite(getchar_ptr, "\xcc", 1);
// Run the libc function whose code we modified. If the write worked,
// we will get a SIGTRAP when the 0xcc executes.
getchar();
}
Здесь /proc/self/mem используется для записи в две недоступные для записи страницы памяти. Первая содержит сам код, а вторая принадлежит libc (функции getchar). Последняя часть вызывает больший интерес: код записывает байт 0xcc (точка прерывания в приложениях под x86-64), который в случае своего исполнения заставит ядро предоставить нашему процессу SIGTRAP. Это буквально меняет исполняемый код libc. И если при следующем вызове getchar мы получим SIGTRAP, то будем знать, что запись была успешной.
Вот как это выглядит при запуске программы:
Работает! Посередине выводятся выражения, которые доказывают, что значение 0x41414140 было успешно записано и считано из памяти. Последний вывод показывает, что после патчинга наш процесс получил SIGTRAP в результате нашего вызова getchar.
На видео:
Мы увидели, как эта возможность работает с точки зрения пользовательского пространства. Давайте копнём глубже. Чтобы полностью разобраться в том, как это работает, нужно посмотреть, как оборудование накладывает ограничения на память.
Оборудование
На платформе x86-64 есть две настройки процессора, которые управляют возможностью ядра обращаться к памяти. Они применяются модулем управления памятью (MMU).
Первая настройка — бит защиты от записи, Write Protect bit (CR0.WP). Из руководства Intel (том 3, раздел 2.5) мы знаем:
Защита от записи (16-й бит CR0). Если задана, он не даёт процедурам уровня супервизора записывать в защищённые от записи страницы. Если бит пуст, то процедуры уровня супервизора могут записывать в защищённые от записи страницы (вне зависимости от настроек бита U/S; см. раздел 4.1.3 и 4.6).
Это не позволяет ядру записывать в защищённые от записи страницы, что, естественно, по умолчанию разрешено.
Вторая настройка — предотвращение доступа в режиме супервизора, Supervisor Mode Access Prevention (SMAP) (CR4.SMAP). Полное описание, приведённое в томе 3, в разделе 4.6, многословное. Если вкратце, то SMAP полностью лишает ядро способности записывать или читать из памяти пользовательского пространства. Это предотвращает эксплойты, которые наполняют пользовательское пространство зловредными данными, которые ядро должно прочитать в ходе исполнения.
Если код ядра использует для доступа к пользовательскому пространству только одобренные каналы (copy_to_user и т.д.), то SMAP можно смело игнорировать, эти функции автоматически задействуют его до и после обращения к памяти. А что насчёт защиты от записи?
Если CR0.WP не задан, то реализация /proc/*/mem в ядре действительно сможет бесцеремонно записывать в защищённую от записи память пользовательского пространства.
Однако CR0.WP задаётся при загрузке и обычно живёт в течение всего времени работы систем. В этом случае при попытке записи будет выдаваться сбой страницы. Это, скорее, инструмент для копирования при записи (Copy-on-Write), чем средство защиты, поэтому не накладывает на ядро никаких реальных ограничений. Иными словами, требуется неудобная обработка сбоев, которая не обязательна при заданном бите.
Давайте теперь разберёмся с реализацией.
Как работает /proc/*/mem
/proc/*/mem реализован в fs/proc/base.c.
Структура file_operations содержит функции обработчика, а функция mem_rw() полностью поддерживает обработчик записи. mem_rw() использует для операций записи access_remote_vm(). А access_remote_vm() делает вот что:
- Вызывает get_user_pages_remote(), чтобы найти физический фрейм, соответствующий целевому виртуальному адресу.
- Вызывает kmap(), чтобы пометить этот фрейм доступный для записи в виртуальном адресном пространстве ядра.
- Вызывает copy_to_user_page() для финального выполнения операций записи.
Эта реализация полностью обходит вопрос о способности ядра записывать в незаписываемую память пользовательского пространства! Контроль ядра над подсистемой виртуальной памяти позволяет полностью обойти MMU, позволяя ядру просто записывать в свое собственное адресное пространство, доступное для записи. Так что обсуждения CR0.WP становится неактуальным.
Рассмотрим каждый из этапов:
get_user_pages_remote()
Для обхода MMU ядру нужно, чтобы в приложении было вручную выполнено то, что MMU делает аппаратно. Сначала необходимо преобразовать целевой виртуальный адрес в физический. Этим занимается семейство функций get_user_pages(). Они проходят по таблицам страниц и ищут фреймы физической памяти, которые соответствуют заданному диапазону виртуальных адресов.
Вызывающий предоставляет контекст и с помощью флагов меняет поведение get_user_pages(). Особенно интересен флаг FOLL_FORCE, который передаётся mem_rw(). Флаг запускает check_vma_flags (логику проверки доступа в get_user_pages()), чтобы игнорировать запись в незаписываемые страницы и продолжить поиск. «Пробивная» семантика полностью относится к FOLL_FORCE (комментарии мои):
static int check_vma_flags(struct vm_area_struct *vma, unsigned long gup_flags)
{
[...]
if (write) { // If performing a write..
if (!(vm_flags & VM_WRITE)) { // And the page is unwritable..
if (!(gup_flags & FOLL_FORCE)) // *Unless* FOLL_FORCE..
return -EFAULT; // Return an error
[...]
return 0; // Otherwise, proceed with lookup
}
get_user_pages() стоже соблюдает семантику копирования при записи (CoW). Если определяется запись в таблицу незаписываемой страницы, то эмулируется сбой страницы с помощью вызова handle_mm_fault, основного обработчика страничных ошибок. Это запускает соответствующую подпрограмму обработки копирования при записи посредством do_wp_page, которая при необходимости копирует страницу. Так что если записи через /proc/*/mem выполняются приватным разделяемым маппингом, например, libc, то они видимы только в рамках процесса.
kmap()
После того, как найден физический фрейм, его нужно отобразить в виртуальное адресное пространство ядра, доступным для записи. Делается это с помощью kmap().
На 64-битной платформе x86 вся физическая память отображается через область линейного отображения виртуального адресного пространства ядра. В этом случае kmap() работает очень просто: ему лишь нужно добавить начальный адрес линейного отображения в физический адрес фрейма, чтобы вычислить виртуальный адрес, в который отображён этот фрейм.
На 32-битной платформе x86 линейное отображение содержит подмножество физической памяти, так что функции kmap() может потребоваться отобразить фрейм с помощью выделения highmem-памяти и манипулирования таблицами страницы.
В обоих случая линейное отображение и highmem-отображение выполняются с защитой PAGE_KERNEL, которая позволяет запись.
copy_to_user_page()
Последний этап — выполнение записи. Это делается с помощью copy_to_user_page(), по сути — memcpy. Это работает, поскольку целью является записываемое отображение из kmap().
Обсуждение
Итак, сначала ядро с помощью таблицы страницы памяти, принадлежащей, программе, преобразует целевой виртуальный адрес в пользовательском пространстве в соответствующий физический фрейм. Затем ядро отображает этот фрейм в собственное виртуальное пространство с возможностью записи. И наконец записывает с помощью простого memcpy.
Поразительно, что здесь не используется CR0.WP. Реализация элегантно обходит этот момент, пользуясь тем, что она не обязана обращаться к памяти через указатель, полученный из пользовательского пространства. Поскольку ядро целиком контролирует виртуальную память, оно может просто переотобразить физический фрейм в собственное виртуальное адресное пространство с произвольными разрешениями и делать с ним что угодно.
Важно отметить: разрешения, которые защищают страницу памяти, связаны с виртуальным адресом, используемым для доступа к этой странице, а не с физическим фреймом, относящимся к странице. Нотаций разрешений по работе с памятью относится исключительно к виртуальной памяти, а не к физической.
Заключение
Изучив подробности «пробивной» семантики в реализации /proc/*/mem мы можем отразить взаимосвязи между ядром и процессором. На первый взгляд, способность ядра писать в недоступную для записи память вызывает вопрос: до какой степени процессор может влиять на доступ ядра к памяти? В руководстве описаны механизмы управления, которые могут ограничивать действия ядра. Но при внимательном рассмотрении оказывается, что ограничения в лучшем случае поверхностны. Это простые препятствия, которые можно обойти.
Источник статьи: https://habr.com/ru/company/mailru/blog/559322/