Platform: Code4rena
Start Date: 04/03/2024
Pot Size: $140,000 USDC
Total HM: 19
Participants: 69
Period: 21 days
Judge: 0xean
Total Solo HM: 4
Id: 343
League: ETH
Rank: 8/69
Findings: 4
Award: $6,221.51
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 Selected for report: MrPotatoMagic
Also found by: Aymen0909, alexfilippov314, pa6kuda, t4sk
2004.2337 USDC - $2,004.23
The current implementation of the ERC20Airdrop2
contract allows a user to claim funds between claimStart
and claimEnd
and to withdraw funds between claimEnd
and claimEnd + withdrawalWindow
. The withdrawable amount also grows linearly and becomes equal to the full claimAmount
at the end of the withdrawal window. Presumably, it was implemented this way to deal with the tokens that might be never claimed.
However, several issues arise with the current implementation:
timestamp == claimEnd + withdrawalWindow
. This block might not exist at all so the users might not be able to withdraw the full airdrop amount.The users might not be able to withdraw the full airdrop amount or could potentially lose it entirely.
-
Manual Review
Consider introducing a new storage variable withdrawalGrowPeriod
that will be used in calculating the withdrawable amount. This value should be less than withdrawalWindow
and provide a reasonable amount of time for users to execute the full withdrawal.
Timing
#0 - c4-pre-sort
2024-03-29T09:44:26Z
minhquanym marked the issue as duplicate of #245
#1 - c4-judge
2024-04-10T11:17:00Z
0xean changed the severity to 3 (High Risk)
#2 - c4-judge
2024-04-10T11:22:01Z
0xean marked the issue as satisfactory
🌟 Selected for report: lightoasis
Also found by: 0xleadwizard, Tendency, alexfilippov314, ladboy233, wangxx2026
1503.1753 USDC - $1,503.18
The TimelockTokenPool
contract allows for token withdrawals on behalf of users by utilizing a permit-like signature:
function withdraw(address _to, bytes memory _sig) external { if (_to == address(0)) revert INVALID_PARAM(); bytes32 hash = keccak256(abi.encodePacked("Withdraw unlocked Taiko token to: ", _to)); address recipient = ECDSA.recover(hash, _sig); _withdraw(recipient, _to); }
This function might be called multiple times as the tokens are unlocked gradually. The issue arises from the fact that the signed message doesn't include deadline
and nonce
. It means that anyone can reuse this signature and replay a withdrawal transaction.
It's not much of an issue if the user manages approvals meticulously, but users often opt for unlimited approvals to save on gas. In this case, an attacker is still unable to steal anything, but they can replay a withdrawal transaction without the user's consent. This is quite concerning, as this transaction might essentially involve swapping costToken
for taikoToken
and the user might have some other use for costToken
in mind. There also might be extreme cases where the user attempts to prevent liquidation using costToken
, only to be front-run by the attacker.
This issue becomes even more severe if the _to
address is compromised. In such a scenario, an attacker can get taikoToken
at the expense of the user. The issue becomes more likely because of the following:
nonce
and deadline
and can't be reused;costToken
approval and withdrawal within a single transaction which is possible but isn't simple and obvious for the common user.An attacker can reuse a signature to execute an action without the user's consent. In some cases it allows the attacker to steal user's funds.
-
Manual Review
Consider adding a nonce
and deadline
to the signed message. Additionally, consider exposing a function that marks the current nonce
as used, providing a means for the user to revoke signatures.
Invalid Validation
#0 - c4-pre-sort
2024-03-28T18:50:31Z
minhquanym marked the issue as duplicate of #60
#1 - c4-judge
2024-04-10T11:21:14Z
0xean marked the issue as satisfactory
#2 - c4-judge
2024-04-10T11:21:24Z
0xean changed the severity to 3 (High Risk)
🌟 Selected for report: alexfilippov314
Also found by: t0x1c
2680.5594 USDC - $2,680.56
https://github.com/code-423n4/2024-03-taiko/blob/f58384f44dbf4c6535264a472322322705133b11/packages/protocol/contracts/L1/provers/GuardianProver.sol#L46 https://github.com/code-423n4/2024-03-taiko/blob/f58384f44dbf4c6535264a472322322705133b11/packages/protocol/contracts/L1/libs/LibProving.sol#L192
The GuardianProver
contract is a multisig that might contest any proof in some exceptional cases (bugs in the prover or verifier). To contest a proof, a predefined number of guardians should approve a hash of the message that includes _meta
and _tran
.
function approve( TaikoData.BlockMetadata calldata _meta, TaikoData.Transition calldata _tran, TaikoData.TierProof calldata _proof ) external whenNotPaused nonReentrant returns (bool approved_) { if (_proof.tier != LibTiers.TIER_GUARDIAN) revert INVALID_PROOF(); bytes32 hash = keccak256(abi.encode(_meta, _tran)); approved_ = approve(_meta.id, hash); if (approved_) { deleteApproval(hash); ITaikoL1(resolve("taiko", false)).proveBlock(_meta.id, abi.encode(_meta, _tran, _proof)); } emit GuardianApproval(msg.sender, _meta.id, _tran.blockHash, approved_); }
The issue arises from the fact that the approved message doesn't include the _proof
. It means that the last approving guardian can provide any desired value in the _proof
. The data from the _proof
is used to determine whether it is necessary to return the liveness bond to the assigned prover or not:
if (isTopTier) { // A special return value from the top tier prover can signal this // contract to return all liveness bond. bool returnLivenessBond = blk.livenessBond > 0 && _proof.data.length == 32 && bytes32(_proof.data) == RETURN_LIVENESS_BOND; if (returnLivenessBond) { tko.transfer(blk.assignedProver, blk.livenessBond); blk.livenessBond = 0; } }
As a result, the last guardian can solely decide whether to return the liveness bond to the assigned prover or not.
The decision to return the liveness bond depends solely on the last guardian.
-
Manual Review
Consider including the _proof
in the approved message.
bytes32 hash = keccak256(abi.encode(_meta, _tran, _proof));
Invalid Validation
#0 - c4-pre-sort
2024-03-29T14:32:32Z
minhquanym marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-03-29T18:38:05Z
minhquanym marked the issue as duplicate of #248
#2 - c4-judge
2024-04-10T11:36:23Z
0xean marked the issue as satisfactory
#3 - c4-judge
2024-04-11T10:37:53Z
0xean marked the issue as selected for report
#4 - PaperParachute
2024-04-26T10:12:12Z
Comment on sponsor behalf (content from private Discord channel):
Now guardian provers must also agree on whether liveness bond should be returned bytes32(_proof.data) == LibStrings.H_RETURN_LIVENESS_BOND, so the hash they sign is now:
bytes32 hash = keccak256(abi.encode(_meta, _tran, _proof.data));
rather than the previous code:
bytes32 hash = keccak256(abi.encode(_meta, _tran));
🌟 Selected for report: MrPotatoMagic
Also found by: 0x11singh99, DadeKuma, Fassi_Security, JCK, Kalyan-Singh, Masamune, Myd, Pechenite, Sathish9098, Shield, albahaca, alexfilippov314, cheatc0d3, clara, foxb868, grearlake, hihen, imare, joaovwfreire, josephdara, ladboy233, monrel, n1punp, oualidpro, pa6kuda, pfapostol, rjs, slvDev, sxima, t0x1c, t4sk, zabihullahazadzoi
33.5408 USDC - $33.54
[1] This comment is misleading. The code does the opposite.
// Use the specified message gas limit if called by the owner, else // use remaining gas
[2] This function is missing the onlyInitializing
modifier:
function __Essential_init(address _owner) internal virtual { _transferOwnership(_owner == address(0) ? msg.sender : _owner); __paused = _FALSE; }
[3] There is a typo in the comment:
// Unchecked is safe: // - _fee cannot be bigger than deposits_[i].amount // - all values are in the same range (uint96) except loop // counter, which obviously cannot be bigger than uint95 <- TYPO // otherwise the function would be gassing out.
[4] The L1_TXLIST_OFFSET
error is not defined in the TaikoErrors.sol
.
https://github.com/code-423n4/2024-03-taiko/blob/f58384f44dbf4c6535264a472322322705133b11/packages/protocol/contracts/L1/libs/LibProposing.sol#L54
[5] There is a typo (isAssignedPover
) in this code block:
bool isAssignedPover = msg.sender == _blk.assignedProver; // The assigned prover can only submit the very first transition. if (_tid == 1 && _ts.tier == 0 && inProvingWindow) { if (!isAssignedPover) revert L1_NOT_ASSIGNED_PROVER(); } else { // Disallow the same address to prove the block so that we can detect that the // assigned prover should not receive his liveness bond back if (isAssignedPover) revert L1_ASSIGNED_PROVER_NOT_ALLOWED(); }
[6] Consider checking that the Config.ethDepositRingBufferSize
is enough to accommodate Config.ethDepositMaxCountPerBlock
deposits in the LibVerifying._isConfigValid
function.
https://github.com/code-423n4/2024-03-taiko/blob/f58384f44dbf4c6535264a472322322705133b11/packages/protocol/contracts/L1/libs/LibVerifying.sol#L245
[7] Multiple contracts have comments specifying how the addresses of these contracts are labeled in the AddressManager
. However, the GuardianProver
, GuardianVerifier
, and SgxVerifier
are missing this comment. Consider adding these comments to improve clarity and consistency.
#0 - c4-pre-sort
2024-03-30T16:19:05Z
minhquanym marked the issue as insufficient quality report
#1 - c4-judge
2024-04-08T19:28:21Z
0xean marked the issue as grade-c
#2 - c4-judge
2024-04-10T10:43:18Z
0xean marked the issue as grade-b