#671·winfsp

Optional framework-level owner-change guard in SetSecurity (let in-memory file systems approximate NTFS's STATUS_INVALID_OWNER)

Author: hooyaoCreated Jun 18, 2026Updated Jul 21, 2026

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_PARAMS flag (e.g. RejectOwnerChangeOnSetSecurity) that makes FspFileSystemOpSetSecurity fail an owner-changing request before dispatching to the FS, or
  • a FspSetSecurityDescriptor variant (e.g. FspSetSecurityDescriptorEx) that takes the object's current owner and returns STATUS_INVALID_OWNER when OWNER_SECURITY_INFORMATION would 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:\Temp

Observed (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.cFspSetSecurityDescriptor merges via SetPrivateObjectSecurity(SecInfo, ModDesc, &InputDesc, &FspFileGenericMapping, /*Token*/ 0); the Token is hardcoded 0, so owner-validity is never checked.
  • inc/winfsp/fsctl.hFSP_FSCTL_TRANSACT_REQ.Req.Create carries UINT64 AccessToken, but Req.SetSecurity has no token field.
  • src/sys/security.cFspFsvolSetSecurity posts the request with no SeAccessCheck / token capture (unlike src/sys/create.c, which fills Req.Create.AccessToken).
  • src/dll/fsop.cFspFileSystemOpSetSecurity calls Interface->SetSecurity(...) with no token parameter, and FspFileSystemOpEnter does not impersonate, so the handler can't recover the caller via OpenThreadToken either.

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_PARAMS flag handled in FspFileSystemOpSetSecurity, or a FspSetSecurityDescriptorEx helper) require no driver or FSCTL change.
  • Not proposing to flow the caller token to SetSecurity (adding AccessToken to Req.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)
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;
}