Introduction

So, in this short blog - we’ll be discussing how you could restrict the creation of threads to a certain extent by using a simple trick. As we know, all thread creation, essentially, goes through NtCreateThreadEx. This means when you invoke; CreateThread, CreateRemoteThread, etc, you’re going through NtCreateThreadEx behind the scenes. This makes it easy for us, who want to prevent people from creating threads because we don’t have to worry about doing this trick on multiple functions. This goes hand in hand with having whitelisted areas where you should be able to create your threads but you should do this under situations where you’re confident because if someone malicious finds out where you’re creating your legitimate threads, what stops them from creating their threads there too?

Theory behind this

Okay so now that we’ve spoken a little on how it works, let’s talk about exactly what we’ll be doing. Since all threads created go through NtCreateThreadEx what we’ll be doing is placing a debug breakpoint within NtCreateThreadEx at the start of the application. This means anyone trying to create a thread before we have even started up will get caught in our custom VEH Handler as trying to create a thread will throw EXCEPTION_BREAKPOINT. Once in our VEH Handler, we have full power over the user. We can start by checking the RAX and if it points to an illegitimate module or area of memory where nobody should be calling CreateThread from we can safely denounce that this is a malicious user as all our thread calls would be from areas of memory that we call whitelisted areas.

Hijacking a Thread

After all this, the malicious user might think instead of creating a thread - let’s hijack one. This is no better for them, while we can’t exactly use our NtCreateThreadEx method here - we can incorporate a new one. We can stack walk the list of addresses that the thread has gone through, if one of the addresses suddenly seems out of place we can subject it to the same theory of our VEH Handler, check the RAX, and see if it is creating a thread to an illegitimate area of memory by a numerous amounts of checks on the actual allocation itself as well as the typical whitelisted area check.

POC

Here’s a proof of concept I have written which will check if the function where CreateThread is being called onto is in a valid module. Of course the check I have is not extensive and you can definitely improve it heavily with just a little more time. I just wrote this in a short amount of time for the sole purpose of showing you how this could work in detecting and preventing thread creation.

namespace nt_handler {
	class nt_create_thread_ex : public memory::c_memory {
	private:
		byte orig_bytes[7];
		PVOID nt_create_thread_ex_addr;
	public:
		nt_create_thread_ex() {
			// Initialise address
			nt_create_thread_ex_addr = reinterpret_cast<PVOID>(GetProcAddress(GetModuleHandleA("ntdll.dll"), "NtCreateThreadEx"));
			// Store original bytes
			memcpy(orig_bytes, nt_create_thread_ex_addr, 7);
		}

		// Return original bytes
		byte* get_original_bytes();

		// Place debug breakpoints
		void place_debug_breakpoints();

		// Place back original bytes
		void place_original_bytes();
	};

	void nt_create_thread_ex::place_debug_breakpoints() {
		// Set old protection
		DWORD old_protection;
		// Change protection so we can edit the memory
		VirtualProtect(this->nt_create_thread_ex_addr, 7, PAGE_EXECUTE_READWRITE, &old_protection);
		// Set 7 bytes to int 3's
		memset(this->nt_create_thread_ex_addr, 0xCC, 7);
		// Restore page protection
		VirtualProtect(this->nt_create_thread_ex_addr, 7, old_protection, NULL);
	}

	void nt_create_thread_ex::place_original_bytes() {
		// Set old protection
		DWORD old_protection;
		// Change protection so we can edit the memory
		VirtualProtect(this->nt_create_thread_ex_addr, 7, PAGE_EXECUTE_READWRITE, &old_protection);
		// Set 7 bytes to original bytes
		memcpy(this->nt_create_thread_ex_addr, this->orig_bytes, 7);
		// Restore page protection
		VirtualProtect(this->nt_create_thread_ex_addr, 7, old_protection, NULL);
	}

	// Helper func for non class variables
	byte* nt_create_thread_ex::get_original_bytes() { return this->orig_bytes; };

	
	extern nt_handler::nt_create_thread_ex* nt_handler = nullptr;
}

This class extends my basic memory class I just use to have some checks on RAX, RIP, etc. This class can be easily replicated to the developers preference.

Now, let’s check the VEH handler out

LONG WINAPI nt_veh_handler(struct _EXCEPTION_POINTERS* exception_info) {
    if (exception_info->ExceptionRecord->ExceptionCode == EXCEPTION_BREAKPOINT) {
#ifdef debug_prints
        std::cout << "Exception" << '\n';
#endif
        if (!nt_handler::nt_handler->is_valid_rax(exception_info->ContextRecord->Rax)) {
#ifdef debug_prints
            std::cout << "Invalid RAX" << '\n';
#endif	
            return EXCEPTION_CONTINUE_SEARCH; // Force crash the malicious user
        }
        else {
#ifdef debug_prints
            std::cout << "Valid RAX, restoring bytes" << '\n';
#endif	
            nt_handler::nt_handler->place_original_bytes();
            return EXCEPTION_CONTINUE_EXECUTION;
        }
    }
    return EXCEPTION_CONTINUE_SEARCH;
}

As you can see, after validating RAX we restore the original bytes and continue with execution so that the user does not crash as (s)he is not malicious. \

This image refers to the creation of the thread initially going through the VEH handler

After being confirmed as a legit call, it’ll create the thread

Why RAX?

The reason why we’re checking RAX is fairly simple, in this case RAX holds the address of the function which is having a thread created for.

Conclusion

In conclusion, this was a really short blog detailing a method that can potentially be used and paired well with other anti-cheat technology to safely stop any vulnerabilities in creating threads. It’s very typical for cheaters to create threads if they’re able to do so without being caught. A lot of them go with the second option of hijacking a thread or maybe even hooking if the option of creating a thread is off the table, but both are just as detectable as the other.