What people found,
and what it cost us.
Our policy says every paid report gets published with its amount. This is that page. It carries the finding, the fix, the transaction that paid for it, and the parts that stayed open, because a page of victories would not be worth reading.
The sign-in nonce was never spent, so one captured signature could be replayed into new sessions
Found by JustGH · credited exactly as asked, plain text, no links
Signing in asked a wallet to sign a nonce, then checked that nonce for age and nothing else. It was never marked as used, so the same wallet, nonce and signature could be posted to /api/auth/verify again and again, and each attempt minted a fresh session cookie. The reporter demonstrated three replays in a row, and a fourth after the victim had pressed sign out.
Anyone who obtained one signed sign-in message, by phishing it on a look-alike page or from any leak of the request, could hold a session as that wallet for the length of the replay window. A session reads /api/rewards, which returns that wallet's order history: what was swapped, for how much, and when. On a router that sells privacy, that is the part that matters. No funds path existed: DarkRoute holds no balances and a session cannot move anything.
- The nonce is bound to the wallet it was issued for, so it cannot be carried to another address.
- It carries twelve random bytes, so two issued in the same millisecond stay distinct.
- It is spent on first use and remembered until it expires, so a second attempt is refused even with a valid signature.
- The window is two minutes instead of ten.
- The message a wallet displays now names the domain and its own expiry, so a signature requested by another site reads as one.
- Bad input to /api/order came back as a blank 500 rather than a 400 with a reason: a zero recipient address and an absurd amount both reached the venue and were refused there. Fixed the same day; those now answer 400 with the venue's own words, and a venue outage answers 502.
- The reporter noted that our warning about the single-instance assumption lived only in a code comment, where nobody configuring a second container would ever read it. He was right. It now leads app/RUNBOOK.md, the deploy script and the env example, and writing it turned up a second thing that breaks quietly at instance two: the rate limiter is per process, so the limits printed on our public API page would silently stop being true.
- One item in the report did not reproduce, on either side: /api/waitlist rejecting hidden kinds. The reporter reran it himself and closed it rather than leaving it in.
- · Nothing from this report. Session revocation, which both sides agreed was a separate issue rather than part of the finding, shipped the same night anyway: signing out now ends every session for that wallet instead of clearing one browser, and session lifetime went from seven days to two.
- · Known and stated rather than hidden: spent nonces are held in the app process, which is correct for one container and wrong the day a second one runs. The runbook says so and names the fix.
- 12 Sep, 15:48
- Reported by email to support@, privately, with a working proof of concept
- 12 Sep, same evening
- Reproduced on production with a throwaway wallet before anything was touched
- 12 Sep, same evening
- Fixed, deployed, and the reporter's own retest confirmed it
- 12 Sep, late
- Session revocation shipped, beyond the scope of the report
- 13 Sep, 10:52
- Paid, 0.0425 ETH
- 13 Sep
- Published here
$105, 0.0425 ETH on Robinhood Chain, a little over the $100 agreed because we rounded up.
robinhoodchain.blockscout.com/tx/0x8650183ccf68d78f1d68dbdfe440739eeef0c8659458dc8ba6bba2b67eb312ecThe waitlist guard was real and pointed at a column the caller controlled
Found by Ucel X · credited exactly as asked, plain text, no links
/api/waitlist took writes from anyone, which was intended, and a wallet address straight out of the request body, which was not thought through. A unique index on (kind, email, wallet) was supposed to hold each person to one signup per list. Because the caller chose the wallet value, keeping one email and inventing a new wallet produced a new row every time, as many as wanted. The same gap let anybody put a wallet they did not hold onto a list.
The mirror launch allocates a fixed number of slots and the waitlist is how they are claimed, so one person could have taken the queue, and someone else's wallet could have been entered without them knowing. No funds, no order data, no session: this is a fairness and integrity problem rather than a theft one. The reporter demonstrated it with three rows rather than flooding the table.
- The wallet is read from the signed session and nowhere else. Every wallet on a list now belongs to someone who signed for it.
- Uniqueness was split into two partial indexes, one row per email per kind and one row per wallet per kind, so each identity stands on its own.
- Email-only signups still work without a wallet, because a waitlist that demands a connected wallet is a different product.
- Existing rows were deduplicated oldest-first before the constraints went on, since a queue position should belong to whoever claimed it first. 26 rows became 21.
- Two of the three reported items did not stand, and we said so with the evidence rather than quietly paying for all three. The sign-in message was reported as promising a 60 second expiry that was not enforced, with a signature at +70 seconds accepted. The message has only ever promised 120 seconds, enforcement has only ever been 120 seconds, and both come from one constant whose entire history we checked. A signature accepted at +70 seconds is the message being honoured.
- PATCH /api/order/{id} was reported as mutating orders with no authentication. That is accurate and it is deliberate: there are no accounts here, so the order id is the credential, the same one that already grants read access on that path. The field allowlist holds, showSize is the only writable field, and the only outcome is whether a shareable receipt prints a USD amount. Recorded as accepted risk, with a standing offer: show us a way to obtain order ids you were not given and it becomes Critical.
- The report listed every control the reporter could not break, which made triage fast enough to fix the real one the same day. More reports should do this.
- · An availability note we could not explain. The reporter saw /api/auth/verify reject 62 of 62 correctly signed challenges over five hours while every other endpoint stayed healthy. We could not reproduce it: a full sign-in cycle passes, and our rate limit of 20 per minute per IP does not fit the pattern. We asked for the raw requests and responses. Until we have them this stays here, unresolved, rather than being quietly dropped because it was inconvenient.
- · PATCH /api/order/{id} remains unauthenticated by design, as described above. If that reasoning is wrong, it is wrong in public.
- 13 to 14 Sep
- Found and verified by the reporter against production
- 14 Sep
- Reported by email with evidence, a reproduction script and a purge list
- 14 Sep
- Triaged: one finding validated, two answered with evidence
- 14 Sep
- Fixed and deployed, then verified against production
- 14 Sep, 13:27
- Paid, 0.0045 ETH, rounded up
- 14 Sep
- Published here
$11.26, 0.0045 ETH on Robinhood Chain, the reporter's choice of asset and network, rounded up from the $10 agreed.
robinhoodchain.blockscout.com/tx/0x598718a2eb47a726d685ba32d31bfd715c766f73fc7056872d182d2a959a3331A link to an order that never existed still unfurled as our receipt
Found by Anon Secure · credited exactly as asked, plain text, no links
Three findings, all valid. The one that carried the most was the rewards ledger: it attributed orders by recipient address, so anyone who had ever received the output of a swap could sign in and read the sender's order, and the rows included the order id. On this product the id is the only credential there is, so the counterparty could then call /api/order/{id} and read the deposit address and both of the sender's addresses. The reporter labelled it Low, reading it as a fairness problem. We raised it to High and he agreed the chain we drew, counterparty reads the id then reads the whole order, was the real severity.
A router whose claim is that an order is nobody else's business was telling the person you paid where your money came from, how much, and when. No funds path: we hold none. The receipt card finding was a credibility one rather than a funds one, and the staking finding had no live impact at all because the contract is not deployed.
- Ledger attribution is now the signed-in wallet and the refund address, which belongs to the sender rather than to whoever they paid. The three address columns also stopped being selected: they were pulled on every request and never used.
- An order id that resolves to nothing gets no card. Any string in that position used to render a branded 1200x630 receipt, including strings that were never ids at all, so a link could unfurl as our receipt for an order that never existed. That was the reported path, and our first fix covered only a neighbouring one.
- A card for an order nobody has funded prints no figures either: branding, status, nothing to screenshot. That is the neighbouring variant, closed at the same time.
- DarkStaking now has a minStake, fixed at deployment and respected by both stake and boostActive, so two wei no longer buys the same commitment badge as two million tokens. Tests went from 26 to 31.
- The reporter corrected our first write-up of finding one and we have taken the correction: what he forged were ids that never resolve, not unfunded orders. Both are closed, but the page should name the path that was actually reported, so it does.
- He also raised his own severity after reading our reasoning rather than defending the label he sent. That is rarer than finding the bug.
- · Nothing from this report. The staking change is in source only: those contracts are deliberately not deployed until an external audit, which is why a flaw in them costs a commit rather than an incident.
- · The ledger fix has a cost we accepted rather than hid: a swap made without signing in, to a destination address you control but whose origin refund address you do not, no longer appears in your ledger. Losing a row is better than publishing one.
- 15 Sep
- Reported by email with a PDF proof of concept
- 15 Sep
- Triaged from our own code; all three validated
- 15 Sep
- Ledger and receipt card fixed and deployed
- 16 Sep
- Reporter corrected our description of finding one; the unvalidated id path closed too
- 16 Sep, 04:50
- Paid, 0.005 ETH
- 16 Sep
- Published here
$12.01, 0.005 ETH on Robinhood Chain, the reporter's choice of chain after he showed us why the address was safe there.
robinhoodchain.blockscout.com/tx/0x05288f3b8072ed3a834e123430829a0de6ff87803356166f595b333a651e36bcThe next one gets the same page.
Same rules, same publication, same receipt. Rewards are sized to what a router with almost no revenue can afford, and we would rather tell you that than quote a number we cannot pay.
Scope and channels →