get_col( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $wpdb->prefix ) . '%' ) ); } /** * Process one time-boxed slice; advances $ti/$off cursors. Returns true when done. * * @param array $pairs Replacement map [old => new]. * @param int $ti Table index cursor (by reference). * @param int $off Row offset cursor (by reference). * @param float $t0 Step start time. * @param float $budget Seconds. * @return bool */ public static function step( array $pairs, &$ti, &$off, $t0, $budget ) { global $wpdb; $tables = self::tables(); while ( ( microtime( true ) - $t0 ) < $budget && $ti < count( $tables ) ) { $table = str_replace( '`', '', $tables[ $ti ] ); $pks = self::primary_keys( $table ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table sanitized (backticks stripped) and backtick-quoted; LIMIT is (int)-cast. $rows = $wpdb->get_results( "SELECT * FROM `{$table}` LIMIT " . (int) $off . ', ' . self::BATCH, ARRAY_A ); if ( empty( $rows ) ) { $ti++; $off = 0; continue; } foreach ( $rows as $row ) { $update = array(); foreach ( $row as $col => $val ) { if ( ! is_string( $val ) || '' === $val ) { continue; } $new = self::replace_value( $val, $pairs ); if ( $new !== $val ) { $update[ $col ] = $new; } } if ( $update ) { self::apply_update( $table, $update, $row, $pks ); } } $off += count( $rows ); if ( count( $rows ) < self::BATCH ) { $ti++; $off = 0; } } return $ti >= count( $tables ); } /** * Replace inside a value, re-serializing serialized data safely. * * @param string $val Value. * @param array $pairs Map. * @return string */ public static function replace_value( $val, array $pairs ) { $un = self::maybe_unserialize( $val ); if ( false !== $un || 'b:0;' === $val ) { // With allowed_classes => false any serialized object came back inert // (__PHP_Incomplete_Class); it can be neither rewritten nor safely re-serialized. // If the value is/contains an object, leave it EXACTLY as it was — don't rewrite // URLs inside it (a rare edge case) rather than corrupt it or crash. Object-free // arrays/scalars re-serialize normally below. if ( self::has_object( $un ) ) { return $val; } return serialize( self::recurse( $un, $pairs ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize } return strtr( $val, $pairs ); } /** * Whether decoded data is, or anywhere contains, an object. * * @param mixed $data Decoded data. * @return bool */ private static function has_object( $data ) { if ( is_object( $data ) ) { return true; } if ( is_array( $data ) ) { foreach ( $data as $v ) { if ( self::has_object( $v ) ) { return true; } } } return false; } /** * Unserialize only real serialized strings (false otherwise). * * @param string $val Value. * @return mixed */ private static function maybe_unserialize( $val ) { if ( ! preg_match( '/^[aOs]:\d+:/', $val ) && 'b:0;' !== $val && 'N;' !== $val ) { return false; } // allowed_classes => false is critical: this runs on every DB string value during a // migration, so unserializing an attacker-controlled payload (a malicious backup being // restored) would otherwise instantiate arbitrary objects and fire __wakeup/__destruct // gadget chains — a remote-code-execution vector. With it, any serialized object comes // back inert (__PHP_Incomplete_Class), never executing code. Arrays/scalars are // unaffected, so the serialization-safe replacement below still works normally. // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize return @unserialize( $val, array( 'allowed_classes' => false ) ); } /** * Recursively replace inside decoded data. * * @param mixed $data Data. * @param array $pairs Map. * @return mixed */ private static function recurse( $data, array $pairs ) { if ( is_array( $data ) ) { $out = array(); foreach ( $data as $k => $v ) { $out[ is_string( $k ) ? strtr( $k, $pairs ) : $k ] = self::recurse( $v, $pairs ); } return $out; } if ( is_object( $data ) ) { // Never instantiate or mutate objects (they are inert __PHP_Incomplete_Class here); // replace_value() already short-circuits object-bearing values, so this is a guard. return $data; } if ( is_string( $data ) ) { return strtr( $data, $pairs ); } return $data; } /** * Primary-key column for a table (or null). * * @param string $table Table. * @return string|null */ private static function primary_keys( $table ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table sanitized (backticks stripped) and backtick-quoted. $keys = $wpdb->get_results( "SHOW KEYS FROM `{$table}` WHERE Key_name = 'PRIMARY'" ); if ( ! $keys ) { return array(); } usort( $keys, static function ( $a, $b ) { return (int) $a->Seq_in_index <=> (int) $b->Seq_in_index; } ); $cols = array(); foreach ( $keys as $k ) { if ( ! empty( $k->Column_name ) ) { $cols[] = (string) $k->Column_name; } } return $cols; } /** * Write one row's replacements back. Matches on ALL primary-key columns (a composite PK * matched on only its first column would overwrite sibling rows); for a table with NO * primary key, matches the exact original row (all columns, NULL-safe) with LIMIT 1 so * duplicate rows are each updated once — rather than skipping the whole table. * * @param string $table Sanitized table name. * @param array $update col => new value. * @param array $row The original row (col => value). * @param array $pks Primary-key column names (possibly empty). * @return void */ private static function apply_update( $table, array $update, array $row, array $pks ) { global $wpdb; if ( $pks ) { $where = array(); foreach ( $pks as $col ) { if ( array_key_exists( $col, $row ) ) { $where[ $col ] = $row[ $col ]; } } if ( $where ) { $wpdb->update( $table, $update, $where ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery return; } } // No usable primary key — match the exact ORIGINAL row. $sets = array(); $params = array(); foreach ( $update as $col => $val ) { $sets[] = '`' . str_replace( '`', '``', (string) $col ) . '` = %s'; $params[] = $val; } $wheres = array(); foreach ( $row as $col => $val ) { $c = '`' . str_replace( '`', '``', (string) $col ) . '`'; if ( null === $val ) { $wheres[] = $c . ' IS NULL'; } else { $wheres[] = $c . ' = %s'; $params[] = $val; } } $safe = str_replace( '`', '``', $table ); $sql = "UPDATE `{$safe}` SET " . implode( ', ', $sets ) . ' WHERE ' . implode( ' AND ', $wheres ) . ' LIMIT 1'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- identifiers backtick-quoted; all values bound via prepare(). $wpdb->query( $wpdb->prepare( $sql, $params ) ); } }