Broken Access Control in Smart Contracts

Part 1

Neda Kheiri
ChainWall
8 min readDec 18, 2023

--

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.

--

--