Beware of using tx.origin to verify user
Overview
According to Solidity doc,
msg.sender (address): sender of the message (current call)
tx.origin (address): sender of the transaction (full call chain)
tx.origin
means the first account that initial transaction.
However, the msg.sender
is exactly the account that triggers the function.
Example
Let's take a look at today's drama.
Starring:
π: 0x5B3, user A, victim.
π: 0xAb8, user B, A's friend
πΎ: 0x4B2, a hacker
Two contracts:
Wallet contract: 0xD91, deployed by user Aπ.
PhishingWallet Contract: 0x9EC, deployed hackerπΎ.
Contract Analysis
There are three functions in the Wallet contract.
- balance(): get the contract balance
- deposit(): deposit ether into the contract
- transfer(): transfer ether from contract to others.
1// SPDX-License-Identifier: MIT
2pragma solidity ^0.8.10;
3
4contract Wallet {
5 address public owner;
6
7 constructor() {
8 owner = msg.sender;
9 }
10
11 function balance() public view returns (uint) {
12 return address(this).balance;
13 }
14
15 function deposit() payable public {
16
17 }
18
19 function transfer(address payable _to, uint _amount) public {
20 require(tx.origin == owner, "Only owner can transfer");
21
22 (bool sent, ) = _to.call{value: _amount}("");
23 require(sent, "Failed");
24 }
25}
The scenario is:
User A (π 0x5B3
) deploys this Wallet contract(0xD91
) and deposits 10 ethers into it. Then he transfers 1 ether to User B(π: 0xAb8
). It works fine!
Wallet contract balance: 9 ethers
User B(π: 0xAb8
) balance: 1 ether.
On the hacker side:
The hacker(πΎ, 0x4B2
) deploys a PhishingWallet contract(0x9EC) and cheats User A (π 0x5B3
) to interact with it.
Let's step into the PhishingWallet contract's transfer function. It calls the original Wallet contract's transfer function to transfer all ethers to hacker.
1contract PhishingWallet {
2 Wallet wallet;
3 address public owner;
4
5 constructor(address payable _wallet_addr) {
6 wallet = Wallet(_wallet_addr);
7 owner = msg.sender;
8 }
9
10 function transfer(address payable _to, uint _amount) public {
11 wallet.transfer(payable(owner), address(wallet).balance);
12 }
13
14}
How can it happen?
The problem is in the Wallet contract's transfer function. The function verify ownership by tx.origin
.
Since user A (π 0x5B3
) invoked the PhishingWallet contract, the tx.origin
value in the Wallet contract was still user A(π 0x5B3
) even the Wallet contract(0xD91
) is invoked by the PhishingWallet contract(0x9EC
). So this requirement check require(tx.origin == owner, "Only owner can transfer");
was passed.
Solutions
If we want to verify user, we should use msg.sender
instead.
1function transfer(address payable _to, uint _amount) public {
2 // require(tx.origin == owner, "Only owner can transfer");
3 require(msg.sender == owner, "Only owner can transfer");
4
5 (bool sent, ) = _to.call{value: _amount}("");
6 require(sent, "Failed to send Ether");
7}
Even more, we can restrict only EOA(Externally Owned Account) can invoke the transfer function.
1function transfer(address payable _to, uint _amount) public {
2 require(tx.origin == msg.sender, "Only EOA can transfer");
3 //require(tx.origin == owner, "Only owner can transfer");
4 require(msg.sender == owner, "Only owner can transfer");
5
6 (bool sent, ) = _to.call{value: _amount}("");
7 require(sent, "Failed to send Ether");
8}