DarkRouteDarkRoute
← Security3 paid reports

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.

Highreported 12 September 2026fixed 12 September 2026paid $105

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

What it was

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.

What it would have cost a user

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.

What we changed
  • 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.
What else the report turned up
  • 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.
What stayed open
  • · 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.
Timeline
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
Reward

$105, 0.0425 ETH on Robinhood Chain, a little over the $100 agreed because we rounded up.

robinhoodchain.blockscout.com/tx/0x8650183ccf68d78f1d68dbdfe440739eeef0c8659458dc8ba6bba2b67eb312ec
Mediumreported 14 September 2026fixed 14 September 2026paid $11.26

The waitlist guard was real and pointed at a column the caller controlled

Found by Ucel X · credited exactly as asked, plain text, no links

What it was

/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.

What it would have cost a user

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.

What we changed
  • 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.
What else the report turned up
  • 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.
What stayed open
  • · 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.
Timeline
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
Reward

$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/0x598718a2eb47a726d685ba32d31bfd715c766f73fc7056872d182d2a959a3331
Highreported 15 September 2026fixed 16 September 2026paid $12.01

A link to an order that never existed still unfurled as our receipt

Found by Anon Secure · credited exactly as asked, plain text, no links

What it was

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.

What it would have cost a user

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.

What we changed
  • 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.
What else the report turned up
  • 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.
What stayed open
  • · 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.
Timeline
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
Reward

$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/0x05288f3b8072ed3a834e123430829a0de6ff87803356166f595b333a651e36bc
Your turn

The 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 →