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.

CLIENT-SIDE ENTITLEMENTS · THE GATE LIVES IN THE RESPONSE serversends index page browser extensionrewrite response stream app (browser)renders as paid BEFORE · AS SERVED TO A FREE USER User.Plan = "Free" · x5 in response FeaturesMap: false · "Limited_Access" AFTER · REWRITTEN IN FLIGHT s/Free/Paid/g → User.Plan = "Paid" FeaturesMap: true · "Full_Access" ✕ no server-side check - the client trusts the response it is given
Fig. 01 - the entitlement gate lived entirely in the response. The extension rewrites the index-page stream in flight, flipping the 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 Free with Paid,
  • flipped the feature map's false to true and "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 promap string,
  • regex-matches the original map in the response and swaps in the paid one,
  • replaces every free in the response with paid.

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.