逆向分析以太坊智能合约(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
的那个地址会在合约代码被删除前接收存储在你合约中的所有以太币。
selfdestruct
(EIP6之前称为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.data
与calldataload
相对应,msg.value
与callvalue
相对应。以太坊交易合约中包含这两个字段。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 0x0
、DUP1
以及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
。大功告成。