33 min read

CVE-2026-73014 (PLUSONE): Reading Another User’s Files Through the Windows Data Sharing Service

A standard user gets the contents of a file its own permissions deny, because another user who also could not read it added them to a sharing token.

Contents

A note on disclosure. This finding was reported to Microsoft and is tracked as CVE-2026-73014 (Microsoft advisory).

Introduction

Windows has a service whose job is to hand a file from one program to another. It does that with a sharing token. You register a file, you name the accounts allowed to collect it, and you get back a GUID. Anyone named on that token can hand the GUID back to the service and receive the file. It is opened by the service rather than by them, without the file’s own permissions ever being checked against them.

The service also lets you change the list of names on a token after it has been created. It does not check whether the caller doing that is allowed to read the file. So a standard user who is merely on that list, and who has an explicit DENY entry on the file, can add a second standard user to the list. The second user hands the GUID to the service and receives the file. Neither of the two can open the file directly, not before and not after.

I call it PLUSONE: the whole attack is adding one name to a list you have no authority to change.

The shape is not new. It is called permission re-delegation: a middleman (the deputy) hands on an authority without checking it was entitled to hand it on. It was described in a 2011 paper, and three of that paper’s authors filed a patent, assigned to Microsoft, describing the check that prevents it. Part 5 covers that. What is new is the code path: I could find nothing published about this one.

Here is the exact bound on the result, before anything else. What I proved is that a standard user read a file that the account which created the token was already allowed to read. That is a limit of the interface rather than of my testing: the create path opens the file while impersonating its caller, so no token exists over a file its creator could not open. The distance between this result and arbitrary file read as SYSTEM is therefore not a gap I left untested. It is closed, and Part 4 gives the instructions that close it.

The attack also needs a second account to hand the file to, and it needs an existing token that already names the attacker and was created in the one mode that allows its recipient list to be changed. No in-box component of Windows creates a token in that mode, so it has to come from software calling the client DLL directly; Part 4 sets out how I checked that. So the exposure is a machine with more than one interactive user (a shared workstation, or a terminal server) on which such a token exists. On a single-user desktop there is nobody to delegate to.

There is also no write. The copy path never writes through the privileged handle. It only asks that handle for the file’s full path, then calls CopyFileExW on the path, so the write access the service holds is never used for anything. Part 4 lists every limit one at a time.


Part 1: The sharing token, and the method that is not documented

What a sharing token is

DsSvc, the Data Sharing Service, exists to solve a real problem. One application holds a file; another application needs it and has no permission to open it. Copying the file is wasteful and leaves a second copy on disk. Loosening the file’s own permissions is worse. So DsSvc acts as a broker instead.

The broker’s object is a sharing token. When an application registers a file with DsSvc, the service stores a record and hands back a GUID in string form. That record holds three things that matter here:

  • the file it points at,
  • the owner: the SID of the account that created the token,
  • the target list: the accounts allowed to collect the file, usually written as plain SID strings.

The GUID names the record. Anyone holding it calls back into the service and asks to redeem it. The service looks up the record, checks that the caller appears on the target list, and opens the file for them. It does not re-check the file’s own permissions against that caller. That is the point of the design: the caller is not supposed to have access, and the token is the access.

Changing the list on an existing token is what the service calls delegation. Delegation is safe as long as one rule holds: nobody may put a name on that list unless they can already reach the file themselves. Everything below is about the method where that rule is not enforced.

The documented surface has no delegation

The documented way to use all of this is a class in the modern Windows app API, SharedStorageAccessManager. The module that implements it, windows.storage.dll, carries the class’s runtime name and binds five of the client DLL’s exports, so it does reach this service. Microsoft’s reference page describes the class like this:

Enables an app to share a file with another app by passing a token via Uri activation, app service, REST API, etc. The target app redeems the token to get the file shared by the source app.

The class has exactly three methods. AddFile(IStorageFile) mints a token for a file. RedeemTokenForFileAsync(String) turns a token back into a file. RemoveFile(String) revokes one.

There is no documented method to delegate a token, to re-share it, or to change who may redeem one that already exists. The service underneath has one.

The same page states three restrictions on a token: it “can only be redeemed one time. After that, the token is no longer valid”, it “expires after 14 days”, and one app “cannot get more than one thousand sharing tokens”. Those describe the documented class. The interface underneath did not hold the first of them in my run. An RPC interface does not expose its methods by name. Each one is a numbered slot, and the number a caller sends to choose one is its opnum. In Part 3 a single token is redeemed twice, once through opnum 5, which returns a handle, and again through opnum 6, which returns a copy, and both calls succeed. Nothing in this post depends on redeeming twice. I record it because the one-redemption rule reads like a limit on the attack, and on this interface it was not one.

Who can call it

Three standard, non-administrator accounts on my test machine called this interface and got results back. The registration details below explain why it works.

The service is dssvc.dll, in C:\Windows\System32. Windows reports its FileVersion as 10.0.26100.1. It runs as NT AUTHORITY\SYSTEM inside a shared svchost.exe, with start type DEMAND_START, so it starts when a call arrives rather than at boot.

My test machine was Windows 11, OS build 26200.8655 (25H2). The RPC interface is bf4dc912-e52f-4904-8ebe-9317c1bdd497, version 1.0. That is the same UUID and version the researcher k0shl published in a 2020 write-up of this service, so it has been stable for six years.

Three registration facts matter.

It is local only. The only transport the binary registers is ncalrpc, the local one. There is no named-pipe binding and no TCP binding. Nothing in this post is remotely reachable.

There is no security callback. A service can hand the RPC runtime a function pointer, and the runtime calls it before a call is dispatched so the service can inspect the caller and refuse. This registration passes NULL, so no check of that kind exists at all.

The registration flags are 0x29: RPC_IF_AUTOLISTEN (0x1), which decides when the interface starts listening, not who may call it, RPC_IF_ALLOW_SECURE_ONLY (0x8) and RPC_IF_ALLOW_LOCAL_ONLY (0x20). RPC_IF_ALLOW_SECURE_ONLY rejects unauthenticated callers, which does not keep out a standard user: an ordinary logged-in account is authenticated. RPC_IF_ALLOW_LOCAL_ONLY is the runtime enforcing the local-only point above.

The binary also carries two hardcoded security descriptors, one for the transport and one for the interface. Both are fallbacks, used only when no descriptor is configured elsewhere, so I cannot tell from the binary alone which is actually in force. It does not change the result: three standard accounts reached the interface in practice. Both fallbacks grant access to interactive users, to authenticated users, and to processes running inside an AppContainer, which is Windows’ name for a sandboxed app package.

The eight functions

The interface exposes eight opnums. The same eight, in the same order, are in the interface listing k0shl published in 2020.

OpnumNameWhat it does
0RpcDSSCreateSharedFileTokenmint a token for a file
1RpcDSSGetSharedFileNameread a token’s friendly name
2RpcDSSGetSharingTokenInformationread a token’s metadata
3RpcDSSDelegateSharingTokenchange who may redeem an existing token
4RpcDSSRemoveSharingTokenrevoke a token
5RpcDSSOpenSharedFileredeem a token for a file handle
6RpcDSSCopyFromSharedFileredeem a token by copying the file
7RpcDSSRemoveExpiredTokensclear out expired tokens

The three SharedStorageAccessManager methods correspond to opnums 0, 5 and 4. That mapping is not a guess. dsclient.dll reaches the service through a single entry point, NdrClientCall3, and each of its exports passes a fixed procedure number to it. It is loaded, in every case, as xor r8d, r8d followed by lea edx, [r8 + N], so the constant is plain. Walking from each export gives the whole table:

client exportopnum
DSCreateSharedFileToken0
DSGetSharedFileName1
DSGetSharingTokenInformation2
DSDelegateSharingToken3
DSRemoveSharingToken4
DSOpenSharedFile5
DSCopyFromSharedFile6
DSRemoveExpiredTokens7

AddFile reaches DSCreateSharedFileToken, RedeemTokenForFileAsync reaches DSOpenSharedFile, and RemoveFile reaches DSRemoveSharingToken.

Each opnum arrives at a generated wrapper that checks the arguments’ shape, and the wrapper calls the handler that does the actual work. I read all eight wrappers and decompiled the handlers behind them, apart from the one that only clears expired tokens. Three of the eight act on a file: create, open, copy. Of the bugs published against these eight methods, all are in those three. Most turn on which file a path resolves to at the moment the service uses it; the one exception, CVE-2019-0573, turns on a missing check that the token had been created for write access. None of them touches who may be on a token’s list.

So I asked a different question: which of these eight changes the set of accounts allowed to redeem a token?

Exactly one does. Opnum 3.


Part 2: The check that is not there

Three standard accounts, one file, one token. lowpriv1 owns the file and mints the token. lowpriv2 is named on it. lowpriv3 is not, and neither lowpriv2 nor lowpriv3 may read the file. Figure 1 is the whole attack, and the rest of this part is the code behind each arrow.

One list, three users, and no check lowpriv1: the owner creates the sharing token file F lowpriv2 and lowpriv3 DENIED lowpriv2: a recipient on the list, denied on F the check that never runs can lowpriv2 read F? never asked opnum3: delegate adds the lowpriv3 SID DsSvc: opnum5 and opnum6 opens F and returns the content lowpriv3 denied on F, now on the list the file content Neither lowpriv2 nor lowpriv3 can read F

Figure 1: Neither of the two users involved in the widening may read the file. lowpriv2 is on the target list but is denied on F. It puts lowpriv3 on the list, and lowpriv3 is also denied on F. The service then opens F itself and hands the content to lowpriv3.

The rule the code depends on

Written as one sentence: nobody can put a name on a token’s target list unless they can reach the file themselves.

That is my reconstruction of what the developer believed. The code never states it, and on the path a standard user controls it never enforces it either.

Everything below comes from reading the disassembly of a copy of dssvc.dll taken from a separate analysis machine, laid out in memory from address 0x180000000. The addresses are specific to that copy and will differ on another build. I read these paths; I did not step through them in a debugger.

The five steps of a delegate call

Opnum 3, RpcDSSDelegateSharingToken at 0x180007520, calls DSSDelegateSharingToken at 0x18000d3b8. The wrapper rejects the call outright unless the token’s GUID string is present and the caller supplies between 1 and 20 recipients. Then the handler runs five steps in a straight line, each one reached only if the last succeeded, with no condition deciding whether any of them runs.

  1. GetAuthorizedSharingToken: find the token by GUID, and decide whether this caller may touch it.
  2. ValidateUsage: check the token is in the right state for a delegate.
  3. ResolveTargets: turn the caller’s recipient entries into target objects.
  4. ShareAccessControl::InitializeScope: install that list on the token.
  5. SharingTokenManager::UpdateSharingToken: write the token back to the service’s store.

Fact 1. The owner check is optional, and opnum 3 declines it.

Authorization runs through GetAuthorizedSharingToken at 0x18000df44. It gets the caller’s identity the correct way. It impersonates the client: for the length of the call the service’s thread carries the caller’s Windows access token, so any access check Windows makes is made against the caller, not against SYSTEM. It reads the user out of that access token and converts it to a SID string. Nothing about who is calling comes off the wire. This is not a spoofed-identity bug: the service knows exactly who the caller is, and authorizes the call anyway.

The second argument decides what it does with that SID. Simplified from the decompiler output, with a name of my own for that argument:

if (requireOwner != 0 &&
    StringEqual(callerSid, tok->ownerSid))
    return S_OK;
if (TargetListHasSid(tok->targetList, callerSid))
    return S_OK;
return 0xC113000A;

Read requireOwner carefully, because it does not do what its name suggests. It does not make the check stricter. It makes it looser. When that argument is 1, the token’s owner is let through and the list is never consulted. When it is 0, only the list is consulted. A caller who fails the owner comparison is not rejected either way. Control falls through to the same list walk. There is no value of that argument that makes ownership a requirement.

The six callers split cleanly. The three operations that read or manage a token (get name, get information, revoke) pass 1. The other three (delegate, open and copy) pass 0. Delegation is grouped with the redeem operations rather than with the management ones.

The listing above leaves out several real steps, and two of them matter here.

When the caller is found on the target list, the helper impersonates the caller and makes one more call before returning success. That call is worth naming, because it is the only thing on this path that looks at the file at all. It is a virtual call on the token object, and it lands on a check that asks Enterprise Data Protection (the enterprise policy that tags managed content) whether this file’s contents may flow to this caller. It begins by asking EdpGetIsManaged whether the device is managed at all, and returns immediately if it is not. If it is, it asks whether the file is encrypted, and returns immediately if it is not. Only past both of those does it reach EdpIsFileAccessAllowed, and only there does it write an answer. On an ordinary file on an unmanaged machine it does nothing, and the caller’s slot keeps the value it was pre-set to: allowed.

When the SID walk fails there is a second walk that matches capability entries: named permissions granted to packaged apps rather than to user accounts. That one calls CapabilityCheck against the caller’s token and touches no file.

So one of the two walks does name the file, but it asks a data-classification question. Neither walk asks the question that matters here: whether this caller has any right to read the file.

Fact 2. The create path proves the caller can reach the file. The delegate path does not.

The service already owns the right function. PolicyChecker::CheckFilePermission tests whether a given caller can actually reach a given file, and the create path calls it against the calling client before it will issue a token at all. Here are the two paths, cut down to the calls that matter:

DSSCreateSharedFileTokenEx(...)
    CanonicalAndValidateFilePath(path);
    PolicyChecker::CheckFilePermission(path, ..., CLIENT);
    ResolveTargets(scope, &list);

DSSDelegateSharingToken(...)
    GetAuthorizedSharingToken(name, 0, &tok);
    ValidateUsage(tok, 1);
    ResolveTargets(scope, &list);
    ShareAccessControl::InitializeScope(tok, &list);

I read DSSDelegateSharingToken end to end. Apart from container and reference-counting helpers and one logger, the five steps listed above are the whole body, and CheckFilePermission is not among them. The delegator’s own entitlement to the file is never checked, anywhere on that path.

Two ways into the same recipient list opnum0: create a token canonicalize the path UNC paths are rejected CheckFilePermission against the calling client resolve the recipients then persist the token opnum3: widen a token requireOwner = 0 the owner compare is skipped no permission check here nothing runs: that is the bug resolve the recipients any well-formed SID accepted the same persisted recipient list

Figure 2: Both paths write the same list. Only one of them proves the caller may change it. The dashed box sits at the same height as CheckFilePermission on purpose: the gap in the delegate path is an absence, and the alignment is what makes it visible.

Fact 3. A new recipient is checked for shape, not for authority.

ResolveTargets at 0x18000e8cc walks the caller’s entries and dispatches on a type field. Type 1 resolves an application identity through PolicyChecker::GetClientSidFromId. Type 2 is a capability. Type 0 is a plain SID string. Simplified, and with a name of my own for the add, this is the whole of what happens to it:

if (entry->type == 0) {
    if (!PolicyChecker::IsValidSid(entry->sid))
        return 0xC1130009;
    PolicyChecker::GetProductId(entry->sid, &productId);
    AddTarget(list, entry->sid, productId);
}

IsValidSid asks whether the string is a well-formed SID. GetProductId zeroes the GUID it is handed, calls IsValidSid a second time and discards the answer, and returns S_OK on every path. The caller does test its result, but that test can never fire. Neither call asks whether the caller may name that account, whether the account has any relationship to the file, or whether the token’s owner would have agreed. Anything else in the type field is rejected outright.

These are three different faults. Fact 2 is the gate that would have stopped the attack: had the delegate path checked the delegator against the file, lowpriv2 would have failed, because it is denied on F. Fact 1 would not have stopped anything even if opnum 3 had passed 1, because lowpriv2 is on the list and the list walk runs regardless. The problem is not that opnum 3 chose the lenient mode. It is that the strict mode does not exist. Fact 3 is what makes the new recipient arbitrary. Without it an attacker could still rewrite the list, but only with accounts the service works out for itself: an application identity, or a capability.

The call replaces the list, and makes the token redeemable

Step 4 is not an append. ShareAccessControl::InitializeScope at 0x180010194 clears the token’s existing target list before it copies in the caller’s entries. After a delegate call the token’s targets are exactly the set the caller supplied, up to the twenty the wrapper allows, and an earlier recipient that was not re-listed is gone. The operation is a rewrite, and the attacker writes the new contents.

Then one instruction I nearly missed. Between installing the list and persisting the token, the handler writes zero into a field at token offset +0x50. That field is the token’s usage type, set when the token was created. ValidateUsage at 0x18000f60c is a single equality test against it: the delegate handler demands 1, and the open and copy handlers both demand 0. So a token minted as delegatable passes the opnum 3 gate, and opnum 3 then rewrites that field to the value opnum 5 demands. Two things follow. The same token cannot be delegated twice, because the field no longer holds 1; delegation is one hop and does not chain. And the two states are mutually exclusive. While a token is delegatable it cannot be read at all, because opnum 5 and opnum 6 both demand 0, so the single act of delegating is also what makes the token redeemable.

SharingTokenManager::UpdateSharingToken at 0x180012b0c writes the token back through SharingTokenDatabase::UpdateToken into the service’s on-disk token database. Both changes outlive the call.

Redemption

Opnum 5, DSSOpenSharedFile at 0x18000d894, returns a file handle. Opnum 6, DSSCopyFromSharedFile at 0x18000cec0, copies the file to a path the caller names. Both authorize through the same GetAuthorizedSharingToken, which means membership of the target list, and both reach DSUtils::OpenFile at 0x18001b8e8.

The open runs in the service’s own context. The code shows this directly, because the impersonation call sits on the same path and is jumped over.

DSSOpenSharedFile asks DSUtils::IsFileEncrypted about the file. If the answer is yes, it calls AutoImpersonate<2>::ImpersonateCallingUserWithEnterpriseContext, and the open runs as the caller. If the answer is no (an ordinary file, which is the case here), that branch is jumped over and control lands on the open with nothing impersonated. DSUtils::OpenFile is one CreateFileW and nothing else, called with no impersonation, so that open is checked against the service’s own token, not the caller’s. The copy handler has the same encryption test, and its source-file open sits before that test, so it is never impersonated at all.

I did not instrument this. It is a reading of the code, and Part 4 gives the argument, the counter-argument, and a published statement from 2019 that says the same thing about the same function.


Part 3: Proving it with three standard users

The setup

Three local standard accounts: lowpriv1, lowpriv2 and lowpriv3, with SIDs ending -1003, -1004 and -1005. Each is in Users and nothing else, and each runs at medium integrity. I captured whoami /groups and whoami /priv for all three. A findstr for the administrators SID S-1-5-32-544 returned exit code 1 on every one of them, and none of the three holds SeImpersonatePrivilege, SeDebugPrivilege or SeAssignPrimaryTokenPrivilege. Those are the privileges the well-known local escalation techniques need.

One file, F, created and owned by lowpriv1, with a canary string in it so that leaked content is unambiguous:

TOPSECRET-DSSVC-FD3-CANARY-7Q2X9

lowpriv1 puts four explicit entries on F: allow lowpriv1, allow SYSTEM, explicit DENY lowpriv2, explicit DENY lowpriv3. Inheritance is left switched on, so F also carries what it inherits from the test directory, which grants Modify to all three accounts. The two deny entries are what decide it, and they are the boundary the rest of this part is about. Both accounts’ direct reads of F fail at every stage of the run.

The four steps

StepWhoWhat happened
Createlowpriv1Writes F, sets the deny entries, calls opnum 0 with the create-time parameter that makes the token delegatable. Succeeds with hr=0x00000000 and returns a token GUID.
Controllowpriv3Direct read of F is denied. Its opnum 5 returns hr=0xC113000A with no handle. Its opnum 6 returns hr=0x00000000 but copies nothing (see below).
Delegatelowpriv2Its own direct read of F is denied. Its opnum 3, with a recipient list carrying the lowpriv3 SID, returns hr=0x00000000. Accepted.
Redeemlowpriv3Direct read of F is still denied. Its opnum 5 now returns hr=0x00000000 with a valid handle, and the canary is read through that handle. Its opnum 6 copies the canary into a destination that lowpriv3 controls.

0xC113000A is the error GetAuthorizedSharingToken returns when the caller is on neither the owner slot nor the list.

One log line that looks wrong

In the control step, before any delegation, lowpriv3’s opnum 6 returns hr=0x00000000. That looks like a success for an unauthorized caller. The next line in the log says what actually happened:

opnum6 COPIED-CONTENT: ""

Nothing was copied. DSSCopyFromSharedFile creates the destination file first, while impersonating the caller, and only reaches the token authorization gate on the source afterwards. That order is what leaves an empty destination behind. I did not root-cause why the call still returns 0x00000000 rather than an error, and I am not going to guess.

A separate control run settles it. Running as lowpriv3 against a fresh token where lowpriv3 is on no list at all, opnum 5 returned 0xC113000A with no handle, and opnum 6 returned hr=0x00000000 with a destination file that exists, has size 0, and does not contain the canary. The canary appears only after the delegate call.

The toggle

One call happens, and one result changes with it. The same lowpriv3 call to opnum 5 returns 0xC113000A and no handle before the delegate, and returns success plus the file content after it. Nothing happened in between except lowpriv2’s single opnum 3 call, which put the lowpriv3 SID on the list and, as Part 2 describes, also zeroed the usage field at +0x50 that opnum 5 tests. lowpriv3’s rights on F never changed, and its direct read was refused in both phases.

The full cycle was run twice, as two complete independent runs. The logs are identical except for the per-run token GUID.

The first run failed

The first end-to-end attempt did not work. The delegate step returned 0xC113000B.

The tempting reading is that the path is gated and the idea is dead. That reading would have been wrong, so I went looking for what my own call had got wrong instead. The error came from ValidateUsage, and the value it compares is the usage type at token offset +0x50 described in Part 2. My token had been minted with the wrong one. A sweep of the create-time parameters confirmed it: with the usage type set to the delegatable value, opnum 3 returns 0x00000000, and opnum 5 then succeeds on the same token. That is exactly what the zero-write at the end of the delegate handler produces.

The harness

The console window that drives the demo is elevated, and its title bar says so. That console registers four one-shot scheduled tasks across the three standard accounts, one per phase, runs them in order, and prints the collected log at the end. It exists so that each phase runs under the right user without an interactive logon. Every RPC call in the log is issued by lowpriv1, lowpriv2 or lowpriv3, and each role prints its own account name and SID as it starts. The elevated console only schedules those three; no step of the attack needs it.

One field in the recording is wrong and I would rather say so. The tool prints IL=? for every role, because it failed to resolve the integrity level. The claim that these are medium-integrity standard users rests on the whoami capture above, not on that field.

The recording

Live capture. The orchestrator prints each role's own account name and SID as that role starts: lowpriv1 mints the token (GUID 0C6851F6-1986-4E58-B0CE-D77C8E8F70E2), lowpriv3's pre-delegation open is refused with 0xC113000A and no handle, lowpriv2 adds the lowpriv3 SID and gets 0x00000000, and the result log at the end shows lowpriv3 reading the canary through a handle and copying it to a destination it controls. Every direct file read by lowpriv2 and lowpriv3 is denied throughout. The elevated console registers the four scheduled tasks, runs them in order, and prints the collected log.

Part 4: What I did not prove

1. The ceiling. lowpriv1, who created the token, could read F, and no token could have existed otherwise. Opnum 0’s handler passes PolicyChecker::CheckFilePermission an impersonate flag of one (the stub zeroes r12 and loads the constant with lea ecx, [r12+1], and the handler forwards it unchanged), and that function calls RpcImpersonateClient before it opens the file. The call sits in a straight line with no branch around it, and a failure returns out of the create.

The access it asks for is a real right rather than an attributes-only probe. ShareAccessControl::MapSharePermission turns permission 0 into GENERIC_READ, 1 into GENERIC_WRITE and 2 into both, and rejects every other value with E_INVALIDARG. So a token cannot be minted over a file its creator cannot open with the right it asks for, and there is no route from here to arbitrary file read as SYSTEM. I am not claiming one.

What that check does not cover is the interval it leaves behind: it runs once, against the path as it stands at create time, and the privileged open happens later against the same stored path. I did not test that interval, and a 2019 report on this same service (quoted in Part 5) found a time-of-check-to-time-of-use problem in this very function, so it is not idle to mention.

2. The impersonation. I never read the service’s effective thread token at the moment of the open, so I never directly observed that the open runs unimpersonated. The service is demand-start, so it is not running until a call arrives, and I arranged no debugger attach for the window in which it runs.

The support is the code. The only impersonation on the open path is inside the IsFileEncrypted branch, F is not encrypted, and DSUtils::OpenFile contains no impersonation of its own. The test file’s access list also grants SYSTEM, so a non-impersonating open needs no special right to succeed.

There is a counter-argument, so here it is. Suppose the open was impersonated after all and still succeeded, because it asked for backup semantics. An open that does so steps past a deny entry whenever the context performing it holds SeBackupPrivilege. The result codes I observed cannot separate that from a non-impersonating open, so the codes do not decide it. Two other things do. The flags: DSSOpenSharedFile passes a FlagsAndAttributes of 0x80, optionally combined with 0x40000000, and DSSCopyFromSharedFile passes 0; neither includes FILE_FLAG_BACKUP_SEMANTICS (0x02000000), and SeBackupPrivilege applies only to an open that asks for it. And the privileges: a thread that impersonates acts in the client’s security context, and the whoami /priv capture in Part 3 shows lowpriv3 holding five privileges, none of them SeBackupPrivilege.

A primary source says the same thing about the same function. The published root cause of CVE-2019-0572 is about DSOpenSharedFile, the dsclient.dll client entry point for the opnum 5 handler that reaches DSUtils::OpenFile, and it addresses the impersonation on that call directly:

Impersonating over the call to CreateFile would fix this issue, but that might make it impossible for the service to do its job of sharing the files if the use calling DSOpenSharedFile can’t already open the file which was shared.

Quoted verbatim. Two things follow from that sentence. It confirms, in 2019, that this path opens the file without impersonating the caller. And the clause after the comma describes exactly the case I proved: a redeemer who cannot already open the file being shared.

Another report from the same batch puts it as a statement rather than a suggestion. CVE-2019-0573 is against the same method, and it describes the behaviour its own bug relies on as “namely not impersonating the user when calling CreateFile”.

3. There is no write. I read the copy path rather than leaving it untested. DSUtils::CopyFileWithProgress at 0x18001af70 takes the SYSTEM-opened source handle and uses it for one purpose: recovering the file’s final path name. It rejects a hardlinked destination, then calls CopyFileExW on that name with no copy flags. The write access on the privileged handle is never passed to anything. There is no write primitive here, and I will not imply one. I did not investigate the destination side, which is a different question.

4. Sandbox reachability. Both fallback descriptors grant access to AppContainer principals. I ran no AppContainer test, so nothing here supports a claim about sandbox escape.

5. Whether any real token is delegatable. Opnum 3 accepts only a token minted with one particular create-time usage value; with the other value it returns 0xC113000B, which is what killed my first run. I minted mine over the RPC interface with that value set deliberately, and as far as the shipped code goes, nothing else does.

Nine modules in System32 bind an export from dsclient.dll. Between them they make ten calls to DSCreateSharedFileToken, and all ten pass the usage argument as a hard-coded zero, compiled as xor r9d, r9d. That includes both call sites in windows.storage.dll, which is where SharedStorageAccessManager reaches this service. So no token an ordinary application creates is delegatable. Not one of the nine binds DSDelegateSharingToken at all: the delegate path ships without a caller.

The client DLL is a different matter. It exports both entry points, and its create validates the usage argument with cmp r15d, 1 followed by jbe, accepting zero and one alike, then forwards the value to the wire unchanged. A delegatable token needs no hand-rolled RPC; a LoadLibrary and a GetProcAddress against a DLL already in System32 are enough.

What I did not do is watch an application create one at runtime. This is read from import tables and call sites on build 26200, cross-checked against the 26100 copies, and it is a statement about the code as shipped rather than about every program that might call it.


Part 5: What was new, and what was not

I searched for the delegate method by name, for the authorization helper, for the recipient-resolution routine, and for the owner argument, and I found nothing each time. I could not find a single public artifact that analyses this code path.

Here is what the existing public work on this DLL does cover.

The January 2019 reports are about the file-operation paths. The report behind CVE-2019-0572 is James Forshaw’s; Exploit-DB carries it as EDB-46157, credited to Google Security Research, and k0shl attributes the January 2019 cluster to him independently. It works like this. A token is created over one file and then redeemed. After the open, the service compares the handle’s path against the registered path with DSUtils::VerifyPathFromHandle, which calls GetFinalPathNameByHandle. Swapping the registered file for a hard link defeats that comparison, and the caller receives a full-access handle that the service opened.

A second report in the same batch, EDB-46104, is a time-of-check-to-time-of-use race in the create-time permission check. Its own words: “there’s a TOCTOU in that the path passed to CreateFile doesn’t need to refer to the same file” that is later passed to DeleteFile, and “CreateFile is called under impersonation and DeleteFile is not”. Neither touches the delegate method. Both entries carry the same note, in which the reporter states his own scope:

I’ve also not reported every possible problem with the service as at some point I had to stop.

That is the frame for this whole section. The January 2019 reports were a bounded piece of work on the file-operation paths, offered as such and not as an audit of the interface, which is why their silence on opnum 3 is not a clearance of opnum 3. Everything below is a record of what has been published about this DLL, not a scorecard.

k0shl’s 2020 write-up published the complete interface. He describes its scope himself: it is not “an in-depth analysis of the specific functions of this service”. What it does examine in depth is RpcDSSMoveFromSharedFile and RpcDSSCopyFromSharedFile, both file-operation logic. That work produced DsSvc bugs he reported to Microsoft, including a hardlink race against PolicyChecker::MoveFileInheritSecurity. RpcDSSDelegateSharingToken is one of the eight methods he listed, and is not among the methods the article examines. Opnum 3 acts on no file, so it falls outside the file-operation surface that every published line of enquiry on this binary, his included, was aimed at.

The most recent public DsSvc work attacks the service from outside. SafeBreach’s Ron Ben Yizhak disclosed a DsSvc vulnerability to Microsoft on 2025-07-14; it was assigned CVE-2025-59200, fixed on 2025-10-14, and published on 2026-07-01. He writes that “The attacker starts as a low integrity process and registers a rogue RPC server mimicking DsSvc.” That is a race to claim the name clients connect to, so that they reach the attacker instead of the service. It is one application of his broader work on winning LPEs by racing services for their RPC endpoints. The mechanism is which server a client reaches, not what the service authorizes once it has been reached, and opnum 3 falls outside its scope.

One detail in k0shl’s article matters here. He records that RpcDSSMoveFromSharedFile and RpcDSSMoveToSharedFile were removed from the interface in a later build. Both of those methods had public analysis behind them; the delegate method, for which I could find none, is still on the interface. I do not know what drove either decision and I am not going to invent one. The reasons a method leaves an internal interface are rarely the ones visible from outside. What I take from the pairing is narrower: the set of methods with published analysis behind them is not the same set as the methods that carry risk.

Which leaves one sentence I could not find an objection to:

As far as I can determine, this is the first published instance of permission re-delegation in a Windows local service of this kind.

What is new is the setting. Felt, Wang, Moshchuk, Hanna and Chin named permission re-delegation at USENIX Security 2011, and their paper’s third listed cause is this bug written down fifteen years early. Attenuating authority means handing on less than you hold, never more: “the developer might expose functionality with the goal of attenuating authority but implement the attenuation policy incorrectly or in a way that is inconsistent with the system policy”.

Three of those authors then filed US8893268B2, “Permission re-delegation prevention”, on 2011-11-15, assigned to Microsoft. Its claim 1 describes that check: before forwarding the privileged call, reduce the deputy’s permission list to “an overlap of permissions between the deputy application and the requester application”. CVE-2024-37882 is the same roles in another product: a Nextcloud recipient of a read-and-share folder could reshare it with more permissions than they held.

The paper and the patent are about applications as principals. The Nextcloud case is between user accounts, but the permissions being re-delegated are that application’s own. Here the grant is written to disk by a Windows service, it is redeemed later through a different method, and it crosses a kernel-enforced access list between two local user accounts. Those differences are worth recording. They do not make it a new class.


Part 6: Detection

The RPC call. Any caller invoking opnum 3 on interface bf4dc912-e52f-4904-8ebe-9317c1bdd497 is worth looking at, because the documented SharedStorageAccessManager surface has no delegate method at all. A low-integrity or otherwise unexpected caller doing it is worth alerting on. The Microsoft-Windows-RPC event-tracing provider is where that visibility lives.

The token record write. The stronger signal, if you can reach it, is a write that changes a token’s target list where the caller is neither the token’s owner nor already on that list. That is the defect itself rather than a side effect of it.


References

Kazuma Matsumoto. “CVE-2026-73014 (PLUSONE): Reading Another User’s Files Through the Windows Data Sharing Service”. 637th Research Lab, 2026-10-21. https://y637f9qq2x.com/posts/cve-2026-73014/