Software Securityhome
Fix a findingSearch
Fix card · A05:2025 Injection

Fix OS command and argument injection in Java and Spring Boot

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

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.

Stack
  • 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)
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks5 cards
Download the project ZIP, 22 KB
On this page

Steps

  1. 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".
  2. Build the ProcessBuilder from a List of the program and its arguments; do not invoke a shell: new ProcessBuilder(List.of(tool, "--", name)).
  3. Put a literal "--" in the list before the first user-controlled operand, so a value starting with '-' is an operand and not an option.
  4. 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.
  5. 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.
  6. Log the tool's output on the server and return a generic 500; its error text helps an attacker tune a payload.

Vulnerable, fixed and wrong

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

Vulnerable…/greet/impl/VulnerableGreetService.javaJava
@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

Fixed…/greet/impl/FixedGreetService.javaJava
@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

Fixed…/greet/impl/FixedGreetService.javaJava
// 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

Wrong fix…/greet/impl/WrongGreetService.javaJava
// 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 regression test

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.

Test…/example/greet/GreetApiTest.javaJava
// 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");
}

Pitfalls

  • 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.

Verify the fix

  1. 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).
  2. Against your own endpoint, send name=Alice;touch /tmp/pwned and name=--output=/tmp/pwned and confirm /tmp/pwned is never created.
  3. 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.

Scanner rules

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.

Scanner rules for Fix OS command and argument injection in Java and Spring Boot
ToolRuleWhat it found here
Semgrepjava.lang.security.audit.command-injection-process-builder.command-injection-process-builderFlagged 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)
CodeQLjava/command-line-injectionNot 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 InjectionListed, 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

Libraries and versions

Libraries and versions
ComponentVersion
LanguageJava 21
RuntimeOpenJDK 21.0.12.1 (Homebrew), Maven 3.9.16
FrameworkSpring Boot 4.1.1 (Spring Framework 7.0.9, Spring MVC)
org.springframework.boot:spring-boot-starter-parent4.1.1
org.springframework.boot:spring-boot-starter-webmvc4.1.1
org.springframework.boot:spring-boot-starter-webmvc-test4.1.1
org.junit.jupiter:junit-jupiter (managed by Spring Boot)6.0.3
java.lang.ProcessBuilder (JDK)built in (Java 21)

Run the tests

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).

Shell
mvn -B -q test
Shell
# 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 test

Each 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.

Download the project ZIP, 22 KB

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

  1. OWASP OS Command Injection Defense Cheat Sheet
  2. Java SE 21 API: java.lang.ProcessBuilder
  3. Spring Framework: Method parameters (@RequestParam)
  4. Spring Boot 4.0 Migration Guide (starters and test starters)
  5. CWE-78: OS Command Injection
  6. CWE-88: Argument Injection
  7. CAPEC-88: OS Command Injection
  8. CAPEC-6: Argument Injection

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.