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: 116/175
Findings: 1
Award: $25.68
π Selected for report: 0
π Solo Findings: 0
π 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/main/src/BranchPort.sol#L362-L372 https://github.com/code-423n4/2023-09-maia/blob/main/src/BranchPort.sol#L188-L219
Ulysses branch ports implement a mechanism for managing active Port Strategies and balancing the tokens that these strategies can use. As it is, this mechanism has several issues:
(1)
A strategy can block withdraws of all other strategiesWhen a strategy wishes to withdraw tokens to use for yielding, they call the BranchPort::manage
function. Before sending the tokens, the function checks if there are enough tokens in the Port left afterwards, to maintain a minimum required amount
// Cache Strategy Token Global Debt uint256 _strategyTokenDebt = getStrategyTokenDebt[_token]; // Check if request would surpass the tokens minimum reserves if (_amount > _excessReserves(_strategyTokenDebt, _token)) revert InsufficientReserves();
and if not reverts. This can be abused as such:
alice
is a strategy that uses a complicated lending/borrowing mechanism that looks for opportunities and requires specific amount of tokens at a time.
alice
and there are enough reserves to actually do it so she initiates a prot withdrawbob
, a competitive strategy, sees alice
s transaction and front-runs it with a withdraw of it's own, lowering the reserves and making alice
fail her transactionalice
had a trigger and a specific opportunity had appear, she now has lost that and any potential gainsThis attack is limited to the amount of tokens a strategy can withdraw daily, as such, is permanent, although it still can do damage.
(2)
In deficit, anybody can decide from which strategy to withdraw tokens fromPort strategy deficit managing mechanism allows for anybody to extract tokens from whatever strategy they choose if there are not enough tokens to meet minimum reserve in the port. This is possible via the second variation of BranchPort::replenishReserves
which is callable by anyone
* @dev can be called by anyone to ensure availability of service
Allowing this mechanism to exist creates a competitive ecosystem were, whenever the minimum reserve is not reached, strategies front-run one another in a bid to achieve overall performance and no yielding down-time. This type of mechanism can harm the protocol over time, resulting in gains for one strategy at the expense of others.
(3)
A defect strategy that has its calls maliciously hijacked can block the entire withdraw foreverTo replenishing the port reserves the functions BranchPort::replenishReserves
are called, either by the strategy itself or by outside call. In both cases there is a check that enough funds have been sent otherwise it reverts
// Get current balance of _token uint256 currBalance = ERC20(_token).balanceOf(address(this)); // Withdraw tokens from startegy IPortStrategy(msg.sender).withdraw(address(this), _token, _amount); // Check if _token balance has increased by _amount require(ERC20(_token).balanceOf(address(this)) - currBalance == _amount, "Port Strategy Withdraw Failed");
In an ideal environment this would be enough, but considering the nature of web3, any strategy that may have say a valid IPortStrategy::withdraw
implementation but somehow allows for external calls to happen, a malicious attacker can hijack flow and block this withdraw by simply sending 1 WEI back to the prot, making the require statement, as it looks for equality ==
revert, thus blocking any form of taking back any tokens by the port.
(4)
Strategies are not required to actually give back the tokens they took and can't be realistically forced to eitherEntire strategy premise is based that strategies will voluntarily send back, at one point, any and all tokens they were loaned. This is not enforced directly and any 3rd party strategy, even non-malicious ones approved through governance, can simply choose not return any of the tokens.
More context points:
(A)
A strategy takes tokens from the port via BranchPort::manage
under some conditions:
- it can only do so within a daily limit, set when the strategy was first added (updatable).
// Check if request would surpass the Port Strategy's daily limit _checkTimeLimit(_token, _amount);
and the _checkTimeLimit implementation:
- there are enough tokens available for withdrawal, while having the minimum reserve meet.
// Cache Strategy Token Global Debt uint256 _strategyTokenDebt = getStrategyTokenDebt[_token]; // Check if request would surpass the tokens minimum reserves if (_amount > _excessReserves(_strategyTokenDebt, _token)) revert InsufficientReserves();
(B)
Anyone can call the BranchPort::replenishReserves
version that takes from a strategy regardless of its will but limited to and only to the difference needed to balance out the reverses, entire strategy debt or 0 if no debt.
// Get reserves lacking uint256 reservesLacking = _reservesLacking(strategyTokenDebt, _token, currBalance); // Cache Port Strategy Token Debt uint256 portStrategyTokenDebt = getPortStrategyTokenDebt[_strategy][_token]; // Calculate amount to withdraw. The lesser of reserves lacking or Strategy Token Global Debt. uint256 amountToWithdraw = portStrategyTokenDebt < reservesLacking ? portStrategyTokenDebt : reservesLacking;
(C)
Once a minimum reserve amount for a strategy token is chosen it cannot be changed and a token cannot be removed, only deactivated (so removing and adding it back is not possible).
Strategy tokens are added via the BranchPort::addStrategyToken
where it is the only place where the minimumReservesRatio
is set
strategyTokens.push(_token); getMinimumTokenReserveRatio[_token] = _minimumReservesRatio; isStrategyToken[_token] = true;
A strategy token can afterwards be only activated and deactivated (this allows the strategy to withdraw tokens from the port).
Taking into consideration (A), (B) and (C)
an example of an unwanted situation that can appear follows:
alice
adds strategy token USDC
with a minimum reserve ration of 50%; valid values are [30%, 100%)alice
adds strategy bob
through governance call. bob
has no malicious code
bob
a daily withdraw limit of 1000 USDC is allowedeve
and charlie
are other strategies that are added by alice
to the protocolbob
continuously withdraw 1000 USDC every day ((A)
) until, for example, he has 30,000 withdrawn (the other 20,000 is split between eve
and charlie
) and the protocol reaches 50% minimum reserve ratiobob
sees this and immediately front-runs the replenishReserves
((B)
) on eve
; eve
is forced to pay what it can, if not enough, bob
goes after charlie
in the same transaction, and so forth until the minimum is reached and he is safealice
(protocol team) feels that the one strategy has a too big proportion of the debt so it wishes for it to return a part of the tokensbob
does not comply, but remember, bob
does not have any malicious code in the strategy, he is just using the mechanism as it isalice
cannot now force bob
to give back the tokens, she must wait until the minimum is again not reached to force take from himbob
again takes from another strategy, by calling replenishReserves
on italice
actually wishes to make bob
give back tokens, since she cannot change the minimum (because of (C)
), deactivating USDC or the bob
strategy does not help, the only alternative she has is to hope that more users take their deposits out, so that the TVL drops bellow the minimum and go through each and every strategy because bob
would front-run any and all replenishReserves
callsalice
wants bob
out, she needs to dry up all strategies up to that point. This is even more difficult in a bull market since users would deposit in the system, the minimum will not be reached that easy, making the tokens bob
has, the 30,000, permanently lostAbusive strategies can whittled paying back tokens or indirectly cause harm to other strategies.
Manual analysis
To avoid any abuse of the system the following changes are recommended:
replenishReserves
but do not allow then for which strategy to take from; add a priority queue that first in to withdraw are the first to be taken from (FIFO); this way is a more fair versionreplenishReserves
versions to >=
instead of ==
DoS
#0 - c4-pre-sort
2023-10-12T13:45:13Z
0xA5DF marked the issue as primary issue
#1 - c4-pre-sort
2023-10-12T13:45:18Z
0xA5DF marked the issue as sufficient quality report
#2 - 0xA5DF
2023-10-12T13:45:24Z
Leaving open for sponsor to comment
#3 - c4-sponsor
2023-10-17T18:50:18Z
0xBugsy (sponsor) confirmed
#4 - c4-sponsor
2023-10-17T18:50:23Z
0xBugsy marked the issue as disagree with severity
#5 - 0xBugsy
2023-10-17T18:54:06Z
The minimum reserve ratio is already updateable by governance. Furthermore, strategies were considered internal system contracts upon their design. Regardless, this issue does in fact suggest valuable improvements that can improve resilience and overall safety of the functions.
#6 - alcueca
2023-10-25T11:47:34Z
Mitigation for 1 already exists. 2 is fair advice, it would belong on analysis 3 also analysis On 4, strategies can be added again after toggling them off, updating the reserves ratio.
Rewarded as QA, but should have been analysis.
#7 - c4-judge
2023-10-25T11:47:41Z
alcueca changed the severity to QA (Quality Assurance)
#8 - c4-judge
2023-10-25T11:47:46Z
alcueca marked the issue as grade-a