The Binary Stayed the Same: Mutex Handle Manipulation with HueSyncPatch
A Windows Object Manager case study in bypassing named-mutex single-instance checks by enumerating, duplicating, and closing remote handles, with EDR hunting caveats.
A named Windows object lives only as long as something still owns a reference to it. For a named mutex, the practical owner is usually a process handle. The object name gives other processes a way to find the same kernel object, but the handle is what keeps that object alive.
That is the load-bearing idea behind HueSyncPatch. The interesting part is not Philips Hue Sync by itself. The useful lesson is that a single-instance check is often just Object Manager state: a process creates a named mutex, keeps a handle open, and later launches decide whether another instance is already running by checking whether that named object already exists.
If a separate tool can find the first process’s mutex handle and force that handle closed, the named object can disappear without patching the executable, rewriting the application, or changing the mutex name. The program’s binary is unchanged. The system’s handle state changed.
This article treats HueSyncPatch as a small Windows Object Manager case study. The same mechanics matter to reversers, defenders, tool builders, and CNO operators because they sit at the boundary between normal administrative introspection and behavior that security products should care about.
The Single-Instance State
Many Windows applications enforce a single-instance rule by creating a named mutex, which is a kernel synchronization object with a path in the Object Manager namespace. A simplified version looks like this:
HANDLE mutex = CreateMutexW(nullptr, FALSE, L"Local\\Philips Hue Sync");
if (mutex == nullptr) {
// Creation failed. Inspect GetLastError().
}
if (GetLastError() == ERROR_ALREADY_EXISTS) {
// Another process already created the named mutex.
// Exit, focus the existing window, or take another product-specific path.
}
The name is not the lifetime. The handle is the lifetime edge the process controls. When the last reference to the mutex is released, the kernel object can be destroyed and the name can stop resolving. A second launch that probes for the same name can then take the “first instance” path again.
That distinction is where the earlier mental model breaks. A mutex is not just a string in a process. It is an Object Manager object with references, handles, access masks, and a name that may include a session-specific path such as \Sessions\<id>\BaseNamedObjects\....
The Handle-State Workflow
HueSyncPatch does not need to scan memory for the mutex string or patch a conditional branch. The workflow is handle-state manipulation:
- Enumerate system handles with
NtQuerySystemInformation. - Keep candidate handles owned by the target process.
- Duplicate each candidate handle into the local process.
- Query the duplicated handle with
NtQueryObject. - Compare the returned Unicode object name with the target mutex name.
- Ask the kernel to close the source process handle with
DuplicateHandleandDUPLICATE_CLOSE_SOURCE.
Each step has a failure mode. A robust implementation treats those failures as expected operating conditions, not as exceptional surprises.
System Handle Enumeration
Windows exposes the global handle table through native system information classes. Tools commonly use NtQuerySystemInformation with SystemExtendedHandleInformation because it returns enough metadata to identify the owning process and the raw handle value.
The important result is not “all mutexes.” It is a snapshot of handle table entries. The snapshot can be stale as soon as it is returned. Handles can close, processes can exit, access checks can fail, and object names can change visibility based on namespace and session context.
Good code accounts for that by treating enumeration as a best-effort pass:
auto status = NtQuerySystemInformation(
SystemExtendedHandleInformation,
buffer.data(),
static_cast<ULONG>(buffer.size()),
&requiredLength);
if (status == STATUS_INFO_LENGTH_MISMATCH) {
// Grow the buffer and retry.
}
if (!NT_SUCCESS(status)) {
// Log the NTSTATUS value and stop or retry according to policy.
}
The buffer-growth loop matters. Handle counts can move while the query is running, so a single fixed allocation is not reliable. A production-quality loop caps retries or maximum allocation size, logs the final NTSTATUS, and avoids treating a larger-than-expected handle table as a reason to read past the returned buffer.
Local Duplication Before Inspection
A handle value is meaningful only inside the process that owns it. If process A has handle value 0x120, process B cannot call NtQueryObject on 0x120 and expect to inspect process A’s object. Process B must first duplicate that handle into its own handle table.
The local duplicate is the safe inspection handle:
HANDLE localRaw = nullptr;
if (!DuplicateHandle(
targetProcess.get(),
remoteHandleValue,
GetCurrentProcess(),
&localRaw,
0,
FALSE,
DUPLICATE_SAME_ACCESS)) {
// Log GetLastError().
// The remote handle may have closed, or access may be denied.
}
unique_handle localHandle{localRaw};
This is also where a race shows up. Enumeration found a handle, but the target process may close it before duplication. That is not a logic bug by itself. It is the normal shape of cross-process handle inspection.
PROCESS_DUP_HANDLE on the target process is the key access right. Without it, the duplicate operation fails before object naming is even relevant.
Querying The Object Name
Once the handle exists in the local process, NtQueryObject can ask for ObjectNameInformation. The returned value is a UNICODE_STRING, not a null-terminated C string. Code that treats it as a conventional wchar_t* risks truncation or accidental overread.
The safe pattern is to use the returned byte length:
const auto* name = reinterpret_cast<const UNICODE_STRING*>(buffer.data());
std::wstring_view objectName{name->Buffer, name->Length / sizeof(wchar_t)};
Object names are Unicode, namespace-qualified, and sometimes absent. Some object types do not have names. Some queries can fail or hang in poorly isolated tooling if the implementation queries the wrong object type without timeouts or worker isolation. A targeted utility should narrow candidates before expensive queries and make name lookup failure a logged outcome, not a crash path.
For a mutex created under Local\..., the queried name may appear under the session’s BaseNamedObjects directory. A comparison routine should make an explicit decision: match the fully qualified Object Manager path, match a known suffix, or normalize the expected Local\ and Global\ forms into canonical namespace paths. Silent string shortcuts are how tools close the wrong handle.
Closing The Source Handle
DuplicateHandle has one flag that changes the operation from “copy this handle” to “close the source handle as part of duplication.” That flag is DUPLICATE_CLOSE_SOURCE.
Used against the target process, it asks the kernel to close the handle owned by that process:
HANDLE closedRaw = nullptr;
if (!DuplicateHandle(
targetProcess.get(),
remoteHandleValue,
GetCurrentProcess(),
&closedRaw,
0,
FALSE,
DUPLICATE_CLOSE_SOURCE | DUPLICATE_SAME_ACCESS)) {
// Log GetLastError().
// Do not blindly retry: DUPLICATE_CLOSE_SOURCE can close the source
// handle even when the function reports failure.
}
unique_handle closedCopy{closedRaw};
The destination duplicate is usually not the point. The close side effect is the point. After the call succeeds, the tool should close its own duplicate through RAII and avoid keeping a new reference alive accidentally. Otherwise the “closed” mutex may still exist because the tool itself preserved a handle to the same object.
That subtlety is easy to miss: if the patching tool duplicates the mutex, then closes the remote source, but keeps its local duplicate open, the named object remains referenced.
RAII Is Part Of Correctness
Handle-state tools should be boring about cleanup. Raw HANDLE values make it too easy to leak a reference, double-close a handle, or return early without releasing the inspection duplicate.
A small RAII wrapper is enough:
class unique_handle {
public:
unique_handle() noexcept = default;
explicit unique_handle(HANDLE value) noexcept : value_(value) {}
unique_handle(const unique_handle&) = delete;
unique_handle& operator=(const unique_handle&) = delete;
unique_handle(unique_handle&& other) noexcept
: value_(std::exchange(other.value_, nullptr)) {}
unique_handle& operator=(unique_handle&& other) noexcept {
if (this != &other) {
reset(std::exchange(other.value_, nullptr));
}
return *this;
}
~unique_handle() noexcept {
reset();
}
[[nodiscard]] HANDLE get() const noexcept {
return value_;
}
[[nodiscard]] explicit operator bool() const noexcept {
return value_ != nullptr && value_ != INVALID_HANDLE_VALUE;
}
void reset(HANDLE next = nullptr) noexcept {
if (*this) {
CloseHandle(value_);
}
value_ = next;
}
private:
HANDLE value_ = nullptr;
};
The exact wrapper can be wil::unique_handle, std::unique_ptr with a custom deleter, or a local type like the one above. The important property is ownership clarity. Every duplicated handle has one owner, one release path, and no hidden lifetime extension.
Privilege Handling
Administrative elevation is not the same thing as having every privilege enabled in the token. A tool may need to enable SeDebugPrivilege before opening a target process with PROCESS_DUP_HANDLE, especially when inspecting processes outside its normal access boundary.
The implementation should log both parts of the operation:
| Step | Failure source |
|---|---|
OpenProcessToken | Win32 error |
LookupPrivilegeValueW | Win32 error |
AdjustTokenPrivileges | Win32 call result plus GetLastError() |
OpenProcess | Win32 error |
NtQuerySystemInformation | NTSTATUS |
NtQueryObject | NTSTATUS |
AdjustTokenPrivileges deserves special attention because a nonzero return value does not guarantee the privilege was assigned. Callers still need to check GetLastError() for ERROR_NOT_ALL_ASSIGNED.
For defenders, privilege enablement is valuable context. For tool authors, it is also a boundary. If the process cannot enable the privilege or cannot open the target with PROCESS_DUP_HANDLE, the correct behavior is to report that boundary clearly, not to continue with misleading partial results.
NTSTATUS And Win32 Errors
Native APIs and Win32 APIs report failure differently. NtQuerySystemInformation and NtQueryObject return NTSTATUS. OpenProcess, DuplicateHandle, and token APIs report through a boolean or handle result plus GetLastError().
Mixing those models creates bad diagnostics. A log line that prints GetLastError() after NtQueryObject is usually noise. A log line that converts every native failure into a generic Win32 error loses detail the operator may need.
Good diagnostics keep the source attached:
NtQueryObject(ObjectNameInformation) failed: NTSTATUS=0xC0000004
DuplicateHandle(DUPLICATE_CLOSE_SOURCE) failed: Win32Error=5
Those are example formats, not captured output from a run. The point is to preserve the error domain so the next reader knows which API produced the value.
Telemetry And Hunting Value
The behavior is useful hunting material because it combines signals that are uncommon in ordinary desktop software:
| Signal | Why it matters |
|---|---|
SeDebugPrivilege enablement | Expands process access boundaries |
| System handle enumeration | Builds a cross-process object map |
Cross-process DuplicateHandle | Inspects or transfers another process’s handles |
DUPLICATE_CLOSE_SOURCE | Forces a source process handle closed |
| Named mutex targeting | Can alter single-instance or coordination state |
The caveat is important. None of these signals is malicious by itself. Debuggers, diagnostic tools, handle viewers, EDR sensors, sandbox tooling, and administrative utilities can all perform some of these actions legitimately.
The stronger hunting shape is the sequence:
- A process enables or relies on elevated debug access.
- It enumerates system handles.
- It opens another process with
PROCESS_DUP_HANDLE. - It queries object names through duplicated handles.
- It uses
DUPLICATE_CLOSE_SOURCEagainst a named mutex or other coordination object.
Even then, the conclusion should be behaviorally precise: the process manipulated another process’s handle state. Additional evidence is needed before claiming malware, credential theft, injection, or persistence.
Where The Case Study Stops
This post does not claim that HueSyncPatch proves a general bypass for every single-instance application. Applications can enforce instance state through windows, lock files, named events, shared memory, IPC servers, per-user state, licensing services, or product-specific coordination. Closing a mutex handle only affects designs where that handle is the relevant lifetime reference.
It also does not claim that system handle enumeration is a stable public contract. The technique relies on native interfaces and structures whose behavior can vary across Windows versions and build configurations. A tool that uses them should isolate that code, validate structure sizes, handle partial failures, and make unsupported builds explicit.
Finally, this is not proof of a live run, a vendor detection, or a production bypass in a specific environment. No command output, screenshots, traces, or offsets are being presented here. The value is the mechanism: how Object Manager handle ownership explains the observable behavior and how defenders can reason about the telemetry without overstating it.
The Payoff
HueSyncPatch is useful because it makes an invisible piece of Windows state visible. A named mutex is not just a name, and a single-instance check is not always an application feature sealed inside the binary. Sometimes it is a handle held open by a process.
Find that handle, duplicate it locally, query its Object Manager name, and close the source reference, and the state of the system changes. The defensive lesson is just as direct: monitor the sequence, preserve the error and privilege context, and describe the behavior for what it is before making larger claims.