A web-hosting product gated its paid themes and features behind a subscription. The catch: the gate lived entirely in the client. By diffing a free response against a paid one and rewriting the bytes the browser trusted, a free account got the full paid experience, and then I packaged the whole thing as a browser extension.
The target is redacted (the report is still unresolved), so call it redacted.com: a normal services site with free and paid tiers, where the paid features require a subscription.
1. The setup
Think of it as a web-hosting product that lets you change your hosted site's theme. Some themes are free; the paid ones are locked, and clicking one opens a "subscribe" popup. The goal: use the product as a paid user without paying.
2. First indicator: the theme switch
Switching between two free themes fired this request:
PUT /api/v1/change/{accountId}
...
theme=Free-Theme
Free-Theme was just the name of the free theme I had picked. I changed it to the name of a paid theme, and the change went through. That alone says the validation is happening client-side. I reported it and kept pulling the thread.
"When you see a good move, look for a better one."
Emanuel Lasker
3. Reading the client
The JavaScript referenced variables and helpers like IsPaid and Usable(Feature_Name). But they were static, the same bundle for every user, paid or not, so on their own they led nowhere.
Plan string and every feature flag, so a free account renders with full paid access.4. Diffing free vs paid with Burp Comparer
To see the difference at scale, I created a real paid account, captured its index-page response, and diffed it against a free user's with Burp's Comparer. Two things jumped out.
Feature map. A single object listed every feature on the site as a key. For a paid user:
window.FeaturesMap = {"FeatureX": true, "FeatureY": "Full_Access", ...};
For a free user it was full of false and "Limited_Access":
window.FeaturesMap = {"FeatureX": false, "FeatureY": "Limited_Access", ...};
User object. The response also embedded the current user:
User = {"Name": "FreeUser", "Email": "freeuser@mail.org", "Plan": "Free"};
The Plan field showed up more than five times across the free response. For the paid user:
User = {"Name": "PaidUser", "Email": "paiduser@mail.org", "Plan": "Paid"};
5. Exploitation
I intercepted the free user's response and rewrote it:
- replaced every occurrence of
FreewithPaid, - flipped the feature map's
falsetotrueand"Limited_Access"to"Full_Access".
The app rendered as a full paid account. Because the entitlement check lived entirely in response data the client trusts, rewriting the response was the whole exploit.
6. Automating it: a browser extension
Doing this by hand on every request is tedious, so I wrapped it in a small Firefox extension that rewrites the response stream on the fly.
manifest.json
{
"description": "Proof of Concept",
"manifest_version": 2,
"name": "Manipulator",
"version": "1.0",
"icons": { "48": "icong.svg" },
"permissions": ["webRequest", "webRequestBlocking", "https://redacted.com/*"],
"background": { "scripts": ["Manipulator.js"] },
"browser_specific_settings": { "gecko": { "strict_min_version": "57.0a1" } }
}
Manipulator.js
function listener(details) {
let filter = browser.webRequest.filterResponseData(details.requestId);
let decoder = new TextDecoder("utf-8");
let encoder = new TextEncoder();
filter.ondata = event => {
let features = {"FeatureX": true, "FeatureY": "Full_Access"};
let promap = "window.FeaturesMap = " + JSON.stringify(features) + ";";
let str = decoder.decode(event.data, {stream: true});
let freemap = /window\.FeaturesMap\s*=\s*\{.*\};/;
str = str.replace(freemap, promap);
str = str.replace(/free/g, "paid");
filter.write(encoder.encode(str));
filter.disconnect();
};
return {};
}
browser.webRequest.onBeforeRequest.addListener(
listener,
{urls: ["https://redacted.com/*"], types: ["main_frame"]},
["blocking"]
);
What it does:
- builds a feature map with the paid capabilities (extend it to unlock more),
- saves it as the
promapstring, - regex-matches the original map in the response and swaps in the paid one,
- replaces every
freein the response withpaid.
Load it in Firefox and the paywall is gone.
7. Root cause and fix
- The client was trusted to enforce entitlements. Plan, feature flags, and access levels were all decided from response data the user fully controls.
- Fix: enforce entitlements server-side on every privileged action. The client may reflect the plan, but the server must verify it on each request and never treat client-held state as authoritative.
Thanks for reading.