“`json
{
“analysis”: “Atomic Edge analysis of CVE-2026-13457:nThis vulnerability allows unauthenticated disclosure of the migration api_signature and WordPress database credentials, which can lead to remote code execution on the target site. The InstaWP Connect plugin fails to protect the wp-content/instawpbackups/ directory from HTTP directory listing, exposing an encrypted options file whose filename contains the decryption key material.nnRoot Cause:nThe root cause is the plugin stores the encrypted migration options file as options-{migrate_key}.txt in the wp-content/instawpbackups/ directory without deploying directory-listing guard files such as index.php or .htaccess. The prior code in class-instawp-tools.php had a create_instawpbackups_dir() function that only created the directory (using mkdir with 0777 permissions) but did not write any guard files. Additionally, the plugin’s failure to delete the options file at every terminal migration state (only doing so on ‘completed’ migrations) meant the file could persist after aborted, failed, or timed-out migrations. The filename contains the 40-character migrate_key, which is then hashed with SHA256 to derive the AES-256-CBC passphrase used to decrypt the file’s contents.nnExploitation:nAn attacker first sends a GET request to the /wp-content/instawpbackups/ directory on an Apache server with directory listing enabled. With mod_autoindex, the server returns a page listing all files, including options-{40_character_key}.txt. The attacker copies the migrate_key from the filename. They then compute the AES passphrase as sha256(migrate_key). Next, they send a request to the /wp-content/instawpbackups/options-{key}.txt file to download its contents. Using the derived passphrase, they decrypt the AES-256-CBC encrypted data to recover the api_signature and the database hostname, username, password, and database name. With the api_signature, the attacker can authenticate to the iwp-serve and iwp-dest endpoints to pull or push arbitrary files to the server, ultimately leading to Remote Code Execution by writing a PHP webshell. The exploit is time-limited to the migration window because the option file may be cleaned up after the migration completes.nnPatch Analysis:nThe patch implements multi-layer defense. First, a new protect_instawpbackups_dir() method in class-instawp-tools.php writes index.php (with “Silence is golden.”) and .htaccess (with FilesMatch rules denying access to *.txt, *.sql, and *.sqlite) into the backups root and its plugins/themes subdirectories. The hooks file adds actions on admin_init and upgrader_process_complete to retrofit the guard onto existing installations. Second, to address the file lifecycle, the patch introduces instawp_delete_migration_options_file(), which is called when a migration reaches a terminal state (completed, failed, aborted, or timed out) and in clean_iwp_files_dir(). This function deletes options-*.txt files unless a specific migrate_key is provided. Third, the patch also adds a path traversal fix in iwp-dest/index.php by sanitizing the X-File-Relative-Path header (iwp_sanitize_relative_path) and verifying the final save path resolves inside the site root, closing the ARBITRARY file write that the api_signature disclosure would enable.nnImpact:nExploitation leads to complete site compromise. The attacker obtains the api_signature, which is the primary authentication mechanism for the iwp-serve and iwp-dest migration endpoints. With this signature, they can push a malicious PHP file to the server and achieve Remote Code Execution. They also directly obtain the database credentials, allowing them to exfiltrate or destroy all WordPress data. The attack requires no authentication and only assumes Apache with directory listing enabled in the wp-content/instawpbackups/ directory, which is commonplace. A successful exploit grants full control over the WordPress instance and its underlying filesystem.”,
“poc_php”: “// Atomic Edge CVE Research – Proof of Conceptn// CVE-2026-13457 – InstaWP Connect <= 0.1.3.6 – Unauthenticated Cryptographic Key Disclosurenn<?phpn/**n * PoC to demonstrate unauthenticated disclosure of api_signature and DB credentials.n * This script interacts with the vulnerable InstaWP Connect plugin (n”,
“modsecurity_rule”: “// Atomic Edge WAF Rule – CVE-2026-13457nSecRule REQUEST_URI “@streq /wp-content/instawpbackups/” “id:20261994,phase:1,deny,status:403,msg:’CVE-2026-13457: InstaWP Connect directory listing attempt’,severity:’CRITICAL’,tag:’CVE-2026-13457′,logdata:’%{MATCHED_VAR}'”n”
}
“`

Published : August 11, 2026
CVE-2026-13457: InstaWP Connect <= 0.1.3.6 Unauthenticated Cryptographic Key Disclosure PoC, Patch Analysis & Rule
CVE ID
CVE-2026-13457
Plugin
instawp-connect
Severity
High
(CVSS 7.5)
CWE
434
Vulnerable Version
0.1.3.6
Patched Version
0.1.3.7
Disclosed
August 10, 2026
Analysis Overview
Differential between vulnerable and patched code
Below is a differential between the unpatched vulnerable code and the patched update, for reference.
Code Diff
--- a/instawp-connect/includes/apis/class-instawp-rest-api-migration.php
+++ b/instawp-connect/includes/apis/class-instawp-rest-api-migration.php
@@ -342,6 +342,12 @@
Option::update_option( 'instawp_last_migration_details', $migration_details );
+ // The migration has reached a terminal state, so the encrypted options file is
+ // no longer needed. Deleted here rather than relying on clean_iwp_files_dir()
+ // below, which only runs for 'completed' — an aborted, failed or timed out
+ // migration would otherwise leave the file (and its key-bearing name) on disk.
+ instawp_delete_migration_options_file( Helper::get_args_option( 'migrate_key', $migration_details ) );
+
$connect_id = instawp_get_connect_id();
// Reset migration state. Connected sites (with a valid connect_id)
--- a/instawp-connect/includes/class-instawp-hooks.php
+++ b/instawp-connect/includes/class-instawp-hooks.php
@@ -17,6 +17,11 @@
add_action( 'instawp_connect_connected', array( $this, 'handle_connected' ) );
add_action( 'load-tools_page_instawp', array( $this, 'handle_connection_state' ) );
add_action( 'admin_init', array( $this, 'generate_api_key' ) );
+ add_action( 'admin_init', array( $this, 'protect_backups_dir' ) );
+ // admin_init only fires when an admin loads wp-admin, so it never reaches a site
+ // updated unattended. Re-assert the guard after this plugin is updated — including
+ // background auto-updates that run in cron with no logged-in user.
+ add_action( 'upgrader_process_complete', array( $this, 'protect_backups_dir_on_upgrade' ), 10, 2 );
add_action( 'update_option', array( $this, 'manage_update_option' ), 10, 3 );
add_action( 'init', array( $this, 'handle_hard_disable_seo_visibility' ) );
add_action( 'admin_init', array( $this, 'handle_clear_all' ), 999 );
@@ -45,6 +50,88 @@
instawp_set_staging_sites_list();
}
+ /**
+ * Retrofit the directory-listing guard onto an existing backups directory.
+ *
+ * Sites that already ran a migration have the directory — and often leftover
+ * options-{key}.txt files — on disk right now, while create_instawpbackups_dir()
+ * is only reached when a migration starts. Without this they would stay listable
+ * until their next migration.
+ *
+ * The option check keeps this to one filesystem probe per guard revision instead
+ * of one on every admin request.
+ *
+ * @return void
+ */
+ public function protect_backups_dir() {
+
+ // Wrapped so a filesystem or environment edge case can never fatal the
+ // admin_init request this runs on.
+ try {
+ $guard_version = '1';
+
+ if ( Option::get_option( 'instawp_backups_dir_guarded' ) === $guard_version ) {
+ return;
+ }
+
+ // Only record the guard as done once it is actually in place, so a transient
+ // permission problem is retried on a later request instead of latching.
+ if ( InstaWP_Tools::protect_instawpbackups_dir() ) {
+ Option::update_option( 'instawp_backups_dir_guarded', $guard_version );
+ }
+ } catch ( Throwable $th ) {
+ Helper::add_error_log( array( 'title' => 'instawp: protect_backups_dir failed' ), $th );
+ }
+ }
+
+ /**
+ * Re-assert the directory-listing guard after this plugin is updated.
+ *
+ * Fires on upgrader_process_complete, which unlike admin_init runs during
+ * unattended background updates (auto-update via cron) where no admin is logged
+ * in. Scoped to updates that actually include this plugin so unrelated plugin,
+ * theme or core updates are ignored. Clears the guard flag first so the version
+ * gate in protect_backups_dir() cannot short-circuit the re-check.
+ *
+ * @param WP_Upgrader $upgrader Upgrader instance (unused).
+ * @param array $options Update context: type, action and affected items.
+ *
+ * @return void
+ */
+ public function protect_backups_dir_on_upgrade( $upgrader, $options ) {
+
+ // Runs on upgrader_process_complete; a failure here must not break the update.
+ try {
+ if ( empty( $options['type'] ) || 'plugin' !== $options['type'] ) {
+ return;
+ }
+
+ $plugin_basename = defined( 'INSTAWP_PLUGIN_SLUG' )
+ ? INSTAWP_PLUGIN_SLUG . '/' . INSTAWP_PLUGIN_SLUG . '.php'
+ : 'instawp-connect/instawp-connect.php';
+
+ // The affected plugins arrive as 'plugins' for bulk/auto updates and 'plugin'
+ // for a single manual update; normalise both into one list.
+ $updated_plugins = array();
+ if ( ! empty( $options['plugins'] ) && is_array( $options['plugins'] ) ) {
+ $updated_plugins = $options['plugins'];
+ } elseif ( ! empty( $options['plugin'] ) ) {
+ $updated_plugins = array( $options['plugin'] );
+ }
+
+ if ( ! in_array( $plugin_basename, $updated_plugins, true ) ) {
+ return;
+ }
+
+ // Force a re-check even if a previous run already recorded the guard version.
+ Option::delete_option( 'instawp_backups_dir_guarded' );
+
+ $this->protect_backups_dir();
+ } catch ( Throwable $th ) {
+ Helper::add_error_log( array( 'title' => 'instawp: protect_backups_dir_on_upgrade failed' ), $th );
+ }
+ }
+
public function handle_connection_state() {
if ( ! instawp_is_connected_origin_valid() ) {
instawp_reset_running_migration( 'hard' );
--- a/instawp-connect/includes/class-instawp-tools.php
+++ b/instawp-connect/includes/class-instawp-tools.php
@@ -104,19 +104,204 @@
public static function create_instawpbackups_dir( $instawpbackups_dir = '' ) {
- if ( empty( $instawpbackups_dir ) ) {
- $instawpbackups_dir = WP_CONTENT_DIR . '/' . INSTAWP_DEFAULT_BACKUP_DIR;
+ // Wrapped so a filesystem or environment edge case can never fatal the caller.
+ try {
+ if ( empty( $instawpbackups_dir ) ) {
+ // Guard against being called before the constants that form the default path are
+ // defined; without them there is no directory to create or protect.
+ if ( ! defined( 'WP_CONTENT_DIR' ) || ! defined( 'INSTAWP_DEFAULT_BACKUP_DIR' ) ) {
+ return false;
+ }
+
+ $instawpbackups_dir = WP_CONTENT_DIR . '/' . INSTAWP_DEFAULT_BACKUP_DIR;
+ }
+
+ $dir_created = false;
+
+ if ( ! is_dir( $instawpbackups_dir ) ) {
+ // Permissions are intentionally left at 0777: some shared hosts run the web
+ // server and PHP as different users and both need to write into this tree.
+ $dir_created = mkdir( $instawpbackups_dir, 0777, true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir
+ }
+
+ // Always re-assert the listing guards, not only when the directory was just
+ // created. On every site that has already run a migration the directory exists,
+ // so a guard written inside the mkdir branch above would never reach them.
+ self::protect_instawpbackups_dir( $instawpbackups_dir );
+
+ return $dir_created;
+ } catch ( Throwable $th ) {
+ Helper::add_error_log( array( 'title' => 'instawp: create_instawpbackups_dir failed' ), $th );
+
+ return false;
}
+ }
+
+ /**
+ * Names of the files that keep a directory from being listed over HTTP.
+ * Kept in one place so the cleanup routines can recognise and preserve them.
+ *
+ * @return string[]
+ */
+ public static function get_dir_guard_files() {
+ return array( 'index.php', 'index.html', '.htaccess' );
+ }
+
+ /**
+ * Check whether a path is one of the directory-listing guard files.
+ *
+ * @param string $file_path Full path or bare filename.
+ *
+ * @return bool
+ */
+ public static function is_dir_guard_file( $file_path ) {
+ return in_array( basename( $file_path ), self::get_dir_guard_files(), true );
+ }
+
+ /**
+ * Check whether a directory is the backups directory or one of its sub-directories.
+ *
+ * Used to scope guard-file handling to the backups tree only, since the cleanup
+ * helpers are also pointed at ABSPATH/iwp-serve and ABSPATH/iwp-dest.
+ *
+ * @param string $dir_path Directory path to test.
+ *
+ * @return bool
+ */
+ public static function is_inside_backups_dir( $dir_path ) {
+
+ if ( empty( $dir_path ) || ! defined( 'INSTAWP_DEFAULT_BACKUP_DIR' ) ) {
+ return false;
+ }
+
+ $backups_dir = wp_normalize_path( untrailingslashit( WP_CONTENT_DIR . '/' . INSTAWP_DEFAULT_BACKUP_DIR ) );
+ $dir_path = wp_normalize_path( untrailingslashit( $dir_path ) );
+
+ return $dir_path === $backups_dir || 0 === strpos( $dir_path, $backups_dir . '/' );
+ }
+
+ /**
+ * Drop the directory-listing guards into the backups directory so it cannot be
+ * listed or its credential files fetched over HTTP.
+ *
+ * This directory holds `options-{migrate_key}.txt`, whose filename doubles as the
+ * key material used to decrypt its own contents (the site's database credentials
+ * and the migration api_signature). Two guard files are written:
+ *
+ * - index.php ("Silence is golden.") — makes mod_dir serve it instead of letting
+ * mod_autoindex generate a listing that would leak the key through the filename.
+ * - .htaccess — denies direct HTTP access to the sensitive migration artifacts
+ * (`*.txt` options files, plus any `*.sql`/`*.sqlite` database dumps) while
+ * leaving `*.zip` reachable, because plugin/theme sync serves `plugins/*.zip`
+ * and `themes/*.zip` out of this same tree over HTTP. The rule is extension-scoped
+ * rather than `deny from all` for that reason, and the deny is safe for the plugin
+ * itself, which only ever reads these files from the filesystem (never over HTTP).
+ * It prefers the mod_access_compat form (Order/Deny — the same the migration-log
+ * guard uses, permitted under `AllowOverride Limit`) and falls back to
+ * `Require all denied` only where mod_access_compat is absent, so it works on
+ * Apache 2.4 and 2.2 and is inert (not a 500) where .htaccess overrides are
+ * disabled.
+ *
+ * This is Apache-layer defense-in-depth only: an nginx front-end (or nginx+Apache
+ * host that serves static files with `try_files $uri`) returns the file directly
+ * without consulting .htaccess, so it does not protect there. The cross-server
+ * guarantee is instead that the options file is force-deleted at every terminal
+ * migration state — see instawp_delete_migration_options_file().
+ *
+ * @param string $instawpbackups_dir Directory to protect. Defaults to the backups dir.
+ *
+ * @return bool True when every existing target directory carries a guard.
+ */
+ public static function protect_instawpbackups_dir( $instawpbackups_dir = '' ) {
- if ( ! is_dir( $instawpbackups_dir ) ) {
- if ( mkdir( $instawpbackups_dir, 0777, true ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir
+ // Wrapped so a filesystem or environment edge case can never fatal the request
+ // that triggered the guard (admin_init, upgrader_process_complete, sync, migration).
+ try {
+ if ( empty( $instawpbackups_dir ) ) {
+ // The default path is derived from these constants, so bail defensively if the
+ // method is reached before WordPress (or the plugin bootstrap) has defined them —
+ // e.g. from a hook that fires very early. Returning false leaves the guard flag
+ // unset so a later request retries.
+ if ( ! defined( 'WP_CONTENT_DIR' ) || ! defined( 'INSTAWP_DEFAULT_BACKUP_DIR' ) ) {
+ return false;
+ }
+
+ $instawpbackups_dir = WP_CONTENT_DIR . '/' . INSTAWP_DEFAULT_BACKUP_DIR;
+ }
+
+ $instawpbackups_dir = untrailingslashit( $instawpbackups_dir );
+
+ if ( ! is_dir( $instawpbackups_dir ) ) {
+ // Nothing on disk to protect yet — create_instawpbackups_dir() guards the
+ // directory at creation time, so this is a success rather than a failure.
return true;
- } else {
- return false;
}
- }
- return false;
+ // Guard the backups root plus the two sub-directories that hold sync artifacts.
+ // migration-log/ writes its own guards in InstaWP_Migrate_Log::get_path().
+ $dirs_to_protect = array(
+ $instawpbackups_dir,
+ $instawpbackups_dir . DIRECTORY_SEPARATOR . 'plugins',
+ $instawpbackups_dir . DIRECTORY_SEPARATOR . 'themes',
+ );
+
+ // The two guard files written into each directory. Kept in a map so each is
+ // written independently — a directory that already carries index.php from an
+ // earlier run still gets the .htaccess added on a later call. The .htaccess
+ // denies only sensitive extensions (*.txt/*.sql/*.sqlite) so it never blocks
+ // the *.zip files sync serves over HTTP. It prefers the mod_access_compat form
+ // (permitted under AllowOverride Limit, like the migration-log guard) and
+ // falls back to Require only where that module is absent.
+ $deny_pattern = '<FilesMatch ".(txt|sql|sqlite)$">';
+ $guard_files = array(
+ 'index.php' => "<?phpn// Silence is golden.n",
+ '.htaccess' => implode( "n", array(
+ '# InstaWP: deny direct HTTP access to migration credential/database artifacts',
+ '# (options-{key}.txt and any .sql/.sqlite). PHP filesystem reads are unaffected;',
+ '# plugin/theme sync .zip stays reachable.',
+ '<IfModule mod_access_compat.c>',
+ "t" . $deny_pattern,
+ "tt" . 'Order Allow,Deny',
+ "tt" . 'Deny from all',
+ "t" . '</FilesMatch>',
+ '</IfModule>',
+ '<IfModule !mod_access_compat.c>',
+ "t" . $deny_pattern,
+ "tt" . 'Require all denied',
+ "t" . '</FilesMatch>',
+ '</IfModule>',
+ '',
+ ) ),
+ );
+
+ $all_dirs_guarded = true;
+
+ foreach ( $dirs_to_protect as $dir_to_protect ) {
+ if ( ! is_dir( $dir_to_protect ) ) {
+ continue;
+ }
+
+ foreach ( $guard_files as $guard_name => $guard_contents ) {
+ $guard_path = $dir_to_protect . DIRECTORY_SEPARATOR . $guard_name;
+
+ if ( file_exists( $guard_path ) ) {
+ continue;
+ }
+
+ // A failed write is reported so the caller does not record the directory
+ // as guarded and can retry later.
+ if ( ! is_writable( $dir_to_protect ) || ! @file_put_contents( $guard_path, $guard_contents ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
+ $all_dirs_guarded = false;
+ }
+ }
+ }
+
+ return $all_dirs_guarded;
+ } catch ( Throwable $th ) {
+ Helper::add_error_log( array( 'title' => 'instawp: protect_instawpbackups_dir failed' ), $th );
+
+ return false;
+ }
}
public static function clean_instawpbackups_dir( $instawpbackups_dir = '', $clean_self = false ) {
@@ -129,12 +314,21 @@
return false;
}
+ // Preserve the index.php listing guard, but only inside the backups tree and only
+ // when the directory itself is being kept. This same method is also called on
+ // ABSPATH/iwp-serve and ABSPATH/iwp-dest, where deleting index.php is the entire
+ // point, and a surviving guard would additionally make the rmdir() below fail.
+ $keep_dir_guards = ! $clean_self && self::is_inside_backups_dir( $instawpbackups_dir );
+
while ( false !== ( $file = readdir( $instawpbackups_dir_handle ) ) ) {
if ( $file !== '.' && $file !== '..' ) {
$file_path = $instawpbackups_dir . DIRECTORY_SEPARATOR . $file;
if ( file_exists( $file_path ) ) {
if ( is_dir( $file_path ) ) {
self::clean_instawpbackups_dir( $file_path );
+ } elseif ( $keep_dir_guards && self::is_dir_guard_file( $file ) ) {
+ // Keep the directory listing guard in place across cleanups.
+ continue;
} elseif ( ! function_exists( 'instawp_is_options_file_protected' ) || ! instawp_is_options_file_protected( $file_path ) ) {
// Skip deletion of options-{key}.txt belonging to an active migration.
// The iwp-serve endpoint needs this file for DB credentials during pull.
@@ -2231,6 +2425,14 @@
*/
public static function clean_iwp_files_dir() {
try {
+ // Drop the encrypted migration options files first. This runs before (and
+ // independently of) the $keep_files check below: that setting is about
+ // retaining database dumps for debugging, and must never extend the life of a
+ // file whose own name is the key to the credentials inside it.
+ if ( function_exists( 'instawp_delete_migration_options_file' ) ) {
+ instawp_delete_migration_options_file();
+ }
+
// Delete IWP files : Start
$files = array();
--- a/instawp-connect/includes/functions-pull-push.php
+++ b/instawp-connect/includes/functions-pull-push.php
@@ -992,6 +992,60 @@
}
}
+if ( ! function_exists( 'iwp_sanitize_relative_path' ) ) {
+ /**
+ * Sanitise a migration file path received over the network.
+ *
+ * Leading separators are stripped rather than rejected. That is deliberate and is
+ * exactly what the receiver already does today: joining "/wp-content/x.php" to the
+ * root yields "<root>//wp-content/x.php", which resolves to the same file as the
+ * stripped form. Rejecting them instead would break senders that legitimately produce
+ * one — notably a Windows source, where iwp-serve strips with DIRECTORY_SEPARATOR
+ * ("") and so leaves a leading "/" on forward-slash paths untouched.
+ *
+ * What genuinely cannot be made safe is rejected outright: null bytes, `..` segments
+ * and Windows drive letters. Those never occur in a well-formed transfer, since the
+ * source derives every path from a filesystem walk rooted at WP_ROOT.
+ *
+ * @param string $relative_path Value taken from the X-File-Relative-Path header.
+ *
+ * @return string|false Sanitised root-relative path, or false when unusable.
+ */
+ function iwp_sanitize_relative_path( $relative_path ) {
+
+ if ( ! is_string( $relative_path ) || '' === trim( $relative_path ) ) {
+ return false;
+ }
+
+ // Null byte truncation.
+ if ( false !== strpos( $relative_path, "