mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
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:
1 parent
70dca86bc1
commit
94f5e4dd45
4 files changed
+169
-2
No files matched your search
@@ -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', {
|
||||
|
||||
Reference in new issue
Block a user