Build the process from a List so ProcessBuilder runs it directly - new ProcessBuilder(List.of(tool, "--", name)) - and no shell parses the arguments; the "--" makes a leading-dash operand data, not an option. Never run "/bin/sh", "-c" with a built string. Choose a format from an allowlist, never from free text.
- Find every ProcessBuilder or Runtime.getRuntime().exec() whose command is built from @RequestParam, @PathVariable or body data, and every call that shells out with "sh -c".
- Build the ProcessBuilder from a List of the program and its arguments; do not invoke a shell: new ProcessBuilder(List.of(tool, "--", name)).
- Put a literal "--" in the list before the first user-controlled operand, so a value starting with '-' is an operand and not an option.
- For any argument that selects the command, a subcommand, a flag or an enumerated option value, map the caller's choice through an allowlist (a Map) and throw IllegalArgumentException (mapped to 400) for anything else.
- Read the process output on a bounded stream and check the exit code; add regression tests with this card's payloads that assert a marker file was never created, next to functional tests for names with spaces, apostrophes and quotes.
- Log the tool's output on the server and return a generic 500; its error text helps an attacker tune a payload.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
@Override
public String greet(String name) {
// "sh -c <string>" hands the whole line to the shell: every metacharacter
// in `name` is live, and ; | && $( ) backticks and newlines start new work.
return ProcessRunner.run(new ProcessBuilder("/bin/sh", "-c", tool + " " + name));
}With IMPL=vulnerable, 16 of 16 tests fail (10 exploit tests).
The fix
@Override
public String greet(String name) {
// A ProcessBuilder built from a List runs the program directly - no shell
// parses the arguments - and "--" makes everything after it an operand, so a
// name that starts with a dash cannot pose as an option.
return ProcessRunner.run(new ProcessBuilder(List.of(tool, "--", name)));
}With IMPL=fixed, 16 of 16 tests pass.
The fix: format allowlist
// The format picks part of the command itself, which a value can never do
// safely, so map the caller's choice onto a fixed flag value from an allowlist.
private static final Map<String, String> FORMAT_FLAGS = Map.of(
"text", "text",
"json", "json");
@Override
public String report(String name, String format) {
String flag = FORMAT_FLAGS.get(format);
if (flag == null) {
throw new IllegalArgumentException("unsupported format");
}
return ProcessRunner.run(new ProcessBuilder(List.of(tool, "--format", flag, "--", name)));
}With IMPL=fixed, 16 of 16 tests pass.
A fix that looks safe but is not
// The "obvious" shell separators. Note what is missing: $ ( ) and backticks
// (command substitution), and the leading dash of an injected option.
private static final Pattern BLOCKED = Pattern.compile("[;|&<>\n]");
private String shellQuote(String value) {
if (BLOCKED.matcher(value).find()) {
throw new IllegalArgumentException("illegal character in input");
}
return "\"" + value + "\"";
}
@Override
public String greet(String name) {
return ProcessRunner.run(new ProcessBuilder("/bin/sh", "-c", tool + " " + shellQuote(name)));
}With IMPL=wrong, 7 of 16 tests fail (5 exploit tests).
The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
// Exploit: each payload must stay data; the marker file must never be created.
private void assertNoMarker(String path, String... nameValuePairs) throws Exception {
Path dir = Files.createTempDirectory("fixcard-cmdi-");
Path marker = dir.resolve("pwned");
String[] args = new String[nameValuePairs.length];
for (int i = 0; i < nameValuePairs.length; i++) {
args[i] = nameValuePairs[i].replace("MARKER", marker.toString());
}
try {
mvc.perform(request(path, args)); // the tool runs synchronously before the response
assertThat(marker).doesNotExist();
} finally {
Files.deleteIfExists(marker);
Files.deleteIfExists(dir);
}
}
@Test
void exploitSemicolonDoesNotStartASecondCommand() throws Exception {
assertNoMarker("/greeting", "name", "Alice; touch MARKER");
}
@Test
void exploitPipeDoesNotStartASecondCommand() throws Exception {
assertNoMarker("/greeting", "name", "Alice | touch MARKER");
}
@Test
void exploitAndChainDoesNotStartASecondCommand() throws Exception {
assertNoMarker("/greeting", "name", "Alice && touch MARKER");
}
@Test
void exploitCommandSubstitutionDoesNotRun() throws Exception {
assertNoMarker("/greeting", "name", "Alice$(touch MARKER)");
}
@Test
void exploitBackticksDoNotRun() throws Exception {
assertNoMarker("/greeting", "name", "Alice`touch MARKER`");
}
@Test
void exploitNewlineDoesNotStartASecondCommand() throws Exception {
assertNoMarker("/greeting", "name", "Alice\ntouch MARKER");
}
@Test
void exploitLeadingOutputOptionCannotWriteAFile() throws Exception {
assertNoMarker("/greeting", "name", "--output=MARKER");
}
@Test
void exploitLeadingShortOptionCannotWriteAFile() throws Exception {
assertNoMarker("/greeting", "name", "-oMARKER");
}
@Test
void exploitOptionInjectedThroughFormatIsRejected() throws Exception {
assertNoMarker("/report", "name", "Alice Martin", "format", "text --output=MARKER");
}
@Test
void exploitLeadingDashNameIsAnOperandNotAnOption() throws Exception {
assertNoMarker("/report", "name", "--output=MARKER", "format", "text");
}- Runtime.getRuntime().exec(String) splits the string on whitespace itself (no shell), so it does not run metacharacters - but exec("sh", "-c", cmd) and ProcessBuilder("sh","-c",cmd) do, and that is the usual bug.
- ProcessBuilder with a List still runs one program, but without "--" an operand that starts with '-' is parsed as an option. Argument injection (CWE-88) needs no shell.
- There is no safe way to hand-quote for a shell from Java; the fix is to not use a shell, not to escape better (that is the mistake in WrongGreetService).
- Reading stdout and stderr one after another can deadlock if the tool fills a pipe; read concurrently, or redirect/merge the streams as this card does.
- Commons Exec's CommandLine.parse(String) re-splits a whole command line and re-introduces the risk; build the command from parsed-once pieces or addArgument with handleQuoting left on.
- Spring's @RequestParam gives one String; a repeated parameter binds the first or a List depending on the type, so validate what you actually receive.
- Allowlist by value, not by a regex on the raw input: a blocklist of shell metacharacters still lets --output= and -o through, because a dash is not a metacharacter.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs java-spring does this for you).
- Against your own endpoint, send name=Alice;touch /tmp/pwned and name=--output=/tmp/pwned and confirm /tmp/pwned is never created.
- Run semgrep --config r/java.lang.security.audit.command-injection-process-builder.command-injection-process-builder src/main/java; it flags every ProcessBuilder, so read the fixed hit as confirmation there is no shell, and keep the regression tests.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | java.lang.security.audit.command-injection-process-builder.command-injection-process-builder | Flagged all three implementations (two matches each): VulnerableGreetService and WrongGreetService (the sh -c string) and also FixedGreetService. The rule warns on any ProcessBuilder whose command is not all string literals, so it cannot tell new ProcessBuilder(List.of(tool, "--", name)) from sh -c. Treat its fixed-code hit as a prompt to confirm there is no shell, not as a bug.Checked: Semgrep 1.180.0 (CE) |
| CodeQL | java/command-line-injection | Not executed. The Java model treats ProcessBuilder and Runtime.exec arguments as command-injection sinks and Spring @RequestParam as a RemoteFlowSource; on a full build it would report VulnerableGreetService and WrongGreetService. It flags the tainted command, not the missing "--".Checked: github/codeql main at b5b8165; not executed |
| Find Security Bugs (SpotBugs plugin) | COMMAND_INJECTIONPotential Command Injection | Listed, not run: the card has no SpotBugs toolchain. The pattern fires on ProcessBuilder/Runtime.exec with non-constant input and is the SpotBugs analogue of the Semgrep rule above.Checked: Bug pattern listed on the Find Security Bugs site; not executed |
| Component | Version |
|---|---|
| Language | Java 21 |
| Runtime | OpenJDK 21.0.12.1 (Homebrew), Maven 3.9.16 |
| Framework | Spring Boot 4.1.1 (Spring Framework 7.0.9, Spring MVC) |
| org.springframework.boot:spring-boot-starter-parent | 4.1.1 |
| org.springframework.boot:spring-boot-starter-webmvc | 4.1.1 |
| org.springframework.boot:spring-boot-starter-webmvc-test | 4.1.1 |
| org.junit.jupiter:junit-jupiter (managed by Spring Boot) | 6.0.3 |
| java.lang.ProcessBuilder (JDK) | built in (Java 21) |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the command-injection/java-springfolder. Maven runs with JDK 21 (set JAVA_HOME).
mvn -B -q test# Expected: every test passes
IMPL=fixed mvn -B -o -q test
# Expected: an exploit test fails
IMPL=vulnerable mvn -B -o -q test
# Expected: a test fails
IMPL=wrong mvn -B -o -q testEach run writes a JUnit report to target/surefire-reports/TEST-*.xml. From the top folder of the download, node run-all.mjs command-injection/java-spring runs all three modes and checks the expected results.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64)
- Toolchain
- java OpenJDK 21.0.12.1 (Homebrew)
- maven 3.9.16
- springBoot 4.1.1
- junitJupiter 6.0.3
- shell /bin/sh (dash-compatible POSIX shell)
- Matrix
- fixed: 16 of 16 tests pass; vulnerable: 16 of 16 tests fail (10 exploit tests); wrong: 7 of 16 tests fail (5 exploit tests)
- Dependency freshness
- Spring Boot 4.1.1 (2026-08-20) is the current 4.1.x GA and well over 14 days old; JUnit Jupiter 6.0.3 is the version Boot 4.1.1 manages, required because the Spring 7 SpringExtension needs Jupiter 6.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- OWASP OS Command Injection Defense Cheat Sheet
- Java SE 21 API: java.lang.ProcessBuilder
- Spring Framework: Method parameters (@RequestParam)
- Spring Boot 4.0 Migration Guide (starters and test starters)
- CWE-78: OS Command Injection
- CWE-88: Argument Injection
- CAPEC-88: OS Command Injection
- CAPEC-6: Argument Injection
Attack patterns: