Platform: Code4rena
Start Date: 12/08/2022
Pot Size: $35,000 USDC
Total HM: 10
Participants: 126
Period: 3 days
Judge: Justin Goro
Total Solo HM: 3
Id: 154
League: ETH
Rank: 102/126
Findings: 1
Award: $29.89
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: oyc_109
Also found by: 0x1f8b, 0x52, 0xDjango, 0xLovesleep, 0xNazgul, 0xNineDec, 0xbepresent, 0xmatt, 0xsolstars, Aymen0909, Bahurum, Bnke0x0, CertoraInc, Chom, CodingNameKiki, DecorativePineapple, Deivitto, Dravee, ElKu, Funen, GalloDaSballo, IllIllI, JC, JohnSmith, Junnon, KIntern_NA, Lambda, LeoS, MiloTruck, Noah3o6, PaludoX0, RedOneN, Respx, ReyAdmirado, Rohan16, RoiEvenHaim, Rolezn, Ruhum, Sm4rty, TomJ, Vexjon, Waze, Yiko, __141345__, a12jmx, ajtra, ak1, apostle0x01, asutorufos, auditor0517, bin2chen, bobirichman, brgltd, bulej93, byndooa, c3phas, cRat1st0s, cryptphi, csanuragjain, d3e4, defsec, delfin454000, djxploit, durianSausage, ellahi, erictee, exd0tpy, fatherOfBlocks, gogo, jonatascm, ladboy233, medikko, mics, natzuu, neumo, p_crypt0, paribus, pfapostol, rbserver, reassor, ret2basic, robee, rokinot, rvierdiiev, sach1r0, saneryee, seyni, sikorico, simon135, sseefried, wagmi, wastewa
29.8923 USDC - $29.89
The transferOwnership() function in VotingEscrow.sol immediately transfers ownership of the voting contract to a new owner. According to the @dev comment, the owner should always be a timelock contract. This is not verified. Nor are other checks made.
If, for any reason the transferOwnership() function's _addr value is incorrect, a call would result in the immediate loss of access to the following VotingEscrow.sol functions:
The issue's two components are:
Currently the only check used is on L140:
require(msg.sender == owner, "Only owner");
The function checks that the caller is the owner via a require() check. However, it does not check that:
Provided msg.sender matches the existing owner, the owner value is immediately updated to a caller-controlled address at Line 141.
If the _addr value doesn't match unverified expectations access to owner-restricted functions would be immediately and irrevocably lost at this point.
Implementing checks to ensure that the value submitted was not address(0) will ensure that if there's a problem with the existing owner contract's state, this won't cause ownership to be irrevocably transferred to a null address.
The code used in Blocklist.sol can be modified as a check to ensure that the new address is at least a contract:
uint256 size; assembly { size := extcodesize(_addr) } require(size > 0, "Not a contract");
As well as checking the contract's codesize to confirm that it is in fact a contract the check will also fail for address(0) (as it has no code).
It's difficult to verify that the new contract is a timelock contract. However, a two-step transfer + claim with timelock pattern could work. Using this pattern, the new contract would propose a change of ownership. The existing owner would then verify the contract as above and initiate a timelock, emitting a message that a change is in progress.
At this point the existing manager would review the values and if incorrect, cancel the change. Ownership will only be transferred from the original contract once the timelock expires, providing it hasn't been cancelled.
The createLock() function in VotingEscrow.sol takes a user-supplied uint256 _value variable, and recasts it several times on lines 418 and 420. Because the uint256 is recast to an int128, large uint256 values will result in negative int128 values.
For example, submitting a value > 170141183460469231731687303715884105727 will result in locked.amount and locked_.delegated values of -170141183460469231731687303715884105728.
Even stranger, submitting a value of 57896044618658097711785492504343953926634992332820282019728792003956564819968 will be recast as int128(0). If someone submitted this value of an ERC20 token and the transferFrom succeeded, their locked_.amount and locked_.delegated values would be 0. Because of this the locked_.amount == 0 test at Line 413 would pass if they called the function again, but their existing balance would be lost.
Whether this is a risk depends on what kinds of ERC20 tokens and emissions are used. If the VotingEscrow contract was opened up to non-native ERC20 tokens, it's possible that someone could supply this value to create a lock and end up with a resulting locked and delegated value of 0.
Version of this also happen on lines 460, 461, 465, 472 of VotingEscrow.sol in the increaseAmount() function. This has the unwelcome effect of potentially turning values negative which would result in the requires on lines 449 and 469 failing, effectively losing their lock.
The contest documentation has this to say about Lock Delegation:
the delegatee's lock expiration needs to be longer than the delegator's.
The contract does not enforce this, with potential consequences for users:
To address points 1 and 2:
A manager can call Blocklist.sol#block() to block addresses. There is no way to unblock a mistake. If the manager makes a mistake, or accidentally misgenerates an address, addresses will be permanently blocked.
The blocklist is stored as private in Blocklist.sol while the isBlocked() function provides a public way to check if an individual address is blocked. The function VotingEscrow.sol#updateBlocklist enables the VotingEscrow.sol owner to migrate to a new blocklist contract.
There is no way to obtain a full list of existing blocked addresses from Blocklist.sol. This means that a separate list of blocked addresses needs to be maintained so that it can be restored in the event of a blocklist upgrade/address-change.
Both of these functions should be restricted to a manager role.
Blocklist.sol sets the manager and ve contract addresses in the constructor. However there is no way to update the manager address once deployed.
If the manager address needs to change (e.g. keys are lost, or an upgrade to a manager contract) a new blocklist contract needs to be deployed.
The ve address only allows the blocklist to manage one ve contract. Normally this wouldn't be a problem but in scenarios where multiple ve contracts exist, separate blocklists will be required for each.
For example, in a major ve contract upgrade people would still be timelocked in the old contract as no migration mechanism exists. This would require two separately managed blocklist contracts, which could easily end up desynchronized. This is because of two reasons:
Moving ve to an array of addresses and creating add and remove function accessible by the manager role will provide blocklist support for multiple ve contracts without the need to migrate.