Scenario
On a random morning, Caldrin Vowmark leaves his chapel desk to escape royal seals, buy bread, and enjoy a quiet walk through the market. But the quay is already restless. Sailors argue over claim-marks, merchants blame bad luck, and a smiling broker named Verrin Goldhand moves through the crowd, calming people with soft words about patience and fortune.
Caldrin follows the noise to a small dockside Sharehouse, where sailors leave coin, salt, and trade goods before long voyages. In return, they receive claim-marks they can exchange when they return. Lately, a few visitors have arrived with modest bundles and come back soon after to collect far more than expected. The keepers call it luck from the sea, but Caldrin notices the same pale wax on every suspicious claim. Before he leaves, he finds one of those pale-waxed marks has been sent onward to a larger Sharehouse in the old quarter.
Challenge Overview
The challenge spins up a private chain with six deployed contracts:
Win condition (Setup.isSolved()):
The sharehouse starts holding 1_000_000e6 real CROWN. We need to drain more than 850_000e6 of it out to a wallet we control.
Code Review
DocksideSharehouse — the vault
This is a textbook ERC-4626-style vault: totalClaimMarks = shares, recordedHoldings = "assets under management" used to price both deposits and withdrawals.
Key observation: recordedHoldings is not the real crownCoin.balanceOf(sharehouse). It is a cached number that only changes via:
leaveGoods/redeemClaim(which move it in lock-step with real transfers — fine), orrecountHoldings, which blindly accepts whateverstampDesk.readStampedOrder(...)returns and overwritesrecordedHoldingswith it — no sanity bound, no minimum/maximum, no cooldown.
If we can make readStampedOrder return an arbitrary/inflated number, we can set recordedHoldings to something wildly higher than the vault's real backing, and then redeem shares at that inflated price — a classic "share price / oracle manipulation" vault drain.
Also note leaveGoods has:
This blocks deposits during a flash loan — but redeemClaim and recountHoldings have no such guard. That asymmetry is exactly what makes the attack ordering work (deposit before the loan, manipulate + redeem during the loan).
PublicStampDesk — the "oracle relay"
This looks safe at first glance — only a keeper-approved (target, calldata) pair can be replayed, and it's a staticcall so it can't mutate state directly. But the approval is keyed on the static bytes of the call, not on the result of the call. If the target function is a view function whose return value depends on mutable external state (like an AMM reserve), then the approved "reading" is a live window into that state — replayable forever, returning whatever the state currently is.
Setup — what got approved
So the one approved "reading" is: call quayMarket.valueCargoAsOneGood(1_000_000e6, 0).
DocksideMarket — the manipulable oracle source
Since SHAREHOUSE_CARGO_POSITION == totalCargoMarks (both 1_000_000e6), this simplifies to:
The approved oracle call literally just returns the market's live CROWN reserve, verbatim. And crownReserve moves freely with every trade() call — a fee-less constant-product swap that anyone can call, with no TWAP, no cooldown, no manipulation resistance whatsoever.
Chain the four contracts together and the vulnerability is:
sharehouse.recordedHoldingscan be set, on demand, to the current, single-block-manipulable CROWN reserve of an unrelated AMM — a number completely decoupled from the vault's real token balance.
GoldhandCredit — the amplifier
A free, uncollateralized single-transaction flash loan of up to 90_000_000e6 CROWN (90x the AMM's own reserve). This gives us all the capital needed to push crownReserve to an arbitrary height for one transaction.
Putting the Attack Together
Reasoning, step by step:
-
Get some shares at a fair price.
takeTravelPurse()is a one-time faucet that mints us10_000e6CROWN and credits us the same amount oftravelPurseCredit, which is the only thing that lets us callleaveGoods(its "goods" must come from the purse). Deposit the full purse vialeaveGoods(10_000e6)whilerecordedHoldingsis still honest (1_000_000e6, matching the real balance). This mints us claim marks proportional to the real backing — no exploit yet, just entry ticket.At this point (computed exactly, see §4):
- our shares =
9_900e18(~0.99% of the vault) - real sharehouse CROWN balance =
1_010_000e6 recordedHoldings=1_010_000e6(still honest, matches real balance)
- our shares =
-
Flash-loan the entire GoldhandCredit liquidity (
90_000_000e6CROWN) viaborrowForOneCall. -
Inside the
onQuayLoancallback, dump the entire loan intoDocksideMarket(trade(0, 1, amount, 0), CROWN→SALT). This is a one-sided swap that pullsamountCROWN into the pool, socrownReservejumps from1_000_000e6to1_000_000e6 + amount(≈91_000_000e6). -
Call
sharehouse.recountHoldings(setup.buildPublicRecountOrder()). This replays the already-approved stamp (no new approval needed —approvedReadingpersists forever) and reads the now-inflatedcrownReserve, overwritingsharehouse.recordedHoldingswith ≈91_000_000e6— while the vault's actual CROWN balance is still only1_010_000e6. -
Call
redeemClaim(ourShares). Payout is computed asshares * recordedHoldings / totalClaimMarks, using the poisoned, inflatedrecordedHoldings. Our tiny 0.99% share now redeems for the vast majority of the vault's real balance.This is paid out of the vault's real balance (
1_010_000e6), which comfortably covers it, and leaves the vault with only~109_009e6CROWN — below the 150_000e6 solve threshold. -
Reverse the swap — sell all the SALT we received back into the market (
trade(1, 0, saltOut, 0)). Since the AMM has zero fees, a full round-trip swap of the exact same notional returns (almost) exactly the original CROWN amount, restoringcrownReserveback to1_000_000e6and giving us back the capital to repay the loan. -
Repay the flash loan (
crownCoin.transfer(credit, amount)), satisfyingDEBT_NOT_RETURNED's>=check. -
Sweep whatever CROWN is left in our contract (the ~
900_990e6extracted from the vault, our profit) back to our own wallet.
Net effect: the sharehouse's real CROWN balance dropped from 1_010_000e6 to ~109_000e6 — well under 150_000e6 — flipping isSolved() to true, while we never risked any capital beyond the one-time 10_000e6 faucet (and that came back to us too, embedded in the payout).
Exact Numbers
Results:
| Value | Amount |
|---|---|
| shares minted | 9_900e18 |
| totalClaimMarks after deposit | 999_900e18 |
| real sharehouse balance after deposit | 1_010_000e6 |
| flash-loan size (X) | 90_000_000e6 |
manipulated recordedHoldings | 91_000_000e6 |
| redeem payout | 900_990.099009e6 |
| sharehouse balance after redeem | 109_009.900991e6 |
| SOLVE_THRESHOLD | 150_000e6 |
| Solved? | True (109,009 < 150,000) |
A round-trip check also confirmed the fee-less AMM returns (within integer-truncation dust) the exact CROWN amount borrowed, so the loan repayment always has ample surplus.
Exploit Contract
Execution
Transaction succeeded (status: 1), and isSolved() returned true. Grabbed the flag from the challenge's netcat menu (option 3):
Root Cause Summary
- Stale/spoofable oracle:
PublicStampDeskapproves a call by its static bytes, not its result, and the approved call happens to be aviewfunction whose return value tracks a freely-manipulable AMM reserve. Once approved, it's replayable forever with whatever live (manipulated) state exists at call time. - Decoupled accounting:
DocksideSharehouse.recordedHoldingsis a cached value used to price shares, independent of the vault's actual token balance, and can be overwritten wholesale by anyone viarecountHoldingswith no sanity checks, cooldowns, or bounds. - Asymmetric reentrancy guard:
leaveGoodsblocks deposits during an active flash loan, butredeemClaimandrecountHoldingshave no such guard — enabling the "deposit cheap, manipulate, redeem rich" sequence entirely within one attacker-controlled transaction. - Free capital:
GoldhandCredit's uncollateralized, single-call flash loan supplies far more capital than the AMM's own reserves, making the price manipulation trivially large relative to the vault's real backing.