Security (medium): multisite sub-site admin can redirect network URLs and super-admin password-reset links to a host they control #6
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Severity: medium (likelihood low, impact high)
Found by: security audit run 3 (commit
01195c8), independently verified in a disposable local WordPress 7.1.3 labFingerprint:
multi-domain-redux:SettingsPage.registerSetting:multisite-subsite-admin-controls-network-url-rewriteSummary
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
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).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).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.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.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).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
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.
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:Suggested change in
src/Rewrite/HookBindings.php:Priority rationale