-
Notifications
You must be signed in to change notification settings - Fork 39
[Coding Guideline] Prevent OS Command Injection #370
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 6 commits
6aa11aa
5b41984
b338384
2c0c53b
bcac33d
8bf8735
8a31c99
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,117 @@ | ||
| .. SPDX-License-Identifier: MIT OR Apache-2.0 | ||
| SPDX-FileCopyrightText: The Coding Guidelines Subcommittee Contributors | ||
|
|
||
| .. guideline:: Prevent OS Command Injection | ||
| :id: gui_a3PpM90Fppwh | ||
| :category: mandatory | ||
| :status: draft | ||
| :release: 1.0.0-latest | ||
| :fls: fls_hdwwrsyunir | ||
| :decidability: undecidable | ||
| :scope: module | ||
| :tags: injection,sanitization | ||
|
|
||
| Commands that are passed to an external OS command interpreter, like ``std::process::Command``, should not allow untrusted input to be parsed as part of the command syntax. | ||
|
|
||
| Instead, an untrusted input should be passed as a single argument. | ||
|
|
||
| .. rationale:: | ||
| :id: rat_IaAZISFOmAt0 | ||
| :status: draft | ||
|
|
||
| This rule was inspired by :cite:`gui_a3PpM90Fppwh:CERT-J-IDS07`. | ||
|
|
||
| When preparing a command to be executed by the operating system, untrusted input should be sanitized to make sure it does not alter the syntax of the command to be executed. For commands that do not tokenize their arguments, such as ``sh``, the easiest way to do this is to avoid mixing untrusted data with trusted data via concatenation or formatting (a la ``format!()``). Instead provide the untrusted data as a lone argument. The ``Command::new()`` constructor makes this easy by accepting the pre-tokenized arguments as a list of strings. | ||
|
|
||
| Traditionally untrusted data should be one argument (aka command-line token). OS command injection occurs when a malicious data fools the command tokenizer into interpreting it as multiple arguments, or even multiple commands. Complexity in the command tokenizer can exacerbate this problem, leading to vulnerabilities such as :cite:`gui_a3PpM90Fppwh:CVE-2024-24576`. See :cite:`gui_a3PpM90Fppwh:RUST-WIN-ARG-SPLIT` and :cite:`gui_a3PpM90Fppwh:SEI-BATBADBUT` for more information. | ||
|
|
||
| .. non_compliant_example:: | ||
| :id: non_compl_ex_Owe2nVInv90z | ||
| :status: draft | ||
|
|
||
| The following code lists the contents the directory provided in the ``dir`` variable. However, since this variable is untrusted, a ``dir`` such as ``dummy | echo BOO`` will cause the command to be executed. Thus, the program prints “BOO”. | ||
|
|
||
| .. rust-example:: | ||
|
|
||
| use std::process::{Command, Output}; | ||
| use std::io; | ||
|
|
||
| fn files(dir: &str) -> io::Result<Output> { | ||
| return Command::new("sh") | ||
| .arg("-c") | ||
| .arg(format!("ls {dir}")) | ||
| .output(); | ||
| } | ||
|
|
||
| fn main() { | ||
| if cfg!(unix) { | ||
| let _ = files("dummy | echo BOO"); // Program prints "BOO" | ||
| } | ||
| } | ||
|
|
||
|
|
||
| .. compliant_example:: | ||
| :id: compl_ex_rJeLKhdopITN | ||
| :status: draft | ||
|
|
||
| An untrusted input should be passed as a single argument. This prevents any spaces or other shell punctuation in the input from being misinterpreted by the OS command interpreter. | ||
|
|
||
| .. rust-example:: | ||
|
|
||
| use std::process::{Command, Output}; | ||
| use std::io; | ||
|
|
||
| fn files(dir: &str) -> io::Result<Output> { | ||
| return Command::new("ls") | ||
| .arg(dir) | ||
| .output(); | ||
| } | ||
|
|
||
| fn main() { | ||
| if cfg!(unix) { | ||
| let _ = files("dummy | echo BOO"); // Command is invalid, but does not print BOO | ||
| } | ||
| } | ||
|
Comment on lines
+64
to
+76
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Following the examples from CERT J, it would also be good to show the corresponding non-compliant code in Windows for the final guideline
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I've tried to make the code more platform-independent. AFAICT the Windows-specific code would be identical, except s/ls/dir/g;. |
||
|
|
||
|
|
||
| .. compliant_example:: | ||
| :id: compl_ex_BSjAFOLfL4Rk | ||
| :status: draft | ||
|
|
||
| A better approach is to avoid OS commands and use a specific API (in this case ``fs::read_dir()``) to achieve the desired result. | ||
|
|
||
| .. rust-example:: | ||
|
|
||
| use std::fs; | ||
| use std::io; | ||
|
|
||
| fn files(dir: &str) -> io::Result<Vec<std::ffi::OsString>> { | ||
| return fs::read_dir(dir)? | ||
| .map(|res| res.map(|e| e.file_name())) | ||
| .collect(); | ||
| } | ||
|
|
||
| fn main() { | ||
| if cfg!(unix) { | ||
| let _ = files("dummy | echo BOO"); // Command is invalid, but does not print BOO | ||
| } | ||
| } | ||
|
|
||
|
|
||
| .. bibliography:: | ||
| :id: bib_CNrst9CcDVQJ | ||
| :status: draft | ||
|
|
||
| .. list-table:: | ||
| :header-rows: 0 | ||
| :widths: auto | ||
| :class: bibliography-table | ||
|
|
||
| * - :bibentry:`gui_a3PpM90Fppwh:CERT-J-IDS07` | ||
| - SEI CERT Java. "IDS07-J. Sanitize untrusted data passed to the Runtime.exec() method." https://wiki.sei.cmu.edu/confluence/x/xTdGBQ | ||
| * - :bibentry:`gui_a3PpM90Fppwh:RUST-WIN-ARG-SPLIT` | ||
| - Module process. "Windows Argument Splitting." https://doc.rust-lang.org/std/process/index.html#windows-argument-splitting | ||
| * - :bibentry:`gui_a3PpM90Fppwh:SEI-BATBADBUT` | ||
| - SEI Blog. "What Recent Vulnerabilities Mean to Rust | “BatBadBut” Command Injection with Windows’ cmd.exe (CVE-2024-24576)." https://www.sei.cmu.edu/blog/what-recent-vulnerabilities-mean-to-rust/ | ||
| * - :bibentry:`gui_a3PpM90Fppwh:CVE-2024-24576` | ||
| - MITRE. "CVE-2024-24576." https://nvd.nist.gov/vuln/detail/CVE-2024-24576 | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In my view,
Commandis actually performing this, the problem is when it is used withsh-c, meaning theCommandruns the interpreter. I think one should never actually run the interpreter, rather than taking care of the parameters.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hello, @alexandruradovici
I would agree that running the "sh" interpreter enables the command injection here. There are two general approaches: first, you would sanitize the arguments to prevent anything malicious from happening. Second, you use a less powerful command, such as invoking 'ls' directly without 'sh', or using fs::read_dir()...both compliant solutions use this second technique.
Are you suggesting a change in the wording, or in the code examples?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My suggestion is to use a more powerful command and advise agains running the interpreter (
shor equivalent) with any command. I would not relate it to the arguments, but to the what kind of commands should or should not be ran.Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wonder how we should do this.
Something that seems simple enough to do is a deny-list:
That criteria could be something along the lines of...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ultimately, This is a Rust rule. I don't want the rule to blacklist some commands, because we can't make that blacklist complete. Too many shell commands + too many OS platforms. Creating a whitelist of 'benign' commands is safer, but no less Herculean a problem.
You also have the problem of when arguments are tokenized on Windows (see the BatBadBut vul).
We could try to limit commands in terms of Turing-completeness, but that also sounds like a huge rabbit hole. Which of these commands would we be forbidden from running: bash, python, sqlite3, jq, gcc ?
The only other option I see would be to forbid Commands entirely (at least for security-critical code). Allow exceptions only on an individual basis. Or allow Commands only in unsafe code.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As an addition to this, I do agree that creating a whitelist is much more feasible, especially when taking into accounts commands such as
python3,ruby,gcc, etc.The way I think should be handled is to pair it with an appropriate enum group of allowed commands (and their arguments). I think this could also be beneficial in case a program needs to run specific commands in parts of the code so that these could be somewhat module-locked (ie.
python3can be run exclusively in a test module paired with#[cfg(test)]or one where you need to externally call Python without using PyO3 or equivalent).Unfortunately which commands should be whitelisted will mostly rely on a case-by-case basis so we cannot really outright suggest "accept this and not this", but within a program's scope the team in charge of designing and building said program should have mapped adequately which commands they'll need to call; the advantage of grouping them in an enum will also make verifying them through match blocks a bit easier than manually scanning the whole codebase.