#3972·x64dbg

createthread-made threads can be mis-recorded in the thread list (TID/handle mismatch); switchthread silently no-ops

Author: KevinLee1231Created Sep 23, 2026Updated Sep 23, 2026

Summary

When a thread is created through the createthread command (i.e. CreateRemoteThread while the debuggee is suspended at a breakpoint), its THREADINFO can end up with a wrong ThreadId↔Handle association. After that happens:

  • switchthread <tid> silently does nothing — no "Thread switched!" message, yet the command reports success;
  • the Threads view "current" marker and the actual register context disagree;
  • switchthread to the mis-recorded thread can activate a different thread's context (register reads/writes then hit the wrong thread).

Threads created by the debuggee itself (CreateThread from the program) are fine; the problem shows up with threads created while the debuggee is suspended, which is exactly what the built-in createthread command does.

Reproduction (x64, Windows 11, development branch build)

  1. Load any target (e.g. notepad.exe), stopped at the system breakpoint.
  2. Command bar: createthread kernel32!GetLastError — note thread A from the log line (Thread ... created at ..., also stored in $result).
  3. createthread kernel32!GetLastError again — note thread B.
  4. switchthread <main tid> — works ("Thread switched!").
  5. switchthread B — often produces no message; the Threads view current marker may highlight a different thread than the register context reflects.
  6. switchthread A — often silently no-ops: no message and cip still shows the previously active thread.

In three consecutive sessions, at least one of the two createthread-made threads was mis-recorded every time (which one varies).

Observed matrix (one session, paused at a breakpoint hit by 0x8458)

0x9754 = main thread, 0x8458 and 0x14c0 = createthread-made threads.

command "Thread switched!" printed Threads-view current register cip
switchthread 0x9754 (main) yes 0x9754 0x9754's
switchthread 0x14c0 no 0x8458 0x14c0's
switchthread 0x8458 no 0x9754 0x9754's

Row 2: the register context actually moved, but the current-thread scan (ThreadGetList's threadHandle == hActiveThread walk) matched a different entry — two entries share the stored handle. Row 3: ThreadGetHandle(0x8458) behaves as if it returns the main thread's handle, so the register context stays on main.

Analysis (development branch sources)

  1. ThreadCreate (src/dbg/thread.cpp) identifies the new thread by the event dispatcher's current event instead of the handle it was just given:
cpp
curInfo.Handle = CreateThread->hThread;
curInfo.ThreadId = GetDebugData()->dwThreadId;   // <- not GetThreadId(CreateThread->hThread)
...
threadList.emplace(curInfo.ThreadId, curInfo);  // <- silently keeps the old entry on key collision

For threads created while the debuggee is suspended, the CREATE_THREAD_DEBUG_EVENT is queued during the break and processed later; trusting GetDebugData()->dwThreadId at that point is what breaks the ThreadId↔Handle association (matches the symptoms: wrong/duplicate stored handles).

  1. emplace never repairs a stale entry: if the key already exists, the new THREADINFO is dropped silently.

  2. cbDebugSwitchthread (src/dbg/commands/cmd-thread-control.cpp) reports success even when it changes nothing — when ThreadGetHandle(threadid) returns the already-active handle (mis-recorded entry), the if(hActiveThread != newThread) block is skipped without a message and true is returned. Same for the call stack view's switchthread %1, quiet.

Suggested fix

  • In ThreadCreate, use curInfo.ThreadId = GetThreadId(CreateThread->hThread); (keep GetDebugData()->dwThreadId only as a fallback).
  • Use insert_or_assign (or erase + insert) instead of emplace so a re-registration can repair a stale entry.
  • Make switchthread distinguish "switched" from "already active", and warn when ThreadGetId(ThreadGetHandle(threadid)) != threadid (stored-association inconsistency), so automation can detect the bad state instead of writing registers to the wrong thread.

We hit this through bridge/script automation (x64dbg-automate plugin) and are happy to re-run the same scenario against a patch.