• YouTube Channel
  • System Status
  • VS Code Extension
  • Owner as Single Point of Failure

    Overview

    What is the Owner as Single Point of Failure vulnerability?

    In Solidity smart contracts, developers may wish to restrict certain functionality to a privileged user rather than allowing any other smart contract or externally owned account to use those functions. A common pattern for this is using a modifier to restrict access on privileged functions. However, if such a modifier permits only one user to ever call the function, it may be prone to abuse or permanent denial of service if the sole owner's private key is compromised or lost.

    Further reading: OpenZeppelin Documentation: Ownership

    Technical example of vulnerable code

      // SPDX-License-Identifier: Unlicense
      pragma solidity ^0.8.0;
    
      contract FundManager {
          address public owner;
          mapping(address => uint256) public pendingWithdrawals;
    
          event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);
          event WithdrawalApproved(address indexed beneficiary, uint256 amount);
          event Withdrawal(address indexed beneficiary, uint256 amount);
    
          constructor() {
              owner = msg.sender;
              emit OwnershipTransferred(address(0), msg.sender);
          }
    
          modifier onlyOwner() {
              require(msg.sender == owner, "Caller is not the owner");
              _;
          }
    
          function transferOwnership(address newOwner) public onlyOwner {
              require(newOwner != address(0), "New owner cannot be the zero address");
              emit OwnershipTransferred(owner, newOwner);
              owner = newOwner;
          }
    
          // Function to deposit funds into the contract
          function depositFunds() public payable {}
    
          // Owner approves withdrawal for a beneficiary
          function approveWithdrawal(address beneficiary, uint256 amount) public onlyOwner {
              require(address(this).balance >= amount, "Insufficient funds in contract");
              pendingWithdrawals[beneficiary] += amount;
              emit WithdrawalApproved(beneficiary, amount);
          }
    
          // Beneficiaries can withdraw their approved funds
          function withdraw() public {
              uint256 amount = pendingWithdrawals[msg.sender];
              require(amount > 0, "No funds to withdraw");
    
              pendingWithdrawals[msg.sender] = 0;
              payable(msg.sender).transfer(amount);
              emit Withdrawal(msg.sender, amount);
          }
     
          // additional functionality
      }
    
    

    The above code is a simplified example of a smart contract that manages user-contributed funds which may be allocated to various beneficiaries via the approveWithdrawal() function. This function uses the onlyOwner modifier, which in this case restricts the smart contract to a single owner. If the sole owner's private key were stolen, an attacker could approve a withdrawal of all funds in the contract to themselves as a beneficiary, and if the private key were lost, funds could become stuck in the contract permanently.

    Technical example of how to fix the vulnerability

      // SPDX-License-Identifier: Unlicense
      pragma solidity ^0.8.0;
    
      contract FundManager {
          mapping(address => bool) public isOwner;
          mapping(address => uint256) public pendingWithdrawals;
    
          event OwnershipRegistered(address indexed newOwner);
          event WithdrawalApproved(address indexed beneficiary, uint256 amount);
          event Withdrawal(address indexed beneficiary, uint256 amount);
    
          constructor() {
              isOwner[msg.sender] = true;
              emit OwnershipRegistered(msg.sender);
            }
    
          modifier onlyOwner() {
              require(isOwner[msg.sender], "Caller is not an owner");
              _;
          }
    
          function registerOwner(address newOwner) public onlyOwner {
              require(newOwner != address(0), "New owner cannot be the zero address");
              isOwner[newOwner] = true;
              emit OwnershipTransferred(owner, newOwner);
          }
    
          // Function to deposit funds into the contract
          function depositFunds() public payable {}
    
          // Owner approves withdrawal for a beneficiary
          function approveWithdrawal(address beneficiary, uint256 amount) public onlyOwner {
              require(address(this).balance >= amount, "Insufficient funds in contract");
              pendingWithdrawals[beneficiary] += amount;
              emit WithdrawalApproved(beneficiary, amount);
          }
    
          // Beneficiaries can withdraw their approved funds
          function withdraw() public {
              uint256 amount = pendingWithdrawals[msg.sender];
              require(amount > 0, "No funds to withdraw");
    
              pendingWithdrawals[msg.sender] = 0;
              payable(msg.sender).transfer(amount);
              emit Withdrawal(msg.sender, amount);
          }
     
          // additional functionality
      }
    
    

    In the updated example above, the nature of "ownership" in the contract has changed to no longer be a single stored address. An existing owner may use the registerOwner() function to approve backup addresses or other trusted users as owners. While different concerns around ownership and access control may still be present (and should always be carefully considered), the contract will no longer be susceptible to locked funds on the basis of a single lost key, if backup owner addresses have been registered.