LIMB
Вход Регистрация

тема

Введение в разработку UEFI-приложений на FASM (x64)

ghotycal57 · · 0 ответов · 535 просмотров

Что может быть лучше, чем писать статью в прекрасную весеннюю погоду? Правильно, ничего.
Сегодня поговорим про разработку UEFI-приложений под архитектуру x64. Разработка тех же DXE-драйверов - направление узкое и специфическое, поэтому увлекаются им немногие. Но такие люди есть. Обычно это те, кто копает в сторону создания собственных ОС или исследует тему буткитов, ну либо пишет их, действительно, что может быть веселее разработки буткитов?
В этой статье мы разберем основы и поймем, как войти в этот закрытый клуб низкоуровневой разработки.
тности это люди которые интересруются разработкой буткитов, разработкой ОС иммногое другое. Начинаем, сегодня напишем два простых приложения для демонстрации.

Для разработки я выбрал свой любимый ассемблер FASM. Чтобы собирать UEFI-приложения, мы будем использовать формат format pe64 dll efi. А чтобы жизнь не превращалась в бесконечный поиск офсетов и смещений внутри системных таблиц, я подготовил отдельный INC-файл. В нем уже прописаны все нужные структуры, так что нам не придется держать их в голове.
Спойлер: EFI.inc
C:
// efi.inc

struc int8 {
  . db ?
}
struc int16 {
  align 2
  . dw ?
}
struc int32 {
  align 4
  . dd ?
}
struc int64 {
  align 8
  . dq ?
}
struc intn {
  align 8
  . dq ?
}
struc dptr {
  align 8
  . dq ?
}
struc POINTER {
  align 8
  . dq ?
}


;symbols

EFIERR = 0x8000000000000000
EFI_SUCCESS            = 0
EFI_LOAD_ERROR            = EFIERR or 1
EFI_INVALID_PARAMETER        = EFIERR or 2
EFI_UNSUPPORTED         = EFIERR or 3
EFI_BAD_BUFFER_SIZE        = EFIERR or 4
EFI_BUFFER_TOO_SMALL        = EFIERR or 5
EFI_NOT_READY            = EFIERR or 6
EFI_DEVICE_ERROR        = EFIERR or 7
EFI_WRITE_PROTECTED        = EFIERR or 8
EFI_OUT_OF_RESOURCES        = EFIERR or 9
EFI_VOLUME_CORRUPTED        = EFIERR or 10
EFI_VOLUME_FULL         = EFIERR or 11
EFI_NO_MEDIA            = EFIERR or 12
EFI_MEDIA_CHANGED        = EFIERR or 13
EFI_NOT_FOUND            = EFIERR or 14
EFI_ACCESS_DENIED        = EFIERR or 15
EFI_NO_RESPONSE         = EFIERR or 16
EFI_NO_MAPPING            = EFIERR or 17
EFI_TIMEOUT            = EFIERR or 18
EFI_NOT_STARTED         = EFIERR or 19
EFI_ALREADY_STARTED        = EFIERR or 20
EFI_ABORTED            = EFIERR or 21
EFI_ICMP_ERROR            = EFIERR or 22
EFI_TFTP_ERROR            = EFIERR or 23
EFI_PROTOCOL_ERROR        = EFIERR or 24

EfiResetCold            = 0
EfiResetWarm            = 1
EfiResetShutdown        = 2

;helper macro for definition of relative structure member offsets

macro struct name
{
  virtual at 0
    name name
  end virtual
}

;structures

struc SIMPLE_INPUT_INTERFACE {
 .Reset         dptr
 .ReadKeyStroke dptr
 .WaitForKey    dptr
}
struct SIMPLE_INPUT_INTERFACE


struc EFI_TABLE_HEADER {
 .Signature    int64
 .Revision     int32
 .HeaderSize   int32
 .CRC32        int32
 .Reserved     int32
}
struct EFI_TABLE_HEADER

struc EFI_SYSTEM_TABLE {
 .Hdr               EFI_TABLE_HEADER
 .FirmwareVendor       dptr
 .FirmwareRevision     int32
 .ConsoleInHandle      dptr
 .ConIn            dptr
 .ConsoleOutHandle     dptr
 .ConOut           dptr
 .StandardErrorHandle  dptr
 .StdErr           dptr
 .RuntimeServices      dptr
 .BootServices           dptr
 .NumberOfTableEntries intn
 .ConfigurationTable   dptr
}
struct EFI_SYSTEM_TABLE

struc SIMPLE_TEXT_OUTPUT_INTERFACE {
 .Reset         dptr
 .OutputString        dptr
 .TestString        dptr
 .QueryMode        dptr
 .SetMode        dptr
 .SetAttribute        dptr
 .ClearScreen        dptr
 .SetCursorPosition dptr
 .EnableCursor        dptr
 .Mode            dptr
}
struct SIMPLE_TEXT_OUTPUT_INTERFACE

struc EFI_BOOT_SERVICES {
 .Hdr                                 EFI_TABLE_HEADER
 .RaiseTPL                            POINTER
 .RestoreTPL                          POINTER
 .AllocatePages                       POINTER
 .FreePages                           POINTER
 .GetMemoryMap                        POINTER
 .AllocatePool                        POINTER
 .FreePool                            POINTER
 .CreateEvent                         POINTER
 .SetTimer                            POINTER
 .WaitForEvent                        POINTER
 .SignalEvent                         POINTER
 .CloseEvent                          POINTER
 .CheckEvent                          POINTER
 .InstallProtocolInterface            POINTER
 .ReinstallProtocolInterface          POINTER
 .UninstallProtocolInterface          POINTER
 .HandleProtocol                      POINTER
 .Reserved                            POINTER
 .RegisterProtocolNotify              POINTER
 .LocateHandle                        POINTER
 .LocateDevicePath                    POINTER
 .InstallConfigurationTable           POINTER
 .LoadImage                           POINTER
 .StartImage                          POINTER
 .Exit                                POINTER
 .UnloadImage                         POINTER
 .ExitBootServices                    POINTER
 .GetNextMonotonicCount               POINTER
 .Stall                               POINTER
 .SetWatchdogTimer                    POINTER
 .ConnectController                   POINTER
 .DisconnectController                POINTER
 .OpenProtocol                        POINTER
 .CloseProtocol                       POINTER
 .OpenProtocolInformation             POINTER
 .ProtocolsPerHandle                  POINTER
 .LocateHandleBuffer                  POINTER
 .LocateProtocol                      POINTER
 .InstallMultipleProtocolInterfaces   POINTER
 .UninstallMultipleProtocolInterfaces POINTER
 .CalculateCrc32                      POINTER
 .CopyMem                             POINTER
 .SetMem                              POINTER
 .CreateEventEx                       POINTER
}
struct EFI_BOOT_SERVICES

struc EFI_RUNTIME_SERVICES {
 .Hdr                       EFI_TABLE_HEADER
 .GetTime                   POINTER
 .SetTime                   POINTER
 .GetWakeupTime             POINTER
 .SetWakeupTime             POINTER
 .SetVirtualAddressMap      POINTER
 .ConvertPointer            POINTER
 .GetVariable               POINTER
 .GetNextVariableName       POINTER
 .SetVariable               POINTER
 .GetNextHighMonotonicCount POINTER
 .ResetSystem               POINTER
 .UpdateCapsule             POINTER
 .QueryCapsuleCapabilities  POINTER
 .QueryVariableInfo         POINTER
}
struct EFI_RUNTIME_SERVICES

struc EFI_GRAPHICS_OUTPUT_PROTOCOL {
 .QueryMode POINTER
 .SetMode   POINTER
 .Blt       POINTER
 .Mode      POINTER
}
struct EFI_GRAPHICS_OUTPUT_PROTOCOL

struc EFI_GRAPHICS_OUTPUT_PROTOCOL_MODE {
 .MaxMode         int32
 .Mode            int32
 .Info            POINTER
 .SizeOfInfo      intn
 .FrameBufferBase int64
 .FrameBufferSize intn
}
struct EFI_GRAPHICS_OUTPUT_PROTOCOL_MODE

Статью я бы хотел начать с базы, что вообще такое UEFI? 
UEFI - это современная прошивка, которая поставляется вместе с материнской платой и служит своеобразным мостом между железом и операционной системой.
По сути, это полноценная мини-ОС со своим API, драйверами, UEFI-драйверы принято называть DXE, если расшифровывать - Driver Execution Environmen, по сути, DXE-драйверы - это программные модули, которые остаются в памяти и предоставляют сервисы для других приложений или самой ОС.
UEFI пришла на смену старому доброму Legacy BIOS, который верой и правдой служил индустрии десятилетиями, но в какой-то момент просто перестал вывозить аппетиты современного софта и объемы новых накопителей.
Главная проблема Legacy BIOS заключалась в его 16-битном режиме работы и жестком лимите адресного пространства в один мегабайт, но в те времена, когда компьютеры занимали целые комнаты, этого хватало с головой, но для современных реалий это стало бутылочным горлышком.
BIOS инициализировал железо по старинке, полагаясь на прерывания и фиксированные адреса, что делало процесс загрузки долгим и неповоротливым. К тому же, ограничение в 2.2 терабайта на размер диска из-за разметки MBR стало последним гвоздем в крышку гроба этой технологии.
Переход на UEFI в середине 2000-х (спасибо Intel и их инициативе Itanium) кардинально изменил правила игры, программисты, да и обычные люди получили полноценный 64-битный режим работы еще до того, как загрузится ядро Windows или Linux. Это позволило использовать нормальные драйверы, графические интерфейсы с поддержкой мышки и, что самое важное, разметку GPT. Теперь мы можем спокойно работать с дисками практически любого объема и не переживать, что система не увидит лишние терабайты.
Исторически UEFI развивалась как проект EFI (Extensible Firmware Interface), целью которого было создание гибкой и расширяемой среды.
Под UEFI можно писать свои драйверы (те самые DXE, о которых я говорил выше), создавать приложения и даже встраивать криптографические протоколы вроде SecureBoot/TPM для защиты цепочки загрузки, обход которых интересен разработчикам буткитов, условно через BYOVD, но - это уже другая тема, к данной статье не имеет никакого отношения. 
Сегодня Legacy BIOS окончательно уходит в прошлое. 

Что-ж, пора бы и на практике что-то попробовать.
C:
format pe64 dll efi   ;// непосредственно сам формат
entry main  ;// точка входа(можно назвать любым)
include 'EFI.inc'

section '.text' code executable readable
main:

section '.data' data readable writeable
Как я уже писал чуть выше, под программирование UEFI есть собственный формат файла. В нашем случае pe64 dll efi, то есть 64 битное UEFI-приложение, кто-то может задаться вопросом почему приписка dll, дело в том, что в FASM указывает компилятору, что наш бинарник должен иметь таблицу перебазирования, релокации, потому что прошивка может загрузить код по любому адресу в оперативной памяти, который будет свободен на момент старта. 
Без этой таблицы внутренние адреса переходов в коде просто поплывут, и система намертво зависнет при попытке выполнить первую же инструкцию
И кстати, важно, PE был выбран спецификацией EFI как стандарт, чтобы не изобретать велосипед и использовать уже готовые инструменты линковки.

Давайте создадим простенькое приложение и скомпилируем его, к примеру: вывод Hello World.
C:
format pe64 dll efi
entry main
include 'EFI.inc'

section '.text' code executable readable
main:
        mov     [SystemTable], rdx
        sub        rsp, 48h ;// выравнивание
        
        mov     rax, [SystemTable]
        mov     rcx, [rax + EFI_SYSTEM_TABLE.ConOut]
        lea     rdx, [_hello]
        mov  rax, [rcx + SIMPLE_TEXT_OUTPUT_INTERFACE.OutputString]
        call    rax
        
        
        add     rsp, 48h
        mov     rax, EFI_SUCCESS
        ret
        
section '.data' data readable writeable
SystemTable dq ?
_hello du 'Hello from UEFI! This is forum-duty.free!', 13, 10, 0
Начнем разбор. Первое, что я бы хотел учесть - это то, что при разработке UEFI-приложений действуют правила похожие на Windows x64 ABI, первые четыре аргумента любой функции всегда передаются через регистры RCX, RDX, R8 и R9. Все, что не влезло, отправляется в стек.
Если же начать разбор куда, то все просто.
SystemTable всегда сохраняется в регистр rdx, SystemTable - это основной пульт управления всей средой UEFI, при старте приложения прошивка всегда передает указатель на эту структуру.
Можно взять этот адрес и сохранить его в переменную, потому что это единственный способ достучаться до функций системы, ввода с клавиатуры или вывода на экран. То есть без этой структуры никак.
Если сразу не сделать mov [SystemTable], rdx, то при первой же попытке что-то напечатать через тот же OutputString который используется для вывода на экран или выделить буфер возникнет проблема, ведь без этого указателя программа остается в полной изоляции от API прошивки. По сути это главная точка входа во все сервисы, поэтому сохранение значения из rdx в секцию .data всегда идет самой первой инструкцией, чтобы потом спокойно вызывать нужные функции через смещения.

Также важно учиывать, что в UEFI-приложениях строка создается через тип данных DU вместо DB, это происходит потому, что прошивка работает исключительно с кодировкой UTF-16.
В обычном ассемблере мы привыкли использовать db для записи каждого символа в один байт, но в UEFI каждый символ занимает строго два байта, если попробовать вывести обычную строку через db, функция OutputString просто не поймет, где заканчивается буква, и на экране вместо текста появится непонятная каша из иероглифов или пустых мест.
Использование директивы du в FASM заставляет компилятор автоматически выделять по 16 бит на каждый знак, что полностью соответствует спецификации.

Теперь касаемо ConOut, это указатель внутри системной таблице SystemTable, который ведет на интерфейс простого текстового вывода, через него вызывается функция OutputString которая как я уже сказал, выводит на экран текст.
Структура функции выглядит так:
Не удалось прогрузить медиа

С-подобный:
C:
typedef struct _SIMPLE_TEXT_OUTPUT_INTERFACE {
    unsigned long long Reset;
    unsigned long long (*OutputString)(struct _SIMPLE_TEXT_OUTPUT_INTERFACE *This, const unsigned short *String);
    unsigned long long TestString;
} SIMPLE_TEXT_OUTPUT_INTERFACE;

typedef struct {
    char Header[24];
    void *FirmwareVendor;
    unsigned int FirmwareRevision;
    void *ConsoleInHandle;
    void *ConIn;
    void *ConsoleOutHandle;
    SIMPLE_TEXT_OUTPUT_INTERFACE *ConOut;
} EFI_SYSTEM_TABLE;
Она находится внутри  нашего EFI.inc, структура SIMPLE_TEXT_OUTPUT_INTERFACE 
служит для описания всех функций управления текстовым выводом на экран, в статье мы будем ею пользоваться для вызова OutputString.
Это по сути набор указателей на системные подпрограммы, которые умеют очищать экран, менять цвет текста, перемещать курсор и печатать строки и многое другое.
C:
        mov     rcx, [rax + EFI_SYSTEM_TABLE.ConOut]
        lea     rdx, [_hello]
        mov        rax, [rcx + SIMPLE_TEXT_OUTPUT_INTERFACE.OutputString]
        call    rax
Непосредственно в программе идет обращение к этой структуре, она принимает к себе указатель на переменную _hello которая хранит в себе строку "Hello from UEFI! This is forum-duty.free!", после чего значение оттуда передается этой функции и она вызывается через call rax.
Если же мы скомпилируем программу, то у нас появится файл [название.EFI]:
Не удалось прогрузить медиа

Как видно на скрине, компиляция прошла успешно, непосредственно для самой эмуляции файла и запуска внутри UEFI, нам пригодится эмулятор QEMU.
После скачивания эмулятора, мы можем приступать к следующему этапу, нам необходимо пройти несколько этапов:
1. Создаем отдельную папку на рабочем столе, назовите ее USB;
2. Внутри этой папки создайте папку EFI;
2. Внутри папки EFI создайте папку BOOT;
3. Переносите туда .EFI файл.
Не удалось прогрузить медиа

Важно, не забудьте переименовать файл в BOOTX64.efi, это нужно для того, чтобы прошивка смогла автоматически найти и запустить приложение при старте системы.
В спецификации UEFI прописан стандартный путь для поиска загрузчика на съемных носителях, файл должен лежать в папке \EFI\BOOT\ под строго определенным именем. Для архитектуры x64 это именно BOOTX64.efi, и если назвать файл иначе, то встроенный менеджер загрузки просто его не увидит.
После всех манипуцяций ваша задача зайти в корневой файл с QEMU, нажать по пустому месту и выбрать "Открыть в терминале"
После чего ввести команду:
Code:
.\qemu-system-x86_64.exe -L . -drive if=pflash,format=raw,readonly=on,file=.\share\edk2-x86_64-code.fd -drive format=raw,file=fat:rw:C:\ВАШ ПУТЬ ДО ФАЙЛА USB
После чего жмем Enter и видим как у нас запускается эмулятор и видим:
Не удалось прогрузить медиа

Отлично, мы вывели текст на экран, но не кажется ли вам, что текст мрачный и почти незаметный?
Давайте украсим его, сделаем красивее, покрасим в зеленый! Сделать это можно через функцию SetAttribute которая находится в структуре SIMPLE_TEXT_OUTPUT_INTERFACE, которую мы уже обсуждали:)
Функция позволяет задать цвет текста и фона для вывода в консоль, чтобы приложение не выглядело как обычное черно-белое окно. 
Можно взять и передать в регистр rdx байтовое значение, где младшие четыре бита отвечают за цвет текста, а следующие четыре - за цвет фона. 
Передадим значение 1Eh, и посмотрим что из этого выйдет.
Code:
        mov     rax, [SystemTable]
        mov     rcx, [rax + EFI_SYSTEM_TABLE.ConOut]
        mov        rdx, 1Eh
        mov     rax, [rcx + SIMPLE_TEXT_OUTPUT_INTERFACE.SetAttribute]
        call     rax
        
        mov     rax, [SystemTable]
        mov     rcx, [rax + EFI_SYSTEM_TABLE.ConOut]
        lea     rdx, [_hello]
        mov        rax, [rcx + SIMPLE_TEXT_OUTPUT_INTERFACE.OutputString]
        call    rax
Важно понимать, что непосредственно красите вы текст еще до того, как его вывели, потому что функция SetAttribute просто меняет текущее состояние консоли.
Можно представить это как выбор фломастера перед тем, как начать писать на бумаге, если сначала вызвать SetAttribute с нужным цветом, то весь последующий текст, который пойдет через OutputString, будет окрашен именно в этот оттенок.
Скомпилируем нашу программу и посмотрим, что выйдет:
Не удалось прогрузить медиа

Как видим программа отработало на отлично, но все равно, не кажется ли вам, что сложно заметить строку? Давай же очистим к чертям все кроме нашей строки!
Сделать это можно через функцию ClearScreen из нашей любимой структуры SIMPLE_TEXT_OUTPUT_INTERFACE.
Функцию очистки всего кроме нашей строки нужно вызывать еще до настройки цвета и вывода чего либо на экран, потому что в консоли может остаться мусор от предыдущих этапов загрузки или самой прошивки.
В ассемблере это выглядит как еще один простой вызов - кладем указатель на ConOut в rcx и вызываем ClearScreen по нужному смещению, после чего экран станет чище.
Не удалось прогрузить медиа

Как видим строка стала виднее, но ,много хлама.
За строкой идет поток логов от загрузчика прошивки, это происходит потому, что после вывода текста программа сразу завершает работу через ret, прошивка получает управление обратно и продолжает свою работу, вываливая в консоль техническую информацию о поиске других загрузочных устройств.
Чтобы экран остался чистым и на нем была только твоя надпись, нужно добавить бесконечный цикл или ожидание нажатия клавиши в конце кода.
Давайте реализуем это! И не только это.
Code:
        mov     [SystemTable], rdx
        sub        rsp, 48h
        
        mov     rax, [SystemTable]
        mov     rcx, [rax + EFI_SYSTEM_TABLE.ConOut]
        mov     rax, [rcx + SIMPLE_TEXT_OUTPUT_INTERFACE.ClearScreen]
        call    rax
        
        mov     rax, [SystemTable]
        mov     rcx, [rax + EFI_SYSTEM_TABLE.ConOut]
        mov        rdx, 1Eh
        mov     rax, [rcx + SIMPLE_TEXT_OUTPUT_INTERFACE.SetAttribute]
        call     rax
        
        mov     rax, [SystemTable]
        mov     rcx, [rax + EFI_SYSTEM_TABLE.ConOut]
        lea     rdx, [_hello]
        mov        rax, [rcx + SIMPLE_TEXT_OUTPUT_INTERFACE.OutputString]
        call    rax
        
        mov     rax, [SystemTable]
        mov     rcx, [rax + EFI_SYSTEM_TABLE.ConIn]
        xor     rdx, rdx
        mov     rax, [rcx + SIMPLE_INPUT_INTERFACE.Reset]
        call     rax
_wait_key:
        mov     rax, [SystemTable]
        mov     rcx, [rax + EFI_SYSTEM_TABLE.ConIn]
        lea     rdx, [InputKey]
        mov     rax, [rcx + SIMPLE_INPUT_INTERFACE.ReadKeyStroke]
        call     rax
        
        test     rax, rax
        jnz     _wait_key
        
        add        rsp, 48h
        mov     rax, EFI_SUCCESS
        ret
Код немного видоизменился, я добавил функцию Reset и цикл обработки нажатий клавиши.
Сначала вызывается функция Reset для протокола ввода, это нужно сделать, чтобы очистить буфер клавиатуры от случайных нажатий, которые могли произойти еще до старта программы. 
Затем идет основной цикл _wait_key. В нем вызывается функция ReadKeyStroke из структуры SIMPLE_INPUT_INTERFACE которая пытается прочитать нажатую клавишу и записывает данные в структуру InputKey.
Не удалось прогрузить медиа

С-подобно:
C:
typedef struct _SIMPLE_INPUT_INTERFACE {
    unsigned long long (*Reset)(struct _SIMPLE_INPUT_INTERFACE *This, unsigned char ExtendedVerification);
    unsigned long long (*ReadKeyStroke)(struct _SIMPLE_INPUT_INTERFACE *This, EFI_INPUT_KEY *Key);
    void *WaitForKey;
} SIMPLE_INPUT_INTERFACE;

typedef struct {
    unsigned short ScanCode; 
    unsigned short UnicodeChar;
} EFI_INPUT_KEY;
Если в момент вызова мы ничего не нажали, вернется ошибка EFI_NOT_READY.
Программа будет висеть в этом цикле до тех пор, пока в rax не придет заветный ноль, он же статус EFI_SUCCESS. 
P.S также я поменял цвет немного.
Давайте же скомпилируем программу и проверим работоспособность:
Не удалось прогрузить медиа

Как видим программа работает, если же мы нажмем любую кнопку сгенерируется EFI_SUCESS нам выведется отладочная информация и все.
Спойлер: Code
C:
;// ghotycal57 created
format pe64 dll efi
entry main
include 'EFI.inc'

section '.text' code executable readable
main:
        mov     [SystemTable], rdx
        sub        rsp, 48h
        
        mov     rax, [SystemTable]
        mov     rcx, [rax + EFI_SYSTEM_TABLE.ConOut]
        mov     rax, [rcx + SIMPLE_TEXT_OUTPUT_INTERFACE.ClearScreen]
        call    rax
        
        mov     rax, [SystemTable]
        mov     rcx, [rax + EFI_SYSTEM_TABLE.ConOut]
        mov        rdx, 1Eh
        mov     rax, [rcx + SIMPLE_TEXT_OUTPUT_INTERFACE.SetAttribute]
        call     rax
        
        mov     rax, [SystemTable]
        mov     rcx, [rax + EFI_SYSTEM_TABLE.ConOut]
        lea     rdx, [_hello]
        mov        rax, [rcx + SIMPLE_TEXT_OUTPUT_INTERFACE.OutputString]
        call    rax
        
        mov     rax, [SystemTable]
        mov     rcx, [rax + EFI_SYSTEM_TABLE.ConIn]
        xor     rdx, rdx
        mov     rax, [rcx + SIMPLE_INPUT_INTERFACE.Reset]
        call     rax
_wait_key:
        mov     rax, [SystemTable]
        mov     rcx, [rax + EFI_SYSTEM_TABLE.ConIn]
        lea     rdx, [InputKey]
        mov     rax, [rcx + SIMPLE_INPUT_INTERFACE.ReadKeyStroke]
        call     rax
        
        test     rax, rax
        jnz     _wait_key
        
        add        rsp, 48h
        mov     rax, EFI_SUCCESS
        ret

section '.data' data readable writeable
    SystemTable dq ?
    InputKey dw 0, 0
    _hello        du 'Hello from UEFI! This is forum-duty.free!', 13, 10
                du 'Press any key to exit...', 0
В заключение хочется сказать, что разработка под UEFI это реально драйвовое занятие, здесь можно писать как PoC буткиты, так и разные сканеры прошивок, инжекторы или вообще свои мини-ОС, надеюсь, этот материал принесет хоть какую-то пользу и поможет быстрее вкатиться в тему, буду благодарен за фидбек, реакции и оценки под постом. Удачи всем!
👍43 🔥6 😁7 😮7

Гостевой просмотр. Чтобы отвечать и ставить реакции, войдите или зарегистрируйтесь.