From 1c37c553d2aa38a4ac129a2a7626f63c9e6e004b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 09:03:23 +0200 Subject: [PATCH 1/8] chore: back to snapshot after v2026.4 (#2540) Files changed: M pom.xml Co-authored-by: nbaars --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index c764d18b1c4..e7c674e5adf 100644 --- a/pom.xml +++ b/pom.xml @@ -10,7 +10,7 @@ org.owasp.webgoat webgoat - 2026.4-SNAPSHOT + 2026.5-SNAPSHOT jar WebGoat From 98fd897acccd8515a9cd0313e20aade265722421 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 09:05:58 +0200 Subject: [PATCH 2/8] chore: bump com.diffplug.spotless:spotless-maven-plugin (#2536) Bumps [com.diffplug.spotless:spotless-maven-plugin](https://github.com/diffplug/spotless) from 3.10.1 to 3.10.2. - [Release notes](https://github.com/diffplug/spotless/releases) - [Changelog](https://github.com/diffplug/spotless/blob/main/CHANGES.md) - [Commits](https://github.com/diffplug/spotless/compare/maven/3.10.1...maven/3.10.2) --- updated-dependencies: - dependency-name: com.diffplug.spotless:spotless-maven-plugin dependency-version: 3.10.2 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index e7c674e5adf..83e00e313f7 100644 --- a/pom.xml +++ b/pom.xml @@ -96,7 +96,7 @@ 6.0.1 - 3.10.1 + 3.10.2 60 6.3.4 /WebGoat From b27a53adbec73071c5605b20bf9e1c0fbfe6d159 Mon Sep 17 00:00:00 2001 From: Nanne Baars Date: Tue, 22 Sep 2026 13:52:43 +0100 Subject: [PATCH 3/8] test(xxe): use deterministic entity targets (#2547) Root directory contents vary across CI runners, so XXE tests need a controlled file to verify external entity expansion reliably. --- .../xxe/ContentTypeAssignmentTest.java | 8 +++--- .../webgoat/lessons/xxe/SimpleXXETest.java | 8 +++--- .../webgoat/lessons/xxe/XXETestPayload.java | 25 +++++++++++++++++++ 3 files changed, 35 insertions(+), 6 deletions(-) create mode 100644 src/test/java/org/owasp/webgoat/lessons/xxe/XXETestPayload.java diff --git a/src/test/java/org/owasp/webgoat/lessons/xxe/ContentTypeAssignmentTest.java b/src/test/java/org/owasp/webgoat/lessons/xxe/ContentTypeAssignmentTest.java index a087951d188..1e1897dc90e 100644 --- a/src/test/java/org/owasp/webgoat/lessons/xxe/ContentTypeAssignmentTest.java +++ b/src/test/java/org/owasp/webgoat/lessons/xxe/ContentTypeAssignmentTest.java @@ -9,10 +9,12 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.file.Path; import org.hamcrest.CoreMatchers; import org.hamcrest.Matchers; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import org.owasp.webgoat.WithWebGoatUser; import org.owasp.webgoat.container.plugins.LessonTest; import org.springframework.http.MediaType; @@ -22,6 +24,8 @@ @WithWebGoatUser class ContentTypeAssignmentTest extends LessonTest { + @TempDir Path tempDir; + @BeforeEach public void setup() { this.mockMvc = MockMvcBuilders.webAppContextSetup(this.wac).build(); @@ -49,9 +53,7 @@ void workingAttack() throws Exception { .perform( MockMvcRequestBuilders.post("/xxe/content-type") .contentType(MediaType.APPLICATION_XML) - .content( - " ]>&root;")) + .content(XXETestPayload.readKnownFile(tempDir))) .andExpect(status().isOk()) .andExpect( jsonPath("$.feedback", CoreMatchers.is(messages.getMessage("assignment.solved")))); diff --git a/src/test/java/org/owasp/webgoat/lessons/xxe/SimpleXXETest.java b/src/test/java/org/owasp/webgoat/lessons/xxe/SimpleXXETest.java index 6da26ddcf4a..ff8016d7cbc 100644 --- a/src/test/java/org/owasp/webgoat/lessons/xxe/SimpleXXETest.java +++ b/src/test/java/org/owasp/webgoat/lessons/xxe/SimpleXXETest.java @@ -7,9 +7,11 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import java.nio.file.Path; import org.hamcrest.CoreMatchers; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import org.owasp.webgoat.WithWebGoatUser; import org.owasp.webgoat.container.plugins.LessonTest; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; @@ -18,6 +20,8 @@ @WithWebGoatUser class SimpleXXETest extends LessonTest { + @TempDir Path tempDir; + @BeforeEach void setup() { this.mockMvc = MockMvcBuilders.webAppContextSetup(this.wac).build(); @@ -29,9 +33,7 @@ void workingAttack() throws Exception { mockMvc .perform( MockMvcRequestBuilders.post("/xxe/simple") - .content( - " ]>&root;")) + .content(XXETestPayload.readKnownFile(tempDir))) .andExpect(status().isOk()) .andExpect( jsonPath("$.feedback", CoreMatchers.is(messages.getMessage("assignment.solved")))); diff --git a/src/test/java/org/owasp/webgoat/lessons/xxe/XXETestPayload.java b/src/test/java/org/owasp/webgoat/lessons/xxe/XXETestPayload.java new file mode 100644 index 00000000000..bf2e5ab8d31 --- /dev/null +++ b/src/test/java/org/owasp/webgoat/lessons/xxe/XXETestPayload.java @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2017 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.xxe; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +final class XXETestPayload { + + private XXETestPayload() {} + + static String readKnownFile(Path directory) throws IOException { + Path target = Files.writeString(directory.resolve("xxe.txt"), "etc Windows"); + + return """ + + ]> + &root; + """ + .formatted(target.toUri()); + } +} From c037c9562aaa8e55d7a8d77e9cef45c9f7ff64ea Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:59:31 +0200 Subject: [PATCH 4/8] chore: bump com.auth0:java-jwt from 4.6.0 to 4.6.1 (#2538) Bumps [com.auth0:java-jwt](https://github.com/auth0/java-jwt) from 4.6.0 to 4.6.1. - [Release notes](https://github.com/auth0/java-jwt/releases) - [Changelog](https://github.com/auth0/java-jwt/blob/master/CHANGELOG.md) - [Commits](https://github.com/auth0/java-jwt/compare/4.6.0...4.6.1) --- updated-dependencies: - dependency-name: com.auth0:java-jwt dependency-version: 4.6.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 83e00e313f7..9f412cd8f41 100644 --- a/pom.xml +++ b/pom.xml @@ -165,7 +165,7 @@ com.auth0 java-jwt - 4.6.0 + 4.6.1 com.google.guava From c92642df475206eab221c2a4c3183dfccfb3e63f Mon Sep 17 00:00:00 2001 From: Nanne Baars Date: Wed, 23 Sep 2026 15:19:24 +0100 Subject: [PATCH 5/8] feat(cryptography): add HMAC timing attack lesson (#2548) Teach how secret-dependent tag comparisons leak HMAC bytes and how repeated timing measurements can recover them under noise. --- .../webgoat/integration/IntegrationTest.java | 25 ++- .../integration/XXEIntegrationTest.java | 26 ++- .../lessons/TimingAttacksLessonUITest.java | 60 ++++++ .../lessons/TimingAttacksLessonPage.java | 83 ++++++++ .../timing/InsecureHmacValidator.java | 55 +++++ .../TimingAttackFirstByteAssignment.java | 42 ++++ .../TimingAttackMitigationAssignment.java | 32 +++ .../timing/TimingAttackNoiseAssignment.java | 43 ++++ .../timing/TimingAttackOracle.java | 73 +++++++ .../timing/TimingAttackSessionState.java | 55 +++++ .../timing/TimingAttackSupport.java | 23 ++ .../timing/TimingAttackTagAssignment.java | 42 ++++ .../cryptography/timing/TimingAttacks.java | 23 ++ .../timing-attacks-discovery.adoc | 11 + .../timing-attacks-introduction.adoc | 12 ++ .../documentation/timing-attacks-jwt.adoc | 16 ++ .../timing-attacks-mitigation.adoc | 18 ++ .../documentation/timing-attacks-noise.adoc | 11 + .../timing-attacks-recovery.adoc | 11 + .../cryptography/html/TimingAttacks.html | 79 +++++++ .../i18n/WebGoatLabels.properties | 19 ++ .../timing/InsecureHmacValidatorTest.java | 68 ++++++ .../timing/TimingAttacksTest.java | 197 ++++++++++++++++++ 23 files changed, 1000 insertions(+), 24 deletions(-) create mode 100644 src/it/java/org/owasp/webgoat/playwright/webgoat/lessons/TimingAttacksLessonUITest.java create mode 100644 src/it/java/org/owasp/webgoat/playwright/webgoat/pages/lessons/TimingAttacksLessonPage.java create mode 100644 src/main/java/org/owasp/webgoat/lessons/cryptography/timing/InsecureHmacValidator.java create mode 100644 src/main/java/org/owasp/webgoat/lessons/cryptography/timing/TimingAttackFirstByteAssignment.java create mode 100644 src/main/java/org/owasp/webgoat/lessons/cryptography/timing/TimingAttackMitigationAssignment.java create mode 100644 src/main/java/org/owasp/webgoat/lessons/cryptography/timing/TimingAttackNoiseAssignment.java create mode 100644 src/main/java/org/owasp/webgoat/lessons/cryptography/timing/TimingAttackOracle.java create mode 100644 src/main/java/org/owasp/webgoat/lessons/cryptography/timing/TimingAttackSessionState.java create mode 100644 src/main/java/org/owasp/webgoat/lessons/cryptography/timing/TimingAttackSupport.java create mode 100644 src/main/java/org/owasp/webgoat/lessons/cryptography/timing/TimingAttackTagAssignment.java create mode 100644 src/main/java/org/owasp/webgoat/lessons/cryptography/timing/TimingAttacks.java create mode 100644 src/main/resources/lessons/cryptography/documentation/timing-attacks-discovery.adoc create mode 100644 src/main/resources/lessons/cryptography/documentation/timing-attacks-introduction.adoc create mode 100644 src/main/resources/lessons/cryptography/documentation/timing-attacks-jwt.adoc create mode 100644 src/main/resources/lessons/cryptography/documentation/timing-attacks-mitigation.adoc create mode 100644 src/main/resources/lessons/cryptography/documentation/timing-attacks-noise.adoc create mode 100644 src/main/resources/lessons/cryptography/documentation/timing-attacks-recovery.adoc create mode 100644 src/main/resources/lessons/cryptography/html/TimingAttacks.html create mode 100644 src/test/java/org/owasp/webgoat/lessons/cryptography/timing/InsecureHmacValidatorTest.java create mode 100644 src/test/java/org/owasp/webgoat/lessons/cryptography/timing/TimingAttacksTest.java diff --git a/src/it/java/org/owasp/webgoat/integration/IntegrationTest.java b/src/it/java/org/owasp/webgoat/integration/IntegrationTest.java index 0819041e283..e5e6c044b47 100644 --- a/src/it/java/org/owasp/webgoat/integration/IntegrationTest.java +++ b/src/it/java/org/owasp/webgoat/integration/IntegrationTest.java @@ -185,19 +185,18 @@ public void checkResults() { public void checkAssignment( String url, ContentType contentType, String body, boolean expectedResult) { - MatcherAssert.assertThat( - RestAssured.given() - .when() - .relaxedHTTPSValidation() - .contentType(contentType) - .cookie("JSESSIONID", getWebGoatCookie()) - .body(body) - .post(url) - .then() - .statusCode(200) - .extract() - .path("lessonCompleted"), - CoreMatchers.is(expectedResult)); + RestAssured.given() + .when() + .relaxedHTTPSValidation() + .contentType(contentType) + .cookie("JSESSIONID", getWebGoatCookie()) + .body(body) + .post(url) + .then() + .log() + .ifValidationFails(LogDetail.BODY) + .statusCode(200) + .body("lessonCompleted", CoreMatchers.is(expectedResult)); } public void checkAssignmentWithGet(String url, Map params, boolean expectedResult) { diff --git a/src/it/java/org/owasp/webgoat/integration/XXEIntegrationTest.java b/src/it/java/org/owasp/webgoat/integration/XXEIntegrationTest.java index 33fd2ff6be0..c65d0e70a6b 100644 --- a/src/it/java/org/owasp/webgoat/integration/XXEIntegrationTest.java +++ b/src/it/java/org/owasp/webgoat/integration/XXEIntegrationTest.java @@ -7,18 +7,22 @@ import io.restassured.RestAssured; import io.restassured.http.ContentType; import java.io.IOException; +import java.nio.file.Path; +import org.apache.commons.lang3.SystemUtils; import org.junit.jupiter.api.Test; public class XXEIntegrationTest extends IntegrationTest { - private static final String xxe3 = - """ -]>&xxe;test -"""; - private static final String xxe4 = + // Windows runners can use a working drive other than the Windows system drive. + private static final String rootUri = + SystemUtils.IS_OS_WINDOWS + ? Path.of(System.getenv("SystemRoot")).getRoot().toUri().toString() + : "file:///"; + + private static final String directoryListing = """ -]>&xxe;test -"""; +]>&xxe;test +""".formatted(rootUri); private static final String dtd7 = """ ">%all; @@ -45,8 +49,8 @@ public class XXEIntegrationTest extends IntegrationTest { // .get(url("service/enable-security.mvc")) // .then() // .statusCode(200); - // checkAssignment(url("xxe/simple"), ContentType.XML, xxe3, false); - // checkAssignment(url("xxe/content-type"), ContentType.XML, xxe4, false); + // checkAssignment(url("xxe/simple"), ContentType.XML, directoryListing, false); + // checkAssignment(url("xxe/content-type"), ContentType.XML, directoryListing, false); // checkAssignment( // url("xxe/blind"), // ContentType.XML, @@ -109,8 +113,8 @@ private String getSecret() { public void runTests() throws IOException { startLesson("XXE", true); webGoatHomeDirectory = webGoatServerDirectory(); - checkAssignment(webGoatUrlConfig.url("xxe/simple"), ContentType.XML, xxe3, true); - checkAssignment(webGoatUrlConfig.url("xxe/content-type"), ContentType.XML, xxe4, true); + checkAssignment(webGoatUrlConfig.url("xxe/simple"), ContentType.XML, directoryListing, true); + checkAssignment(webGoatUrlConfig.url("xxe/content-type"), ContentType.XML, directoryListing, true); checkAssignment( webGoatUrlConfig.url("xxe/blind"), ContentType.XML, diff --git a/src/it/java/org/owasp/webgoat/playwright/webgoat/lessons/TimingAttacksLessonUITest.java b/src/it/java/org/owasp/webgoat/playwright/webgoat/lessons/TimingAttacksLessonUITest.java new file mode 100644 index 00000000000..6d784a4d228 --- /dev/null +++ b/src/it/java/org/owasp/webgoat/playwright/webgoat/lessons/TimingAttacksLessonUITest.java @@ -0,0 +1,60 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2026 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 static org.assertj.core.api.Assertions.assertThat; + +import com.microsoft.playwright.Browser; +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.TimingAttacksLessonPage; + +public class TimingAttacksLessonUITest extends PlaywrightTest { + + private TimingAttacksLessonPage lessonPage; + + @BeforeEach + void navigateToLesson(Browser browser) { + var lessonName = new LessonName("TimingAttacks"); + var page = Authentication.sylvester(browser); + + lessonPage = new TimingAttacksLessonPage(page); + lessonPage.resetLesson(lessonName); + lessonPage.open(lessonName); + } + + @Test + @DisplayName("Discover and submit the first HMAC byte through response timing") + void shouldRecoverTheFirstByte() { + assertThat(lessonPage.title()).hasText("Timing Attacks"); + assertThat(lessonPage.numberOfAssignments()).isEqualTo(4); + + lessonPage.navigateTo(2); + String firstByte = lessonPage.recoverFirstByte(); + lessonPage.submitFirstByte(firstByte); + + assertThat(lessonPage.firstByteFeedback()) + .containsText("The response timing revealed the first byte"); + } + + @Test + @DisplayName("Reject an incomplete mitigation and accept the constant-time comparison API") + void shouldReviewTheMitigation() { + lessonPage.navigateTo(5); + + lessonPage.submitMitigation("early-exit", "random-delay"); + assertThat(lessonPage.mitigationFeedback()) + .containsText("merely makes measurement harder"); + + lessonPage.submitMitigation("early-exit", "message-digest"); + assertThat(lessonPage.mitigationFeedback()) + .containsText("MessageDigest.isEqual is the appropriate Java comparison"); + } +} diff --git a/src/it/java/org/owasp/webgoat/playwright/webgoat/pages/lessons/TimingAttacksLessonPage.java b/src/it/java/org/owasp/webgoat/playwright/webgoat/pages/lessons/TimingAttacksLessonPage.java new file mode 100644 index 00000000000..8599f853f4a --- /dev/null +++ b/src/it/java/org/owasp/webgoat/playwright/webgoat/pages/lessons/TimingAttacksLessonPage.java @@ -0,0 +1,83 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.playwright.webgoat.pages.lessons; + +import static org.owasp.webgoat.playwright.webgoat.PlaywrightTest.webGoatUrl; + +import com.microsoft.playwright.APIResponse; +import com.microsoft.playwright.Locator; +import com.microsoft.playwright.Page; +import java.util.Comparator; +import java.util.stream.IntStream; + +public class TimingAttacksLessonPage extends LessonPage { + + public TimingAttacksLessonPage(Page page) { + super(page); + } + + public Locator title() { + return getPage().locator("#lesson-title"); + } + + public String recoverFirstByte() { + return IntStream.range(0, 256) + .mapToObj(candidate -> new Candidate(candidate, minimumDuration(candidate, 3))) + .max(Comparator.comparingLong(Candidate::durationNanos)) + .map(candidate -> "%02x".formatted(candidate.value())) + .orElseThrow(); + } + + public void submitFirstByte(String firstByte) { + var form = getPage().locator("form[action$='/crypto/timing/first-byte']"); + form.locator("input[name='firstByte']").fill(firstByte); + form.locator("button[type='submit']").click(); + } + + public Locator firstByteFeedback() { + return getPage() + .locator("form[action$='/crypto/timing/first-byte'] ~ .attack-feedback"); + } + + public void submitMitigation(String cause, String mitigation) { + var form = getPage().locator("form[action$='/crypto/timing/mitigation']"); + form.locator("input[name='cause'][value='" + cause + "']").check(); + form.locator("input[name='mitigation'][value='" + mitigation + "']").check(); + form.locator("button[type='submit']").click(); + } + + public Locator mitigationFeedback() { + return getPage().locator("form[action$='/crypto/timing/mitigation'] ~ .attack-feedback"); + } + + private long minimumDuration(int candidate, int samples) { + return IntStream.range(0, samples) + .mapToLong(ignored -> measure(candidate)) + .min() + .orElseThrow(); + } + + private long measure(int candidate) { + String signature = "%02x000000".formatted(candidate); + long start = System.nanoTime(); + APIResponse response = + getPage() + .request() + .get( + webGoatUrl( + "crypto/timing/verify?message=WebGoat&signature=" + signature)); + long duration = System.nanoTime() - start; + try { + if (!response.ok()) { + throw new IllegalStateException("Timing oracle returned HTTP " + response.status()); + } + return duration; + } finally { + response.dispose(); + } + } + + private record Candidate(int value, long durationNanos) {} +} diff --git a/src/main/java/org/owasp/webgoat/lessons/cryptography/timing/InsecureHmacValidator.java b/src/main/java/org/owasp/webgoat/lessons/cryptography/timing/InsecureHmacValidator.java new file mode 100644 index 00000000000..840fd1f863c --- /dev/null +++ b/src/main/java/org/owasp/webgoat/lessons/cryptography/timing/InsecureHmacValidator.java @@ -0,0 +1,55 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.cryptography.timing; + +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.LockSupport; +import java.util.function.IntUnaryOperator; +import java.util.function.LongConsumer; + +/** Intentionally insecure code used only to demonstrate a timing side channel. */ +final class InsecureHmacValidator { + + private final LongConsumer delay; + private final IntUnaryOperator randomDelay; + + InsecureHmacValidator() { + this(LockSupport::parkNanos, bound -> ThreadLocalRandom.current().nextInt(bound)); + } + + InsecureHmacValidator(LongConsumer delay, IntUnaryOperator randomDelay) { + this.delay = delay; + this.randomDelay = randomDelay; + } + + boolean matches( + byte[] expected, + byte[] supplied, + long matchingByteDelayMillis, + int maximumJitterMillis) { + if (maximumJitterMillis > 0) { + pause(randomDelay.applyAsInt(maximumJitterMillis + 1)); + } + + if (expected.length != supplied.length) { + return false; + } + + for (int i = 0; i < expected.length; i++) { + if (expected[i] != supplied[i]) { + return false; + } + pause(matchingByteDelayMillis); + } + return true; + } + + private void pause(long milliseconds) { + if (milliseconds > 0) { + delay.accept(TimeUnit.MILLISECONDS.toNanos(milliseconds)); + } + } +} diff --git a/src/main/java/org/owasp/webgoat/lessons/cryptography/timing/TimingAttackFirstByteAssignment.java b/src/main/java/org/owasp/webgoat/lessons/cryptography/timing/TimingAttackFirstByteAssignment.java new file mode 100644 index 00000000000..bfaec00ff8a --- /dev/null +++ b/src/main/java/org/owasp/webgoat/lessons/cryptography/timing/TimingAttackFirstByteAssignment.java @@ -0,0 +1,42 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.cryptography.timing; + +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.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@AssignmentHints({ + "timing-attacks.hints.behavior", + "timing-attacks.hints.measure", + "timing-attacks.hints.first-byte" +}) +public class TimingAttackFirstByteAssignment implements AssignmentEndpoint { + + private final TimingAttackSessionState state; + + public TimingAttackFirstByteAssignment(TimingAttackSessionState state) { + this.state = state; + } + + @PostMapping("/crypto/timing/first-byte") + @ResponseBody + public AttackResult submit(@RequestParam(required = false) String firstByte) { + byte[] supplied = TimingAttackSupport.parseTag(firstByte, 1); + byte expected = state.tagFor(TimingAttackSessionState.KNOWN_MESSAGE)[0]; + if (supplied != null && supplied[0] == expected) { + return success(this).feedback("timing-attacks.first-byte.success").build(); + } + return failed(this).feedback("timing-attacks.try-again").build(); + } +} diff --git a/src/main/java/org/owasp/webgoat/lessons/cryptography/timing/TimingAttackMitigationAssignment.java b/src/main/java/org/owasp/webgoat/lessons/cryptography/timing/TimingAttackMitigationAssignment.java new file mode 100644 index 00000000000..a6eb9dfaf8a --- /dev/null +++ b/src/main/java/org/owasp/webgoat/lessons/cryptography/timing/TimingAttackMitigationAssignment.java @@ -0,0 +1,32 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.cryptography.timing; + +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.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@AssignmentHints({"timing-attacks.hints.mitigation", "timing-attacks.hints.primitive"}) +public class TimingAttackMitigationAssignment implements AssignmentEndpoint { + + @PostMapping("/crypto/timing/mitigation") + @ResponseBody + public AttackResult submit( + @RequestParam(required = false) String cause, + @RequestParam(required = false) String mitigation) { + if ("early-exit".equals(cause) && "message-digest".equals(mitigation)) { + return success(this).feedback("timing-attacks.mitigation.success").build(); + } + return failed(this).feedback("timing-attacks.mitigation.try-again").build(); + } +} diff --git a/src/main/java/org/owasp/webgoat/lessons/cryptography/timing/TimingAttackNoiseAssignment.java b/src/main/java/org/owasp/webgoat/lessons/cryptography/timing/TimingAttackNoiseAssignment.java new file mode 100644 index 00000000000..eb473290fdf --- /dev/null +++ b/src/main/java/org/owasp/webgoat/lessons/cryptography/timing/TimingAttackNoiseAssignment.java @@ -0,0 +1,43 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.cryptography.timing; + +import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; +import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; + +import java.security.MessageDigest; +import org.owasp.webgoat.container.assignments.AssignmentEndpoint; +import org.owasp.webgoat.container.assignments.AssignmentHints; +import org.owasp.webgoat.container.assignments.AttackResult; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@AssignmentHints({ + "timing-attacks.hints.noise", + "timing-attacks.hints.statistics", + "timing-attacks.hints.elimination" +}) +public class TimingAttackNoiseAssignment implements AssignmentEndpoint { + + private final TimingAttackSessionState state; + + public TimingAttackNoiseAssignment(TimingAttackSessionState state) { + this.state = state; + } + + @PostMapping("/crypto/timing/noisy-tag") + @ResponseBody + public AttackResult submit(@RequestParam(required = false) String signature) { + byte[] supplied = TimingAttackSupport.parseTag(signature, TimingAttackSessionState.TAG_LENGTH); + byte[] expected = state.tagFor(TimingAttackSessionState.NOISY_MESSAGE); + if (supplied != null && MessageDigest.isEqual(expected, supplied)) { + return success(this).feedback("timing-attacks.noise.success").build(); + } + return failed(this).feedback("timing-attacks.try-again").build(); + } +} diff --git a/src/main/java/org/owasp/webgoat/lessons/cryptography/timing/TimingAttackOracle.java b/src/main/java/org/owasp/webgoat/lessons/cryptography/timing/TimingAttackOracle.java new file mode 100644 index 00000000000..e8c352b2ef9 --- /dev/null +++ b/src/main/java/org/owasp/webgoat/lessons/cryptography/timing/TimingAttackOracle.java @@ -0,0 +1,73 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.cryptography.timing; + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class TimingAttackOracle { + + static final String VALID = "Valid signature"; + static final String INVALID = "Invalid signature"; + + private final InsecureHmacValidator validator = new InsecureHmacValidator(); + private final TimingAttackSessionState state; + + public TimingAttackOracle(TimingAttackSessionState state) { + this.state = state; + } + + @GetMapping(path = "/crypto/timing/verify", produces = MediaType.TEXT_PLAIN_VALUE) + public ResponseEntity verifyDiscovery( + @RequestParam(defaultValue = TimingAttackSessionState.KNOWN_MESSAGE) String message, + @RequestParam(required = false) String signature) { + return verify(message, signature, 25, 0); + } + + @GetMapping(path = "/crypto/timing/verify-tag", produces = MediaType.TEXT_PLAIN_VALUE) + public ResponseEntity verifyTag( + @RequestParam(defaultValue = TimingAttackSessionState.KNOWN_MESSAGE) String message, + @RequestParam(required = false) String signature) { + return verify(message, signature, 5, 0); + } + + @GetMapping(path = "/crypto/timing/verify-noisy", produces = MediaType.TEXT_PLAIN_VALUE) + public ResponseEntity verifyNoisy( + @RequestParam(defaultValue = TimingAttackSessionState.NOISY_MESSAGE) String message, + @RequestParam(required = false) String signature) { + return verify(message, signature, 2, 8); + } + + private ResponseEntity verify( + String message, + String signature, + long matchingByteDelayMillis, + int maximumJitterMillis) { + if (!state.tryAcquireRequestSlot()) { + return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS) + .contentType(MediaType.TEXT_PLAIN) + .body("Too many concurrent requests"); + } + + try { + byte[] supplied = TimingAttackSupport.parseTag(signature, TimingAttackSessionState.TAG_LENGTH); + boolean valid = + supplied != null + && validator.matches( + state.tagFor(message), + supplied, + matchingByteDelayMillis, + maximumJitterMillis); + return ResponseEntity.ok(valid ? VALID : INVALID); + } finally { + state.releaseRequestSlot(); + } + } +} diff --git a/src/main/java/org/owasp/webgoat/lessons/cryptography/timing/TimingAttackSessionState.java b/src/main/java/org/owasp/webgoat/lessons/cryptography/timing/TimingAttackSessionState.java new file mode 100644 index 00000000000..6a447e14a4b --- /dev/null +++ b/src/main/java/org/owasp/webgoat/lessons/cryptography/timing/TimingAttackSessionState.java @@ -0,0 +1,55 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.cryptography.timing; + +import java.io.Serial; +import java.io.Serializable; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.concurrent.Semaphore; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import org.springframework.stereotype.Component; +import org.springframework.web.context.annotation.SessionScope; + +@Component +@SessionScope +public class TimingAttackSessionState implements Serializable { + + @Serial private static final long serialVersionUID = 1L; + + static final String KNOWN_MESSAGE = "WebGoat"; + static final String NOISY_MESSAGE = "WebGoat with noise"; + static final int TAG_LENGTH = 4; + static final int MAX_CONCURRENT_REQUESTS = 4; + + private final byte[] secret; + private final Semaphore requestSlots = new Semaphore(MAX_CONCURRENT_REQUESTS); + + public TimingAttackSessionState() { + secret = new byte[32]; + new SecureRandom().nextBytes(secret); + } + + byte[] tagFor(String message) { + try { + Mac hmac = Mac.getInstance("HmacSHA256"); + hmac.init(new SecretKeySpec(secret, "HmacSHA256")); + return Arrays.copyOf(hmac.doFinal(message.getBytes(StandardCharsets.UTF_8)), TAG_LENGTH); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("HmacSHA256 is not available", e); + } + } + + boolean tryAcquireRequestSlot() { + return requestSlots.tryAcquire(); + } + + void releaseRequestSlot() { + requestSlots.release(); + } +} diff --git a/src/main/java/org/owasp/webgoat/lessons/cryptography/timing/TimingAttackSupport.java b/src/main/java/org/owasp/webgoat/lessons/cryptography/timing/TimingAttackSupport.java new file mode 100644 index 00000000000..a3306876430 --- /dev/null +++ b/src/main/java/org/owasp/webgoat/lessons/cryptography/timing/TimingAttackSupport.java @@ -0,0 +1,23 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.cryptography.timing; + +import java.util.HexFormat; + +final class TimingAttackSupport { + + private TimingAttackSupport() {} + + static byte[] parseTag(String value, int expectedBytes) { + if (value == null || value.length() != expectedBytes * 2) { + return null; + } + try { + return HexFormat.of().parseHex(value); + } catch (IllegalArgumentException ignored) { + return null; + } + } +} diff --git a/src/main/java/org/owasp/webgoat/lessons/cryptography/timing/TimingAttackTagAssignment.java b/src/main/java/org/owasp/webgoat/lessons/cryptography/timing/TimingAttackTagAssignment.java new file mode 100644 index 00000000000..9f338434873 --- /dev/null +++ b/src/main/java/org/owasp/webgoat/lessons/cryptography/timing/TimingAttackTagAssignment.java @@ -0,0 +1,42 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.cryptography.timing; + +import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; +import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; + +import java.security.MessageDigest; +import org.owasp.webgoat.container.assignments.AssignmentEndpoint; +import org.owasp.webgoat.container.assignments.AssignmentHints; +import org.owasp.webgoat.container.assignments.AttackResult; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@AssignmentHints({ + "timing-attacks.hints.keep-prefix", + "timing-attacks.hints.repeat" +}) +public class TimingAttackTagAssignment implements AssignmentEndpoint { + + private final TimingAttackSessionState state; + + public TimingAttackTagAssignment(TimingAttackSessionState state) { + this.state = state; + } + + @PostMapping("/crypto/timing/tag") + @ResponseBody + public AttackResult submit(@RequestParam(required = false) String signature) { + byte[] supplied = TimingAttackSupport.parseTag(signature, TimingAttackSessionState.TAG_LENGTH); + byte[] expected = state.tagFor(TimingAttackSessionState.KNOWN_MESSAGE); + if (supplied != null && MessageDigest.isEqual(expected, supplied)) { + return success(this).feedback("timing-attacks.tag.success").build(); + } + return failed(this).feedback("timing-attacks.try-again").build(); + } +} diff --git a/src/main/java/org/owasp/webgoat/lessons/cryptography/timing/TimingAttacks.java b/src/main/java/org/owasp/webgoat/lessons/cryptography/timing/TimingAttacks.java new file mode 100644 index 00000000000..b18ae19a6b5 --- /dev/null +++ b/src/main/java/org/owasp/webgoat/lessons/cryptography/timing/TimingAttacks.java @@ -0,0 +1,23 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.cryptography.timing; + +import org.owasp.webgoat.container.lessons.Category; +import org.owasp.webgoat.container.lessons.Lesson; +import org.springframework.stereotype.Component; + +@Component +public class TimingAttacks extends Lesson { + + @Override + public Category getDefaultCategory() { + return Category.A2; + } + + @Override + public String getTitle() { + return "timing-attacks.title"; + } +} diff --git a/src/main/resources/lessons/cryptography/documentation/timing-attacks-discovery.adoc b/src/main/resources/lessons/cryptography/documentation/timing-attacks-discovery.adoc new file mode 100644 index 00000000000..5bbeb83019e --- /dev/null +++ b/src/main/resources/lessons/cryptography/documentation/timing-attacks-discovery.adoc @@ -0,0 +1,11 @@ +== Discover the side channel + +Send candidate tags to this endpoint: + +---- +GET /WebGoat/crypto/timing/verify?message=WebGoat&signature=00000000 +---- + +Every invalid candidate returns the same status and the same `Invalid signature` body. The comparison still behaves differently. It checks bytes from left to right, stops at the first mismatch, and waits about 25 milliseconds after each matching byte. + +Change only the first byte, measure the complete request time, and repeat measurements before deciding which value is slower. Submit that byte as two hexadecimal characters. diff --git a/src/main/resources/lessons/cryptography/documentation/timing-attacks-introduction.adoc b/src/main/resources/lessons/cryptography/documentation/timing-attacks-introduction.adoc new file mode 100644 index 00000000000..b147a78cfa6 --- /dev/null +++ b/src/main/resources/lessons/cryptography/documentation/timing-attacks-introduction.adoc @@ -0,0 +1,12 @@ +== Timing attacks + +A message authentication code, or MAC, lets a recipient check that a message came from someone who knows a shared secret and that the message was not changed. HMAC builds a MAC from a cryptographic hash function and a secret key. + +Knowing the message does not reveal its HMAC. In this lesson, the server calculates `HMAC-SHA256(secret, message)` correctly. The weakness appears later, when application code compares the expected tag with the supplied tag. + +[quote] +Strong cryptography cannot protect you from information leaked by the code around it. + +The exercises use the known message `WebGoat`. They truncate the HMAC-SHA256 result to four bytes so the lab can finish in a reasonable time. A 32-bit authentication tag is not suitable for production use. You are not expected to brute-force the complete tag. Exploit the timing information instead. + +The server generates a different HMAC secret for each session and calculates every expected tag dynamically. Copying another learner's tag will not work. diff --git a/src/main/resources/lessons/cryptography/documentation/timing-attacks-jwt.adoc b/src/main/resources/lessons/cryptography/documentation/timing-attacks-jwt.adoc new file mode 100644 index 00000000000..78820b32d07 --- /dev/null +++ b/src/main/resources/lessons/cryptography/documentation/timing-attacks-jwt.adoc @@ -0,0 +1,16 @@ +== Connection to JWT + +JWT tokens that use HS256 authenticate this signing input: + +---- +base64url(header) + "." + base64url(payload) +---- + +with HMAC-SHA256. The JWT signature is therefore an HMAC authentication value. A JWT verifier that compares it with a secret-dependent early exit can expose the same timing side channel. + +The attack in this lesson did not crack HMAC-SHA256. The server calculated a secure primitive correctly, then leaked information while checking its result. The existing JWT lesson covers token structure and JWT-specific attacks in more detail. + +[source] +---- +Secure primitive + insecure implementation behavior = exploitable system +---- diff --git a/src/main/resources/lessons/cryptography/documentation/timing-attacks-mitigation.adoc b/src/main/resources/lessons/cryptography/documentation/timing-attacks-mitigation.adoc new file mode 100644 index 00000000000..4d4c05288c4 --- /dev/null +++ b/src/main/resources/lessons/cryptography/documentation/timing-attacks-mitigation.adoc @@ -0,0 +1,18 @@ +== Review the repair + +The vulnerable comparison has a secret-dependent early exit: + +[source,java] +---- +for (int i = 0; i < expected.length; i++) { + if (expected[i] != provided[i]) { + return false; + } +} +---- + +Adding random delay makes measurement more expensive, but repeated sampling can still reveal the signal. Converting the values to strings moves the same comparison mistake into another API. + +Choose the root cause and the repair that removes it. Application code should use a library comparison intended for authentication values instead of inventing its own constant-time loop. + +Java cannot promise identical CPU time under every JIT, cache, scheduler, runtime, and hardware condition. The practical rule is to avoid intentional secret-dependent early exits and use an appropriate library primitive such as `MessageDigest.isEqual(expected, provided)`. diff --git a/src/main/resources/lessons/cryptography/documentation/timing-attacks-noise.adoc b/src/main/resources/lessons/cryptography/documentation/timing-attacks-noise.adoc new file mode 100644 index 00000000000..7dea2c730fb --- /dev/null +++ b/src/main/resources/lessons/cryptography/documentation/timing-attacks-noise.adoc @@ -0,0 +1,11 @@ +== Extract a noisy signal + +Real measurements vary because of scheduling, runtime activity, network latency, and other work on the host. This endpoint adds between 0 and 8 milliseconds of random jitter while reducing the matching-byte signal to about 2 milliseconds: + +---- +GET /WebGoat/crypto/timing/verify-noisy?message=WebGoat%20with%20noise&signature=00000000 +---- + +This assignment uses the message `WebGoat with noise`, so the tag recovered in the previous assignment cannot be reused. One request is no longer useful evidence. Take several samples for each candidate and compare an aggregate such as the median, mean, or trimmed mean. No single statistical method is required. + +You can reduce the request count through progressive elimination. Measure every candidate a few times, retain the strongest group, take more samples for that group, and repeat until one candidate remains. This is a statistical attack against an implementation leak. It does not recover the HMAC secret or break SHA-256. diff --git a/src/main/resources/lessons/cryptography/documentation/timing-attacks-recovery.adoc b/src/main/resources/lessons/cryptography/documentation/timing-attacks-recovery.adoc new file mode 100644 index 00000000000..05043995ec6 --- /dev/null +++ b/src/main/resources/lessons/cryptography/documentation/timing-attacks-recovery.adoc @@ -0,0 +1,11 @@ +== Recover the tag + +The next endpoint reduces the delay to about 5 milliseconds per matching byte: + +---- +GET /WebGoat/crypto/timing/verify-tag?message=WebGoat&signature=00000000 +---- + +Recover the four-byte tag one position at a time. Keep the known prefix fixed, try each possible value for the next byte, and pad the untested positions. Repeat measurements to reduce mistakes. The candidate whose requests consistently take longest is evidence for the next byte. + +The exercise supports scripts, intercepting proxies, command-line tools, and AI assistants. The objective is to explain why the measurements reveal a prefix, not to type hundreds of requests by hand. diff --git a/src/main/resources/lessons/cryptography/html/TimingAttacks.html b/src/main/resources/lessons/cryptography/html/TimingAttacks.html new file mode 100644 index 00000000000..c0ab0cc81d6 --- /dev/null +++ b/src/main/resources/lessons/cryptography/html/TimingAttacks.html @@ -0,0 +1,79 @@ + + + + +
+
+
+ +
+
+
+
+
+ + + +
+
+
+
+
+ +
+
+
+
+
+ + + +
+
+
+
+
+ +
+
+
+
+
+ + + +
+
+
+
+
+ +
+
+
+
+
+
+ What is the root cause? +
+
+ +
+
+ Which repair addresses the root cause? +
+
+ +
+ +
+
+
+
+
+ +
+
+
+ + diff --git a/src/main/resources/lessons/cryptography/i18n/WebGoatLabels.properties b/src/main/resources/lessons/cryptography/i18n/WebGoatLabels.properties index 51f1f862163..d5ba18fda23 100644 --- a/src/main/resources/lessons/cryptography/i18n/WebGoatLabels.properties +++ b/src/main/resources/lessons/cryptography/i18n/WebGoatLabels.properties @@ -1,4 +1,23 @@ 6.crypto.title=Crypto Basics +timing-attacks.title=Timing Attacks + +timing-attacks.try-again=That value is not correct. Gather more timing samples and try again. +timing-attacks.first-byte.success=Correct. The response timing revealed the first byte without breaking HMAC-SHA256. +timing-attacks.tag.success=Correct. You reconstructed the short educational tag from the prefix-dependent delay. +timing-attacks.noise.success=Correct. Repeated measurements extracted the signal despite the jitter. +timing-attacks.mitigation.success=Correct. The early exit caused the leak, and MessageDigest.isEqual is the appropriate Java comparison primitive here. +timing-attacks.mitigation.try-again=Review whether the proposed repair removes the secret-dependent early exit or merely makes measurement harder. + +timing-attacks.hints.behavior=Every invalid response looks the same, but check whether every request takes the same amount of time. +timing-attacks.hints.measure=Measure the complete request duration and repeat each candidate more than once. +timing-attacks.hints.first-byte=Change only the first byte. Keep all remaining bytes at a fixed padding value. +timing-attacks.hints.keep-prefix=Once you identify a byte, keep that prefix fixed while testing the next position. +timing-attacks.hints.repeat=One slow request may be an outlier. Compare repeated measurements for each candidate. +timing-attacks.hints.noise=The random jitter can be larger than one byte of signal, so one sample cannot identify a candidate. +timing-attacks.hints.statistics=Compare candidate distributions with an aggregate such as the median or a trimmed mean. +timing-attacks.hints.elimination=Sample every candidate a few times, keep the strongest group, and spend more samples only on that group. +timing-attacks.hints.mitigation=Random sleep adds noise but does not remove the relationship between a matching prefix and execution time. +timing-attacks.hints.primitive=The HMAC primitive remains secure. Look at how the application compares the calculated and supplied tags. crypto-encoding.empty=Try again, did you decode it properly? crypto-encoding.success=Congratulations. That was easy, right? diff --git a/src/test/java/org/owasp/webgoat/lessons/cryptography/timing/InsecureHmacValidatorTest.java b/src/test/java/org/owasp/webgoat/lessons/cryptography/timing/InsecureHmacValidatorTest.java new file mode 100644 index 00000000000..4e0d5c90b05 --- /dev/null +++ b/src/test/java/org/owasp/webgoat/lessons/cryptography/timing/InsecureHmacValidatorTest.java @@ -0,0 +1,68 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.cryptography.timing; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; + +class InsecureHmacValidatorTest { + + @Test + void delayDependsOnTheNumberOfMatchingPrefixBytes() { + List delays = new ArrayList<>(); + var validator = new InsecureHmacValidator(delays::add, ignored -> 0); + byte[] expected = {0x01, 0x02, 0x03, 0x04}; + + assertThat(validator.matches(expected, new byte[] {0x01, 0x02, 0x7f, 0x00}, 5, 0)) + .isFalse(); + + assertThat(delays) + .containsExactly(TimeUnit.MILLISECONDS.toNanos(5), TimeUnit.MILLISECONDS.toNanos(5)); + } + + @Test + void firstByteMismatchHasNoMatchingByteDelay() { + List delays = new ArrayList<>(); + var validator = new InsecureHmacValidator(delays::add, ignored -> 0); + + assertThat( + validator.matches( + new byte[] {0x01, 0x02, 0x03, 0x04}, + new byte[] {0x7f, 0x02, 0x03, 0x04}, + 25, + 0)) + .isFalse(); + assertThat(delays).isEmpty(); + } + + @Test + void jitterIsIndependentOfTheMatchingPrefix() { + List delays = new ArrayList<>(); + var validator = new InsecureHmacValidator(delays::add, ignored -> 7); + + assertThat( + validator.matches( + new byte[] {0x01, 0x02, 0x03, 0x04}, + new byte[] {0x7f, 0x02, 0x03, 0x04}, + 2, + 8)) + .isFalse(); + assertThat(delays).containsExactly(TimeUnit.MILLISECONDS.toNanos(7)); + } + + @Test + void equalTagsMatchAfterEveryByteIsChecked() { + List delays = new ArrayList<>(); + var validator = new InsecureHmacValidator(delays::add, ignored -> 0); + byte[] tag = {0x01, 0x02, 0x03, 0x04}; + + assertThat(validator.matches(tag, tag.clone(), 2, 0)).isTrue(); + assertThat(delays).hasSize(tag.length); + } +} diff --git a/src/test/java/org/owasp/webgoat/lessons/cryptography/timing/TimingAttacksTest.java b/src/test/java/org/owasp/webgoat/lessons/cryptography/timing/TimingAttacksTest.java new file mode 100644 index 00000000000..66b986256e7 --- /dev/null +++ b/src/test/java/org/owasp/webgoat/lessons/cryptography/timing/TimingAttacksTest.java @@ -0,0 +1,197 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.cryptography.timing; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.Collections; +import java.util.HexFormat; +import org.junit.jupiter.api.Test; +import org.owasp.webgoat.container.lessons.LessonName; +import org.owasp.webgoat.container.plugins.LessonTest; +import org.owasp.webgoat.container.session.Course; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.mock.web.MockHttpSession; + +class TimingAttacksTest extends LessonTest { + + @Autowired private Course course; + + @Test + void assignmentsAreAttachedToTheTimingLesson() { + var timingLesson = course.getLessonByName(new LessonName("TimingAttacks")); + + assertThat(timingLesson.getAssignments()) + .extracting(assignment -> assignment.getName()) + .containsExactlyInAnyOrder( + "TimingAttackFirstByteAssignment", + "TimingAttackTagAssignment", + "TimingAttackNoiseAssignment", + "TimingAttackMitigationAssignment"); + } + + @Test + void lessonPageRenders() throws Exception { + mockMvc + .perform(get("/TimingAttacks.lesson")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("Discover the side channel"))) + .andExpect(content().string(containsString("MessageDigest.isEqual"))) + .andExpect(content().string(containsString("Connection to JWT"))); + } + + @Test + void invalidOracleResponsesAreIndistinguishable() throws Exception { + MockHttpSession session = new MockHttpSession(); + TimingAttackSessionState state = initializeState(session); + String incorrectTag = incorrectTagFor(state, TimingAttackSessionState.KNOWN_MESSAGE); + + mockMvc + .perform( + get("/crypto/timing/verify") + .session(session) + .param("message", TimingAttackSessionState.KNOWN_MESSAGE) + .param("signature", "not-hex!")) + .andExpect(status().isOk()) + .andExpect(content().string(TimingAttackOracle.INVALID)); + + mockMvc + .perform( + get("/crypto/timing/verify") + .session(session) + .param("message", TimingAttackSessionState.KNOWN_MESSAGE) + .param("signature", incorrectTag)) + .andExpect(status().isOk()) + .andExpect(content().string(TimingAttackOracle.INVALID)); + } + + @Test + void oracleAcceptsTheDynamicSessionTag() throws Exception { + MockHttpSession session = new MockHttpSession(); + String tag = tagFor(initializeState(session), TimingAttackSessionState.KNOWN_MESSAGE); + mockMvc + .perform( + get("/crypto/timing/verify-tag") + .session(session) + .param("message", TimingAttackSessionState.KNOWN_MESSAGE) + .param("signature", tag)) + .andExpect(status().isOk()) + .andExpect(content().string(TimingAttackOracle.VALID)); + } + + @Test + void firstByteAssignmentChecksTheCurrentSession() throws Exception { + MockHttpSession session = new MockHttpSession(); + String tag = tagFor(initializeState(session), TimingAttackSessionState.KNOWN_MESSAGE); + + mockMvc + .perform(post("/crypto/timing/first-byte").session(session).param("firstByte", "zz")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.lessonCompleted").value(false)); + + mockMvc + .perform( + post("/crypto/timing/first-byte") + .session(session) + .param("firstByte", tag.substring(0, 2))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.lessonCompleted").value(true)); + } + + @Test + void completeTagAssignmentsUseTheCorrectPerSessionTags() throws Exception { + MockHttpSession session = new MockHttpSession(); + TimingAttackSessionState state = initializeState(session); + String tag = tagFor(state, TimingAttackSessionState.KNOWN_MESSAGE); + String noisyTag = tagFor(state, TimingAttackSessionState.NOISY_MESSAGE); + + mockMvc + .perform(post("/crypto/timing/tag").session(session).param("signature", tag)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.lessonCompleted").value(true)); + + mockMvc + .perform(post("/crypto/timing/noisy-tag").session(session).param("signature", noisyTag)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.lessonCompleted").value(true)); + } + + @Test + void differentSessionsHaveIndependentState() throws Exception { + TimingAttackSessionState first = initializeState(new MockHttpSession()); + TimingAttackSessionState second = initializeState(new MockHttpSession()); + + assertThat(first).isNotSameAs(second); + } + + @Test + void oracleLimitsConcurrentRequestsForOneSession() throws Exception { + MockHttpSession session = new MockHttpSession(); + TimingAttackSessionState state = initializeState(session); + for (int i = 0; i < TimingAttackSessionState.MAX_CONCURRENT_REQUESTS; i++) { + assertThat(state.tryAcquireRequestSlot()).isTrue(); + } + + try { + mockMvc + .perform(get("/crypto/timing/verify").session(session).param("signature", "00000000")) + .andExpect(status().isTooManyRequests()); + } finally { + for (int i = 0; i < TimingAttackSessionState.MAX_CONCURRENT_REQUESTS; i++) { + state.releaseRequestSlot(); + } + } + } + + @Test + void mitigationRequiresTheRootCauseAndAppropriateApi() throws Exception { + mockMvc + .perform( + post("/crypto/timing/mitigation") + .param("cause", "early-exit") + .param("mitigation", "random-delay")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.lessonCompleted").value(false)) + .andExpect(jsonPath("$.feedback").value(containsString("secret-dependent early exit"))); + + mockMvc + .perform( + post("/crypto/timing/mitigation") + .param("cause", "early-exit") + .param("mitigation", "message-digest")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.lessonCompleted").value(true)); + } + + private TimingAttackSessionState initializeState(MockHttpSession session) throws Exception { + mockMvc + .perform(get("/crypto/timing/verify").session(session)) + .andExpect(status().isOk()) + .andExpect(content().string(TimingAttackOracle.INVALID)); + + return Collections.list(session.getAttributeNames()).stream() + .map(session::getAttribute) + .filter(TimingAttackSessionState.class::isInstance) + .map(TimingAttackSessionState.class::cast) + .findFirst() + .orElseThrow(); + } + + private static String tagFor(TimingAttackSessionState state, String message) { + return HexFormat.of().formatHex(state.tagFor(message)); + } + + private static String incorrectTagFor(TimingAttackSessionState state, String message) { + byte[] tag = state.tagFor(message); + tag[0] ^= 1; + return HexFormat.of().formatHex(tag); + } +} From d15692b311521df735fe8c0b097042b0769bdac1 Mon Sep 17 00:00:00 2001 From: Nanne Baars Date: Wed, 23 Sep 2026 15:31:50 +0100 Subject: [PATCH 6/8] test(sql-injection): cover mitigation form submissions Existing endpoint tests bypass the lesson forms and miss incorrect action URLs that return 404. Add regression coverage for both input validation forms to prevent this failure from returning. Closes: #2502 --- .../SqlInjectionMitigationsFormTest.java | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 src/test/java/org/owasp/webgoat/lessons/sqlinjection/mitigation/SqlInjectionMitigationsFormTest.java diff --git a/src/test/java/org/owasp/webgoat/lessons/sqlinjection/mitigation/SqlInjectionMitigationsFormTest.java b/src/test/java/org/owasp/webgoat/lessons/sqlinjection/mitigation/SqlInjectionMitigationsFormTest.java new file mode 100644 index 00000000000..07c63fff121 --- /dev/null +++ b/src/test/java/org/owasp/webgoat/lessons/sqlinjection/mitigation/SqlInjectionMitigationsFormTest.java @@ -0,0 +1,48 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.sqlinjection.mitigation; + +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.jsoup.Jsoup; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.owasp.webgoat.container.plugins.LessonTest; + +class SqlInjectionMitigationsFormTest extends LessonTest { + + @ParameterizedTest + @CsvSource( + delimiter = '|', + value = { + "userid_sql_only_input_validation|Smith';SELECT/**/*/**/from/**/user_system_data;--", + "userid_sql_only_input_validation_on_keywords|Smith';SESELECTLECT/**/*/**/FRFROMOM/**/user_system_data;--" + }) + void inputValidationFormsSubmitToWorkingEndpoints(String inputName, String solution) + throws Exception { + var response = + mockMvc + .perform(get("/WebGoat/SqlInjectionMitigations.lesson").contextPath("/WebGoat")) + .andExpect(status().isOk()) + .andReturn() + .getResponse(); + var form = + Jsoup.parse(response.getContentAsString()) + .selectFirst("form:has(input[name=" + inputName + "])"); + assertNotNull(form, "The lesson must contain the input validation form"); + assertEquals("POST", form.attr("method").toUpperCase(java.util.Locale.ROOT)); + + mockMvc + .perform(post(form.attr("action")).contextPath("/WebGoat").param(inputName, solution)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.lessonCompleted", is(true))); + } +} From f1a569c82c865b5c3007b5412c91aa637f4992ed Mon Sep 17 00:00:00 2001 From: Nanne Baars Date: Wed, 23 Sep 2026 15:48:23 +0100 Subject: [PATCH 7/8] ci: run matrix builds in parallel Run the operating-system builds concurrently now that the flaky tests have been stabilized. --- .github/workflows/build.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f67ec45fa39..66b812e0b0d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -37,7 +37,6 @@ jobs: fail-fast: true matrix: os: [ windows-latest, ubuntu-latest, macos-15-intel ] - max-parallel: 1 steps: - uses: actions/checkout@v7 - name: Set up JDK From 3284a8e466dfde083858f681e89aabb94e8b9c9e Mon Sep 17 00:00:00 2001 From: Nanne Baars Date: Wed, 23 Sep 2026 16:37:53 +0100 Subject: [PATCH 8/8] feat(http): add parameter pollution lesson Teach how duplicate parameters can cause validation and execution to interpret the same request differently, including safe handling guidance. --- .../HttpParameterPollution.java | 23 +++ .../ParameterPollutionMitigation.java | 17 ++ .../ParameterPollutionObservation.java | 60 +++++++ .../ParameterPollutionTransfer.java | 67 +++++++ .../css/http-parameter-pollution.css | 22 +++ .../HttpParameterPollution_1.adoc | 16 ++ .../HttpParameterPollution_2.adoc | 20 +++ .../HttpParameterPollution_3.adoc | 13 ++ .../HttpParameterPollution_4.adoc | 27 +++ .../html/HttpParameterPollution.html | 60 +++++++ .../i18n/WebGoatLabels.properties | 17 ++ .../HttpParameterPollutionTest.java | 165 ++++++++++++++++++ 12 files changed, 507 insertions(+) create mode 100644 src/main/java/org/owasp/webgoat/lessons/httpparameterpollution/HttpParameterPollution.java create mode 100644 src/main/java/org/owasp/webgoat/lessons/httpparameterpollution/ParameterPollutionMitigation.java create mode 100644 src/main/java/org/owasp/webgoat/lessons/httpparameterpollution/ParameterPollutionObservation.java create mode 100644 src/main/java/org/owasp/webgoat/lessons/httpparameterpollution/ParameterPollutionTransfer.java create mode 100644 src/main/resources/lessons/httpparameterpollution/css/http-parameter-pollution.css create mode 100644 src/main/resources/lessons/httpparameterpollution/documentation/HttpParameterPollution_1.adoc create mode 100644 src/main/resources/lessons/httpparameterpollution/documentation/HttpParameterPollution_2.adoc create mode 100644 src/main/resources/lessons/httpparameterpollution/documentation/HttpParameterPollution_3.adoc create mode 100644 src/main/resources/lessons/httpparameterpollution/documentation/HttpParameterPollution_4.adoc create mode 100644 src/main/resources/lessons/httpparameterpollution/html/HttpParameterPollution.html create mode 100644 src/main/resources/lessons/httpparameterpollution/i18n/WebGoatLabels.properties create mode 100644 src/test/java/org/owasp/webgoat/lessons/httpparameterpollution/HttpParameterPollutionTest.java diff --git a/src/main/java/org/owasp/webgoat/lessons/httpparameterpollution/HttpParameterPollution.java b/src/main/java/org/owasp/webgoat/lessons/httpparameterpollution/HttpParameterPollution.java new file mode 100644 index 00000000000..4adf0dc6b96 --- /dev/null +++ b/src/main/java/org/owasp/webgoat/lessons/httpparameterpollution/HttpParameterPollution.java @@ -0,0 +1,23 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2014 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.httpparameterpollution; + +import org.owasp.webgoat.container.lessons.Category; +import org.owasp.webgoat.container.lessons.Lesson; +import org.springframework.stereotype.Component; + +@Component +public class HttpParameterPollution extends Lesson { + + @Override + public Category getDefaultCategory() { + return Category.A7; + } + + @Override + public String getTitle() { + return "http-parameter-pollution.title"; + } +} diff --git a/src/main/java/org/owasp/webgoat/lessons/httpparameterpollution/ParameterPollutionMitigation.java b/src/main/java/org/owasp/webgoat/lessons/httpparameterpollution/ParameterPollutionMitigation.java new file mode 100644 index 00000000000..16a268664b2 --- /dev/null +++ b/src/main/java/org/owasp/webgoat/lessons/httpparameterpollution/ParameterPollutionMitigation.java @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2014 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.httpparameterpollution; + +final class ParameterPollutionMitigation { + + private ParameterPollutionMitigation() {} + + static String requireSingleValue(String[] values) { + if (values == null || values.length != 1) { + throw new IllegalArgumentException("Exactly one value is required"); + } + return values[0]; + } +} diff --git a/src/main/java/org/owasp/webgoat/lessons/httpparameterpollution/ParameterPollutionObservation.java b/src/main/java/org/owasp/webgoat/lessons/httpparameterpollution/ParameterPollutionObservation.java new file mode 100644 index 00000000000..3eda5526366 --- /dev/null +++ b/src/main/java/org/owasp/webgoat/lessons/httpparameterpollution/ParameterPollutionObservation.java @@ -0,0 +1,60 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2014 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.httpparameterpollution; + +import static org.apache.commons.text.StringEscapeUtils.escapeHtml4; +import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; +import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; + +import jakarta.servlet.http.HttpServletRequest; +import java.util.Arrays; +import org.owasp.webgoat.container.assignments.AssignmentEndpoint; +import org.owasp.webgoat.container.assignments.AssignmentHints; +import org.owasp.webgoat.container.assignments.AttackResult; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@AssignmentHints({ + "http-parameter-pollution.hints.observation.1", + "http-parameter-pollution.hints.observation.2" +}) +public class ParameterPollutionObservation implements AssignmentEndpoint { + + @GetMapping("/HttpParameterPollution/parameters") + @ResponseBody + public AttackResult showParameters(HttpServletRequest request) { + String[] values = request.getParameterValues("name"); + String firstValue = values == null || values.length == 0 ? "No value" : values[0]; + String allValues = values == null ? "[]" : Arrays.toString(values); + int valueCount = values == null ? 0 : values.length; + String output = + "

The request contains " + + valueCount + + " values named name.

" + + "

A component that expects one value reads: " + + escapeHtml4(firstValue) + + "

A component that accepts every value reads: " + + escapeHtml4(allValues) + + "

"; + + boolean hasDifferentValues = + values != null + && values.length > 1 + && Arrays.stream(values).skip(1).anyMatch(value -> !values[0].equals(value)); + + if (hasDifferentValues) { + return success(this) + .feedback("http-parameter-pollution.observation.success") + .output(output) + .build(); + } + return failed(this) + .feedback("http-parameter-pollution.observation.try-again") + .output(output) + .build(); + } +} diff --git a/src/main/java/org/owasp/webgoat/lessons/httpparameterpollution/ParameterPollutionTransfer.java b/src/main/java/org/owasp/webgoat/lessons/httpparameterpollution/ParameterPollutionTransfer.java new file mode 100644 index 00000000000..7ecffb1b6a2 --- /dev/null +++ b/src/main/java/org/owasp/webgoat/lessons/httpparameterpollution/ParameterPollutionTransfer.java @@ -0,0 +1,67 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2014 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.httpparameterpollution; + +import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; +import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; + +import jakarta.servlet.http.HttpServletRequest; +import org.owasp.webgoat.container.assignments.AssignmentEndpoint; +import org.owasp.webgoat.container.assignments.AssignmentHints; +import org.owasp.webgoat.container.assignments.AttackResult; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@AssignmentHints({ + "http-parameter-pollution.hints.transfer.1", + "http-parameter-pollution.hints.transfer.2", + "http-parameter-pollution.hints.transfer.3", + "http-parameter-pollution.hints.transfer.4" +}) +public class ParameterPollutionTransfer implements AssignmentEndpoint { + + @PostMapping("/HttpParameterPollution/transfer") + @ResponseBody + public AttackResult transfer(HttpServletRequest request) { + String[] recipients = request.getParameterValues("recipient"); + if (recipients == null || recipients.length == 0 || recipients[0].isBlank()) { + return failed(this) + .feedback("http-parameter-pollution.transfer.missing-recipient") + .build(); + } + + Integer amount = readAmount(request.getParameterValues("amount")); + if (amount == null) { + return failed(this).feedback("http-parameter-pollution.transfer.invalid-amount").build(); + } + + String validatedRecipient = recipients[0]; + String actualRecipient = recipients[recipients.length - 1]; + + if (!"alice".equals(validatedRecipient)) { + return failed(this).feedback("http-parameter-pollution.transfer.unauthorized").build(); + } + if ("attacker".equals(actualRecipient) && amount == 100) { + return success(this).feedback("http-parameter-pollution.transfer.success").build(); + } + if ("alice".equals(actualRecipient) && amount == 100) { + return failed(this).feedback("http-parameter-pollution.transfer.allowed").build(); + } + return failed(this).feedback("http-parameter-pollution.transfer.invalid-recipient").build(); + } + + private Integer readAmount(String[] amounts) { + if (amounts == null || amounts.length != 1) { + return null; + } + try { + return Integer.valueOf(amounts[0]); + } catch (NumberFormatException e) { + return null; + } + } +} diff --git a/src/main/resources/lessons/httpparameterpollution/css/http-parameter-pollution.css b/src/main/resources/lessons/httpparameterpollution/css/http-parameter-pollution.css new file mode 100644 index 00000000000..522461f605f --- /dev/null +++ b/src/main/resources/lessons/httpparameterpollution/css/http-parameter-pollution.css @@ -0,0 +1,22 @@ +.http-parameter-fields { + display: grid; + grid-template-columns: max-content 20rem; + gap: 0.5rem; + align-items: center; + margin-bottom: 0.5rem; +} + +.http-parameter-fields label { + margin: 0; +} + +.http-parameter-fields input { + box-sizing: border-box; + width: 100%; +} + +@media (max-width: 576px) { + .http-parameter-fields { + grid-template-columns: 1fr; + } +} diff --git a/src/main/resources/lessons/httpparameterpollution/documentation/HttpParameterPollution_1.adoc b/src/main/resources/lessons/httpparameterpollution/documentation/HttpParameterPollution_1.adoc new file mode 100644 index 00000000000..377a25472ce --- /dev/null +++ b/src/main/resources/lessons/httpparameterpollution/documentation/HttpParameterPollution_1.adoc @@ -0,0 +1,16 @@ +== Introduction: one name, multiple values + +HTTP Parameter Pollution (HPP) occurs when the same parameter name appears more than once in an HTTP request. + +Consider this request: + +[source,text] +---- +?color=red&color=blue +---- + +What is the value of `color`? The answer depends on how the receiving application interprets the request. + +Different components can interpret the same request differently. A proxy might inspect one value while the application uses another. Frameworks, gateways, and custom validation code can also disagree. + +The security problem begins when one interpretation controls a security decision and another controls the operation. diff --git a/src/main/resources/lessons/httpparameterpollution/documentation/HttpParameterPollution_2.adoc b/src/main/resources/lessons/httpparameterpollution/documentation/HttpParameterPollution_2.adoc new file mode 100644 index 00000000000..085551667a8 --- /dev/null +++ b/src/main/resources/lessons/httpparameterpollution/documentation/HttpParameterPollution_2.adoc @@ -0,0 +1,20 @@ +== Observe the parser + +Different parts of an application may interpret duplicate parameters in different ways. + +For example, a component might use the first value, the last value, or the complete list. + +=== Assignment + +The form below has two inputs named `name`. The browser sends both values in one query string: + +[source,text] +---- +?name=WebGoat&name=WebGoat +---- + +Submit the form once and compare what a component that expects one value sees with what a component that accepts several values sees. + +Then change one input so the two values differ and submit the form again. + +The endpoint reads the complete value array. It deliberately displays the first value beside the full array, so the result does not depend on implicit framework behavior. diff --git a/src/main/resources/lessons/httpparameterpollution/documentation/HttpParameterPollution_3.adoc b/src/main/resources/lessons/httpparameterpollution/documentation/HttpParameterPollution_3.adoc new file mode 100644 index 00000000000..9af0a6f8d74 --- /dev/null +++ b/src/main/resources/lessons/httpparameterpollution/documentation/HttpParameterPollution_3.adoc @@ -0,0 +1,13 @@ +== Exploit: transfer validation bypass + +This exercise contains a fictional transfer function. You may transfer 100 credits to `alice`. The recipient `attacker` is not approved. + +A direct transfer to `attacker` fails. A normal transfer to `alice` succeeds, but it does not complete the assignment. + +The validation code and transfer code do not agree on the meaning of the request. + +=== Assignment + +Transfer exactly 100 credits to `attacker` without failing the recipient validation. Use what you observed on the previous page. + +The form creates an ordinary transfer request with one recipient. You may use browser developer tools or an intercepting proxy to inspect and modify that request. diff --git a/src/main/resources/lessons/httpparameterpollution/documentation/HttpParameterPollution_4.adoc b/src/main/resources/lessons/httpparameterpollution/documentation/HttpParameterPollution_4.adoc new file mode 100644 index 00000000000..987ed223446 --- /dev/null +++ b/src/main/resources/lessons/httpparameterpollution/documentation/HttpParameterPollution_4.adoc @@ -0,0 +1,27 @@ +== Mitigation + +Define how many values each parameter accepts. Then make validation and business logic use the same canonical representation. + +For a single-valued recipient, reject requests that contain anything other than one value: + +[source,java] +---- +values = allValues("recipient"); +if (values == null || values.size() != 1) { + reject("Exactly one recipient is required"); +} + +recipient = values.get(0); +validateAllowedRecipient(recipient); +transfer(recipient, amount); +---- + +If an input is intentionally multi-valued, model it as a collection and validate every value. Choosing the first or last value is not a general defense. The application must define the parameter contract and use it consistently. + +=== Spring MVC handling + +Spring MVC binds query parameters and form data with `@RequestParam`. Declaring the controller argument as an array or `List` resolves all values that use the same parameter name. A `MultiValueMap` can retain all values for every parameter. + +Do not rely on scalar binding as a duplicate-parameter policy. For a security-sensitive, single-valued field, read the complete value set with `HttpServletRequest.getParameterValues()`, reject unexpected multiplicity, and pass the one accepted value to both validation and business logic. + +This lesson also reads the complete value array. Its vulnerable endpoint deliberately selects the first recipient for validation and the last recipient for execution. The mismatch is explicit, so the exercise does not depend on undocumented Spring or servlet-container behavior. diff --git a/src/main/resources/lessons/httpparameterpollution/html/HttpParameterPollution.html b/src/main/resources/lessons/httpparameterpollution/html/HttpParameterPollution.html new file mode 100644 index 00000000000..d0f4ebbdea6 --- /dev/null +++ b/src/main/resources/lessons/httpparameterpollution/html/HttpParameterPollution.html @@ -0,0 +1,60 @@ + + + + + + +
+ +
+
+ +
+ +
+
+
+
+
+
+ + + + +
+ +
+
+
+
+
+
+ +
+ +
+
+
+
+
+
+ + + + +
+ +
+
+
+
+
+
+ +
+ +
+
+ diff --git a/src/main/resources/lessons/httpparameterpollution/i18n/WebGoatLabels.properties b/src/main/resources/lessons/httpparameterpollution/i18n/WebGoatLabels.properties new file mode 100644 index 00000000000..6caac28efa2 --- /dev/null +++ b/src/main/resources/lessons/httpparameterpollution/i18n/WebGoatLabels.properties @@ -0,0 +1,17 @@ +http-parameter-pollution.title=HTTP Parameter Pollution + +http-parameter-pollution.hints.observation.1=Can one parameter name occur more than once in a query string? +http-parameter-pollution.hints.observation.2=Try name=WebGoat&name=OWASP. +http-parameter-pollution.observation.success=The two components received the same request but used its values differently. +http-parameter-pollution.observation.try-again=Both components currently agree. Change one name value and submit the form again. + +http-parameter-pollution.hints.transfer.1=Submit one transfer to alice and one to attacker. Compare the feedback from both requests. +http-parameter-pollution.hints.transfer.2=The form has one recipient input, but the HTTP request can contain recipient more than once. Intercept the request or edit it with your browser tools. +http-parameter-pollution.hints.transfer.3=Validation checks the first recipient value, while the transfer uses the last value. +http-parameter-pollution.hints.transfer.4=Send alice as the first recipient and attacker as the last recipient. Keep a single amount with the value 100. +http-parameter-pollution.transfer.missing-recipient=The recipient parameter is missing. +http-parameter-pollution.transfer.invalid-amount=Submit exactly one numeric amount with a value of 100. +http-parameter-pollution.transfer.invalid-recipient=The transfer did not send 100 credits to the attacker. +http-parameter-pollution.transfer.unauthorized=The validated recipient is not approved. +http-parameter-pollution.transfer.allowed=The transfer to alice succeeded, but the exploit assignment is not complete. +http-parameter-pollution.transfer.success=The validation passed for alice, but the transfer sent 100 credits to the attacker. diff --git a/src/test/java/org/owasp/webgoat/lessons/httpparameterpollution/HttpParameterPollutionTest.java b/src/test/java/org/owasp/webgoat/lessons/httpparameterpollution/HttpParameterPollutionTest.java new file mode 100644 index 00000000000..0667ea3589f --- /dev/null +++ b/src/test/java/org/owasp/webgoat/lessons/httpparameterpollution/HttpParameterPollutionTest.java @@ -0,0 +1,165 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2014 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.httpparameterpollution; + +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.hamcrest.Matchers.containsString; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.Test; +import org.owasp.webgoat.container.plugins.LessonTest; + +class HttpParameterPollutionTest extends LessonTest { + + @Test + void singleParameterShowsBothInterpretationsWithoutCompletingTheAssignment() throws Exception { + mockMvc + .perform(get("/HttpParameterPollution/parameters").param("name", "WebGoat")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.lessonCompleted").value(false)) + .andExpect(jsonPath("$.output").value(containsString("request contains 1"))) + .andExpect(jsonPath("$.output").value(containsString("WebGoat"))) + .andExpect(jsonPath("$.output").value(containsString("[WebGoat]"))); + } + + @Test + void differentDuplicateParametersCompleteTheObservationAssignment() throws Exception { + mockMvc + .perform( + get("/HttpParameterPollution/parameters") + .param("name", "WebGoat") + .param("name", "OWASP")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.lessonCompleted").value(true)) + .andExpect(jsonPath("$.output").value(containsString("request contains 2"))) + .andExpect(jsonPath("$.output").value(containsString("one value reads: WebGoat"))) + .andExpect(jsonPath("$.output").value(containsString("[WebGoat, OWASP]"))); + } + + @Test + void equalDuplicateParametersDoNotCompleteTheObservationAssignment() throws Exception { + mockMvc + .perform( + get("/HttpParameterPollution/parameters") + .param("name", "WebGoat") + .param("name", "WebGoat")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.lessonCompleted").value(false)); + } + + @Test + void allowedTransferDoesNotCompleteTheExploitAssignment() throws Exception { + mockMvc + .perform( + post("/HttpParameterPollution/transfer") + .param("recipient", "alice") + .param("amount", "100")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.lessonCompleted").value(false)) + .andExpect(jsonPath("$.feedback").value(containsString("transfer to alice succeeded"))); + } + + @Test + void directTransferToAttackerIsBlocked() throws Exception { + mockMvc + .perform( + post("/HttpParameterPollution/transfer") + .param("recipient", "attacker") + .param("amount", "100")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.lessonCompleted").value(false)); + } + + @Test + void duplicateRecipientCanExploitTheInterpretationMismatch() throws Exception { + mockMvc + .perform( + post("/HttpParameterPollution/transfer") + .param("recipient", "alice") + .param("recipient", "attacker") + .param("amount", "100")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.lessonCompleted").value(true)); + } + + @Test + void reverseRecipientOrderFailsValidation() throws Exception { + mockMvc + .perform( + post("/HttpParameterPollution/transfer") + .param("recipient", "attacker") + .param("recipient", "alice") + .param("amount", "100")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.lessonCompleted").value(false)); + } + + @Test + void lastOfThreeRecipientValuesControlsTheTransfer() throws Exception { + mockMvc + .perform( + post("/HttpParameterPollution/transfer") + .param("recipient", "alice") + .param("recipient", "attacker") + .param("recipient", "bob") + .param("amount", "100")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.lessonCompleted").value(false)); + + mockMvc + .perform( + post("/HttpParameterPollution/transfer") + .param("recipient", "alice") + .param("recipient", "bob") + .param("recipient", "attacker") + .param("amount", "100")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.lessonCompleted").value(true)); + } + + @Test + void missingRecipientFailsCleanly() throws Exception { + mockMvc + .perform(post("/HttpParameterPollution/transfer").param("amount", "100")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.lessonCompleted").value(false)); + } + + @Test + void missingAmountFailsCleanly() throws Exception { + mockMvc + .perform(post("/HttpParameterPollution/transfer").param("recipient", "alice")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.lessonCompleted").value(false)); + } + + @Test + void duplicateAmountFailsCleanly() throws Exception { + mockMvc + .perform( + post("/HttpParameterPollution/transfer") + .param("recipient", "alice") + .param("recipient", "attacker") + .param("amount", "100") + .param("amount", "100")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.lessonCompleted").value(false)); + } + + @Test + void mitigationAcceptsExactlyOneRecipient() { + assertEquals("alice", ParameterPollutionMitigation.requireSingleValue(new String[] {"alice"})); + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy( + () -> + ParameterPollutionMitigation.requireSingleValue( + new String[] {"alice", "attacker"})); + } + +}