Quantstamp reviewed ZNS Token Upgrade (Zero Name Service (ZNS)) on 17 Nov 2023: 18 findings, 0 critical; 10 marked fixed, 7 acknowledged, 1 open.
Report hosted on certificate.quantstamp.com.
Trustblock does not review code.
Statuses are the security firm’s own, from its report.
| Severity | Open | Acknowledged | Fixed |
|---|---|---|---|
| Critical | 0 | 0 | 0 |
| High | 0 | 0 | 1 |
| Medium | 0 | 3 | 3 |
| Low | 1 | 4 | 6 |
The `ZNSRootRegistrar.registerRootDomain()` function does not validate if the provided name contains a dot. Consequently, if a user has registered a domain like `0://wilder`, another user could register `0://wilder.fundme`, which might mislead users into believing it is a legitimate subdomain of the original domain. This impersonation risk arises because the genuine subdomain registration logic resides solely in the `ZNSSubRegistrar.sol` contract. Furthermore, this oversight in validation extends to the `ZNSSubRegistrar.registerSubdomain()` function. An attacker can register multi-level subdomains all at once. As an example, given the registered domain `0://wilder`, an attacker could directly register a third-level subdomain like `0://wilder.2ld.3ld.4ld` without sequentially registering `0://wilder.2ld` and `0://wilder.2ld.3ld`.
The `getPrice()` function within the ZNSCurvePricer contract calculates the domain's price based on the `CurvePriceConfig`. If no pricing configuration is set for a domain, the system may inadvertently return a price of zero due to the default behavior of Solidity which initializes struct values to zero. When a user attempts to register a subdomain, the `ZNSSubRegistrar.registerSubdomain()` function, present in an external contract, invokes the `getPrice()` function. If the domain pricing configuration is missing or not correctly defined, the domain may be registered for free.
The `ZNSRootRegistrar.registerRootDomain()` function and `ZNSSubRegistrar.registerSubdomain()` subdomain registration function does not account for domain name string variations, especially concerning character casing. This lack of validation means that domain names, which appear identical after normalization, are recognized as distinct entities on-chain due to their differing hash representations. For instance, both `wilder` and `wIlder` can be registered as separate domains. However, when client-side normalization interprets both names as `wilder`, both domains could resolve to the same address, creating potential impersonation risks.
The `ZNSCurvePricer._getPrice()` function is designed to determine the price of domain labels based on their lengths, following an asymptotic decline in price as length increases. However, there is a flaw in the function `_validateConfig`. While the function is intended to return a price no less than the `minPrice`, an edge case exists when the domain length is exactly `maxLength`. This issue emerges because the validation function `_validateConfig` only checks the price at `maxLength - 1`, assuming the price at `maxLength` would always be above `minPrice`. Therefore, the returned price might be less than the specified `minPrice` and the protocol might unintentionally charge users less than the intended `minPrice` when they acquire domains of exact length `maxLength`. This can lead to potential revenue loss for the domain owner.
The `ZNSSubRegistrar` contract provides functionality for a domain owner/operator to update the resolver contract for a specific domain hash via the `ZNSRegistry.updateDomainResolver()` or `ZNSRegistry.updateDomainRecord()` function. If manipulated by a malicious owner or operator, they could redirect requests to a malevolent resolver that hijacks the intended flow of external contracts trying to resolve a ZNS domain address. Although the integrity of the protocol is not directly compromised, any third-party systems relying on the external resolver are susceptible to unexpected and potentially malicious behaviors. If users adhere to the `IZNSAddressResolver` interface, which mandates that `getAddress()` is a view function, the adverse potential of this issue is limited but still possible. The designation of `getAddress()` as a view function means the contract resolving the domain will use the `STATICCALL` opcode, which ensures that the state cannot be altered during the call. However, external protocols are not guaranteed to use `IZNSAddressResolver`.
The protocol permits domain owners to designate a beneficiary address for payments associated with a specific domain, identified by its `domainHash` in the `ZNSTreasury` through functions `setPaymentConfig()` and `setBeneficiary()`. However, if a beneficiary is not set for a `domainHash`, funds sent to purchase a subdomain under this parent `domainHash` will be directed to a zero address. While OpenZeppelin's ERC20 implementation includes checks against transfers to a zero address, the treasury is designed to accept any ERC20 tokens, which might not have such safeguards.
All privileged permissions should be clearly documented for users. The privileged permissions of the protocol contract are documented below. <br> The following are cases of `GOVERNOR_ROLE` privileges: - The `UUPS` function `upgradeToAndCall()` allows governors to update the implementation used and invoke a call in upgradeable contracts. - The `UUPS` function `upgradeTo()` allows governors to update the implementation used in upgradeable contracts. - The governors can grant `GOVERNOR_ROLE` to addresses. - The governors can grant `ADMIN_ROLE` to addresses. - The governors can grant any role to any address through the function `ZNSAccessController.setRoleAdmin()`. <br> The following are cases of `ADMIN_ROLE` privileges: - The function `setRegistry()` allows admins to update the `registry` address for contracts inheriting `ARegistryWired`: - `ZNSCurvePricer` - `ZNSRootRegistrar` - `ZNSSubRegistrar` - `ZNSAddressResolver` - `ZNSDomainToken` - `ZNSTreasury` - The function `ZNSRootRegister.setRootPricer()` allows admins to update the `pricer` contract used to determine pricing for root domains. - The function `ZNSRootRegister.setTreasury()` allows admins to update the `ZNSTreasury` contract used to store protocol fees and staked funds. - The function `ZNSRootRegister.setDomainToken()` allows admins to update the domain token contract used to validate domain ownership. - The function `ZNSRootRegister.setSubRegistrar()` allows admins to update the subdomain registrar contract. - The function `ZNSRootRegister.setAddressResolver()` allows admins to update the root domain resolver. - The admins can grant `REGISTRAR_ROLE` to addresses. - (Fix Review) The function `ZNSRegistry.addResolverType()` allows admins to add new resolver types to the mapping of resolver types and addresses. - (Fix Review) The function `ZNSRegistry.deleteResolverType()` allows admins to delete resolver types to the mapping of resolver types and addresses. <br> The following are cases of `REGISTRAR_ROLE` privileges: - The function `ZNSRootRegister.coreRegister()` allows registrars to register domains. - The function `ZNSRegistry.createDomainRecord()` allows registrars to register domain records which track ownership and address solver. - The function `ZNSDomainToken.register()` allows registrars to mint tokens which are used to validate domain ownership. - The function `ZNSDomainToken.revoke()` allows registrars to burn tokens to revoke domain ownership. - The function `ZNSTreasury.stakeForDomain()` allows registrars to process registration fee to beneficiaries and stake domain funds in the treasury. The staked funds are returned to the domain owner when the domain is revoked. - The function `ZNSTreasury.unstakeForDomain()` allows registrars to unstake domain registration funds in the treasury during the domain revocation process. - The function `ZNSTreasury.processDirectPayment()` allows registrars to process registration fees to beneficiaries directly. Based on documentation and discussion with the client, the `REGISTRAR_ROLE` is reserved for contracts `ZNSRootRegistrar` and `ZNSSubRegistrar` only. <br> The role `EXECUTOR_ROLE` does not have any privileges. Based on the code documentation, this role may be used for future implementations.
The implementation contracts may not consistently ensure their initializer is disabled. This vulnerability allows malicious actors to call the `initialize()` function on the implementation contracts. Though the `initialize()` function is guarded by the "initializer" modifier to ensure it is invoked only once, this protection is valid only from the proxy contract's storage layout. The logic contract remains uninitialized from its direct perspective because `initialize()` is not directly activated within it. Consequently, unauthorized entities can call the `initialize()` function and alter state variables of the implementation contract.
When a domain is revoked via `ZNSRootRegistrar.revokeDomain()`, several actions occur: the access type is set to `AccessType.LOCKED`, its token is burnt, its registry record is removed, and staked funds, if any, are returned to the domain beneficiary. However, if a new owner purchases this domain and sets the `AccessType` to `MINTLIST`, the protocol will default to the previous owner's mintlist configuration, which is not cleared during revocation. The new domain owner, unaware, can be constrained by the previous owner's access settings until they use `updateMintlistForDomain()` to clear the previous allowance.
Every Solidity file specifies in the header a version number of the format `pragma solidity (^)0.8.*`. The caret (`^`) before the version number implies an unlocked pragma, meaning that the compiler will use the specified version *and above*, hence the term "unlocked".
The report names a repository or files. Open the report to see its scope.
Repository and files the report names (129)
236 projects reviewed · 332 reports · latest Oct 2026
See Quantstamp’s auditsSomething wrong on this page? A missing report, a wrong date, a finding that was fixed since. Tell us and we check it against its source.
Tell us