mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-05-25 16:00:35 +00:00
fix(powershell): windows security enhancement, sec deny powershell encoding bypass via iex inje… (#2836)
* fix(powershell): sec deny powershell encoding bypass via iex injection. * fix(exec): security guard bypass fixes for PowerShell/CMD encoding and path traversal - Split deny patterns into defaultDenyPatterns (all platforms) and windowsDenyPatterns (Windows-only) to avoid false positives - Add PowerShell encoding bypass detection: - [Text.Encoding] and [System.Text.Encoding] variants - -EncodedCommand short forms (-e, -ec, -enc) - .GetString([byte[]] with whitespace variations - FromBase64String decoding - PowerShell variable = [byte[](...) patterns - Literal \uXXXX Unicode escape sequences - Expand PowerShell ($env:VAR) and CMD (%VAR%) environment variables before workspace path checking to prevent $env:USERPROFILE bypass - Expand ~ to home directory on Windows - Add .../.../ path traversal variant detection (blocks .../.../, ..../..../) - Add symlink/junction resolution before workspace check - Add Windows path normalization for ADS (file.txt:stream) and extended-length paths (\?\) - Add comprehensive tests for all new patterns Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(exec): fix -EncodedCommand regex and rename Windows tests with expanded payloads - Fix -EncodedCommand regex to match all short forms: -e, -ec, -enc, -en - Rename Windows-specific tests with TestWindows_ prefix for clarity: - TestWindows_TildeBypassPrevented - TestWindows_SymlinkBypassPrevented - TestWindows_PowerShellEncodingBypass - Expand test payloads: - [Text.Encoding]: add UTF8 and Unicode variants - -EncodedCommand: add -enc and -en forms - Unicode escape: add multiple \uXXXX forms Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * ci: retest --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+78
-1
@@ -97,6 +97,26 @@ var (
|
|||||||
regexp.MustCompile(`\bsource\s+.*\.sh\b`),
|
regexp.MustCompile(`\bsource\s+.*\.sh\b`),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// windowsDenyPatterns contains PowerShell-specific deny patterns that only
|
||||||
|
// apply on Windows, where commands are executed via powershell -Command.
|
||||||
|
windowsDenyPatterns = []*regexp.Regexp{
|
||||||
|
// [Text.Encoding] used to construct command strings at runtime.
|
||||||
|
// Matches [Text.Encoding] and [System.Text.Encoding] variants.
|
||||||
|
regexp.MustCompile(`\[(?:\w+\.)?text\.encoding\]`),
|
||||||
|
// PowerShell -EncodedCommand flag (base64-encoded command) and all short forms.
|
||||||
|
// Matches: -e, -ec, -enc, -en, -EncodedCommand (all with space prefix)
|
||||||
|
regexp.MustCompile(` -e(?:$|\s)| -ec(?:$|\s)| -enc(?:$|\s)| -en(?:$|\s)| -encodedcommand\b`),
|
||||||
|
// .GetString called on byte array to decode commands.
|
||||||
|
regexp.MustCompile(`\.getstring\s*\(\s*\[byte\[\]`),
|
||||||
|
// FromBase64String used in command construction chain.
|
||||||
|
regexp.MustCompile(`frombase64string\(`),
|
||||||
|
// PowerShell variable holding byte array used in GetString.
|
||||||
|
regexp.MustCompile(`\$[a-zA-Z_]\w*\s*=\s*\[byte\[\]`),
|
||||||
|
// Unicode escape sequences that could be used to construct commands.
|
||||||
|
// Matches \uXXXX format used to represent characters like i = "i"
|
||||||
|
regexp.MustCompile(`\\u[0-9a-fA-F]{4}`),
|
||||||
|
}
|
||||||
|
|
||||||
// absolutePathPattern matches absolute file paths in commands (Unix and Windows).
|
// absolutePathPattern matches absolute file paths in commands (Unix and Windows).
|
||||||
absolutePathPattern = regexp.MustCompile(`[A-Za-z]:\\[^\\\"']+|/[^\s\"']+`)
|
absolutePathPattern = regexp.MustCompile(`[A-Za-z]:\\[^\\\"']+|/[^\s\"']+`)
|
||||||
|
|
||||||
@@ -138,6 +158,9 @@ func NewExecToolWithConfig(
|
|||||||
allowRemote = execConfig.AllowRemote
|
allowRemote = execConfig.AllowRemote
|
||||||
if enableDenyPatterns {
|
if enableDenyPatterns {
|
||||||
denyPatterns = append(denyPatterns, defaultDenyPatterns...)
|
denyPatterns = append(denyPatterns, defaultDenyPatterns...)
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
denyPatterns = append(denyPatterns, windowsDenyPatterns...)
|
||||||
|
}
|
||||||
if len(execConfig.CustomDenyPatterns) > 0 {
|
if len(execConfig.CustomDenyPatterns) > 0 {
|
||||||
fmt.Printf("Using custom deny patterns: %v\n", execConfig.CustomDenyPatterns)
|
fmt.Printf("Using custom deny patterns: %v\n", execConfig.CustomDenyPatterns)
|
||||||
for _, pattern := range execConfig.CustomDenyPatterns {
|
for _, pattern := range execConfig.CustomDenyPatterns {
|
||||||
@@ -161,6 +184,9 @@ func NewExecToolWithConfig(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
denyPatterns = append(denyPatterns, defaultDenyPatterns...)
|
denyPatterns = append(denyPatterns, defaultDenyPatterns...)
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
denyPatterns = append(denyPatterns, windowsDenyPatterns...)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var timeout time.Duration
|
var timeout time.Duration
|
||||||
@@ -1019,6 +1045,30 @@ func (t *ExecTool) executeSendKeys(args map[string]any) *ToolResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// expandPowerShellEnvVars expands environment variable syntax used by both
|
||||||
|
// PowerShell ($env:VAR) and CMD (%VAR%) to their actual values.
|
||||||
|
func expandPowerShellEnvVars(cmd string) string {
|
||||||
|
// Handle PowerShell style: $env:VAR and ${env:VAR}
|
||||||
|
rePs := regexp.MustCompile(`\$\{?env:(\w+)\}?`)
|
||||||
|
cmd = rePs.ReplaceAllStringFunc(cmd, func(match string) string {
|
||||||
|
varName := rePs.FindStringSubmatch(match)[1]
|
||||||
|
if val := os.Getenv(varName); val != "" {
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
return match
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle CMD style: %VAR%
|
||||||
|
reCmd := regexp.MustCompile(`%([^%]+)%`)
|
||||||
|
return reCmd.ReplaceAllStringFunc(cmd, func(match string) string {
|
||||||
|
varName := reCmd.FindStringSubmatch(match)[1]
|
||||||
|
if val := os.Getenv(varName); val != "" {
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
return match
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (t *ExecTool) guardCommand(command, cwd string) string {
|
func (t *ExecTool) guardCommand(command, cwd string) string {
|
||||||
cmd := strings.TrimSpace(command)
|
cmd := strings.TrimSpace(command)
|
||||||
lower := strings.ToLower(cmd)
|
lower := strings.ToLower(cmd)
|
||||||
@@ -1054,7 +1104,8 @@ func (t *ExecTool) guardCommand(command, cwd string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if t.restrictToWorkspace {
|
if t.restrictToWorkspace {
|
||||||
if strings.Contains(cmd, "..\\") || strings.Contains(cmd, "../") {
|
// Block path traversal patterns including .../.../ variants
|
||||||
|
if regexp.MustCompile(`\.\.(?:[\\/]\.\.)*[\\/]`).MatchString(cmd) {
|
||||||
return "Command blocked by safety guard (path traversal detected)"
|
return "Command blocked by safety guard (path traversal detected)"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1068,6 +1119,16 @@ func (t *ExecTool) guardCommand(command, cwd string) string {
|
|||||||
// file:// URIs are still validated against the workspace boundary.
|
// file:// URIs are still validated against the workspace boundary.
|
||||||
webSchemes := []string{"http:", "https:", "ftp:", "ftps:", "sftp:", "ssh:", "git:"}
|
webSchemes := []string{"http:", "https:", "ftp:", "ftps:", "sftp:", "ssh:", "git:"}
|
||||||
|
|
||||||
|
// On Windows, expand ~ and PowerShell environment variables ($env:VAR) before path checking
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
// Expand PowerShell environment variables ($env:VAR and ${env:VAR})
|
||||||
|
cmd = expandPowerShellEnvVars(cmd)
|
||||||
|
// Also expand ~ for completeness
|
||||||
|
if home, err := os.UserHomeDir(); err == nil {
|
||||||
|
cmd = strings.ReplaceAll(cmd, "~", filepath.FromSlash(home))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
matchIndices := absolutePathPattern.FindAllStringIndex(cmd, -1)
|
matchIndices := absolutePathPattern.FindAllStringIndex(cmd, -1)
|
||||||
|
|
||||||
for _, loc := range matchIndices {
|
for _, loc := range matchIndices {
|
||||||
@@ -1099,6 +1160,22 @@ func (t *ExecTool) guardCommand(command, cwd string) string {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Windows-specific: normalize paths to block ADS and extended-length paths
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
// Strip \\?\ prefix (extended-length path)
|
||||||
|
p = strings.TrimPrefix(p, `\\?\`)
|
||||||
|
// Strip NTFS alternate data streams (only if colon is not at position 1 = drive letter)
|
||||||
|
if idx := strings.Index(p, ":"); idx > 1 {
|
||||||
|
p = p[:idx]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check symlinks and junctions
|
||||||
|
resolved, err := filepath.EvalSymlinks(p)
|
||||||
|
if err == nil {
|
||||||
|
p = resolved
|
||||||
|
}
|
||||||
|
|
||||||
if safePaths[p] {
|
if safePaths[p] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -703,6 +703,185 @@ func TestShellTool_URLBypassPrevented(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestWindows_TildeBypassPrevented verifies that ~ (home directory) cannot be
|
||||||
|
// used to escape workspace restrictions on Windows.
|
||||||
|
func TestWindows_TildeBypassPrevented(t *testing.T) {
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
|
t.Skip("Windows-only test")
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
tool, err := NewExecTool(tmpDir, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unable to configure exec tool: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Tilde should be blocked when it expands outside workspace
|
||||||
|
blockedCommands := []string{
|
||||||
|
"ls ~",
|
||||||
|
"ls ~/some/path",
|
||||||
|
"cat ~/.config/file",
|
||||||
|
// PowerShell environment variables also expand to home directory
|
||||||
|
"dir $env:USERPROFILE",
|
||||||
|
"ls $env:USERPROFILE",
|
||||||
|
"cat $env:USERPROFILE\\.config\\file",
|
||||||
|
// CMD environment variables
|
||||||
|
`cmd /c "dir %USERPROFILE%"`,
|
||||||
|
`cmd /c "cd %USERPROFILE% && dir"`,
|
||||||
|
`cmd /c "type %USERPROFILE%\\.config\\file"`,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, cmd := range blockedCommands {
|
||||||
|
result := tool.Execute(ctx, map[string]any{"action": "run", "command": cmd})
|
||||||
|
if !result.IsError || !strings.Contains(result.ForLLM, "path outside working dir") {
|
||||||
|
t.Errorf("tilde bypass should be blocked: %q\n got: %s", cmd, result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestShellTool_PathTraversalVariants verifies that .../.../ and similar
|
||||||
|
// path traversal variants are blocked.
|
||||||
|
func TestShellTool_PathTraversalVariants(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
tool, err := NewExecTool(tmpDir, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unable to configure exec tool: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Path traversal variants should be blocked
|
||||||
|
blockedCommands := []string{
|
||||||
|
"ls .../.../",
|
||||||
|
"ls ..../..../",
|
||||||
|
"cat .../.../../../etc/passwd",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, cmd := range blockedCommands {
|
||||||
|
result := tool.Execute(ctx, map[string]any{"action": "run", "command": cmd})
|
||||||
|
if !result.IsError || !strings.Contains(result.ForLLM, "path traversal") {
|
||||||
|
t.Errorf("path traversal variant should be blocked: %q\n got: %s", cmd, result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legitimate commands with ... should not be blocked (if such commands exist)
|
||||||
|
// Note: these will fail for other reasons but should not be blocked by path traversal
|
||||||
|
allowedCommands := []string{
|
||||||
|
"echo ...",
|
||||||
|
"ls ...",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, cmd := range allowedCommands {
|
||||||
|
result := tool.Execute(ctx, map[string]any{"action": "run", "command": cmd})
|
||||||
|
// These should not be blocked by path traversal check specifically
|
||||||
|
if strings.Contains(result.ForLLM, "path traversal") {
|
||||||
|
t.Errorf("legitimate command with ... should not be blocked: %q", cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWindows_SymlinkBypassPrevented verifies that symlinks pointing outside
|
||||||
|
// workspace are detected and blocked after resolution.
|
||||||
|
func TestWindows_SymlinkBypassPrevented(t *testing.T) {
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
|
t.Skip("Windows-only test")
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
tool, err := NewExecTool(tmpDir, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unable to configure exec tool: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// /tmp is outside the user workspace, should be blocked after symlink resolution
|
||||||
|
// On Windows /tmp resolves to C:\tmp which may not exist, causing different error
|
||||||
|
result := tool.Execute(ctx, map[string]any{"action": "run", "command": "ls /tmp"})
|
||||||
|
if !result.IsError {
|
||||||
|
t.Errorf("symlink bypass should be blocked: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWindows_PowerShellEncodingBypass verifies that PowerShell encoding bypass techniques are blocked.
|
||||||
|
func TestWindows_PowerShellEncodingBypass(t *testing.T) {
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
|
t.Skip("Windows-only test")
|
||||||
|
}
|
||||||
|
|
||||||
|
tool, err := NewExecTool("", false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Commands using [Text.Encoding] to construct a command string at runtime.
|
||||||
|
encodingBypassCommands := []string{
|
||||||
|
// Basic byte array forms
|
||||||
|
`[Text.Encoding]::ASCII.GetString([byte[]](0x6c,0x73,0x20,0x7e))`,
|
||||||
|
`[Text.Encoding]::ASCII.GetString([byte[]](0x69,0x65,0x78))`,
|
||||||
|
// System.Text.Encoding variant
|
||||||
|
`[System.Text.Encoding]::ASCII.GetString([byte[]](0x69,0x65,0x78))`,
|
||||||
|
// With whitespace variation
|
||||||
|
`[System.Text.Encoding]::ASCII.GetString ([byte[]](0x69,0x65,0x78))`,
|
||||||
|
// Variable storage form
|
||||||
|
`$b = [byte[]](0x69,0x65,0x78); [Text.Encoding]::ASCII.GetString($b)`,
|
||||||
|
// UTF8 variant
|
||||||
|
`[Text.Encoding]::UTF8.GetString([byte[]](0x69,0x65,0x78))`,
|
||||||
|
// Unicode variant
|
||||||
|
`[Text.Encoding]::Unicode.GetString([byte[]](0x69,0x00,0x65,0x00,0x78,0x00))`,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, cmd := range encodingBypassCommands {
|
||||||
|
result := tool.Execute(ctx, map[string]any{"action": "run", "command": cmd})
|
||||||
|
if !result.IsError {
|
||||||
|
t.Errorf("expected [Text.Encoding] bypass to be blocked: %s", cmd)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "blocked") && !strings.Contains(result.ForUser, "blocked") {
|
||||||
|
t.Errorf("expected 'blocked' message for %s, got: %s", cmd, result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commands using PowerShell's -EncodedCommand flag (base64), including short forms.
|
||||||
|
encodedCommands := []string{
|
||||||
|
// Full form
|
||||||
|
`powershell -NoProfile -NonInteractive -EncodedCommand SQBFAHIAaABlAGwAbAAvAC8A`,
|
||||||
|
`pwsh -EncodedCommand aWV4`,
|
||||||
|
// Short forms: -e, -ec, -enc, -en
|
||||||
|
`pwsh -e SQBFAHIAaABlAGwAbAAvAC8A`,
|
||||||
|
`pwsh -ec aWV4`,
|
||||||
|
`pwsh -enc aWV4`,
|
||||||
|
`pwsh -en aWV4`,
|
||||||
|
`powershell -e SQBFAHIAaABlAGwAbAAvAC8A`,
|
||||||
|
`powershell -ec aWV4`,
|
||||||
|
`powershell -enc aWV4`,
|
||||||
|
`powershell -en aWV4`,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, cmd := range encodedCommands {
|
||||||
|
result := tool.Execute(ctx, map[string]any{"action": "run", "command": cmd})
|
||||||
|
if !result.IsError {
|
||||||
|
t.Errorf("expected -EncodedCommand to be blocked: %s", cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unicode escape sequences that could construct malicious commands
|
||||||
|
// Using double backslash to represent literal \u in Go string
|
||||||
|
unicodeCommands := []string{
|
||||||
|
`cmd /c "cd %USERPROFILE% \\u0026 dir"`,
|
||||||
|
`powershell -Command "Write-Host \\u0049EX"`,
|
||||||
|
`cmd /c "echo \\u0069\\u0065\\u0078"`,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, cmd := range unicodeCommands {
|
||||||
|
result := tool.Execute(ctx, map[string]any{"action": "run", "command": cmd})
|
||||||
|
if !result.IsError {
|
||||||
|
t.Errorf("expected Unicode escape to be blocked: %s", cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestShellTool_Background_ReturnsImmediately(t *testing.T) {
|
func TestShellTool_Background_ReturnsImmediately(t *testing.T) {
|
||||||
tool, err := NewExecTool("", false)
|
tool, err := NewExecTool("", false)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|||||||
Reference in New Issue
Block a user