Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions tests/test_shell_client_path_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,28 @@ def test_newline_injection_blocked(self):
sc._is_command_allowed("Get-ChildItem\nGet-Content secrets.txt")
)

def test_dotnet_method_invocation_blocked(self):
# CWE-184: an allow-listed cmdlet must not be usable as a springboard
# into arbitrary .NET static-method invocation carried inside plain
# parentheses / scriptblocks.
for cmd in (
"Write-Output ([System.Diagnostics.Process]::Start('calc'))",
(
"Get-ChildItem | ForEach-Object "
"{ [System.IO.File]::WriteAllText('poc.txt','proof') }"
),
"Write-Output ([math]::Pi)",
"Write-Output (New-Object System.Net.WebClient)",
"Write-Output ([scriptblock]::Create('calc')).Invoke()",
"Get-ChildItem | & { calc }",
"Get-ChildItem | . { calc }",
):
with self.subTest(cmd=cmd):
self.assertFalse(
sc._is_command_allowed(cmd),
f"{cmd!r} must be blocked by the denylist",
)

def test_registry_and_provider_paths_blocked(self):
for cmd in (
"Get-ChildItem HKLM:\\SOFTWARE",
Expand Down Expand Up @@ -288,6 +310,19 @@ def test_blocks_env_expansion(self):
"Get-Content $env:USERPROFILE\\.ssh\\id_rsa"
)

def test_blocks_dotnet_method_invocation(self):
# MSRC123355: .NET static-method invocation through an allow-listed
# cmdlet must be rejected before reaching ``powershell -Command``.
for cmd in (
"Write-Output ([System.Diagnostics.Process]::Start('calc'))",
(
"Get-ChildItem | ForEach-Object "
"{ [System.IO.File]::WriteAllText('poc.txt','proof') }"
),
):
with self.subTest(cmd=cmd):
self._assert_blocked(cmd)


if __name__ == "__main__": # pragma: no cover
unittest.main()
26 changes: 26 additions & 0 deletions ufo/automator/app_apis/shell/shell_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,19 @@
re.compile(r"\bAdd-Type\b", re.IGNORECASE),
re.compile(r"\b(cmd|powershell|pwsh)(\.exe)?\s+[/-]", re.IGNORECASE),
re.compile(r"[|;&`]\s*(bash|sh|cmd|powershell|pwsh)", re.IGNORECASE),
# .NET static-member / type-accelerator invocation, e.g.
# ``[System.Diagnostics.Process]::Start('calc')`` or
# ``[System.IO.File]::WriteAllText(...)``. PowerShell evaluates the
# ``-Command`` string as a full script, so a plain-parenthesised .NET
# call carried by an allow-listed cmdlet would otherwise reach arbitrary
# code execution (CWE-184). ``::`` has no legitimate use in the
# read-only cmdlet surface and is blocked outright.
re.compile(r"::"),
# Arbitrary object instantiation and dynamic method/scriptblock
# invocation that can be used to reach .NET primitives by other names.
re.compile(r"\bNew-Object\b", re.IGNORECASE),
re.compile(r"\.Invoke\b", re.IGNORECASE),
re.compile(r"[&.]\s*[({]", re.IGNORECASE), # call/dot-source operator
re.compile(r"\bNew-Service\b|\bsc\.exe\b", re.IGNORECASE),
re.compile(r"\breg(\.exe)?\s+(add|delete|import)", re.IGNORECASE),
re.compile(r"\bschtasks(\.exe)?\b", re.IGNORECASE),
Expand Down Expand Up @@ -371,6 +384,18 @@ def run_shell(self, params: Dict[str, Any]) -> Any:
r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"
)

# Force the PowerShell child process into ConstrainedLanguage mode.
# This is the primary runtime control: even if a crafted string slips
# past the allow-list / denylist, ConstrainedLanguage blocks arbitrary
# .NET type access and method invocation (e.g.
# ``[System.Diagnostics.Process]::Start`` or
# ``[System.IO.File]::WriteAllText``), which is the only way an
# allow-listed cmdlet can be escalated into code execution. The
# allow-list and dangerous-pattern denylist remain as
# defense-in-depth.
child_env = os.environ.copy()
child_env["__PSLockdownPolicy"] = "4"

try:
# Use shell=False with an explicit argument list.
# PowerShell's -NoProfile -NonInteractive flags prevent profile
Expand All @@ -388,6 +413,7 @@ def run_shell(self, params: Dict[str, Any]) -> Any:
shell=False,
text=True,
cwd=self.current_directory,
env=child_env,
)

stdout, stderr = process.communicate(timeout=timeout)
Expand Down