TARA Bridge Light Client Validator Forgery Exploit
An attacker on Ethereum mainnet (block 24,513,601) drained the TARA cross-chain bridge by exploiting a compromised bridge validator key. The vulnerability is an access control failure: the TARA light client contract (0xcdf14446) accepted ECDSA-signed bridge state submissions from any registered validator, and the attacker’s own EOA (0x7bd736631afbe1d3795a94f60574f7fa0ae89347) was a registered validator. By signing a crafted bridge state payload with their own private key, the attacker forged a valid finalized bridge root, which the bridge then used to authorize draining all bridge adapters. The net loss to the bridge was approximately 7.382 ETH (4.336 ETH direct + 5,956.71 USDT converted to WETH + TARA minted and sold into DODO liquidity pools), with unlimited TARA tokens also minted but largely unsellable.
Root Cause
Vulnerable Contract
- Name: DODOCoopPool (TARA Bridge Light Client)
- Proxy address:
0xcdf14446f78ea7ebcaa62fdb0584e4d2e536b999(EIP-1967 proxy,is_proxy: true) - Implementation address:
0x34eae07195d83305a0be4786f2ab8705613c69c0 - Source type: TAC decompiled (
contract.tac) with recovered Solidity (recovered.sol) — no verified source available
This contract implements the TARA bridge’s light client interface. It stores finalized bridge state roots and exposes getFinalizedBridgeRoot(uint256 epoch) (selector 0xaa2bb43d) for the bridge to consume. It also exposes finalizeBlocks(...) (selector 0x5d0d5734) to accept new block finalization proofs from validators.
Vulnerable Function
- Function name:
finalizeBlocks - Selector:
0x5d0d5734 - 4byte signature:
finalizeBlocks(((uint256,bytes32,bytes32,bytes32,uint256),(address,int32)[])[],(bytes32,bytes32)[]) - Contract: DODOCoopPool implementation at
0x34eae07195d83305a0be4786f2ab8705613c69c0
Vulnerable Code
The following is derived from the call trace and TAC analysis. No verified Solidity source exists; the function behavior is reconstructed from the on-chain evidence.
// [recovered — approximation, derived from trace evidence]
// DODOCoopPool implementation (0x34eae07195d83305a0be4786f2ab8705613c69c0)
function finalizeBlocks(
BlockHeader[] calldata blocks,
Signature[] calldata signatures
) external {
// Unpack the block header: epoch, stateRoot, parentHash, bridgeRoot, blockNumber
// Unpack the validator info: validatorAddress, validatorWeight
// Verify ECDSA signature over the block header hash
bytes32 blockHeaderHash = keccak256(abi.encode(block_data));
address recoveredSigner = ecrecover( // <-- VULNERABILITY: no restriction on who can call this
blockHeaderHash,
sig.v,
sig.r,
sig.s
);
// Check signer is the submitted validator address
require(recoveredSigner == validatorAddress); // <-- VULNERABILITY: does not require multi-validator quorum
// Store the bridge root for this epoch — anyone whose address is registered
// in the validator set can finalize any block with any content
finalizedBridgeRoots[epoch] = bridgeRoot; // <-- VULNERABILITY: root written from attacker-controlled input
currentEpoch = epoch;
// (also stores validator weight 0x7fffffff into validator mapping)
}
Key evidence from the trace (ground truth — trace_callTracer.json):
// Call to ecrecover precompile inside finalizeBlocks delegatecall
{
"from": "0xcdf14446f78ea7ebcaa62fdb0584e4d2e536b999",
"to": "0x0000000000000000000000000000000000000001",
"input": "0x55c4d6426ae29a84b42667b370157dc34fa0daa21cc1bb72de4a71ea933c158a
000000000000000000000000000000000000000000000000000000000000001c
7b0abe76bca420caa2dd80d17d1c815de5f9260e7472df0b898cb84eaaba8021
3fd9a471f889a351485a163095ebb9c74eb0a64ebb29f6e811f6689cba9b8c6d",
"output": "0x0000000000000000000000007bd736631afbe1d3795a94f60574f7fa0ae89347",
"type": "STATICCALL"
}
ecrecover returned the attacker’s own EOA (0x7bd736631afbe1d3795a94f60574f7fa0ae89347). This address was accepted as a valid registered validator.
Why It’s Vulnerable
Expected behavior: finalizeBlocks should require signatures from validators representing at least a supermajority (e.g., 2/3) of total registered stake. No single validator should be able to unilaterally finalize a block and write an arbitrary bridge state root.
Actual behavior: finalizeBlocks accepts a proof signed by a single validator and immediately stores the supplied bridge state root. As long as the supplied validatorAddress is registered in the contract and the signature verifies, the submitted bridgeRoot (which is fully attacker-controlled) is stored as authoritative.
Why this matters: The attacker held the private key to 0x7bd736631afbe1d3795a94f60574f7fa0ae89347, which was a registered validator in the light client. The attacker:
- Crafted an arbitrary bridge state that claimed the bridge owed them 4.336 ETH, 5,956.71 USDT, and 1e32 TARA.
- Computed a fake
bridgeRootfor this crafted state. - Signed the corresponding block header hash with their own private key.
- Called
finalizeBlocks(...)— the function accepted the submission since the signature was valid and the signer was registered. - Called
BridgeBase.applyState(StateWithProof)— the bridge read the forged root from the light client and authorized all payouts.
Compounding factor: The TARATMintAdapter (0xff235ea7) is the owner of the TARA ERC-20 token and can mint unlimited amounts. It has no per-call mint cap. A crafted bridge state can instruct it to mint any amount to any address.
Attack Execution
High-Level Flow
- Attacker EOA deploys an outer wrapper contract (
0xddf10e09) which immediately deploys the core exploit contract (0xfc99fa4b) and calls it with the validator signing key parameters. - Exploit contract calls
DODOCoopPool.finalizeBlocks(...)— passing a crafted block header and a signature generated with the attacker’s registered validator private key. - The light client verifies the single-signer ECDSA proof (calls
ecrecover, recovers attacker EOA = registered validator), and stores the forged bridge root for epoch 572. - Exploit contract calls
BridgeBase.applyState(StateWithProof)with the crafted state data. The bridge reads the forged root from the light client, verifies the proof (passes by construction), and dispatchesapplyState(bytes)on all three bridge adapters. - ETHAdapter sends 4.336 ETH to the exploit contract; USDTAdapter transfers 5,956.71 USDT; TARATMintAdapter mints 1e32 TARA tokens.
- Exploit contract swaps all 5,956.71 USDT to WETH (~3.056 WETH) via the DODO swap router through the Uniswap V3 WETH/USDT 0.01% pool.
- Exploit contract dumps 1e23 TARA (a portion) into a TARA/WETH DODO Uniswap V4 pool, receiving 0.086 WETH.
- Exploit contract dumps 1e15 TARA into a DODO DPP Advanced WETH/TARA pool via
sellQuote, receiving 0.219 WETH. - Unwraps accumulated WETH to ETH, verifies minimum profit threshold, tips 0.01 ETH to an MEV relay, then self-destructs forwarding all ETH through the outer contract to the attacker EOA.
Detailed Call Trace
EOA 0x7bd736 -> CREATE 0xddf10e09 [depth 0]
0xddf10e09 -> CREATE 0xfc99fa4b [depth 1]
0xddf10e09 -> CALL 0xfc99fa4b.0x91ac5e30(r, s, v) [depth 1]
// Step 1: Read current bridge state for context
0xfc99fa4b -> STATICCALL 0xcdf14446.finalized() [0xb3f05b97] [depth 2]
0xcdf14446 -> DELEGATECALL 0x34eae07195.finalized() [returns 7-field state struct]
// Step 2: Forge bridge state in light client
0xfc99fa4b -> CALL 0xcdf14446.finalizeBlocks(blocks[], sigs[]) [0x5d0d5734] [depth 2]
0xcdf14446 -> DELEGATECALL 0x34eae07195.finalizeBlocks(...)
0x34eae07195 -> STATICCALL ecrecover(0x000...0001)
input: hash=0x55c4d642...c158a, v=0x1c, r=0x7b0abe76..., s=0x3fd9a471...
output: 0x7bd736631afbe1d3795a94f60574f7fa0ae89347 ← attacker EOA accepted
// Stores forged root 0x7759b67c...98b433 for epoch 0x23c
// Step 3: Apply forged state, drain all adapters
0xfc99fa4b -> CALL 0x359cf536.applyState(StateWithProof) [0x6cd50a67] [depth 2]
0x359cf536 -> DELEGATECALL 0x089f8b42.applyState(...)
// Light client root lookup
0x089f8b42 -> STATICCALL 0xcdf14446.getFinalizedBridgeRoot(0x23c) [0xaa2bb43d]
0xcdf14446 -> DELEGATECALL 0x34eae07195.getFinalizedBridgeRoot(0x23c)
returns: 0x7759b67c...98b433 ← the forged root just stored
// Proof verification passes (crafted state matches forged root)
// Dispatch to adapters:
0x089f8b42 -> CALL 0x2b5ec5c4.applyState(abi.encode(0xfc99fa4b, 4336484040293252284)) [0x9bd15c9e]
0x2b5ec5c4 -> DELEGATECALL 0x87c7aaa5.applyState(...)
CALL 0xfc99fa4b (value: 4,336,484,040,293,252,284 wei = 4.336484 ETH) ← ETH drained
0x089f8b42 -> CALL 0x950bcda6.applyState(abi.encode(0xfc99fa4b, 5956711820)) [0x9bd15c9e]
0x950bcda6 -> DELEGATECALL 0xd897e85c.applyState(...)
CALL 0xdac17f95.transfer(0xfc99fa4b, 5956711820) ← 5956.71 USDT drained
0x089f8b42 -> CALL 0xff235ea7.applyState(abi.encode(0xfc99fa4b, 1e50)) [0x9bd15c9e]
0xff235ea7 -> DELEGATECALL 0xa134a951.applyState(...)
CALL 0x2f42b7d6.mint(0xfc99fa4b, 100000000000000000000000000000000000000000000000000)
← 1e32 TARA minted
// Step 4: Swap USDT -> WETH
0xfc99fa4b -> CALL 0xdac17f95.approve(0x8892d085..., 5956711820) [0x095ea7b3]
0xfc99fa4b -> CALL 0x8892d085.swapV3(USDT, true, 100, ...) [0xafeae12b] [depth 2]
→ Uniswap V3 WETH/USDT pool 0xc7bbec68
0xfc99fa4b receives 3,055,585,815,467,536,538 WETH (3.0556 WETH)
// Step 5: Dump TARA into DODO V4 pool
0xfc99fa4b -> CALL 0x2f42b7d6.transfer(0x5745050e, 1e41) [0xa9059cbb]
0xfc99fa4b -> CALL 0x5745050e.sellBase(0xfc99fa4b, 0, bytes) [0x30e6ae31] [depth 2]
→ Uniswap V4 PoolManager 0x000...4444c5
0xfc99fa4b receives 86,048,754,305,662,772 WETH (0.086049 WETH)
// Step 6: Dump TARA into DODO DPP Advanced
0xfc99fa4b -> CALL 0x2f42b7d6.transfer(0x0ecef178, 1e33) [0xa9059cbb]
0xfc99fa4b -> CALL 0x0ecef178.sellQuote(0xfc99fa4b) [0xdd93f59a] [depth 2]
0xfc99fa4b receives 219,265,664,794,845,497 WETH (0.219266 WETH)
// Step 7: Convert WETH to ETH
0xfc99fa4b -> CALL 0xc02aaa39.withdraw(3055585815467536538) [0x2e1a7d4d]
// Step 8: Tip and exit
0xfc99fa4b -> CALL 0xdadb0d80 (value: 0.01 ETH) [tip]
0xfc99fa4b -> SELFDESTRUCT -> 0xddf10e09 (7.382076 ETH)
0xddf10e09 -> SELFDESTRUCT -> 0x7bd736 (7.382076 ETH)
Financial Impact
| Asset Lost | Protocol Source | Amount | Notes |
|---|---|---|---|
| ETH | ETHAdapter (0x2b5ec5c4) | 4.336484 ETH | Full adapter balance drained |
| USDT | USDTAdapter (0x950bcda6) | 5,956.71 USDT | Full adapter balance drained, swapped to 3.056 WETH |
| TARA | TARATMintAdapter (0xff235ea7) | 1e32 TARA (minted) | Unlimited mint; 1e23+1e15 dumped into DODO pools for ~0.305 WETH |
Attacker net gain (from funds_flow.json attacker_gains):
- 7.382076 ETH returned to attacker EOA (net after 0.01 ETH tip and gas)
- Residual TARA: ~1e32 TARA remains in the exploit contract addresses (destroyed via SELFDESTRUCT, effectively lost/burned)
Victims:
- TARA bridge liquidity providers who deposited ETH and USDT into the bridge adapters.
- TARA token holders: the minting of 1e32 TARA (against a pre-attack total supply that was far smaller) is an astronomical inflation event. While the minted tokens were largely left in DODO pools or lost, the token’s perceived integrity is severely damaged.
- DODO liquidity providers in the WETH/TARA DPP pool (
0x0ecef178) and Uniswap V4 TARA/WETH pool (0x5745050e): they absorbed 1e23 TARA as quote-side dumping, receiving attacker’s profit of 0.305 WETH at unfavorable prices.
Protocol solvency: The ETH and USDT bridge adapters are fully drained. The TARA bridge is insolvent for these assets.
Evidence
Selector Verification
All selectors confirmed with cast sig:
| Selector | Function | Verification |
|---|---|---|
0x5d0d5734 | finalizeBlocks(((uint256,bytes32,bytes32,bytes32,uint256),(address,int32)[])[],(bytes32,bytes32)[]) | cast sig "finalizeBlocks(((uint256,bytes32,bytes32,bytes32,uint256),(address,int32)[])[],(bytes32,bytes32)[])" = 0x5d0d5734 |
0x6cd50a67 | applyState(((uint256,(address,bytes)[]),(address,bytes32)[])) | cast sig "applyState(((uint256,(address,bytes)[]),(address,bytes32)[]))" = 0x6cd50a67 |
0xaa2bb43d | getFinalizedBridgeRoot(uint256) | cast sig "getFinalizedBridgeRoot(uint256)" = 0xaa2bb43d |
0x9bd15c9e | applyState(bytes) | cast sig "applyState(bytes)" = 0x9bd15c9e |
0xb3f05b97 | finalized() | From decoded_calls.json (4byte resolved) |
Storage State Changes (from trace_prestateTracer.json)
Light client 0xcdf14446 — post-attack storage:
| Slot | Value | Meaning |
|---|---|---|
0x05 | 0x23c | Epoch advanced from 0x23b → 0x23c (forged epoch 572) |
0x04 | 0x7759b67c...98b433 | Forged bridge root stored for latest epoch |
0x7e7883c4... | 0x7759b67c...98b433 | Mapping: epoch 572 → forged root |
0xd1124... | 0x7fffffff | Validator weight stored for attacker’s EOA |
Bridge 0x359cf536 — post-attack:
| Slot | Value | Meaning |
|---|---|---|
0x06 | 0x23c | Epoch counter updated to 572 (matches light client) |
ETHAdapter 0x2b5ec5c4 — pre-attack balance: 4.336484 ETH. Post-attack: 0.
USDT balances from funds_flow.json:
0x950bcda6(USDTAdapter): net change−5956.71 USDT0xfc99fa4b(exploit contract): net USDT change0(received and immediately swapped)
Ecrecover Output Confirming Attacker as Validator
From trace_callTracer.json, inside finalizeBlocks delegatecall:
- Input hash:
0x55c4d6426ae29a84b42667b370157dc34fa0daa21cc1bb72de4a71ea933c158a - Signature:
v=0x1c,r=0x7b0abe76bca420caa2dd80d17d1c815de5f9260e7472df0b898cb84eaaba8021,s=0x3fd9a471f889a351485a163095ebb9c74eb0a64ebb29f6e811f6689cba9b8c6d - Recovered address:
0x7bd736631afbe1d3795a94f60574f7fa0ae89347— the attacker’s EOA
This is absolute proof that the attacker signed the forged bridge state with their own private key and that the light client accepted this as a valid validator attestation.
Transaction Receipt Status
receipt.json: "status": "0x1" — transaction succeeded in full, all payouts executed.
Related URLs
- Transaction: https://etherscan.io/tx/0xa1301596c77938cb31cbd282da79f6499f23cd8ffff5e609a77216ea1cf040a4
- Light client (DODOCoopPool): https://etherscan.io/address/0xcdf14446f78ea7ebcaa62fdb0584e4d2e536b999
- BridgeBase: https://etherscan.io/address/0x359cf536b1fd6248ebad1449e1b3727cab33a01d
- Attacker EOA: https://etherscan.io/address/0x7bd736631afbe1d3795a94f60574f7fa0ae89347