Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
2056111
feat: add cloud log secret leakage challenge (#345)
Jeffy123-zhu Sep 1, 2026
8190d3b
Merge branch 'master' into feature/issue-345-cloud-log-leak
Jeffy123-zhu Sep 1, 2026
c8d6dbd
Merge branch 'master' into feature/issue-345-cloud-log-leak
Jeffy123-zhu Sep 1, 2026
09b28bd
Merge branch 'master' into feature/issue-345-cloud-log-leak
commjoen Sep 5, 2026
55469c8
Update src/main/resources/wrong-secrets-configuration.yaml
Jeffy123-zhu Sep 8, 2026
186ab0b
Merge branch 'master' into feature/issue-345-cloud-log-leak
commjoen Sep 9, 2026
6148fa3
[pre-commit.ci lite] apply automatic fixes
pre-commit-ci-lite[bot] Sep 9, 2026
e888b4b
Update README.md
Jeffy123-zhu Sep 10, 2026
3243f3f
Update src/main/java/org/owasp/wrongsecrets/challenges/cloud/Challeng…
Jeffy123-zhu Sep 10, 2026
f0265e8
Update src/main/java/org/owasp/wrongsecrets/challenges/cloud/Challeng…
Jeffy123-zhu Sep 10, 2026
cc33d84
Update src/main/java/org/owasp/wrongsecrets/challenges/cloud/Challeng…
Jeffy123-zhu Sep 10, 2026
0db2c92
Merge branch 'master' into feature/issue-345-cloud-log-leak
Jeffy123-zhu Sep 10, 2026
9351faa
Merge branch 'master' into feature/issue-345-cloud-log-leak
commjoen Sep 13, 2026
5f7dbec
fix compiler issues and make secret long term and leak only once
commjoen Sep 13, 2026
867dc68
[pre-commit.ci lite] apply automatic fixes
pre-commit-ci-lite[bot] Sep 13, 2026
5f304c0
fix tests
commjoen Sep 13, 2026
c61d08c
Merge branch 'master' into feature/issue-345-cloud-log-leak
commjoen Sep 13, 2026
d563ffd
Merge branch 'master' into feature/issue-345-cloud-log-leak
commjoen Sep 14, 2026
4750101
Merge branch 'master' into feature/issue-345-cloud-log-leak
commjoen Sep 14, 2026
e0f574d
Merge branch 'master' into feature/issue-345-cloud-log-leak
Jeffy123-zhu Sep 15, 2026
1be7ee1
Merge branch 'master' into feature/issue-345-cloud-log-leak
Jeffy123-zhu Sep 16, 2026
216e840
Merge branch 'master' into feature/issue-345-cloud-log-leak
Jeffy123-zhu Sep 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ Not sure which setup is right for you? Here's a quick guide:
| Try it quickly online | [Container running on Heroku](https://www.wrongsecrets.com/) | Basic challenges (0-4, 8, 12-32, 34-43, 49-52, 54-66) |
| Run locally with Docker | [Basic Docker](#basic-docker-exercises) | Same as above, but on your machine |
| Learn Kubernetes secrets | [K8s/Minikube Setup](#basic-k8s-exercise) | Kubernetes challenges (0-6, 8, 12-43, 48-66) |
| Practice with cloud secrets | [Cloud Challenges](#cloud-challenges) | All challenges (0-66) |
| Practice with cloud secrets | [Cloud Challenges](#cloud-challenges) | All challenges (0-70) |
| Run a workshop/CTF | [CTF Setup](#ctf) | Customizable challenge sets |
| Contribute to the project | [Development Setup](#notes-on-development) | All challenges + development tools |

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package org.owasp.wrongsecrets.challenges.cloud;

import com.google.common.base.Strings;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.Base64;
import lombok.extern.slf4j.Slf4j;
import org.owasp.wrongsecrets.challenges.FixedAnswerChallenge;
import org.slf4j.MDC;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

/**
* Cloud challenge which leaks a Base64 encoded secret into the log stream of the cloud provider.
*
* <p>The application never exposes the secret through an endpoint, a file or an environment
* variable: it only writes the encoded value to standard out. The logging agent of the cloud
* provider (CloudWatch Logs on AWS, Cloud Logging on GCP, Log Analytics on Azure) ships that line
* to the central log sink, so the log sink is the only place where the secret can be retrieved.
*
* <p>Note the difference with {@code Challenge8}: that challenge logs the answer in plain text and
* is solvable from local container logs. Here the value is encoded first, and the challenge is only
* offered in the cloud environments.
*
* <p>See <a href="https://github.com/OWASP/wrongsecrets/issues/345">issue 345</a>.
*/
@Slf4j
@Component
public class Challenge67 extends FixedAnswerChallenge {

private static final String NOT_SET = "not_set";
private static final String MDC_CHALLENGE_KEY = "wrongsecrets.challenge";
private static final String MDC_PAYLOAD_KEY = "audit.payload";
private static final String ALPHABET =
"0123456789QWERTYUIOPASDFGHJKLZXCVBNMqwertyuiopasdfghjklzxcvbnm";
private static final int GENERATED_SECRET_LENGTH = 16;

private final SecureRandom secureRandom = new SecureRandom();
private final String configuredSecret;
Comment thread
Jeffy123-zhu marked this conversation as resolved.
private String secret;

/**
* Cloud challenge which leaks a Base64 encoded secret towards the log sink of the cloud provider.
*
* @param configuredSecret the secret injected by the cloud deployment; when it is absent a random
* secret is generated so that every boot still has a unique answer instead of a value that
* can be read from this repository
*/
public Challenge67(@Value("${challenge67_cloud_log_secret}") String configuredSecret) {
this.configuredSecret = configuredSecret;
}

@Override
public String getAnswer() {
if (Strings.isNullOrEmpty(secret)) {
secret = resolveSecret();
leakSecretToCloudLogging(secret);
}
return secret;
}

private String resolveSecret() {
if (Strings.isNullOrEmpty(configuredSecret) || NOT_SET.equals(configuredSecret)) {
return generateRandomSecret();
}
return configuredSecret;
}

private String generateRandomSecret() {
StringBuilder builder = new StringBuilder(GENERATED_SECRET_LENGTH);
for (int i = 0; i < GENERATED_SECRET_LENGTH; i++) {
builder.append(ALPHABET.charAt(secureRandom.nextInt(ALPHABET.length())));
}
return builder.toString();
}

/**
* Writes the Base64 encoded secret to the log stream, both inside the message and as a structured
* MDC field. Shipping an "audit event" with the raw payload attached is a realistic way for a
* credential to end up in a cloud log sink without anybody noticing.
*
* @param secret the plain text secret which is the answer to this challenge
*/
private void leakSecretToCloudLogging(String secret) {
String encodedSecret =
Base64.getEncoder().encodeToString(secret.getBytes(StandardCharsets.UTF_8));
MDC.put(MDC_CHALLENGE_KEY, "challenge-67");
MDC.put(MDC_PAYLOAD_KEY, encodedSecret);
try {
log.info(
"Shipping audit event to the cloud logging sink, encoded credential: {}", encodedSecret);
} finally {
MDC.remove(MDC_CHALLENGE_KEY);
MDC.remove(MDC_PAYLOAD_KEY);
}
}
}
1 change: 1 addition & 0 deletions src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ challenge_rando_key_ctf_to_provide_to_host_value=not_set
challenge_thirty_ctf_to_provide_to_host_value=not_set
challenge_acht_ctf_to_provide_to_host_value=not_set
challenge_acht_ctf_host_value=not_set
challenge67_cloud_log_secret=not_set
CTF_SERVER_ADDRESS=not_set
reason_enabled=true
plainText13=This is not the secret
Expand Down
9 changes: 9 additions & 0 deletions src/main/resources/explanations/challenge67.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
=== Secrets shipped to your cloud log sink

Applications rarely log a secret on purpose. What happens far more often is that an "audit event", a request dump, or a structured log field carries a credential along with it. Because the value is encoded, nobody notices it during code review: it just looks like an opaque blob.

In this challenge the application writes such an audit event to standard out. It never returns the secret through an endpoint, never mounts it as a file, and never puts it in an environment variable. The only place the value shows up is the log stream that your cloud provider collects for you, so you will have to go and query the log sink of the cloud you are running on.

Note that the logged value is Base64 encoded, so finding the line is only half of the work.

Tip: this is not the same as challenge 8. There the answer is logged in plain text and you can read it straight from your local container logs.
15 changes: 15 additions & 0 deletions src/main/resources/explanations/challenge67_hint-azure.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
You can solve this challenge by the following steps:

1. Make sure Container Insights is enabled for your AKS cluster, so the container output lands in your https://portal.azure.com[*Log Analytics*] workspace.
2. Query the `ContainerLogV2` table for the audit event, either in the portal or from the CLI:
+
[source,shell]
----
az monitor log-analytics query \
--workspace <workspaceId> \
--analytics-query "ContainerLogV2 | where LogMessage contains 'encoded credential' | project TimeGenerated, LogMessage | take 5"
----
3. On older workspaces the table is called `ContainerLog` and the column `LogEntry`, so use `ContainerLog | where LogEntry contains 'encoded credential'` instead.
4. Take the Base64 blob from the message and decode it: `echo '<blob>' | base64 -d`. That decoded value is the answer.

Not seeing anything yet? The event is emitted the first time the challenge is opened, so hit the page once and query again.
15 changes: 15 additions & 0 deletions src/main/resources/explanations/challenge67_hint-gcp.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
You can solve this challenge by the following steps:

1. Open https://console.cloud.google.com/logs/query[*Cloud Logging*] (formerly Stackdriver) for the project that hosts your GKE cluster.
2. Query for the audit event that the application emits:
+
[source,shell]
----
gcloud logging read \
'resource.type="k8s_container" AND textPayload:"encoded credential"' \
--limit=5 --format='value(textPayload)'
----
3. In the console you can use the same filter in the Log Explorer query box: `resource.type="k8s_container"` combined with `textPayload:"encoded credential"`.
4. Take the Base64 blob from the message and decode it: `echo '<blob>' | base64 -d`. That decoded value is the answer.

Not seeing anything yet? The event is emitted the first time the challenge is opened, so hit the page once and query again.
17 changes: 17 additions & 0 deletions src/main/resources/explanations/challenge67_hint.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
You can solve this challenge by the following steps:

1. Find the log group of the WrongSecrets container in https://console.aws.amazon.com/cloudwatch/[*CloudWatch Logs*]. With the EKS setup from the `aws` folder the logs of the pod are shipped to a log group named after the cluster, for example `/aws/containerinsights/<clustername>/application`.
2. Query the log group for the audit event, for instance with CloudWatch Logs Insights:
+
[source,shell]
----
aws logs start-query \
--log-group-name "/aws/containerinsights/<clustername>/application" \
--start-time $(($(date +%s) - 3600)) \
--end-time $(date +%s) \
--query-string 'fields @message | filter @message like /encoded credential/'
----
3. Alternatively tail it directly: `aws logs tail "/aws/containerinsights/<clustername>/application" --follow --filter-pattern "encoded credential"`.
4. Take the Base64 blob from the message and decode it: `echo '<blob>' | base64 -d`. That decoded value is the answer.

Not seeing anything yet? The event is emitted the first time the challenge is opened, so hit the page once and query again.
17 changes: 17 additions & 0 deletions src/main/resources/explanations/challenge67_reason.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
*Why logging a secret to your cloud provider is a problem*

Encoding is not encryption. Base64 keeps a credential out of sight during a quick code review, but anybody who can read the log sink can decode it in one command. Treat an encoded secret in a log line as a plain text secret.

A few things that make this particularly nasty in a cloud setup:

- the audience is much wider than you think. Log sinks are usually readable by the whole platform or SRE team, and often by any workload with a broad `logs:FilterLogEvents`, `roles/logging.viewer` or Log Analytics reader permission. The blast radius of the secret becomes the blast radius of your logging permissions.
- retention outlives rotation. Logs are commonly kept for months and replicated into an archive bucket or a SIEM. Rotating the credential does not remove the old value from those copies, so you have to treat every downstream sink as compromised too.
- structured logging makes it easy to leak by accident. Attaching a whole payload, request or MDC context to an "audit event" is convenient, and it pulls in whatever happens to be in that context, including tokens and keys.
- log data leaves your trust boundary. Shipping logs to a third party observability vendor means the secret leaves your account, which is usually not covered by the threat model you wrote for that secret.

What to do instead:

- never put credentials in a log statement, not even encoded, and not even at `DEBUG`.
- redact at the source. Filter sensitive keys before they reach the appender, for example with a Logback converter or a masking layout, so a future code change cannot reintroduce the leak.
- scan for it. Secret detection tooling can run against log output as well as against source code.
- if it did happen: rotate the secret, then clean up or expire every sink and archive that received it.
23 changes: 23 additions & 0 deletions src/main/resources/wrong-secrets-configuration.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1014,6 +1014,29 @@ configurations:
ctf:
enabled: true

- name: Challenge 67
short-name: "challenge-67"
sources:
- class-name: "org.owasp.wrongsecrets.challenges.cloud.Challenge67"
explanation: "explanations/challenge67.adoc"
hint: "explanations/challenge67_hint.adoc"
reason: "explanations/challenge67_reason.adoc"
environments: [ *aws ]
- class-name: "org.owasp.wrongsecrets.challenges.cloud.Challenge67"
explanation: "explanations/challenge67.adoc"
hint: "explanations/challenge67_hint-gcp.adoc"
reason: "explanations/challenge67_reason.adoc"
environments: [ *gcp ]
- class-name: "org.owasp.wrongsecrets.challenges.cloud.Challenge67"
explanation: "explanations/challenge67.adoc"
hint: "explanations/challenge67_hint-azure.adoc"
reason: "explanations/challenge67_reason.adoc"
environments: [ *azure ]
difficulty: *normal
category: *logging
ctf:
enabled: false

- name: Challenge 68
short-name: "challenge-68"
sources:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package org.owasp.wrongsecrets.challenges.cloud;

import static org.assertj.core.api.Assertions.assertThat;

import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.AppenderBase;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.slf4j.LoggerFactory;

class Challenge67Test {

private static final String CONFIGURED_SECRET = "cloudwatch-leak-42";

private Logger challengeLogger;
private CapturingAppender appender;

@BeforeEach
void attachAppender() {
challengeLogger = (Logger) LoggerFactory.getLogger(Challenge67.class);
appender = new CapturingAppender();
appender.setContext(challengeLogger.getLoggerContext());
appender.start();
challengeLogger.addAppender(appender);
}

@AfterEach
void detachAppender() {
challengeLogger.detachAppender(appender);
appender.stop();
}

@Test
void spoilerShouldRevealConfiguredSecretAndSolveAnswer() {
var challenge = new Challenge67(CONFIGURED_SECRET);

assertThat(challenge.spoiler().solution()).isEqualTo(CONFIGURED_SECRET);
assertThat(challenge.answerCorrect(CONFIGURED_SECRET)).isTrue();
}

@Test
void spoilerShouldRevealGeneratedSecretWhenNotConfigured() {
var challenge = new Challenge67("not_set");

var answer = challenge.spoiler().solution();

assertThat(answer).isNotEmpty().hasSize(16).doesNotContain("not_set");
assertThat(challenge.answerCorrect(answer)).isTrue();
}

@Test
void spoilerShouldRevealGeneratedSecretWhenConfiguredValueIsBlank() {
var challenge = new Challenge67("");

var answer = challenge.spoiler().solution();

assertThat(answer).hasSize(16);
assertThat(challenge.answerCorrect(answer)).isTrue();
}

@Test
void incorrectAnswerShouldNotSolveChallenge() {
var challenge = new Challenge67(CONFIGURED_SECRET);

assertThat(challenge.answerCorrect("not-the-secret")).isFalse();
assertThat(challenge.answerCorrect("")).isFalse();
}

@Test
void answerShouldBeLoggedBase64EncodedAndNeverInPlainText() {
var challenge = new Challenge67(CONFIGURED_SECRET);

var answer = challenge.spoiler().solution();
var expectedEncoded =
Base64.getEncoder().encodeToString(answer.getBytes(StandardCharsets.UTF_8));

assertThat(appender.messages).isNotEmpty();
assertThat(appender.messages).anyMatch(message -> message.contains(expectedEncoded));
assertThat(appender.messages).noneMatch(message -> message.contains(answer));
assertThat(appender.levels).contains(Level.INFO);
}

@Test
void encodedAnswerShouldBeAttachedAsStructuredLogField() {
var challenge = new Challenge67(CONFIGURED_SECRET);

var answer = challenge.spoiler().solution();
var expectedEncoded =
Base64.getEncoder().encodeToString(answer.getBytes(StandardCharsets.UTF_8));

assertThat(appender.mdcSnapshots)
.anySatisfy(
mdc -> {
assertThat(mdc).containsEntry("wrongsecrets.challenge", "challenge-67");
assertThat(mdc).containsEntry("audit.payload", expectedEncoded);
});
}

@Test
void answerShouldBeCachedSoTheSecretIsOnlyLeakedOnce() {
var challenge = new Challenge67("not_set");

var first = challenge.spoiler().solution();
var second = challenge.spoiler().solution();

assertThat(first).isEqualTo(second);
assertThat(appender.messages).hasSize(1);
}

@Test
void mdcShouldBeCleanedUpAfterLogging() {
new Challenge67(CONFIGURED_SECRET).spoiler();

assertThat(org.slf4j.MDC.get("audit.payload")).isNull();
assertThat(org.slf4j.MDC.get("wrongsecrets.challenge")).isNull();
}

/**
* Appender which snapshots the message, level and MDC while the event is being appended. Logback
* populates {@link ILoggingEvent#getMDCPropertyMap()} lazily, so reading it after the challenge
* cleared the MDC would return an empty map.
*/
private static final class CapturingAppender extends AppenderBase<ILoggingEvent> {

private final List<String> messages = new ArrayList<>();
private final List<Level> levels = new ArrayList<>();
private final List<Map<String, String>> mdcSnapshots = new ArrayList<>();

@Override
protected void append(ILoggingEvent event) {
messages.add(event.getFormattedMessage());
levels.add(event.getLevel());
mdcSnapshots.add(new HashMap<>(event.getMDCPropertyMap()));
}
}
}