|
| 1 | +/** |
| 2 | + * Provides modeling for the `IO` module. |
| 3 | + */ |
| 4 | + |
| 5 | +private import codeql.ruby.ApiGraphs |
| 6 | +private import codeql.ruby.Concepts |
| 7 | +private import codeql.ruby.DataFlow |
| 8 | +private import codeql.ruby.controlflow.CfgNodes |
| 9 | + |
| 10 | +/** Provides modeling for the `IO` class. */ |
| 11 | +module IO { |
| 12 | + /** |
| 13 | + * A system command executed via the `IO.popen` method. |
| 14 | + * Signature: |
| 15 | + * ``` |
| 16 | + * popen([env,] cmd, mode="r" [, opt]) -> io |
| 17 | + * popen([env,] cmd, mode="r" [, opt]) {|io| block } -> obj |
| 18 | + * ``` |
| 19 | + * `IO.popen` does different things based on the the value of `cmd`: |
| 20 | + * ``` |
| 21 | + * "-" : fork |
| 22 | + * commandline : command line string which is passed to a shell |
| 23 | + * [env, cmdname, arg1, ..., opts] : command name and zero or more arguments (no shell) |
| 24 | + * [env, [cmdname, argv0], arg1, ..., opts] : command name, argv[0] and zero or more arguments (no shell) |
| 25 | + * (env and opts are optional.) |
| 26 | + * ``` |
| 27 | + * ```ruby |
| 28 | + * IO.popen("cat foo.txt | tail") |
| 29 | + * IO.popen({some_env_var: "123"}, "cat foo.txt | tail") |
| 30 | + * IO.popen(["cat", "foo.txt"]) |
| 31 | + * IO.popen([{some_env_var: "123"}, "cat", "foo.txt"]) |
| 32 | + * IO.popen([["cat", "argv0"], "foo.txt"]) |
| 33 | + * IO.popen([{some_env_var: "123"}, ["cat", "argv0"], "foo.txt"]) |
| 34 | + * ``` |
| 35 | + * Ruby documentation: https://docs.ruby-lang.org/en/3.1.0/IO.html#method-c-popen |
| 36 | + */ |
| 37 | + class POpenCall extends SystemCommandExecution::Range, DataFlow::CallNode { |
| 38 | + POpenCall() { this = API::getTopLevelMember("IO").getAMethodCall("popen") } |
| 39 | + |
| 40 | + override DataFlow::Node getAnArgument() { this.argument(result, _) } |
| 41 | + |
| 42 | + override predicate isShellInterpreted(DataFlow::Node arg) { this.argument(arg, true) } |
| 43 | + |
| 44 | + /** |
| 45 | + * A helper predicate that holds if `arg` is an argument to this call. `shell` is true if the argument is passed to a subshell. |
| 46 | + */ |
| 47 | + private predicate argument(DataFlow::Node arg, boolean shell) { |
| 48 | + exists(ExprCfgNode n | n = arg.asExpr() | |
| 49 | + // Exclude any hash literal arguments, which are likely to be environment variables or options. |
| 50 | + not n instanceof ExprNodes::HashLiteralCfgNode and |
| 51 | + not n instanceof ExprNodes::ArrayLiteralCfgNode and |
| 52 | + ( |
| 53 | + // IO.popen({var: "a"}, "cmd", {some: :opt}) |
| 54 | + arg = this.getArgument([0, 1]) and |
| 55 | + // We over-approximate by assuming a subshell if the argument isn't an array or "-". |
| 56 | + // This increases the sensitivity of the CommandInjection query at the risk of some FPs. |
| 57 | + if n.getConstantValue().getString() = "-" then shell = false else shell = true |
| 58 | + or |
| 59 | + // IO.popen({var: "a"}, [{var: "b"}, "cmd", "arg1", "arg2", {some: :opt}]) |
| 60 | + shell = false and |
| 61 | + exists(ExprNodes::ArrayLiteralCfgNode arr | this.getArgument([0, 1]).asExpr() = arr | |
| 62 | + n = arr.getAnArgument() |
| 63 | + or |
| 64 | + // IO.popen({var: "a"}, [{var: "b"}, ["cmd", "argv0"], "arg1", "arg2", {some: :opt}]) |
| 65 | + n = arr.getArgument(0).(ExprNodes::ArrayLiteralCfgNode).getArgument(0) |
| 66 | + ) |
| 67 | + ) |
| 68 | + ) |
| 69 | + } |
| 70 | + } |
| 71 | +} |
0 commit comments