Reproduced Exploit
[H-02] Canto asD — permissionless `lzCompose` lets anyone steal a bridged composed-message transfer
Chain
Other
Category
untagged
Date
Jan 1970
Source
AuditVault
EVM Playground
Source-level debugger — step opcodes and Solidity in sync
The attack is replayed in an in-browser EVM preloaded with the exact dumped fork state. The execution tree shows every call; step by Solidity line or by opcode across all depths — source, Stack, Memory, Storage, Balances (native / ERC-20 / NFT), Transient storage and Return value stay in sync. Click a tree node, opcode, or source line to jump. No backend, no live RPC.
Source & credit. Reproduction of a public audit finding curated by AuditVault — the original finding: 32130-h-02-dual-transaction-nature-of-composed-message-transfer-al. Standalone Foundry PoC and full write-up: 32130-h-02-dual-transaction-nature-of-composed-message-transfer-al_exp in the
evm-hack-registrymirror.
Canto's ASDRouter receives USDC-style LayerZero OFTs and, via a composed
message, swaps them to $NOTE, mints asD, and forwards the asD to the
user. LayerZero V2 delivers a composed message in two separate transactions
on the destination chain:
- Receive — the LayerZero endpoint transfers the OFT to
ASDRouter. - Compose — an executor permissionlessly calls
ASDRouter.lzCompose(...)to run the swap/mint/forward logic.
Because step 2 is a later, separate, permissionless transaction, the OFT is
already sitting in the router when anyone may call lzCompose. lzCompose has
no msg.sender restriction and never binds the composed payload to
composeFrom (the original sender). An attacker who watches the router for a
freshly delivered OFT can craft their own composed message — setting
_dstReceiver to themselves — and call lzCompose before the honest
executor, redirecting the resulting asD to their own address. The victim's
bridged value is stolen outright.
Root cause#
// contracts/asd/asdRouter.sol (audited commit)
function lzCompose(address _from, bytes32 _guid, bytes calldata _message, address _executor, bytes calldata _extraData) external payable {
// ^ no access control; anyone may call, and the OFT is already in the router.
(, , uint256 amountLD, bytes32 composeFrom, bytes memory composeMsg) = _decodeOFTComposeMsg(_message);
// composeFrom (the real sender) is decoded but NEVER used to authorize the receiver.
OftComposeMessage memory payload = abi.decode(composeMsg, (OftComposeMessage));
...
_sendASD(_guid, payload, amountNote); // sends asD to payload._dstReceiver — attacker-chosen
}
function _sendASD(bytes32 _guid, OftComposeMessage memory _payload, uint _amount) internal {
if (_payload._dstLzEid == cantoLzEID) {
ASDOFT(_payload._cantoAsdAddress).transfer(_payload._dstReceiver, _amount); // L140
} ...
}
_dstReceiver comes from the caller-supplied payload and is trusted verbatim.
PoC — real audited contracts, local deploy#
The PoC deploys the unmodified audited sources — ASDRouter, ASDUSDC, and
the asD LayerZero OFT (ASDOFT, extending @layerzerolabs/lz-evm-oapp-v2
OFT → OFTCore → OApp) — vendored from the audited commit under
src/canto/. Only genuinely external systems are doubled: the LayerZero
endpoint (its cross-chain messaging is irrelevant here — the real lzCompose
handling runs unchanged), the Ambient DEX, the Compound cNOTE market, and the
source-chain USDC OFT token.
Flow reproduced with concrete numbers (AMOUNT = 100e18):
- Tx 1 (receive): LayerZero has delivered
100USDC-OFT to the router. - Tx 2 (compose): the attacker calls
lzComposewith a payload whose_dstReceiver = attacker. The router:- deposits the 100 USDC-OFT into
ASDUSDC→ 100asdUSDC(real), - swaps
asdUSDC→ 100$NOTE(Ambient double), ASDOFT.mint(100)— pulls$NOTE, mints viacNOTE, mints 100asDto the router (real),ASDOFT.transfer(attacker, 100)— the theft (real).
- deposits the 100 USDC-OFT into
Asserted harm: attacker asD balance 0 → 100e18; victim asD balance 0;
router's delivered OFT fully consumed (100e18 → 0). The honest user receives
nothing.
Reproduce#
_shared/run-poc/run_poc.sh 32130-h-02-dual-transaction-nature-of-composed-message-transfer-al_exp -vvvvv
Expected: [PASS] test_attackerFrontRunsComposedMessageAndStealsFunds.
Mitigation#
Restrict lzCompose to trusted/whitelisted executors, or redesign to implement
_lzReceive directly (an OApp) so no separate composed transaction — and thus
no front-runnable window — exists. At minimum, bind the composed payload's
receiver to the authenticated composeFrom.
Sources: AuditVault finding #32130 · Canto @ 1516028 · Code4rena report · fix PR: Plex-Engineer/ASD-V2#4.
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 32130-h-02-dual-transaction-nature-of-composed-message-transfer-al_exp (evm-hack-registry mirror).
- AuditVault finding: 32130-h-02-dual-transaction-nature-of-composed-message-transfer-al.
- Upstream DeFiHackLabs PoC directory: src/test.
Alerts & third-party analyses
- DeFiHackLabs incident explorer: search "[H-02] Canto asD".
- Web3Sec X hacked database: search.
- Rekt leaderboard: search.
- Solodit incident search: search.
These dashboards index community alerts tweets, post-mortems, and independent write-ups. Reach them through the protocol name above to cross-check this reproduction against other analyses.