cxrlos
← Security

Smart-contract security · EVM

A tx.origin takeover, exploited and then fixed

This is the other side of the escrow work. There I'm testing my own program to prove it rejects bad input; here I'm attacking a contract that's wrong on purpose, to show exactly how the bug pays out. The target is a toy Vault that authorizes its owner with tx.origin instead of msg.sender, a classic EVM mistake. I first did this as a local Foundry experiment in September 2026; the code below is a clean reproduction I rebuilt and re-ran so the output here is real, not remembered.

The bug: trusting tx.origin

The Vault guards its one privileged action, changeOwner, with require(tx.origin == owner). That looks right and works fine when the owner calls the Vault directly. The problem is what tx.origin means: it's the externally-owned account that signed the transaction, all the way back at the start of the call chain, not whoever is calling you right now. msg.sender is the immediate caller; tx.origin is the origin of the whole thing. If the owner can be induced to call any other contract, that contract calls the Vault with tx.origin still set to the owner, and the check waves it through.

function changeOwner(address newOwner) external {
    // BUG: tx.origin is the EOA that started the tx, not the immediate
    // caller. Any contract the owner is talked into calling passes this.
    require(tx.origin == owner, "not owner");
    owner = newOwner;
}
src/Vault.sol: the vulnerable check (reproduction)

The attack contract, and the call chain

The exploit is a thin intermediate. The attacker deploys it pointing at the Vault, then gets the owner to call its execute(), the kind of thing a "claim" or "verify" button does. execute() calls Vault.changeOwner(attacker). The chain is owner → Attack.execute() → Vault.changeOwner(attacker). Inside changeOwner, msg.sender is the Attack contract, but tx.origin is still the owner, so the Vault believes the owner authorized it.

contract Attack {
    IOwnable public immutable vault;
    address public immutable attacker;

    constructor(address _vault, address _attacker) {
        vault = IOwnable(_vault);
        attacker = _attacker;
    }

    // Owner is induced to call this; tx.origin stays the owner the whole way.
    function execute() external {
        vault.changeOwner(attacker);
    }
}
src/Attack.sol: the malicious intermediate

Running it: the takeover happens

The test deploys the Vault as the owner, deploys the Attack contract, then has the owner call execute() once. vm.prank(owner, owner) sets both msg.sender and tx.origin to the owner for that call, which is exactly the real scenario: the owner genuinely signed it. After the single call, the Vault's owner is the attacker.

Both tests pass: testTakeover confirms ownership actually moves to the attacker, and testFixBlocksTakeover (below) confirms the fix stops it. For context, the original September 2026 experiment reported the same, a passing testTakeover, but the result quoted here is from this fresh run, not that one.

function testTakeover() public {
    vm.prank(owner, owner);        // msg.sender AND tx.origin = owner
    Vault vault = new Vault();
    assertEq(vault.owner(), owner);

    Attack attack = new Attack(address(vault), attacker);

    vm.prank(owner, owner);        // owner is tricked into calling execute()
    attack.execute();

    assertEq(vault.owner(), attacker);   // ownership taken over
}

// Output (Foundry 1.8.5, solc 0.8.37):
//   [PASS] testTakeover() (gas: 57175)
//   [PASS] testFixBlocksTakeover() (gas: 44898)
//   Suite result: ok. 2 passed; 0 failed; 0 skipped
test/TxOriginTakeover.t.sol: forge test -vv

The fix: authorize the immediate caller

The fix is one word: check msg.sender == owner. Now the identity that has to match is whoever is directly calling changeOwner. In the attack chain that's the Attack contract, not the owner, so the require fails and ownership doesn't move. The same test, pointed at the fixed vault, expects the revert and asserts the owner is unchanged, and it passes. The rule this leaves me with: tx.origin is never an authorization check. The only honest use for it is refusing to be called by a contract at all (msg.sender == tx.origin), and even that breaks account abstraction.

// Fixed: the immediate caller must be the owner.
function changeOwner(address newOwner) external {
    require(msg.sender == owner, "not owner");
    owner = newOwner;
}

// test: same chain, but the Attack contract is now a stranger.
function testFixBlocksTakeover() public {
    vm.prank(owner, owner);
    SafeVault vault = new SafeVault();
    Attack attack = new Attack(address(vault), attacker);

    vm.prank(owner, owner);
    vm.expectRevert(bytes("not owner"));
    attack.execute();

    assertEq(vault.owner(), owner);   // unchanged
}
src/Vault.sol (SafeVault) and the test that the attack now reverts

This is a deliberately vulnerable contract I wrote and attacked in a local Foundry lab: a reproduction of an earlier experiment, rebuilt so the code and the test output shown here are from a real run on Foundry 1.8.5. It is not an exploit against anyone's deployed protocol, and the takeover only works because the Vault was written to be broken. The contrast is intentional: the Protected Pay write-up is the same discipline pointed the other way, proving a real program rejects the malicious input instead of falling for it.