DEV Community

GUIDANCE WHITE
GUIDANCE WHITE

Posted on

CVE-2026-82222 Deep Dive — Unauthenticated PHP Object Injection to RCE in GiveWP (CVSS 10.0)

Overview

Item Detail
CVE ID CVE-2026-82222
Target GiveWP (WordPress donation/fundraising plugin, 100,000+ installs)
Affected versions ≤ 4.16.7.1
Vulnerability class CWE-502 (Deserialization of Untrusted Data) → PHP Object Injection → RCE
CVSS 10.0 (Critical)
Auth required None (unauthenticated)
Patched in 4.16.7.2
Reported by Udin Chan via Patchstack (2026-07-28)

GiveWP provides donation forms, payment gateways, and donor management for WordPress. This CVE lets an attacker with no account and no login run arbitrary commands on the server. It isn't one isolated bug — it's three separate flaws chained together:

  • A "safe" unserialize helper that doesn't actually strip objects
  • A donation flow that re-deserializes data read straight back from the database, with no validation
  • A gadget chain — assembled from libraries GiveWP ships to production — that reaches system()

On 4.16.5.1 and below, a default install is enough (one active payment gateway, one published donation form). 4.16.6 through 4.16.7.1 narrow the reachable surface somewhat, but the chain still holds under common conditions (legacy forms, the Option-Based Form Editor, etc.).

Here's the full flow at a glance:


1. The "safe" unserialize helper isn't safe

GiveWP never calls unserialize() directly — it routes everything through its own helper, safeUnserialize(), in src/Helpers/Utils.php. The name alone suggests a safety net.

// src/Helpers/Utils.php - safeUnserialize()
public static function safeUnserialize( $data ) {
    $data = self::removeBackslashes( $data );

    // allowed_classes => false: objects become __PHP_Incomplete_Class, not nothing
    $unserializedData = @unserialize( trim( $data ), [ 'allowed_classes' => false ] );

    return ! $unserializedData && ! self::containsSerializedDataRegex( $data ) ? $data : $unserializedData;
}
Enter fullscreen mode Exit fullscreen mode

The catch is allowed_classes => false. Per the PHP manual, this option does not prevent object creation — it substitutes any object with a __PHP_Incomplete_Class placeholder that keeps the original class name and every property intact. When that placeholder gets serialized again later, PHP re-emits the exact same bytes.

In other words, this helper doesn't remove the payload — it just makes it look neutralized for this one read, then hands the untouched data straight back downstream. Here's what that looks like step by step:


2. How attacker data reaches the helper

The catch: this helper runs on data pulled from the database, not the request itself. The flow:

  1. The attacker stores a serialized gadget payload in their own account's last_name field via profile.php.
  2. When they submit a donation, includes/process-donation.php builds user_info from that account data and runs every field through safeUnserialize().
// includes/process-donation.php - give_process_donation_form()
$user_info = [
    'id'         => $user['user_id'],
    'title'      => $user['user_title'],
    'email'      => $user['user_email'],
    'first_name' => $user['user_first'],
    'last_name'  => $user['user_last'],   // attacker-controlled serialized gadget
    'address'    => $user['address'],
];

// "safe" unserialize just becomes __PHP_Incomplete_Class - not stripped
$user_info     = array_map( '\Give\Helpers\Utils::maybeSafeUnserialize', stripslashes_deep( $user_info ) );
$donation_data = [ /* ... */ 'user_info' => $user_info, /* ... */ ];

// written into the wp_give_sessions table as-is
$session_data = $donation_data;
give_set_purchase_session( $session_data );
Enter fullscreen mode Exit fullscreen mode

Because this value comes from account data rather than the request body, ordinary input validation never touches it. The moment the __PHP_Incomplete_Class value gets serialize()d again on its way into wp_give_sessions, the original gadget bytes land in the database intact. The next request that reads this session calls unserialize() with no allowed_classes guard — and the real gadget object comes back to life.


3. The gadget chain — from TCPDF to system()

Object injection alone doesn't do anything yet — it needs a gadget chain. GiveWP bundles the TCPDF library for PDF generation alongside its own Give\TestData classes for demo/test data, and together they form a complete chain.

// src/TestData/Framework/ProviderForwarder.php - the terminal gadget
public function __call( $name, $arguments ) {
    $provider = isset( $this->loadedProviders[ $name ] )
        ? $this->loadedProviders[ $name ]
        : $this->loadProvider( $name );

    // no check on what $provider actually is
    return call_user_func_array( $this->loadedProviders[ $name ], $arguments );
}
Enter fullscreen mode Exit fullscreen mode

loadedProviders is just a plain array property, so the attacker can populate it with any value inside the deserialized object. When that object goes out of scope and is destroyed, TCPDF::__destruct() calls _destroy(), which calls a method on an undefined property — triggering the magic method __call(). Since __call() passes the value straight to call_user_func_array() with zero validation, setting loadedProviders to system gets arbitrary OS commands executed as the web server user.


4. Getting the account for free — the unauthenticated registration bypass

The chain above requires a logged-in user, and GiveWP hands one over for free. The give_action=user_register action never checks WordPress's users_can_register option, so anyone can create an account and receive an authentication cookie even on sites where registration is disabled.

4.16.6 added a nonce requirement to this handler, but it narrows the window rather than closing it. That nonce is only emitted by the [give_register] shortcode template, and WordPress nonces for logged-out visitors are identical across the whole site for any given moment. If that shortcode appears on even one public page, an attacker harvests the nonce once and reuses it indefinitely.


5. The full chain, reassembled

Here's Patchstack's reconstruction of the actual attack sequence:

  1. Register an account — POST with give_action=user_register. The server creates the account and issues an auth cookie regardless of the site's registration setting.
  2. Plant the gadget — Read the profile nonce from profile.php, then POST the serialized gadget chain into the account's last_name field.
  3. Poison the session — Fetch a donation nonce (action=give_donation_form_nonce), then submit a donation (action=give_process_donation) with the form ID, gateway, and amount, omitting give_last. The server writes the gadget object into wp_give_sessions before returning an HTTP 500.
  4. Trigger execution — Request any front-end page with the same cookie. The server reads the poisoned session, unserializes the gadget, the object gets destroyed, and system() runs. The output is reflected right back in the HTTP response.


The patch (4.16.7.2) — what actually changed

The interesting part isn't a single fix at the reported entry point — it's that GiveWP broke the chain at several independent layers simultaneously.

An earlier attempt in 4.16.6 shows why that matters. It added a recursive check for __PHP_Incomplete_Class and, on detecting one, returned the raw string $data. That hands the original payload bytes straight back, achieving nothing — the helper still deferred the attack to the next unguarded read. 4.16.7.2 returns false instead:

// src/Helpers/Utils.php - safeUnserialize(), 4.16.7.2
if ( self::containsPhpIncompleteClass( $unserializedData ) ) {
    return false;   // 4.16.6 returned $data here, re-arming the payload
}
Enter fullscreen mode Exit fullscreen mode

4.16.7.2 then closes the chain at five separate points:

  • The write path. process-donation.php now rejects the whole donation outright if any name field contains serialized data, and the usermeta fallback runs through give_clean(), which reduces serialized input to an empty string.
  • The three read sinks. The session getter (class-give-session.php), the session table read (class-give-db-sessions.php), and the donor wall (class-give-donor-wall.php) all now explicitly pass ['allowed_classes' => false]. The donor wall mattered most, since it was reachable by an anonymous visitor through the public [give_donor_wall] shortcode with no session cookie at all.
  • The gadget itself. ProviderForwarder::__call() now verifies the resolved provider implements the expected contract before calling it.
  • Meta writes. Donor and billing name meta now pass through sanitize_text_field() before being stored.
  • Existing damage. A SanitizeSerializedObjectPayloads migration walks usermeta, give_donormeta, give_donationmeta, and give_sessions, replacing any nested object with an empty string — cleaning up payloads planted before the update. Without this step, sites poisoned pre-patch would keep a live payload sitting in their database.

The unauthenticated registration issue (give_action=user_register still ignoring users_can_register) remains unresolved in 4.16.7.2. Since the object injection chain is now broken, this alone no longer leads to code execution — Patchstack treats it as a separate access-control issue rather than part of the patched RCE chain.


Mitigation

  • Update GiveWP to 4.16.7.2 or later immediately.
  • If a site may have been exposed before patching, a version bump alone isn't enough — verify the sanitization migration actually ran and that no residual serialized payloads remain in usermeta, give_donormeta, give_donationmeta, or give_sessions.
  • Even on sites with registration disabled, give_action=user_register stays reachable — consider blocking it separately with a WAF rule or access control if it's not needed.

Top comments (0)