Skip to content

Commit 1f105bf

Browse files
committed
Model shell -c commands
1 parent 20f36e0 commit 1f105bf

7 files changed

Lines changed: 292 additions & 71 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
category: minorAnalysis
3+
---
4+
* Direct `-c` command arguments to recognized POSIX shell interpreters through `os.exec*`,
5+
`os.spawn*`, `os.posix_spawn*`, and `subprocess` APIs are now treated as command-injection
6+
sinks.

python/ql/lib/semmle/python/frameworks/Stdlib.qll

Lines changed: 125 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1171,27 +1171,67 @@ module StdlibPrivate {
11711171
override predicate isShellInterpreted(DataFlow::Node arg) { arg = this.getCommand() }
11721172
}
11731173

1174+
/**
1175+
* Holds if `flag` makes `interpreter` execute the following argument as a command.
1176+
*
1177+
* See https://docs.python.org/3/library/subprocess.html#popen-constructor.
1178+
*/
1179+
private predicate isShellCommandFlag(DataFlow::Node interpreter, DataFlow::Node flag) {
1180+
interpreter.asExpr().(StringLiteral).getText().regexpMatch("(.*/)?(sh|bash|dash|zsh)") and
1181+
flag.asExpr().(StringLiteral).getText() = "-c"
1182+
}
1183+
1184+
/** Gets the command from separate interpreter, flag, and command arguments. */
1185+
private DataFlow::Node getShellCommandFromArguments(
1186+
DataFlow::Node interpreter, DataFlow::Node flag, DataFlow::Node command
1187+
) {
1188+
isShellCommandFlag(interpreter, flag) and
1189+
result = command
1190+
}
1191+
1192+
/** Gets the command from an argument sequence passed to `interpreter`. */
1193+
private DataFlow::Node getShellCommandFromSequence(
1194+
DataFlow::Node interpreter, DataFlow::Node arguments
1195+
) {
1196+
exists(SequenceNode sequence, DataFlow::Node flag |
1197+
arguments.asCfgNode() = sequence and
1198+
flag.asCfgNode() = sequence.getElement(1) and
1199+
isShellCommandFlag(interpreter, flag) and
1200+
result.asCfgNode() = sequence.getElement(2)
1201+
)
1202+
}
1203+
11741204
/**
11751205
* A call to any of the `os.exec*` functions
11761206
* See https://docs.python.org/3.8/library/os.html#os.execl
11771207
*/
11781208
private class OsExecCall extends SystemCommandExecution::Range, FileSystemAccess::Range,
11791209
DataFlow::CallCfgNode
11801210
{
1211+
string name;
1212+
11811213
OsExecCall() {
1182-
exists(string name |
1183-
name in ["execl", "execle", "execlp", "execlpe", "execv", "execve", "execvp", "execvpe"] and
1184-
this = os().getMember(name).getACall()
1185-
)
1214+
name in ["execl", "execle", "execlp", "execlpe", "execv", "execve", "execvp", "execvpe"] and
1215+
this = os().getMember(name).getACall()
1216+
}
1217+
1218+
private DataFlow::Node getShellCommand() {
1219+
name in ["execl", "execlp"] and
1220+
result = getShellCommandFromArguments(this.getArg(0), this.getArg(2), this.getArg(3))
1221+
or
1222+
name in ["execle", "execlpe"] and
1223+
exists(this.getArg(4)) and
1224+
result = getShellCommandFromArguments(this.getArg(0), this.getArg(2), this.getArg(3))
1225+
or
1226+
name in ["execv", "execve", "execvp", "execvpe"] and
1227+
result = getShellCommandFromSequence(this.getArg(0), this.getArg(1))
11861228
}
11871229

1188-
override DataFlow::Node getCommand() { result = this.getArg(0) }
1230+
override DataFlow::Node getCommand() { result in [this.getArg(0), this.getShellCommand()] }
11891231

1190-
override DataFlow::Node getAPathArgument() { result = this.getCommand() }
1232+
override DataFlow::Node getAPathArgument() { result = this.getArg(0) }
11911233

1192-
override predicate isShellInterpreted(DataFlow::Node arg) {
1193-
none() // this is a safe API.
1194-
}
1234+
override predicate isShellInterpreted(DataFlow::Node arg) { arg = this.getShellCommand() }
11951235
}
11961236

11971237
/**
@@ -1201,28 +1241,44 @@ module StdlibPrivate {
12011241
private class OsSpawnCall extends SystemCommandExecution::Range, FileSystemAccess::Range,
12021242
DataFlow::CallCfgNode
12031243
{
1244+
string name;
1245+
12041246
OsSpawnCall() {
1205-
exists(string name |
1206-
name in [
1207-
"spawnl", "spawnle", "spawnlp", "spawnlpe", "spawnv", "spawnve", "spawnvp", "spawnvpe"
1208-
] and
1209-
this = os().getMember(name).getACall()
1210-
)
1247+
name in [
1248+
"spawnl", "spawnle", "spawnlp", "spawnlpe", "spawnv", "spawnve", "spawnvp", "spawnvpe"
1249+
] and
1250+
this = os().getMember(name).getACall()
12111251
}
12121252

1213-
override DataFlow::Node getCommand() {
1253+
private DataFlow::Node getInterpreter() {
12141254
result = this.getArg(1)
12151255
or
12161256
// `file` keyword argument only valid for the `v` variants, but this
12171257
// over-approximation is not hurting anyone, and is easy to implement.
12181258
result = this.getArgByName("file")
12191259
}
12201260

1221-
override DataFlow::Node getAPathArgument() { result = this.getCommand() }
1261+
private DataFlow::Node getArguments() { result in [this.getArg(2), this.getArgByName("args")] }
12221262

1223-
override predicate isShellInterpreted(DataFlow::Node arg) {
1224-
none() // this is a safe API.
1263+
private DataFlow::Node getShellCommand() {
1264+
name in ["spawnl", "spawnlp"] and
1265+
result = getShellCommandFromArguments(this.getInterpreter(), this.getArg(3), this.getArg(4))
1266+
or
1267+
name in ["spawnle", "spawnlpe"] and
1268+
exists(this.getArg(5)) and
1269+
result = getShellCommandFromArguments(this.getInterpreter(), this.getArg(3), this.getArg(4))
1270+
or
1271+
name in ["spawnv", "spawnve", "spawnvp", "spawnvpe"] and
1272+
result = getShellCommandFromSequence(this.getInterpreter(), this.getArguments())
1273+
}
1274+
1275+
override DataFlow::Node getCommand() {
1276+
result in [this.getInterpreter(), this.getShellCommand()]
12251277
}
1278+
1279+
override DataFlow::Node getAPathArgument() { result = this.getInterpreter() }
1280+
1281+
override predicate isShellInterpreted(DataFlow::Node arg) { arg = this.getShellCommand() }
12261282
}
12271283

12281284
/**
@@ -1234,13 +1290,23 @@ module StdlibPrivate {
12341290
{
12351291
OsPosixSpawnCall() { this = os().getMember(["posix_spawn", "posix_spawnp"]).getACall() }
12361292

1237-
override DataFlow::Node getCommand() { result in [this.getArg(0), this.getArgByName("path")] }
1293+
private DataFlow::Node getInterpreter() {
1294+
result in [this.getArg(0), this.getArgByName("path")]
1295+
}
12381296

1239-
override DataFlow::Node getAPathArgument() { result = this.getCommand() }
1297+
private DataFlow::Node getArguments() { result in [this.getArg(1), this.getArgByName("argv")] }
12401298

1241-
override predicate isShellInterpreted(DataFlow::Node arg) {
1242-
none() // this is a safe API.
1299+
private DataFlow::Node getShellCommand() {
1300+
result = getShellCommandFromSequence(this.getInterpreter(), this.getArguments())
1301+
}
1302+
1303+
override DataFlow::Node getCommand() {
1304+
result in [this.getInterpreter(), this.getShellCommand()]
12431305
}
1306+
1307+
override DataFlow::Node getAPathArgument() { result = this.getInterpreter() }
1308+
1309+
override predicate isShellInterpreted(DataFlow::Node arg) { arg = this.getShellCommand() }
12441310
}
12451311

12461312
/** An additional taint step for calls to `os.path.join` */
@@ -1267,13 +1333,11 @@ module StdlibPrivate {
12671333
* ref: https://docs.python.org/3/library/subprocess.html#legacy-shell-invocation-functions
12681334
*/
12691335
private class SubprocessPopenCall extends SystemCommandExecution::Range, API::CallNode {
1336+
string name;
1337+
12701338
SubprocessPopenCall() {
1271-
exists(string name |
1272-
name in [
1273-
"Popen", "call", "check_call", "check_output", "run", "getoutput", "getstatusoutput"
1274-
] and
1275-
this = subprocess().getMember(name).getACall()
1276-
)
1339+
name in ["Popen", "call", "check_call", "check_output", "run", "getoutput", "getstatusoutput"] and
1340+
this = subprocess().getMember(name).getACall()
12771341
}
12781342

12791343
/** Gets the API-node for the `args` argument, if any. */
@@ -1296,20 +1360,43 @@ module StdlibPrivate {
12961360
/** Gets the API-node for the `executable` argument, if any. */
12971361
private API::Node get_executable_arg() { result = this.getParameter(2, "executable") }
12981362

1363+
/** Holds if the `executable` argument overrides the executable from `args`. */
1364+
private predicate hasExecutableOverride() {
1365+
exists(DataFlow::Node executable |
1366+
executable = this.get_executable_arg().asSink() and
1367+
not executable.asExpr() instanceof None
1368+
)
1369+
}
1370+
1371+
/** Gets the interpreter that will execute `args`, if it can be determined. */
1372+
private DataFlow::Node getInterpreter() {
1373+
this.hasExecutableOverride() and
1374+
result = this.get_executable_arg().asSink()
1375+
or
1376+
not this.hasExecutableOverride() and
1377+
result.asCfgNode() = this.get_args_arg().asSink().asCfgNode().(SequenceNode).getElement(0)
1378+
}
1379+
1380+
/** Gets a shell command passed in the `args` sequence. */
1381+
private DataFlow::Node getShellCommand() {
1382+
name in ["Popen", "call", "check_call", "check_output", "run"] and
1383+
this.get_shell_arg_value() = false and
1384+
result = getShellCommandFromSequence(this.getInterpreter(), this.get_args_arg().asSink())
1385+
}
1386+
12991387
override DataFlow::Node getCommand() {
1300-
// TODO: Track arguments ("args" and "shell")
1301-
// TODO: Handle using `args=["sh", "-c", <user-input>]`
1388+
this.hasExecutableOverride() and
13021389
result = this.get_executable_arg().asSink()
13031390
or
13041391
exists(DataFlow::Node arg_args, boolean shell |
13051392
arg_args = this.get_args_arg().asSink() and
13061393
shell = this.get_shell_arg_value()
13071394
|
1308-
// When "executable" argument is set, and "shell" argument is `False`, the
1309-
// "args" argument will only be used to set the program name and arguments to
1395+
// When the `executable` argument overrides `args[0]`, and `shell` is `False`, the
1396+
// `args` argument will only be used to set the program name and arguments to
13101397
// the program, so we should not consider any of them as command execution.
13111398
not (
1312-
exists(this.get_executable_arg()) and
1399+
this.hasExecutableOverride() and
13131400
shell = false
13141401
) and
13151402
(
@@ -1327,11 +1414,15 @@ module StdlibPrivate {
13271414
result = arg_args
13281415
)
13291416
)
1417+
or
1418+
result = this.getShellCommand()
13301419
}
13311420

13321421
override predicate isShellInterpreted(DataFlow::Node arg) {
13331422
arg = [this.get_executable_arg(), this.get_args_arg()].asSink() and
13341423
this.get_shell_arg_value() = true
1424+
or
1425+
arg = this.getShellCommand()
13351426
}
13361427
}
13371428

python/ql/test/library-tests/frameworks/stdlib/SystemCommandExecution.py

Lines changed: 48 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -105,30 +105,61 @@ def os_members():
105105
########################################
106106
# actively using known shell as the executable
107107

108-
subprocess.Popen(["/bin/sh", "-c", "vuln"]) # $ getCommand="/bin/sh" MISSING: getCommand="vuln"
109-
subprocess.Popen(["/bin/bash", "-c", "vuln"]) # $ getCommand="/bin/bash" MISSING: getCommand="vuln"
110-
subprocess.Popen(["/bin/dash", "-c", "vuln"]) # $ getCommand="/bin/dash" MISSING: getCommand="vuln"
111-
subprocess.Popen(["/bin/zsh", "-c", "vuln"]) # $ getCommand="/bin/zsh" MISSING: getCommand="vuln"
112-
113-
subprocess.Popen(["sh", "-c", "vuln"]) # $ getCommand="sh" MISSING: getCommand="vuln"
114-
subprocess.Popen(["bash", "-c", "vuln"]) # $ getCommand="bash" MISSING: getCommand="vuln"
115-
subprocess.Popen(["dash", "-c", "vuln"]) # $ getCommand="dash" MISSING: getCommand="vuln"
116-
subprocess.Popen(["zsh", "-c", "vuln"]) # $ getCommand="zsh" MISSING: getCommand="vuln"
108+
subprocess.Popen(["/bin/sh", "-c", "vuln"]) # $ getCommand="/bin/sh" getCommand="vuln"
109+
subprocess.Popen(["/bin/bash", "-c", "vuln"]) # $ getCommand="/bin/bash" getCommand="vuln"
110+
subprocess.Popen(["/bin/dash", "-c", "vuln"]) # $ getCommand="/bin/dash" getCommand="vuln"
111+
subprocess.Popen(["/bin/zsh", "-c", "vuln"]) # $ getCommand="/bin/zsh" getCommand="vuln"
112+
113+
subprocess.Popen(["sh", "-c", "vuln"]) # $ getCommand="sh" getCommand="vuln"
114+
subprocess.Popen(["bash", "-c", "vuln"]) # $ getCommand="bash" getCommand="vuln"
115+
subprocess.Popen(["dash", "-c", "vuln"]) # $ getCommand="dash" getCommand="vuln"
116+
subprocess.Popen(["zsh", "-c", "vuln"]) # $ getCommand="zsh" getCommand="vuln"
117+
subprocess.run(("/usr/local/bin/sh", "-c", "vuln")) # $ getCommand="/usr/local/bin/sh" getCommand="vuln"
118+
subprocess.run(args=["sh", "-c", "vuln"]) # $ getCommand="sh" getCommand="vuln"
119+
subprocess.Popen(["sh", "-c", "vuln", "not-command"]) # $ getCommand="sh" getCommand="vuln"
117120

118121
# Check that we don't consider ANY argument a command injection sink
119122
subprocess.Popen(["sh", "/bin/python"]) # $ getCommand="sh"
123+
subprocess.Popen(["sh", "--command", "not-vuln"]) # $ getCommand="sh"
124+
subprocess.Popen(["sh", "-C", "not-vuln"]) # $ getCommand="sh"
125+
subprocess.Popen(["not-a-shell", "-c", "not-vuln"]) # $ getCommand="not-a-shell"
126+
subprocess.Popen(["sh", "-c"]) # $ getCommand="sh"
127+
subprocess.Popen(["sh", "-c", "not-vuln"], shell=True) # $ getCommand="sh"
128+
subprocess.getoutput(["sh", "-c", "not-vuln"]) # $ getCommand="sh"
129+
subprocess.getstatusoutput(("sh", "-c", "not-vuln")) # $ getCommand="sh"
120130

121131
subprocess.Popen(["cmd.exe", "/c", "vuln"]) # $ getCommand="cmd.exe" MISSING: getCommand="vuln"
122132
subprocess.Popen(["cmd.exe", "/C", "vuln"]) # $ getCommand="cmd.exe" MISSING: getCommand="vuln"
123133
subprocess.Popen(["cmd", "/c", "vuln"]) # $ getCommand="cmd" MISSING: getCommand="vuln"
124134
subprocess.Popen(["cmd", "/C", "vuln"]) # $ getCommand="cmd" MISSING: getCommand="vuln"
125135

126-
subprocess.Popen(["<progname>", "-c", "vuln"], executable="/bin/bash") # $ getCommand="/bin/bash" MISSING: getCommand="vuln"
136+
subprocess.Popen(["<progname>", "-c", "vuln"], executable="/bin/bash") # $ getCommand="/bin/bash" getCommand="vuln"
137+
subprocess.Popen(["sh", "-c", "not-vuln"], executable="/bin/echo") # $ getCommand="/bin/echo"
138+
subprocess.Popen(["sh", "-c", "vuln"], executable=None) # $ getCommand="sh" getCommand="vuln"
127139

128140
if UNKNOWN:
129-
os.execl("/bin/sh", "<progname>", "-c", "vuln") # $ getCommand="/bin/sh" getAPathArgument="/bin/sh" MISSING: getCommand="vuln"
130-
131-
os.spawnl(os.P_WAIT, "/bin/sh", "<progname>", "-c", "vuln") # $ getCommand="/bin/sh" getAPathArgument="/bin/sh" MISSING: getCommand="vuln"
141+
os.execl("/bin/sh", "<progname>", "-c", "vuln") # $ getCommand="/bin/sh" getAPathArgument="/bin/sh" getCommand="vuln"
142+
os.execle("/bin/sh", "<progname>", "-c", "vuln", env) # $ getCommand="/bin/sh" getAPathArgument="/bin/sh" getCommand="vuln"
143+
os.execlp("sh", "<progname>", "-c", "vuln") # $ getCommand="sh" getAPathArgument="sh" getCommand="vuln"
144+
os.execlpe("sh", "<progname>", "-c", "vuln", env) # $ getCommand="sh" getAPathArgument="sh" getCommand="vuln"
145+
os.execv("/bin/sh", ("<progname>", "-c", "vuln")) # $ getCommand="/bin/sh" getAPathArgument="/bin/sh" getCommand="vuln"
146+
os.execvp("sh", ["<progname>", "-c", "vuln"]) # $ getCommand="sh" getAPathArgument="sh" getCommand="vuln"
147+
148+
os.execl("/bin/sh", "<progname>", "--command", "not-vuln") # $ getCommand="/bin/sh" getAPathArgument="/bin/sh"
149+
os.execle("/bin/sh", "<progname>", "-c", env) # $ getCommand="/bin/sh" getAPathArgument="/bin/sh"
150+
os.execlpe("sh", "<progname>", "-c", env) # $ getCommand="sh" getAPathArgument="sh"
151+
os.execv("not-a-shell", ["<progname>", "-c", "not-vuln"]) # $ getCommand="not-a-shell" getAPathArgument="not-a-shell"
152+
153+
os.spawnl(os.P_WAIT, "/bin/sh", "<progname>", "-c", "vuln") # $ getCommand="/bin/sh" getAPathArgument="/bin/sh" getCommand="vuln"
154+
os.spawnle(os.P_WAIT, "/bin/sh", "<progname>", "-c", "vuln", env) # $ getCommand="/bin/sh" getAPathArgument="/bin/sh" getCommand="vuln"
155+
os.spawnle(os.P_WAIT, "/bin/sh", "<progname>", "-c", env) # $ getCommand="/bin/sh" getAPathArgument="/bin/sh"
156+
os.spawnlpe(os.P_WAIT, "sh", "<progname>", "-c", "vuln", env) # $ getCommand="sh" getAPathArgument="sh" getCommand="vuln"
157+
os.spawnlpe(os.P_WAIT, "sh", "<progname>", "-c", env) # $ getCommand="sh" getAPathArgument="sh"
158+
os.spawnv(os.P_WAIT, "/bin/sh", ["<progname>", "-c", "vuln"]) # $ getCommand="/bin/sh" getAPathArgument="/bin/sh" getCommand="vuln"
159+
os.spawnv(mode=os.P_WAIT, file="/bin/sh", args=["<progname>", "-c", "vuln"]) # $ getCommand="/bin/sh" getAPathArgument="/bin/sh" getCommand="vuln"
160+
os.posix_spawn("/bin/sh", ["<progname>", "-c", "vuln"], env) # $ getCommand="/bin/sh" getAPathArgument="/bin/sh" getCommand="vuln"
161+
os.posix_spawn(path="/bin/sh", argv=["<progname>", "-c", "vuln"], env=env) # $ getCommand="/bin/sh" getAPathArgument="/bin/sh" getCommand="vuln"
162+
os.posix_spawnp("sh", ["<progname>", "-c", "vuln"], env) # $ getCommand="sh" getAPathArgument="sh" getCommand="vuln"
132163

133164

134165
########################################
@@ -137,6 +168,10 @@ def os_members():
137168
args = ["/bin/sh", "-c", "vuln"]
138169
subprocess.Popen(args) # $ getCommand=args
139170

171+
exec_args = ["<progname>", "-c", "vuln"] # $ MISSING: getCommand="vuln"
172+
if UNKNOWN:
173+
os.execv("/bin/sh", exec_args) # $ getCommand="/bin/sh" getAPathArgument="/bin/sh"
174+
140175
args = "<progname>"
141176
use_shell = False
142177
exe = "executable"

0 commit comments

Comments
 (0)