CVE-2026-82312: How a NULL DACL on OpenVPN’s Named Objects Turned Every Multi-User Windows Box Into a Local Denial-of-Service Target
Most vulnerability research stories start with a theory and end with a crash. This one starts with a single Windows API call that looked wrong the moment I saw it in Ghidra, and ends with a patch merged across three branches (master, release/2.7, and release/2.6) and a CVE assigned by the project’s own security team. No memory corruption, no shellcode, no privilege escalation. Just a security descriptor that said “everyone is welcome” on objects that should have said “only the owner.”
This is the story of CVE-2026-82312, my first CVE, found during an independent assessment of OpenVPN Community 2.7.5 for Windows using a custom thick-client security framework I built for exactly this kind of work. I want to walk through the bug itself, the Windows internals that make it exploitable, the coordinated disclosure process from first email to merged fix, and what the patch actually changed in the code. If you are learning Windows security research, this is the kind of bug that teaches you more about how the operating system thinks about access control than any textbook chapter will.
What OpenVPN Ships on Windows
Before touching the bug, you need the architecture, because the bug only matters in the context of how these components trust each other.
OpenVPN on Windows can run a tunnel in a few different modes, and the distinction is the whole key to this bug:
- Interactive Service (
openvpnserv.exe) – runs asNT AUTHORITY\SYSTEM, listens on the named pipe\\.\pipe\openvpn\service, and starts a tunnel when a user connects through the GUI. The important detail: it launchesopenvpn.exeas the requesting (unprivileged) user and performs the privileged parts, adding routes and changing DNS, on the daemon’s behalf over the pipe. This is the default and recommended mode. - Automatic service – starts configured profiles at boot as SYSTEM, launching
openvpn.exewith the--service <exit_event>flag so it can be told to stop. - Direct / manual – a user or admin runs
openvpn.exestraight from the console, and it manages the tunnel in that user’s own context.
In the automatic-service and direct-run modes, openvpn.exe itself creates two named kernel objects to coordinate at the OS level:
- A named event (the
--serviceexit event) that a controlling service can signal to tell a runningopenvpn.exeinstance to shut down gracefully. - A named semaphore (the fixed-name
openvpn_netcmdguard) that serializes the daemon’s net commands,route.exe,netsh.exe,ipconfig, andregister-dns, because Windows does not handle several of these running at once reliably.
Both are global named objects visible to every process on the machine. The question that drives this entire finding is: who is allowed to touch them? Hold onto the mode distinction above, because it is also the answer to why the interactive service is the one configuration this bug does not affect: there, openvpn.exe runs as the user and the privileged work happens over the pipe, so there is no cross-user privileged object to hijack.
The Bug: init_security_attributes_allow_all and the NULL DACL
The answer, before the patch, was everyone.
Both the exit event and the openvpn_netcmd semaphore were created through a helper function called init_security_attributes_allow_all. The name tells you what it does, but looking at the code makes it worse:
// src/openvpn/win32.c (pre-patch)
bool
init_security_attributes_allow_all(struct security_attributes *obj)
{
CLEAR(*obj);
obj->sa.nLength = sizeof(SECURITY_ATTRIBUTES);
obj->sa.lpSecurityDescriptor = &obj->sd;
obj->sa.bInheritHandle = FALSE;
if (!InitializeSecurityDescriptor(&obj->sd, SECURITY_DESCRIPTOR_REVISION))
{
return false;
}
if (!SetSecurityDescriptorDacl(&obj->sd, TRUE, NULL, FALSE))
{
return false;
}
return true;
}
The critical line is SetSecurityDescriptorDacl(&obj->sd, TRUE, NULL, FALSE). That third argument, NULL, is the entire vulnerability.
To understand why, you need to understand how Windows decides who can access a kernel object.
Windows Security Descriptors: The Access Control Model That Makes This a Bug
Every securable object in Windows – files, registry keys, processes, mutexes, events, semaphores, named pipes – carries a security descriptor. The security descriptor contains, among other things, a Discretionary Access Control List (DACL). The DACL is a list of Access Control Entries (ACEs), each of which says “this security principal (user/group SID) is allowed (or denied) these specific access rights.”
When a process calls OpenEvent, OpenSemaphore, CreateFile, or any other API that opens a handle to a secured object, the kernel’s Security Reference Monitor (SRM) evaluates the caller’s token against the object’s DACL. If the DACL grants the requested access, the handle is opened. If not, the call fails with ERROR_ACCESS_DENIED.
Here is where the subtlety lives. The DACL field in a security descriptor can be in one of three states, and each has a radically different meaning:
| DACL state | What it means | Effect |
|---|---|---|
| Present, populated | Explicit ACEs list who can do what | Normal access control – SRM evaluates each ACE |
Present, NULL (SetSecurityDescriptorDacl(TRUE, NULL, ...)) | “I explicitly chose to have no access control” | Grants full access to everyone. Every process on the machine, regardless of privilege level, can open, read, write, signal, or delete the object. |
Not present (SetSecurityDescriptorDacl(FALSE, ...)) | “No DACL was set” | Uses the default DACL from the creator’s token, which is typically restricted to the owner and SYSTEM |
The distinction between “NULL DACL” and “no DACL” is the trap that has caught developers for decades. A NULL DACL is not “no security.” It is “explicitly no security” – a positive assertion that this object should be accessible to the entire machine. Microsoft’s own documentation warns against it:
“When an object has no DACL (when the
pDaclparameter is NULL), no protection is assigned to the object, and all access requests are granted… You should not use a NULL DACL with an object because any user can change the DACL and owner of the security descriptor.” – SetSecurityDescriptorDacl documentation, Microsoft Learn
One nuance worth stating for the learners in the audience: the safe “inherit a restrictive default” behavior of a missing DACL applies at object-creation time, which is exactly the case that matters here. An existing security descriptor that genuinely has no DACL at access-check time is evaluated like a NULL one and grants everyone access. For creating a new object, though, the missing-DACL path is the safe one and the NULL-DACL path is the dangerous one.
The init_security_attributes_allow_all function chose the dangerous middle option. It explicitly set a NULL DACL, and then applied the resulting security descriptor to both the exit event and the openvpn_netcmd semaphore. Any user on the box – a standard user with no admin rights, a service account, a guest account if one exists – could open and manipulate both objects.

The Two Attack Surfaces
Surface 1: The --service Exit Event
When openvpn.exe runs under the automatic service (as SYSTEM) or is started directly with the --service flag, it creates a named event that its controlling service can signal to request a graceful shutdown. The event name is derived from the config and is predictable. In the pre-patch code:
// win32_signal_open() in win32.c
if (!init_security_attributes_allow_all(&sa))
{
msg(M_ERR, "Error: win32_signal_open: init SA failed");
}
ws->in.read = CreateEvent(&sa.sa, TRUE, FALSE, exit_event_name);
The event is created with the NULL-DACL security attributes. Any local user who knows or guesses the event name can call OpenEvent(EVENT_MODIFY_STATE, ...) and then SetEvent() to signal it. The openvpn.exe process sees the signal, interprets it as a shutdown request, and terminates.
Impact: any unprivileged local user can kill a tunnel they do not own. The sharpest case is the automatic service, which runs openvpn.exe as SYSTEM: a standard user can signal that exit event and terminate a tunnel running under SYSTEM. On a shared workstation or terminal server, that is a denial of service against VPN connectivity the attacker has no business touching. The one mode this does not reach is the interactive service, where openvpn.exe runs as the connecting user and the privileged work goes over the pipe, so there is no higher-privileged tunnel object to hijack.
Surface 2: The openvpn_netcmd Guard Semaphore
OpenVPN on Windows shells out to several net commands, route.exe, netsh.exe, ipconfig, and register-dns, to configure routing, DNS resolvers, and adapter settings. Because concurrent invocations can corrupt each other’s state, the code serializes all of them behind a single fixed-name semaphore, openvpn_netcmd:
// semaphore_open() in win32.c
if (init_security_attributes_allow_all(&sa))
{
s->hand = CreateSemaphore(&sa.sa, 1, 1, name);
}
Same NULL DACL. The semaphore has count 1 (a mutex, effectively), and openvpn.exe acquires it before every net command with WaitForSingleObject. The lock has a 600-second timeout; if it expires, the process aborts with M_FATAL (“Cannot lock net command semaphore”) rather than run a command unserialized.
Attack: a malicious local user opens the semaphore with OpenSemaphore(SEMAPHORE_ALL_ACCESS, ...), acquires it with WaitForSingleObject, and never releases it. Because the name is fixed and predictable, no guessing is needed. Every subsequent openvpn.exe instance that needs a net command blocks on the semaphore, hits the 600-second timeout, and dies with the fatal error above. No VPN tunnel on the machine can complete its DNS/route setup.
This is a subtler and more persistent DoS than the event signal. Killing a tunnel via the exit event is noisy and one-shot (the user can restart). Starving the semaphore silently prevents all tunnels from completing their setup, and the attacker holds it as long as their process runs.
Discovery: How the Framework Found It
I built a thick-client security assessment framework specifically for this class of work: Windows desktop applications that ship privileged services. The framework’s enumeration phase runs Sysinternals tools, audits service DACLs, scans for DLL hijack surfaces, and – critically – enumerates named kernel objects exposed by the target and checks their security descriptors.
The OpenVPN assessment ran through the standard phases. During the IPC enumeration (Phase 5 in the framework), the named-pipe and named-object scans flagged two objects created by openvpn.exe with NULL DACLs. That was the initial signal.
I confirmed it statically by decompiling openvpn.exe in Ghidra and tracing the CreateEvent and CreateSemaphore calls back to init_security_attributes_allow_all. The function name alone was a red flag, but the decompiled code confirmed: SetSecurityDescriptorDacl with a NULL DACL pointer, applied to globally-named objects that cross user boundaries.
The dynamic confirmation was straightforward. From a standard user account (pentest_low), I opened both objects by name and successfully signaled the event and acquired the semaphore. The VPN tunnel of the admin user terminated. That was the confirmed DoS.
What I did not find – and this is the honest part – was a privilege escalation. The exit event and semaphore only give you denial-of-service. You can kill tunnels and block netsh, but you cannot inject code, redirect traffic, or elevate privileges through these objects alone. I looked hard at whether signaling the exit event could create a race condition during shutdown that might lead to a writable-path or config-injection opportunity, and it did not. The impact is local DoS, and I reported it as such.
Severity: Honest Rating
This is the part where a lot of researchers inflate. I did not.
| Attribute | Value |
|---|---|
| Bug class | CWE-732: Incorrect Permission Assignment for Critical Resource |
| Attack vector | Local |
| Privileges required | Low (any authenticated standard user) |
| User interaction | None |
| Impact | Availability only (DoS against VPN tunnels) |
| Confidentiality / Integrity | None |
| CVSS 3.1 | AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:L – 3.3 (Low) |
| Scope | Unchanged – the attacker cannot cross a trust boundary beyond availability |
One honesty note, since this section is about not inflating: the vector I filed with OpenVPN set availability to High (A:H), which by the book computes to 5.5 (Medium) – CVSS weights a sustained denial of the affected component heavily. For this writeup I am rating availability Low (A:L, 3.3): in practical terms the machine keeps running and only the VPN component drops, which reads to me as reduced availability rather than a total loss. Reasonable people can argue A:H here, so I am flagging the difference openly rather than quietly presenting the smaller number.
It is a real bug. The NULL DACL is unambiguously wrong, and the impact on shared systems (terminal servers, lab VMs, multi-user workstations) is real. But it is not a privilege escalation, it is not remote, and it does not compromise confidentiality or integrity. Dressing it up as anything more than a local denial of service would be dishonest, and inflation burns the one asset a researcher has: credibility with the vendor.
The OpenVPN security team accepted the report, tracked it in their private issue tracker (openvpn-private-issues#167), assigned CVE-2026-82312, and fixed it in the next release. That outcome – a real bug fixed in production code – is the whole point, regardless of the CVSS number.
Coordinated Disclosure: The Timeline
This was my first coordinated disclosure with a major open-source project, so I want to document the process for anyone doing it for the first time.
| Date | Event |
|---|---|
| Late June 2026 | Assessment of OpenVPN 2.7.5 begins. NULL DACL flagged during IPC enumeration. |
| Early July 2026 | Static and dynamic confirmation complete. Report written with PoC. |
| July 2026 | Report sent to security@openvpn.net with full technical details, CWE classification, and benign PoC. |
| August 2026 | Gert Doering (OpenVPN project lead) responds. Confirms the issue is tracked at openvpn-private-issues#167, says “this is not how things should be, and should be fixable without losing functionality.” Asks for credit format. |
| August 22, 2026 | Reply sent confirming credit format. |
| August 25, 2026 | Heiko Hund (OpenVPN developer) sends the patch fix-null-dacls.diff for review. |
| August 31, 2026 | Gert confirms CVE-2026-82312 has been assigned. Patch sent with GPG signature. |
| September 3, 2026 | OpenVPN 2.7.7 released. Fix merged to master, release/2.7, and release/2.6. CVE credited in the release notes and Changes.rst. |
A few observations for first-time reporters. The response took a few weeks (Gert mentioned vacation and a backlog of reports – OpenVPN is a high-value target that gets a lot of submissions). That is normal. Do not follow up aggressively after a week; give them a reasonable window. The entire exchange was professional, efficient, and respectful. They asked how I wanted to be credited, sent the patch for review before merging, and assigned the CVE through their own CNA process. I never had to file with MITRE or chase the CVE assignment separately.
The credit in the official release notes reads:
windows: don’t use NULL DACL with system objects, namely the
--serviceexit event and thenetsh.exeguard semaphore. The old approach was prone to a local DoS where one user could interfere with other users’ openvpn processes by blocking the netsh semaphore or sending events. This only affects setups not using the iservice, or using the automatic service to start/stop openvpn (CVE-2026-82312)Bug found by DEBRAJ BASAK https://in.linkedin.com/in/debrajbasak, tracked in Github: OpenVPN/openvpn-private-issues#167
The Fix: Patch Analysis
The patch, authored by Heiko Hund, replaces init_security_attributes_allow_all with a new function init_security_attributes_allow_user at both call sites. Here is the complete new function:
// src/openvpn/win32.c (post-patch)
static bool
init_security_attributes_allow_user(struct security_attributes *obj)
{
bool ret = false;
CLEAR(*obj);
obj->sa.nLength = sizeof(SECURITY_ATTRIBUTES);
obj->sa.lpSecurityDescriptor = &obj->sd;
obj->sa.bInheritHandle = FALSE;
if (!InitializeSecurityDescriptor(&obj->sd, SECURITY_DESCRIPTOR_REVISION))
{
return ret;
}
// Step 1: Get the current process token
HANDLE token = NULL;
if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token))
{
return ret;
}
// Step 2: Extract the user SID from the token
PTOKEN_USER info = NULL;
DWORD info_len = 0;
if (!GetTokenInformation(token, TokenUser, info, info_len, &info_len)
&& GetLastError() != ERROR_INSUFFICIENT_BUFFER)
{
goto out;
}
info = malloc(info_len);
if (!info || !GetTokenInformation(token, TokenUser, info, info_len, &info_len))
{
goto out;
}
// Step 3: Build an EXPLICIT_ACCESS granting GENERIC_ALL to only that SID
EXPLICIT_ACCESS ea = { 0 };
ea.grfAccessPermissions = GENERIC_ALL;
ea.grfAccessMode = SET_ACCESS;
ea.grfInheritance = NO_INHERITANCE;
ea.Trustee.TrusteeForm = TRUSTEE_IS_SID;
ea.Trustee.TrusteeType = TRUSTEE_IS_USER;
ea.Trustee.ptstrName = (LPTSTR)info->User.Sid;
// Step 4: Create a proper DACL with that single ACE
if (SetEntriesInAcl(1, &ea, NULL, &obj->dacl) != ERROR_SUCCESS)
{
goto out;
}
// Step 5: Apply the DACL to the security descriptor
if (SetSecurityDescriptorDacl(&obj->sd, TRUE, obj->dacl, FALSE))
{
ret = true;
}
out:
free(info);
CloseHandle(token);
return ret;
}
Walk through what changed. Instead of passing NULL as the DACL (granting everyone full access), the fix:
- Opens the process token of the current
openvpn.exeinstance. - Queries
TokenUserto get the SID of the user account the process is running as. - Builds an
EXPLICIT_ACCESSentry grantingGENERIC_ALLto only that SID. - Calls
SetEntriesInAclto construct a proper DACL containing that single ACE. - Applies the real, populated DACL to the security descriptor.
The resulting objects – the exit event and the openvpn_netcmd semaphore – are now accessible only to the user who created them. Another user’s process cannot open, signal, or acquire them. The DoS is dead.
A companion free_security_attributes function was also added to properly release the allocated DACL via LocalFree, and the security_attributes struct got a new PACL dacl member to track the allocation:
// src/openvpn/win32.h (post-patch)
struct security_attributes
{
SECURITY_ATTRIBUTES sa;
SECURITY_DESCRIPTOR sd;
PACL dacl; // <-- new: tracks the allocated DACL for cleanup
};
One more change worth noting: the semaphore creation failure was upgraded from M_WARN (log a warning, continue) to M_ERR (fatal error, exit). In the pre-patch code, if the semaphore could not be created the process carried on without the guard, meaning concurrent netsh calls could corrupt each other silently. After the patch, a failure to create the semaphore is treated as a hard error. That is the right call – running without the serialization guard is worse than not running at all.
The patch also added a documentation comment to the semaphore declaration in win32.h:
/*
* Its DACL is restricted to the creating user to prevent an unprivileged
* local user from starving it and DoS'ing running instances. This means
* different user accounts running OpenVPN directly, not via the interactive
* service, will make all but the first user's instances exit.
*/
extern struct semaphore netcmd_semaphore;
That comment explicitly documents the design tradeoff: restricting the semaphore to the creating user means different users running OpenVPN directly (not through the Interactive Service) will conflict. The project chose security over multi-user convenience, and documented why. That is exactly the right engineering decision.

The Broader Class: Why NULL DACLs Keep Showing Up
CVE-2026-82312 is not a novel bug class. NULL DACLs on named objects are one of the oldest and most persistent patterns in Windows local security research, and they keep appearing because the API makes the insecure option easy and the secure option verbose.
Creating an object with a NULL DACL is four lines of code. Creating an object with a proper user-restricted DACL – as the fix demonstrates – is fifty lines spanning token queries, SID extraction, EXPLICIT_ACCESS construction, ACL building, and cleanup. Developers under deadline pressure, especially those more familiar with Unix (where file descriptors are not globally named and there is no equivalent of a NULL DACL), naturally reach for the shorter path. The function was even named init_security_attributes_allow_all, suggesting the original developer knew what it did and chose it deliberately as a “make it work” solution.
This pattern has generated CVEs across the Windows ecosystem for years:
| Product | Object type | NULL DACL on | Impact | Year |
|---|---|---|---|---|
| OpenVPN | Event + Semaphore | --service exit event, netsh guard semaphore | Local DoS | 2026 |
| Docker Desktop (and similar daemon-backed apps) | Named pipe | Privileged control channel | LPE to SYSTEM | Recurring |
| Various AV products | Named pipe / mutex | Inter-process communication channels | LPE / DoS | Recurring |
| Multiple VPN clients | Named pipe | Service control channels | LPE / config injection | Recurring |
| Windows services (third-party) | Service binary / ProgramData dirs | Writable by Users | LPE via DLL hijack | Recurring |
The lesson is not “do not use named objects.” Named events and semaphores are the correct IPC primitive for cross-process signaling and serialization on Windows. The lesson is always build a proper DACL, and if you find yourself reaching for a NULL DACL because the ACL API is too verbose, wrap it once (like init_security_attributes_allow_user now does) and call the wrapper everywhere.
If you are auditing a Windows application and want to find this class systematically, here is the pattern:
- Enumerate named objects created by the target using Process Hacker,
handle.exe(Sysinternals), or WinObj. Look for events, semaphores, mutexes, sections, and pipes with names that include the product name. - Check their security descriptors from a standard user context.
accesschk.exe -o <objectname>shows the DACL. If it showsEveryone: Full Controlor no DACL entries at all, that is the signal. - Trace the creation in a decompiler. Find the
Create*call, trace theSECURITY_ATTRIBUTESargument, and check whether the security descriptor has a NULL DACL. - Confirm from a low-priv context. Open the object by name from a standard user and attempt the operation (signal the event, acquire the semaphore, write to the pipe). If it succeeds, you have a finding.
- Assess the impact honestly. A NULL DACL on a named mutex used for single-instancing (preventing multiple copies of the app) is a DoS. A NULL DACL on a named pipe that a SYSTEM service reads commands from is an LPE. The bug class is the same; the severity depends entirely on what the object controls.
What This Teaches Beyond the Bug
Three things I took away from this engagement that have nothing to do with the specific CVE.
First, honest severity wins. I could have dressed this up. I could have spent pages constructing a theoretical scenario where killing a VPN tunnel at the right moment creates a race condition leading to something worse. I did not, because I looked for that path and it was not there. Reporting a Low-severity DoS as exactly what it is – and having the vendor accept it, fix it, assign a CVE, and credit me in the release notes – is worth more to a career than inflating a bug and having a vendor dismiss it. The OpenVPN team’s response was professional precisely because the report was honest.
Second, the coordinated disclosure process works. For anyone intimidated by the idea of emailing a security team at a major project: they are people who want their software to be secure. Send a clear report with a PoC, state the impact accurately, offer to coordinate on timeline, and be patient. The process from first email to merged fix took about two months, which is fast. Not every vendor is this responsive, but the process itself is not the barrier most researchers think it is.
Third, the framework works. The custom thick-client assessment framework I built – the enumeration scripts, the IPC auditors, the low-privilege validation round, the structured reporting – found this bug systematically, not by luck. The same framework found SYSTEM-level LPE in UltraVNC during a separate engagement. The tooling is reusable, the methodology is repeatable, and the output is structured enough to generate a professional report directly. If you are building your own assessment practice, invest in your framework. It compounds.
Key Takeaways
- CVE-2026-82312 is a NULL DACL on two named kernel objects (an event and a semaphore) in OpenVPN for Windows, allowing any local user to kill VPN tunnels or block
netshoperations. Local DoS, Low severity, no privilege escalation. - A NULL DACL (
SetSecurityDescriptorDacl(TRUE, NULL, ...)) is not “no security descriptor” – it is an explicit grant of full access to every principal on the machine. The API distinction between “NULL DACL” and “no DACL” has generated decades of bugs. - The fix replaces the NULL DACL with a properly constructed single-ACE DACL restricted to the creating user’s SID, using
OpenProcessToken→GetTokenInformation(TokenUser)→SetEntriesInAcl→SetSecurityDescriptorDacl. The pattern is reusable anywhere you need a user-restricted named object. - Honest severity assessment is not just ethics – it is strategy. Reporting a DoS as a DoS (not inflating it to LPE) earned a professional response, a clean fix, and a CVE credit. Inflation would have earned a dismissal.
- Named kernel objects (events, semaphores, mutexes, pipes, sections) are a high-yield audit target on Windows. Enumerate them with Process Hacker or Sysinternals, check their DACLs from a standard user, and trace the
Create*call in a decompiler. The bug class is simple and the tooling is free.
Timeline Summary
June 2026 Assessment begins, NULL DACL flagged
July 2026 Report sent to security@openvpn.net
August 2026 Vendor acknowledges, patch developed and sent for review
August 31 CVE-2026-82312 assigned
September 3 OpenVPN 2.7.7 released with the fix
Related Tutorials
- Phishing Campaign Design: Pretexting, Lures, and Target Profiling
- Fibers: User-Mode Cooperative Threads
- Bad Characters, Null Bytes, and Restricted Character Sets
- Passive OSINT: Mapping the Target Without Touching It
- System Calls and SSDT: How User Mode Reaches the Kernel
References
Official CVE records (these will populate as the advisory documentation is completed and the CVE goes fully public):
- NVD – CVE-2026-82312 – nvd.nist.gov
- CVE Record – CVE-2026-82312 – cve.org
- OpenVPN Security Announcement – CVE-2026-82312 – community.openvpn.net
Fix and technical references:
- OpenVPN 2.7.7 Release Notes – community.openvpn.net
- Changes.rst (v2.7.7) – github.com/OpenVPN/openvpn
- Commit 23396ac4 (master) – github.com/OpenVPN/openvpn
- Commit 73fb222a (release/2.7) – github.com/OpenVPN/openvpn
- Commit 78a1a7bd (release/2.6) – github.com/OpenVPN/openvpn
- SetSecurityDescriptorDacl function – Microsoft Learn
- CWE-732: Incorrect Permission Assignment for Critical Resource – MITRE
- OpenVPN Security – openvpn.net