Security (medium): multisite sub-site admin can redirect network URLs and super-admin password-reset links to a host they control #6

Closed
opened 2026-10-15 22:36:01 -07:00 by eric · 0 comments
Owner

Severity: medium (likelihood low, impact high)
Found by: security audit run 3 (commit 01195c8), independently verified in a disposable local WordPress 7.1.3 lab
Fingerprint: multi-domain-redux:SettingsPage.registerSetting:multisite-subsite-admin-controls-network-url-rewrite

Summary

On a subdomain (or domain-mapped) multisite with the plugin active on a sub-site, that sub-site's administrator (manage_options on that site only, not a super admin) can save a host list containing a host they control, marked primary, plus the network's main host, leaving their own sub-site hostname out of the list. Requests to the sub-site's own hostname then do not match the list, so HostResolver falls back to the attacker-chosen primary. Because the plugin filters network_site_url, network_home_url, home_url, site_url, admin_url and others, and loads the booting blog's settings once for the whole request, every network-scoped or other-blog URL whose host is listed is rebuilt on the attacker host. Observed in a WP 7.1.3 lab: (1) retrieve_password() builds the reset link with network_site_url(), so anyone can request, unauthenticated, a reset for any network login (including the super admin) through the sub-site's wp-login.php, and the genuine reset email carries http://attacker.test/wp-login.php?login=superadmin&key=<valid key>&action=rp; (2) the super admin's view of the sub-site dashboard shows 15 admin-bar links on the attacker host (Network Admin and children, My Sites, the main site's blog-1 menu, Edit Site, Add New Site); (3) under the sub-site context get_home_url(1) and home_url() after switch_to_blog(1) return the attacker host, while blogs whose hosts are not listed are unaffected; (4) the attacker host is accepted by wp_validate_redirect on the sub-site via allowed_redirect_hosts. A super admin who follows the reset link delivers the key to the attacker's host, and the attacker can complete the reset on the real network. If the sub-site's own hostname is also listed, the request matches it and network URLs are rewritten to the sub-site host instead of the attacker host. The same fallback also redirects unauthenticated sign-up traffic: on the sub-site's hostname, GET wp-login.php?action=register and GET wp-signup.php (core builds both redirects with network_site_url('wp-signup.php')) return 302 to http://attacker.test/wp-signup.php instead of the network's own sign-up page, so visitors who try to register are sent to the attacker host.

Root cause

The host allowlist is gated only by manage_options (register_setting has no capability override, so options.php uses the default; add_options_page and SettingsRenderer::render also use manage_options), and SettingsSanitizer accepts any syntactically valid hostname and a submitter-chosen primary without checking that the hosts belong to the current blog or that the saver may change network URLs. The per-blog list is nonetheless applied to every URL generated during the request, including network_site_url, network_home_url and other blogs' home/site URLs, with any unmatched Host falling back to the chosen primary. On multisite, sub-site admins hold manage_options but are below super admin and cannot otherwise change network URLs.

Expected behaviour

A sub-site admin should only affect URLs of their own site. Hosts that network-scoped URLs, reset links, other blogs' URLs and the redirect allowlist are rewritten to should be chosen only by a network-trusted principal (manage_network_options), or configured hosts should be limited to the current blog's hostnames and network or other-blog URLs should never be rewritten from a sub-site's per-site option.

Source trace

  1. src/Admin/SettingsPage.php:38 (SettingsPage::registerSetting): register_setting for multi_domain_redux_settings with no capability override; options.php then requires only manage_options, which sub-site admins hold (lab: sub-site admin b, is_super_admin false, no manage_network_options, saved via options.php with the Settings-API nonce, 302 settings-updated=true).
  2. src/Admin/SettingsSanitizer.php:66 (SettingsSanitizer::fromInput): Any valid hostname is accepted after normalize() (attacker.test, network.test); is_primary is chosen by the submitter (line 82).
  3. src/Plugin.php:34 (Plugin::boot): The booting blog's option is loaded once and drives global URL filters for the whole request, including other blogs' and network URLs.
  4. src/Request/HostResolver.php:62 (HostResolver::resolve): The sub-site's own Host (b.network.test) is not listed, so the current host falls back to settings->primary() = attacker.test.
  5. src/Rewrite/HookBindings.php:22 (HookBindings::URL_FILTERS): network_home_url and network_site_url (lines 22-23), plus home_url, site_url, admin_url and others, are registered to rewriteUrlFilter (line 57).
  6. src/Rewrite/UrlRewriter.php:61 (UrlRewriter::rewrite): Any URL on a listed host (network.test), such as the reset link http://network.test/wp-login.php?...&action=rp and network-admin and main-site URLs, is rebuilt on the current host attacker.test.

Conditions

  • system configuration: WordPress multisite with subdomain or domain-mapped sub-sites; on a subdirectory network all sites share the network Host, which then matches its own list entry.
  • system configuration: Plugin active on the sub-site (activated for that site by a super admin, or network-activated).
  • authorization role: Attacker administers one sub-site only (manage_options there; is_super_admin false; no manage_network_options) and lists the network main host but not their own sub-site host.
  • network routing: To harvest the key, the attacker must control a server for the hostname they configure.
  • third party dependency: WordPress core behaviour observed in a WP 7.1.3 lab: retrieve_password() builds the reset URL with network_site_url(); users are network-global and a reset for any login can be requested via any site's wp-login.php; sub-site admins hold manage_options and options.php enforces only that default capability for the registered setting.
  • user interaction: Key theft requires the targeted user to follow the link in the genuine reset email (or a rewritten admin-bar link), whose host is the attacker's domain.

Lab reproduction (local, dummy data only)

Principal: Dummy administrator b of sub-site b.network.test on a subdomain network whose main host is network.test; victim is dummy super admin superadmin.

  1. In a WordPress 7.1.3 subdomain multisite lab (http only), the super admin activates the plugin on sub-site b only.
  2. Log in as sub-site admin b, read _wpnonce from /wp-admin/options-general.php?page=multi-domain-redux, and POST the host list (without the sub-site's own hostname) to /wp-admin/options.php.
  3. Unauthenticated, POST user_login=superadmin to b's wp-login.php?action=lostpassword and read the captured mail.
  4. Log in as superadmin, GET b's /wp-admin/ and list admin-bar hrefs on attacker.test; compare with the main site's dashboard.
  5. Unauthenticated, GET b's /wp-login.php?action=register and /wp-signup.php without following redirects and read the Location header; repeat with the plugin deactivated on b.
  6. wp eval --url=b.network.test: print network_site_url('wp-login.php'), get_home_url(1), home_url() after switch_to_blog(1) and after switch_to_blog(3), and wp_validate_redirect('http://attacker.test/x'); repeat with --url=network.test as control; then delete the option and deactivate the plugin.

Observed: Stored option [attacker.test primary, network.test]. The captured reset email to [email protected] contained http://attacker.test/wp-login.php?login=superadmin&key=<REDACTED>&action=rp&wp_lang=en_US. The super admin's b dashboard had 15 admin-bar hrefs on attacker.test (Network Admin and children, My Sites, blog-1, edit-site, add-new-site); the main dashboard had 0. Under b, network_site_url, get_home_url(1) and blog 1's home_url after switch_to_blog all returned attacker.test; blog 3 stayed at c.network.test; wp_validate_redirect accepted http://attacker.test/x. Main-site controls unchanged. With b.network.test also listed, the reset link went to b.network.test instead. Scheme was http because the lab is http-only. The key was never used and the lab was restored. Independently reproduced earlier by hunter w3a (sub-site c) and verifier v2 (sub-site b). Sign-up redirect variant (verifier v10, lab hosts net2.test / b.net2.test, option [attacker.test primary, net2.test]): unauthenticated GET /wp-login.php?action=register and GET /wp-signup.php on Host b.net2.test both returned 302 Location: http://attacker.test/wp-signup.php; with the plugin inactive on b both returned 302 Location: http://net2.test/wp-signup.php.

Fix

Bind host authority to the right principal. On multisite, require manage_network_options for the settings page and options.php saves (filter option_page_capability_multi_domain_redux_group and use the same capability in add_options_page and SettingsRenderer::render), or reject hosts equal to the network domain or another site's domain unless the saver has manage_network_options. Defence in depth: do not rewrite network-scoped or other blogs' URLs from a per-site snapshot. Skip network_home_url/network_site_url on multisite, return early while ms_is_switched(), and leave URLs for other blogs untouched using each filter's own blog_id argument: it is the 4th argument of home_url/site_url but the 3rd of admin_url/rest_url (get_admin_url(N) and get_rest_url(N) apply their filters without switching, and get_home_url(N) applies its filter after restore_current_blog(), so ms_is_switched() alone is not enough). Regression tests: on multisite a non-super-admin cannot save; network_site_url is unchanged on a sub-site for an unmatched Host; home_url, site_url, admin_url and rest_url for another blog_id are not rewritten.

Suggested change in src/Admin/SettingsPage.php:

public function register(): void {
	add_action( 'admin_init', array( $this, 'registerSetting' ) );
	add_action( 'admin_menu', array( $this, 'registerMenu' ) );
	add_action( 'admin_enqueue_scripts', array( $this->assets, 'enqueue' ) );
	add_filter( 'option_page_capability_' . self::OPTION_GROUP, array( self::class, 'capability' ) );
	// ...
}

public static function capability(): string {
	return is_multisite() ? 'manage_network_options' : 'manage_options';
}
// registerMenu(): pass self::capability() instead of 'manage_options'.
// SettingsRenderer::render(): check current_user_can( SettingsPage::capability() ).

Suggested change in src/Rewrite/HookBindings.php:

private const BLOG_ID_ARG4_FILTERS = array( 'home_url', 'site_url' );
private const BLOG_ID_ARG3_FILTERS = array( 'admin_url', 'rest_url' );
private const NETWORK_FILTERS      = array( 'network_home_url', 'network_site_url' );

// In register():
foreach ( self::URL_FILTERS as $filter ) {
	if ( is_multisite() && in_array( $filter, self::NETWORK_FILTERS, true ) ) {
		continue;
	}
	if ( in_array( $filter, self::BLOG_ID_ARG4_FILTERS, true ) ) {
		add_filter( $filter, array( $this, 'rewriteSiteUrlFilter' ), 99, 4 );
	} elseif ( in_array( $filter, self::BLOG_ID_ARG3_FILTERS, true ) ) {
		add_filter( $filter, array( $this, 'rewriteAdminUrlFilter' ), 99, 3 );
	} else {
		add_filter( $filter, array( $this, 'rewriteUrlFilter' ), 99, 1 );
	}
}

/**
 * @param mixed $url
 */
public function rewriteUrlFilter( $url ): mixed {
	if ( ! is_string( $url ) ) {
		return $url;
	}
	if ( is_multisite() && ms_is_switched() ) {
		return $url;
	}
	return $this->rewriter->rewrite( $url );
}

/**
 * Filters home_url / site_url: ( $url, $path, $scheme, $blog_id ).
 *
 * @param mixed $url
 * @param mixed $path
 * @param mixed $scheme
 * @param mixed $blogId
 */
public function rewriteSiteUrlFilter( $url, $path = '', $scheme = null, $blogId = null ): mixed {
	unset( $path, $scheme );
	return $this->isOtherBlog( $blogId ) ? $url : $this->rewriteUrlFilter( $url );
}

/**
 * Filters admin_url / rest_url: ( $url, $path, $blog_id ).
 *
 * @param mixed $url
 * @param mixed $path
 * @param mixed $blogId
 */
public function rewriteAdminUrlFilter( $url, $path = '', $blogId = null ): mixed {
	unset( $path );
	return $this->isOtherBlog( $blogId ) ? $url : $this->rewriteUrlFilter( $url );
}

/**
 * @param mixed $blogId
 */
private function isOtherBlog( $blogId ): bool {
	return is_multisite()
		&& ! empty( $blogId )
		&& is_numeric( $blogId )
		&& (int) $blogId !== get_current_blog_id();
}

Priority rationale

  • Likelihood: Requires a subdomain or domain-mapped multisite where the plugin is active on a sub-site run by a less-trusted admin who also controls a server for the attacker host, and the victim must follow a reset or admin-bar link pointing at an unfamiliar host. Once configured, the attacker can trigger the genuine reset email unauthenticated.
  • Impact: A valid password-reset key for any network user, including a super admin, is delivered in a link to a host the sub-site admin controls, crossing the sub-site-admin/super-admin boundary and enabling network takeover; the super admin's network-admin navigation links are also redirected to that host.
**Severity:** medium (likelihood low, impact high) **Found by:** security audit run 3 (commit `01195c8`), independently verified in a disposable local WordPress 7.1.3 lab **Fingerprint:** `multi-domain-redux:SettingsPage.registerSetting:multisite-subsite-admin-controls-network-url-rewrite` ## Summary On a subdomain (or domain-mapped) multisite with the plugin active on a sub-site, that sub-site's administrator (manage_options on that site only, not a super admin) can save a host list containing a host they control, marked primary, plus the network's main host, leaving their own sub-site hostname out of the list. Requests to the sub-site's own hostname then do not match the list, so HostResolver falls back to the attacker-chosen primary. Because the plugin filters network_site_url, network_home_url, home_url, site_url, admin_url and others, and loads the booting blog's settings once for the whole request, every network-scoped or other-blog URL whose host is listed is rebuilt on the attacker host. Observed in a WP 7.1.3 lab: (1) retrieve_password() builds the reset link with network_site_url(), so anyone can request, unauthenticated, a reset for any network login (including the super admin) through the sub-site's wp-login.php, and the genuine reset email carries http://attacker.test/wp-login.php?login=superadmin&key=&lt;valid key&gt;&action=rp; (2) the super admin's view of the sub-site dashboard shows 15 admin-bar links on the attacker host (Network Admin and children, My Sites, the main site's blog-1 menu, Edit Site, Add New Site); (3) under the sub-site context get_home_url(1) and home_url() after switch_to_blog(1) return the attacker host, while blogs whose hosts are not listed are unaffected; (4) the attacker host is accepted by wp_validate_redirect on the sub-site via allowed_redirect_hosts. A super admin who follows the reset link delivers the key to the attacker's host, and the attacker can complete the reset on the real network. If the sub-site's own hostname is also listed, the request matches it and network URLs are rewritten to the sub-site host instead of the attacker host. The same fallback also redirects unauthenticated sign-up traffic: on the sub-site's hostname, GET wp-login.php?action=register and GET wp-signup.php (core builds both redirects with network_site_url('wp-signup.php')) return 302 to http://attacker.test/wp-signup.php instead of the network's own sign-up page, so visitors who try to register are sent to the attacker host. ## Root cause The host allowlist is gated only by manage_options (register_setting has no capability override, so options.php uses the default; add_options_page and SettingsRenderer::render also use manage_options), and SettingsSanitizer accepts any syntactically valid hostname and a submitter-chosen primary without checking that the hosts belong to the current blog or that the saver may change network URLs. The per-blog list is nonetheless applied to every URL generated during the request, including network_site_url, network_home_url and other blogs' home/site URLs, with any unmatched Host falling back to the chosen primary. On multisite, sub-site admins hold manage_options but are below super admin and cannot otherwise change network URLs. ## Expected behaviour A sub-site admin should only affect URLs of their own site. Hosts that network-scoped URLs, reset links, other blogs' URLs and the redirect allowlist are rewritten to should be chosen only by a network-trusted principal (manage_network_options), or configured hosts should be limited to the current blog's hostnames and network or other-blog URLs should never be rewritten from a sub-site's per-site option. ## Source trace 1. `src/Admin/SettingsPage.php:38` (SettingsPage::registerSetting): register_setting for multi_domain_redux_settings with no capability override; options.php then requires only manage_options, which sub-site admins hold (lab: sub-site admin b, is_super_admin false, no manage_network_options, saved via options.php with the Settings-API nonce, 302 settings-updated=true). 2. `src/Admin/SettingsSanitizer.php:66` (SettingsSanitizer::fromInput): Any valid hostname is accepted after normalize() (attacker.test, network.test); is_primary is chosen by the submitter (line 82). 3. `src/Plugin.php:34` (Plugin::boot): The booting blog's option is loaded once and drives global URL filters for the whole request, including other blogs' and network URLs. 4. `src/Request/HostResolver.php:62` (HostResolver::resolve): The sub-site's own Host (b.network.test) is not listed, so the current host falls back to settings-&gt;primary() = attacker.test. 5. `src/Rewrite/HookBindings.php:22` (HookBindings::URL_FILTERS): network_home_url and network_site_url (lines 22-23), plus home_url, site_url, admin_url and others, are registered to rewriteUrlFilter (line 57). 6. `src/Rewrite/UrlRewriter.php:61` (UrlRewriter::rewrite): Any URL on a listed host (network.test), such as the reset link http://network.test/wp-login.php?...&action=rp and network-admin and main-site URLs, is rebuilt on the current host attacker.test. ## Conditions - **system configuration**: WordPress multisite with subdomain or domain-mapped sub-sites; on a subdirectory network all sites share the network Host, which then matches its own list entry. - **system configuration**: Plugin active on the sub-site (activated for that site by a super admin, or network-activated). - **authorization role**: Attacker administers one sub-site only (manage_options there; is_super_admin false; no manage_network_options) and lists the network main host but not their own sub-site host. - **network routing**: To harvest the key, the attacker must control a server for the hostname they configure. - **third party dependency**: WordPress core behaviour observed in a WP 7.1.3 lab: retrieve_password() builds the reset URL with network_site_url(); users are network-global and a reset for any login can be requested via any site's wp-login.php; sub-site admins hold manage_options and options.php enforces only that default capability for the registered setting. - **user interaction**: Key theft requires the targeted user to follow the link in the genuine reset email (or a rewritten admin-bar link), whose host is the attacker's domain. ## Lab reproduction (local, dummy data only) **Principal:** Dummy administrator b of sub-site b.network.test on a subdomain network whose main host is network.test; victim is dummy super admin superadmin. 1. In a WordPress 7.1.3 subdomain multisite lab (http only), the super admin activates the plugin on sub-site b only. 2. Log in as sub-site admin b, read _wpnonce from /wp-admin/options-general.php?page=multi-domain-redux, and POST the host list (without the sub-site's own hostname) to /wp-admin/options.php. 3. Unauthenticated, POST user_login=superadmin to b's wp-login.php?action=lostpassword and read the captured mail. 4. Log in as superadmin, GET b's /wp-admin/ and list admin-bar hrefs on attacker.test; compare with the main site's dashboard. 5. Unauthenticated, GET b's /wp-login.php?action=register and /wp-signup.php without following redirects and read the Location header; repeat with the plugin deactivated on b. 6. wp eval --url=b.network.test: print network_site_url('wp-login.php'), get_home_url(1), home_url() after switch_to_blog(1) and after switch_to_blog(3), and wp_validate_redirect('http://attacker.test/x'); repeat with --url=network.test as control; then delete the option and deactivate the plugin. **Observed:** Stored option [attacker.test primary, network.test]. The captured reset email to [email protected] contained http://attacker.test/wp-login.php?login=superadmin&key=&lt;REDACTED&gt;&action=rp&wp_lang=en_US. The super admin's b dashboard had 15 admin-bar hrefs on attacker.test (Network Admin and children, My Sites, blog-1, edit-site, add-new-site); the main dashboard had 0. Under b, network_site_url, get_home_url(1) and blog 1's home_url after switch_to_blog all returned attacker.test; blog 3 stayed at c.network.test; wp_validate_redirect accepted http://attacker.test/x. Main-site controls unchanged. With b.network.test also listed, the reset link went to b.network.test instead. Scheme was http because the lab is http-only. The key was never used and the lab was restored. Independently reproduced earlier by hunter w3a (sub-site c) and verifier v2 (sub-site b). Sign-up redirect variant (verifier v10, lab hosts net2.test / b.net2.test, option [attacker.test primary, net2.test]): unauthenticated GET /wp-login.php?action=register and GET /wp-signup.php on Host b.net2.test both returned 302 Location: http://attacker.test/wp-signup.php; with the plugin inactive on b both returned 302 Location: http://net2.test/wp-signup.php. ## Fix Bind host authority to the right principal. On multisite, require manage_network_options for the settings page and options.php saves (filter option_page_capability_multi_domain_redux_group and use the same capability in add_options_page and SettingsRenderer::render), or reject hosts equal to the network domain or another site's domain unless the saver has manage_network_options. Defence in depth: do not rewrite network-scoped or other blogs' URLs from a per-site snapshot. Skip network_home_url/network_site_url on multisite, return early while ms_is_switched(), and leave URLs for other blogs untouched using each filter's own blog_id argument: it is the 4th argument of home_url/site_url but the 3rd of admin_url/rest_url (get_admin_url(N) and get_rest_url(N) apply their filters without switching, and get_home_url(N) applies its filter after restore_current_blog(), so ms_is_switched() alone is not enough). Regression tests: on multisite a non-super-admin cannot save; network_site_url is unchanged on a sub-site for an unmatched Host; home_url, site_url, admin_url and rest_url for another blog_id are not rewritten. Suggested change in `src/Admin/SettingsPage.php`: ```php public function register(): void { add_action( 'admin_init', array( $this, 'registerSetting' ) ); add_action( 'admin_menu', array( $this, 'registerMenu' ) ); add_action( 'admin_enqueue_scripts', array( $this->assets, 'enqueue' ) ); add_filter( 'option_page_capability_' . self::OPTION_GROUP, array( self::class, 'capability' ) ); // ... } public static function capability(): string { return is_multisite() ? 'manage_network_options' : 'manage_options'; } // registerMenu(): pass self::capability() instead of 'manage_options'. // SettingsRenderer::render(): check current_user_can( SettingsPage::capability() ). ``` Suggested change in `src/Rewrite/HookBindings.php`: ```php private const BLOG_ID_ARG4_FILTERS = array( 'home_url', 'site_url' ); private const BLOG_ID_ARG3_FILTERS = array( 'admin_url', 'rest_url' ); private const NETWORK_FILTERS = array( 'network_home_url', 'network_site_url' ); // In register(): foreach ( self::URL_FILTERS as $filter ) { if ( is_multisite() && in_array( $filter, self::NETWORK_FILTERS, true ) ) { continue; } if ( in_array( $filter, self::BLOG_ID_ARG4_FILTERS, true ) ) { add_filter( $filter, array( $this, 'rewriteSiteUrlFilter' ), 99, 4 ); } elseif ( in_array( $filter, self::BLOG_ID_ARG3_FILTERS, true ) ) { add_filter( $filter, array( $this, 'rewriteAdminUrlFilter' ), 99, 3 ); } else { add_filter( $filter, array( $this, 'rewriteUrlFilter' ), 99, 1 ); } } /** * @param mixed $url */ public function rewriteUrlFilter( $url ): mixed { if ( ! is_string( $url ) ) { return $url; } if ( is_multisite() && ms_is_switched() ) { return $url; } return $this->rewriter->rewrite( $url ); } /** * Filters home_url / site_url: ( $url, $path, $scheme, $blog_id ). * * @param mixed $url * @param mixed $path * @param mixed $scheme * @param mixed $blogId */ public function rewriteSiteUrlFilter( $url, $path = '', $scheme = null, $blogId = null ): mixed { unset( $path, $scheme ); return $this->isOtherBlog( $blogId ) ? $url : $this->rewriteUrlFilter( $url ); } /** * Filters admin_url / rest_url: ( $url, $path, $blog_id ). * * @param mixed $url * @param mixed $path * @param mixed $blogId */ public function rewriteAdminUrlFilter( $url, $path = '', $blogId = null ): mixed { unset( $path ); return $this->isOtherBlog( $blogId ) ? $url : $this->rewriteUrlFilter( $url ); } /** * @param mixed $blogId */ private function isOtherBlog( $blogId ): bool { return is_multisite() && ! empty( $blogId ) && is_numeric( $blogId ) && (int) $blogId !== get_current_blog_id(); } ``` ## Priority rationale - Likelihood: Requires a subdomain or domain-mapped multisite where the plugin is active on a sub-site run by a less-trusted admin who also controls a server for the attacker host, and the victim must follow a reset or admin-bar link pointing at an unfamiliar host. Once configured, the attacker can trigger the genuine reset email unauthenticated. - Impact: A valid password-reset key for any network user, including a super admin, is delivered in a link to a host the sub-site admin controls, crossing the sub-site-admin/super-admin boundary and enabling network takeover; the super admin's network-admin navigation links are also redirected to that host.
eric closed this issue 2026-10-15 23:18:32 -07:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
eric/multi-domain-redux#6
No description provided.