Broken Access Control in Smart Contracts
Part 1
The objective of this paper is to provide a detailed understanding of the broken access control vulnerability in smart contracts and ways to mitigate it.
Introduction to Broken Access Control
Generally, a broken access control vulnerability often occurs when resources are not protected through proper configurations or when protection mechanisms are not enforced correctly. in smart contracts broken access control allows unauthorized users to access and manipulate data or functions that they shouldn’t be able to. This can have serious consequences, such as theft of funds, manipulation of data, or even complete compromise of the smart contract.
Here are some examples of how broken access control can be exploited in smart contracts:
- An attacker could exploit a vulnerability in a smart contract to steal funds from the contract.
- An attacker could exploit a vulnerability in a smart contract to change the owner of a digital asset.
- An attacker could exploit a vulnerability in a smart contract to manipulate the outcome of a vote.
Broken access control in smart contracts occurs in the following seven ways:
1. Authorization Through tx.origin
2. Unprotected Ether Withdrawal
3. Unprotected Selfdestruct Instruction
4. Delegatecall to Untrusted Callee
5. Write to Arbitrary Storage Location
6. Hash Collisions With Multiple Variable Length Arguments
7. Unencrypted Private Data On-Chain
in this section, we will describe items 1 to 4.
Authorization Through tx.origin
tx.origin
is a global variable in Solidity which returns the address of the account that sent the transaction. Nonetheless, it is distinct from msg.sender
, which provides the address of the immediate caller of a function. In specific situations, malicious actors can exploit the tx.origin
variable. They may employ phishing attacks to deceive users into performing authenticated actions on susceptible contracts that depend on tx.origin
for user authentication and access management. This enables the attacker to carry out actions on the vulnerable contract that would otherwise be beyond their capabilities, such as withdrawing funds or altering the contract’s state.
Attack scenario
To illustrate the attack scenario more effectively, I developed two separate contracts: ChainWallet
and ChainAttack
as you can see below.
contract ChainWallet {
address public owner;
constructor() payable {
owner = msg.sender;
}
function transfer(address payable _to, uint _amount) public {
require(tx.origin == owner);
(bool sent, ) = _to.call{value: _amount}("");
require(sent, "Failed to send Ether");
}
}
contract ChainAttack {
address payable public owner;
ChainWallet cwallet;
constructor(ChainWallet _cwallet) {
cwallet = ChainWallet(_cwallet);
owner = payable(msg.sender);
}
function attack() public {
cwallet.transfer(owner, address(cwallet).balance);
}
}
In this hypothetical scenario, we have two Ethereum accounts: “Owner” and “Attacker”. The owner has created a smart contract called ChainWallet
, which includes a transfer
function that validates whether the tx.origin
matches the contract owner instead of using msg.sender
for verification. The attacker discovers the security loophole and creates a phishing smart contract called ChainAttack
. Now, if the owner of the ChainWallet
contract sends a transaction with enough gas to the ChainAttack
address, it will invoke the fallback function, which in turn calls the transfer
function of the ChainWallet
contract with the parameter attacker.
Due to the fact that the original call originated from the victim (i.e., the owner of the ChainWallet
contract), all funds within the contract are subsequently transferred to the attacker's address. Since the transaction originated from the owner, the tx.origin
check will pass.
The steps involved in the attack unfold as shown below.
How to protect
To mitigate Tx Origin attacks effectively, it is recommended to avoid using tx.origin
for authentication purposes. Instead, using msg.sender
.
contract ChainWallet {
address public owner;
constructor() payable {
owner = msg.sender;
}
function transfer(address payable _to, uint _amount) public {
require(msg.sender == owner, "Only the owner can perform this action."); // Only the owner can transfer funds.
require(_to.balance >= _amount, "Recipient does not have enough balance."); // Recipient should have enough balance to receive the amount.
require(_to.call{value: _amount}(""), "Failed to send Ether"); // Transfer the amount to the recipient.
}
}
Unprotected Ether Withdrawal
The “Unprotected Ether Withdrawal” attack in smart contracts refers to a vulnerability where a smart contract allows unauthorized access to the contract’s funds, enabling an attacker to withdraw ether from the contract without appropriate checks and balances. This vulnerability can result from the lack of access control mechanisms, such as inadequate permission checks or missing modifiers in the contract code. Here’s an example of a vulnerable smart contract with an unprotected ether withdrawal.
pragma solidity ^0.8.0;
contract VulnerableChainContract {
mapping(address => uint) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint amount) external {
require(balances[msg.sender] >= amount, "Insufficient balance");
balances[msg.sender] -= amount;
payable(msg.sender).transfer(amount);
}
}
In the above example, the withdraw
function allows any address to withdraw funds as long as they have a sufficient balance, but it lacks proper checks to ensure that only authorized users can withdraw funds. This means any address can call the withdraw
function and drain the contract's ether balance.
How to protect
To address the “Unprotected Ether Withdrawal” vulnerability, access control mechanisms should be implemented in the smart contract to ensure that only authorized users or contracts can withdraw funds. This can be achieved by using modifiers or access control lists to restrict the withdrawal functionality to specific addresses or roles.
To mitigate this vulnerability, we can add some access control mechanisms to the code.
pragma solidity ^0.8.0;
contract SecureChainContract {
mapping(address => uint) public balances;
mapping(address => bool) public isOwner; // Assume owners are pre-defined
modifier onlyOwner {
require(isOwner[msg.sender], "Unauthorized");
_;
}
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint amount) external onlyOwner {
require(balances[msg.sender] >= amount, "Insufficient balance");
balances[msg.sender] -= amount;
payable(msg.sender).transfer(amount);
}
}
In this modified example, the onlyOwner
modifier is added to the withdraw
function to ensure that only addresses marked as owners are allowed to initiate withdrawals, thereby protecting the contract from unauthorized ether withdrawals.
Unprotected Selfdestruct Instruction
The “Unprotected Selfdestruct Instruction” is a vulnerability in Ethereum smart contracts that can lead to the loss of funds or the disruption of contract functionality. The vulnerability arises when a contract includes a selfdestruct instruction without appropriate checks and balances, allowing anyone to trigger the destruction of the contract and potentially redirect its remaining Ether balance to unintended recipients. Here’s an example of a vulnerable smart contract in Solidity that demonstrates the unprotected selfdestruct instruction.
contract VulnerableChainContract {
function selfDestruct() public {
selfdestruct(msg.sender);
}
}
When the selfdestruct
() function is unprotected, any user, regardless of their role or permissions, can call this function, potentially leading to disastrous consequences. For instance, a malicious actor could exploit this vulnerability to destroy a contract holding a significant amount of Ether or other digital assets, causing significant financial losses to users or the contract's owners.
How to protect
To mitigate the risk of the “Unprotected Selfdestruct Instruction” vulnerability, it is crucial to implement robust access control measures around the selfdestruct
() function. This can be achieved by restricting access to the function based on specific conditions, such as requiring approval from multiple authorized parties or ensuring that the contract has a non-zero balance before self-destruction is allowed.
In some cases, it may be advisable to completely remove the selfdestruct
() function from the contract if it is not absolutely necessary. This eliminates the possibility of unauthorized self-destruction altogether, enhancing the overall security of the contract. The code below shows how to protect the selfdestruct
() function using a multisig scheme.
contract SecureChainContract {
mapping(address => bool) public approvedOwners;
constructor() {
approvedOwners[msg.sender] = true;
}
modifier onlyMultiSig() {
require(approvedOwners[msg.sender], "Only authorized owners can self-destruct.");
_;
}
function selfDestruct() public onlyMultiSig {
// Implement self-destruction logic here
}
}
Delegatecall to Untrusted Callee
The “Delegatecall to Untrusted Callee” vulnerability refers to a security flaw that arises when a smart contract delegates execution to an external contract (callee) without proper validation or authorization. This vulnerability allows malicious actors to inject malicious code into the calling contract through the callee contract, potentially leading to unauthorized access, theft of funds, or even complete destruction of the calling contract.
To understand the vulnerability, it’s essential to grasp the concept of delegatecall, a special type of message call in Solidity. While a regular message call sends data to an external contract and waits for a response, a delegatecall executes the called contract’s bytecode directly within the context of the calling contract. This means that the callee contract has full access to the calling contract’s storage, balance, and other state variables.
The crux of the vulnerability lies in the fact that the calling contract implicitly trusts the callee contract when using delegatecall. It assumes that the callee code is safe and won’t execute any malicious operations. However, if the callee contract is malicious or has been tampered with, it can exploit this trust to inject harmful code into the calling contract.
Consider the following example.
pragma solidity ^0.4.24;
contract TrustedCaller {
address owner;
constructor() public {
owner = msg.sender;
}
function delegateCallUntrusted (address UntrustedContract , bytes _data) public {
require(UntrustedContract.delegatecall(_data));
}
}
contract UntrustedContract {
address public target;
function maliciousFunction() public {
// Malicious code to modify target's state
}
}
In this example, the TrustedCaller
contract contains a function called delegateCallUntrusted
that's meant to delegate control to an external contract. However, it doesn't properly validate whether the callee contract (in this case, UntrustedContract
) can be trusted. If UntrustedContract
contains a function like maliciousFunction
that can modify the state of the target contract in unexpected ways, calling delegateCallUntrusted
with an instance of UntrustedContract
could lead to undesired state modifications in the target contract, potentially leading to security risks.
How to protect
Be careful when using delegatecall, and always make sure not to call untrusted contracts. If the target address comes from user input, be certain to verify it against a list of trusted contracts. The code below shows how to protect against “Delegatecall to Untrusted Callee” vulnerability.
pragma solidity ^0.4.24;
contract TrustedCaller {
address callee;
address owner;
modifier onlyOwner {
require(msg.sender == owner);
_;
}
constructor() public {
callee = address(0x0);
owner = msg.sender;
}
function setCallee(address newCallee) public onlyOwner {
callee = newCallee;
}
function delegateCalltrusted (bytes _data) public {
require(callee.delegatecall(_data));
}
}
This code attempts to prevent this vulnerability by introducing a level of control over the callee that can be set only by the owner of the contract. It has a modifier onlyOwner
that restricts access to certain functions to only the owner of the contract. The setCallee
function allows the owner to set the address of the trusted callee. So, the delegateCalltrusted
function performs the delegatecall
to the trusted callee.