Windows System Call Tables
The repository contains system call tables collected from all modern and most older releases of Windows, starting with Windows NT.
Both 32-bit and 64-bit builds were analyzed, and the tables were extracted from both the core kernel image (ntoskrnl.exe
) and the graphical subsystem (win32k.sys
).
Formats
The data is formatted in the CSV and JSON formats for programmatic use, and as an HTML table for manual inspection.
The HTML files are also hosted on my blog under the following links:
ntoskrnl.exe
, x86: https://j00ru.vexillium.org/syscalls/nt/32/ntoskrnl.exe
, x64: https://j00ru.vexillium.org/syscalls/nt/64/win32k.sys
, x86: https://j00ru.vexillium.org/syscalls/win32k/32/win32k.sys
, x64: https://j00ru.vexillium.org/syscalls/win32k/64/
Operating systems
The following major versions of Windows are included in the tables:
System | x86 versions | x64 versions |
---|---|---|
Windows NT 3.x | 3.1, 3.5, 3.51 | — |
Windows NT 4.0 | SP0, SP1, SP2, SP3, SP3 Terminal Server Edition, SP4, SP5, SP6 | — |
Windows 2000 | SP0, SP1, SP2, SP3, SP4 | — |
Windows XP | SP0, SP1, SP2, SP3 | SP1, SP2 |
Windows Server 2003 | SP0, SP1, SP2, R2, R2 SP2 | SP0, SP2, R2, R2 SP2 |
Windows Vista | SP0, SP1, SP2 | SP0, SP1, SP2 |
Windows 7 | SP0, SP1 | SP0, SP1 |
Windows 8 | 8.0, 8.1 | 8.0, 8.1 |
Windows 10 | 1507, 1511, 1607, 1703, 1709, 1803, 1809, 1903, 1909, 2004, 20H2, 21H1, 21H2, 22H2 | 1507, 1511, 1607, 1703, 1709, 1803, 1809, 1903, 1909, 2004, 20H2, 21H1, 21H2, 22H2 |
Windows 11 | — | 21H2, 22H2, 23H2, 24H2 |
Windows Server | — | 2022, 23H2, 2025 |
Some older versions of Windows Server are not included, as their syscall tables are equivalent to these of desktop Windows editions:
Windows Server version | Windows Desktop version |
---|---|
2008 SP0/SP2 | Vista SP1/SP2 |
2008 R2 SP0/SP1 | 7 SP0/SP1 |
2012 SP0 | 8.0 |
2012 R2 | 8.1 |
2016 LTSC (1607) | 10 1607 |
1709 | 10 1709 |
1803 | 10 1803 |
2019 LTSC (1809) | 10 1809 |
1903 | 10 1903 |
1909 | 10 1909 |
2004 | 10 2004 |
20H2 | 10 20H2 |
Historical system call counts
Below is a line chart showing the progression of Windows system call development over time. It covers all major desktop versions of Windows starting with Windows NT 3.1 released in July 1993, up to the most recent versions of Windows 11. Server editions are not included for brevity. The analysis was performed on x86 builds for consistency, with the exception of Windows 11 where x64 is the only supported platform. There might be very small differences on x64 builds of the kernel or the less popular editions (e.g. Windows NT 4.0 Terminal Server Edition), but they are insignificant for the purpose of this overview chart.
Thanks
We would like to thank the following contributors to the project: Woodmann, Deus, Gynvael Coldwind, MeMek, Alex, Omega Red, Wandering Glitch.
Contact
Mateusz ‘j00ru’ Jurczyk (j00ru.vx@gmail.com)
Введение
Системные вызовы выступают в роли посредников между приложениями и ядром. Они создают уровень абстракции, который защищает важные компоненты системы, такие как ЦП и память, от случайных ошибок и целенаправленных атак.
В этой статье мы рассмотрим, как работают различные системные вызовы, разберемся в их функциях, а также постараемся сделать работу вашей системы более безопасной и предсказуемой.
Что такое системный вызов?
Системный вызов – это интерфейс между программой, которая работает в пользовательском пространстве, и операционной системой (ОС). Прикладные программы используют системные вызовы для того, чтобы запрашивать действия и функции у ядра ОС. Этот механизм позволяет программе вызывать действия, например, чтение из файла, не обращаясь к системным ресурсам напрямую.
Когда программа обращается к системному вызову, контекст выполнения переключается с пользовательского режима в режим ядра, позволяя системе получить доступ к аппаратному обеспечению и безопасно выполнить необходимые операции. После того, как операции завершены, управление возвращает в пользовательский режим, а программа продолжает свое выполнение.
Такой многоуровневый подход, осуществляемый при помощи системных вызовов:
- Гарантирует изоляцию аппаратных ресурсов от процессов пользовательского пространства
- Не допускает прямого доступа к ядру или аппаратной памяти
- Позволяет коду приложения работать на различных аппаратных архитектурах
Какова задача системного вызова?
Системные вызовы выполняют несколько довольно важных функций:
- Граница между пользователем и ядром. При запросе действия у ядра системные вызовы выступают в качестве авторизованного шлюза для пользовательских программ. Они гарантируют, что последние не смогут получить доступ к функциям ядра или критически важным системным ресурсам без достаточных оснований.
- Управление ресурсами. С помощью системных вызовов пользовательские программы могут запрашивать важными ресурсами и управлять ими, например, временем ЦП, памятью и хранилищем файлов. ОС контролирует этот процесс и гарантирует, что он выполняется по всем правилам.
- Более простая разработка. Системные вызовы позволяют абстрагироваться от сложностей аппаратного обеспечения. Таким образом, разработчики могут выполнять такие операции, как чтение и запись в файл или управление данными сети, и не писать отдельный код для аппаратного обеспечения.
- Безопасность и контроль доступа. Системные вызовы выполняют проверки для того, чтобы гарантировать, что запросы, отправленные пользовательскими программами, действительны и что программы имеют необходимые права доступа для выполнения запрашиваемых операций.
- Межпроцессное взаимодействие (IPC — Inter-Process Communication). Системные вызовы предоставляют механизмы, с помощью которых процессы могут взаимодействовать друг с другом. Они предлагают такие средства, как каналы, очереди сообщений и общую память, для упрощения межпроцессного взаимодействия.
- Сетевые операции. Системные вызовы предоставляют фреймворк для того, чтобы программы могли взаимодействовать по сети. Разработчики могут полностью сконцентрироваться на построении логики своего приложения вместо того, чтобы уделять внимание низкоуровневому сетевому программированию.
Как работают системные вызовы?
Здесь кратко описано как работают системные вызовы:
1. Запрос системного вызова. Приложение запрашивает системный вызов, используя для этого соответствующую функцию. Например, для того, чтобы прочитать данные из файла, программа может использовать функцию read().
2. Переключение контекста в пространство ядра. Для инициирования переключения контекста и перехода из пользовательского режима в режим ядра используются программное прерывание и специальные инструкции.
3. Идентификация системного вызова. Для идентификации системного вызова и адреса соответствующей функции ядра система использует индекс.
4. Выполнение функции ядра. Выполняется функция ядра, соответствующая системному вызову, например, чтение данных из файла.
5. Подготовка возвращаемых значений. После того, как функция ядра завершает свою работу, все возвращаемые значения или результаты подготавливаются для отправки в пользовательское приложение.
6. Переключение контекста в пользовательское пространство. Контекст выполнения переключается обратно из режима ядра в пользовательский режим.
7. Возобновление работы приложения. Приложение возобновляет свою работу с момента своей остановки, но теперь уже с результатами системного вызова.
Примечание: точное количество шагов и принцип работы системных вызовов могут различаться в зависимости от операционной системы.
Каковы функции системных вызовов?
Ниже представлены функции, характеризующие системные вызовы:
- Безопасность. Системные вызовы гарантируют, что приложения пользовательского пространства никак не смогут навредить системе или помешать другим процессам.
- Абстракция. Программам, например, не нужно знать особенности конфигурации сетевого оборудования для того, чтобы отправлять данные через Интернет или выполнять операции с диском для чтения файлов, так как эти задачи выполняет операционная система.
- Контроль доступа. Системные вызовы обеспечивают соблюдение всех необходимых мер безопасности, проверяя, имеет ли программа соответствующие права доступа для обращения к ресурсам.
- Согласованность. Взаимодействие между ОС и программой остается согласованным, независимо от базовой конфигурации аппаратного обеспечения. Одна и та же программа может работать на разном оборудовании, главное, чтобы ее поддерживала операционная система.
- Синхронное выполнение. Многие системные вызовы выполняются синхронно, блокируя вызывающий процесс до завершения операции. Однако существуют и асинхронные системные вызовы, которые позволяют процессам продолжать выполнение, не ожидая завершения операции.
- Управление процессами. Системные вызовы упрощают управление процессами и выполнение нескольких задач одновременно за счет механизмов создания, завершения, планирования и синхронизации процессов.
- Управление файлами. Системные вызовы поддерживают операции с файлами, такие как такие как чтение, запись, открытие и закрытие файлов.
- Управление устройствами. С помощью системных вызовов процессы могут запрашивать доступ к устройствам, выполнять операции чтения/записи на этих устройствах и освобождать их.
- Управление ресурсами. Системные вызовы помогают выделять и освобождать ресурсы, такие как память, время ЦП и устройства ввода-вывода.
- Текущее обслуживание. Системные вызовы можно использовать для получения или настройки системной информации, такой как дата и время или состояние процесса.
- Взаимодействие. Системные вызовы позволяют процессам взаимодействовать друг с другом и синхронизировать свои действия.
- Обработка ошибок. Если системный вызов не может быть завершен, он возвращает код ошибки, который может обработать вызывающая программа.
Типы системных вызовов
Следующий перечень представляет собой классификацию системных вызовов на основании их функций:
1. Управление процессами
Системные вызовы играют важную роль в управлении системными процессами. С их помощью можно:
- Создавать новые процессы и завершать текущие
- Загружать и выполнять программы в пространстве процесса
- Планировать процессы и устанавливать параметры выполнения, например, приоритет
- Дожидаться завершения процесса или подавать сигнал о его завершении
2. Управление файлами
Системные вызовы могут выполнять различные операции с файлами, например,
- Чтение файлов и запись в файлы
- Открытие и закрытие файлов
- Удаление и изменение файловых атрибутов
- Перемещение или переименование файлов
3. Управление устройствами
Системные вызовы можно использовать для помощи в управлении устройствами, а именно для:
- Запроса доступа к устройству и его освобождения после использования
- Установка атрибутов и параметров устройства
- Чтение с устройств или запись на устройства
- Сопоставление имен логических устройств с физическими
4. Информационное обеспечение
Эти системные вызовы позволяют процессам:
- Получать и изменять различные системные атрибуты
- Устанавливать системную дату и время
- Запрашивать показатели производительности системы
5. Взаимодействие
Эти системные вызовы упрощают:
- Отправку и получение сообщение между процессами
- Синхронизацию действий между пользовательскими процессами
- Организации областей разделяемой памяти для межпроцессного взаимодействия
- Работу в сети через сокеты
6. Безопасность и управление доступом
Системные вызовы вносят свой вклад в обеспечение безопасности и управление доступом за счет:
- Определения того, какие процессы или пользователи получают доступ к тем или иным ресурсам, и кто может читать, записывать и выполнять ресурсы
- Упрощения процедур аутентификации пользователей
Примеры системных вызовов
Ниже в таблице перечислены самые распространенные системные вызовы Unix и Windows и их описания.
Примечание: поведение системного вызова, его параметры и возвращаемые значения могут отличаться в зависимости от операционной системы и ее версии. Более подробную информацию можно найти в документации к операционной системе.
СИСТЕМНЫЙ ВЫЗОВЫ UNIX |
ОПИСАНИЕ |
СИСТЕМНЫЕ ВЫЗОВЫ WINDOWS |
ОПИСАНИЕ |
Управление процессами |
|||
fork() |
Создает новый процесс |
CreateProcess() |
Создает новый процесс |
exit() |
Завершает текущий процесс |
ExitProcess() |
Завершает текущий процесс |
wait() |
Переводит процесс в режим ожидания до тех пор, пока не завершатся его дочерние процессы |
WaitForSingleObject() |
Ждет, пока процесс или поток завершит свою работу |
exec() |
Выполняет новую программу в процессе |
CreateProcess() или |
Выполняет новую программу в процессе |
getpid() |
Получает уникальный идентификатор процесса |
GetCurrentProcessId() |
Получает уникальный идентификатор процесса |
Управление файлами |
|||
open() |
Открывает файл (или устройство) |
open() |
Открывает или создает файл или устройство |
close() |
Закрывает открытый файл (или устройство) |
close() |
Закрывает дескриптор открытого объекта |
read() |
Выполняет чтение из файла (или устройства) |
read() |
Выполняет чтение данных из файла или устройство ввода |
write() |
Выполняет запись в файл (или устройство) |
write() |
Выполняет запись данных в файл или устройство вывода |
lseek() |
Изменяет место выполнения чтения/записи в файле |
lseek() |
Устанавливает положение указателя позиции в файле |
unlink() |
Удаляет файл |
unlink() |
Удаляет существующий файл |
rename() |
Переименовывает файл |
rename() |
Перемещает или переименовывает файл |
Управление каталогами |
|||
mkdir() |
Создает новый каталог |
CreateDirectory() |
Создает новый каталог |
rmdir() |
Удаляет каталог |
RemoveDirectory() |
Удаляет существующий каталог |
chdir() |
Изменяет текущий каталог |
SetCurrentDirectory() |
Изменяет текущий каталог |
stat() |
Получает статус файла |
GetFileAttributesEx() |
Получает расширенные атрибуты файла |
fstat() |
Получает статус открытого файла |
GetFileInformationByHandle() |
Получает информацию о файле, используя его дескриптор |
link() |
Создает ссылку на файл |
CreateHardLink() |
Создает жесткую ссылку на существующий файл |
symlink() |
Получает статус открытого файла |
CreateSymbolicLink() |
Создает символическую ссылку |
Управление устройствами |
|||
brk()или sbrk() |
Увеличивает/уменьшает пространство данных программы |
VirtualAlloc()или |
Резервирует, фиксирует изменения или освобождает область памяти |
mmap() |
Проецируют файлы или устройства в память |
MapViewOfFile() |
Проецирует файл в адресное пространство приложения |
Информационное обеспечение |
|||
time() |
Получает текущее время |
GetSystemTime() |
Получает текущее системное время |
alarm() |
Получает статус открытого файла |
SetWaitableTimer() |
Устанавливает таймер |
getuid() |
Устанавливает будильник для подачи сигнала |
GetUserName()или |
Получает имя пользователям или его ID |
getgid() |
Получает идентификатор группы |
GetTokenInformation() |
Получает информацию о маркере доступа |
Взаимодействие |
|||
socket() |
Создает новый сокет |
socket() |
Создает новый сокет |
bind() |
Привязывает сокет к сетевому адресу |
bind() |
Привязывает сокет к сетевому адресу |
listen() |
Привязывает сокет к сетевому адресу |
listen() |
Отслеживает соединения в сокете |
accept() |
Принимает новое соединение в сокете |
accept() |
Принимает новое соединение в сокете |
connect() |
Инициализирует соединение в сокете |
connect() |
Инициализирует соединение в сокете |
send()или recv() |
Отправляет и получает данные через сокет |
send()или recv() |
Отправляет и получает данные через сокет |
Безопасность и управление доступом |
|||
chmod()или umask() |
Изменяет права доступа/режим файла |
SetFileAttributes()или SetSecurityInfo() |
Изменяет атрибуты файла или сведения о защите |
chown() |
Изменяет владельца или группу файла |
SetSecurityInfo() |
Устанавливает сведения о защите |
Как передавать параметры системным вызовам?
Когда пользовательская программа обращается к системному вызову, ей, как правило, для того, чтобы указать запрос, нужно передать ему дополнительные параметры. То, насколько грамотно передаются эти параметры между пользователем и ядром, влияет на производительность системы.
Способ передачи параметров зависит от системной архитектуры, но есть некоторые общие правила:
- Ограниченное количество параметров. Чаще всего системные вызовы принимают ограниченное количество параметров. Это правило нужно для того, чтобы упростить взаимодействие и принудить пользователей использовать структуры данных или блоки памяти.
- Использование регистров ЦП. Регистры ЦП – это области памяти, к которым можно быстрее всего получить доступ. Количество регистров ЦП ограничено, а это значит, что количество передаваемых вызову параметров также ограничено. Если вы передаете небольшое количество параметров, используйте регистры ЦП.
- Использование указателей для агрегирования данных. Вместо того, чтобы передавать огромное количество параметров или большие объемы данных, используйте переменные-указатели, чтобы указывать на блоки или структуры памяти (которые содержат все параметры). Ядро будет использовать этот указатель для того, чтобы получить доступ к этому блоку памяти и извлечь параметры.
- Проверка целостности и безопасности данных. Ядро должно проверять любые указатели, которые передаются из пользовательского пространства. Оно должно проверить, что эти указатели относятся только к тем областям, к которым имеет доступ пользовательская программа. Кроме того, прежде чем использовать данные, которые поступают из пользовательских программ, оно их перепроверяет.
- Обработка стековых параметров. Некоторые системы помещают параметры в стек, и для того, чтобы выполнить их обработку, ядру приходится их извлекать. Такой метод не так распространен, как использование регистров ЦП и указателей, поскольку он более сложен с точки зрения реализации и управления.
- Изоляция данных путем их копирования. Ядро часто копирует данные из пространства пользователя в пространство ядра (и наоборот). Оно это делает для того, чтобы защитить систему от ошибочных или вредоносных данных. В связи с этим, данные, которые передаются между этими двумя пространствами, не должны передаваться напрямую.
- Возвращаемые значения и обработка ошибок. Системный вызов возвращает значение – как правило, это код успешного завершения или ошибки. В случае получения кода ошибки, всегда пытайтесь найти больше информации об этой ошибке. Сообщения об ошибках чаще всего хранятся в определенных местах, например, в переменной errno (Linux).
Примечание: правила и методы, приведенные выше, могут различаться в зависимости от архитектуры (x86, ARM, MIPS и т.д.) и особенностей операционной системы. Всегда обращайтесь к документации операционной системы или исходному коду, чтобы получить более точную информацию.
Заключение
В этой статье мы рассмотрели, как работают системные вызовы и почему они столь важны для обеспечения бесперебойной работы, безопасности и управления аппаратными и программными ресурсами.
Системные вызовы Windows
Последнее обновление: 09.10.2023
Для взаимодействия с ресурсами системы на Windows, также как и на Linux, теоретически можно использовать системные вызовы или syscalls. Однако в Windows обращение к
системным вызовам имеет свои особенности. Прежде всего, надо установить номер вызываемой системной функции в регистре RAX. И как и в общем случае для выполнения системного вызова применяется инструкция syscall:
movq НОМЕР_СИСТЕМНОГО_ВЫЗОВА, %rax syscall
Если системный вызов принимает параметры, то их можно передать, как и в общую функцию C/C++: для передачи в функцию первых четырех параметров используются регистры,
но первый параметр передается через регистр R10, а не через RCX, как в случае с функциями C/C++. То есть первые четыре параметра передаются через R10, RDX, R8 и R9 соответственно. Результат вызова возвращается через регистр RAX.
Минусом ОС Windows является то, что она не ориентирована на использование системных вызовов. Так, официальной документации по этому поводу нет, более
того о некоторых системных функциях нет вообще никакого упоминания в документации а сами номера системных вызовах
могут меняться в зависимости от номера билда ОС. Хотя в целом они стабильны для большинства выпусков. Более менее полную таблицу системных вызовов
для Windows можно найти на страницу https://hfiref0x.github.io/NT10_syscalls.html.
Возьмем простейший системный вызов — завершение процесса, который представлен функцией NtTerminateProcess. Если мы обратимся к вышеуказанной таблице,
то увидим, что эта функция имеет номер 44. Мы можем найти в документации определение этой функции:
NTSYSAPI NTSTATUS ZwTerminateProcess( [in, optional] HANDLE ProcessHandle, [in] NTSTATUS ExitStatus );
Хотя здесь указана функция ZwTerminateProcess, а не NtTerminateProcess, но в целом
Zw-версии функций и Nt-версии аналогичны.
И из определения функции мы видим, что она принимает два параметра:
-
ProcessHandle
: дескриптор процесса, который надо закрыть. Это необязательный параметр. Если он равен 0, то закрываем текущий процесс. -
ExitStatus
: статус завершения процесса, в качестве которого выступает числовой код и который обычно возвращается данной функцией.
Применим данную функцию в программе:
.globl main .text main: movq $0, %r10 # первый параметр - ProcessHandle не указываем movq $17, %rdx # второй параметр - код статуса - произвольное число movq $44, %rax # в rax номер вызываемой системной функции - 44 - NtTerminateProcess syscall # вызываем системную функцию ret
Поскольку первый параметр необязательный, и мы хотим завершить текущую программу, то первому параметру через регистр R10 передаем значение 0.
Второму параметру через регистр RDХ передается произвольный числовой код статуса, в нашем случае число 17.
Для вызова системной функции инструкции в регистр %rax передается числовой код функции — 44, и выполняется инструкция syscall.
Результат компиляции и вызова программы (допустим, код программы расположен в файле hello.s
и компилируется в файл hello.exe
):
c:\asm>as hello.s -o hello.o c:\asm>ld hello.o -o hello.exe c:\asm>hello.exe c:\asm>echo %ERRORLEVEL% 17 c:\asm>
Таким образом, функция NtTerminateProcess возвратила число 17, которое передавалось через второй параметр функции.
NtWriteFile
Рассмотрим другой пример — запись данных в файл, и как частный случай, вывод строки на консоль. За это отвечает системная функция
NtWriteFile, которая для последних версий Windows имеет номер 8 и которая имеет следующий заголовок:
__kernel_entry NTSYSCALLAPI NTSTATUS NtWriteFile( [in] HANDLE FileHandle, [in, optional] HANDLE Event, [in, optional] PIO_APC_ROUTINE ApcRoutine, [in, optional] PVOID ApcContext, [out] PIO_STATUS_BLOCK IoStatusBlock, [in] PVOID Buffer, [in] ULONG Length, [in, optional] PLARGE_INTEGER ByteOffset, [in, optional] PULONG Key );
Функция принимает аж 9 параметров, из которых отметим наболее важные.
-
1-й параметр —
FileHandle
представляет дескриптор файла (в нашем случае дискриптор консольного вывода). -
5-й пареметр
IoStatusBlock
представляет блок байтов, в который записывается статус операции. -
6-й параметр —
Buffer
— указатель на данные для записи (в нашем случае это будет адрес строки для вывода на экран) -
7-й параметр —
Length
хранит размер записываемых данных (размер строки для вывода)
Все остальные параметры необязательные, и вместо них будет использоваться значение по умолчанию — 0. Но при желении про эти параметры можно прочитать в
документации. Тепеь определим программу для вывода строки на консоль:
.globl main .data message: .asciz "Hello METANIT.COM\n" .equ message_len, . - message .balign 8 IoStatusBlock: .space 16 # буфер для получения статуса .text main: subq $88, %rsp movq $-11, %rcx # Аргумент для GetStdHandle - STD_OUTPUT call GetStdHandle # вызываем функцию GetStdHandle movq %rax, %r10 # первый аргумент movq $0, %rdx # Второй аргумент movq $0, %r8 # Третий аргумент movq $0, %r9 # Четвертый аргумент leaq IoStatusBlock(%rip), %rax # Пятый аргумент movq %rax, 40(%rsp) leaq message(%rip), %rax movq %rax, 48(%rsp) # Шестой аргумент - строка для вывода movq $message_len, 56(%rsp) # # Седьмой аргумент - длина строки movq $0, 64(%rsp) # Восьмой аргумент - для него выделяем память в стеке movq $0, 72(%rsp) # Девятый аргумент - для него выделяем память в стеке movq $8, %rax # вызов системной функции NtWriteFile syscall movq $0, %r10 # первый параметр - ProcessHandle не указываем movq $message_len, %rdx # второй параметр - код статуса - произвольное число movq $44, %rax # в rax номер вызываемой системной функции - 44 - NtTerminateProcess syscall # вызываем системную функцию addq $88, %rsp # очищаем стек ret
Для упрощения для получения дескриптора консольного вывода используем функцию GetStdHandle. Эта функция возвращает через регистр
RAX дескриптор, который помещаем в регистр R10. Стоит отметить, что поскольку системный вызов NtWriteFile принимает 9 параметров, соответственно все параметры
не поместятся в 4 стандартных регистра, поэтому выделяем достаточное местов в стеке. Причем пятый параметр (он же первый параметр, который помещается в стек) должен начинаться в стеке со смещения
40(%rsp)
. Большинство параметров необязательные, и данном случае не имеют значения, поэтому передаем им значение 0.:
movq %rax, %r10 # первый аргумент movq $0, %rdx # Второй аргумент movq $0, %r8 # Третий аргумент movq $0, %r9 # Четвертый аргумент leaq IoStatusBlock(%rip), %rax # Пятый аргумент movq %rax, 40(%rsp) leaq message(%rip), %rax movq %rax, 48(%rsp) # Шестой аргумент - строка для вывода movq $message_len, 56(%rsp) # # Седьмой аргумент - длина строки movq $0, 64(%rsp) # Восьмой аргумент - для него выделяем память в стеке movq $0, 72(%rsp) # Девятый аргумент - для него выделяем память в стеке
После установки параметров вызываем системную функцию:
movq $8, %rax # вызов системной функции NtWriteFile syscall
Поскольку в программе используется функция GetStdHandle, то при компоновке программы необходимо передать компоновщику системную
библиотеку kernel32. Полный консольный вывод программы с компиляцией:
c:\asm>as hello.s -o hello.o c:\asm>ld hello.o -o hello.exe -lkernel32 c:\asm>hello.exe Hello METANIT.COM c:\asm>echo %ERRORLEVEL% 19 c:\asm>
EreTIk’s Box » Cтатьи, исходники » Таблица системных сервисов (SSTD) на x64 системах: поиск и трактовка содержимого
Warning (12.08.2016) |
Статья не применима к ядру Windows 10.0.14393. Описанный в конце статьи способ поиска не применим к этой версии ОС. |
Сразу скажу, что под x64-системами, здесь и далее будет идти речь о
AMD64-сборках Windows.С 64-х разрядными Itanium-сборками (IA-64) ядра Windows я
так тесно не работал, да и Microsoft уже отказалась от этой платформы. Поэтому
Itanium-платформа в рамках этой статьи рассматриваться не будет.
С приходом x64-ядер Windows разработчики драйверов, в частности разработчики
защит и прочих продуктов, использующих перехваты вызовов системных сервисов,
сразу столкнулись с рядом трудностей. Среди этого длинного списка:
PatchGuard,
Kernel Mode Code Signing (KMCS).
Впрочем, сейчас существует достаточно надежные способы как отключения защиты
от модификации ядра, так и способы загрузки неподписанного кода в ядро Windows.
Хотя количество способов сильно ограничено, но все таки они есть и успешно
применяются.
И тут разработчик сталкивается со следующей особенностью x64-систем: переменная
nt!KeServiceDescriptorTable больше не экспортируется из ядра ОС. Как
известно, nt!KeServiceDescriptorTable является массивом, в котором
хранятся описатели таблицы системных вызовов: указатель на начало таблицы, ее
размер и еще некоторые служебные поля. На 32-х разрядных системах в таблице
системных вызовов хранятся указатели на соответствующие функции-обработчики. И
это утверждение неверно для x64-систем, при загруженном ядре в таблице
системных вызовов хранятся не указатели, а знаковые 4-х байтные смещения. Но и
на этом проблемы не закачиваются, формат хранения данных в таблицах системных
вызовов различается на ОС раньше Vista и системах Vista и старше. Поэтому
начнем именно с формата таблицы системных вызовов.
Формат данных таблицы системных вызовов SSDT на x64-системах
Вначале рассмотрим формат SSDT для систем младше Vista. Ни для кого не
секрет, что ядра Windows XP Professional x64 это не что иное, как ядро Windows
2003 Server x64. В этом легко убедится, если выполнить простую команду в WinDbg:
kd> ? wo(nt!NtBuildNumber)
Evaluate expression: 3790 = 00000000`00000ece
Номер сборки 3790 всегда был закреплен за 2003-им сервером (напомню, что для XP
номер сборки всегда был 2600), о чем нам и говорит WinDbg при соединении с
Windows XP Professional x64:
Windows Server 2003 Kernel Version 3790 (Service Pack 2) MP (2 procs) Free x64
Product: WinNt, suite: TerminalServer SingleUserTS
Built by: 3790.srv03_sp2_qfe.100324-1618
Поэтому сейчас речь пойдет о Windows 2003 Server x64, подразумевая, что на
Windows XP Professional x64 дела обстоят аналогично. При рассмотрении формата
SSDT будем использовать таблицу системных сервисов ядра, которая
расположена в KiServiceTable. Именно на этот символ указывает поле
начала таблицы системных сервисов дескриптора с индексом 0 из массива
nt!KeServiceDescriptorTable. Итак, откроем файл ntoskrnl.exe и взглянем
на содержимое начала таблицы системных сервисов:
0:000> dps ntoskrnl!KiServiceTable
00000000`0045ea80 00000000`007659c0 ntoskrnl!NtMapUserPhysicalPagesScatter
00000000`0045ea88 00000000`00681ef0 ntoskrnl!NtWaitForSingleObject
00000000`0045ea90 00000000`00426d10 ntoskrnl!NtCallbackReturn
00000000`0045ea98 00000000`00665685 ntoskrnl!NtReadFile+0x5
Вроде бы все привычно, но вот только немного смущает символ
ntoskrnl!NtReadFile+0x5. На самом деле все просто: в значениях таблицы
системных сервисов, точнее в младших четырех битах, закодировано количество
параметров, передаваемых через стек. У функции
ZwReadFile(…)
девять параметров, но, учитывая специфику передачи параметров на x64 платформе
(Calling Conventions: x64 Architecture:
первые четыре параметра передаются через регистры), как раз и получается, что
при вызове пять параметров будут переданы в эту функцию через стек.
Теперь посмотрим на тот же символ nt!KiServiceTable, но уже на
загруженной системе:
0: kd> dd nt!KiServiceTable
fffff800`0105ea80 00306f40 00223470 fffc8290 00206c05
fffff800`0105ea90 002240b6 0023b385 00225091 001fe1a0
fffff800`0105eaa0 0022b870 0020ddd0 0022e9e0 0020bfb0
fffff800`0105eab0 0022d330 00230f21 0020a4b1 0023f480
На очень многих форумах по низкоуровневому программированию поднималась
примерно одна и та же тема: почему команда WinDbg
«dps nt!KiServiceTable» в начале таблицы отображает «мусор»? А все
потому, что это не мусор, а, как было написано выше, 4-х байтные смещения. Но
вот только количество параметров, закодированное в исходной таблице системных
вызовов, никуда не делось. Поэтому младшие четыре бита в каждом 4-х байтном
слове таблицы это все то же количество параметров, передаваемых через стек. В
этом легко убедиться, выполнив следующую команду:
0: kd> ln nt!KiServiceTable+(dwo(nt!KiServiceTable+(3*4))&0xFFFFFFF0)
(fffff800`01265680) nt!NtReadFile | (fffff800`01265b90) nt!NtSetInformationFile
Exact matches:
nt!NtReadFile = <no type information>
Аналогично тому, как мы видели в файле ntoskrnl.exe, по индексу 3 лежит
смещение до NtReadFile(…). Преобразованием элементов таблицы из 8-ми
байтовых указателей а 4-х байтовые смещения занимается цикл в функции
nt!KeCompactServiceTable (в первом параметре rcx передан
указатель на таблицу, а во втором параметре edx передано количество
элементов таблицы):
mov r11d,edx ; количество элементов mov r10,rcx ; начало таблицы описателей mov rdx,rcx ; начальный указатель массива и ULONG’ов, и PVOID’ов test r11d,r11d je nt!KeCompactServiceTable+0x35 mov r9,r11 ; количество сервисов ; ... nt!KeCompactServiceTable+0x20: mov eax,dword ptr [rdx] ; очередной (указатель | кол-во параметров) add rcx,4 ; смещаем указатель ULONG’ов add rdx,8 ; смещаем указатель PVOID’ов sub eax,r10d ; указатель –> смещение от начала таблицы dec r9 mov dword ptr [rcx-4],eax jne nt!KeCompactServiceTable+0x20
Теперь перейдем к системам Windows Vista и старше. Откроем файл ntoskrnl.exe от
Windows 7 RTM x64 (7600) и взглянем на KiServiceTable:
0:000> dps ntoskrnl!KiServiceTable
00000001`40072b00 00000001`40483190 ntoskrnl!NtMapUserPhysicalPagesScatter
00000001`40072b08 00000001`40369a00 ntoskrnl!NtWaitForSingleObject
00000001`40072b10 00000001`40069dd0 ntoskrnl!NtCallbackReturn
00000001`40072b18 00000001`4038cb10 ntoskrnl!NtReadFile
То есть на системах Vista и старше в файле элементы таблицы системных сервисов
больше не содержат количество параметров функций. Для этого существует, как и
на x32, отдельная таблица KiArgumentTable. Естественно, что в ней
учтено, что первые 4-е параметра передается через стек, поэтому значения в ней
(размер параметров на стеке в байтах, из расчета 4-х байт на параметр)
отличаются по содержимому от таблицы в x32-сборке. Например, для того же
ntoskrnl.exe из Windows 7 x64:
0:000> db ntoskrnl!KiArgumentTable
00000001`4007378c 00 00 00 14 18 14 04 00-00 00 00 00 00 04 04 00
Но, как и для систем Win2k3, после загрузки ядра массив KiServiceTable
преобразуется в 4-х байтные смещения, младшие четыре бита которых содержат
количество параметров, передаваемых через стек:
1: kd> dd nt!KiServiceTable
fffff800`030c8300 04113300 02f9e200 fff73100 031cb705
fffff800`030c8310 031ac106 0315f605 02ba5601 02b76c00
fffff800`030c8320 0310e200 03e0fc00 02cf1e00 031bdc40
fffff800`030c8330 03153200 02e7ee01 02e25d01 02dfcb80
В данном формате смешением являются старшие только 28 бит 4-х байтового слова
элемента таблицы (т.е. нужно отбросить младшие 4-е бита). Например,
можно вычислить адрес того же NtReadFile‘а по индексу 3:
1: kd> ln nt!KiServiceTable+(dwo(nt!KiServiceTable+(3*4))>>4)
(fffff800`033e4e70) nt!NtReadFile | (fffff800`033e55b0) nt!NtOpenFile
Exact matches:
nt!NtReadFile = <no type information>
Преобразованием таблицы занимается все та же функция
nt!KeCompactServiceTable, но формат ее вызова расширен, ведь в
изначальной таблице (той, что записана в файле) не было количества
параметров, передаваемых через стек:
nt!KiInitializeKernel+0x358: mov r8d,dword ptr [nt!KiServiceLimit] lea rdx,[nt!KiArgumentTable] lea rcx,[nt!KiServiceTable] call nt!KeCompactServiceTable
Ну и логика преобразования таблицы поменялась, как и поменялся формат
содержимого:
mov rdi,rdx mov r10d,r8d mov rbx,rcx mov rdx,rcx test r8d,r8d je nt!KeCompactServiceTable+0x45 mov r11,r10 nt!KeCompactServiceTable+0x1e: mov r8d,dword ptr [rdx] movzx eax,byte ptr [rdi] add rdx,8 sub r8d,ebx ; указатель -> смещение shr eax,2 ; 'элемент KiArgumentTable -> количество параметров inc rdi shl r8d,4 ; перенос значения смещения в старшие 28 бит or r8d,eax ; сохранение количества параметров mov dword ptr [rcx],r8d add rcx,4 sub r11,1 jne nt!KeCompactServiceTable+0x1e
Реализацию разбора содержимого таблицы на языке Python можно посмотреть в
примере ~\samples\ssdt.py из проекта PYKD.
Поиск таблицы системных вызовов ядра: nt!KiServiceTable
Таблица системных вызовов ядра (указатель на которую хранится в
nt!KeServiceDescriptorTable по нулевому индексу) расположена по адресу
символа nt!KiServiceTable, который не экспортируется ни в x32, ни в x64
сборках ядра ОС. В интернете можно найти достаточное количество способов
нахождения массива nt!KeServiceDescriptorTable, по данным которого
определяется адрес nt!KiServiceTable. Но я хочу рассмотреть способ
«эвристического» поиска nt!KiServiceTable на x64-платформе
напрямую.
Способ основан на формате SSDT, описанном выше. Фактически,
nt!KeCompactServiceTable перетирает первую половину таблицы
nt!KiServiceTable смещениями, оставляя вторую половину нетронутой. Это
означает, что во второй половине таблицы остаются «настоящие» указатели
на Nt-функции ядра. Это можно использовать:
-
Выбираем экспортируемую Nt-функцию, которая всегда находится во второй половине
таблицы и получаем ее адрес. Я бы рекомендовал выбрать функции с количеством
параметров <= 4, что бы при поиске не закладываться на номер билда ОС,
например это функция
NtSetSecurityObject - Проходим все not-paged секции PE-модуля ядра
-
В каждой секции с шагом в размер указателя сравниваем очередные 8-мь байт с
полученным на первом этапе указателем функции. Когда значения совпадут — мы
внутри nt!KiServiceTable -
Перед таблицей nt!KiServiceTable всегда (во всяком случае я иного не
встречал) есть область padding’а заполненная nop’ами. Следовательно, для поиска
начала таблицы нужно идти вверх от NtSetSecurityObject, пока не встретим
значение 0x9090909090909090 -
Что бы найти конец таблицы (и узнать ее размер) необходимо идти по таблице от
NtSetSecurityObject вниз и проверять, что очередные 8-мь байт являются
указателем в диапазон одной из секций кода PE-модуля ядра
Метод не самый надежный, но был протестирован на нескольких машинах с разными
версиями x64-ОС. Во всех случаях таблица nt!KiServiceTable была найдена
успешно.
ΞρεΤΙκ
Время на прочтение13 мин
Количество просмотров45K
Про системные вызовы уже много было сказано, например здесь или здесь. Наверняка вам уже известно, что системный вызов — это способ вызова функции ядра ОС. Мне же захотелось копнуть глубже и узнать, что особенного в этом системном вызове, какие существуют реализации и какова их производительность на примере архитектуры x86-64. Если вам также интересны ответы на данные вопросы, добро пожаловать под кат.
System call
Каждый раз, когда мы хотим что-то отобразить на мониторе, записать в устройство, считать с файла, нам приходится обращаться к ядру ОС. Именно ядро ОС отвечает за любое общение с железом, именно там происходит работа с прерываниями, режимами процессора, переключениями задач… Чтобы пользователь программой не смог завалить работу всей операционной системы, было решено разделить пространство памяти на пространство пользователя (область памяти, предназначенная для выполнения пользовательских программ) и пространство ядра, а также запретить пользователю доступ к памяти ядра ОС. Реализовано это разделение в x86-семействе аппаратно при помощи сегментной защиты памяти. Но пользовательской программе нужно каким-то образом общаться с ядром, для этого и была придумана концепция системных вызовов.
Системный вызов — способ обращения программы пользовательского пространства к пространству ядра. Со стороны это может выглядеть как вызов обычной функции со своим собственным calling convention, но на самом деле процессором выполняется чуть больше действий, чем при вызове функции инструкцией call. Например, в архитектуре x86 во время системного вызова как минимум происходит увеличение уровня привилегий, замена пользовательских сегментов на сегменты ядра и установка регистра IP на обработчик системного вызова.
Программист обычно не работает с системными вызовами напрямую, так как системные вызовы обернуты в функции и скрыты в различных библиотеках, например libc.so в Linux или же ntdll.dll в Windows, с которыми и взаимодействует прикладной разработчик.
Теоретически, реализовать системный вызов можно при помощи любого исключения, хоть при помощи деления на 0. Главное — это передача управления ядру. Рассмотрим реальные примеры реализаций исключений.
Способы реализации системных вызовов
Выполнение неверной инструкции.
Ранее, ещё на 80386 это был самый быстрый способ сделать системный вызов. Для этого обычно применялась бессмысленная и неверная инструкция LOCK NOP, после исполнения которой процессором вызывался обработчик неверной инструкции. Это было больше 20 лет назад и, говорят, этим приёмом обрабатывались системные вызовы в корпорации Microsoft. Обработчик неверной инструкции в наши дни используется по назначению.
Call gates
Для того, чтобы иметь доступ к сегментам кода с различным уровнем привилегий, в Intel был разработан специальный набор дескрипторов, называемый gate descriptors. Существует 4 вида таких дескрипторов:
- Call gates
- Trap gates (для исключений, вроде int 3, требующих выполнения участка кода)
- Interrupt gates (аналогичен trap gates, но с некоторыми отличиями)
- Task gates (полагалось, что будут использоваться для переключения задач)
Нам интересны только call gates, так как именно через них планировалось реализовывать системные вызовы в x86.
Call gate реализован при помощи инструкции call far или jmp far и принимает в качестве параметра call gate-дескриптор, который настраивается ядром ОС. Является достаточно гибким механизмом, так как возможен переход и на любой уровень защитного кольца, и на 16-битный код. Считается, что call gates производительней прерываний. Этот способ использовался в OS/2 и Windows 95. Из-за неудобства использования в Linux механизм так и не был реализован. Со временем совсем перестал использоваться, так как появились более производительные и простые в обращении реализации системных вызовов (sysenter/sysexit).
Системные вызовы, реализованные в Linux
В архитектуре x86-64 ОС Linux существует несколько различных способов системных вызовов:
- int 80h
- sysenter/sysexit
- syscall/sysret
- vsyscall
- vDSO
В реализации каждого системного вызова есть свои особенности, но в общем, обработчик в Linux имеет примерно одинаковую структуру:
- Включается защита от чтения/записи/исполнения кода пользовательского пространства.
- Заменяется пользовательский стек на стек ядра, сохраняются callee-saved регистры.
- Выполняется обработка системного вызова
- Восстановление стека, регистров
- Отключение защиты
- Выход из системного вызова
Рассмотрим немного подробнее каждый системный вызов.
int 80h
Изначально, в архитектуре x86, Linux использовал программное прерывание 128 для совершения системного вызова. Для указания номера системного вызова, пользователь задаёт в eax номер системного вызова, а его параметры располагает по порядку в регистрах ebx, ecx, edx, esi, edi, ebp. Далее вызывается инструкция int 80h, которая программно вызывает прерывание. Процессором вызывается обработчик прерывания, установленный ядром Linux ещё во время инициализации ядра. В x86-64 вызов прерывания используется только во время эмуляции режима x32 для обратной совместимости.
В принципе, никто не запрещает пользоваться инструкцией в расширенном режиме. Но вы должны понимать, что используется 32-битная таблица вызовов и все используемые адреса должны помещаться в 32-битное адресное пространство. Согласно SYSTEM V ABI [4] §3.5.1, для программ, виртуальный адрес которых известен на этапе линковки и помещается в 2гб, по умолчанию используется малая модель памяти и все известные символы находятся в 32-битном адресном пространстве. Под это определение подходят статически скомпилированные программы, где и возможно использовать int 80h. Пошаговая работа прерывания подробно описана на stackoverflow.
В ядре обработчиком этого прерывания является функция entry_INT80_compat и находится в arch/x86/entry/entry_64_compat.S
Пример вызова int 80h
section .text
global _start
_start:
mov edx,len
mov ecx,msg
mov ebx,1 ; file descriptor (stdout)
mov eax,4 ; system call number (sys_write)
int 0x80 ; call kernel
mov eax,1 ; system call number (sys_exit)
int 0x80 ; call kernel
section .data
msg db 'Hello, world!',0xa
len equ $ - msg
Компиляция:
nasm -f elf main.s -o main32.o
ld -melf_i386 main32.o -o a32.out
Или в расширенном режиме (программа работает так как компилируется статически)
nasm -f elf64 main.s -o main.o
ld main.o -o a.out
sysenter/sysexit
Спустя некоторое время, ещё когда не было x86-64, в Intel поняли, что можно ускорить системные вызовы, если создать специальную инструкцию системного вызова, тем самым минуя некоторые издержки прерывания. Так появилась пара инструкций sysenter/sysexit. Ускорение достигается за счёт того, что на аппаратном уровне при выполнении инструкции sysenter опускается множество проверок на валидность дескрипторов, а так же проверок, зависящих от уровня привилегий [3] §6.1. Также инструкция опирается на то, что вызывающая её программа использует плоскую модель памяти. В архитектуре Intel, инструкция валидна как для режима совместимости, так и для расширенного режима, но у AMD данная инструкция в расширенном режиме приводит к исключению неизвестного опкода [3]. Поэтому в настоящее время пара sysenter/sysexit используется только в режиме совместимости.
В ядре обработчиком этой инструкции является функция entry_SYSENTER_compat и находится в arch/x86/entry/entry_64_compat.S
Пример вызова sysenter
section .text
global _start
_start:
mov edx,len ;message length
mov ecx,msg ;message to write
mov ebx,1 ;file descriptor (stdout)
mov eax,4 ;system call number (sys_write)
push continue_l
push ecx
push edx
push ebp
mov ebp,esp
sysenter
hlt ; dumb instructions that is going to be skipped
continue_l:
mov eax,1 ;system call number (sys_exit)
mov ebx,0
push ecx
push edx
push ebp
mov ebp,esp
sysenter
section .data
msg db 'Hello, world!',0xa
len equ $ - msg
Компилирование:
nasm -f elf main.s -o main.o
ld main.o -melf_i386 -o a.out
Несмотря на то, что в реализации архитектуры от Intel инструкция валидна, в расширенном режиме скорее всего такой системный вызов никак не получится использовать. Это из-за того, что в регистре ebp сохраняется текущее значение стека, а адрес верхушки независимо от модели памяти находится вне 32-битного адресного пространства. Это всё потому, что Linux отображает стек на конец нижней половины каноничного адреса пространства.
Разработчики ядра Linux предостерегают пользователей от жесткого программирования sysenter из-за того, что ABI системного вызова может измениться. Из-за того, что Android не последовал этому совету, Linux пришлось откатить свой патч для сохранения обратной совместимости. Правильно реализовывать системный вызов нужно используя vDSO, речь о которой будет идти далее.
syscall/sysret
Так как именно AMD разработали x86-64 архитектуру, которая и называется AMD64, то они решили создать свой собственный системный вызов. Инструкция разрабатывалась AMD, как аналог sysenter/sysexit для архитектуры IA-32. В AMD позаботились о том, чтобы инструкция была реализована как в расширенном режиме, так и в режиме совместимости, но в Intel решили не поддерживать данную инструкцию в режиме совместимости. Несмотря на всё это, Linux имеет 2 обработчика для каждого из режимов: для x32 и x64. Обработчиками этой инструкции является функции entry_SYSCALL_64 для x64 и entry_SYSCALL_compat для x32 и находится в arch/x86/entry/entry_64.S и arch/x86/entry/entry_64_compat.S соответственно.
Кому интересно более подробно ознакомиться с инструкциями системных вызовов, в мануале Intel [0] (§4.3) приведён их псевдокод.
Пример вызова syscall
section .text
global _start
_start:
mov rdx,len ;message length
mov rsi,msg ;message to write
mov rdi,1 ;file descriptor (stdout)
mov rax,1 ;system call number (sys_write)
syscall
mov rax,60 ;system call number (sys_exit)
syscall
section .data
msg db 'Hello, world!',0xa
len equ $ - msg
Компилирование
nasm -f elf64 main.s -o main.o
ld main.o -o a.out
Пример вызова 32-битного syscall
Для тестирования следующего примера потребуется ядро с конфигурацией CONFIG_IA32_EMULATION=y и компьютер AMD. Если же у вас компьютер фирмы Intel, то можно запустить пример на виртуалке. Linux может без предупреждения изменить ABI и этого системного вызова, поэтому в очередной раз напомню: системные вызовы в режиме совместимости правильнее исполнять через vDSO.
section .text
global _start
_start:
mov edx,len ;message length
mov ebp,msg ;message to write
mov ebx,1 ;file descriptor (stdout)
mov eax,4 ;system call number (sys_write)
push continue_l
push ecx
push edx
push ebp
syscall
hlt
continue_l:
mov eax,1 ;system call number (sys_exit)
mov ebx,0
push ecx
push edx
push ebp
syscall
section .data
msg db 'Hello, world!',0xa
len equ $ - msg
Компиляция:
nasm -f elf main.s -o main.o
ld main.o -melf_i386 -o a.out
Непонятна причина, по которой AMD решили разработать свою инструкцию вместо того, чтобы расширить инструкцию Intel sysenter на архитектуру x86-64.
vsyscall
При переходе из пространства пользователя в пространство ядра происходит переключение контекста, что является не самой дешёвой операцией. Поэтому, для улучшения производительности системных вызовов, было решено их обрабатывать в пространстве пользователя. Для этого было зарезервировано 8 мб памяти для отображения пространства ядра в пространство пользователя. В эту память для архитектуры x86 поместили 3 реализации часто используемых read-only вызова: gettimeofday, time, getcpu.
Со временем стало понятно, что vsyscall имеет существенные недостатки. Фиксированное размещение в адресном пространстве является уязвимым местом с точки зрения безопасности, а отсутствие гибкости в размере выделяемой памяти может негативно сказаться на расширении отображаемой области ядра.
Для того, чтобы пример работал, необходимо, чтобы в ядре была включена поддержка vsyscall: CONFIG_X86_VSYSCALL_EMULATION=y
Пример вызова vsyscall
#include <sys/time.h>
#include <stdio.h>
#define VSYSCALL_ADDR 0xffffffffff600000UL
int main()
{
// Offsets in x86-64
// 0: gettimeofday
// 1024: time
// 2048: getcpu
int (*f)(struct timeval *, struct timezone *);
struct timeval tm;
unsigned long addrOffset = 0;
f = (void*)VSYSCALL_ADDR + addrOffset;
f(&tm, NULL);
printf("%d:%d\n", tm.tv_sec, tm.tv_usec);
}
Компиляция:
gcc main.c
Linux не отображает vsyscall в режиме совместимости.
На данный момент, для сохранения обратной совместимости, ядро Linux предоставляет эмуляцию vsyscall. Эмуляция сделана для того, чтобы залатать дыры безопасности в ущерб производительности.
Эмуляция может быть реализована двумя способами.
Первый способ — при помощи замены адреса функции на системный вызов syscall. В таком случае виртуальный системный вызов функции gettimeofday на x86-64 выглядит следующим образом:
movq $0x60, %rax
syscall
ret
Где 0x60 — код системного вызова функции gettimeofday.
Второй же способ немного интереснее. При вызове функции vsyscall генерируется исключение Page fault, которое обрабатывается Linux. ОС видит, что ошибка произошла из-за исполнения инструкции по адресу vsyscall и передаёт управление обработчику виртуальных системных вызовов emulate_vsyscall (arch/x86/entry/vsyscall/vsyscall_64.c).
Реализацией vsyscall можно управлять при помощи параметра ядра vsyscall. Можно как отключить виртуальный системный вызов при помощи параметра vsyscall=none
, задать реализацию как при помощи инструкции syscall syscall=native
, так и через Page fault vsyscall=emulate
.
vDSO (Virtual Dynamic Shared Object)
Чтобы исправить основной недостаток vsyscall, было предложено реализовать системные вызовы в виде отображения динамически подключаемой библиотеки, к которой применяется технология ASLR. В «длинном» режиме библиотека называется linux-vdso.so.1, а в режиме совместимости — linux-gate.so.1. Библиотека автоматически подгружается для каждого процесса, даже статически скомпилированного. Увидеть зависимости приложения от неё можно при помощи утилиты ldd
в случае динамической компоновки библиотеки libc.
Также vDSO используется в качестве выбора наиболее производительного способа системного вызова, например в режиме совместимости.
Список разделяемых функций можно посмотреть в руководстве.
Пример вызова vDSO
#include <sys/time.h>
#include <dlfcn.h>
#include <stdio.h>
#include <assert.h>
#if defined __x86_64__
#define VDSO_NAME "linux-vdso.so.1"
#else
#define VDSO_NAME "linux-gate.so.1"
#endif
int main()
{
int (*f)(struct timeval *, struct timezone *);
struct timeval tm = {0};
void *vdso = dlopen(VDSO_NAME,
RTLD_LAZY | RTLD_LOCAL | RTLD_NOLOAD);
assert(vdso && "vdso not found");
f = dlsym(vdso, "__vdso_gettimeofday");
assert(f);
f(&tm, NULL);
printf("%d:%d\n", tm.tv_sec, tm.tv_usec);
}
Компиляция:
gcc -ldl main.c
Для режима совместимости:
gcc -ldl -m32 main.c -o a32.elf
Правильнее всего искать функции vDSO при помощи извлечения адреса библиотеки из вспомогательного вектора AT_SYSINFO_EHDR и последующего парсинга разделяемого объекта. Пример парсинга vDSO из вспомогательного вектора можно найти в исходном коде ядра: tools/testing/selftests/vDSO/parse_vdso.c
Или если интересно, то можно покопаться и посмотреть, как парсится vDSO в glibc:
- Парсинг вспомогательных векторов: elf/dl-sysdep.c
- Парсинг разделяемой библиотеки: elf/setup-vdso.h
- Установка значений функций: sysdeps/unix/sysv/linux/x86_64/init-first.c, sysdeps/unix/sysv/linux/x86/gettimeofday.c, sysdeps/unix/sysv/linux/x86/time.c
Согласно System V ABI AMD64 [4] вызовы должны происходить при помощи инструкции syscall. На практике же к этой инструкции добавляются вызовы через vDSO. Поддержка системных вызовов в виде int 80h и vsyscall остались для обратной совместимости.
Сравнение производительности системных вызовов
С тестированием скорости системных вызовов всё неоднозначно. В архитектуре x86 на выполнение одной инструкции влияет множество факторов таких как наличие инструкции в кэше, загруженность конвейера, даже существует таблица задержек для данной архитектуры [2]. Поэтому достаточно сложно определить скорость выполнения участка кода. У Intel есть даже специальный гайд по замеру времени для участка кода [1]. Но проблема в том, что мы не можем замерить время согласно документу из-за того, что нам нужно вызывать объекты ядра из пользовательского пространства.
Поэтому было решено замерить время при помощи clock_gettime и тестировать производительность вызова gettimeofday, так как он есть во всех реализациях системных вызовов. На разных процессорах время может отличаться, но в целом, относительные результаты должны быть схожи.
Программа запускалась несколько раз и в итоге бралось минимальное время исполнения.
Тестирование int 80h, sysenter и vDSO-32 производилось в режиме совместимости.
Программа тестирования
#include <sys/time.h>
#include <time.h>
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <syscall.h>
#include <dlfcn.h>
#include <limits.h>
#define min(a,b) ((a) < (b)) ? (a) : (b)
#define GIGA 1000000000
#define difftime(start, end) (end.tv_sec - start.tv_sec) * GIGA + end.tv_nsec - start.tv_nsec
static struct timeval g_timespec;
#if defined __x86_64__
static inline int test_syscall() {
register long int result asm ("rax");
asm volatile (
"lea %[p0], %%rdi \n\t"
"mov $0, %%rsi \n\t"
"mov %[sysnum], %%rax \n\t"
"syscall \n\t"
: "=r"(result)
: [sysnum] "i" (SYS_gettimeofday), [p0] "m" (g_timespec)
: "rcx", "rsi");
return result;
}
#endif
static inline int test_int80h() {
register int result asm ("eax");
asm volatile (
"lea %[p0], %%ebx \n\t"
"mov $0, %%ecx \n\t"
"mov %[sysnum], %%eax \n\t"
"int $0x80 \n\t"
: "=r"(result)
: [sysnum] "i" (SYS_gettimeofday), [p0] "m" (g_timespec)
: "ebx", "ecx");
return result;
}
int (*g_f)(struct timeval *, struct timezone *);
static void prepare_vdso() {
void *vdso = dlopen("linux-vdso.so.1",
RTLD_LAZY | RTLD_LOCAL | RTLD_NOLOAD);
if (!vdso) {
vdso = dlopen("linux-gate.so.1",
RTLD_LAZY | RTLD_LOCAL | RTLD_NOLOAD);
}
assert(vdso && "vdso not found");
g_f = dlsym(vdso, "__vdso_gettimeofday");
}
static int test_g_f() {
return g_f(&g_timespec, 0);
}
#define VSYSCALL_ADDR 0xffffffffff600000UL
static void prepare_vsyscall() {
g_f = (void*)VSYSCALL_ADDR;
}
static inline int test_sysenter() {
register int result asm ("eax");
asm volatile (
"lea %[p0], %%ebx \n\t"
"mov $0, %%ecx \n\t"
"mov %[sysnum], %%eax \n\t"
"push $cont_label%=\n\t"
"push %%ecx \n\t"
"push %%edx \n\t"
"push %%ebp \n\t"
"mov %%esp, %%ebp \n\t"
"sysenter \n\t"
"cont_label%=: \n\t"
: "=r"(result)
: [sysnum] "i" (SYS_gettimeofday), [p0] "m" (g_timespec)
: "ebx", "esp");
return result;
}
#ifdef TEST_SYSCALL
#define TEST_PREPARE()
#define TEST_PROC_CALL() test_syscall()
#elif defined TEST_VDSO
#define TEST_PREPARE() prepare_vdso()
#define TEST_PROC_CALL() test_g_f()
#elif defined TEST_VSYSCALL
#define TEST_PREPARE() prepare_vsyscall()
#define TEST_PROC_CALL() test_g_f()
#elif defined TEST_INT80H
#define TEST_PREPARE()
#define TEST_PROC_CALL() test_int80h()
#elif defined TEST_SYSENTER
#define TEST_PREPARE()
#define TEST_PROC_CALL() test_sysenter()
#else
#error Choose test
#endif
static inline unsigned long test() {
unsigned long result = ULONG_MAX;
struct timespec start = {0}, end = {0};
int rt, rt2, rt3;
for (int i = 0; i < 1000; ++i) {
rt = clock_gettime(CLOCK_MONOTONIC, &start);
rt3 = TEST_PROC_CALL();
rt2 = clock_gettime(CLOCK_MONOTONIC, &end);
assert(rt == 0);
assert(rt2 == 0);
assert(rt3 == 0);
result = min(difftime(start, end), result);
}
return result;
}
int main() {
TEST_PREPARE();
// prepare calls
int a = TEST_PROC_CALL();
assert(a == 0);
a = TEST_PROC_CALL();
assert(a == 0);
a = TEST_PROC_CALL();
assert(a == 0);
unsigned long result = test();
printf("%lu\n", result);
}
Компиляция:
gcc -O2 -DTEST_SYSCALL time_test.c -o test_syscall
gcc -O2 -DTEST_VDSO -ldl time_test.c -o test_vdso
gcc -O2 -DTEST_VSYSCALL time_test.c -o test_vsyscall
#m32
gcc -O2 -DTEST_VDSO -ldl -m32 time_test.c -o test_vdso_32
gcc -O2 -DTEST_INT80H -m32 time_test.c -o test_int80
gcc -O2 -DTEST_SYSENTER -m32 time_test.c -o test_sysenter
О системе
cat /proc/cpuinfo | grep "model name" -m 1
— Intel® Core(TM) i7-5500U CPU @ 2.40GHz
uname -r
— 4.14.13-1-ARCH
Таблица Результатов
Реализация | время (нс) |
---|---|
int 80h | 498 |
sysenter | 338 |
syscall | 278 |
vsyscall emulate | 692 |
vsyscall native | 278 |
vDSO | 37 |
vDSO-32 | 51 |
Как можно увидеть, каждая новая реализация системного вызова является производительней предыдущей, не считая vsysvall, так как это эмуляция. Как вы наверное уже догадались, если бы vsyscall был таким, каким его задумывали, время вызова было бы аналогично vDSO.
Все текущие сравнения производительности были произведены с патчем KPTI, исправляющим уязвимость meltdown.
Бонус: Производительность системных вызовов без KPTI
Патч KPTI был разработан специально для исправления уязвимости meltdown. Как известно, данный патч замедляет производительность ОС. Проверим производительность с выключенным KPTI (pti=off).
Таблица результатов с выключенным патчем
Реализация | Время (нс) | Увеличение времени исполнения после патча (нс) | Ухудшение производительности после патча (t1 - t0) / t0 * 100% |
---|---|---|---|
int 80h | 317 | 181 | 57% |
sysenter | 150 | 188 | 125% |
syscall | 103 | 175 | 170% |
vsyscall emulate | 496 | 196 | 40% |
vsyscall native | 103 | 175 | 170% |
vDSO | 37 | 0 | 0% |
vDSO-32 | 51 | 0 | 0% |
Переход в режим ядра и обратно в среднем после патча стал занимать примерно на 180 нс. больше времени, видимо это и есть цена сброса TLB-кэша.
Производительность системного вызова через vDSO не ухудшилась по причине того, то в данном типе вызова нет перехода в режим ядра, и, следовательно, нет причин сбрасывать TLB-кэш.
Для дальнейшего чтения
Реализация виртуальных системных вызовов в ядре Linux (очень хорошая книга, советую): https://0xax.gitbooks.io/linux-insides/content/SysCall/syscall-3.html
История развития Linux: https://www.win.tue.nl/~aeb/linux/lk/lk-4.html
Анатомия системных вызовов, часть 1: https://lwn.net/Articles/604287/
Анатомия системных вызовов, часть 2: https://lwn.net/Articles/604515/
Ссылки
[0] Intel 64 and IA-32 Architectures Developer’s Manual: Vol. 2B
[1] How to benchmark code execution times …
[2] Instruction latencies and throughput for AMD and Intel x86 processors
[3] AMD64 Architecture Programmer’s Manual Volume 2: System Programming
[4] System V ABI AMD64