Limit-book matcher trusts caller-supplied book_start_hint without side validation (cross-escrow drain) #272
Labels
No labels
agent:fix_bugfix
agent:fix_conflicts
agent:fix_security
agent:gap_analysis
agent:implement
agent:implement
agent:implement
agent:open_issues
agent:ready
agent:research
agent:security_audit
agent:verify
architecture
backend
blocker:hybrid
blocker:launch
blocker:limit-orders
blocker:v2
block:log_only
block:security
bug
ci
contracts
correctness
deploy
dev
devops
docs
documentation
duplicate
e2e
enhancement
epic
feature
frontend
functional-completion
gas
good first issue
governance
help wanted
high-risk
hooks
hybrid
indexer
infra
infrastructure
integrators
invalid
launch-blocker
limit-orders
localnet
localterra
low priority
missing-implementation
needs-design
ops
performance
priority
high
priority
medium
product
qa
QA
question
ready
ready
research
scripts
security
security-hardening
smartcontracts
tech-debt
testing
ux
UX
v2
verification
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
code/cl8y-dex-terraclassic#272
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Severity: Critical
Reachability: Permissionless. Any external wallet, through a single CW20
Swapcarrying crafted hybrid params. No router, no privileged role.Affected: pair limit-book matching (
match_bids/match_asks) reached via the public hybrid swap path.Root cause: the caller-supplied
book_start_hintis validated for existence only — not that it, or the orders walked from it, belong to the side being matched.Summary
The hybrid swap path lets the caller pass a
book_start_hintso the matcher can start walking the book near the right place instead of from the head. Problem: the matcher only checks the hinted order id loads. It never checks the order is on the side it's matching, and it never re-checks side as it walks thenextchain.The per-side escrow pools (one backing bids, one backing asks) get debited based on which matcher ran, not on the actual side of the orders consumed. So a wrong-side hint makes one side's match settle against the other side's escrow pool. That breaks per-side escrow accounting and lets a caller pull value out of a pool that isn't backing what they matched — i.e. drain escrow and leave the pair short of what it owes its makers.
This is a fund-loss / insolvency bug. It's the top launch blocker.
Current codebase
book_start_hintis public on the swap message — it rides inside the hybrid params ofCw20HookMsg::Swapand is not gated to the router. Any sender sets it.execute_swapforwards the hint verbatim into the matcher. The matcher branch (bid vs ask) is chosen by which token was sent in, not by the hint.nextpointer and settles against the side escrow without asserting the walked order's side matches the leg.Reproduction
We're pre-deploy, so here's the whole thing.
Exact lines:
orderbook.rs:1348-1356—match_bidsacceptsbook_start_hintonORDERS.may_load(h).is_some()— existence only, no side check. Same shape inmatch_asks.orderbook.rs:1367-1368— the walk loads the order atcurand followsorder.next; it never checksorder.sideequals the side being matched.contract.rs:881— the matcher branch is chosen by which token the taker sends (offer == token_a→match_bids, elsematch_asks), not by the hint.orderbook.rs:1476—match_bidsdebits the BID escrow pool (escrow_sub_pending_token1);match_asksdebits the ASK pool (token0). The debit follows the matcher, not the actual side of the order consumed.Consequence:
match_bidswill pay the taker token1 out of the bid escrow pool for whatever order it walks — including an ASK order, whose collateral lives in the token0 pool and never funded the bid pool.Drain (self-dealing):
Swapof token_a (token0) with hybrid params:book_input > 0,max_maker_fills >= 1,book_start_hint = A.match_bidsruns. Accepts hint A (exists), walks A as if a bid, fills at A's price, pays the taker token1 from the BID pool, debitsPENDING_ESCROW_TOKEN1, marks A consumed.Symmetric: send token_b (token1) to run
match_asksagainst a wrong-side BID hint and drain the token0 pool instead.Invariant broken:
PENDING_ESCROW_TOKEN0should cover open asks,PENDING_ESCROW_TOKEN1open bids. After a wrong-side match the debited pool no longer reconciles to its open orders.Why this matters
Per-side escrow solvency is the core invariant of the book — each side's pool must always cover the open orders on that side. If a caller can settle a match against the wrong pool, the pair's escrow no longer covers its outstanding orders: makers can't be paid, and a motivated caller can walk away with the mismatch. Constant-product reserves and book escrow share the same contract, so this is real money, not just a counter being wrong.
Recommended direction
book_start_hint, require itssideequals the side being matched; if it doesn't (or doesn't exist), ignore it and start from the correct head. Honest callers never notice.sidematches the active matcher before consuming/settling it; skip or stop on mismatch.Acceptance criteria
Swapwhose hybrid hint points at an order on the opposite side cannot consume that order or touch the opposite escrow pool.Test plan (functional)
Test plan (attack / abuse)
Verification
Per-side escrow invariant holds under fuzzing, the self-dealing drain reverts/no-ops, and the attack cases above are locked by named regression tests in the pair suite. Localnet check: fund both escrow pools via honest orders, run the wrong-side-hint swap, assert the opposite pool was NOT debited and
sum(open bids) == bid-pool balanceafterward.@PlasticDigits this is the #1 pre-mainnet blocker. Full repro is in here now since nothing's deployed. Nothing goes live with money in a pair until the hint is side-validated.
mentioned in issue #290
changed the description
Turned the repro into regression tests and confirmed it's live.
Added two tests on the match path: a bid-side match pointed at an attacker's own ask, and the symmetric ask-side case. On current code both fail — the bid matcher walks and consumes the wrong-side ask and debits the token1 (bid) escrow pool (ask side symmetric on token0). That's the cross-escrow drain, reproduced, not theoretical.
Fix is small — side-validate
book_start_hintat the four resolution sites (match_bids,match_asks,simulate_match_bids,simulate_match_asks) and fall back to the head on a mismatch. Bid sites:Ask sites the same with
LimitOrderSide::Ask/HEAD_ASK. With that in, both new tests pass and the full pair suite stays green (39/39). The existing insert-path wrong-side-hint test already passed — only the match path was open.Please include an MR demonstrating the exploit, so we can add automated tests to verify the fix and no regressions.
mentioned in commit
dd70ea3283Fix landed on
main(dd70ea3)Summary: Hybrid match walks now treat
book_start_hintlike insert hints: the hinted order must exist on the same side as the active matcher (match_bids/simulate_match_bids→ bid;match_asks/simulate_match_asks→ ask). Wrong-side, stale, or missing hints fall back to the correct book head with no error. During the walk, any order whosesidedoes not match the matcher is skipped (no fill, no cross-poolPENDING_ESCROW_*debit).Code:
resolve_match_start_hint+order_on_match_sideinsmartcontracts/contracts/pair/src/orderbook.rs(execute + simulate paths).Invariant: L17 in
docs/contracts-security-audit.md(cross-linked fromdocs/limit-orders.md).Agent playbook:
skills/AGENTS_BOOK_MATCH_HINT_SECURITY.md(also linked fromskills/AGENTS_HYBRID_QUOTING.md).Verification checklist
cd smartcontracts && cargo test -p cl8y-dex-pair book_start_hint_side_testscd smartcontracts && cargo test -p cl8y-dex-pair prop_match_bids_adversarial_wrong_side_hintcd smartcontracts && cargo test -p cl8y-dex-tests hybrid_wrong_side_book_start_hintcd smartcontracts && cargo test -p cl8y-dex-tests hybrid_same_side_book_start_hint_still_matchescd smartcontracts && cargo test -p cl8y-dex-tests match_invalid_book_start_hint_falls_back_to_headdocs/contracts-security-audit.mdmatches implementationTests added
orderbook::book_start_hint_side_tests::*prop_match_bids_adversarial_wrong_side_hint_preserves_escrowhybrid_wrong_side_book_start_hint_no_cross_escrow_drain,hybrid_wrong_side_book_start_hint_match_asks_symmetric,hybrid_same_side_book_start_hint_still_matchesFollow-up
Request: Please run the checklist above on
mainand confirm the self-dealing drain repro from this issue no longer extracts from the opposite escrow pool.Issue remains open until QA sign-off.
mentioned in issue #292
Verified the
dd70ea3fix onmain(d167c45) — source + tests + a live wrong-side-hint repro on the new SDK53 localnet. The cross-escrow drain is closed: a wrong-side hint is rejected, the walk falls back to the correct head, and the opposite escrow pool is never touched.Source / tests (cw-multitest, chain-independent) —
cargo testwhole workspace 412/0, every named test green:orderbook::book_start_hint_side_tests::{match_bids_wrong_side_hint_falls_back_to_bid_head, match_asks_wrong_side_hint_falls_back_to_ask_head, simulate_match_bids_wrong_side_hint_matches_execute_start}orderbook::proptest_limits::prop_match_bids_adversarial_wrong_side_hint_preserves_escrowlimit_order_tests::{hybrid_wrong_side_book_start_hint_no_cross_escrow_drain, _match_asks_symmetric, hybrid_same_side_book_start_hint_still_matches, match_invalid_book_start_hint_falls_back_to_head}resolve_match_start_hintreturns the hint only whenorder.side == expected_side, else falls back toHEAD_BID/HEAD_ASK;order_on_match_sideskip on all four walk sites (execute + simulate). L17 row matches the code. The integration tests assert the right thing (opposite order untouched + correct head consumed + taker gain bounded), so they flip on the pre-fix existence-only check.Reviewed the fix from completeness / escrow-accounting / list-walk / test-vacuousness / bypass angles — nothing real. bids/asks are separate DLLs so the per-step skip is defense-in-depth (correct to keep); recommendation #3 (debit-follows-consumed-side) is moot since the skip guarantees the matcher only consumes same-side orders.
Live on terrad v4 localnet (deployed
d167c45, pair terra146ypn…c9mjav): honest bid + attacker ask, attacker sends a token0 hybrid swap withbook_start_hint = <own ask id>→ match_bids with a wrong-side hint. Result: ask untouched (49550→49550, token0 pool not debited), matcher fell back to the bid head and filled it (99100→78100), attacker got one legit fill (+20811 token1) not a pool drain. swap tx163DC0A7…code 0.Two optional non-gating hardening ideas: give
clean_limit_book's loop the same per-node side guard for parity; tightensimulate_match_bids_wrong_side_hint_matches_execute_start(passes but is a tautology). Neither blocks.Good to close from my side. @PlasticDigits
mentioned in issue #289
QA sign-off — GitLab #272 (limit-book
book_start_hintcross-escrow drain)Verified on
main@9f0babe(includes fixdd70ea3). No repository changes; closing after checklist pass.Results
cargo test -p cl8y-dex-pair book_start_hint_side_testsmatch_bids_wrong_side_hint_falls_back_to_bid_head,match_asks_wrong_side_hint_falls_back_to_ask_head,simulate_match_bids_wrong_side_hint_matches_execute_startcargo test -p cl8y-dex-pair prop_match_bids_adversarial_wrong_side_hintprop_match_bids_adversarial_wrong_side_hint_preserves_escrowcargo test -p cl8y-dex-tests hybrid_wrong_side_book_start_hinthybrid_wrong_side_book_start_hint_no_cross_escrow_drain,_match_asks_symmetriccargo test -p cl8y-dex-tests hybrid_same_side_book_start_hint_still_matchescargo test -p cl8y-dex-tests match_invalid_book_start_hint_falls_back_to_headresolve_match_start_hint+order_on_match_sideinorderbook.rsmatchdocs/contracts-security-audit.mdL17 anddocs/limit-orders.md; playbookskills/AGENTS_BOOK_MATCH_HINT_SECURITY.mdcross-linked fromskills/AGENTS_HYBRID_QUOTING.mdbook_start_hint=2→ ask unchanged (49550000→49550000), bid head consumed (99100000→79100000), legit token1 gain (+20000000). Swap txA7BBE348BDCFC0CFA755F1AFB1409609673945376EDEF95199A5D9D1A12A02D9Acceptance criteria (issue body)
Aligns with @Brouie verification on
d167c45; re-confirmed on currentmain.Note
make deploy-localfailed at first pair create (missing--amountforpair_creation_fee_uluna); minimal pair creation with100000000ulunawas used for the localnet repro only — unrelated to #272 fix behavior.mentioned in issue #318
mentioned in commit
4c4c26846bmentioned in merge request !795
mentioned in issue #332
mentioned in merge request !816
mentioned in issue #376
mentioned in issue #424
mentioned in issue #707
mentioned in issue #708