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: 6/69
Findings: 6
Award: $7,017.62
π Selected for report: 0
π Solo Findings: 0
π Selected for report: zzebra83
Also found by: MrPotatoMagic, ladboy233, mojito_auditor, monrel
2004.2337 USDC - $2,004.23
https://github.com/code-423n4/2024-03-taiko/blob/f58384f44dbf4c6535264a472322322705133b11/packages/protocol/contracts/L1/libs/LibProposing.sol#L68 https://github.com/code-423n4/2024-03-taiko/blob/f58384f44dbf4c6535264a472322322705133b11/packages/protocol/contracts/L1/hooks/AssignmentHook.sol#L100 https://github.com/code-423n4/2024-03-taiko/blob/f58384f44dbf4c6535264a472322322705133b11/packages/protocol/contracts/L1/hooks/AssignmentHook.sol#L18
in function proposeBlock,
function proposeBlock( TaikoData.State storage _state, TaikoData.Config memory _config, IAddressResolver _resolver, bytes calldata _data, bytes calldata _txList )
in the end of proposeBlock, the assigned prover needs to transfer 250 token as bond via the assignment hook
// Run all hooks. // Note that address(this).balance has been updated with msg.value, // prior to any code in this function has been executed. address prevHook; for (uint256 i; i < params.hookCalls.length; ++i) { if (uint160(prevHook) >= uint160(params.hookCalls[i].hook)) { revert L1_INVALID_HOOK(); } // When a hook is called, all ether in this contract will be send to the hook. // If the ether sent to the hook is not used entirely, the hook shall send the Ether // back to this contract for the next hook to use. // Proposers shall choose use extra hooks wisely. IHook(params.hookCalls[i].hook).onBlockProposed{ value: address(this).balance }( blk, meta_, params.hookCalls[i].data ); prevHook = params.hookCalls[i].hook; } // Refund Ether if (address(this).balance != 0) { msg.sender.sendEther(address(this).balance); } if (tko.balanceOf(address(this)) != tkoBalance + _config.livenessBond) { revert L1_LIVENESS_BOND_NOT_RECEIVED(); }
the expect hook and the expected called function is:
IHook(params.hookCalls[i].hook).onBlockProposed{ value: address(this).balance }( blk, meta_, params.hookCalls[i].data );
which calls onBlockProposed
function,
and in the function note these lines. Line of code
// Send the liveness bond to the Taiko contract IERC20 tko = IERC20(resolve("taiko_token", false)); tko.transferFrom(_blk.assignedProver, taikoL1Address, _blk.livenessBond); // Find the prover fee using the minimal tier uint256 proverFee = _getProverFee(assignment.tierFees, _meta.minTier); // The proposer irrevocably pays a fee to the assigned prover, either in // Ether or ERC20 tokens. if (assignment.feeToken == address(0)) { // Paying Ether _blk.assignedProver.sendEther(proverFee, MAX_GAS_PAYING_PROVER); } else { // Paying ERC20 tokens IERC20(assignment.feeToken).safeTransferFrom( _meta.coinbase, _blk.assignedProver, proverFee ); }
note the logic:
else { // Paying ERC20 tokens IERC20(assignment.feeToken).safeTransferFrom( _meta.coinbase, _blk.assignedProver, proverFee );
We transfer the fee token from coinbase address to the assigend prover address,
the problem is that:
the fee token, proverFee amount and coinbase address lacks input validation:
function onBlockProposed( TaikoData.Block memory _blk, TaikoData.BlockMetadata memory _meta, bytes memory _data ) external payable nonReentrant onlyFromNamed("taiko") { // Note that // - 'msg.sender' is the TaikoL1 contract address // - 'block.coinbase' is the L1 block builder // - 'meta.coinbase' is the L2 block proposer Input memory input = abi.decode(_data, (Input));
as we can see, the fee token comes from the decoded data:
struct ProverAssignment { address feeToken; uint64 expiry; uint64 maxBlockId; uint64 maxProposedIn; bytes32 metaHash; bytes32 parentMetaHash; TaikoData.TierFee[] tierFees; bytes signature; } struct Input { ProverAssignment assignment; uint256 tip; // A tip to L1 block builder }
Problem is that the user can set coinbase address to any address.
A malicious user can set:
fee token to taiko token,
and coinbase address to a previous assigned prover who can give max approval to the hook contract.
if (params.coinbase == address(0)) { params.coinbase = msg.sender; }
Then, the taiko token is transferred from the previous assigned prover
to the current assigned prover via the hook, which makes the previous assigned prover lose fund
while another address that approve the hook as a spender also lose fund.
One of the most possible case is that if multiple prover uses the same hook address,
they need to give approval to the hook contract
to spend their taiko token to pay for the bond fee when propsoing the block
Implement comprehensive validation checks
for all inputs involved in the block proposal process.
This includes verifying the legitimacy and integrity of the
fee token, the prover fee amount, and the coinbase address.
Token-Transfer
#0 - c4-pre-sort
2024-03-28T00:31:54Z
minhquanym marked the issue as duplicate of #163
#1 - c4-judge
2024-04-10T11:20:12Z
0xean marked the issue as satisfactory
π Selected for report: lightoasis
Also found by: 0xleadwizard, Tendency, alexfilippov314, ladboy233, wangxx2026
1503.1753 USDC - $1,503.18
https://github.com/code-423n4/2024-03-taiko/blob/f58384f44dbf4c6535264a472322322705133b11/packages/protocol/contracts/team/TimelockTokenPool.sol#L168 https://github.com/code-423n4/2024-03-taiko/blob/f58384f44dbf4c6535264a472322322705133b11/packages/protocol/contracts/team/TimelockTokenPool.sol#L208
In TimelockTokenPool.sol
,
/// @notice Withdraws all withdrawable tokens. /// @param _to The address where the granted and unlocked tokens shall be sent to. /// @param _sig Signature provided by the grant recipient. // @audit // replay protection is missing 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); }
there is lack of validation for signature replay,
then once the recipient sign a signature, anyone can replay the signature to trigger withdraw repeatedly
function _withdraw(address _recipient, address _to) private { Recipient storage r = recipients[_recipient]; (,,, uint128 amountToWithdraw, uint128 costToWithdraw) = getMyGrantSummary(_recipient); r.amountWithdrawn += amountToWithdraw; r.costPaid += costToWithdraw; totalAmountWithdrawn += amountToWithdraw; totalCostPaid += costToWithdraw; IERC20(taikoToken).transferFrom(sharedVault, _to, amountToWithdraw); IERC20(costToken).safeTransferFrom(_recipient, sharedVault, costToWithdraw);
And this will let the sharedVault recipient replay the amountToWithdraw several times,
and force the _recipient to pay the costToWithdraw several times to drain the fund
that remain static in the _recipient wallet
mark the hash as used and do not allow signature reuse.
Token-Transfer
#0 - c4-pre-sort
2024-03-28T18:50:21Z
minhquanym marked the issue as duplicate of #60
#1 - c4-judge
2024-04-10T11:21:16Z
0xean marked the issue as satisfactory
2061.9688 USDC - $2,061.97
Inside the function processDeposits
,
When the deposit is processed,
this line of code is executed, Line of code
// @audit this can changed? the block.basefee uint96 fee = uint96(_config.ethDepositMaxFee.min(block.basefee * _config.ethDepositGas));
the fee is computed using the formula:
block.basefee * _config.ethDepositGas
the _config.ethDepositGas
is hardcoded as 21000
but the block.baseFee
depends on the block usage
Refer to the EIP-1559 Gas Fee calculations doc
The Base Fee is determined by the Ethereum network rather than being set by end-users looking to transact or miners seeking to validate transactions. The Base Fee targets 50% full blocks and is based upon the contents of the most recent confirmed block. Depending on how full that new block is, the Base Fee is automatically increased or decreased.
For example: If the last block was exactly 50% full, the Base Fee will remain unchanged. If the last block was 100% full, the Base Fee will increase by the maximum 12.5% for the next block. If the last block was more than 50% full but less than 100% full, the Base Fee will increase by less than 12.5%. If the last block was 0% full β that is, empty β the Base fee will decrease the maximum 12.5% for the next block. If the last block was more than 0% full but less than 50% full, the Base Fee will decrease by less than 12.5%
note that _config.ethDepositMaxFee
is 1 ether / 10 = 0.1 ETH
then this actually only give user incentive to selectively process the deposit
when the block usage is very high to collect high fee from deposit fund.
and user basically ignore the deposit and cease the process when the block.baseFee
is low.
Introduce a maximum fee cap that limits the deposit fee regardless of the baseFee
Token-Transfer
#0 - c4-pre-sort
2024-03-27T23:58:46Z
minhquanym marked the issue as insufficient quality report
#1 - c4-pre-sort
2024-03-28T00:03:15Z
minhquanym marked the issue as duplicate of #321
#2 - c4-judge
2024-04-10T11:31:56Z
0xean marked the issue as satisfactory
π Selected for report: josephdara
Also found by: Aymen0909, Fassi_Security, MrPotatoMagic, Shield, grearlake, iamandreiski, josephdara, ladboy233, lanrebayode77, t0x1c
177.522 USDC - $177.52
When process the message in Bridge contract,
if a address is banned, the message is marked DONE.
// Process message differently based on the target address if ( _message.to == address(0) || _message.to == address(this) || _message.to == signalService || addressBanned[_message.to] ) { // Handle special addresses that don't require actual invocation but // mark message as DONE refundAmount = _message.value; _updateMessageStatus(msgHash, Status.DONE); }
But the same addressBanned check is missing when the message is retried.
Assume the following sequence of event.
we need to validate if an address is banned when retry message
and refund ETH and fee and mark the message as DONE
if the address is banned when address is retried.
function retryMessage( Message calldata _message, bool _isLastAttempt ) external nonReentrant whenNotPaused sameChain(_message.destChainId) { if (_message.gasLimit == 0 || _isLastAttempt) { if (msg.sender != _message.destOwner) revert B_PERMISSION_DENIED(); } bytes32 msgHash = hashMessage(_message); if (messageStatus[msgHash] != Status.RETRIABLE) { revert B_NON_RETRIABLE(); } // Attempt to invoke the messageCall. if (_invokeMessageCall(_message, msgHash, gasleft())) { _updateMessageStatus(msgHash, Status.DONE); } else if (_isLastAttempt) { _updateMessageStatus(msgHash, Status.FAILED); } emit MessageRetried(msgHash); }
validate if the target address is banned when message is retried.
Token-Transfer
#0 - c4-pre-sort
2024-03-28T00:48:29Z
minhquanym marked the issue as duplicate of #26
#1 - c4-pre-sort
2024-03-28T18:58:58Z
minhquanym marked the issue as duplicate of #298
#2 - c4-judge
2024-04-09T18:23:36Z
0xean changed the severity to QA (Quality Assurance)
#3 - c4-judge
2024-04-10T11:46:55Z
0xean marked the issue as grade-b
#4 - c4-judge
2024-04-12T14:03:24Z
This previously downgraded issue has been upgraded by 0xean
#5 - c4-judge
2024-04-12T14:04:00Z
0xean marked the issue as satisfactory
1237.1813 USDC - $1,237.18
https://github.com/code-423n4/2024-03-taiko/blob/f58384f44dbf4c6535264a472322322705133b11/packages/protocol/contracts/bridge/Bridge.sol#L217 https://github.com/code-423n4/2024-03-taiko/blob/f58384f44dbf4c6535264a472322322705133b11/packages/protocol/contracts/bridge/Bridge.sol#L282 https://github.com/code-423n4/2024-03-taiko/blob/f58384f44dbf4c6535264a472322322705133b11/packages/protocol/contracts/bridge/Bridge.sol#L491
When sending cross chain from source chain to target chain,
anyone can process the message permissionlessly if the gaslimit is set to a non-zero value Line of code
function processMessage( Message calldata _message, bytes calldata _proof ) external nonReentrant whenNotPaused sameChain(_message.destChainId) {
then inside the function we call these lines of code
// Use the specified message gas limit if called by the owner, else // use remaining gas uint256 gasLimit = msg.sender == _message.destOwner ? gasleft() : _message.gasLimit; if (_invokeMessageCall(_message, msgHash, gasLimit)) { _updateMessageStatus(msgHash, Status.DONE); } else { _updateMessageStatus(msgHash, Status.RETRIABLE); }
the gas limit is passed into the function _invokeMessageCall
function _invokeMessageCall( Message calldata _message, bytes32 _msgHash, uint256 _gasLimit ) private returns (bool success_) { if (_gasLimit == 0) revert B_INVALID_GAS_LIMIT(); assert(_message.from != address(this)); _storeContext(_msgHash, _message.from, _message.srcChainId); if ( _message.data.length >= 4 // msg can be empty && bytes4(_message.data) != IMessageInvocable.onMessageInvocation.selector && _message.to.isContract() ) { success_ = false; } else { (success_,) = ExcessivelySafeCall.excessivelySafeCall( _message.to, _gasLimit, _message.value, 64, // return max 64 bytes _message.data ); }
But Because
refer to eip-150 specification
if a call asks for more gas than all but one 64th of the maximum allowed amount, call with all but one 64th of the maximum allowed amount of gas (this is equivalent to a version of EIP-90ethereum/EIPs#90 plus EIP-114ethereum/EIPs#114). CREATE only provides all but one 64th of the parent gas to the child call.
the 1/64 rules presences, only the 63/64 gas from gas limit is passed and spent to execute the message
then malicious user can ensure that the 63 / 64 of gasleft() is less than the message.gasLimit to make message executes but failed
The following POC shows that lack of validation on the gas limit results in failed transaction
// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.13; import "forge-std/Test.sol"; import "forge-std/console.sol"; contract ExternalContract { function entry() public { try this.execute() { uint256 cur = gasleft(); for (uint i = 0; i < 3650; i++) { i += 10; // console.log("i: ", i); } console.log("gas used: ", cur - gasleft()); } catch { } } function execute() public { console.log("gas left: ", gasleft()); } } contract CounterTest is Test { using stdStorage for StdStorage; StdStorage stdlib; function setUp() public { } function testEIP150() public { ExternalContract externalContract = new ExternalContract(); uint256 gasLimit = 64000; externalContract.entry{gas: gasLimit}(); } function testAccurateGasLimit() public { ExternalContract externalContract = new ExternalContract(); uint256 gasLimit = 100000; externalContract.entry{gas: gasLimit}(); } }
running 3500 for loop would cost 63513 unit of gas
but if we specify gas limit as 64000, because only 63 / 64 of gas is passed to external call, the external transaction can still marked as failed
because this allows the external transaction executes in the gas limit less than user specify
The recommendation is check :
gasLeft() / 63 * 64 >= message.gasLimit()
to make sure that the gas spend on execute message is at least the message.gasLimit
user requires
ETH-Transfer
#0 - minhquanym
2024-03-29T17:34:19Z
A bit similar #97 but not point out the impact
#1 - c4-pre-sort
2024-03-29T17:34:23Z
minhquanym marked the issue as sufficient quality report
#2 - dantaik
2024-04-02T15:49:43Z
Valid bug report, fixed in https://github.com/taikoxyz/taiko-mono/pull/16613
#3 - c4-sponsor
2024-04-02T15:50:01Z
dantaik (sponsor) confirmed
#4 - adaki2004
2024-04-04T13:11:13Z
Agree with the proposal (confirm) but I'd disagree with severity, since gaslimit is user specified - so it could have been even fix in our UI flow, without touching the contracts.
#5 - c4-sponsor
2024-04-04T13:11:17Z
adaki2004 marked the issue as disagree with severity
#6 - c4-judge
2024-04-09T21:56:06Z
0xean marked the issue as duplicate of #97
#7 - c4-judge
2024-04-10T11:40:49Z
0xean marked the issue as satisfactory
π 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
function processDeposits( TaikoData.State storage _state, TaikoData.Config memory _config, address _feeRecipient ) internal returns (TaikoData.EthDeposit[] memory deposits_) { // Calculate the number of pending deposits. uint256 numPending = _state.slotA.numEthDeposits - _state.slotA.nextEthDepositToProcess; if (numPending < _config.ethDepositMinCountPerBlock) { deposits_ = new TaikoData.EthDeposit[](0); } else { deposits_ = new TaikoData.EthDeposit[](numPending.min(_config.ethDepositMaxCountPerBlock)); uint96 fee = uint96(_config.ethDepositMaxFee.min(block.basefee * _config.ethDepositGas)); // @audit this can changed? the block.basefee uint64 j = _state.slotA.nextEthDepositToProcess; uint96 totalFee; for (uint256 i; i < deposits_.length;) { uint256 data = _state.ethDeposits[j % _config.ethDepositRingBufferSize]; deposits_[i] = TaikoData.EthDeposit({ recipient: address(uint160(data >> 96)), amount: uint96(data), id: j }); uint96 _fee = deposits_[i].amount > fee ? fee : deposits_[i].amount; // 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 // otherwise the function would be gassing out. unchecked { deposits_[i].amount -= _fee; totalFee += _fee; ++i; ++j; } } _state.slotA.nextEthDepositToProcess = j; // This is the fee deposit _state.ethDeposits[_state.slotA.numEthDeposits % _config.ethDepositRingBufferSize] = _encodeEthDeposit(_feeRecipient, totalFee); // Unchecked is safe: // - uint64 can store up to ~1.8 * 1e19, which can represent 584K // years if we are depositing at every second unchecked { _state.slotA.numEthDeposits++; } } }
After user deposit ETH, anyone can trigger processDeposits
the min amount ETH is 1 ETH per deposit
the max amount of ETH is 32 ETH per deposit
then when process the deposit, the fee is computed in this way:
uint96 fee = uint96(_config.ethDepositMaxFee.min(block.basefee * _config.ethDepositGas)); // @audit this can changed? the block.basefee
the max fee is 1 ether / 10 = 0.1 ETH
the config.ethDepositGas = 21000
the block.baseFee changes depends on the block usage
but if we go to etherscan gas tracker, Ethereum gas tracker
a 15 gwei - 30 gwei is reasonable.
Let us assume 15 gwei,
using ethereum unit converter,
15 gwei = 0.000000015 ETH
15 gwei * 21000 = 0.000000015 ETH * 21000 = 0.00031499999999999996 ETH
We need to times 8
for (uint256 i; i < deposits_.length;) {
because the ethDepositMinCountPerBlock is 8
ethDepositMinCountPerBlock: 8, ethDepositMaxCountPerBlock: 32,
0.00031499999999999996 ETH * 8 = 0.0025199999999999997 ETH
ok, when the block usage is not high, a user process 8 deposit and collect 0.00251 ETH as fee
_state.slotA.nextEthDepositToProcess = j; // This is the fee deposit _state.ethDeposits[_state.slotA.numEthDeposits % _config.ethDepositRingBufferSize] = _encodeEthDeposit(_feeRecipient, totalFee);
the totalFee is 0.00251 ETH
but the problem is that someone needs to process the ETH deposit, including the fee
then when later another user process the deposit,
assume the block.baseFee is high, the block.baseFee * 21000 exceed 0.1 ETH,
uint96 fee = uint96(_config.ethDepositMaxFee.min(block.basefee * _config.ethDepositGas)); // @audit this can changed? the block.basefee
then when the previous user's ETH fee deposit transaction is processed, the 0.00251 ETH fee will all be paid as the fee
because 0.1 ETH > 0.00251 ETH
uint96 _fee = deposits_[i].amount > fee ? fee : deposits_[i].amount; unchecked { deposits_[i].amount -= _fee; totalFee += _fee; ++i; ++j; }
Establish a minimum fee threshold to ensure that
the collected fees will always be sufficient
to cover the gas costs of processing,
regardless of block.baseFee fluctuations.
Token-Transfer
#0 - c4-pre-sort
2024-03-28T00:02:02Z
minhquanym marked the issue as duplicate of #10
#1 - c4-judge
2024-04-09T13:42:04Z
0xean changed the severity to QA (Quality Assurance)
#2 - c4-judge
2024-04-10T11:46:57Z
0xean marked the issue as grade-b