Optional framework-level owner-change guard in SetSecurity (let in-memory file systems approximate NTFS's STATUS_INVALID_OWNER)
What I'd like
An opt-in, purely user-mode way for a WinFsp file system to have the framework reject a
SetSecurity request that reassigns the owner when the caller could not have the privilege to
do so — returning STATUS_INVALID_OWNER (Win32 ERROR_INVALID_OWNER, 1307), the same status real
NTFS returns.
Concretely, one of:
- a
FSP_FSCTL_VOLUME_PARAMSflag (e.g.RejectOwnerChangeOnSetSecurity) that makesFspFileSystemOpSetSecurityfail an owner-changing request before dispatching to the FS, or - a
FspSetSecurityDescriptorvariant (e.g.FspSetSecurityDescriptorEx) that takes the object's current owner and returnsSTATUS_INVALID_OWNERwhenOWNER_SECURITY_INFORMATIONwould change it,
so that in-memory file systems (memfs and the many third-party ones built on it) can match NTFS's owner-assignment behavior with a one-line opt-in, instead of each re-implementing the same check.
No kernel/driver or FSCTL protocol change is implied by either option — both live entirely in the user-mode DLL.
Why — the behavior gap this closes
On a WinFsp in-memory file system that implements SetSecurity via the documented helper
FspSetSecurityDescriptor (as bundled memfs does), a non-privileged caller can reassign a
file/directory owner to an arbitrary SID and the call succeeds. On real NTFS the identical
operation is refused with ERROR_INVALID_OWNER (1307), because owner assignment requires
SeRestorePrivilege / SeTakeOwnershipPrivilege the caller does not hold.
Because NTFS's refusal is atomic (the DACL bundled in the same request is also dropped), an
object there always keeps its creator-owner and therefore stays deletable/recoverable by that
creator. On memfs the owner change goes through, the creator loses the owner-implied
READ_CONTROL / WRITE_DAC, and the object can become un-deletable and its ACL un-readable by
the very principal that created it.
This is observable with real tools: profilers / ETW collectors that lock down their own temp
directories by tightening the SD (including changing the owner) leave un-deletable, un-readable
leftovers when %TEMP% is on a memfs-backed volume, then fail on a subsequent run when they try
to recreate the same *_<GUID> temp path (it still exists and they can't touch it). The same
workload on NTFS works, because NTFS refuses the owner reassignment up front.
Repro (stock memfs vs NTFS, non-elevated)
Self-contained C reproducer attached below. Build with MSVC
(cl /W4 winfsp-setsecurity-owner-repro.c advapi32.lib), run from a non-elevated shell with a
memfs mount and an NTFS control path:
repro.exe M:\ C:\TempObserved (abridged) — owner reassignment to BUILTIN\Administrators (S-1-5-32-544):
==== root: M:\ (memfs) ====
owner before: S-1-5-21-...-1001 (the creator)
attempting owner change -> S-1-5-32-544
SetKernelObjectSecurity(OWNER) SUCCEEDED <== owner reassigned
owner after: S-1-5-32-544
==== root: C:\Temp (NTFS) ====
owner before: S-1-5-21-...-1001
attempting owner change -> S-1-5-32-544
open(WRITE_OWNER) FAILED win32=5 => owner change refused (NTFS-like)
owner after: S-1-5-21-...-1001 (unchanged)(NTFS may refuse at the SetSecurity call with ERROR_INVALID_OWNER 1307, or earlier at handle open
with ACCESS_DENIED 5, depending on the object's DACL; either way the owner is not reassigned. The
point is memfs accepts the reassignment while NTFS does not.)
Why this can't be done correctly inside the FS today
The reason an in-memory FS can't already do this itself is that the caller token is not available at the SetSecurity path, so it cannot perform the real privilege check NTFS performs:
src/dll/security.c—FspSetSecurityDescriptormerges viaSetPrivateObjectSecurity(SecInfo, ModDesc, &InputDesc, &FspFileGenericMapping, /*Token*/ 0); theTokenis hardcoded0, so owner-validity is never checked.inc/winfsp/fsctl.h—FSP_FSCTL_TRANSACT_REQ.Req.CreatecarriesUINT64 AccessToken, butReq.SetSecurityhas no token field.src/sys/security.c—FspFsvolSetSecurityposts the request with noSeAccessCheck/ token capture (unlikesrc/sys/create.c, which fillsReq.Create.AccessToken).src/dll/fsop.c—FspFileSystemOpSetSecuritycallsInterface->SetSecurity(...)with no token parameter, andFspFileSystemOpEnterdoes not impersonate, so the handler can't recover the caller viaOpenThreadTokeneither.
A passthrough FS sidesteps all this by forwarding to a real handle and letting the kernel SRM do
the check (tst/ntptfs/ptfs.c SetSecurity -> NtSetSecurityObject(Handle, ...)). An in-memory
FS has neither the caller token nor a real backing handle, so it cannot replicate the NTFS outcome
on its own.
The proposed enhancement deliberately avoids needing the token: instead of asking "does the caller
hold the privilege to set this owner?" (unanswerable without the token), it asks "is the owner
being changed at all?" and refuses if so. That is a strict, safe approximation — it never silently
strips an object's recoverability, and it exactly matches the NTFS-observable result for the
non-privileged case (owner stays with the creator). A file system that legitimately needs to
support owner changes simply does not opt in (or implements SetSecurity itself).
Scope / non-goals
- In user-mode only. Both proposed shapes (a
VOLUME_PARAMSflag handled inFspFileSystemOpSetSecurity, or aFspSetSecurityDescriptorExhelper) require no driver or FSCTL change. - Not proposing to flow the caller token to SetSecurity (adding
AccessTokentoReq.SetSecurity+ capturing it in the driver +SetPrivateObjectSecurityEx). That would enable an exact check but touches the kernel and the protocol; I'm noting it only for completeness and assume it's out of scope. - Default behavior unchanged; existing file systems are unaffected unless they opt in.
Happy to help
I have the attached repro and a working user-mode implementation of the "reject owner change ->
STATUS_INVALID_OWNER" approximation in my own file system. I'm glad to turn it into a draft PR for
either the VOLUME_PARAMS flag or the FspSetSecurityDescriptorEx helper if you think one of them
is the right shape.
Self-contained C reproducer (
winfsp-setsecurity-owner-repro.c)/*
* winfsp-setsecurity-owner-repro.c
*
* Minimal, self-contained reproducer for: a non-privileged caller can reassign the
* owner of a file/directory via SetSecurity on a WinFsp in-memory file system (e.g.
* the bundled memfs), whereas real NTFS refuses it with ERROR_INVALID_OWNER (1307).
*
* What it does, on each <root> you pass:
* 1. Creates a fresh directory <root>\winfsp_owner_repro_<pid>
* 2. Reads and prints its current owner SID (the creating principal).
* 3. Picks a *different* well-known owner SID (Administrators, or LocalSystem if the
* caller already owns as Administrators) so the attempt is a genuine owner CHANGE.
* 4. Opens a handle with WRITE_OWNER and calls SetKernelObjectSecurity with
* OWNER_SECURITY_INFORMATION only.
* 5. Reports whether the owner change SUCCEEDED, the resulting owner, and whether the
* directory is still deletable by the (same, non-elevated) caller.
*
* Run it twice -- once with a WinFsp memfs mount, once with a path on a real NTFS volume --
* and compare. Run from a NON-ELEVATED shell (that is the whole point).
*
* Build (MSVC): cl /W4 /nologo winfsp-setsecurity-owner-repro.c advapi32.lib
* Build (MinGW): gcc -O2 -Wall -o repro winfsp-setsecurity-owner-repro.c -ladvapi32
*
* Usage: repro.exe <root1> [<root2> ...]
* Example: repro.exe M:\ C:\Temp
* (M: = memfs mount, C:\Temp = NTFS control)
*
* Expected output: on NTFS the owner change FAILS with win32=1307 (ERROR_INVALID_OWNER)
* and the directory stays deletable; on memfs the owner change SUCCEEDS and the directory
* may become undeletable by its creator.
*/
#include <windows.h>
#include <sddl.h>
#include <stdio.h>
static void print_owner(const char *label, const char *path)
{
UCHAR buf[1024];
DWORD needed = 0;
if (!GetFileSecurityA(path, OWNER_SECURITY_INFORMATION, buf, sizeof(buf), &needed))
{
printf(" %-18s <GetFileSecurity failed, win32=%lu>\n", label, GetLastError());
return;
}
PSID owner = NULL;
BOOL defaulted = FALSE;
if (!GetSecurityDescriptorOwner((PSECURITY_DESCRIPTOR)buf, &owner, &defaulted) || owner == NULL)
{
printf(" %-18s <no owner in SD>\n", label);
return;
}
LPSTR sidstr = NULL;
if (ConvertSidToStringSidA(owner, &sidstr))
{
printf(" %-18s %s\n", label, sidstr);
LocalFree(sidstr);
}
else
{
printf(" %-18s <ConvertSidToStringSid failed, win32=%lu>\n", label, GetLastError());
}
}
/* Read the current owner SID string into out (caller frees with LocalFree). NULL on failure. */
static LPSTR get_owner_sid(const char *path)
{
UCHAR buf[1024];
DWORD needed = 0;
if (!GetFileSecurityA(path, OWNER_SECURITY_INFORMATION, buf, sizeof(buf), &needed))
return NULL;
PSID owner = NULL;
BOOL defaulted = FALSE;
if (!GetSecurityDescriptorOwner((PSECURITY_DESCRIPTOR)buf, &owner, &defaulted) || owner == NULL)
return NULL;
LPSTR sidstr = NULL;
if (!ConvertSidToStringSidA(owner, &sidstr))
return NULL;
return sidstr; /* LocalFree by caller */
}
static void run_on_root(const char *root)
{
char dir[MAX_PATH];
DWORD pid = GetCurrentProcessId();
/* Build "<root>\winfsp_owner_repro_<pid>", tolerating a trailing backslash on root. */
size_t n = strlen(root);
if (n > 0 && (root[n - 1] == '\\' || root[n - 1] == '/'))
snprintf(dir, sizeof(dir), "%swinfsp_owner_repro_%lu", root, pid);
else
snprintf(dir, sizeof(dir), "%s\\winfsp_owner_repro_%lu", root, pid);
printf("==== root: %s ====\n", root);
printf(" target dir: %s\n", dir);
if (!CreateDirectoryA(dir, NULL))
{
printf(" CreateDirectory failed, win32=%lu (skipping this root)\n\n", GetLastError());
return;
}
/* Current owner = creating principal. */
print_owner("owner before:", dir);
LPSTR ownerBefore = get_owner_sid(dir);
/* Pick a DIFFERENT well-known owner so this is a genuine change regardless of who we are.
* S-1-5-32-544 = BUILTIN\Administrators, S-1-5-18 = LocalSystem (SY). */
const char *targetSid = "S-1-5-32-544";
if (ownerBefore && _stricmp(ownerBefore, "S-1-5-32-544") == 0)
targetSid = "S-1-5-18";
char sddl[64];
snprintf(sddl, sizeof(sddl), "O:%s", targetSid);
PSECURITY_DESCRIPTOR sd = NULL;
ULONG sdlen = 0;
if (!ConvertStringSecurityDescriptorToSecurityDescriptorA(sddl, SDDL_REVISION_1, &sd, &sdlen))
{
printf(" ConvertStringSD(%s) failed, win32=%lu\n\n", sddl, GetLastError());
if (ownerBefore) LocalFree(ownerBefore);
RemoveDirectoryA(dir);
return;
}
printf(" attempting owner change -> %s\n", targetSid);
/* Open with WRITE_OWNER. FILE_FLAG_BACKUP_SEMANTICS is required to get a directory handle. */
HANDLE h = CreateFileA(dir, WRITE_OWNER | WRITE_DAC | READ_CONTROL,
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL,
OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, NULL);
if (h == INVALID_HANDLE_VALUE)
{
/* On real NTFS a non-privileged caller may not even get WRITE_OWNER -> ACCESS_DENIED here,
* which is itself a valid "refused" outcome. */
printf(" open(WRITE_OWNER) FAILED win32=%lu => owner change refused at open (NTFS-like)\n",
GetLastError());
}
else
{
BOOL ok = SetKernelObjectSecurity(h, OWNER_SECURITY_INFORMATION, sd);
DWORD err = GetLastError();
CloseHandle(h);
if (ok)
printf(" SetKernelObjectSecurity(OWNER) SUCCEEDED <== owner reassigned\n");
else
printf(" SetKernelObjectSecurity(OWNER) FAILED win32=%lu%s\n", err,
err == ERROR_INVALID_OWNER ? " (ERROR_INVALID_OWNER) <== NTFS-like refusal" : "");
}
/* Resulting owner + can we still delete it? */
print_owner("owner after:", dir);
if (RemoveDirectoryA(dir))
printf(" RemoveDirectory: OK (still deletable by creator)\n");
else
printf(" RemoveDirectory: FAILED win32=%lu (LEAK: creator can no longer delete it)\n",
GetLastError());
LocalFree(sd);
if (ownerBefore) LocalFree(ownerBefore);
printf("\n");
}
int main(int argc, char **argv)
{
if (argc < 2)
{
fprintf(stderr,
"usage: %s <root1> [<root2> ...]\n"
" e.g. %s M:\\ C:\\Temp (M: = winfsp memfs mount, C:\\Temp = NTFS control)\n"
" run from a NON-ELEVATED shell.\n",
argv[0], argv[0]);
return 2;
}
BOOL elevated = FALSE;
HANDLE tok = NULL;
if (OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &tok))
{
TOKEN_ELEVATION el;
DWORD cb = 0;
if (GetTokenInformation(tok, TokenElevation, &el, sizeof(el), &cb))
elevated = el.TokenIsElevated;
CloseHandle(tok);
}
printf("running %s\n\n", elevated ? "ELEVATED (note: results are only meaningful non-elevated)"
: "non-elevated");
for (int i = 1; i < argc; i++)
run_on_root(argv[i]);
return 0;
}
Source: winfsp/winfsp