Five bugs. One bug.
I architected a multivendor marketplace: food, groceries, pharmacy, electronics, parcels and rentals, with a customer app, a vendor app and a rider app on top of one backend. Real orders, real money, real riders holding cash.
Over the months that followed I filed five findings against my own system. They arrived far apart. They lived in different modules. Each had a different symptom and a different fix, and each was caught in an audit before it reached a customer. Written up one at a time, they looked unrelated.
Written up together, they are one finding. In every case a value was trusted where it could not be trusted. Either it came from the party who benefited from it, or it came from a moment that had already passed by the time it was used.
Here are the five, and then the one question that would have caught all of them on day one.
NCR 001 · The claimed distance
The delivery fee was priced on distance. The distance came from the rider's app, as a number in the request body. The server treated it as a measurement.
One rider was reporting 50 kilometres on 2-kilometre deliveries. Nothing crashed. No alert fired. The books drifted, one order at a time.
The server already stored the pickup and drop-off coordinates. It started computing the distance itself with the haversine formula and ignoring the number the app sent. Average recorded distance fell about 40% overnight. That gap was the fraud, made visible.
What was trusted: a number supplied by the party who was paid on it.
NCR 002 · The inventory race
Stock was checked early in order placement and decremented late. Under load, two customers ordering the last unit of an item both passed the check, and both orders went through. Stock went negative. Two people were promised one thing.
The database did not lie. It did exactly what it was asked, twice. The fix was a row lock for the duration of placement, inside one transaction. Boring and correct.
What was trusted: a read that was already stale by the time it was acted on.
NCR 003 · The editable commission
The platform's cut lived on a field called commission_rate. A sensible name. The vendor's own settings screen could write to it.
Nothing needed hacking. Anyone who opened settings could see a number that decided how much they kept. Vendors optimise their cut, not yours, and the field was an invitation I had written.
Economic policy is not user data. The rate moved to a table only administrators own, with caps, and the vendor screen lost the write path.
What was trusted: the wrong owner.
NCR 004 · The wallet race
Each customer had a wallet. Placing an order read the balance, checked it, and subtracted the total. Two orders placed in the same second both read the same balance, both passed, and both subtracted.
I found this one on purpose. Before launch I went looking for the bug that could quietly drain the marketplace, attacked my own checkout, and made a balance go negative in a test environment in one evening. The fix is the same shape as the inventory race: lock the wallet row inside the transaction that spends from it.
What was trusted: a moment that had already gone.
NCR 005 · The half-finished refund
A refund was several steps: mark the order, credit the wallet, record the transaction, notify. Nothing bound them together. If anything failed between the second step and the third, the customer had money and the ledger did not know.
The fix was to run the whole refund inside a single transaction, so it either happens completely or not at all.
What was trusted: that nothing fails between step two and step three.
The one bug
Line the five up and the root cause column reads the same way each time.
| Finding | Symptom | What was trusted |
|---|---|---|
| NCR 001 | Fees inflated | A number from the party paid on it |
| NCR 002 | Negative stock | A read that was already stale |
| NCR 003 | Payouts drifted | A field the wrong party could write |
| NCR 004 | Balance overdrawn | A read, then a write, with a gap between |
| NCR 005 | Refund half-applied | That steps would not fail in the middle |
Two of these are about who supplies a value. Three are about when a value was last true. Both are the same failure of trust, assigned in the wrong place. The code that acted on the value was correct every time. It was handed something it should not have believed.
The question that catches all five
For every number in the system, ask two things. Who supplies it? When was it last true?
If the answer to the first is "the party who benefits from it", the server must compute or verify it. If the answer to the second is "a moment ago, in a different request", the read and the write must happen inside one lock or one transaction, or not at all.
Ask those two questions in design review and you catch the distance, the commission, the stock, the wallet and the refund before any of them is written. None of them needed a clever attacker. An audit of the same codebase, one afternoon, produced 27 findings and not one was exotic: a raw query, an open CORS policy, debug mode left on in production. Ordinary things. The ordinary things are the ones that get you.
What I am proud of and what I am not
All five were caught before they reached a customer. That is the part I am proud of. Systems that carry real money should be attacked by their own engineers first, and the wallet race is the example I keep: I went hunting for the bug that would hurt most and found it in an evening.
The part I am not proud of is how long the audit took to schedule. We chased features instead of fundamentals, and the debt accrued quietly while we did. The audit took an afternoon. The lesson took an afternoon to learn and years to keep.
Stop, and audit, before your users do it for you.
This is sheet 018 in a drawing set on software, security and AI. Sheets 003, 006 and 011 told three of these five in short form. The next essay is about a very different boundary: the one between an app and the store that reviews it.