Platform: Code4rena
Start Date: 12/07/2022
Pot Size: $75,000 USDC
Total HM: 16
Participants: 100
Period: 7 days
Judge: LSDan
Total Solo HM: 7
Id: 145
League: ETH
Rank: 42/100
Findings: 3
Award: $124.79
π Selected for report: 0
π Solo Findings: 0
π Selected for report: rajatbeladiya
Also found by: 0x29A, 0xNineDec, Amithuddar, Aussie_Battlers, Ch_301, Dravee, GimelSec, IllIllI, Jujic, Limbooo, RedOneN, Ruhum, TomJ, _Adam, __141345__, alan724, asutorufos, berndartmueller, c3phas, cccz, cryptphi, durianSausage, fatherOfBlocks, hake, hyh, pashov, scaraven, zzzitron
5.45 USDC - $5.45
https://github.com/code-423n4/2022-07-ens/blob/ff6e59b9415d0ead7daf31c2ed06e86d9061ae22/contracts/ethregistrar/ETHRegistrarController.sol#L182-L186 https://github.com/code-423n4/2022-07-ens/blob/ff6e59b9415d0ead7daf31c2ed06e86d9061ae22/contracts/ethregistrar/ETHRegistrarController.sol#L203-L205
ETHRegistrarController's register() and renew() transfer out remainder native tokens via payable(to).transfer
call. This is unsafe as transfer
has hard coded gas budget and can fail when msg.sender
is a smart contract. Such transactions will fail for smart contract users which don't fit to 2300 gas stipend transfer
have.
The affected users are smart contracts, so it is a programmatic usage failure, i.e. denial of service for downstream systems.
Setting severity to be medium as while register() is msg.sender
based, it is initiating functionality and can be easily repeated with a different caller, while renew() do not have any msg.sender accounting, so the impact is core functionality unavailability that can be mitigated.
User facing register() calls transfer
:
if (msg.value > (price.base + price.premium)) { payable(msg.sender).transfer( msg.value - (price.base + price.premium) ); }
renew() as well:
if (msg.value > price.base) { payable(msg.sender).transfer(msg.value - price.base); }
Both functions return only excess values only and the impact is their partial unavailability. I.e. when user is a smart contract that can't handle transfer the register() and renew() will be unavailable whenever msg.value
exceeds the amount needed and msg.sender
can't accept the change.
Also, owner's withdraw():
function withdraw() public { payable(owner()).transfer(address(this).balance); }
Notice that BulkRenewal's renewAll() does the same (not in scope, listed for completeness):
// Send any excess funds back payable(msg.sender).transfer(address(this).balance); }
The issues with transfer()
are outlined here:
https://consensys.net/diligence/blog/2019/09/stop-using-soliditys-transfer-now/
Generally replacing the transfer()
should be coupled with the introduction of non-reentrancy feature, but here the transfer calls happen after all internal updates, being the very last operation each time, so reentrancy isn't an issue.
This way the recommendation is to use low-level call.value(amount)
with the corresponding result check or employ OpenZeppelin's Address.sendValue
:
https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/utils/Address.sol#L60
#0 - jefflau
2022-07-27T07:40:31Z
π Selected for report: IllIllI
Also found by: 0x1f8b, 0x29A, 0xDjango, 0xNazgul, 0xNineDec, 0xf15ers, 8olidity, Aussie_Battlers, Bnke0x0, Ch_301, Critical, Deivitto, Dravee, ElKu, Funen, GimelSec, JC, JohnSmith, Lambda, MiloTruck, PwnedNoMore, ReyAdmirado, Rohan16, Rolezn, Ruhum, RustyRabbit, Sm4rty, TomJ, Waze, _Adam, __141345__, alan724, asutorufos, benbaessler, berndartmueller, bin2chen, brgltd, bulej93, c3phas, cRat1st0s, cryptonue, cryptphi, csanuragjain, delfin454000, dxdv, exd0tpy, fatherOfBlocks, gogo, hake, hyh, joestakey, kyteg, lcfr_eth, minhtrng, p_crypt0, pashov, pedr02b2, philogy, rajatbeladiya, rbserver, rishabh, robee, rokinot, sach1r0, sashik_eth, seyni, simon135, svskaushik, zuhaibmohd, zzzitron
79.4817 USDC - $79.48
renewAll() doesn't calculate the funds needed and sends back the whole balance instead.
BulkRenewal's renewAll sends the whole balance as a change to the function caller:
// Send any excess funds back payable(msg.sender).transfer(address(this).balance); }
function renewAll(string[] calldata names, uint256 duration) external payable override { ETHRegistrarController controller = getController(); for (uint256 i = 0; i < names.length; i++) { IPriceOracle.Price memory price = controller.rentPrice( names[i], duration ); controller.renew{value: price.base + price.premium}( names[i], duration ); } // Send any excess funds back payable(msg.sender).transfer(address(this).balance); }
Consider reusing rentPrice's approach and send back msg.value - total
only:
ETHRegistrarController controller = getController(); for (uint256 i = 0; i < names.length; i++) { IPriceOracle.Price memory price = controller.rentPrice( names[i], duration ); total += (price.base + price.premium); }
Both functions use while (true)
loop with a break condition.
nameLength() and labelCount() use infinite loop:
function nameLength(bytes memory self, uint offset) internal pure returns(uint) { uint idx = offset; while (true) { assert(idx < self.length); uint labelLen = self.readUint8(idx); idx += labelLen + 1; if (labelLen == 0) { break; } } return idx - offset;
function labelCount(bytes memory self, uint offset) internal pure returns(uint) { uint count = 0; while (true) { assert(offset < self.length); uint labelLen = self.readUint8(offset); offset += labelLen + 1; if (labelLen == 0) { break; } count += 1; } return count;
Consider updating the logic with control of the loop, for example:
function nameLength(bytes memory self, uint offset) internal pure returns(uint) { uint idx = offset; + bool lenReached; + while (idx < self.length && !lenReached) { - while (true) { - assert(idx < self.length); uint labelLen = self.readUint8(idx); idx += labelLen + 1; if (labelLen == 0) { lenReached = true; } } + require(lenReached, "nameLength: length exceeded"); return idx - offset;
calling account
is referred in the description, while it's any account provided:
/** * @dev Transfers ownership of the reverse ENS record associated with the * calling account. * @param addr The reverse record to set * @param owner The address to set as the owner of the reverse record in ENS. * @return The ENS node hash of the reverse record. */ function claimForAddr( address addr, address owner, address resolver ) public override authorised(addr) returns (bytes32) { bytes32 labelHash = sha3HexAddress(addr); bytes32 reverseNode = keccak256( abi.encodePacked(ADDR_REVERSE_NODE, labelHash) ); emit ReverseClaimed(addr, reverseNode); ens.setSubnodeRecord(ADDR_REVERSE_NODE, labelHash, owner, resolver, 0); return reverseNode; }
Consider updating to the account provided
First updates the resolver to the default reverse resolver if necessary
is claimed in the description, while it's not happening (most probably the line copied over from ReverseRegistrar's setName):
/** * @dev Sets the `name()` record for the reverse ENS record associated with * the account provided. First updates the resolver to the default reverse * resolver if necessary. * Only callable by controllers and authorised users * @param addr The reverse record to set * @param owner The owner of the reverse node * @param name The name to set for this address. * @return The ENS node hash of the reverse record. */ function setNameForAddr( address addr, address owner, address resolver, string memory name ) public override returns (bytes32) { bytes32 node = claimForAddr(addr, owner, resolver); NameResolver(resolver).setName(node, name); return node; }
NameResolver(resolver).setName only sets the record:
/** * Sets the name associated with an ENS node, for reverse records. * May only be called by the owner of that node in the ENS registry. * @param node The node to update. */ function setName(bytes32 node, string calldata newName) virtual external authorised(node) { names[node] = newName; emit NameChanged(node, newName); }
Consider removing the First updates the resolver to the default reverse resolver if necessary
part.
Assert will consume all the available gas, providing no additional benefits when being used instead of require, which both returns gas and allows for error message.
nameLength() and labelCount() use assert:
function nameLength(bytes memory self, uint offset) internal pure returns(uint) { uint idx = offset; while (true) { assert(idx < self.length);
function labelCount(bytes memory self, uint offset) internal pure returns(uint) { uint count = 0; while (true) { assert(offset < self.length);
Using assert in production isn't recommended, consider substituting it with require in all the cases.
To be PARENT_CANNOT_CONTROL
:
* @param fuses fuses to burn (cannot burn PARENT_CANOT_CONTROL)
π Selected for report: 0xKitsune
Also found by: 0x040, 0x1f8b, 0x29A, 0xNazgul, 0xNineDec, 0xsam, 8olidity, Aussie_Battlers, Aymen0909, Bnke0x0, CRYP70, Ch_301, Chom, Deivitto, Dravee, ElKu, Fitraldys, Funen, GimelSec, IllIllI, JC, JohnSmith, Lambda, MiloTruck, Noah3o6, RedOneN, ReyAdmirado, Rohan16, Rolezn, Ruhum, Sm4rty, TomJ, Tomio, Waze, _Adam, __141345__, ajtra, ak1, arcoun, asutorufos, benbaessler, brgltd, bulej93, c3phas, cRat1st0s, cryptonue, delfin454000, durianSausage, fatherOfBlocks, gogo, hake, hyh, joestakey, karanctf, kyteg, lcfr_eth, lucacez, m_Rassska, rajatbeladiya, rbserver, robee, rokinot, sach1r0, sahar, samruna, sashik_eth, seyni, simon135, zuhaibmohd
39.8559 USDC - $39.86
upgradeContract
is a storage variable that is being directly read multiple times:
function setUpgradeContract(INameWrapperUpgrade _upgradeAddress) public onlyOwner { if (address(upgradeContract) != address(0)) { registrar.setApprovalForAll(address(upgradeContract), false); ens.setApprovalForAll(address(upgradeContract), false); } upgradeContract = _upgradeAddress; if (address(upgradeContract) != address(0)) { registrar.setApprovalForAll(address(upgradeContract), true); ens.setApprovalForAll(address(upgradeContract), true); } }
Consider introducing memory variable instead of reading storage
function setUpgradeContract(INameWrapperUpgrade _upgradeAddress) public onlyOwner { + address _upgradeContract = address(upgradeContract); if (_upgradeContract != address(0)) { ... }