-
-
Notifications
You must be signed in to change notification settings - Fork 618
feat: add cloud log secret leakage challenge (#345) #2638
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Jeffy123-zhu
wants to merge
4
commits into
OWASP:master
Choose a base branch
from
Jeffy123-zhu:feature/issue-345-cloud-log-leak
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+340
−3
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
2056111
feat: add cloud log secret leakage challenge (#345)
Jeffy123-zhu 8190d3b
Merge branch 'master' into feature/issue-345-cloud-log-leak
Jeffy123-zhu c8d6dbd
Merge branch 'master' into feature/issue-345-cloud-log-leak
Jeffy123-zhu 09b28bd
Merge branch 'master' into feature/issue-345-cloud-log-leak
commjoen File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
95 changes: 95 additions & 0 deletions
95
src/main/java/org/owasp/wrongsecrets/challenges/cloud/Challenge67.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,95 @@ | ||||||||||
| package org.owasp.wrongsecrets.challenges.cloud; | ||||||||||
|
|
||||||||||
| 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; | ||||||||||
|
|
||||||||||
| /** | ||||||||||
| * 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() { | ||||||||||
| String secret = resolveSecret(); | ||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||
| leakSecretToCloudLogging(secret); | ||||||||||
| return secret; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| private String resolveSecret() { | ||||||||||
| if (configuredSecret == null | ||||||||||
| || configuredSecret.isBlank() | ||||||||||
| || NOT_SET.equals(configuredSecret)) { | ||||||||||
|
Comment on lines
+59
to
+61
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||
| 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); | ||||||||||
| } | ||||||||||
| } | ||||||||||
| } | ||||||||||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
15
src/main/resources/explanations/challenge67_hint-azure.adoc
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
145 changes: 145 additions & 0 deletions
145
src/test/java/org/owasp/wrongsecrets/challenges/cloud/Challenge67Test.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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())); | ||
| } | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.