Skip to content

fix(installer): refuse an untrusted data folder - #2172

Merged
Benoît Cortier (CBenoit) merged 10 commits into
masterfrom
claude/distracted-boyd-bb97a8
Oct 28, 2026
Merged

Benoît Cortier (CBenoit) merged 10 commits into
masterfrom
claude/distracted-boyd-bb97a8

Conversation

@CBenoit

Copy link
Copy Markdown
Member

The installer keeps the Gateway's configuration, keys and databases under %ProgramData%\Devolutions\Gateway and writes there as SYSTEM, but it accepted that folder whatever its owner, applied only the DACL from its SDDL so the folder kept the owner it had, and reset the permissions of the files below by path, following any junction in the tree.
It also gave LOCAL SERVICE write access to the folder and to users.txt, and the service account the right to delete the folder itself.
The Gateway trusts what it reads from that folder, so an account that owned or could replace it could keep control of the Gateway's configuration.

The installer now:

  • refuses an install, upgrade, repair or feature change, before it changes anything, with a localized error naming the folder and the account, when Devolutions or Gateway is a link, a file, can't be read, or is owned by anyone but SYSTEM, Administrators, TrustedInstaller or a member of Administrators, or when the DACL of Devolutions lets another account replace it or an entry of it; a full uninstall is never refused;
  • creates a missing Gateway with its final security descriptor, or resets the owner and DACL of an existing one on the handle it checked, then gives each file and unprotected folder below the entries Gateway now passes on, removing those it passed on before, without following any link and skipping files with several hard links; a protected folder keeps its DACL, and users.txt keeps its own until its reset;
  • sets the owner and DACL of users.txt through its handle, refusing a link or a hard-linked file;
  • gives LOCAL SERVICE no entry, removes any it holds below the folder, and gives the service account no right to delete or rename the folder itself, keeping its rights on everything below.

Issue: DGW-478
Security: yes

🤖 Generated with Claude Code

Issue: DGW-478
Security: yes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…links

The Gateway service trusts what it reads from %ProgramData%\Devolutions\Gateway,
so the installer must only reset the security of folders that SYSTEM and
administrators control.

Before it changes anything, an install, upgrade, repair or feature change now
fails with a localized error naming the folder and the account when Devolutions
or Gateway is a link, is not a directory, can't be read, is owned by an account
that is not SYSTEM, TrustedInstaller or an administrator, or when the DACL of
Devolutions lets another account replace it or one of its entries. A full
uninstall is never refused, so a machine can always be uninstalled.

The folders are created and their security set through handles held without
delete sharing, so no path is resolved twice. The files below Gateway get the
entries it passes on through the handles of their held parents: reparse points
are not followed or changed, files with several hard links are skipped and
logged, and users.txt is refused when it is a reparse point or a hard link.

A new xUnit project covers the checks, the walk and the sequencing, and runs
in CI without elevation.

Issue: DGW-478
Security: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Gateway service account had Delete on %ProgramData%\Devolutions\Gateway
itself, so it could rename or replace the folder whose security the installer
resets, which only SYSTEM and administrators should be able to do.

Its entry on the folder now omits Delete and keeps Delete Subfolders and Files,
and an inherit-only entry gives it Delete on the files and subfolders below,
so its rights on everything the service manages are unchanged. The service
never deletes or renames the data folder itself.

Issue: DGW-478
Security: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t lies below

Only SYSTEM, administrators and the service account may change what the
Gateway stores in %ProgramData%\Devolutions\Gateway, yet the data folder and
users.txt also granted LOCAL SERVICE read and write access. That entry is
removed; a service account that is itself LOCAL SERVICE keeps its rights
through the service account's entries.

So that an upgrade also removes the entry from existing data, folders below
Gateway now get the same reset as files: explicit entries are kept, protection
is removed, and inherited entries are computed again from the held parent,
top-down. An entry whose DACL is not marked auto-inherited holds what it
inherited as explicit entries, so these are told apart by comparing them with
what its folder passed on before the reset, as Windows does when it passes a
new DACL on. The reset of an existing Gateway therefore walks the tree in the
same call that changes its DACL, on the handle it checked, and the new DACL is
stored marked auto-inherited so that entries created later carry inherited
entries Windows can tell apart.

Issue: DGW-478
Security: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… its owner

The check of %ProgramData%\Devolutions refused an allow entry with a right to
replace the folder or its entries unless it named SYSTEM, Administrators or
TrustedInstaller, while the owner rule also trusts members of Administrators.
An account that may own the folder gains nothing from an entry it could give
itself as the owner, and a Devolutions folder that an administrator's
application created first, without elevation, carries a full-control entry for
that administrator, so such an entry is now accepted. Entries for any other
account with a replace right are still refused.

Issue: DGW-478
Security: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Creating or checking %ProgramData%\Devolutions\Gateway already sets its owner
and DACL and resets the files and folders below it in the same call, so the
separate action that did the same afterwards only walked the tree a second
time. It is removed; the users database keeps its own action.

Issue: DGW-478
Security: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the data folder reset

The reset of the data folder's security now leaves the users database
directly below it as it is, because another action replaces its DACL and
the entries the folder passes on would let users read it until then.

A protected directory below the folder keeps its DACL, which an
administrator set on purpose, while the entries below it are still reset
against it.
Every allow entry for LOCAL SERVICE is removed below the folder, in a
protected directory too, because only SYSTEM, administrators and the
service account may change what the Gateway stores there, and earlier
installers gave LOCAL SERVICE write access.

The overloads only tests used are removed, and the tests drive the
handle the installer holds.
The major upgrade is checked to remove the installed product after
InstallInitialize, so before the data folder check.

Issue: DGW-478
Security: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…failed trust checks

CreateDirectory stores a DACL without marking it auto-inherited even
when the descriptor asks for it, so a new data folder's DACL is set again
through the new directory's handle.
The entries created below it then hold what they inherit as inherited
entries, which a later reset tells apart from their explicit ones.

When the groups of a folder's owner, or of an account with an entry,
can't be resolved, the installer logs why before falling back to the
local members of Administrators or refusing the account, so a refused
domain administrator can be told apart from a failed lookup.

A refusal's text is passed in a record field rather than escaped into
the format string, so it is shown as written.

Issue: DGW-478
Security: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Windows Installer replaces a property reference in a record field it
inserts, not only in the format string, so a data folder path holding
`[NAME]` would show a property value in the refusal. The refusal text is
now the format string with each bracket escaped.

Creating a folder whose DACL is marked auto-inherited still sets the
DACL a second time, since CreateDirectory leaves the mark off even with
SE_DACL_AUTO_INHERIT_REQ set. That second open no longer claims a share
mode that holds nothing, as the check and the set act on the handle.

Issue: DGW-478
Security: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The refusal shows its text as written only when its record comes from
MessageRecord; no test runs Refuse, so the comment there names it.

Issue: DGW-478
Security: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@CBenoit

Copy link
Copy Markdown
Member Author

Validation

Head 2dbfc5355; the build and tests below ran at e344a87eb, which differs from the head only by a comment.

  • Lines changed: production code +1624 −109 (package/WindowsManaged/Actions/*.cs, Program.cs, Resources/Includes.cs), tests +1348 −0 (new package/WindowsManaged.UnitTests), localization resources +95 −0, intent and CI +23 −1.
  • dotnet test package/WindowsManaged.UnitTests/DevolutionsGateway.Installer.Tests.csproj: 109 passed, 0 failed, without elevation; the new gateway-installer-tests CI job runs it.
  • dotnet test package/AgentWindowsManaged.Tests: 82 passed, 0 failed (unchanged project, run to check nothing shared broke).
  • ci/check-oracle-markers.ps1 -Base e8a4b082b: passes.
  • MSI built locally with placeholder payloads; InstallExecuteSequence: InstallInitialize 1500, CA.checkProgramDataDirectory 1503 (NOT (REMOVE="ALL")), RemoveExistingProducts 1504, CreateFolders 3700, CA.createProgramDataDirectory 3702, CA.setUserDatabasePermissions 6597, InstallFinalize 6600; all three are deferred, not impersonated, and checked.
  • Session.FormatRecord on the refusal record, in a session where SECRET is set, returns a text holding [SECRET], lone brackets and [\[] unchanged; the same test fails when the text goes in a record field or unescaped in the format string.
  • Mutations applied by hand and reverted (not a mutation tool run): comparing stale entries against the new parent instead of the previous one, skipping the conversion, restoring DELETE on the folder, restoring the LOCAL SERVICE entry, and recomputing a protected folder each fail at least one test.
  • Six independent review rounds; the last found nothing blocking.

Intent

package/WindowsManaged/INTENT.md (Data folder) is new and was approved verbatim by the maintainer before the code.
Audit against it:

  • Owner and permissions set on creation and reset on every install, upgrade and repair: conforms (DataFolders.Create).
  • Only SYSTEM and administrators can rename or replace the folder: conforms (no DELETE for the service account on the folder itself; the Devolutions DACL rule).
  • Only SYSTEM, administrators and the service account can change what the Gateway stores there: partial, see Known limits.
  • Refusal before any change, localized, naming folder and account; uninstall never refused: conforms (CA.checkProgramDataDirectory before RemoveExistingProducts, re-checked in Create).
  • No link followed when setting security: conforms (every open uses FILE_FLAG_OPEN_REPARSE_POINT, decisions from the handle, hard-linked files skipped or refused).

Decisions

  • Refuse rather than repair, as the Agent installer does, because the Gateway trusts the configuration and keys it finds in the folder.
  • The Devolutions DACL rule accepts an entry for a member of Administrators, as the owner rule accepts that account; the Agent installer and the agent's own ancestor check still refuse it, and are to be aligned separately.
  • LOCAL SERVICE has no entry, and the reset removes its entries below the folder; the installer already refuses it as the service account.
  • A folder below the data folder that an administrator protected keeps its DACL, apart from LOCAL SERVICE entries.
  • The folder-security code is a copy of the Agent installer's, adapted; the two installers share no code today.

Known limits

  • The reset changes DACLs only: an entry below the folder keeps its owner, so after a change of service account the previous account still owns, and may change the security of, the files it created.
  • A protected folder below the data folder keeps its other explicit entries.
  • users.txt that is a link or a hard link fails the run late, with the generic installer error rather than a named one.
  • A Devolutions folder whose DACL gives a non-administrator account a replace right blocks install, upgrade and repair until an administrator fixes it; the error names the account.
  • The German strings need a native speaker's review.
  • Other installer messages still let Windows Installer read brackets in their text as property references: ValidateServiceAccount puts its text in the format string, the exception logging inserts e.ToString() as a record field. The Agent installer already escapes its texts the same way.
  • A refusal naming a folder whose path holds a brace group with a bracket inside, such as {[x]}, loses those braces, and the log line written just before the error reads brackets in the path as property references; both need a redirected ProgramData with such a path.
  • A failed group lookup logs the exception for every entry it checks, so a log can repeat it several times.

UNTESTED

  • CustomActions.Refuse: a refusal is sent as InstallMessage.Error, which a silent install logs and an interactive one shows.
  • DataFolders.Check/Create: an existing Gateway that is a link or has an untrusted owner is refused (needs a passing Devolutions, whose owner a non-elevated test can't set).
  • DataFolders.Create: the Devolutions handle stays open until Gateway is created, or checked and reset.
  • DataFolders.Create: setting the owner of an existing Gateway to SYSTEM succeeds only for a caller allowed to set another owner (the installer's SYSTEM process).
  • DataFolders.Create: the entries below an existing Gateway lose what it passed on before only when reset in the same call that changes its DACL.
  • DataFolders.Evaluate and SecureDirectory.IsTrustedOwner: a member of Administrators is trusted, through Authz or the local member list.
  • SecureDirectory walk: each directory handle stays open until the entries below it are done, and a directory is walked only when the held handle is the entry first opened.

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant