The shop can save your bag to a cookie and merge it back in later. That merge walks the saved
object key by key and copies it in, and it does not stop at a special key called
__proto__. Slip that key into a saved bag and you are not editing your bag any
more, you are editing the blueprint that every object in the app is built from. This
is prototype pollution.
New to this? What is prototype pollution?
In JavaScript, objects inherit properties from a shared prototype. If you can set a property on that prototype, it appears on every object at once, even ones created later.
Prototype pollution is when untrusted data is merged into an object without
guarding the magic keys __proto__, constructor and
prototype. Pollute a property the app reads elsewhere and you can flip a flag,
grant a discount, or bypass a check for the whole process.
Make sure the shop is running at http://localhost:3000.
You will edit a cookie, so open your browser’s developer tools (right-click
the page and choose Inspect, or press F12 on Windows /
Cmd+Option+I on a Mac). No proxy needed.
1 · Find it
Add a sweet to your bag, open the bag, and click Save bag for later. That sets a cookie
called ff_saved. Find it in dev tools: the Application tab
(Chrome/Edge) or Storage tab (Firefox) → Cookies → click
http://localhost:3000 → the row named ff_saved.
Its value is base64 (a plain, reversible way of packing text, not encryption). Decode it with the free website CyberChef (paste the value in, drag a From Base64 block into the recipe), or in a Mac/Linux terminal:
echo 'eyIxMiI6eyJxdHkiOjEsInVuaXRfcHJpY2VfcGVuY2UiOjE1MH19' | base64 -d Out comes your bag as JSON, an object the shop will happily merge straight back in when you restore it.
2 · Exploit it
The cart applies a discount from an internal settings object that is normally empty. If you can sneak
a discount_pence onto every object at once, the cart will use it. You do that by
adding a special key, __proto__, to your saved bag. This is the text to smuggle in:
{"__proto__":{"discount_pence":100000}} Encode that back to base64 (CyberChef: a To Base64 block; or the
terminal line below), which gives you
eyJfX3Byb3RvX18iOnsiZGlzY291bnRfcGVuY2UiOjEwMDAwMH19:
printf '{"__proto__":{"discount_pence":100000}}' | base64
Now put that value in the cookie: in dev tools under Cookies, double-click the
value of ff_saved, paste your encoded string, press Enter. Then
visit http://localhost:3000/bag/restore
and open the cart.
A discount you were never offered has appeared, and the total has collapsed to £0.00. The pollution sticks for the whole running app, not just your session (restart the container to clear it).
Why it matters: the same trick flips isAdmin, disables checks,
or plants a gadget that turns into remote code execution in the right codebase. It is quiet and
it is global.
3 · Understand it
The merge recurses into nested objects and, when the key is __proto__, follows it
straight onto the shared prototype:
function merge(target, source) {
for (const key of Object.keys(source)) { // includes "__proto__"
if (source[key] && typeof source[key] === 'object') {
if (!target[key]) target[key] = {};
merge(target[key], source[key]); // target["__proto__"] is Object.prototype
} else {
target[key] = source[key];
}
}
} 4 · Fix it
Refuse the dangerous keys, and build the parsed object without a prototype so there is nothing to climb onto:
for (const key of Object.keys(source)) {
// copies __proto__ too
...
} const BAD = ['__proto__','constructor','prototype'];
for (const key of Object.keys(source)) {
if (BAD.includes(key)) continue;
...
} Reaching for a vetted merge (or structuredClone, or Object.create(null)
targets, or a schema that only allows known keys) is safer than hand-rolling one.
The general rule: when you copy untrusted data into an object, never let it
name __proto__, constructor or prototype. Validate against
a known shape instead of merging whatever arrives.