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

+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', {