Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,17 @@

separators = [" ", "\t", "\n", ";", "&", "|", "(", ")", "<", ">", "\r", "\f"]

# Patterns that can expand to create separators at runtime
# These are shell expansion patterns that can be used to bypass literal separator checks
expansion_patterns = [
"${IFS}", # Internal Field Separator (whitespace by default)
"$IFS", # IFS without braces
"${IFS%?}", # IFS with parameter expansion (removes shortest suffix)
"${IFS#?}", # IFS with parameter expansion (removes shortest prefix)
"${IFS%%?}", # IFS with parameter expansion (removes longest suffix)
"${IFS##?}", # IFS with parameter expansion (removes longest prefix)
]


# Function to sort commands by length (longer commands first)
def by_length(a, b):
Expand Down Expand Up @@ -138,6 +149,12 @@ def contains_shell_syntax(command, user_input):
if any(c in user_input for c in dangerous_chars):
return True

# Check if user input contains shell expansion patterns that can create separators
# e.g., ${IFS}, $IFS which expand to whitespace at runtime
for pattern in expansion_patterns:
if pattern in user_input:
return True

# The command is the same as the user input
# Rare case, but it's possible
# e.g. command is `shutdown` and user input is `shutdown`
Expand All @@ -162,6 +179,44 @@ def contains_shell_syntax(command, user_input):
char_before = command[match.start() - 1] if match.start() > 0 else None
char_after = command[match.end()] if match.end() < len(command) else None

# Check if preceded by an expansion pattern that can create a separator
# e.g. `true;${IFS}whoami` where ${IFS} expands to whitespace
if match.start() > 0:
for pattern in expansion_patterns:
pattern_start = match.start() - len(pattern)
if pattern_start >= 0:
preceding_text = command[pattern_start : match.start()]
if preceding_text == pattern:
# Found an expansion pattern immediately before the matched command
# Check if there's a separator before the expansion pattern
char_before_expansion = (
command[pattern_start - 1] if pattern_start > 0 else None
)
if (
char_before_expansion in separators
or char_before_expansion is None
):
return True

# Check if followed by an expansion pattern that can create a separator
# e.g. `whoami${IFS}echo` where ${IFS} expands to whitespace
if match.end() < len(command):
for pattern in expansion_patterns:
pattern_end = match.end() + len(pattern)
if pattern_end <= len(command):
following_text = command[match.end() : pattern_end]
if following_text == pattern:
# Found an expansion pattern immediately after the matched command
# Check if there's a separator after the expansion pattern
char_after_expansion = (
command[pattern_end] if pattern_end < len(command) else None
)
if (
char_after_expansion in separators
or char_after_expansion is None
):
return True

# Check surrounding characters
if char_before in separators and char_after in separators:
# e.g. `<separator>rm<separator>`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,3 +161,30 @@ def test_form_feed_as_separator():
assert contains_shell_syntax("ls\frm", "rm") is True
assert contains_shell_syntax("echo test\frm -rf /", "rm") is True
assert contains_shell_syntax("rm\fls", "rm") is True


def test_detects_shell_expansion_separators():
# Test ${IFS} expansion that creates runtime separators
assert contains_shell_syntax("true;${IFS}whoami", "whoami") is True
assert contains_shell_syntax("echo${IFS}whoami", "whoami") is True
assert contains_shell_syntax("ls;${IFS}rm", "rm") is True

# Test $IFS expansion (without braces)
assert contains_shell_syntax("true;$IFS whoami", "whoami") is True
assert contains_shell_syntax("echo$IFS rm", "rm") is True

# Test IFS with parameter expansion (various forms)
assert contains_shell_syntax("true;${IFS%?}whoami", "whoami") is True
assert contains_shell_syntax("true;${IFS#?}whoami", "whoami") is True
assert contains_shell_syntax("true;${IFS%%?}whoami", "whoami") is True
assert contains_shell_syntax("true;${IFS##?}whoami", "whoami") is True

# Test expansion patterns in user input itself
assert contains_shell_syntax("cmd ${IFS}whoami", "${IFS}whoami") is True
assert contains_shell_syntax("cmd $IFS", "$IFS") is True
assert contains_shell_syntax("cmd ${IFS%?}", "${IFS%?}") is True

# Test expansion after command
assert contains_shell_syntax("whoami${IFS}echo", "whoami") is True
assert contains_shell_syntax("rm${IFS%?}ls", "rm") is True
assert contains_shell_syntax("rm${IFS##?}ls", "rm") is True
Original file line number Diff line number Diff line change
Expand Up @@ -426,3 +426,25 @@ def test_form_feed_as_separator_between_commands():
is_shell_injection("ls\frm", "rm")
is_shell_injection("echo test\frm -rf /", "rm")
is_shell_injection("rm\fls", "rm")


def test_detects_shell_expansion_bypass():
# Test the specific bypass case from the security report
is_shell_injection("true;${IFS}whoami", "whoami")

# Test various IFS expansion patterns
is_shell_injection("echo${IFS}whoami", "whoami")
is_shell_injection("ls;${IFS}rm", "rm")
is_shell_injection("true;$IFS whoami", "whoami")
is_shell_injection("cmd;${IFS%?}whoami", "whoami")
is_shell_injection("cmd;${IFS#?}whoami", "whoami")
is_shell_injection("cmd;${IFS%%?}whoami", "whoami")
is_shell_injection("cmd;${IFS##?}whoami", "whoami")

# Test expansion patterns in user input
is_shell_injection("cmd ${IFS}whoami", "${IFS}whoami")
is_shell_injection("cmd $IFS", "$IFS")

# Test expansion after command
is_shell_injection("whoami${IFS}echo", "whoami")
is_shell_injection("rm${IFS%?}ls", "rm")
Loading