Airoverflow logo
Back to Advisories
Security Advisory2026-09-13

CVE-2026-69379: TOCTOU in ReFsDedupSvc Enables Arbitrary File Overwrite as SYSTEM

CVE IDCVE-2026-69379
SeverityHigh(7.8)
Published2026-09-13
Affected
  • Windows 11
  • ReFS Deduplication Service (ReFsDedupSvc.exe)

Summary

ReFsDedupSvc.exe, the ReFS deduplication service, enables SE_BACKUP_PRIVILEGE and SE_RESTORE_PRIVILEGE around its persisted-state save routine. Two independent defects combine inside that window:

  1. The privilege intended to be thread-scoped lands on the service's primary process token instead, because the WIL helper's OpenThreadToken attempt always fails on this call path and falls back to OpenProcessToken.
  2. The exclusive handle taken on the destination file is released four operations before the destination is used, leaving a ~3 ms window in which the path can be redirected.

Chained, an unprivileged local user redirects the service's MoveFileExW through a directory junction and lands an attacker-controlled file on any same-volume target. Because SE_RESTORE_PRIVILEGE suppresses the DACL check on NtSetInformationFile(FileRenameInformation), Windows Resource Protection and TrustedInstaller ownership are both bypassed.

This was found by Corvus, our binary analysis platform, with no prior context on the target binary.

Severity

FieldValue
CVSS v3.1 Base7.8 (High)
VectorAV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H
CWECWE-367 (TOCTOU Race Condition), CWE-269 (Improper Privilege Management)
ImpactLocal privilege escalation to NT AUTHORITY\SYSTEM

The score above is AirOverflow's assessment. AC:L rather than AC:H is deliberate: the race is signal-driven via FindFirstChangeNotification rather than polled, and the PoC wins on the first iteration. Refer to the MSRC advisory for Microsoft's official rating.

Affected

  • Windows 11 with ReFsDedupSvc.exe present (ships by default)
  • Any host where the ReFS deduplication service is running, or can be started

The service is Manual start and does not run by default on Windows 11. That is a weak mitigation: Start-Service ReFsDedupSvc is one command, and any environment actually using ReFS deduplication already runs it continuously with no attacker interaction required.

Technical analysis

The mentality behind choosing ReFsDedupSvc.exe was that most of the CVEs found in the past few months related to Defender, NTFS, and the TCP/IP stack. There was very little previously reported in ReFS. ReFsDedupSvc.exe handles deduplication of files on ReFS volumes, ships by default, and had almost no public research behind it — a good test of how Corvus performs against a new binary with zero pre-context.

Corvus started at the privilege APIs rather than the file APIs. The search order is picked by rarity, not relevance: CreateFileW has hundreds of xrefs in this binary, MoveFileExW has a handful, AdjustTokenPrivileges has almost none. Starting at the file APIs means triaging hundreds of call sites with no filter. Starting at the privilege APIs means triaging three or four.

The binary imports:

code
api-ms-win-core-processthreads-l1-1-0!OpenProcessToken       0x1401a04d8
api-ms-win-core-processthreads-l1-1-0!OpenThreadToken        0x1401a04f8
api-ms-win-security-base-l1-1-0!AdjustTokenPrivileges        0x1401a0b08
api-ms-win-security-base-l1-1-0!GetTokenInformation          0x1401a0b18
api-ms-win-security-base-l1-1-0!CheckTokenMembership         0x1401a0b20
api-ms-win-security-base-l1-1-0!RevertToSelf                 0x1401a0af8
combase!CoImpersonateClientOfObject                          0x1401a0bd8
ntdll!RtlAdjustPrivilege                                     0x1401a1168

Two things worth noting. LookupPrivilegeValueW is not imported at all, so privileges are referenced by raw LUID and a string search for SeRestorePrivilege returns nothing. And RtlAdjustPrivilege is a red herring — its only xrefs are the UEFI variable helpers GetVariableImpl and SetVariableImpl.

AdjustTokenPrivileges xrefs in ReFsDedupSvc.exe

Four functions call AdjustTokenPrivileges, pairing into two RAII classes:

AddressSymbolRole
0x140021e10Windows::FileSystem::ReFs::ScopedAdjustTokenPrivilegesenable
0x14001c8e8wil::details::lambda_call<...>::~lambda_callrestore
0x14005d2b4DedupUtil::ScopedPrivileges::EnablePrivilegesenable
0x14005f208DedupUtil::ScopedPrivileges::RestorePrivilegesrestore

An enable paired with a restore means a defined window where the privilege is live, and windows are where races live.

Which privileges

The caller passes qword_1401FFAF0, populated at runtime. Its initializer:

c
int Windows::FileSystem::ReFs::_dynamic_initializer_for__BackupAndRestorePrivileges___3()
{
  v2 = 17;
  v1[0] = &v2;
  v3 = 18;
  v1[1] = &v4;
  Windows::FileSystem::ReFs::TokenPrivileges::TokenPrivileges(&qword_1401FFAF0, v1);
  ...
}

From winnt.h, SE_BACKUP_PRIVILEGE = 17 and SE_RESTORE_PRIVILEGE = 18.

SE_RESTORE_PRIVILEGE is not an ordinary privilege. Its entire documented purpose is to make the kernel skip the DACL check on write paths — that is what backup software needs to exist. It means any attacker-influenced write performed while it is held is an unchecked write.

BackupAndRestorePrivileges initializer, LUIDs 17 and 18

Defect 1: the token that was never scoped

ScopedAdjustTokenPrivileges obtains its handle from wil::open_current_access_token, which calls:

c
signed int __fastcall wil::open_current_access_token_nothrow(void **a1, DWORD a2, int a3)
{
  v5 = a3 == 1;
  CurrentThread = GetCurrentThread();
  if ( OpenThreadToken(CurrentThread, a2, v5, a1) )
    return 0;
  result = GetLastError();
  if ( result > 0 )
    result = (unsigned __int16)result | 0x80070000;
  if ( result == -2147023888 )                      // 0x800703F0 = ERROR_NO_TOKEN
  {
    CurrentProcess = GetCurrentProcess();
    if ( OpenProcessToken(CurrentProcess, a2, a1) )
      return 0;
    ...
  }
  return result;
}

The OpenThreadToken-first structure shows the intent: scope the privilege to one thread. But there is no COM impersonation active on this call path, so OpenThreadToken fails with ERROR_NO_TOKEN every time, the fallback is taken every time, and the privilege lands on the service's primary process token. Every thread in ReFsDedupSvc.exe holds SE_RESTORE_PRIVILEGE for the duration of the save.

Nobody wrote a bad line of code here. The helper is a general-purpose WIL utility whose fallback is sensible in the contexts it was designed for, and this call path is not one of them. Reviewing the wrapper in isolation shows nothing wrong — you only see it by asking which branch is reachable from here.

The second privilege class does not even attempt thread scoping; it calls OpenProcessToken directly with the same 0x28 mask.

On its own this is a least-privilege violation with no proven impact.

Defect 2: the lock released too early

MoveFileExW has four xrefs, all template instantiations of VolumePersistedState<T>::SaveTo. One source bug, four vulnerable save paths.

The four MoveFileExW xrefs

Trimmed to the calls that matter, with WIL line numbers in decimal:

c
  Windows::FileSystem::ReFs::ScopedAdjustTokenPrivileges(v39, qword_1401FFAF0);   // 0x1400214fc  privileges enabled

  *(_QWORD *)NumberOfBytesWritten = CreateFileW(v21, 0, 0, nullptr, 4u, 0x80u, nullptr);   // 0x140021537  line 191
  Throw_GetLastErrorIfMsg(..., "Failed to open or create the persisted state file", ...);
  unique_storage<handle_invalid_resource_policy<CloseHandle>>::~unique_storage(NumberOfBytesWritten);  // 0x140021575  <-- LOCK DROPPED

  FileW = CreateFileW(v22, 0x40000000u, 0, nullptr, 2u, 0x80u, nullptr);          // 0x1400215ae  line 197
  WriteFile(FileW, *(LPCVOID *)a1, v24, NumberOfBytesWritten, nullptr);           // 0x140021656  line 207
  FlushFileBuffers(FileW);                                                        // 0x140021697

  if ( !MoveFileExW(v27, v28, 1u) )                                               // 0x1400216e0  line 214  <-- RENAME
    _Throw_GetLastError(...);

Decoding the flags:

CallArgsMeaning
CreateFileW(dest, 0, 0, ..., 4, 0x80, ...)dwDesiredAccess = 0, dwShareMode = 0, OPEN_ALWAYSpure lock, no read, no write, no sharing
CreateFileW(temp, 0x40000000, 0, ..., 2, 0x80, ...)GENERIC_WRITE, CREATE_ALWAYSthe temp file
MoveFileExW(temp, dest, 1)MOVEFILE_REPLACE_EXISTINGthe rename

Zero desired access and zero share mode means that first handle is never read from or written to. Its only function is to hold the destination exclusively — the developer knew the destination needed protecting, which is why the call exists at all. Then it is dropped at 0x140021575, four operations before the destination is used.

The destructor fires immediately after the throw check, not at end of scope. That handle was never a named variable; it was a temporary whose lifetime ended at the end of its full expression:

cpp
// line 191, roughly
THROW_LAST_ERROR_IF_MSG(!wil::unique_hfile(CreateFileW(dest, 0, 0, nullptr, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr)),
                        "Failed to open or create the persisted state file");
// handle is already destroyed by the time we reach line 192

One wil::unique_hfile that needed a name and did not get one. The reservation taken at line 191 is gone by line 192; the destination string is handed to the kernel at line 214 to be re-resolved from scratch. Nothing carries forward between the check and the use.

Measured on the target, the window averaged 3063.8 µs with a maximum of 4370.2 µs across ten trials.

SaveTo: the destination lock handle destroyed at 0x140021575

SaveTo: MoveFileExW at line 214, where the window closes

Exploitation

The destination is built by GetSviPath:

code
140045a09    lea     r8, aWsWsWsWs               ; "%ws\\%ws\\%ws\\%ws"
1400459fd    mov     rax, cs:off_14019FA60       ; "System Volume Information"
1400459f1    mov     rax, cs:off_14019FA70       ; "ReFsDedupSvc"
140045a10    call    StringCchPrintfW

Resolving to <volume>\System Volume Information\ReFsDedupSvc\ReFsDedupSvc.schedule. The staging temp file is a GUID name in the same parent directory, which satisfies the same-volume rename constraint for free.

A file symlink on ReFsDedupSvc.schedule does not work — MoveFileExW with MOVEFILE_REPLACE_EXISTING replaces the reparse point object rather than following it. A directory junction on the parent does work, because the kernel follows directory reparse points on intermediate path components during resolution. Inside the window:

  1. delete ReFsDedupSvc.schedule
  2. remove the now-empty ReFsDedupSvc directory
  3. recreate it as a plain directory
  4. set FSCTL_SET_REPARSE_POINT with IO_REPARSE_TAG_MOUNT_POINT pointing at C:\Windows\System32

None of these four steps require any privilege. At line 214 the rename walks the junction and lands in System32.

The general lesson: when the final component is protected from redirection, move the redirection up one level. Path resolution walks every component, and every component is an opportunity.

Two constraints are worth stating because any reviewer will ask. Renames cannot cross volumes — NtSetInformationFile(FileRenameInformation) moves a directory entry, not data, and no amount of privilege changes that. And the window should not be polled: FindFirstChangeNotification on the containing directory fires on the CloseHandle at 0x140021575 itself, turning the race into a signal-driven operation rather than a timing gamble.

Impact

Verified against C:\Windows\System32\calc.exe:

code
C:\> icacls C:\Windows\System32\calc.exe
C:\windows\system32\calc.exe NT SERVICE\TrustedInstaller:(F)
                             BUILTIN\Administrators:(RX)
                             NT AUTHORITY\SYSTEM:(RX)
                             BUILTIN\Users:(RX)

A direct write from an elevated Administrator session returns ERROR_ACCESS_DENIED (5), as it should. After the redirect, the file goes from 45,056 bytes to 136,704 bytes and running calc.exe launches the payload.

Same-volume targets worth naming:

  • System32\*.dll and *.exe — WRP protected, TrustedInstaller owned, loaded by SYSTEM processes
  • System32\drivers\*.sys — kernel drivers loaded at boot
  • System32\config\SYSTEM — the registry hive

Overwriting anything SYSTEM loads converts a file write into persistent SYSTEM-integrity code execution that survives reboot.

The privilege belongs to the service, not to the attacker. No TrustedInstaller token and no SE_RESTORE_PRIVILEGE of your own is required — the service brings it. What is required is the ability to delete and recreate the parent directory on the ReFS volume inside the window, plus junction creation and change notifications, all ordinary standard-user operations. Check the ACL on <volume>\System Volume Information\ReFsDedupSvc on your own test volume before quoting a severity number, because that single fact separates "low priv to SYSTEM" from "admin to TrustedInstaller".

Proof of concept

code
poc_toctou.exe --drive E: --file C:\Windows\System32\calc.exe --from msgbox.exe
code
[check] direct write to C:\Windows\System32\calc.exe blocked (error 5)

[target] C:\Windows\System32\calc.exe
[target] orig size: 45056 bytes
[payload] msgbox.exe  (136704 bytes)

[service] lock acquired
[service] lock released  <- race window open
[attacker] junction: ...\ReFsDedupSvc -> C:\Windows\System32
[service] MoveFileExW rc=1  err=0
[timing] race window: 1974.0 us  (real service avg: 3063 us)

[verify] size before: 45056 bytes
[verify] size after : 136704 bytes

PASS: TrustedInstaller-owned file overwritten via TOCTOU + SE_RESTORE_PRIVILEGE

The winning window was 1974 µs against a measured service average of 3063 µs — a comfortable margin, won on the first iteration.

PoC run: TrustedInstaller-owned calc.exe overwritten

calc.exe launching the payload after the overwrite

The chain

DefectAloneWhy it is dismissed alone
Process-wide SE_RESTOREwrites only where the service already writesno attacker-controlled destination
Lock released at line 191redirects the renameDACL check still blocks System32

Neither survives triage on its own. Together, one supplies the destination while the other removes the check.

The bug exists in neither function. It exists in the relationship between a general-purpose privilege helper whose fallback branch is wrong for this caller, and a save routine whose lock was correct but released four operations too early. Auditing either file in isolation finds nothing. The finding is an edge, not a node.

Mitigation

Apply the September 2026 Windows security update. Microsoft shipped the fix on 2026-09-08.

Where patching is delayed:

  • Stop and disable the service if ReFS deduplication is not in use: Stop-Service ReFsDedupSvc followed by Set-Service ReFsDedupSvc -StartupType Disabled. The service is Manual start by default, so on most hosts this closes the exposure at no functional cost.
  • Restrict the staging directory. Review the ACL on <volume>\System Volume Information\ReFsDedupSvc on every ReFS volume. Standard users should not be able to delete or recreate that directory. This is the single access-control fact the exploit depends on.
  • Audit ReFS volume provisioning. Volumes where standard users hold write access to System Volume Information are exposed regardless of this specific CVE.

Detection

  • Audit object-access events for deletion and recreation of ReFsDedupSvc directories on ReFS volumes.
  • Alert on reparse points (IO_REPARSE_TAG_MOUNT_POINT) created under System Volume Information — there is no legitimate reason for one to exist there.
  • Monitor file integrity for WRP-protected binaries in System32; a size or hash change outside a servicing window is the terminal signal.

Timeline

DateEvent
2026-05-27Reported to MSRC
2026-07-18Case accepted
2026-08-15Bounty awarded
2026-09-08Fix shipped, CVE-2026-69379 published
2026-09-13Public writeup

References