Platform: Code4rena
Start Date: 10/05/2024
Pot Size: $300,500 USDC
Total HM: 4
Participants: 27
Period: 17 days
Judge: Picodes
Total Solo HM: 1
Id: 375
League: ETH
Rank: 1/27
Findings: 3
Award: $105,573.46
🌟 Selected for report: 1
🚀 Solo Findings: 0
45836.2695 USDC - $45,836.27
When the required stake (to create a new assertions) is updated to a lower amount, adversary can make the honest party unable to retrieve their assertion stakes.
A -- B -- C -- D(latest confirmed) -- E
Suppose the initial stake amount is 1000 ETH, and till now no invalid assertions have been made. (A, B, C, D, E are all valid and made by the same validator). The rollup contract should hold 1000 ETH now.
A -- B -- C -- D(latest confirmed) -- E \ \ F(invalid)
Then, the admin update the required stake to 700 ETH, Alice made an invalid assertion F. Since its parent D was created before the update, Alice will still need to stake 1000 ETH, and the 1000 ETH will be sent to loserStakeEscrow.
if (!getAssertionStorage(newAssertionHash).isFirstChild) { // only 1 of the children can be confirmed and get their stake refunded // so we send the other children's stake to the loserStakeEscrow IERC20(stakeToken).safeTransfer(loserStakeEscrow, assertion.beforeStateData.configData.requiredStake); }
A -- B -- C -- D(latest confirmed) -- E \ \ F -- G
(a) Alice creates F's children, G. Now, only 700 ETH of stake is needed. However, as the comment suggests, no refund will be made since G's ancestor could need more stake.
// requiredStake is user supplied, will be verified against configHash later // the prev's requiredStake is used to make sure all children have the same stake // the staker may have more than enough stake, and the entire stake will be locked // we cannot do a refund here because the staker may be staker on an unconfirmed ancestor that requires more stake // excess stake can be removed by calling reduceDeposit when the staker is inactive require(amountStaked(msg.sender) >= assertion.beforeStateData.configData.requiredStake, "INSUFFICIENT_STAKE");
(b) To bypass the limit in (a), Alice calls her friend Bob to make the assertion G instead , Bob will only need to stake 700 ETH now. The rollup contract currently holds 1700 ETH. Then, Alice can withdraw her stake since she is no longer active. (her last staked assertion have a child)
function requireInactiveStaker(address stakerAddress) internal view { require(isStaked(stakerAddress), "NOT_STAKED"); // A staker is inactive if // a) their last staked assertion is the latest confirmed assertion // b) their last staked assertion have a child bytes32 lastestAssertion = latestStakedAssertion(stakerAddress); bool isLatestConfirmed = lastestAssertion == latestConfirmed(); bool haveChild = getAssertionStorage(lastestAssertion).firstChildBlock > 0; require(isLatestConfirmed || haveChild, "STAKE_ACTIVE"); }
Now the rollup contract holds 700 ETH, which means it is insolvent. The honest validator cannot withdraw her original stake. (700 < 1000)
Manual
Ensure the following
Context
#0 - gzeoneth
2024-05-31T19:52:32Z
Not a dupe of #49, #8 and #37 should be a group dupe, and #49 #22 #19 should be another group of dupe.
#1 - gzeoneth
2024-05-31T20:13:15Z
https://github.com/code-423n4/2024-05-arbitrum-foundation-validation/issues/237 is also a dupe of #8
#2 - gzeoneth
2024-06-03T13:52:48Z
patched with https://github.com/OffchainLabs/bold/pull/655
#3 - c4-judge
2024-06-05T07:38:14Z
Picodes marked the issue as not a duplicate
#4 - c4-judge
2024-06-05T07:38:43Z
Picodes marked the issue as primary issue
#5 - c4-judge
2024-06-05T07:44:57Z
Picodes marked the issue as satisfactory
#6 - c4-judge
2024-06-05T07:45:00Z
Picodes marked the issue as selected for report
#7 - Picodes
2024-06-05T08:23:55Z
code-423n4/2024-05-arbitrum-foundation-validation#237 is also a dupe of #8
My understanding is that this is #37 in this repository
🌟 Selected for report: Kow
Also found by: Emmanuel, SpicyMeatball, xuwinnie
12737.1941 USDC - $12,737.19
checkClaimIdLink
does not check ClaimId
, and a terminal node can inherit timers which it does not deserve.
According to BoLD paper section 5.4, suppose A, B are terminal nodes which rivals each other (aka shares the same mutualId), and A has children a1, a2, B has children b1, b2, b3. The five children share the same origin id (which is A,B's mutualId).
We should have β(A,t) := λ(A,t) + max{β(a1,t), β(a2,t), β(a3,t)} β(B,t) := λ(B,t) + max{β(b1,t), β(b2,t)}
function checkClaimIdLink(EdgeStore storage store, bytes32 edgeId, bytes32 claimingEdgeId, uint8 numBigStepLevel) private view { // the origin id of an edge should be the mutual id of the edge in the level below if (store.edges[edgeId].mutualId() != store.edges[claimingEdgeId].originId) { revert OriginIdMutualIdMismatch(store.edges[edgeId].mutualId(), store.edges[claimingEdgeId].originId); } // the claiming edge must be exactly one level below if (nextEdgeLevel(store.edges[edgeId].level, numBigStepLevel) != store.edges[claimingEdgeId].level) { revert EdgeLevelInvalid( edgeId, claimingEdgeId, nextEdgeLevel(store.edges[edgeId].level, numBigStepLevel), store.edges[claimingEdgeId].level ); } }
However, in our implementation, we only check the originId of the children matches the mutualId of the parent. We can actually set β(A,t) := λ(A,t) + β(b1,t), which means an edge can inherit timer from its rival's children! Even worse, all of a1, a2, a3, b1, b2's descendants at the same level will share the same originId. We can start from a (proved) proof node at level N, by using it as claimingEdgeId and using its level N-1 length 1 ancestor (or the ancestor's rival) as edgeId, we can set edgeId's timer to type(uint64).max. By repeating so, we can almost instantly confirm any level 0 length 1 edge.
// when bisecting originId is preserved ChallengeEdge memory lowerChild = ChallengeEdgeLib.newChildEdge( ce.originId, ce.startHistoryRoot, ce.startHeight, bisectionHistoryRoot, middleHeight, ce.level );
Manual
require(store.edges[claimingEdgeId].claimId == edgeId);
Context
#0 - c4-judge
2024-06-03T22:30:27Z
Picodes marked the issue as satisfactory
🌟 Selected for report: Sathish9098
Also found by: Audinarey, Dup1337, K42, KupiaSec, LessDupes, Rhaydden, SpicyMeatball, Takarez, ZanyBonzy, bronze_pickaxe, carlitox477, dontonka, forgebyola, fyamf, guhu95, hihen, josephdara, ladboy233, slvDev, twcctop, xuwinnie, zanderbyte
0 USDC - $0.00
https://github.com/code-423n4/2024-05-arbitrum-foundation/blob/6f861c85b281a29f04daacfe17a2099d7dad5f8f/src/rollup/RollupAdminLogic.sol#L145 https://github.com/code-423n4/2024-05-arbitrum-foundation/blob/6f861c85b281a29f04daacfe17a2099d7dad5f8f/src/rollup/RollupUserLogic.sol#L110
/** * @notice Pause interaction with the rollup contract. * The time spent paused is not incremented in the rollup's timing for assertion validation. * @dev this function may be frontrun by a validator (ie to create a assertion before the system is paused). * The pause should be called atomically with required checks to be sure the system is paused in a consistent state. * The RollupAdmin may execute a check against the Rollup's latest assertion num or the OldChallengeManager, then execute this function atomically with it. */ function pause() external override { _pause(); emit OwnerFunctionCalled(3); }
According to the comment, the time spent paused should not be incremented in the rollup's timing for assertion validation. However, the rollup's timing does not take paused time into consideration in reality. As a result, adversary can censor transactions (within the censorship budget) and force incorrect assertion to be confirmed.
According to the contest doc
We assume that an adversary can censor transactions for at most 1 challengePeriodBlocks or confirmPeriodBlock (whichever is smaller)
Suppose challengePeriodBlocks and confirmPeriodBlock are both 7 days. The steps are as following:
Since no one can make new assertions when the contract is paused, the adversary only needs to spend 6 days of censorship budget (to confirm an incorrect assertion) in this case.
require(block.number >= assertion.createdAtBlock + prevConfig.confirmPeriodBlocks, "BEFORE_DEADLINE");
As this line suggests, no special care for paused time is taken.
Manual
Make sure the time spent paused is not incremented in the rollup's timing for assertion validation.
Context
#0 - c4-sponsor
2024-05-31T19:08:30Z
godzillaba (sponsor) acknowledged
#1 - godzillaba
2024-05-31T19:13:55Z
We disagree with the severity and think this should be low/QA. We will add comments to the pause/unpause functions informing the admin that timers do not stop
#2 - c4-judge
2024-06-05T11:16:01Z
Picodes marked the issue as satisfactory
#3 - c4-judge
2024-06-05T11:16:05Z
Picodes marked the issue as selected for report
#4 - Picodes
2024-06-05T11:19:39Z
This report shows how in the event the contract is paused, the challenge period is effectively shorter, and it could eventually drop to a point where it's within the censorship budget of an attacker. As this is a core invariant of the system, and as it wasn't documented, I tend to accept this as a valid Medium-severity issue
#5 - gzeoneth
2024-06-06T00:13:30Z
This report shows how in the event the contract is paused, the challenge period is effectively shorter, and it could eventually drop to a point where it's within the censorship budget of an attacker. As this is a core invariant of the system, and as it wasn't documented, I tend to accept this as a valid Medium-severity issue
We respectfully disagree. Pausing and unpausing are highly trusted admin actions where in most case should be followed by a contract upgrade and/or force confirmation. If an emergency pause is performed, the censorship invariant is not expected to hold. Admin actions that lead to the protocol being stuck in suboptimal state should be considered as misconfiguration. I believe this should be Low risk at best.
#6 - JeffCX
2024-06-06T12:43:32Z
past very similar issue is judged as medium
https://github.com/code-423n4/2024-03-taiko-findings/issues/170
also the code comments says:
the time spent paused should not be incremented in the rollup's timing for assertion validation.
impact is confirmation grace period that act as safe guard is bypassed when pausing.
#7 - gzeoneth
2024-06-06T12:47:56Z
Worth to note we don't currently have a pauser role, only the admin can pause so it is quite different than a system where a lower privileged admin can pause and unpause the protocol.
#8 - carlitox477
2024-06-06T22:57:18Z
Regarding the severity of this issue, I added a comment in #3
#9 - Picodes
2024-06-09T20:28:21Z
I am still hesitating following all your comments on the correct decision for #3 and #7.
Note that these issues as well are related to the fact that invariants are broken following admin actions:
#10 - Picodes
2024-06-09T20:30:41Z
The sponsor's argument that admin actions are meant to be used only to deal with an emergency bug or an upgrade and that therefore it's intended that invariants do not hold anymore is convincing, but up to my knowledge wasn't clearly specified before the contest
#11 - Picodes
2024-06-10T10:20:34Z
Although I do agree with #3 and #7 and the overall fact that the consequence of admin actions on "usual" invariants were poorly documented, it seems that the intention of the sponsor was that indeed pausing or for fast confirming assertions is made for emergency or upgrade procedures and that therefore it's intended that special care must be taken and that once called, resuming operations isn't just calling "unpause", so it makes sense that invariant may be broken in this case.
Consequently, I believe these reports fit "state handling, function incorrect as to spec, issues with comments" more than "function of the protocol or its availability could be impacted".
I'll therefore downgrade #3 and #7 to QA.
#12 - c4-judge
2024-06-10T10:22:06Z
Picodes changed the severity to QA (Quality Assurance)
#13 - DecentralDisco
2024-06-11T18:23:38Z
For transparency, the selected for report
label was removed now that all QA votes have been received and a clear top scoring report has been determined.
#14 - DecentralDisco
2024-06-11T21:02:03Z
Noting that the 'grade-a' label was added on behalf of the judge after receiving confirmation.