How to Mitigate Access Control Vulnerability
Introduction
In August 2021, Poly Network, a decentralized finance platform, was hacked for $611 million. The attacker exploited an access control vulnerability between two important Poly smart contracts to gain control of funds. You may be asking what access control is in a smart contract, especially in Solidity.
Access control in Solidity smart contracts means controlling who can do certain things with the contract based on their permission. This is important for smart contracts. It decides who can make new tokens, vote on ideas, make withdrawals, stop transfers, and more.
So, it’s crucial to understand how to set it up properly, or attackers will take advantage of your system.
What is an Access Control Vulnerability?
An Access control vulnerability is a type of security flaw that allows users without permission to interact with and alter data or functions in a smart contract. This vulnerability can occur when there is a loophole in access restrictions and role assignment in a Solidity smart contract.
A lot of decentralized projects have lost tons of money due to this simple security issue. But this security risk can be prevented. In the next section below, we will go over some mitigation strategies and examples.
Mitigation Strategies and Examples
- Use the Require statement effectively:
// This contract is vulnerable to an access control vulnerability
contract AccessControlVulnerable {
mapping(address => uint256) private balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint256 amount) public {
// Missing require statement to check balance
uint256 balance = balances[msg.sender];
// No check for sufficient balance before withdrawal
balances[msg.sender] -= amount;
payable(msg.sender).transfer(amount);
}
function getBalance() public view returns (uint256) {
return balances[msg.sender];
}
}
Looking at the contract above, you see that the withdraw
function has a flaw, an access control vulnerability because it doesn’t include arequire
statement to check if the sender has sufficient balance before allowing the withdrawal.
This means that anyone can withdraw any amount, even if they don’t have enough balance in their account.
How do we fix this vulnerability? By including arequire
statement to check if the sender has sufficient balance before allowing the withdrawal.
Solution
// This contract has improved access control
contract AccessControlImproved {
mapping(address => uint256) private balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint256 amount) public {
require(amount <= balances[msg.sender], "Insufficient balance");
balances[msg.sender] -= amount;
payable(msg.sender).transfer(amount);
}
function getBalance() public view returns (uint256) {
return balances[msg.sender];
}
}
- Utilize Access Modifiers:
contract AccessControlVulnerability {
address public owner;
uint256 public secretNumber;
constructor(uint256 _initialNumber) {
owner = msg.sender;
secretNumber = _initialNumber;
}
function setSecretNumber(uint256 _newNumber) public {
// No access modifier, so anyone can change the secretNumber
secretNumber = _newNumber;
}
function getSecretNumber() public view returns (uint256) {
return secretNumber;
}
}
Observe the example above: thesetSecretNumber
function doesn’t have an access modifier like,onlyOwner
which means anyone can call it to change the secretNumber
. This is a vulnerability because the contract’s intention was likely to allow only the owner to modify the secret number, but due to the absence of access control, anyone can change it.
Here is a solution:
contract SecureAccessControl {
address public owner;
uint256 public secretNumber;
constructor(uint256 _initialNumber) {
owner = msg.sender;
secretNumber = _initialNumber;
}
modifier onlyOwner() {
require(msg.sender == owner, "Only the owner can call this function");
_;
}
function setSecretNumber(uint256 _newNumber) public onlyOwner {
secretNumber = _newNumber;
}
function getSecretNumber() public view returns (uint256) {
return secretNumber;
}
}
Now we’ve updated the contract, the onlyOwner
modifier has been added. This modifier checks if the caller of a function is the contract owner before allowing the function to proceed. By adding this modifier to the setSecretNumber
function, we ensure that only the owner of the contract can change the secret number.
- Use OpenZepplin Access Control Interface:
The contract below is a VotingSystem contract, and it lacks proper access control for the vote function. This means that any address can call the vote function multiple times, evading any limitations on the number of votes a single user should be allowed to cast. This stands as a flaw in the voting system, as it doesn’t prevent unauthorized or over-voting.
// vulnerable contract
contract VotingSystem {
mapping(address => bool) private hasVoted;
uint256 public totalVotes;
constructor() {
totalVotes = 0;
}
function vote() public {
// No access control check; anyone can vote multiple times
require(!hasVoted[msg.sender], "You have already voted");
hasVoted[msg.sender] = true;
totalVotes++;
}
function getVoteStatus(address voter) public view returns (bool) {
return hasVoted[voter];
}
}
The vulnerability in the VotingSystem contract can be mitigated by using an access control interface from OpenZepplin
//secured contract
import "@openzeppelin/contracts/access/Ownable.sol";
contract SecureVotingSystem is Ownable {
mapping(address => bool) private hasVoted;
uint256 public totalVotes;
constructor() {
totalVotes = 0;
}
function vote() public onlyOwner {
require(!hasVoted[msg.sender], "You have already voted");
hasVoted[msg.sender] = true;
totalVotes++;
}
function getVoteStatus(address voter) public view returns (bool) {
return hasVoted[voter];
}
}
You can see that we’ve imported the Ownable
contract from OpenZeppelin, which provides a simple access control system. By using the onlyOwner
modifier, we ensure that only the contract’s owner (deployer) can call the vote function. This prevents unauthorized users from voting multiple times and implements proper access control over the voting process.
- Implement a Well-Designed Role-Based Access Control System:
Role-based access control is a system where different roles are assigned to users or entities and access permissions are granted based on these roles. Implementing a well-designed role-based access control system can greatly enhance the security of a smart contract by preventing unauthorized access, and minimizing the impact of access control vulnerabilities.
For a clearer understanding, let’s look at the example below:
Vulnerable contract:
pragma solidity ^0.8.0;
// Vulnerable Contract - LendingData.sol
contract LendingData {
address public owner;
constructor() {
owner = msg.sender;
}
// Vulnerable function allowing the owner to transfer ownership
function transferOwnership(address newOwner) public {
require(msg.sender == owner, "Only the owner can transfer ownership");
owner = newOwner;
}
// Other functions...
}
Solution Contract:
pragma solidity ^0.8.0;
// Solution Contract - LendingData.sol
contract LendingDataSolution {
address public owner;
mapping(address => bool) public managers; // Additional role
constructor() {
owner = msg.sender;
managers[msg.sender] = true; // Assign the creator as manager
}
modifier onlyOwner() {
require(msg.sender == owner, "Only the owner can call this function");
_;
}
modifier onlyManager() {
require(managers[msg.sender], "Only managers can call this function");
_;
}
// Functions with proper access control
function transferOwnership(address newOwner) public onlyOwner {
owner = newOwner;
}
function addManager(address newManager) public onlyOwner {
managers[newManager] = true;
}
// Other functions...
}
From the two contracts above, you will notice that In the vulnerable contract, the LendingData
contract allows only the owner to transfer ownership and perform other functions. This is an example of improper role-based access control because it gives too much authority to the owner, which might lead to security issues if the owner’s account is compromised.
The solution contract, LendingDataSolution, implements a more full-bodied access control mechanism. It introduces the concept of managers as an additional role.
The owner can still perform critical actions, but the ability to add managers allows the owner to delegate specific responsibilities without giving away full control. The onlyOwner
and onlyManager
modifiers ensure that only the appropriate roles can call specific functions, reducing potential security risks.
This solution highlights the essence of applying proper access control policies and avoiding a single point of authority.
- Whitelisting:
When it comes to mitigating access control-related vulnerabilities, whitelisting is another essential technique. Whitelisting is used to specify a list of addresses or entities that are allowed to perform certain actions or access specific resources in a smart contract. By doing this, only those addresses that have been pre-approved have access, thereby reducing the attack surface and potential vulnerabilities.
For instance, let’s consider an NFT marketplace that allows users to interact with a smart contract to create and manage digital collectables (NFTs). Here’s a vulnerable contract where anyone can mint NFTs without proper access control:
In this vulnerable contract, the mintNFT
function doesn’t have any access control system. Anyone can call this function and mint new NFTs, which could lead to unauthorized or malicious minting in this situation.
// Vulnerable Contract - Collectibles.sol
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
contract Collectibles is ERC721 {
uint256 public tokenIdCounter;
constructor() ERC721("Collectibles", "COLL") {}
function mintNFT(address to) public {
tokenIdCounter++;
_mint(to, tokenIdCounter);
}
}
Now let’s use whitelisting to stop unauthorized minting and maintain control over who can mint NFTs:
// Solution Contract - Collectibles.sol
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract CollectiblesSolution is ERC721, Ownable {
uint256 public tokenIdCounter;
mapping(address => bool) public whitelistedMinters;
constructor() ERC721("Collectibles", "COLL") {}
function mintNFT(address to) public onlyWhitelistedMinter {
tokenIdCounter++;
_mint(to, tokenIdCounter);
}
function addToWhitelist(address minter) public onlyOwner {
whitelistedMinters[minter] = true;
}
function removeFromWhitelist(address minter) public onlyOwner {
whitelistedMinters[minter] = false;
}
modifier onlyWhitelistedMinter() {
require(whitelistedMinters[msg.sender], "Only whitelisted minters can call this function");
_;
}
}
So, in the solution contract, we’ve used whitelisting by maintaining a mapping of addresses that are authorized to mint NFTs. The mintNFT
function now has the onlyWhitelistedMinter
modifier, which makes sure that only addresses on the whitelist can call this function. Additionally, the contract owner can manage the whitelist using the addToWhitelist
and removeFromWhitelist
functions.
Conclusion
To wrap everything up, access control vulnerabilities are a very popular attack vector used by hackers to exploit smart contracts, and it is important we manage access and permissions in smart contracts effectively. Using mitigation techniques like Role-Based Access Control Systems, access modifiers, OpenZeppelin access control interfaces, require statements, and whitelisting can go a long way toward minimizing the risk of being hacked.
function getSecretNumber() public view returns (uint256) {
return secretNumber;
}
}