#38634 [SC-Medium] Insufficient validation on offchainTokenData in TokenPool.releaseOrMint allows CCIP message to be executed with mismatched payload potentially leading to loss of funds in cross-ch...
Submitted on Jan 8th 2025 at 13:20:56 UTC by @nnez for Audit Comp | Lombard
Report ID: #38634
Report Type: Smart Contract
Report severity: Medium
Target: https://github.com/lombard-finance/evm-smart-contracts/blob/main/contracts/bridge/adapters/TokenPool.sol
Impacts:
Direct theft of any user funds, whether at-rest or in-motion, other than unclaimed yield
Permanent freezing of funds
Description
Vulnerabilty Details
2 factor cross-chain transfer
LBTC utilizes CCIP to perfrom a cross-chain token transfer. However, the protocol also adds its own security layer, namely, Consortium. Apart from cross-chain message being signed by Chainlink RMN, Validators of Consortium must also sign on the payload of the source chain.
This process is called 2-factor LBTC bridging.
User can initiate a cross-chain transfer by invoking deposit
method on Bridge contract or directly call ccipSend
on CCIP Router contract in case of needing to perform additional cross-chain action.
When request is made, the source chain bridge emits DepositToBridge
with a payload, Validators then sign on this payload.
Once the payload is signed, the cross-chain transfer can be executed through CCIP OffRamp contract with payload and proof (signature) attached in offchainTokenData
variable.
To put it simply, for every CCIP cross-chain message, there is a corresponding signed payload from Consortium. (Note that signed payload is not part of CCIP message, so it is not blessed by RMN)
That is, the legitimacy of cross-chain transaction is verified by CCIP and the legitimacy of token transfer is verified by Consortium.
Hence, the name: 2-factor.
Cross-chain transfer flow on destination is as follows:
Consortium signed the payload and CCIP's RMN blesses the message
CCIP's DON or user executes the message and attach signatures from Consortium in
offchainTokenData
through OffRamp contractOffRamp contract then calls LBTC
TokenPool.releaseOrMint
on destination chainTokenPool then calls to CLAdapter which takes care of the proof verificaiton and minting of token to receiver (specified in payload)
After tokens are minted, if the receiver's address is CCIP compatible, OffRamp proceed to call
ccipReceive
method on receiver address.
The simplified flow:
Vulnerability
Let's say Alice is requesting two cross-chain transfers of LBTC and let's name each transfer A and B respectively.
Transfer A: amount=1, receiver=Bob Transfer B: amount=2, receiver=Bob
Ideally, Transfer A's payload should only work with Transfer A's CCIP message. However, since offchainTokenData
(containing the payload and proof) is not part of CCIP's message, it is possible to use Transfer B's payload with Transfer A's CCIP message and pass CCIP verification.
See the comment explaning why offchainTokenData
is user-control and untrusted: https://github.com/smartcontractkit/ccip/blob/ccip-develop/contracts/src/v0.8/ccip/pools/USDC/USDCTokenPool.sol#L127-L155
Also, if we look further into TokenPool.releaseOrMint
implementation:
TokenPool._validateReleaseOrMint: https://github.com/smartcontractkit/ccip/blob/ccip-develop/contracts/src/v0.8/ccip/pools/TokenPool.sol#L222-L235
We can see that it relies solely on offchainTokenData
to verify and mint tokens to receiver. It doesn't check that CCIP message is corresponding with input offChainTokenData
.
This means that we can execute CCIP message of Transfer A while using payload of Transfer B successfully, minting tokens as per Transfer B payload.
Fortunately, CCIP OffRamp verifies receiver's balance (using receiver from CCIP's message) after every call to TokenPool.releaseOrMint
. That is, the balance of receiver must increase to the same amount return by TokenPool. This creates a constraint where one can only switch payload if the receiver of CCIP's message is the same as the payload.
Initially, this might not appear problematic due to the constraint of having the same receiver. After all, once all messages are executed, the receiver's total balance amount is unchanged.
However, there exists some use cases that this switch of payload could cause a problem. Consider the following scenario:
Alice performs a cross-chain swap of 1 LBTC using SwapRouter as the receiver with specific swap data.
Eve observes Alice’s transaction and initiates a zero-data, zero-gas cross-chain transfer of 0.001 LBTC to SwapRouter. (This forces CCIP to not call a SwapRouter)
Due to network congestion, both transactions are delayed by the DON.
Eve manually executes Eve's CCIP message using Alice's payload.
The TokenPool mints Alice’s 1 LBTC to SwapRouter, but Eve’s zero-data and zero-gas message ensures the SwapRouter is not invoked.
Alice’s 1 LBTC remains stuck in the SwapRouter.
When Alice attempts to execute her own message, it fails because the payload has already been withdrawn.
Even though Alice manages to execute their message with Eve's payload, the transaction would either fail from insufficient token or invalid data.
Alice loses 1 LBTC Eve pays 0.001 LBTC
Impact
This scenario, while potentially occurring in specific use cases of LBTC cross-chain transfers, can result in the loss or freezing of funds. Although the impact may be limited to certain situations, this is a feasibility issue which should not affect the severity of its consequence (loss of funds -> Critical).
Ref: https://immunefisupport.zendesk.com/hc/en-us/articles/16913132495377-Feasibility-Limitation-Standards
Recommended Mitigations
Add a check that descPoolData
(part of CCIP's message) is corresponding with input offchainTokenData.payload
.
References
Conditions where OffRamp skip calling ccipReceiver: https://github.com/smartcontractkit/ccip/blob/ccip-develop/contracts/src/v0.8/ccip/offRamp/EVM2EVMOffRamp.sol#L518-L530
Proof of Concept
Proof-of-Concept
Due to limitation in test suite, I couldn't construct a complete flow of PoC.
Instead, as the root cause is insufficient validation on offchainTokenData
, I wrote a PoC to demonstrate that TokenPool.releaseOrMint
can be invoked with CCIP message with a mismatched payload (same receiver).
Steps
Add the following test case in
test/Bridge.ts
, after should route message case.
Run
yarn hardhat test test/Bridge.ts --grep "Should demonstrate that"
Observe that the test passes, indicate that 1_000 LBTC is minted to signer2 despite CCIP message specifying 1e8. That is,
TokenPool.releaseOrMint
can be invoked with CCIP message with mismatched payload and that it blindly trustoffchainTokenData
.
Was this helpful?