So if you just take away the libcall, people will make their own version by just doing execl() of /bin/sh. If you want this to change, I think you have to ask why do people want to do this in the first place.
And the answer here is basically that because of the unix design philosophy, the shell is immensely useful. There are all these cool, small utilities and tricks you can use in lieu of writing a lot of extra code. On Windows, command-line conventions, filesystem quirks, and escaping gotchas are actually more numerous. It's just that there's almost nothing to call, so you get fewer bugs.
The most practical way to make this class of bugs go away is to make the unix shell less useful.
strace -o tmp.spawnlp -ff python3 -c 'import os; os.spawnlp(os.P_WAIT, "true", "true")'
In parent: clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7fdc03233310) = 225954
wait4(225954, [{WIFEXITED(s) && WEXITSTATUS(s) == 0}], 0, NULL) = 225954
--- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=225954, si_uid=1000, si_status=0, si_utime=0, si_stime=0} ---
In child: set_robust_list(0x7fdc03233320, 24) = 0
gettid() = 225954
clock_gettime(CLOCK_MONOTONIC, {tv_sec=2458614, tv_nsec=322829153}) = 0
clock_gettime(CLOCK_MONOTONIC, {tv_sec=2458614, tv_nsec=323030718}) = 0
execve("/usr/local/bin/true", ["true"], 0x7ffdc5008458 /* 44 vars */) = -1 ENOENT (No such file or directory)
execve("/usr/bin/true", ["true"], 0x7ffdc5008458 /* 44 vars */) = 0
Here, I think strace shows clone() rather than fork() because glibc's fork() is a library function that invokes clone(), rather than a real system call.Good. How do you pipeline commands with these?
p1 = Popen(["dmesg"], stdout=PIPE)
p2 = Popen(["grep", "hda"], stdin=p1.stdout, stdout=PIPE)
p1.stdout.close() # Allow p1 to receive a SIGPIPE if p2 exits.
output = p2.communicate()[0]
Of course, now, nobody has an hda, and dmesg is root-only. A more modern example is in http://canonical.org/~kragen/sw/dev3/whereroot.py: p1 = subprocess.Popen(["df"], stdout=subprocess.PIPE)
p2 = subprocess.Popen(["grep", "/$"], stdin=p1.stdout, stdout=subprocess.PIPE)
p1.stdout.close()
return p2.communicate()[0]
Note that the result here is a byte string, so if you want to print it out safely without the shell-like bugginess induced by Python's default character handling (what happens if the device name isn't valid UTF-8?), you have to do backflips with sys.stdout.buffer or UTF-8B.Python got a lot of things wrong, and it gets worse all the time, but for now spawning subprocesses is one of the things it got right. Although, unlike IIRC Tcl, it doesn't raise an exception by default if one of the commands fails.
Apart from the semantics of the operations, you could of course desire a better notation for them. In Python you could maybe achieve something like
(cmd(["df"]) | ["grep", "/$"]).output()
but that is secondary to being able to safely handle arguments containing spaces and pipes and whatnot.docker run --rm -it ...?
Now run a container doing the exact same thing ("docker-in-docker").
docker run --rm -it -v $DOCKER_HOST:/var/run/docker.sock ...?
Never used docker before, but this seems to work:
docker run --rm -it debian bash -c 'vim -- "$1"' _ "$user_provided_path"The version I tested was
docker run --rm -it debian bash -c 'apt update; apt install -y vim; vim -- "$1"' _ "$user_provided_path"I tried printing positional parameters, they looked fine. (And already uninstalled docker. What's the point of containerization if you need superuser privileges to use it?)
> if $user_provided_path is empty, it defaults to browsing the filesystem
That's what
vim -- ""
does.> But there are a lot of characters there that are specifically there to avoid footguns
What are those characters? --? That's not a lot
Shell scripts are much higher in footguns per character than most programming languages.
It is possible for a coder to understand bash so well that he never shoots his own foot off, but it requires more learning hours than the same feat in another language requires, and unless I've also put in the (many) learning hours, I have no way of knowing whether a shell script written by someone I don't know contains security vulnerabilities or fragility when dealing with unusual inputs that will surface in unpredictable circumstances.
The traditional Unix shell might be the most overrated tool on HN.
What are the footguns in this?
df | grep /$Allow me to correct my previous comment: using Python's os.spawnlp or Popen to invoke Unix commands has fewer footguns than using the shell. (Kragen's code only addresses your question of how to set up a pipeline in Python.)
exec* are not "better replacements" of the shell, they are just used for different use cases.
The whole article could be summarized to 3 bullet points:
1) Sanitize your inputs
2) If you want to execute a specific program, exec it after 1), no need for the shell
3) Allow the shell if there is no injection risk
There's two ways to think of "running a command:"
1. A list of strings containing an executable name (which may or may not be a complete path) and its arguments (think C's const char **argv).
2. A single string which is a space-separated list of arguments, with special characters in arguments (including spaces) requiring quoting to represent correctly.
Conversion between these two forms is non trivial. And the basic problem is that there's a lot of tools which incorrectly convert the former to the latter by just concatenating all of the arguments into a single string and inserting spaces. Part of the problem is that shell script itself makes doing the conversion difficult, but the end effect is that if you have to with commands with inputs that have special characters (including, but not limited to, spaces), you end up just going slowly insane trying to figure out how to get the quoting right to work around the broken tools.
In my experience, the world is so much easier if your own tools just break everything up into the list-of-strings model and you never to try to use an API that requires single-string model.
What GP is referring to is the fact that that solution doesn't work as well on Windows, because the OS's native idea of a command line isn't list-of-strings but rather a single-string, and how that single string is broken up into a list-of-strings is dependent on the application being invoked.
In python you have "shlex.quote" and "shlex.join". In bash, you have "${env@Q}". I've found those to work wonderfully to me - and I did crazy things like quote arguments, embed into shell script, quote script again for ssh, and quote 3rd time to produce executable .sh file.
In other languages.. yeah, you are going to have bad time. Especially on Windows, where I'd just give up and move to WSL.
In particular, failure to mention `printf -v` is horrible. Not only is it better performing than creating a whole process for command substitution, it also avoids the nasty newline problem.
You don't need to handle any quoting with exec*(). You still need to handle options, yes. But under Windows you always have to to handle the quoting yourself and it is more difficult than for the POSIX shell and it is program dependent. Without knowing what program is executed you can't know what quoting syntax you have to use and as such a standard library cannot write a generic interface to pass arguments to another process in a safe way under Windows.
I just felt it sounded like POSIX is particularly bad in that context, while in fact it is better than Windows here. Still, the system() function is a mistake. Use posix_spawn(). (Note: Do not use _spawn*() under Windows. That just concatenates the arguments with a space between and no quoting whatsoever.)
They are entirely different interfaces though. If you'd implemented system() using posix_spawn() it'd be just as bad as system()
If you say you don't make such mistakes: Yeah, but people do. People that write the code that runs on your system.
Force user to always create a wrapper script? that's just extra annoyance and if user is bad at quoting, they'll have the same problems with a script
Disable hooks at all? that's bad functionality regression
Ask for multiple arguments? this makes command-line parsing much more awkward.. I have not seen any good solutions for that.
(The only exception is writing a command wrapper that takes exactly 1 user command, like "timeout" or "xargs".. but those already using argument vector instead of parsing)
Then, you parse that out into a proper argument array and pass it to exec*/posix_spawn.
To correctly escape arbitrary shell syntax, not only do you need to handle the full POSIX syntax (which is quite complex) …
https://pubs.opengroup.org/onlinepubs/9799919799/utilities/V...
… but you must also cover any bugs and undocumented/underspecified extensions implemented by the actual shell providing /bin/sh on every platform and platform version to which your code will be deployed.
That’s not just difficult — it’s impossible, and everyone that has tried has failed, repeatedly. Leading to security bugs, repeatedly.
https://gist.github.com/Zenexer/40d02da5e07f151adeaeeaa11af9...
There’s a reason why we use parameterized queries instead of escaping to prevent SQL injection, and SQL syntax and parsing behavior is far more rigorously specified than the shell.
But it also doesn't matter how complex the standard is, it is a standard and one with much preexisting support. Your "standard" will end up being different for each application.
Doing things the way you seem to prefer is just willful negligence at this point.
https://github.com/openjdk/jdk/blob/jdk-26%2B1/src/java.base...
But i would be lying if i said i understood what was going on there. Some googling suggests this was added around 1.7, ie in the early 2010s.
But then, that Rust CVE seems to originate in this work, and this guy claims Java said "won't fix", which suggests it is vulnerable:
https://flatt.tech/research/posts/batbadbut-you-cant-securel...
But there's no link, and i can't find any discussion about it, so i don't know what the actual situation is.