Skip to main content
BlockchainMar 28, 2026

Deep EVM #4: Security Primitives — msg.sender, Access Control, and Reentrancy

OS
Open Soft Team

Engineering Team

Security at the EVM Level

Smart contract security is not about adding checks on top of working code — it is about understanding how the EVM’s execution model creates attack surfaces. Every external call is a potential reentry point. Every delegatecall is a storage hijack vector. Every unchecked return value is a silent failure.

In this article, we go beyond the Solidity surface to understand the EVM mechanics behind the most devastating vulnerabilities.

msg.sender vs tx.origin

These two values look similar but have fundamentally different security properties.

  • msg.sender (CALLER opcode): The immediate caller of the current execution context. Changes with each CALL.
  • tx.origin (ORIGIN opcode): The externally owned account (EOA) that initiated the transaction. Never changes, regardless of call depth.
// Transaction flow: EOA -> ContractA -> ContractB
//
// Inside ContractA:
//   msg.sender = EOA address
//   tx.origin  = EOA address
//
// Inside ContractB (called by ContractA):
//   msg.sender = ContractA address
//   tx.origin  = EOA address      <-- same!

The tx.origin Attack

Using tx.origin for authentication is a well-known vulnerability:

// VULNERABLE: uses tx.origin for auth
contract Wallet {
    address public owner;

    function transfer(address to, uint256 amount) external {
        require(tx.origin == owner, "Not owner");  // WRONG!
        payable(to).transfer(amount);
    }
}

// Attacker deploys this contract and tricks the owner into calling it:
contract Attack {
    Wallet wallet;

    function attack() external {
        // When owner calls this function:
        // tx.origin = owner (the EOA who signed the transaction)
        // msg.sender = this contract
        wallet.transfer(attacker, wallet.balance);
        // tx.origin check passes because owner initiated the tx!
    }
}

The fix is simple: always use msg.sender, never tx.origin for authentication. In the context of account abstraction (ERC-4337), tx.origin may even be a bundler address, not the user at all.

Reentrancy: The DAO Attack Pattern

Reentrancy is the most infamous smart contract vulnerability — it caused the $60M DAO hack in 2016 and has been exploited repeatedly since. The pattern is simple: an external call returns control to the attacker before state updates are complete.

How It Works at the EVM Level

When a contract executes a CALL opcode, execution transfers to the callee. The callee has its own stack and memory, but crucially, the caller’s storage has not yet been updated if the SSTORE comes after the CALL.

// VULNERABLE: state update after external call
contract VulnerableVault {
    mapping(address => uint256) public balances;

    function withdraw() external {
        uint256 amount = balances[msg.sender];
        require(amount > 0, "No balance");

        // DANGER: external call before state update
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");

        // This line executes AFTER the external call returns
        // But the attacker can re-enter before reaching here!
        balances[msg.sender] = 0;
    }
}

The attacker’s contract receives ETH via the call, which triggers its receive() function. Inside receive(), the attacker calls withdraw() again. Since balances[msg.sender] has not been set to 0 yet, the check passes and the attacker drains the contract.

contract Attacker {
    VulnerableVault vault;

    function attack() external payable {
        vault.deposit{value: 1 ether}();
        vault.withdraw();
    }

    receive() external payable {
        if (address(vault).balance >= 1 ether) {
            vault.withdraw();  // Re-enter!
        }
    }
}

The Checks-Effects-Interactions Pattern

The standard defense is to order operations correctly:

  1. Checks — Validate all conditions (require statements)
  2. Effects — Update all state variables
  3. Interactions — Make external calls last
function withdraw() external {
    uint256 amount = balances[msg.sender];  // Check
    require(amount > 0, "No balance");       // Check
    balances[msg.sender] = 0;                // Effect (BEFORE call)
    (bool success, ) = msg.sender.call{value: amount}("");  // Interaction
    require(success, "Transfer failed");
}

Now if the attacker re-enters, balances[msg.sender] is already 0, so the require fails.

Reentrancy Guards

For complex functions where CEI ordering is difficult, use a reentrancy guard:

// OpenZeppelin-style reentrancy guard
abstract contract ReentrancyGuard {
    uint256 private constant NOT_ENTERED = 1;
    uint256 private constant ENTERED = 2;
    uint256 private _status = NOT_ENTERED;

    modifier nonReentrant() {
        require(_status != ENTERED, "ReentrancyGuard: reentrant call");
        _status = ENTERED;
        _;
        _status = NOT_ENTERED;
    }
}

Notice that _status uses values 1 and 2, never 0. This is a gas optimization: changing a nonzero slot to a different nonzero value costs 2900 gas, while changing from zero to nonzero costs 20000 gas.

Transient Storage Reentrancy Guards (EIP-1153)

With transient storage, reentrancy guards become dramatically cheaper:

modifier nonReentrant() {
    assembly {
        if tload(0) { revert(0, 0) }
        tstore(0, 1)
    }
    _;
    assembly {
        tstore(0, 0)
    }
}
// Cost: ~200 gas vs ~5000 gas for storage-based guard

Cross-Function Reentrancy

Reentrancy is not limited to re-entering the same function. An attacker can call a different function on the same contract that reads stale state:

contract CrossFunctionVuln {
    mapping(address => uint256) public balances;

    function withdraw() external {
        uint256 amount = balances[msg.sender];
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success);
        balances[msg.sender] = 0;
    }

    function transfer(address to, uint256 amount) external {
        // Attacker re-enters HERE during withdraw
        // balances[msg.sender] still has the old value!
        require(balances[msg.sender] >= amount);
        balances[msg.sender] -= amount;
        balances[to] += amount;
    }
}

DELEGATECALL: Power and Peril

DELEGATECALL executes another contract’s code in the context of the calling contract. The callee’s code reads and writes the caller’s storage, and msg.sender/msg.value are preserved from the original call.

This is the foundation of proxy patterns (ERC-1967, UUPS, Transparent Proxy) that enable upgradeable contracts. But it is also incredibly dangerous.

Storage Collision Attack

// Proxy contract (simplified)
contract Proxy {
    address public implementation;  // slot 0
    address public admin;           // slot 1

    fallback() external payable {
        (bool s, ) = implementation.delegatecall(msg.data);
        require(s);
    }
}

// Implementation contract
contract Implementation {
    uint256 public value;  // slot 0 -- COLLIDES with implementation address!

    function setValue(uint256 _v) external {
        value = _v;  // This overwrites the proxy's implementation address!
    }
}

When setValue executes via DELEGATECALL, value writes to slot 0 of the Proxy — which is the implementation address. The attacker can set the implementation to their own contract and take over the proxy.

The fix: use unstructured storage with pseudo-random slots (ERC-1967 standard):

bytes32 constant IMPLEMENTATION_SLOT = bytes32(uint256(
    keccak256("eip1967.proxy.implementation")) - 1
);
// = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc

DELEGATECALL and msg.sender Preservation

In a DELEGATECALL chain:

User -> Proxy (DELEGATECALL) -> Implementation
// Inside Implementation:
//   msg.sender = User (preserved!)
//   address(this) = Proxy (executing in Proxy's context)
//   storage reads/writes = Proxy's storage

This means the implementation contract must never assume address(this) is its own deployment address. Any selfdestruct, balance check, or address comparison will reference the proxy.

Access Control Patterns

Ownable

The simplest pattern: a single owner address with privileged access.

contract Ownable {
    address public owner;
    error NotOwner();

    modifier onlyOwner() {
        if (msg.sender != owner) revert NotOwner();
        _;
    }
}

Limitations: single point of failure, no granularity.

Role-Based Access Control (RBAC)

OpenZeppelin’s AccessControl provides multi-role management:

bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");

function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) {
    _mint(to, amount);
}

At the EVM level, role checks are mapping(bytes32 => mapping(address => bool)) storage lookups — two keccak256 hash computations and an SLOAD per check.

Multisig and Timelock

For high-value operations, combine RBAC with:

  • Multisig — Require M-of-N signatures (Gnosis Safe pattern)
  • Timelock — Delay execution to allow community review (Compound’s Timelock pattern)

Return Value Checks

The CALL opcode pushes 1 (success) or 0 (failure) onto the stack. If you do not check this return value, a failed call is silently ignored:

// DANGEROUS: unchecked low-level call
(bool success, ) = target.call(data);
// If success is false and you don't check, execution continues!

// SAFE: always check
(bool success, ) = target.call(data);
require(success, "Call failed");

This also applies to ERC-20 tokens. Some tokens (like USDT) do not return a boolean on transfer(). Use OpenZeppelin’s SafeERC20 or check return data length:

(bool success, bytes memory data) = token.call(
    abi.encodeWithSelector(IERC20.transfer.selector, to, amount)
);
require(success && (data.length == 0 || abi.decode(data, (bool))));

Integer Overflow and Underflow

Solidity 0.8+ includes automatic overflow/underflow checks. Before 0.8, and in raw Yul/assembly, arithmetic silently wraps:

// In Yul, there are no overflow checks!
let a := sub(0, 1)  // a = 2^256 - 1 (max uint256)
let b := add(0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff, 1)  // b = 0

If you write Yul for gas optimization (as many MEV bots do), you must manually validate arithmetic bounds or prove that overflow is impossible for your input domain.

Practical Security Checklist for MEV Bots

  1. Never use tx.origin — Use msg.sender or signature-based authentication.
  2. CEI pattern everywhere — Update state before making external calls.
  3. Check all return values — Especially for low-level calls and non-standard tokens.
  4. Validate DELEGATECALL targets — Only delegate to trusted, audited implementations.
  5. Use reentrancy guards — Especially for functions that transfer ETH or tokens.
  6. Validate calldata — Ensure function selectors and parameters are within expected ranges.
  7. Protect against sandwich attacks — Your bot is a smart contract too; it can be attacked.
  8. Use access control — Only your EOA/multisig should be able to call profit-extraction functions.
  9. Implement circuit breakers — Pause functionality if unexpected behavior is detected.
  10. Audit Yul/Huff code extra carefully — No compiler safety nets in raw assembly.

Conclusion

Security in the EVM is not a layer you add — it is a property of how you structure your state transitions relative to external calls. The checks-effects-interactions pattern, reentrancy guards, and careful delegatecall usage are not best practices — they are survival requirements. In the next article, we leave Solidity behind and enter the world of Yul, Solidity’s inline assembly language.