🎓 A1Learner
🔍
0 of 48 lessons completed

Solidity Tx Origin Pitfall

The tx origin pitfall is trusting tx.origin for permission checks. Tx.origin is the original outside account that started the whole call chain.

Tx.origin stays the same through a middle contract

The proof calls the middle contract, which calls the target. The target sees the proof as tx.origin and the middle contract as msg.sender.

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

contract Target {
    address public seenOrigin;
    address public seenSender;

    function record() public {
        seenOrigin = tx.origin;
        seenSender = msg.sender;
    }
}

contract Middle {
    function forward(address target) public {
        Target(target).record();
    }
}

An owner check on tx.origin is tricked by a middle contract

The proof is the owner, but it never calls the victim directly. The middle contract forwards the call and the victim pays out anyway.

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

contract Victim {
    address public owner;

    constructor() {
        owner = msg.sender;
    }

    receive() external payable {}

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

contract Middle {
    function trick(Victim victim) public {
        victim.withdraw();
    }
}

A check on msg.sender stops the same trick

The safe victim checks msg.sender instead of tx.origin. The forwarded call is rejected, while a direct call by the owner still works.

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

contract SafeVictim {
    address public owner;

    constructor() {
        owner = msg.sender;
    }

    receive() external payable {}

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

contract Middle {
    function trick(SafeVictim victim) public {
        victim.withdraw();
    }
}

Example

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

contract Victim {
    address public owner;

    constructor() {
        owner = msg.sender;
    }

    receive() external payable {}

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

contract SafeVictim {
    address public owner;

    constructor() {
        owner = msg.sender;
    }

    receive() external payable {}

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

contract Middle {
    function trickOrigin(Victim victim) public {
        victim.withdraw();
    }

    function trickSafe(SafeVictim victim) public {
        victim.withdraw();
    }
}

Key points

  • Tx.origin: the original outside account that started the whole call chain.
  • Msg.sender: the immediate caller of the current function.
  • Forwarding: a middle contract passes a call on, changing msg.sender but not tx.origin.
  • Trick: an owner check on tx.origin passes even when the owner never called directly.
  • Safe check: a check on msg.sender fails for a forwarded call.

In this example, a middle contract tricks the tx.origin victim into paying out, because tx.origin is still the owner. The same middle contract fails against the safe victim, because msg.sender is the middle contract and not the owner. A direct withdrawal by the owner still works on the safe victim.

Note

tx.origin is never the right owner check for a contract that holds funds.

Try it Yourself

Edit the code, then press Run.

Running is simulated · Remix IDE

Exercise

Why does a tx.origin owner check fail?