Object Injection vulnerability fixed in SEOPress 7.9

During a routine audit of various WordPress plugins, we identified a few issues in SEOPress (300k+ active installs). More specifically, we discovered an authentication bug which could allow attackers to access certain protected REST API routes without having any kind of account on the targeted site.

Digging deeper into what an attacker could do with this level of access, we identified another issue in how the plugin saves and retrieves posts metadata from its custom database tables, allowing malicious actors to unserialize arbitrary objects and conduct Object Injection attacks.

Object Injection vulnerabilities allow attackers to craft Property‑Oriented Programming chains (aka. POP chains), which consist of serialized class instances under the attacker’s control meant to be triggered at different points in time during execution, often via PHP magic methods. As we have shown in the past, certain POP chains can lead to full blown Remote Code Execution.

This vulnerability was fixed on June 18th, with version 7.9.

Authentication Bypass Leading To Object Injection

Fixed Version7.8.1
CVE IDCVE-2024-5488
WPVDB-ID28507376-ded0-4e1a-b2fc-2182895aa14c
CVSSv3.18.1

The plugin declares several REST API routes, some of them meant to be publicly accessible and others not. Unfortunately, for a subset of the latters, a faulty authentication check made relatively sensitive routes accessible to unauthenticated users.

            'permission_callback' => function ($request) {
                $nonce = $request->get_header('x-wp-nonce');
                if ($nonce && wp_verify_nonce($nonce, 'wp_rest')) {
                    if (current_user_can('edit_posts')) {
                        return true;
                    }
                }
                $authorization_header = $request->get_header('Authorization');
                if (!$authorization_header) {
                    return false;
                }
                $authorization_parts = explode(' ', $authorization_header);
                if (count($authorization_parts) !== 2 || $authorization_parts[0] !== 'Basic') {
                    return false;
                }
                $credentials = base64_decode($authorization_parts[1]);
                list($username, $password) = explode(':', $credentials);
                $wp_user = get_user_by('login', $username);
                $user = wp_authenticate_application_password($wp_user, $username, $password);
                if (is_wp_error($user)) {
                    return false;
                }
                if (!user_can($user, 'edit_posts')) {
                    return false;
                }
                return true;
            },

The function hooked to those REST API routes’ permission_callback argument was trying to reimplement some of the authentication logic that WordPress does under the hood, namely verifying that any authenticated requests is providing a valid wp_rest nonce and checking for potential application passwords the user may have used. 

The implementation as it was done had one major flaw: it provided a valid WP_User object to the wp_authenticate_application_password() function, which it got from grabbing the username sent by the user straight to the get_user_by() function some lines before. To understand why this is a problem, it is good to know that the wp_authenticate_application_password() function is hooked to the “authenticate” WordPress filter

The value being filtered there is meant to either be a WP_User instance if a user was successfully authenticated via some previous mechanisms, a WP_Error object or null if authentication failed. Thus, if a WP_User object was passed as the first parameter of any functions hooked to that filter, it should simply return it, since it is assumed that successful authentication already took place.

This enabled attackers to update certain SEOPress‑related posts metadata, which as we’ve found could be escalated to conduct Object Injection attacks, but also others like SEO spam campaigns.

Unserializing Arbitrary Objects

A separate anti‑pattern caught our attention when reviewing the plugin: how it handles saving and retrieving metadata saved to the plugin’s custom database tables.

    public function getContentAnalysis($postId, $columns = ["*"]){
        global $wpdb;
        $strColumns = implode(', ', $columns);
        $sql = $wpdb->prepare(
            "SELECT {$strColumns}
             FROM {$this->getTableName()}
             WHERE post_id = %d
             ORDER BY analysis_date DESC
             LIMIT 1",
            $postId
        );
        $result = $wpdb->get_results($sql, ARRAY_A);
        if(empty($result)){
            return null;
        }
        return array_map("maybe_unserialize", $result[0]);
    }

The ContentAnalysisRepository::getContentAnalysis() method will pass every queried columns to the WordPress maybe_unserialize() function

    protected function getFormatValue($value){
        if (is_string($value)) {
            return "'" . addslashes($value) . "'";
        } elseif (is_int($value)) {
            return $value;
        }
        elseif ($value instanceof \DateTime){
            return "'" . $value->format('Y-m-d H:i:s') . "'";
        }
        else if(is_array($value)){
            if(empty($value)){
                return "NULL";
            }
            else{
                return "'" . addslashes(serialize($value)) . "'";
            }
        }
        return "NULL";
    }

However, when checking how it formats values before inserting them into an SQL query to save or update a post’s metadata, it becomes clear that the above logic may cause issues: the function only serializes a value if it’s a non‑empty array, but nothing ensures that a string being saved contains a serialized object, which will be unserialized by the getContentAnalysis method shown before.

Connecting the dots, we realized some of the posts metadata handled by these functions could be modified, and retrieved via the same REST API routes we’ve shown before could be accessed by unauthenticated attackers. This could have made it possible for them to store a malicious POP chain in arbitrary post’s metadata, then force the plugin logic we described to retrieve it, implying it all passed through the unserialization routine, and generated a user‑controlled object.

A proof of concept will be made available on the WPScan entry for this issue on July 17th, 2024.

Timeline

May 24th, 2024 – Internal discovery of this vulnerability.

May 29th, 2024 – We report the issue to SEOPress.

May 31st, 2024 – SEOPress confirms they could replicate the problem.

June 4th, 2024 – SEOPress sends us a tentative patch fixing the issue. We confirm it does.

June 18th, 2024 – SEOPress 7.9 is released, fixing the issues.

June 24th, 2024 – We publish this advisory.

Credits

Original research: Marc Montpas

Thanks to the rest of the WPScan team for feedback, help, and corrections.

Posted by

Get News and Tips From WPScan