Reading /etc/passwd via Translation Upload in Tolgee
Tolgee's XML translation importers did not disable external entities. A crafted upload let me read /etc/passwd from its cloud platform.
How I Found It
Tolgee is an open-source localization platform. Teams use it to manage translations for their apps, and one of its core features is importing translation files in formats like Android XML, XLIFF, .resx, and Apple stringsdict.
Every one of those formats is XML.
When I see an application that accepts user-uploaded XML files and processes them server-side, I check whether the parser disables external entity resolution. If it doesn’t, a custom entity in a DTD can point to a local file. The parser may resolve it and put the file contents wherever the entity was referenced. That’s XML external entity injection, or XXE.
I cloned the Tolgee repo and grepped for XMLInputFactory and DocumentBuilderFactory, the two standard ways to create XML parsers in the JVM ecosystem. Six results. I opened each one and looked for the security properties that should be there: IS_SUPPORTING_EXTERNAL_ENTITIES, SUPPORT_DTD, disallow-doctype-decl.
None of them had any.
The Vulnerable Code
Here’s the Android XML resource importer:
// XmlResourcesProcessor.kt
val inputFactory: XMLInputFactory = XMLInputFactory.newInstance()
inputFactory.createXMLEventReader(context.file.data.inputStream())
The factory is created without any security settings. In the setup I tested, it followed SYSTEM entity declarations, resolved file:// and http:// URIs, and substituted the results inline.
The same two lines appeared in five other files:
ResxProcessor.kt(.NET resource files),XMLInputFactory.newInstance()XliffFileProcessor.kt(XLIFF translations),XMLInputFactory.newDefaultFactory()StringsdictFileProcessor.kt(Apple stringsdict), same patternTextToXmlResourcesConvertor.kt(output converter),DocumentBuilderFactory.newInstance()domBuilder.kt(DOM utility),DocumentBuilderFactory.newInstance()in two separate places
Six entry points. Any of them could be reached by uploading a file through the import UI.
Exploitation
The payload is a standard Android XML resource file with a DTD that defines an external entity:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE resources [
<!ENTITY xxe SYSTEM "file:///etc/passwd">
]>
<resources>
<string name="test_key">&xxe;</string>
</resources>
When Tolgee’s XmlResourcesProcessor parses this file, it encounters &xxe; and resolves it by reading /etc/passwd from the server’s filesystem. The file contents become the translation value for test_key. The upload was enough to trigger the file read.
I tested this on app.tolgee.io, Tolgee’s own cloud platform.
What an attacker could read
The file:// scheme can read files accessible to the application user. On a typical Tolgee deployment, those may include:
/proc/self/environ, the process environment. This is where most containerized apps store database passwords, JWT signing keys, API tokens for third-party services, and the encryption key for stored secrets.- Application config files like
application.yamlor.env, often containing the same secrets in a more readable format. /proc/self/cmdline, which can reveal startup flags, config file paths, and sometimes inline credentials.
SSRF via http:// entities
XXE isn’t limited to local files. You can define an entity with an http:// URI, and the server will make an outbound HTTP request to resolve it:
<!ENTITY xxe SYSTEM "http://169.254.169.254/latest/meta-data/iam/security-credentials/">
On an AWS host where that metadata endpoint is reachable, this could expose instance credentials. The same XXE behavior could also reach other internal HTTP services, depending on the deployment.
Impact
The attack surface here is unusually wide for an XXE. Any authenticated user with import permissions on a project can trigger it. On a multi-tenant cloud platform, that’s a low bar. After signing up and creating a project, I could import a file and read a file available to the shared application process.
Depending on file permissions and network access, a possible follow-on path is:
- Read
/proc/self/environto extract database credentials - Connect to the database directly (if network-reachable) or use SSRF to query it through an internal endpoint
- Access every tenant’s translations, API keys, and user data
This is why it scored a CVSS 9.3. Low privilege requirement, no user interaction, network-accessible, and it breaks confidentiality across tenant boundaries.
Remediation
Tolgee shipped a fix in commit 7c71d5a. Rather than patching each file individually, they created a centralized XmlSecurity utility:
object XmlSecurity {
fun newSecureXmlInputFactory(): XMLInputFactory {
val factory = XMLInputFactory.newInstance()
factory.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false)
factory.setProperty(XMLInputFactory.SUPPORT_DTD, false)
return factory
}
fun newSecureDocumentBuilderFactory(): DocumentBuilderFactory {
val factory = DocumentBuilderFactory.newInstance()
factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true)
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true)
factory.setFeature("http://xml.org/sax/features/external-general-entities", false)
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false)
factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false)
factory.isXIncludeAware = false
factory.isExpandEntityReferences = false
return factory
}
}
All six affected files were updated to call XmlSecurity.newSecureXmlInputFactory() or XmlSecurity.newSecureDocumentBuilderFactory() instead of the raw constructors. They also added XxeVulnerabilityTest.kt with test cases covering every import processor, so this class of bug can’t silently regress.
The central factory gives future XML parsers a secure default, and the tests check that the affected importers reject XXE payloads.
Timeline
-
Vulnerability discovered and reported to Tolgee
-
Public disclosure