*/ private const ALLOWED_UNSERIALIZE_CLASSES = [ 'stdClass', 'ArrayObject', 'DateTime', 'DateTimeImmutable', ]; /** * Hard cap on nested serialized content. Real WordPress data nests a few levels * at most, so anything deeper is a reference cycle or a pathological payload and * recursion stops. */ private const MAX_REPLACEMENT_DEPTH = 50; /** * Deepest slash-escaping a URL needle is matched at. Plugins that re-encode their * own blobs stack a backslash per pass, so one URL can appear as `\/`, `\\/`, and * so on. Eight covers the deepest stacking seen in real option data. */ private const MAX_SLASH_ESCAPE_DEPTH = 8; /** * What may not follow a URL needle that ends inside a host or path segment. * `https://old.com` must not rewrite `https://old.com.au`, `https://old.com-shop` * or `https://old.com:8080`. A slash, quote, space or end of value closes the * address and is fine. */ private const URL_BOUNDARY = '(?![A-Za-z0-9_\-]|\.[A-Za-z0-9]|:[0-9])'; /** * Replace string values inside a PHP serialized blob. * * @param string[] $find Strings to find. * @param string[] $replace Replacements, parallel to $find. * * @return string Fixed serialized string with correct length prefixes. */ public static function replace( string $data, array $find, array $replace, bool $case_sensitive = true ): string { return self::do_replace( $data, $find, $replace, $case_sensitive, 0 ); } /** * Depth-aware core of replace(). Nested serialized payloads recurse through here * so their inner prefixes are rewritten too; the depth cap stops reference cycles. * * @param string[] $find Strings to find. * @param string[] $replace Replacements. * @param int $depth Current nesting level. * * @return string Fixed serialized string with correct length prefixes. */ private static function do_replace( string $data, array $find, array $replace, bool $case_sensitive, int $depth ): string { if ( '' === $data || $depth > self::MAX_REPLACEMENT_DEPTH ) { return $data; } if ( ! self::is_serialized( $data ) ) { $json_result = self::replace_json_string( $data, $find, $replace, $case_sensitive, $depth ); return null !== $json_result ? $json_result : self::replace_plain( $data, $find, $replace, $case_sensitive ); } // Unserialize, recurse, re-serialize. $value = @unserialize( $data, ['allowed_classes' => self::ALLOWED_UNSERIALIZE_CLASSES] ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Probes whether the value is serialized; a malformed string returns false and is handled below. if ( false === $value && $data !== serialize( false ) ) { // A blob whose length prefixes disagree with its contents cannot be // unserialized. Repair the prefixes and retry; the repair only counts if // it round-trips through serialize() byte for byte, otherwise the byte // walker is the fallback. $repaired = self::repair_length_prefixes( $data ); if ( null !== $repaired ) { $try = @unserialize( $repaired, ['allowed_classes' => self::ALLOWED_UNSERIALIZE_CLASSES] ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Probes the repaired string; a false return is handled by the check below. if ( false !== $try || $repaired === serialize( false ) ) { $data = $repaired; $value = $try; } } if ( false === $value && $data !== serialize( false ) ) { return self::regex_replace_checked( $data, $find, $replace, $case_sensitive, $depth ); } } $opaque = false; $modified = self::walk( $value, $find, $replace, $case_sensitive, $depth, $opaque ); if ( $opaque ) { // Something in the tree cannot be rebuilt from what unserialize() // returned, so serializing it would not be a faithful copy. The byte // walker rewrites every length-delimited string in the original blob and // copies the rest through untouched. return self::regex_replace_checked( $data, $find, $replace, $case_sensitive, $depth ); } $result = serialize( $modified ); // If the result does not unserialize cleanly, use the byte-walker fallback. if ( false === @unserialize( $result, ['allowed_classes' => self::ALLOWED_UNSERIALIZE_CLASSES] ) && $result !== serialize( false ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Probes the re-serialized result; a false return falls through to the byte-walker fallback below. return self::regex_replace_checked( $data, $find, $replace, $case_sensitive, $depth ); } return $result; } /** * Recursively walk a value produced by unserialize() and apply replacements. * * @param mixed $value Value to process. * @param string[] $find Strings to find. * @param string[] $replace Replacements. * @param int $depth Current nesting level. * @param bool $opaque Set when re-serializing the tree would not reproduce the input faithfully. * * @return mixed Modified value. */ private static function walk( mixed $value, array $find, array $replace, bool $case_sensitive, int $depth, bool &$opaque ): mixed { if ( $depth > self::MAX_REPLACEMENT_DEPTH ) { return $value; } if ( is_string( $value ) ) { // A string that is itself serialized data goes through the same // length-aware path, not replace_plain(). The depth cap bounds this for // self-referential payloads. if ( self::is_serialized( $value ) ) { return self::do_replace( $value, $find, $replace, $case_sensitive, $depth + 1 ); } $json_result = self::replace_json_string( $value, $find, $replace, $case_sensitive, $depth ); return null !== $json_result ? $json_result : self::replace_plain( $value, $find, $replace, $case_sensitive ); } if ( is_array( $value ) ) { $result = []; foreach ( $value as $k => $v ) { // Keys can also contain URL-like strings (e.g., option names). $new_key = is_string( $k ) ? self::replace_plain( $k, $find, $replace, $case_sensitive ) : $k; $result[ $new_key ] = self::walk( $v, $find, $replace, $case_sensitive, $depth + 1, $opaque ); } return $result; } if ( is_object( $value ) ) { if ( self::is_incomplete_object( $value ) ) { // The allowlist could not build this class, so its state is invisible // here. Flag the tree so the caller rewrites the original bytes, since // re-serializing an incomplete object would discard anything the class // encoded itself rather than storing as properties. $opaque = true; return $value; } if ( $value instanceof \ArrayObject ) { // ArrayObject keeps its storage behind the class, so get_object_vars() // is empty and the generic branch below would rewrite nothing. Values // are updated in place and keys are left alone, because rebuilding would // flatten the storage into a plain array. foreach ( $value as $key => $item ) { $value[ $key ] = self::walk( $item, $find, $replace, $case_sensitive, $depth + 1, $opaque ); } return $value; } $vars = get_object_vars( $value ); foreach ( $vars as $prop => $prop_value ) { $value->$prop = self::walk( $prop_value, $find, $replace, $case_sensitive, $depth + 1, $opaque ); } return $value; } return $value; } /** * Whether a value is JSON the rewriter may decode. A leading brace is not proof: * shortcode attributes open with `{` too, and the JSON rewriter would leave stray * backslashes in a value that was never JSON. */ public static function looks_like_json( string $value ): bool { $trimmed = trim( $value ); if ( '' === $trimmed || ! in_array( $trimmed[0], [ '{', '[', '"' ], true ) ) { return false; } json_decode( $trimmed ); return JSON_ERROR_NONE === json_last_error(); } /** * @param string $value Candidate JSON string. * @param string[] $find Strings to find. * @param string[] $replace Replacements. * @param int $depth Current nesting level. * * @return string|null */ private static function replace_json_string( string $value, array $find, array $replace, bool $case_sensitive, int $depth ): ?string { if ( ! self::looks_like_json( $value ) ) { return null; } // Rewrite only the bodies of JSON string literals and copy every other // byte (numbers, booleans, whitespace, key text) verbatim. Re-encoding the // whole document would normalize numeric tokens the replacement never // touched, and a whole-body re-escape would double escapes like `\/`. $out = ''; $body = ''; $in_str = false; $len = strlen( $value ); $changed = false; for ( $i = 0; $i < $len; $i++ ) { $c = $value[ $i ]; if ( ! $in_str ) { if ( '"' === $c ) { $in_str = true; $body = ''; } else { $out .= $c; } continue; } if ( '\\' === $c ) { $body .= $c; if ( isset( $value[ $i + 1 ] ) ) { $body .= $value[ ++$i ]; } continue; } if ( '"' !== $c ) { $body .= $c; continue; } $in_str = false; // A serialized payload inside a JSON string goes through the // length-aware path, not a raw str_replace on the escaped body. $unescaped = json_decode( '"' . $body . '"' ); $deep = ( is_string( $unescaped ) && self::is_serialized( $unescaped ) ) ? self::do_replace( $unescaped, $find, $replace, $case_sensitive, $depth + 1 ) : null; if ( null !== $deep && $deep !== $unescaped ) { $changed = true; $out .= wp_json_encode( $deep ); continue; } $rewritten = self::rewrite_json_literal( $body, $unescaped, $find, $replace, $case_sensitive ); if ( null !== $rewritten ) { $changed = true; $out .= '"' . $rewritten . '"'; } else { $out .= '"' . $body . '"'; } } return $changed ? $out : $value; } /** * Rewrite a JSON string-literal body through its decoded text. * * Needles are matched against the decoded value, so `\/` and \uXXXX escapes * still match, with the same replacer and boundary rule as every plain value. * A literal nothing matched is copied byte for byte; a changed one is re-encoded * whole, which can restyle its escapes but never its meaning. This runs at * str_replace speed, where a per-character walk took seconds per megabyte and * ran a page of large JSON rows into the request time limit. * * @param string $body Escaped body between the surrounding quotes. * @param mixed $unescaped json_decode() of the quoted body, null when it is malformed. * @param string[] $find Strings to find. * @param string[] $replace Replacements. * * @return string|null Rewritten body, or null when no needle matched or the literal is malformed. */ private static function rewrite_json_literal( string $body, mixed $unescaped, array $find, array $replace, bool $case_sensitive ): ?string { if ( ! is_string( $unescaped ) || '' === $unescaped ) { return null; } $text = self::replace_plain( $unescaped, $find, $replace, $case_sensitive ); if ( $text === $unescaped ) { return null; } return self::json_escape_fragment( $text ); } /** * JSON-escape plain decoded text without the surrounding quotes. */ private static function json_escape_fragment( string $text ): string { $encoded = wp_json_encode( $text ); if ( ! is_string( $encoded ) || strlen( $encoded ) < 2 ) { return str_replace( [ '\\', '"' ], [ '\\\\', '\\"' ], $text ); } return substr( $encoded, 1, -1 ); } /** * Apply replacements using the requested case sensitivity. Public because * FindReplaceEngine routes its plain branch here, so the two cannot drift apart. * * @param string[] $find Strings to find. * @param string[] $replace Replacements. */ public static function replace_plain( string $value, array $find, array $replace, bool $case_sensitive ): string { $find = array_values( $find ); $replace = array_values( $replace ); $needles = []; $subs = []; $bounded = []; // Block markup and hand-encoded option blobs store URLs with backslash- // escaped slashes, which a plain search cannot match across. Every depth // the data may sit at gets its own pair so the scan can reach it. foreach ( $find as $i => $needle ) { $needle = (string) $needle; if ( '' === $needle ) { continue; } $target = (string) ( $replace[ $i ] ?? '' ); $edge = self::needs_boundary( $needle ); $needles[] = $needle; $subs[] = $target; $bounded[] = $edge; if ( ! self::is_url_needle( $needle ) ) { continue; } for ( $depth = 1; $depth <= self::MAX_SLASH_ESCAPE_DEPTH; $depth++ ) { $slashes = str_repeat( '\\', $depth ); $needles[] = str_replace( '/', $slashes . '/', $needle ); $subs[] = str_replace( '/', $slashes . '/', $target ); $bounded[] = $edge; } } if ( [] === $needles ) { return $value; } // A lone plain needle is just str_replace(), the common case. if ( 1 === count( $needles ) && ! $bounded[0] ) { return $case_sensitive ? str_replace( $needles[0], $subs[0], $value ) : str_ireplace( $needles[0], $subs[0], $value ); } $present = false; foreach ( $needles as $needle ) { if ( $case_sensitive ? str_contains( $value, $needle ) : false !== stripos( $value, $needle ) ) { $present = true; break; } } return $present ? self::replace_single_pass( $value, $needles, $subs, $bounded, $case_sensitive ) : $value; } /** * Replace every needle in one left-to-right scan, longest needle first. * * str_replace() with arrays runs each pair over the previous pair's output, so a * needle inside another pair's replacement rewrites it again (`//example.com` to * `//example.com/blog`, then `https://example.com`, gives `/blog/blog`). A single * scan never revisits text it has written. * * @param string[] $needles Non-empty needles. * @param string[] $subs Replacements, parallel to $needles. * @param bool[] $bounded Whether each needle must end on a URL boundary. */ private static function replace_single_pass( string $value, array $needles, array $subs, array $bounded, bool $case_sensitive ): string { $order = array_keys( $needles ); usort( $order, static fn( int $a, int $b ): int => strlen( $needles[ $b ] ) <=> strlen( $needles[ $a ] ) ?: $a <=> $b ); $alternatives = []; $map = []; foreach ( $order as $k ) { $alternatives[] = preg_quote( $needles[ $k ], '~' ) . ( $bounded[ $k ] ? self::URL_BOUNDARY : '' ); } // The earlier pair wins when two needles are the same text, as with // str_replace(). foreach ( $needles as $k => $needle ) { $key = $case_sensitive ? $needle : strtolower( $needle ); if ( ! array_key_exists( $key, $map ) ) { $map[ $key ] = $subs[ $k ]; } } $out = preg_replace_callback( '~' . implode( '|', $alternatives ) . '~' . ( $case_sensitive ? '' : 'i' ), static function ( array $m ) use ( $map, $case_sensitive ): string { $key = $case_sensitive ? $m[0] : strtolower( $m[0] ); return $map[ $key ] ?? $m[0]; }, $value ); // A PCRE failure (backtrack limit on a huge value) leaves the value as it // was rather than half-rewritten. return is_string( $out ) ? $out : $value; } /** * Whether a needle is a URL, anchored by a scheme or a protocol-relative `//`. */ private static function is_url_needle( string $needle ): bool { return str_contains( $needle, '://' ) || str_starts_with( $needle, '//' ); } /** * Whether a match of this needle has to be followed by a URL boundary. Only a URL * ending mid-label needs one; a needle ending in `/` has closed its host. */ private static function needs_boundary( string $needle ): bool { return self::is_url_needle( $needle ) && 1 === preg_match( '/[A-Za-z0-9_\-]$/', $needle ); } /** * Rewrite length prefixes that disagree with the bytes they describe. * * A writer that ran strlen() before its own encoding pass leaves wrong counts, and * one bad count makes unserialize() reject the whole blob. Each bad token is * re-measured against its closing `";`. The result is used only if it * unserializes and serializes back to the same bytes; otherwise null and the * caller keeps the byte walker. * * @param string $data Serialized PHP string that failed to unserialize. * * @return string|null Repaired blob, or null when it cannot be trusted. */ private static function repair_length_prefixes( string $data ): ?string { $out = ''; $len = strlen( $data ); $i = 0; $fixed = 0; while ( $i < $len ) { // A string token opens with s::" if ( 's' !== $data[ $i ] || $i + 2 >= $len || ':' !== $data[ $i + 1 ] ) { $out .= $data[ $i++ ]; continue; } $j = $i + 2; $digits = ''; while ( $j < $len && ctype_digit( $data[ $j ] ) ) { $digits .= $data[ $j++ ]; } if ( '' === $digits || $j + 1 >= $len || ':' !== $data[ $j ] || '"' !== $data[ $j + 1 ] ) { $out .= $data[ $i++ ]; continue; } $declared = (int) $digits; $val_start = $j + 2; if ( $val_start + $declared + 1 < $len && '"' === $data[ $val_start + $declared ] && ';' === $data[ $val_start + $declared + 1 ] ) { $out .= substr( $data, $i, $val_start + $declared + 2 - $i ); $i = $val_start + $declared + 2; continue; } // The count is wrong. Trust the terminator, but only as a candidate; the // round-trip below decides. $close = strpos( $data, '";', $val_start ); if ( false === $close ) { return null; } $actual = $close - $val_start; $out .= 's:' . $actual . ':"' . substr( $data, $val_start, $actual ) . '";'; $i = $close + 2; $fixed++; } if ( 0 === $fixed ) { return null; } $value = @unserialize( $out, ['allowed_classes' => self::ALLOWED_UNSERIALIZE_CLASSES] ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Probes the rebuilt value; a malformed string returns false and is handled below. if ( false === $value && $out !== serialize( false ) ) { return null; } return serialize( $value ) === $out ? $out : null; } /** * Byte-walking fallback, returned only when its output still unserializes. The * walker passes an unvalidated prefix through, so its output is not automatically * better; a blob the destination cannot read loses the data, so keeping the input * is the smaller failure. * * @param string[] $find Strings to find. * @param string[] $replace Replacements. * @param int $depth Current nesting level. * * @return string Rewritten blob, or $data when the rewrite cannot be trusted. */ private static function regex_replace_checked( string $data, array $find, array $replace, bool $case_sensitive, int $depth ): string { $out = self::regex_replace( $data, $find, $replace, $case_sensitive, $depth ); if ( $out !== $data && false === @unserialize( $out, ['allowed_classes' => self::ALLOWED_UNSERIALIZE_CLASSES] ) // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Probes the rebuilt value; a malformed string returns false and is rejected by the condition below. && $out !== serialize( false ) ) { return $data; } return $out; } /** * Length-aware fallback: walks the blob byte by byte, reading each `s:N:"..."` * token by its declared count instead of searching for a terminator, so string * values containing `";` do not break it. * * @param string[] $find Strings to find. * @param string[] $replace Replacements. * @param int $depth Current nesting level. * * @return string Modified serialized string with corrected length prefixes. */ private static function regex_replace( string $data, array $find, array $replace, bool $case_sensitive, int $depth ): string { if ( $depth > self::MAX_REPLACEMENT_DEPTH ) { return $data; } $output = ''; $len = strlen( $data ); $i = 0; while ( $i < $len ) { // Detect the start of a string token: s::" if ( 's' === $data[ $i ] && ( $i + 2 ) < $len && ':' === $data[ $i + 1 ] ) { $j = $i + 2; $num_str = ''; while ( $j < $len && ctype_digit( $data[ $j ] ) ) { $num_str .= $data[ $j++ ]; } if ( '' !== $num_str && ( $j + 1 ) < $len && ':' === $data[ $j ] && '"' === $data[ $j + 1 ] ) { $str_len = (int) $num_str; $val_start = $j + 2; // position immediately after the opening " // Validate: the declared N bytes must be followed by "; if ( ( $val_start + $str_len + 1 ) < $len && '"' === $data[ $val_start + $str_len ] && ';' === $data[ $val_start + $str_len + 1 ] ) { $value = substr( $data, $val_start, $str_len ); $new_val = self::is_serialized( $value ) ? self::do_replace( $value, $find, $replace, $case_sensitive, $depth + 1 ) : self::replace_plain( $value, $find, $replace, $case_sensitive ); $output .= 's:' . strlen( $new_val ) . ':"' . $new_val . '";'; $i = $val_start + $str_len + 2; // skip past closing "; continue; } } } $output .= $data[ $i++ ]; } return $output; } /** * Whether an unserialized object is incomplete (class not available). */ private static function is_incomplete_object( object $value ): bool { if ( '__PHP_Incomplete_Class' === get_class( $value ) ) { return true; } return false; } /** * Whether a string is PHP serialized data. Mirrors WordPress's is_serialized() * without depending on it. */ public static function is_serialized( string $data ): bool { $data = trim( $data ); if ( '' === $data ) { return false; } if ( 'N;' === $data ) { return true; } if ( strlen( $data ) < 4 ) { return false; } if ( ':' !== $data[1] ) { return false; } $token = $data[0]; if ( in_array( $token, [ 's', 'i', 'd', 'b', 'a', 'O', 'C' ], true ) ) { $end = substr( $data, -1 ); if ( ';' === $end || '}' === $end ) { return true; } } return false; } }