fix(windows): isolate the ACL verifier from the PowerShell 7 module path (#508)

Supersedes #161 by @michaelhan1208, ported onto the current component layout with fixes from review.

Verified before merge: format, package boundaries, unit suite, build, and the browser tracking suite; a browser harness for the change where one applies; adversarial review applied.

Co-authored-by: michaelhan1208 <[email protected]>
This commit is contained in:
Bilawal Sidhuandmichaelhan1208 authored and GitHub committed 2026-09-14 19:38:28 -05:00
1 parent 70dca86bc1
commit 94f5e4dd45
4 files changed
+169 -2

No files matched your search

+32 -1
View File
@@ -9,6 +9,12 @@ import {
/** PowerShell verification for the exact owner-only Windows credential DACL. */
const WINDOWS_ACL_VERIFY_SCRIPT = [
"$ErrorActionPreference = 'Stop'",
// Load Microsoft.PowerShell.Security (Get-Acl) from the module tree of the
// interpreter that is actually running. $PSHOME is that interpreter's own
// physical directory, so this is correct even when the executable was named
// through the Sysnative bridge, and it cannot be steered by anything the
// parent environment set.
"$env:PSModulePath = Join-Path $PSHOME 'Modules'",
'$acl = Get-Acl -LiteralPath $env:GEV_ACL_FILE',
'if (-not $acl.AreAccessRulesProtected) { exit 2 }',
"$allowed = @($env:GEV_ACL_USER_SID, 'S-1-5-18', 'S-1-5-32-544')",
@@ -143,15 +149,40 @@ export function hardenCredentialFile(filepath, {
// accept only three explicit FullControl allow principals, with inheritance
// disabled. Any unexpected rule, right, command error, or missing principal
// fails closed before the secret reaches disk.
//
// The verify process must load Microsoft.PowerShell.Security (Get-Acl)
// from the Windows PowerShell system module tree ONLY. A side-by-side
// PowerShell 7 install prepends its own module trees to PSModulePath at
// startup; inherited into a 5.1 process, the incompatible 7.x manifest
// cannot be autoloaded and the verify step fails. The script itself sets
// the path from $PSHOME; this is the same value computed ahead of time, so
// nothing inherited is in force even for the moment before it runs. The
// Sysnative spelling is a 32-bit caller's bridge and not a directory the
// launched native process can read, so the physical name is used.
const powershellModuleDirectory = path.win32.join(
path.win32
.dirname(tools.powershell)
.replace(/\\Sysnative\\/i, '\\System32\\'),
'Modules',
);
// Windows environment names are case-insensitive, and a child can end up
// carrying a differently cased alias alongside the value set here. Drop
// every spelling before setting the trusted one.
const verifyEnvironment = Object.fromEntries(
Object.entries(environment).filter(
([name]) => name.toLowerCase() !== 'psmodulepath',
),
);
const verified = spawn(tools.powershell, [
'-NoProfile',
'-NonInteractive',
'-Command', WINDOWS_ACL_VERIFY_SCRIPT,
], {
env: {
...environment,
...verifyEnvironment,
GEV_ACL_FILE: filepath,
GEV_ACL_USER_SID: sid,
PSModulePath: powershellModuleDirectory,
},
stdio: 'ignore',
windowsHide: true,
+126
View File
@@ -126,6 +126,132 @@ test('Windows hardening applies and then verifies the exact restricted DACL', ()
assert.match(calls[2].args.at(-1), /seen\.Count -ne 3/);
});
test('Windows hardening isolates the verify PowerShell from a pwsh7-polluted PSModulePath', () => {
const calls = [];
// Exactly what a side-by-side PowerShell 7 install prepends at startup:
// its own user Documents\PowerShell, Program Files\PowerShell, and
// Program Files\PowerShell\7 module trees all shadow the 5.1 system path.
const pollutedModulePath = [
'C:\\Users\\alice\\Documents\\PowerShell\\Modules',
'C:\\Program Files\\PowerShell\\Modules',
'c:\\program files\\powershell\\7\\Modules',
'C:\\Program Files\\WindowsPowerShell\\Modules',
'C:\\WINDOWS\\system32\\WindowsPowerShell\\v1.0\\Modules',
].join(';');
const result = hardenCredentialFile('C:\\GEV\\ENVIRONMENT.tmp', {
platform: 'win32',
environment: {
SYSTEMROOT: WINDOWS_ROOT,
PSModulePath: pollutedModulePath,
},
fileSystem: windowsFileSystem(),
spawn(command, args, options) {
calls.push({ command, args, options });
if (command.endsWith('\\whoami.exe')) {
return { status: 0, signal: null, stdout: `"WORKSTATION\\alice","${USER_SID}"\r\n` };
}
return { status: 0, signal: null };
},
});
assert.equal(result, true);
const verify = calls.find(({ command }) => command.endsWith('powershell.exe'));
assert.ok(verify, 'the ACL verify powershell must be spawned');
const modulePath = verify.options.env.PSModulePath;
// Fail closed: the verification interpreter must never inherit pwsh7's
// module trees — a 7.x Microsoft.PowerShell.Security manifest cannot be
// autoloaded by 5.1, and an untrusted tree must not shadow Get-Acl at all.
assert.ok(!/Documents\\PowerShell\\Modules/i.test(modulePath), 'user pwsh7 module tree must be stripped');
assert.ok(!/Program Files\\PowerShell\\Modules/i.test(modulePath), 'Program Files pwsh7 module tree must be stripped');
assert.ok(!/Program Files\\PowerShell\\7\\Modules/i.test(modulePath), 'pwsh7 install module tree must be stripped');
// ... but the 5.1 system module directory (where Get-Acl lives) must remain.
assert.match(modulePath, /System32\\WindowsPowerShell\\v1\.0\\Modules/i);
});
test('the verify script takes its module path from the running interpreter', () => {
const calls = [];
const result = hardenCredentialFile('C:\\GEV\\ENVIRONMENT.tmp', {
platform: 'win32',
environment: { SYSTEMROOT: WINDOWS_ROOT },
fileSystem: windowsFileSystem(),
spawn(command, args, options) {
calls.push({ command, args, options });
if (command.endsWith('\\whoami.exe')) {
return { status: 0, signal: null, stdout: `"WORKSTATION\\alice","${USER_SID}"` };
}
return { status: 0, signal: null };
},
});
assert.equal(result, true);
const verify = calls.find(({ command }) => command.endsWith('powershell.exe'));
const script = verify.args.at(-1);
// $PSHOME is the interpreter's own physical directory, so this holds even
// where the executable was named through a bridge path.
assert.match(script, /\$env:PSModulePath = Join-Path \$PSHOME 'Modules'/);
// ...and it is the first thing the script does, before Get-Acl is resolved.
assert.ok(
script.indexOf('PSModulePath') < script.indexOf('Get-Acl'),
'the module path must be set before Get-Acl is invoked',
);
});
test('a 32-bit caller passes the physical module directory, not the Sysnative bridge', () => {
const calls = [];
const result = hardenCredentialFile('C:\\GEV\\ENVIRONMENT.tmp', {
platform: 'win32',
architecture: 'ia32',
environment: { SYSTEMROOT: WINDOWS_ROOT },
fileSystem: windowsFileSystem({ systemDirectory: 'Sysnative' }),
spawn(command, args, options) {
calls.push({ command, args, options });
if (command.endsWith('\\whoami.exe')) {
return { status: 0, signal: null, stdout: `"WORKSTATION\\alice","${USER_SID}"` };
}
return { status: 0, signal: null };
},
});
assert.equal(result, true);
const verify = calls.find(({ command }) => command.endsWith('powershell.exe'));
// The executable is still named through the bridge the 32-bit caller needs.
assert.match(verify.command, /\\Sysnative\\/);
// The module directory is not: Sysnative is not a directory the launched
// native process can read.
assert.doesNotMatch(verify.options.env.PSModulePath, /Sysnative/i);
assert.match(
verify.options.env.PSModulePath,
/^C:\\Windows\\System32\\WindowsPowerShell\\v1\.0\\Modules$/i,
);
});
test('differently cased PSModulePath aliases do not survive into the verify process', () => {
const calls = [];
const result = hardenCredentialFile('C:\\GEV\\ENVIRONMENT.tmp', {
platform: 'win32',
environment: {
SYSTEMROOT: WINDOWS_ROOT,
// Windows environment names are case-insensitive; a child can keep a
// differently cased alias alongside the value set for it.
PSMODULEPATH: 'C:\\Program Files\\PowerShell\\7\\Modules',
psmodulepath: 'C:\\Users\\alice\\Documents\\PowerShell\\Modules',
PsModulePath: 'C:\\attacker\\Modules',
},
fileSystem: windowsFileSystem(),
spawn(command, args, options) {
calls.push({ command, args, options });
if (command.endsWith('\\whoami.exe')) {
return { status: 0, signal: null, stdout: `"WORKSTATION\\alice","${USER_SID}"` };
}
return { status: 0, signal: null };
},
});
assert.equal(result, true);
const verify = calls.find(({ command }) => command.endsWith('powershell.exe'));
const aliases = Object.keys(verify.options.env).filter(
(name) => name.toLowerCase() === 'psmodulepath',
);
assert.deepEqual(aliases, ['PSModulePath'], 'exactly one spelling may reach the child');
assert.doesNotMatch(verify.options.env.PSModulePath, /PowerShell\\7|Documents|attacker/i);
});
test('Windows hardening bypasses PATH-shadowed native ACL tools', () => {
const commands = [];
const result = hardenCredentialFile('D:\\GEV\\ENVIRONMENT.tmp', {