GiveWP
Unauthenticated PHP Object Injection to Remote Code Execution
This blog post is about an unauthenticated remote code execution vulnerability in the GiveWP plugin. An attacker with no account can run arbitrary commands on the server of a GiveWP site that has one published donation form and one active payment gateway which, on the versions where the chain is fully reachable, describes a default installation.
The flaw chains a broken “safe unserialize” helper, a donation flow that feeds that helper attacker-controlled data, and a gadget chain in code that GiveWP ships. Patchstack has issued mitigation rules to protect against exploitation of this vulnerability.
This vulnerability was discovered and reported to Patchstack by Udin Chan.
✌️ Our users are protected from this vulnerability. Are yours?
Identify vulnerabilities in your plugins and get recommendations for fixes.
Request auditProtect your users, improve server health and earn additional revenue.
Patchstack for hostsAbout the GiveWP plugin
GiveWP is a popular WordPress donation and fundraising plugin. It provides donation forms, multiple payment gateways, donor management, and reporting for nonprofits and other organizations collecting online donations.
The security vulnerability
In versions 4.16.7.1 and below, GiveWP contains an unauthenticated PHP Object Injection vulnerability that can be chained into full remote code execution. On 4.16.5.1 and below a default installation is enough to exploit: it ships with an active manual (Test Donation) gateway and an active offline gateway, and only needs one published donation form. No Test Mode, open registration, debug mode, or administrator action is required.
On 4.16.6 through 4.16.7.1 the reachability narrows but the vulnerability remains. Those releases make the legacy donation processor bail out when the submitted form is a Visual Form Builder (v3) form, so a fresh default install is no longer exploitable through it. That is a reachability change rather than a fix: a single give_forms post lacking formBuilderSettings re-arms the whole chain, in any post status including draft and trashed. That covers every site upgraded from an older version, any form import or restore, and any site where an administrator has enabled Settings → Advanced → “Option-Based Form Editor”.
The vulnerability has three parts that combine into a single chain.
The unsafe “safe” unserialize helper
GiveWP wraps unserialize() in a helper meant to make it safe. In src/Helpers/Utils.php, maybeSafeUnserialize() defers to safeUnserialize(), which calls unserialize() with allowed_classes => false:
// src/Helpers/Utils.php - safeUnserialize()
public static function safeUnserialize( $data ) {
$data = self::removeBackslashes( $data );
// allowed_classes => false: any object becomes __PHP_Incomplete_Class
$unserializedData = @unserialize( trim( $data ), [ 'allowed_classes' => false ] );
return ! $unserializedData && ! self::containsSerializedDataRegex( $data ) ? $data : $unserializedData;
}
The allowed_classes => false option does not remove the attacker’s object. Per the PHP manual, it instantiates the object as __PHP_Incomplete_Class instead, a placeholder that preserves the original class name and all of its properties. When that placeholder is later serialized again, PHP writes the same original bytes back out. So the helper does not neutralize the payload; it merely hides it for that one read and hands the untouched serialized object back to storage, deferring the attack to the next time the data is read and unserialized without this guard.
A donation flow that reaches the helper
A logged-in donor can plant serialized data that the donation flow later unserializes. The key trick is that the malicious data does not arrive in the request that triggers the unserialize; it comes from the database. The attacker stores the serialized gadget in their own account’s last_name user meta (via profile.php). When they submit a donation, includes/process-donation.php builds the donor’s user_info from that account data and runs every field through the “safe” helper:
// 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 turns the gadget into __PHP_Incomplete_Class
$user_info = array_map( '\Give\Helpers\Utils::maybeSafeUnserialize', stripslashes_deep( $user_info ) );
$donation_data = [ /* ... */ 'user_info' => $user_info, /* ... */ ];
// stored in the wp_give_sessions table
$session_data = $donation_data;
give_set_purchase_session( $session_data );
Because the data is read back from the account rather than taken directly from the request, ordinary input validation never sees it. The __PHP_Incomplete_Class placeholder is then serialized on its way into the wp_give_sessions table, and because PHP re-emits the original class name and properties for that placeholder, the malicious object bytes land in the database intact. The next request that reads the session unserializes those bytes without the allowed_classes guard and brings the real gadget object to life.
A gadget chain in the shipped code
To turn object injection into code execution, an attacker needs a “gadget chain”: a sequence of methods in already-loaded classes that, when an object is destroyed or serialized, ultimately calls a dangerous function. GiveWP ships both the TCPDF library and the Give\TestData classes, and together these form a complete chain. Destruction of the injected object enters at TCPDF::__destruct(), which calls _destroy(); that reaches the ProviderForwarder trait’s __call() magic method, which forwarded straight into call_user_func_array() using an attacker-controlled callable and argument:
// 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 );
}
Because loadedProviders is just an array property carried inside the injected object, the attacker sets it to any callable they like. Pointing it at system() executes an arbitrary OS command as the web server user.
Getting the account for free
The chain above needs a logged-in user, but GiveWP hands the attacker one. It exposes an unauthenticated registration action (give_action=user_register) that never consults the WordPress users_can_register option. Even on a site that has registration disabled, the attacker can create an account and receive an authentication cookie, then carry out the rest of the attack in the same sequence.
Version 4.16.6 added a nonce requirement to this handler, which narrows the window rather than closing it. The nonce is only emitted by the [give_register] shortcode template, and WordPress nonces for logged-out visitors are identical for every anonymous request to a given site. On any site that renders that shortcode on a public page, an attacker harvests the nonce once and reuses it.
The full chain: Unauthenticated to Remote Code Execution
- Register an account – Send a POST with
give_action=user_register. The server creates the account and returns an authentication cookie, regardless of the site’s registration setting. - Plant the gadget – Read the profile nonce from
profile.php, then POST the serialized gadget chain into the account’slast_namemeta field. - 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 but withoutgive_last. The server writes the gadget object intowp_give_sessionsbefore returning an HTTP 500. - Trigger execution – Request any front-end page with the same cookie. The server reads the poisoned session, unserializes the gadget, and runs the attacker’s command. The output can be read back over HTTP.
The patch
GiveWP fixed this in 4.16.7.2. It is worth looking at what shipped, because the interesting part is not any single change but the decision to break the chain in several places at once rather than patching the reported entry point.
An earlier attempt in 4.16.6 illustrates why that matters. It added a recursive check for __PHP_Incomplete_Class and, on detecting one, returned $data, the raw serialized string. That hands back the original payload bytes verbatim, which is the same outcome as before, so 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
}
The release then closes the chain at five independent points:
- The write path. includes/process-donation.php now rejects the whole donation outright if any name field holds serialized data, before anything is stored. The user meta fallback that started this chain is also run through
give_clean(), which returns an empty string for serialized input, so omittinggive_lastno longer smuggles anything through. - The read sinks. The three places that unserialized this data now pass
[ 'allowed_classes' => false ]: the session getter in includes/class-give-session.php, the session table read in includes/database/class-give-db-sessions.php, and the donor wall in includes/donors/class-give-donor-wall.php. The last one matters most, because it was reachable by an anonymous visitor through the public[give_donor_wall]shortcode with no session cookie at all. - The gadget.
ProviderForwarder::__call()now verifies the resolved provider implements the expected contract before calling it, so the terminalcall_user_func_array()can no longer be pointed at an arbitrary callable. - Meta writes. Donor and billing name meta are passed through
sanitize_text_field()in includes/payments/class-give-payment.php instead of being stored raw. - Existing damage. A migration,
SanitizeSerializedObjectPayloads, walksusermeta,give_donormeta,give_donationmetaandgive_sessionsand replaces any nested object with an empty string. This is the part most fixes omit: without it, a site poisoned before updating keeps a live payload in its database.
Any one of the write rejection, the restricted reads, or the provider check would break the reported attack on its own. Layering them is the right instinct for a deserialization bug, where the exploitable surface is rarely limited to the one path a report demonstrates.
One item from the report is not addressed: as noted above, give_action=user_register still does not consult users_can_register. That is an access control issue rather than a step toward code execution once the object injection is closed.
Conclusion
This case shows how PHP object injection turns into remote code execution when three ingredients line up: a place to store an attacker-controlled serialized object, code that later unserializes it, and a gadget chain in loaded classes. GiveWP’s “safe” unserialize helper is the linchpin, because it gives site owners a false sense of protection while quietly passing the malicious object straight back to storage.
The root causes are common: trusting a serialization sanitizer that does not actually strip objects, unserializing data read back from the database as if it were trusted, and shipping development-only libraries into production where they provide ready-made gadget chains. The last ingredient, an unauthenticated registration path that ignores the site’s own registration policy, is worth calling out separately because it is the one part of this report that the current release does not address.
Timeline
🤝 You can help us make the Internet a safer place
Streamline your disclosure process to fix vulnerabilities faster and comply with CRA.
Get started for freeProtect your users too! Improve server health and earn added revenue with proactive security.
Patchstack for hostsReport vulnerabilities to our gamified bug bounty program to earn monthly cash rewards.
Learn more
