逆向分析以太坊智能合约(Part 2)

逆向分析以太坊智能合约(part1)  传送门:https://www.anquanke.com/post/id/101979

 

一、前言

在前一篇文章中(原文译文),我们初步逆向分析了Greeter.sol合约。我们仔细研究了Greeter.sol的dispatcher,作为合约的一部分,dispatch可以接收交易数据,决定应该发送哪个函数。

让我们再来看以下Greeter.sol合约:

contract mortal {
    /* Define variable owner of the type address */
    address owner;

    /* This function is executed at initialization and sets the owner of the contract */
    function mortal() { owner = msg.sender; }

    /* Function to recover the funds on the contract */
    function kill() { if (msg.sender == owner) selfdestruct(owner); }
}

contract greeter is mortal {
    /* Define variable greeting of the type string */
    string greeting;

    /* This runs when the contract is executed */
    function greeter(string _greeting) public {
        greeting = _greeting;
    }

    /* Main function */
    function greet() constant returns (string) {
        return greeting;
    }
}

这次让我们分析一下kill()方法。

每份智能合约中都存在dispatcher。kill()的函数标识符为0x41c0e1b5,这是因为该ID是kill()方法keccak256`哈希的前4个字节:

keccak256("kill()") = 41c0e1b5...

Dispatcher会检查发往合约的交易数据,决定是否要与kill()函数进行通信。大家可以回顾之前那篇文章,详细了解我们分解过的那些指令。

这里我们分析下当dispatcher把我们带到这个函数时会发生什么情况。

 

二、kill()

Greeter.sol中的kill()函数实际上继承自上一层的mortal合约:

contract mortal {
    /* Define variable owner of the type address */
    address owner;

    ...

    /* Function to recover the funds on the contract */
    function kill() { if (msg.sender == owner) selfdestruct(owner); }
}

contract greeter is mortal {
    ...
}

由于greeter is mortal,因此greeter可以访问mortal的所有函数以及成员。即便我们只是把greeter的字节码加载到Binary Ninja中,由于存在这种继承关系,该字节码中也会包含mortal的所有函数。

kill()函数可以执行如下操作:

1、检查发送交易的地址是否与合约的address owner成员相匹配。

2、如果相匹配,kill()就会调用内置的selfdestruct函数,将owner地址以参数形式传入。

selfdestruct实际上是一种操作码(opcode),因此其实已经内置在EVM(以太坊虚拟机)中。理论上讲,这是我们从以太坊区块链上删除智能合约的唯一方法。如果你的合约接收以太币(ether),那么你以参数形式传递给selfdestruct的那个地址会在合约代码被删除前接收存储在你合约中的所有以太币。

selfdestructEIP6之前称为suicide)的功能是允许人们通过删除旧的或者未使用的合约来清理区块链。如果有人将以太币发送给已经销毁的合约,那么这些以太币将永远丢失,因为合约地址已经不再具备将以太币转移到另一个地址的任何代码。大家可以访问此链接了解关于selfdestruct的更多信息。

 

三、反汇编kill()

接下来让我们反汇编kill(),检查相关操作码。

 

Payable修饰符

第一部分指令为:

CALLVALUE
ISZERO
PUSH2 0x5c
JUMPI

CALLVALUE是一次交易中发送的wei的数量,对应于一次交易中的msg.value参数。Wei是以太币的最小单位,就像美分是美元的最小单位一样,只不过1 ether=10^18 wei。为了便于说明,这里我将用以太币来表示发送的币值。

CALLVALUE会将发送给kill()函数的以太币数量压入栈中,ISZERO会将该值弹出,如果值为0(即没有以太币发送到kill()函数),则将1压入栈中。

请记住,msg.datacalldataload相对应,msg.valuecallvalue相对应。以太坊交易合约中包含这两个字段。msg.data字段会告诉智能合约此次交易希望与那个函数交互,也会包含该函数所需的任何参数。msg.value字段也可以为该函数包含一些以太币,这是一个完全独立的字段。

对于我们这个例子,假设真的有人在交易中往kill()发送了一些以太币,那么ISZERO就会将0压入栈中。在PUSH2 0x5c执行之后,栈的布局如下所示:

0: 0
1: 0x5c

前一篇文章中我们提到过,JUMPI对应的是jumpi(label, cond),也就是说如果cond为非零值,那么就会跳转到label。在这个例子中,cond等于0,因此我们不会跳转。这样我们就会进入左边分支,遇到REVERT指令。

当某人将以太币发送到kill()函数时,为什么我们会跳转到REVERT?这是因为kill()函数并没有在源代码中被标记为payable

function kill() {

如果某个函数原型没有在尾部使用payable修饰符时,则会拒绝与之对应的包含以太币的交易。如果智能合约作者没有显式地包含一个函数来转发存储在智能合约中的以太币,那么这些以太币将永远丢失,添加“payable”修饰符可以确保降低这种情况发生的概率。

 

优化

作为一门可访问的语言,在编写智能合约这样艰巨的任务方面Solidity已经表现得非常不错。然而,由于这门语言仍属于较新颖的一门语言(对于以太坊来说也是如此),因此Solidity编译器solc在编译出来的字节码中仍然会产生冗余的指令。

比如,我们的kill()函数中包含如下一组指令:

这3条指令分别为: PUSH1 0x0DUP1以及SWAP1,分别做了如下操作:

1、将0x0压入栈:

0: 0x0

2、复制这个值:

0: 0x0
1: 0x0

3、交换这些值,因此栈上的两个0x0会被互相交换:

0: 0x0
1: 0x0

人们仍在解决这些冗余操作,幸运的是,solc编译器有一个optimizer标志,可以很好地解决这些冗余问题。大家可以参考此处了解更多信息。

在我们这个例子中,我们可以使用如下命令生成经过优化的字节码:

solc --bin-runtime --optimize --optimize-rounds 200 Greeter.sol

将生成的字节码导入Binary Ninja,我们可以得到如下输出:

你会发现这里的payable逻辑仍然与前面相同,但操作数明显减少了许多。

我们会继续分析经过优化的这个字节码。

 

四、分解kill()

前面我们已经介绍过payable逻辑,接下来我们继续分析kill()中紧随其后的其他指令:

第一条指令是PUSH2 0x65。这个值会一直停留在栈上,直到kill()函数结束。你可以提前知道这个信息,因为如果你查看代码执行尾部,你可以看到0x131地址处有一个JUMP指令。

我们知道JUMP指令需要一个参数,以便EVM知道要跳转到哪个地方,因此栈上肯定要存在某个值。我们也可以看到这条JUMP指令会指引我们直接转到0x65这个地址。因此,我们可以得出一个结论,那就是我们推到栈上的0x65将会作为该函数尾部JUMP指令的参数。

下一条指令是PUSH2 0xf1,该指令可以为后面那条JUMP指令做铺垫。当JUMP执行完毕后,栈上只包含0x65这个值。

接下来我们看以下kill()第一部分主要指令集合:

JUMPDEST指令(作为JUMP指令的占位符)之后,第一条指令是PUSH1 0x0然后是SLOAD。我们知道SLOAD代表的是storage load,该指令会根据存储(storage)索引中加载一个值,然后将其压入栈中。


0: 0x65
在这个例子中,0这个参数会传入这条指令(因为0在栈上刚好位于该指令前面),因此SLOAD会将storage[0]压入栈。在我们的合约中,这就是合约中的“address owner”成员。

1: contract owner's address

下一条指令是CALLER,该指令会将调用发送者(或者发送交易的人/合约)的地址压入栈中。

0: 0x65
1: contract owner's address
2: caller address

执行PUSH20 0xffffff...SWAP1以及DUP2指令后,此时栈布局如下所示:

0: 0x65
1: contract owner's address
2: 0xffffff... (20 bytes long)
3: caller address
4: 0xffffff... (20 bytes long)

下一条指令是AND。将0xffffff...(20个字节)与调用者地址进行AND操作后,结果不会发生变化。这条指令的作用是确保栈的比特位被正确设置。AND会将这两个值从栈中弹出,然后将这个地址压入栈。

0: 0x65
1: contract owner's address
2: 0xffffff... (20 bytes long)
3: caller address

接下来的指令是SWAP2以及AND,这里会对合约拥有者的地址执行AND操作。同样,这个AND操作的结果也会被压入栈顶,这次合约所有者的地址也没发生改变。这些指令执行完毕后,栈布局如下所示:

0: 0x65
1: caller address
2: contract owner's address

下一条指令是EQ,该指令会检查栈顶上的两个元素是否相同,相同的话则压入1,否则压入0。这个例子中,EQ会检查调用者地址是否等于合约所有者的地址。

这听起来是不是特别耳熟?其实这对应于kill()函数中if (msg.sender == owner)这条语句。

    /* Function to recover the funds on the contract */
    function kill() { if (msg.sender == owner) selfdestruct(owner); }

下一条指令是ISZERO,该指令会检查EQ的处理结果是0还是1。如果结果为0,则意味着信息发送方并不是合约的所有者,ISZERO的结果为真。如果ISZERO的结果为真,则会将1压入栈,告诉JUMP指令跳过下一个指令块,跳转到0x130,然后将我们踢出合约外。

假设发送该交易的地址的确与合约“所有者”的地址相匹配,那么执行流程将会进入PUSH1 0x0代码块。这条指令执行完毕后,栈布局如下所示:

0: 0x65
1: 0

下一条指令又是SLOAD,这次该指令的参数又是0,因此会将合约所有者的地址压入栈。再一次执行我们熟悉的PUSH20 0xffffff...以及AND指令后,我们的栈布局如下所示:

0: 0x65
1: contract owner's address

这个指令块的最后一条指令是SELFDESTRUCT,该指令会将栈顶元素当成存储以太币的所有合约的目的地址,然后删除所有合约的代码。当SELFDESTRUCT指令弹出合约所有者的地址后,栈上只包含0x65,最后的JUMP指令会将这个值当作参数,跳转到STOP

现在我们的合约代码已经被删除,存储在合约中的所有以太币已经发送到owner。大功告成。

(完)