Segurium Research BackWPup
BackWPup 5.7.6 closes an open redirect and a debug info leak
- Plugin
- BackWPup
backwpup - Affected versions
- 5.7.5 and earlier
- Fixed in
5.7.6- Class
- Open redirect
- Severity
- medium Segurium Research assessment. No CVSS score published yet.
- CVE
- None assigned at the time of writing
- Installs
- 400,000 active
- Patch released
- Sources
BackWPup shipped 5.7.6 on September 7, 2026. The plugin runs on ~400,000 sites. The changelog lists four security fixes and one archive bug fix.
The vendor wrote this about the redirect: “Security fix: Restricted BackWPup redirects to trusted destinations to prevent unsafe off-site redirects.” Two more lines read “Restricted access to BackWPup debug information to users with Settings access” and “Restricted license activation and deactivation to users with Settings access.”
The diff between 5.7.5 and 5.7.6 covers 14 files, +778 and -72 lines, PHP only. Most of the added lines rebuild Tar archive resume handling and carry no security weight. The security work sits in 10 files. Two of those files are new: src/Admin/Frontend/Redirect.php and src/Common/Domains.php.
What the release fixes
In 5.7.5 the admin redirect handler took a destination from the query string and passed it to wp_redirect(). The only destination check asked whether the string was a well-formed URL. The nonce that guarded the handler used one fixed action string, so a single token authorized a redirect to any host. A second change in the same release adds a capability check to an AJAX action that printed the site’s debug information to any logged-in user.
- Affected versions: 5.7.5 and earlier
- Fixed in: 5.7.6
- Class: Open redirect, plus information disclosure in an AJAX action
- Installs: ~400,000 sites, from the wordpress.org directory
How the bug works
BackWPup sends its upgrade links through wp-admin so it can fire a tracking event on the way. A banner link points at admin.php and carries three query arguments: bwu_redirect, bwu_event and _wpnonce. The file components/restore/upgrade-banner.php builds one of these links:
$cta_url = add_query_arg(
[
'bwu_redirect' => rawurlencode( $upgrade_url ),
'bwu_event' => 'Upgrade nudge banner clicked',
'bwu_event_property_nudge_location' => 'restore_upgrade',
'_wpnonce' => wp_create_nonce( 'backwpup_redirect_nonce' ),
],
admin_url( 'admin.php' )
);
parts/alerts/backup-success-nudge.php and views/notice/notice_upgrade_to_pro.php mint the same link with the same nonce action. src/Admin/Beacon/Beacon.php wraps its support-article links the same way, with wp_nonce_url( $selected_suggest['url'], 'backwpup_redirect_nonce' ). Four places, one fixed string.
WPMedia\BackWPup\Admin\Frontend\Subscriber::backwpup_redirect() handles the return trip. This is the whole method as it stood in 5.7.5:
public function backwpup_redirect() {
if ( isset( $_GET['bwu_redirect'] ) && filter_var( wp_unslash( $_GET['bwu_redirect'] ), FILTER_VALIDATE_URL ) ) {
// Verify nonce for security.
if ( ! isset( $_GET['_wpnonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), 'backwpup_redirect_nonce' ) ) {
wp_die( esc_html__( 'Security check failed.', 'backwpup' ) );
}
// If event is set, trigger it.
if ( isset( $_GET['bwu_event'] ) ) {
$event = sanitize_text_field( wp_unslash( $_GET['bwu_event'] ) );
$properties = [];
foreach ( $_GET as $key => $value ) {
if ( 0 === strpos( $key, 'bwu_event_property_' ) ) {
$clean_key = str_replace( 'bwu_event_property_', '', $key );
$properties[ $clean_key ] = sanitize_text_field( wp_unslash( $value ) );
}
}
// Trigger Mixpanel event.
do_action( 'backwpup_link_clicked', $event, $properties );
}
wp_redirect( sanitize_url( wp_unslash( $_GET['bwu_redirect'] ) ) ); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect
exit();
}
}
Follow bwu_redirect from the query string to the Location header. The value enters at $_GET['bwu_redirect']. wp_unslash() removes the slashes WordPress added. filter_var() with FILTER_VALIDATE_URL then asks one question: is this string shaped like a URL. It does not ask whose URL it is. Any absolute https:// address on any host passes that test.
The nonce check runs next, against the action backwpup_redirect_nonce. That string never changes and never mentions the destination. The token proves the request came from a link the plugin minted for that user. The token says nothing about where the link points. One nonce, every destination.
Then the value reaches the sink. wp_redirect() writes the Location header as given. WordPress ships wp_safe_redirect() for exactly this case, and that function runs wp_validate_redirect(), which compares the host against allowed_redirect_hosts and falls back to the local site when the host does not match. BackWPup called the unchecked function instead. The phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect comment on that line shows the coding standard flagged the call and somebody silenced the warning.
One more detail sits on that line. filter_var() ran on wp_unslash( $_GET['bwu_redirect'] ). wp_redirect() received sanitize_url( wp_unslash( $_GET['bwu_redirect'] ) ). Those are two different strings. sanitize_url() strips characters and can rewrite the value, so the string that passed the check is not always the string that goes into the header. Checking one value and using another is its own defect, independent of the missing host test.
The plugin already owned a domain allowlist in 5.7.5. It lived in src/Admin/Beacon/Subscriber.php and decided which links could carry a tracking hash:
$host = strtolower( $host );
$allowed_domains = [
'backwpup.com',
'backwpup.de',
'com.bwu.local',
'de.bwu.local',
];
The redirect handler never consulted that list. 5.7.6 moves the list into WPMedia\BackWPup\Common\Domains and makes both call sites use it:
public const OWNED = [
'backwpup.com',
'backwpup.de',
'com.bwu.local',
'de.bwu.local',
];
public static function is_owned( string $host ): bool {
$host = strtolower( $host );
if ( '' === $host ) {
return false;
}
foreach ( self::OWNED as $domain ) {
if ( $host === $domain ) {
return true;
}
if ( strlen( $host ) > strlen( $domain ) && substr( $host, -strlen( '.' . $domain ) ) === '.' . $domain ) {
return true;
}
}
return false;
}
The suffix test compares against '.' . $domain, not against $domain alone. That closes the usual hole in this pattern, where a host ending in the allowed name matches without owning it. The length guard blocks the apex from matching itself twice. Two entries in OWNED are development hosts under .local, and they stay in the shipped code. A .local name only resolves on a network whose resolver answers for bwu.local.
The new Redirect class holds both halves of the fix:
public static function nonce_action( string $url ): string {
return 'backwpup_redirect_' . md5( sanitize_url( $url ) );
}
public static function validate( string $url ): string {
if ( ! filter_var( $url, FILTER_VALIDATE_URL ) ) {
return '';
}
$host = (string) wp_parse_url( $url, PHP_URL_HOST );
if ( ! Domains::is_owned( $host ) ) {
return '';
}
// wp_validate_redirect() still performs the scheme and parsing checks.
return (string) self::with_host_allowed(
$host,
static function () use ( $url ) {
return wp_validate_redirect( $url, '' );
}
);
}
nonce_action() hashes the destination into the action string. A nonce minted for the upgrade page now verifies only for the upgrade page. Every view changed the same way, for example in views/notice/notice_upgrade_to_pro.php:
- '_wpnonce' => wp_create_nonce( 'backwpup_redirect_nonce' ),
+ '_wpnonce' => wp_create_nonce( Redirect::nonce_action( $upgrade_url ) ),
with_host_allowed() adds the host to allowed_redirect_hosts for one call, then removes the filter in a finally block. Redirect::to() uses the same wrapper around wp_safe_redirect(). The unchecked wp_redirect() is gone from the handler.
The rewritten handler also fixes the check-one-value-use-another split:
$requested = (string) wp_unslash( $_GET['bwu_redirect'] );
$target = sanitize_url( $requested );
if ( ! filter_var( $target, FILTER_VALIDATE_URL ) ) {
return '';
}
if ( ! isset( $_GET['_wpnonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), Redirect::nonce_action( $requested ) ) ) {
wp_die( esc_html__( 'Security check failed.', 'backwpup' ) );
}
$this->track_link_click();
$destination = Redirect::validate( $target );
return '' === $destination ? admin_url() : $destination;
The sanitiser now runs first. The validated string and the used string are the same string. A host outside the allowlist sends the user to admin_url().
One limit on this section. The diff does not contain Subscriber::get_subscribed_events(), so no hunk shows which hook runs backwpup_redirect(). The links the plugin mints all target admin.php under wp-admin. The entry point beyond that is not established by these bytes.
The second security change is a separate bug in inc/class-admin.php. In 5.7.5 an anonymous function was hooked straight onto the AJAX action:
- add_action(
- 'wp_ajax_backwpup_debug_info',
- function () {
- echo '<html lang="en"><head><title>' . esc_html__( 'Debug Information', 'backwpup' ) . '</title></head><body style="font-family:monospace;word-wrap: break-word;">';
- $information = BackWPup_Page_Settings::get_information();
- foreach ( $information as $item ) {
- echo esc_html( trim( $item['label'] ) ) . ': ' . esc_html( trim( $item['value'] ) ) . "\n<br/>";
- }
- echo '</body></html>';
- wp_die();
- }
- );
The wp_ajax_ prefix means WordPress runs this for every logged-in user. There is no wp_ajax_nopriv_ twin, so a logged-out visitor gets nothing. There is also no current_user_can() call and no nonce check inside the closure. A Subscriber account reaches it as easily as an Administrator. Every value passes through esc_html(), so the page does not produce XSS. The problem is disclosure only.
5.7.6 promotes the closure to a named method and gates it:
public static function ajax_debug_info() {
if ( ! current_user_can( 'backwpup_settings' ) ) {
wp_die( '', '', [ 'response' => 403 ] );
}
...
}
The docblock the vendor added above that method is the useful part:
* Reached only by typing the URL, it is not linked from anywhere in the admin.
* See issue #1127: support hands this address to a user who needs to report
* their environment, and product asked for it to stay unadvertised.
An unlinked page is still a registered AJAX action. The action name is fixed and it ships in the plugin source, so nothing about it was secret. The capability backwpup_settings is what now protects it.
The diff does not include BackWPup_Page_Settings::get_information(), so the exact list of labels and values that page printed is not established here. The page title is “Debug Information” and the loop prints label and value pairs about the environment.
The third changelog line needs a correction. “Restricted license activation and deactivation to users with Settings access” is supported in this diff by one change, in parts/sidebar/advanced-settings.php:
- if ( \BackWPup::is_pro() ):
+ // Settings tier panels, hidden from users who cannot save them anyway.
+ if ( \BackWPup::is_pro() && current_user_can( 'backwpup_settings' ) ):
That hides the License button from users without the capability. The code that activates or deactivates a licence is not in this diff. A hidden button is not an authorization check. If the handler gained its own check, it happened outside these 14 files.
The fourth changelog line names an XSS fix in backup download error messages. No PHP hunk in this diff touches download error messages. The diff supplied is PHP only, so a fix in another file type would not appear here.
The remaining bulk of the release, roughly 600 of the added lines, rebuilds Tar and TarGz resume handling in inc/class-create-archive.php and inc/class-job.php. It adds a .bwuidx sidecar log next to each Tar archive, a tar_index map of written entries, an abort() method and a cleanup_aborted_archive() call in the job class. That work fixes duplicate entries after an interrupted backup resumes. None of it is security.
Who is exposed
Every site on 5.7.5 or earlier carries both defects.
The debug AJAX action was open to any logged-in account, at any role. Subscriber is enough. A site with open registration or with WooCommerce customer accounts gives that role to strangers. On a site where every account belongs to staff, the exposure stops at staff.
The redirect needs a valid nonce for the action backwpup_redirect_nonce. WordPress binds a nonce to the user ID and the session token, so the value differs per user and expires on the usual nonce tick. An attacker cannot mint that token for somebody else’s session. This narrows the bug sharply: the holder of the nonce and the person who follows the link are the same person. What the static action removed is the tie between a token and a destination. One link in a banner handed a user a token good for every host on the internet, not only for backwpup.com.
The banner links appear in wp-admin, so the handler is reached from an admin page load. The diff does not register the hook, so whether any other request path runs backwpup_redirect() is not shown by these bytes.
The License panel change applies only to Pro installs, because \BackWPup::is_pro() still guards the branch. The change affects what the sidebar displays.
The Tar and TarGz duplicate-entry fix matters for sites that use those archive formats and whose jobs get interrupted and resumed. Zip jobs are untouched.
What to do
Update to 5.7.6. The plugin directory serves it as the current release, and WordPress auto-updates pick it up when you have them enabled for BackWPup.
On a site that ran 5.7.5 or earlier, check these:
- Search your access logs for
admin-ajax.phprequests carrying thebackwpup_debug_infoaction. Match the source accounts against the people who hold thebackwpup_settingscapability. Requests from anyone else read your environment page. - Search your access logs for
admin.phprequests carrying abwu_redirectargument whose host is neitherbackwpup.comnorbackwpup.de. Those requests asked the plugin to forward a user off site. - Review your user list for accounts you did not create. The debug page was open to every role, so a self-registered Subscriber was enough to read it.
- If you run Tar or TarGz jobs, look in your backup folder for leftover
.bwuidxfiles whose archive is gone. The new code deletes the sidecar when an archive completes, aborts or is removed, but a file written before the update can remain.
The patch does three things. Redirect::nonce_action() ties each nonce to the destination it authorizes, so a token minted for one URL fails for another. Redirect::validate() checks the host against Domains::is_owned() and then runs wp_validate_redirect(), and Redirect::to() sends the user with wp_safe_redirect() instead of wp_redirect(). BackWPup_Admin::ajax_debug_info() calls current_user_can( 'backwpup_settings' ) and answers 403 to everybody else.