Start now →

Access Control: Authorization Control Vulnerabilities in Smart Contracts

By Mert Çoban · Published April 29, 2026 · 11 min read · Source: Ethereum Tag
EthereumRegulationSecurity
Access Control: Authorization Control Vulnerabilities in Smart Contracts
Press enter or click to view image in full size

Access Control: Authorization Control Vulnerabilities in Smart Contracts

Mert ÇobanMert Çoban10 min read·Just now

--

Thousands of smart contracts are deployed every day on the EVM (Ethereum Virtual Machine), which is described as “the world’s shared computer.” However, logical vulnerabilities and faulty designs in these contracts pave the way for systems to be “exploited” through various methods.

In July 2017, the initWallet() function in Parity Multisig wallets was left unprotected. An attacker called this function and took ownership of the wallets, and approximately 30 million dollars were seized. Four months later, a user accidentally triggered the same flaw and executed selfdestruct on the shared library contract, and approximately 300 million dollars were permanently locked. In March 2022, 5/9 validator keys were compromised on the Ronin Network. Approximately 625 million dollars were seized.

In my article, I will not be content with a purely theoretical explanation; I will examine the access control vulnerabilities that have caused millions of dollars in losses across the ecosystem through scenarios I have constructed myself. Accompanied by code blocks, I will analyze step by step the anatomy of these vulnerabilities, how they work, and what concrete measures must be taken to protect against this risk, using structures such as AccessControl and onlyOwner.

The content of my article consists of four different access control vulnerabilities, a PoC for each, solutions, proven tests, and an audit checklist.

The contracts were written in the Solidity programming language, and the tests were written using the Foundry framework.

To understand how the attack works, four concepts need to be known.

msg.sender provides the information of which address called the invoked function.

tx.origin provides the information of the address that initiated the entire call chain.

Modifiers are blocks that bind functions to a precondition for them to execute. For example:

modifier onlyOwner() {
require(msg.sender == owner, "Access denied!");
_;
}

Initializer is the function used in place of a constructor in upgradeable contracts that use the proxy pattern.

Section 1: Missing Ownership Check

Below is a simplified vault contract whose code I am sharing for an attack analysis. It contains the 2 fundamental functions expected from a vault: deposit() so that users can deposit their ether, and withdraw() so that they can withdraw the ether they have deposited.

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

contract VaultVulnerable {
function deposit() public payable {}

function withdraw() public {
(bool ok,) = msg.sender.call{value: address(this).balance}("");
require(ok, "Transfer failed");
}
}

In order, VaultVulnerable.sol does the following:

function deposit() public payable {}

The deposit() function is used to send ether to the contract.

function withdraw() public {
(bool ok,) = msg.sender.call{value: address(this).balance}("");
require(ok, "Transfer failed");
}

withdraw() transfers the entire balance in the vault contract, “address(this).balance”, to the calling address, msg.sender.

The vulnerability in the vault contract given in this example stems from the fact that there is no precondition check on who can call the withdraw() function. Regardless of whether they have sent ether or not, any person can call the withdraw() function and withdraw the entire ether balance held by the contract.

Proof through tests:

    function setUp() public {
.......

vm.prank(alice);
vaultVulnerable.deposit{value: 1 ether}();
vm.prank(bob);
vaultVulnerable.deposit{value: 1 ether}();
vm.prank(charlie);
vaultVulnerable.deposit{value: 1 ether}();
.......
}
    function test_VulnerableVault_AnyoneCanDrain() public {
vm.prank(attacker);
vaultVulnerable.withdraw();
assertEq(attacker.balance, 3 ether);
assertEq(address(vaultVulnerable).balance, 0);
}

Specifically for this test, before starting, ether has been given to the users in the setup() function and three ether have been deposited into the contract. The attack is built upon this scenario.

The attacker calls the withdraw() function in the simplified vault contract, which holds three ether, and since there is no check on the function, drains the entire contract.

Press enter or click to view image in full size
test_VulnerableVault_AnyoneCanDrain() output

Section 1: Missing Ownership Check: Solution

The VaultFixed.sol contract in which the vulnerability has been prevented by adding access control:

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

contract VaultFixed {
address public owner;

constructor() {
owner = msg.sender;
}

function deposit() public payable {}

function withdraw() public {
require(owner == msg.sender, "Access denied");
(bool ok,) = owner.call{value: address(this).balance}("");
require(ok, "Transfer failed");
}
}
  1. The owner variable is defined with address public owner;.
  2. Inside the constructor, the address that deploys this contract is assigned to the owner variable.
  3. The precondition for the withdraw() function to execute is added as owner == msg.sender, meaning the person calling the function must be the same as the address that deployed this contract.

This change ensures that the withdraw() function is called by an authorized party, and the vulnerability is prevented.

    function test_FixedVault_OnlyOwnerCanWithdraw() public {
vm.prank(attacker);
vm.expectRevert("Access denied");
vaultFixed.withdraw();
uint256 before = address(this).balance;
vaultFixed.withdraw();
assertEq(address(this).balance - before, 3 ether);
}

The setup operations have been performed in parallel with the previous test scenario given. The attacker wants to call the withdraw() function, but since the deploying address is the address of the test contract, owner has been assigned as msg.sender. The attacker is blocked by the line “require(owner == msg.sender, “Access denied”);” and cannot call the function.

Press enter or click to view image in full size
test_FixedVault_OnlyOwnerCanWithdraw() output

Section 2: tx.origin Phishing

The developer performing the owner assignment with tx.origin instead of msg.sender during the constructor stage gives rise to a vulnerability. This type of attack is frequently encountered not only in smart contracts but also in other areas of information technology. Attackers generally aim to seize users’ information by deceiving them through social engineering methods. Since tx.origin represents the address that initiates the transaction chain, the attacker can exploit the vulnerability through their own contract by directing the deploying address to a function in which this check is performed. In the constructed scenario, the attacker makes the vault owner execute the claimAirDrop() function. This function triggers the withdraw() function, which is checked with tx.origin, and the ether in the vault is transferred to the attacker. Since the withdraw() function verifies the address that initiated the transaction as the deploying address, the relevant condition is satisfied.

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

contract VaultTxOrigin {
address public owner;

constructor() {
owner = msg.sender;
}

function deposit() public payable {}

function withdraw() public {
require(tx.origin == owner, "Access denied");
(bool ok,) = tx.origin.call{value: address(this).balance}("");
require(ok, "Transfer failed");
}
}

The PhishingAttacker.sol contract used for phishing:

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

interface IVault {
function deposit() external payable;
function withdraw() external;
}

contract PhishingAttacker {
IVault vault;

constructor(address _vault) {
vault = IVault(_vault);
}

function claimAirDrop() public payable {
vault.withdraw();
}
}

The vault contract owner, having been phished by the attacker, calls the claimAirDrop() function, and the attack takes place.

Deployer => claimAirDrop() => vault.withdraw() => ether sent to the attacker. (deployer address verified via tx.origin).

Proof through tests:

    function test_TxOrigin_PhishingDrainsVault() public {
vm.prank(dave);
vaultTxOrigin.deposit{value: 1 ether}();

vm.prank(dave, dave);
phishingAttacker.claimAirDrop();

assertEq(dave.balance, 1 ether);
assertEq(address(vaultTxOrigin).balance, 0);
}
  1. Dave sends one ether to the vault.
  2. The phished vault owner calls the claimAirDrop() function.
  3. The vault is drained by the attacker.
Press enter or click to view image in full size
test_TxOrigin_PhishingDrainsVault() output

Section 2: tx.origin Phishing: Solution

The fixed VaultMsgSender.sol contract in which tx.origin is removed and access control is performed with msg.sender:

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

contract VaultMsgSender {
address public owner;

constructor() {
owner = msg.sender;
}

function deposit() public payable {}

function withdraw() public {
require(msg.sender == owner, "Access denied");
(bool ok,) = msg.sender.call{value: address(this).balance}("");
require(ok, "transfer failed");
}
}

The vulnerability has been eliminated by performing the access control with msg.sender instead of tx.origin.

Press enter or click to view image in full size
test_MsgSender_PhishingReverts() output

Section 3: Missing Role Modifier

OpenZeppelin has an abstract contract named AccessControl. Through this contract, specific roles can be assigned to addresses, and the execution of functions can be bound to these role conditions. For example, by authorizing a single address for token minting, the mint() function can be made to be triggered only by this defined address. The aforementioned role management processes provide smart contracts with an advanced security layer.

If roles are created but not distributed or are distributed incompletely, a major vulnerability arises. This is called a missing role modifier. The vulnerable contract, RoleVaultVulnerable.sol:

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

import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";

contract RoleVaultVulnerable is AccessControl {
bytes32 public constant WITHDRAWER_ROLE = keccak256("WITHDRAWER_ROLE");
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");

constructor(address withdrawer, address minter) {
_grantRole(WITHDRAWER_ROLE, withdrawer);
_grantRole(MINTER_ROLE, minter);
_grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
}

function deposit() public payable {}

function withdraw() public {
(bool ok,) = msg.sender.call{value: address(this).balance}("");
require(ok, "Transfer failed");
}

function mint() public onlyRole(MINTER_ROLE) {}
}

The developer defines the “WITHDRAWER_ROLE” role but does not write it as a modifier on the withdraw() function. Due to this omission, anyone can call the withdraw() function.

Proof through tests:


function test_RoleVaultVulnerable_AnyoneCanDrain() public {
vm.prank(eve);
roleVaultVulnerable.deposit{value: 5 ether}();

vm.prank(attacker);
roleVaultVulnerable.withdraw();

assertEq(attacker.balance, 5 ether);
}

Eve sends 5 ether to the contract. The attacker, who has no access role on the contract, drains the vault.

Press enter or click to view image in full size
test_RoleVaultVulnerable_AnyoneCanDrain() output

Section 3: Missing Role Modifier: Solution

The fixed RoleVault.sol contract in which access control is performed by granting WITHDRAWER_ROLE:

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

import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";

contract RoleVault is AccessControl {
bytes32 public constant WITHDRAWER_ROLE = keccak256("WITHDRAWER_ROLE");
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");

constructor(address withdrawer, address minter) {
_grantRole(WITHDRAWER_ROLE, withdrawer);
_grantRole(MINTER_ROLE, minter);
_grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
}

function deposit() public payable {}

function withdraw() public onlyRole(WITHDRAWER_ROLE) {
(bool ok,) = msg.sender.call{value: address(this).balance}("");
require(ok, "Transfer failed");
}

function mint() public onlyRole(MINTER_ROLE) {}
}
Press enter or click to view image in full size
test_RoleVault_OnlyWithdrawerCanWithdraw() output

Section 4: Unprotected Initializer

In upgradeable smart contracts, the constructor structure cannot be used. The constructor is executed only once during deployment and saves the data to the storage area of the implementation contract. Due to this technical constraint, the initialize() function is preferred for the purpose of assigning initial values. However, since this function bears the nature of a standard public function, if it is not protected with appropriate access control mechanisms, it carries the risk of being callable by any external actor.

The InitVaultVulnerable.sol contract in which there is no access control on the initialize() function:

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

import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";

contract InitVaultVulnerable is AccessControl {
function initialize() public {
_grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
}

function deposit() public payable {}

function withdraw() public onlyRole(DEFAULT_ADMIN_ROLE) {
(bool ok,) = msg.sender.call{value: address(this).balance}("");
require(ok, "Transfer failed");
}
}

Since there is no modifier on the initialize() function to provide any access control, it becomes vulnerable to attack.

Proof through tests:

    function test_InitVaultVulnerable_AnyoneCanTakeOwnership() public {
vm.prank(eve);
initVaultVulnerable.initialize();

vm.prank(eve);
initVaultVulnerable.deposit{value: 5 ether}();

vm.prank(attacker);
initVaultVulnerable.initialize();

vm.prank(attacker);
initVaultVulnerable.withdraw();

assertEq(attacker.balance, 5 ether);
assertEq(address(initVaultVulnerable).balance, 0);
}
  1. Eve executes the initialize() function and is assigned as admin.
  2. Eve sends 5 ether to the contract.
  3. Since there is no access control on the initialize() function, the attacker calls initialize() again and is assigned as admin.
  4. The attacker passes the DEFAULT_ADMIN_ROLE check of the withdraw() function and drains the vault.
Press enter or click to view image in full size
test_InitVaultVulnerable_AnyoneCanTakeOwnership() output

Section 4: Unprotected Initializer: Solution

By giving the initializer modifier to the initialize() function, it is prevented from being called again. The fixed InitVaultFixed.sol contract:

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

import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";
import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.sol";

contract InitVaultFixed is AccessControl, Initializable {
function initialize() public initializer {
_grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
}

function deposit() public payable {}

function withdraw() public onlyRole(DEFAULT_ADMIN_ROLE) {
(bool ok,) = msg.sender.call{value: address(this).balance}("");
require(ok, "Transfer failed");
}
}

Due to the use of the initializer modifier, initialize() cannot be called again, and the vulnerability is eliminated.

Press enter or click to view image in full size
test_InitVaultFixed_CannotReinitialize() output

Section 5: My Own Audit Checklist

  1. Is there an explicit access control on all functions that perform fund transfers, ownership changes, parameter updates, mint/burn, and upgrades?
  2. Is msg.sender used in the access control? Is there any use of tx.origin?
  3. If there is role-based access, has the relevant onlyRole modifier been applied to all authorized functions?
  4. In upgradeable contracts, is the initialize() function protected with the initializer modifier? Does the constructor of the implementation contract call _disableInitializers()?
  5. Has the principle of least privilege been applied? Does a single owner hold all authorities, or have the roles been separated?
  6. Have privilege escalation paths been checked? Can a low-privileged role grant itself higher privileges?
  7. Have temporary privileges (deployment helper, migration script) been revoked after they were used?

In conclusion, in order to avoid experiencing access control vulnerabilities, every developer must always think about who should be able to call the function they are writing while writing their contracts. With planned and thoughtfully written lines and the right precautions, contracts can become more secure.

All contracts and Foundry tests used in the article are available in the Github repository. https://github.com/mertcobn/access-control-vulnerabilities

You can follow me for more content on smart contract security:
LinkedIn: https://www.linkedin.com/in/mert-coban/
Mail: [email protected]

In the upcoming articles, I will continue to examine different security vulnerabilities in the same way.

In my previous article, I had examined the reentrancy vulnerability. https://medium.com/@mertcoban/reentrancy-attack_

This article was originally published on Ethereum Tag and is republished here under RSS syndication for informational purposes. All rights and intellectual property remain with the original author. If you are the author and wish to have this article removed, please contact us at [email protected].

NexaPay — Accept Card Payments, Receive Crypto

No KYC · Instant Settlement · Visa, Mastercard, Apple Pay, Google Pay

Get Started →