Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions java/ql/lib/change-notes/2026-10-16-delegating-launchers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
category: minorAnalysis
---
* The `java/command-line-injection` query now detects command injection through simple invocations of delegating launchers such as `env`, `sudo`, and `timeout`.
14 changes: 9 additions & 5 deletions java/ql/lib/semmle/code/java/JDK.qll
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,14 @@ class StringPartialMatchMethod extends Method {
}
}

/** The type `String[]`. */
class ArrayOfStringType extends Array {
ArrayOfStringType() {
this.getDimension() = 1 and
this.getElementType() instanceof TypeString
}
}

/** The class `java.lang.StringBuffer`. */
class TypeStringBuffer extends Class {
TypeStringBuffer() { this.hasQualifiedName("java.lang", "StringBuffer") }
Expand Down Expand Up @@ -444,11 +452,7 @@ class MainMethod extends Method {
this.getReturnType().hasName("void") and
this.hasName("main") and
this.getNumberOfParameters() = 1 and
exists(Array a |
a = this.getAParameter().getType() and
a.getDimension() = 1 and
a.getElementType() instanceof TypeString
)
this.getAParameter().getType() instanceof ArrayOfStringType
}
}

Expand Down
232 changes: 193 additions & 39 deletions java/ql/lib/semmle/code/java/security/CommandArguments.qll
Original file line number Diff line number Diff line change
Expand Up @@ -10,46 +10,152 @@

/**
* Holds if `ex` is used safely as an argument to a command;
* i.e. it's not in the first position and it's not a shell command.
* i.e. it's not in a position that selects a command to execute.
*/
predicate isSafeCommandArgument(Expr ex) {
exists(ArrayInit ai, int i |
ex = ai.getInit(i) and
i > 0 and
not isShell(ai.getInit(0))
exists(ArrayInit init, int index |
ex = init.getInit(index) and
index > 0 and
isNonDelegatingCommand(init.getInit(0))
)
or
exists(CommandArgumentList cal |
not cal.isShell() and
ex = cal.getASubsequentAdd().getArgument(0)
exists(CommandArgumentList list |
not list.isShell() and
not list.isDelegatingLauncher() and
ex = list.getASubsequentAdd().getArgument(0)
)
or
exists(CommandArgArrayImmutableFirst caa |
not caa.isShell() and
ex = caa.getAWrite(any(int i | i > 0))
exists(CommandArgArrayImmutableFirst array |
not array.isShell() and
not array.isDelegatingLauncher() and
ex = array.getAWrite(any(int index | index > 0))
)
or
exists(CommandArguments arguments, int i |
ex = arguments.getArgument(i) and
forall(CommandArguments otherArguments, int otherIndex |
ex = otherArguments.getArgument(otherIndex)
|
otherIndex > 0 and otherArguments.hasSafeDelegatedArgument(otherIndex)
)
)
}

/**
* Holds if the given expression is the name of a shell command such as bash or python
*/
private predicate isShell(Expr ex) {
exists(string cmd | cmd = ex.(StringLiteral).getValue() |
cmd.regexpMatch(".*(sh|javac?|python[23]?|osascript|cmd)(\\.exe)?$")
)
private string commandName(Expr ex) {
result = ex.(StringLiteral).getValue()
or
exists(SsaDefinition ssa, SsaExplicitWrite def |
ex = ssa.getARead() and
isShell(def.getDefiningExpr())
result = commandName(def.getDefiningExpr())
|
ssa.getAnUltimateDefinition() = def
or
ssa.(SsaCapturedDefinition).getAnUltimateCapturedDefinition() = def
)
or
isShell(ex.(Assignment).getRhs())
result = commandName(ex.(Assignment).getRhs())
or
isShell(ex.(LocalVariableDeclExpr).getInit())
result = commandName(ex.(LocalVariableDeclExpr).getInit())
}

/**
* Holds if the given expression is the name of a shell command such as bash or python
*/
private predicate isShell(Expr ex) {
commandName(ex).regexpMatch(".*(sh|javac?|python[23]?|osascript|cmd)(\\.exe)?$")
}

/**
* Gets the fixed position of the command executed by a supported delegating launcher.
*
* Only simple invocation forms with a command at the expected position are recognized. Launcher
* options and environment assignments are intentionally not interpreted.
*/
private int delegatedCommandIndex(Expr ex) {
exists(string launcher |
launcher = commandName(ex).regexpCapture("(.*/)?([^/.]+)(\\.exe)?$", 2) and
(
launcher = ["env", "nice", "nohup", "setsid", "sudo", "xargs"] and result = 1
or
launcher = ["chroot", "stdbuf", "timeout"] and result = 2
)
)
}

private predicate isNonDelegatingCommand(Expr ex) {
exists(commandName(ex)) and
not isShell(ex) and
not exists(delegatedCommandIndex(ex))
}

/** Holds if `init` initializes an array passed directly to a call. */
private predicate isInlineArgumentArray(ArrayInit init) {
exists(ArrayCreationExpr array, Call call |
array.getInit() = init and
array = call.getAnArgument() and
not exists(MethodCall methodCall |
call = methodCall and
methodCall.getMethod().getDeclaringType().hasQualifiedName("java.util", "Arrays") and
methodCall.getMethod().hasName("copyOf")
)
)
}

private newtype TCommandArguments =
TLiteralArguments(ArrayInit init) { isInlineArgumentArray(init) } or
TListArguments(CommandArgumentList list) or
TMutableArguments(CommandArgArrayImmutableFirst array)

/** An ordered sequence of command arguments. */
private class CommandArguments extends TCommandArguments {
string toString() { result = this.getArgument(0).toString() }

/** Gets a possible value at the given index. */
Expr getArgument(int index) {
exists(ArrayInit init |
this = TLiteralArguments(init) and
result = init.getInit(index)
)
or
exists(CommandArgumentList list |
this = TListArguments(list) and
(
result = list.getArgument(index)
or
index = 3 and result = list.getAnArgumentAfterKnownPrefix().getArgument(0)
)
)
or
exists(CommandArgArrayImmutableFirst array |
this = TMutableArguments(array) and
result = array.getAnElement(index)
)
}

/** Gets the single string literal stored at the given index, if there is one. */
private StringLiteral getFixedLiteralArgument(int index) {
result = this.getArgument(index) and
forall(Expr value | value = this.getArgument(index) | value = result)
}

predicate hasSafeDelegatedArgument(int index) {
exists(this.getArgument(index)) and
forall(Expr launcher | launcher = this.getArgument(0) |
exists(delegatedCommandIndex(launcher)) and
forall(int commandIndex | commandIndex = delegatedCommandIndex(launcher) |
(
index < commandIndex
or
index > commandIndex and
exists(StringLiteral command |
command = this.getFixedLiteralArgument(commandIndex) and
not isShell(command) and
not exists(delegatedCommandIndex(command))
)
)
)
)
}
}

/**
Expand Down Expand Up @@ -101,6 +207,39 @@
result.getQualifier() = this.getAUseBeforeFirstAdd()
}

/** Gets a single-element addition at the given index. */
private MethodCall getAnAddAtIndex(int index) {
index = 0 and
result = this.getAFirstAdd() and
result.getMethod().hasName("add") and
result.getNumArgument() = 1
or
index > 0 and
index <= 2 and
exists(MethodCall previous |
previous = this.getAnAddAtIndex(index - 1) and
result = this.getAnAdd() and
result.getMethod().hasName("add") and
result.getNumArgument() = 1 and
adjacentUseUse(previous.getQualifier(), result.getQualifier())
)
}

/** Gets a single-element addition after the first three elements. */
MethodCall getAnArgumentAfterKnownPrefix() {
result = this.getAnAdd() and
result.getMethod().hasName("add") and
result.getNumArgument() = 1 and
exists(MethodCall previous |
previous = this.getAnAddAtIndex(2) or previous = this.getAnArgumentAfterKnownPrefix()
|
adjacentUseUse(previous.getQualifier(), result.getQualifier())
)
}

/** Gets the argument added at the given index. */
Expr getArgument(int index) { result = this.getAnAddAtIndex(index).getArgument(0) }

/** Gets an addition to this list which is not the first element. */
MethodCall getASubsequentAdd() {
result = this.getAnAdd() and
Expand All @@ -109,15 +248,15 @@

/** Holds if the first element of this list is a shell command. */
predicate isShell() {
exists(MethodCall ma | ma = this.getAFirstAdd() and isShell(ma.getArgument(0)))
exists(MethodCall add | add = this.getAFirstAdd() and isShell(add.getArgument(0)))
}
}

/**
* The type `String[]`.
*/
private class ArrayOfStringType extends Array {
ArrayOfStringType() { this.getElementType() instanceof TypeString }
/** Holds if the first element of this list is a delegating launcher. */
predicate isDelegatingLauncher() {
exists(MethodCall add | add = this.getAFirstAdd() |
exists(delegatedCommandIndex(add.getArgument(0)))
)
}
}

private predicate arrayVarWrite(ArrayAccess acc) { exists(Assignment a | a.getDest() = acc) }
Expand Down Expand Up @@ -153,7 +292,7 @@
*/
private class CommandArgArrayImmutableFirst extends CommandArgumentArray {
CommandArgArrayImmutableFirst() {
(exists(this.getAWrite(0)) or exists(firstElementOf(this.getDefiningExpr()))) and
(exists(this.getAWrite(0)) or exists(arrayElementOf(this.getDefiningExpr(), 0))) and
forall(VarRead use | exists(this.getAWrite(0, use)) | use = ssaGetAFirstUse(this))
}

Expand All @@ -162,41 +301,56 @@
result = this.getAWrite(0)
or
not exists(this.getAWrite(0)) and
result = firstElementOf(this.getDefiningExpr())
result = arrayElementOf(this.getDefiningExpr(), 0)
}

/** Holds if the first element of this array is a shell command. */
predicate isShell() { isShell(this.getFirstElement()) }

/** Holds if the first element of this array is a delegating launcher. */
predicate isDelegatingLauncher() { exists(delegatedCommandIndex(this.getFirstElement())) }

/** Gets a possible value for the element at the given index. */
Expr getAnElement(int index) {
index = 0 and result = this.getFirstElement()
or
index > 0 and
(
result = this.getAWrite(index)
or
result = arrayElementOf(this.getDefiningExpr(), index)
)
}
}

/** Gets the first element of an imutable array of strings */
private Expr firstElementOf(Expr arr) {
/** Gets an element of an immutable array of strings. */
private Expr arrayElementOf(Expr arr, int index) {
arr.getType() instanceof ArrayOfStringType and
(
result = firstElementOf(arr.(Assignment).getRhs())
result = arrayElementOf(arr.(Assignment).getRhs(), index)
or
result = firstElementOf(arr.(LocalVariableDeclExpr).getInit())
result = arrayElementOf(arr.(LocalVariableDeclExpr).getInit(), index)
or
exists(CommandArgArrayImmutableFirst caa | arr = caa.getARead() |
result = caa.getFirstElement()
result = caa.getAnElement(index)
)
or
exists(MethodCall ma, Method m |
arr = ma and
ma.getMethod() = m and
m.getDeclaringType().hasQualifiedName("java.util", "Arrays") and
m.hasName("copyOf") and
result = firstElementOf(ma.getArgument(0))
result = arrayElementOf(ma.getArgument(0), index)
)
or
exists(Field f |
f.isStatic() and
arr.(FieldRead).getField() = f and
result = firstElementOf(f.getInitializer())
result = arrayElementOf(f.getInitializer(), index)
)
or
result = arr.(ArrayInit).getInit(0)
result = arr.(ArrayInit).getInit(index)
or
result = arr.(ArrayCreationExpr).getInit().getInit(0)
result = arr.(ArrayCreationExpr).getInit().getInit(index)
)
}
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
| TaintedEnvironment.java:39:35:39:55 | new String[] | Command with a relative path 'ls' is executed. |
| Test.java:50:46:50:49 | "ls" | Command with a relative path 'ls' is executed. |
| Test.java:219:46:219:49 | "ls" | Command with a relative path 'ls' is executed. |
Loading
Loading