Extend Link <= 2.0.0 - Authenticated (Contributor+) Server-Side Request Forgery
Description
The Extend Link plugin for WordPress is vulnerable to Server-Side Request Forgery in all versions up to, and including, 2.0.0. This makes it possible for authenticated attackers, with Contributor-level access and above, to make web requests to arbitrary locations originating from the web application which can be used to query and modify information from internal services.
CVSS Vector Breakdown
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:L/A:NTechnical Details
What Changed in the Fix
Changes introduced in v2.0.1
Source Code
WordPress.org SVN# Exploitation Research Plan: CVE-2026-25310 (Extend Link SSRF) ## 1. Vulnerability Summary The **Extend Link** plugin (<= 2.0.0) is vulnerable to an authenticated Server-Side Request Forgery (SSRF) via the "Link Status Checker" feature. The plugin registers an AJAX action `extend_link_plu_check_li…
Show full research plan
Exploitation Research Plan: CVE-2026-25310 (Extend Link SSRF)
1. Vulnerability Summary
The Extend Link plugin (<= 2.0.0) is vulnerable to an authenticated Server-Side Request Forgery (SSRF) via the "Link Status Checker" feature. The plugin registers an AJAX action extend_link_plu_check_link intended to verify if a URL is active for SEO purposes. This handler fails to restrict requests to external public IP addresses or validate the target port, allowing an authenticated attacker (Contributor+) to force the web server to make requests to internal services, local hostnames, or cloud metadata endpoints.
2. Attack Vector Analysis
- Endpoint:
/wp-admin/admin-ajax.php - AJAX Action:
extend_link_plu_check_link - Vulnerable Parameter:
url - Authentication Required: Contributor level (
edit_postscapability) or higher. - Preconditions: The attacker must have access to a nonce generated with the action
extend_link_nonce.
3. Code Flow
- Hook Registration: In
extend-link.php, theExtend_Link_TinyMCE::init()method registers the AJAX action:add_action('wp_ajax_extend_link_plu_check_link', array($this, 'check_link_status')); - Nonce Generation: The
enqueue_translationsmethod (hooked toadmin_enqueue_scripts) generates a nonce for users on the post editor screen:$translations = array( // ... 'nonce' => wp_create_nonce('extend_link_nonce'), ); wp_localize_script('extend_link_tinymce-button-style', 'extendLinkI18n', $translations); - JS Invocation: In
js/tinymce-button.js, the "Check" button in the dialog triggers an AJAX call:var formData = new FormData(); formData.append('action', 'extend_link_plu_check_link'); formData.append('url', url); formData.append('nonce', extendLinkI18n.nonce); // ... sends to admin-ajax.php - Vulnerable Sink (PHP): The
check_link_statusfunction (inferred) processes the$_POST['url']. It likely utilizeswp_remote_get()orwp_remote_head()without applyingwp_http_validate_url()or similar restrictions, allowing the server to hitlocalhost,127.0.0.1, or internal IP ranges.
4. Nonce Acquisition Strategy
The nonce is required and is specifically localized for the post/page editor screens.
- Requirement: Create a user with the Contributor role.
- Access Page: Navigate to the "New Post" page (
/wp-admin/post-new.php). - Extraction: Use
browser_evalto extract the nonce from the global JavaScript objectextendLinkI18n.- JavaScript Variable:
extendLinkI18n.nonce - Action String:
extend_link_nonce
- JavaScript Variable:
5. Exploitation Strategy
The goal is to demonstrate SSRF by querying an internal service or the server's own loopback interface.
Step 1: Authentication and Nonce Retrieval
- Log in as a Contributor.
- Navigate to
/wp-admin/post-new.php. - Run:
browser_eval("window.extendLinkI18n?.nonce").
Step 2: SSRF Execution
Using the http_request tool, send a POST request to admin-ajax.php.
Request Details:
- URL:
http://[TARGET]/wp-admin/admin-ajax.php - Method:
POST - Headers:
Content-Type: application/x-www-form-urlencoded - Body:
action=extend_link_plu_check_link&url=http://127.0.0.1:80&nonce=[NONCE]
Step 3: Payload Variations
- Local Port Scanning: Set
urltohttp://127.0.0.1:[PORT](e.g., 22, 3306, 6379) to identify open ports based on the plugin's response. - Internal Service Discovery: Set
urltohttp://192.168.1.1or other RFC1918 addresses. - Cloud Metadata (if applicable): Set
urltohttp://169.254.169.254/latest/meta-data/.
6. Test Data Setup
- Create User:
wp user create attacker attacker@example.com --role=contributor --user_pass=password - No special content required: The vulnerability is accessible directly via the AJAX endpoint once the nonce is obtained from the post editor.
7. Expected Results
- Success Response: The plugin typically returns a JSON object or status message containing the HTTP code of the target URL.
- Proof of SSRF: If the request to
http://127.0.0.1:80returns a200 OKstatus (or the HTML of the WP homepage) via the AJAX response, SSRF is confirmed. If a closed port returns a connection error or a different status (e.g., 0 or 500), it confirms the ability to probe internal networking.
8. Verification Steps
Since this is an SSRF, the primary evidence is in the HTTP response body of the AJAX call. To confirm via server state:
- Monitor Logs: Check the server access logs to see if the WordPress instance made a request to itself (loopback).
- Port Scan Confirmation: Attempt to hit a known closed port (e.g.,
127.0.0.1:9999) and compare the response to a known open port (e.g.,127.0.0.1:80).
9. Alternative Approaches
If the extendLinkI18n object is not populated:
- Check Script Loading: Ensure the plugin script
js/tinymce-button.jsis actually loaded. The code suggests it only loads onpost.phpandpost-new.php. - Direct Nonce Search: Use
browser_eval("document.documentElement.innerHTML.match(/'nonce':'([a-f0-9]+)'/)")as a fallback if the object name is different in certain versions. - Gutenberg vs Classic: If the site uses Gutenberg, ensure you are interacting with a "Classic Block" or that the TinyMCE initialization still triggers the
admin_enqueue_scriptshook forExtend Link.
Summary
The Extend Link plugin for WordPress is vulnerable to an authenticated Server-Side Request Forgery (SSRF) via its link status checker feature. Users with Contributor-level permissions or higher can trigger the plugin to make web requests to arbitrary internal or external URLs, potentially allowing them to scan internal ports or access sensitive metadata services.
Vulnerable Code
// extend-link.php approx line 190 public function check_link_status() { if ( !isset($_POST['nonce']) || empty($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'extend_link_check_status_nonce') ) { wp_send_json_error(['message' => __('Security check failed. Please refresh the page and try again.', 'extend-link')]); } $url = isset($_POST['url']) ? sanitize_url(wp_unslash($_POST['url'])) : ''; $url = !empty(trim($url)) ? esc_url_raw($url) : ''; if (empty($url)) { wp_send_json_error(['message' => __('Please enter a URL first to check its status.', 'extend-link')]); } if ( !filter_var($url, FILTER_VALIDATE_URL) ) { wp_send_json_error(['message' => __('Please enter a valid URL.', 'extend-link')]); } $response = wp_remote_head($url, array( 'timeout' => 10, 'sslverify' => false )); if (is_wp_error($response)) { wp_send_json_error(__('There is an error. Please try again after a few seconds.', 'extend-link')); } $status = wp_remote_retrieve_response_code($response);
Security Fix
@@ -187,31 +187,92 @@ ); } + /** + * Enhanced security check for URLs + * Validates that the URL is safe to check and not pointing to internal resources + * + * @param string $url The URL to validate + * @return bool True if URL is safe, false otherwise + */ + public function is_safe_url($url) { + $parsed = wp_parse_url($url); + + if (!$parsed || !isset($parsed['host'])) { + return false; + } + + $host = $parsed['host']; + + // Block common localhost and internal addresses + $blocked_hosts = array( + 'localhost', + '127.0.0.1', + '0.0.0.0', + '::1', + '[::1]' + ); + + if (in_array(strtolower($host), $blocked_hosts)) { + return false; + } + + // Check for private/reserved IP ranges + if (filter_var($host, FILTER_VALIDATE_IP)) { + if (!filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { + return false; + } + } + + // Only allow HTTP and HTTPS protocols + if (!in_array($parsed['scheme'], array('http', 'https'))) { + return false; + } + + return true; + } + + /** + * AJAX handler to check link status + * Uses wp_safe_remote_head() for enhanced security against SSRF attacks + */ public function check_link_status() { + // Verify user capabilities + if (!current_user_can('edit_posts')) { + wp_send_json_error(['message' => __('You do not have permission to perform this action.', 'extend-link')]); + } - if ( !isset($_POST['nonce']) || empty($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'extend_link_check_status_nonce') ) { + // Verify nonce for CSRF protection + if (!isset($_POST['nonce']) || empty($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'extend_link_check_status_nonce')) { wp_send_json_error(['message' => __('Security check failed. Please refresh the page and try again.', 'extend-link')]); } + // Sanitize and validate URL $url = isset($_POST['url']) ? sanitize_url(wp_unslash($_POST['url'])) : ''; - $url = !empty(trim($url)) ? esc_url_raw($url) : ''; if (empty($url)) { wp_send_json_error(['message' => __('Please enter a URL first to check its status.', 'extend-link')]); } - if ( !filter_var($url, FILTER_VALIDATE_URL) ) { + if (!filter_var($url, FILTER_VALIDATE_URL)) { wp_send_json_error(['message' => __('Please enter a valid URL.', 'extend-link')]); } - $response = wp_remote_head($url, array( + // Additional security check for internal addresses + if (!$this->is_safe_url($url)) { + wp_send_json_error(['message' => __('This URL is not allowed for security reasons.', 'extend-link')]); + } + + $response = wp_safe_remote_head($url, array( 'timeout' => 10, - 'sslverify' => false + 'sslverify' => true, + 'redirection' => 3, + 'user-agent' => 'WordPress/' . get_bloginfo('version') . '; ' . get_bloginfo('url'), + 'httpversion' => '1.1' )); if (is_wp_error($response)) { - wp_send_json_error(__('There is an error. Please try again after a few seconds.', 'extend-link')); + wp_send_json_error(['message' => __('There is an error. Please try again after a few seconds.', 'extend-link')]); } $status = wp_remote_retrieve_response_code($response);
Exploit Outline
To exploit this SSRF vulnerability, an attacker must have an account with at least Contributor-level privileges. First, the attacker logs in and navigates to the post editor page to obtain a valid security nonce from the `extendLinkI18n` JavaScript object. The attacker then makes an AJAX POST request to `/wp-admin/admin-ajax.php` with the following parameters: `action` set to `extend_link_plu_check_link`, `nonce` set to the retrieved value, and `url` set to an internal target (e.g., `http://127.0.0.1:80` or AWS metadata endpoints). Since the vulnerable version uses `wp_remote_head` without validating the host, the server will attempt to connect to the provided URL and return the HTTP status code in the response, allowing the attacker to verify the presence of internal services or scan for open ports.
Check if your site is affected.
Run a free security audit to detect vulnerable plugins, outdated versions, and misconfigurations.