Security — Threat Model and Defenses (v2 service split)
Current architecture: a privileged service fmf-service (LocalSystem, least privilege) reads NTFS $MFT/USN,
and the non-privileged UI connects over a named pipe. Decision history and rejected options are in
ADR-0016 / ADR-0017;
API spec verification is in RESEARCH.md.
Threats and Defenses
| # | Threat | Defense |
|---|---|---|
| 1 | ACL-bypass name leak — the privileged indexer exposes file names invisible under the user’s own ACL to another user | Restrict the pipe DACL to SYSTEM + the user SID (SID captured at install time + the everyday-user SID forwarded by the non-elevated UI via --owner-sid. The latter is accepted only if it is a real-user type via validate_user_sid — keeps the everyday user from being locked out even under OTS elevation, while preventing injection of an arbitrary SID). No Authenticated Users / Everyone ACE (deny by default) + token check on connect. SID mismatch disconnects only that client. A token-verification API failure fails closed and stays recoverable: the accept loop drops the listener, serve() observes the lost listener and stops the service gracefully (flush → SCM Stopped), and the next on-demand start rebuilds it. No client is ever admitted unverified, and a Running service with no listener — unrecoverable, since clients reconnect forever and start is a no-op — is the state this explicitly excludes. RevertToSelf failure still aborts the process outright, so no later privileged work can continue under a client token |
| 2 | Remote connection | PIPE_REJECT_REMOTE_CLIENTS (+ server features are permanently out of scope per the won’t-do list) |
| 3 | Anonymous connection | No anonymous ACE in the explicit DACL = deny by default (the NullSessionPipes default is policy-dependent, so do not rely on it) |
| 4 | Pipe-name squatting / spoofed server | Server: FILE_FLAG_FIRST_PIPE_INSTANCE on the first instance only (no flag on subsequent instances — name preemption is impossible as long as the first instance is held). Client: for the default pipe name, GetNamedPipeServerProcessId → match against the SCM-registered fmf-engine service PID (QueryServiceStatusEx; works non-elevated — a SYSTEM process token cannot be opened non-elevated [ACCESS_DENIED], and a session 0 process identity is not obtainable either. A squatter cannot register with the SCM [requires admin] so its PID will not match) |
| 5 | Malicious client input (malformed frame, huge len, unknown opcode, pathological query) | Every allocation-bearing bound is contract, not a tunable: the caps live in fmf-contract::limits and are enforced identically at the C#, FFI, pipe, and core boundaries. The fixed 16-byte header is checked against the opcode-specific cap before any payload is allocated or read, so an attacker-declared length can never reserve memory (the separate global cap exists for response frames, not as a request-allocation allowance). Validation failure drops the connection + pipe_malformed_frames counter. Query text, parsed groups, parsed terms, and regex terms are each separately capped, so neither a long query nor a deeply combinatorial one can force unbounded parse work. IndexStart is transactionally restricted to unique canonical labels in the current fixed-NTFS set, so it cannot launch workers for FAT/exFAT, removable, network, or synthetic paths. The whole dispatcher is a catch_unwind firewall (panic returns FMF_E_PANIC, the service survives). Regex is linear-time matching (no ReDoS) + compile caps size_limit/dfa_size_limit=1 MiB to gracefully reject computational DoS (overflow returns FMF_E_QUERY_SYNTAX. ADR-0023/ADR-0044, RESEARCH.md) |
| 6 | Local DoS (connection flood, handle exhaustion, flush spamming) | Every unbounded-growth path is given a fixed compile-time ceiling rather than a runtime policy: concurrent pipe instances (overflow rejects the connection + pipe_connections_rejected), decoded requests in flight per connection (bounded backpressure; stop/disconnect cancels a blocked producer), result handles per connection (LRU evict → STALE, so exhaustion degrades into the existing re-query path instead of failing), and the per-connection event queue. The contract-visible ceilings are fmf-contract::limits; the accept-loop instance cap is the pipe server’s own constant. Flush is not exposed over the pipe (only the service-internal periodic flush and flush on stop). Events use a bounded queue + drop to protect the USN thread. Note that only the authorized same user can even reach this (#1) |
| 7 | Leak/tamper of the machine index (.fmfidx contains every file name) | Install creates the root protected, publishes it by exact handle, and pins its NTFS identity in protected HKLM; an unproven fixed-name object is quarantined, never repaired in place. Which pre-opened handle fails the install closed and which is rotated out is not a choice — it follows from what Windows share-access can see. Sharing buckets desired access into read (FILE_READ_DATA/FILE_EXECUTE), write (FILE_WRITE_DATA/FILE_APPEND_DATA) and delete (DELETE), so a squatter holding DELETE — or FILE_ADD_FILE, which is FILE_WRITE_DATA — causes a sharing violation and install refuses. WRITE_DAC, WRITE_OWNER and FILE_DELETE_CHILD are in none of those buckets: no share mode can exclude them, and install rotates the object out of the privileged name instead. That is sufficient because a handle is bound to the object, not to the name — measured against a real standard-user token, the squatter keeps its capability over the quarantined directory and is denied even enumeration of the fresh root, whose descriptor admits only SYSTEM and Administrators. Root/descendant mutation is handle-bound and atomic. Administrators own the protected SYSTEM+Administrators tree; only logs add authorized-user read. Fixed paths reject reparse points at install, service start, GC, and purge. The existing tree is hardened bottom-up and write-nothing-on-the-way-down: a directory receives its descriptor only after its whole subtree has been opened FILE_FLAG_OPEN_REPARSE_POINT, type-checked, and given a protected descriptor of its own, so no object’s access depends on what it inherits and an inheritable ACE never reaches a level not yet proven free of reparse points. Both halves are measured rather than assumed: an unelevated test plants a real junction (which, unlike a symlink, any standard user can create) and pins that the target’s descriptor is untouched and that the walk refuses the tree; another builds the production shape — a child directory holding only files — which is the shape no earlier test built, because they all created an empty root and so never executed the walk at all. Both walks are additionally handle-relative: a directory’s contents are enumerated from the handle already held for it (GetFileInformationByHandleEx, never a second open by name), and every child is opened with NtCreateFile against OBJECT_ATTRIBUTES.RootDirectory = that same parent handle under a leaf-only ObjectName (OBJ_CASE_INSENSITIVE + OBJ_DONT_REPARSE, still FILE_OPEN_REPARSE_POINT so the per-handle reparse rejection stays the authority). No step re-resolves a full path, so no ancestor name is consulted twice and there is no TOCTOU window in which one could be repointed — a name repointed between enumeration and open can only present a different object under the already-verified parent, and that object is still judged by the per-handle reparse, type, and link-count checks before anything is mutated. Enumeration therefore also stops depending on the very ACL the hardening walk is rewriting. Pinned by unelevated swap tests that replace a child with a junction (hardening) and with a second link to a file outside the tree (deletion) inside the walk’s own window, and require both to fail closed with the outside object untouched |
| 8 | Residual risk (accepted) | An authorized user can search the “name/path” of files invisible under their own ACL (a structural property of name-only indexing; the contents and the actual ACL cannot be read). Targets single-user machines primarily; multi-user authorization is a re-examination trigger in ADR-0017 |
| 9 | Privilege escalation via the unelevated start/stop right (on-demand lifecycle, ADR-0027) | The service-object DACL grants the authorized user SID(s) only SERVICE_START/SERVICE_STOP/SERVICE_QUERY_STATUS+read (built by the unit-pinned security::service_sddl; never hand-rolled). It deliberately withholds SERVICE_CHANGE_CONFIG, DELETE, WRITE_DAC, WRITE_OWNER from a standard user — granting any would let a non-admin repoint this LocalSystem service’s binary and run code as SYSTEM. SYSTEM/Administrators keep full control (SCM management + the SYSTEM-run GC’s DeleteService). The pin asserts start/stop are present and the four escalation rights are absent |
| 10 | Tampering with the stable SYSTEM binary / elevated helper or adjacent-DLL hijack (ADR-0045) | Before the UI passes the bundled helper to runas, it locks the image without write/delete sharing, locks each non-root parent directory the same way wherever this token is granted DELETE on it (a directory whose DACL withholds DELETE from the app’s token — C:\ProgramData does, for a standard user — denies an attacker holding that same token the rename or delete just as strictly, so the open degrades to observation instead of failing closed), rejects file or directory reparse points, compares a constant-time SHA-256 over Windows’ Authenticode PE digest stream with the exact digest embedded by xtask publish, and requires WinVerifyTrust to accept the Authenticode signature. The digest excludes only the mutable certificate table, so it is identical before/after release signing and rejects an older same-signer helper as well as an unsigned replacement. The service statically links the MSVC CRT and embeds /DEPENDENTLOADFLAG:0x800, limiting all remaining static imports to System32; both publish and package parse the PE Load Config and require that exact value. Thus locking the EXE cannot be bypassed with a planted sibling DLL, and no VC Redistributable is a hidden prerequisite. Unpinned or incorrectly linked developer builds cannot cross this elevation boundary. The installed service/GC then run %ProgramData%\find-my-files\fmf-service.exe as SYSTEM; install copies it only after threat-7 DACL hardening and re-hardens it. The GC task runs as S-1-5-18 with HighestAvailable. Install invokes the Known-Folder-resolved absolute System32\schtasks.exe (never PATH search) and XML-escapes its action path |
Required machine-security gate
just test-admin pins and runs
pipe::admin_security_tests::named_pipe_security_boundaries_are_enforced_on_real_tokens_and_transports.
It must prove all three boundaries on real Windows tokens and transports:
- a temporary local standard user’s distinct
TokenUserSID is denied by the production pipe DACL, while the authorized user connects to the same pending pipe instance; - a deliberately wide test-only DACL admits that other user at the kernel, but
server-side
verify_clientrejects and disconnects it; and \\COMPUTERNAME\pipe\...succeeds withPIPE_ACCEPT_REMOTE_CLIENTS, then fails withPIPE_REJECT_REMOTE_CLIENTSunder the same identity and DACL.
The remote control is fail-closed: inability to prove remote transport fails the
test. The test account and password are unique per process, the password is never
logged and is cleared after logon, and RAII deletes the account during normal
completion or panic unwinding. Release and nightly admin workflows upload this
test in build/engine/nextest/admin/admin.xml; removing or renaming it makes the
test-admin inventory preflight fail before execution.
Distribution integrity
release.yml is dispatchable only from protected main — the release and
release-please environments restrict deployments to that branch, so an
off-branch dispatch reaches no signing credential, App token, or publication
authority — and its first job binds the dispatch to one exact tag, commit, and
draft release ID before any other job starts (ADR-0048). It then isolates build,
signing, and publishing into separate jobs. Only the
approval-gated signing job receives SSL.com eSigner secrets; every first-party PE
listed in the committed signing manifest must pass Authenticode chain, RFC 3161
timestamp, and signer-subject verification.
The unsigned service’s Authenticode-stable image digest is embedded into the app
before signing; collection proves that signing preserved every first-party PE
image, and package independently rechecks the service load policy before sealing
the ZIP. Runtime verifies the same service identity immediately before UAC.
Actions are commit-SHA pinned and publishing fails closed. Operational steps live
in RELEASING.md; rationale is in
ADR-0020 and
ADR-0029.