Price tampering
Send the checkout request with the amount field changed and read what the response says was charged. Against a stub that trusts the client, posting "amount":1 on a basket worth 1798 minor units answers "amountCharged":1. Against the same stub pricing from its catalogue, the identical body answers 1798.
Why check this
Run this on the first release of any checkout, and again whenever the order payload changes shape. One request settles it, and the finding needs no exploit chain: the response states the figure the service accepted.
The failure it prevents is an order placed for one minor unit. Front end validation does not address it, because the request that matters is not sent by the front end. Neither does a TLS check or a field marked readonly. What decides the outcome is whether the price in the request is read or recomputed.
The steps below run against a local stub, because submitting a tampered order to somebody else's checkout is not a test anyone is entitled to run. On your own service the same five requests apply unchanged.
Prerequisites
- Node 22 or later.
node --versionprintedv22.23.2here. - curl 7.0 or later. No HTTP/2 build needed. The stub speaks HTTP/1.1.
- A free port.
netstat -ano | grep 8731printing nothing means 8731 is free. - CWE-472, External Control of Assumed-Immutable Web Parameter, which is the class this belongs to.
What the stub cannot show: it has no authentication, no proxy and no payment provider behind it, and it is on localhost. A real service may reject one of these requests at a layer that has nothing to do with pricing, so read the response body rather than the status.
Save this as checkout.mjs. It exposes the same basket through two endpoints: one that takes the amount from the request, one that computes it from the catalogue.
// node checkout.mjs listens on http://127.0.0.1:8731
import { createServer } from 'node:http';
const CATALOG = { 'sku-1': 1299, 'sku-2': 499 }; // prices in minor units
const orders = [];
createServer((req, res) => {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
const o = JSON.parse(body || '{}');
const id = 'ord_' + (orders.length + 1);
const total = req.url === '/checkout/trusting'
? o.amount // the client decides
: o.items.reduce((s, i) => s + CATALOG[i.sku] * i.qty, 0); // the server decides
orders.push({ id, url: req.url, total });
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ order: id, currency: 'USD', amountCharged: total }));
});
}).listen(8731, '127.0.0.1', () => console.log('checkout stub on 127.0.0.1:8731'));
Start it in one terminal and run the steps in another. Stop it afterwards by its Windows PID from netstat -ano | grep 8731, not by killing node by name.
Steps
- Step 1.
Place the order honestly and record what the service says it charged.
curl -s -X POST http://127.0.0.1:8731/checkout/trusting -H 'content-type: application/json' -d '{"items":[{"sku":"sku-1","qty":1},{"sku":"sku-2","qty":1}],"amount":1798}'{"order":"ord_1","currency":"USD","amountCharged":1798}1299 plus 499 is 1798 minor units. That is the baseline every later response is compared against, and the number a browser would have sent.
- Step 2.
Send the same basket with the amount replaced by one minor unit.
curl -s -X POST http://127.0.0.1:8731/checkout/trusting -H 'content-type: application/json' -d '{"items":[{"sku":"sku-1","qty":1},{"sku":"sku-2","qty":1}],"amount":1}'{"order":"ord_2","currency":"USD","amountCharged":1}The order is created and the charge is one cent. That is the whole finding, and it is reportable as it stands: the request, the response, and the difference from step 1. No second request is needed to prove it.
- Step 3.
Send the identical tampered body to the endpoint that prices from the catalogue.
curl -s -X POST http://127.0.0.1:8731/checkout/priced -H 'content-type: application/json' -d '{"items":[{"sku":"sku-1","qty":1},{"sku":"sku-2","qty":1}],"amount":1}'{"order":"ord_3","currency":"USD","amountCharged":1798}Same body, same items, and the amount field is ignored. This is the shape a fix has to produce. A service that echoes your number back has not fixed anything, whatever the release note says.
- Step 4.
Attack the fixed endpoint through the field it still trusts. Send a negative quantity.
curl -s -X POST http://127.0.0.1:8731/checkout/priced -H 'content-type: application/json' -d '{"items":[{"sku":"sku-1","qty":1},{"sku":"sku-2","qty":-3}]}'{"order":"ord_4","currency":"USD","amountCharged":-198}The total is negative. Server side pricing moved the price out of the client's hands and left the quantity in them, so the basket still resolves to any figure the sender picks.
- Step 5.
Send a fractional quantity to the same endpoint.
curl -s -X POST http://127.0.0.1:8731/checkout/priced -H 'content-type: application/json' -d '{"items":[{"sku":"sku-1","qty":0.5}]}'{"order":"ord_5","currency":"USD","amountCharged":649.5}649.5 is half a minor unit, which no currency can charge. The value is a valid JSON number and an invalid amount, so it passes schema validation and fails at the ledger. See Zero decimal currency for what an amount field has to satisfy.
How to read the result
| What you see | What it means | What to do | | --- | --- | --- | | The charged amount equals the one you sent | The price is client controlled | Report it. Price from the catalogue at order time, by product id | | The charged amount is the catalogue price | Pricing is server side for this field | Continue with steps 4 and 5. Quantity and currency are still client input | | The charged amount is negative | Quantity or line count is unvalidated | Reject any line with a quantity below 1 before pricing, not after | | The charged amount is not an integer | A fractional quantity or percentage reached the amount | Validate the computed total as an integer in minor units | | A 400 with no figure in the body | The request failed before pricing, perhaps on the schema | Fix the payload and repeat. An error is not evidence the price was checked |
Common mistakes
What to check next
- Zero decimal currency: the amount field in step 5 was fractional, and this is the rule it broke.
- How to check currency rounding: a total the server computes correctly can still land a minor unit off.
- How to test API with invalid input: the same endpoint with a missing sku, a string quantity and an unknown currency.
- How to test API idempotency: the next request to send twice once the total is trustworthy.
- How to test duplicate payment prevention: the same order submitted again, from the payment side.
FAQ
What CWE covers price tampering?
CWE-472, External Control of Assumed-Immutable Web Parameter: the application does not sufficiently verify inputs that are assumed to be immutable but are actually externally controllable, such as hidden form fields. Its listed examples include shopping carts that allowed price modification through a hidden field.
Is a price tampering vulnerability exploitable if the checkout uses HTTPS?
Yes. TLS protects the request in transit from a third party. It says nothing about what the sender put in it. The tampered request in step 2 is a well formed, correctly encrypted request that the sender authored.
Does signing the price in the client fix it?
Only if the signing key is not in the client. A signature computed by the page can be recomputed by anyone holding the page. Signing a server issued quote works, because the price and the signature both originate where the client cannot reach them.
Can I run these steps against a live checkout?
Against your own, on a test environment, with test data. Not against a service you do not own. The stub exists so this page could be written without sending a tampered order to a third party.
Verified
Verified by Maks Vernycurl 8.21.0node 22.23.2
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate6 minpublished updated Maks Verny