Peckshield luobobo
0x00 背景
本文将以一起真实事例——Beauty Coin Token被盗事件,解析如何在字节码层面还原代币溢出漏洞技巧。该合约漏洞原因与一年前美链(Beauty Chain)被攻击事件——“美链(BEC)一行代码蒸发了¥6,447,277,680 人民币!”的漏洞原因一致。
https://etherscan.io/address/0x623afe103fb8d189b56311e4ce9956ec0989b412#code
我们在etherscan上可以获取该合约的Contract Creation Code,这一部分字节码并非完全是存储在区块链上的。Contract Creation Code由runtime bytecode与creation part组成,creation part部分的字节码完成合约构造函数中的初始化赋值,并将runtime code部署上链。关于runtime bytecode与bytecode的详细概念在 https://arvanaghi.com/blog/reversing-ethereum-smart-contracts/ 已有讲解,不在赘述。由于本合约开源,所以读者也可参考源码阅读反编译结果。本文中反编译工具选用免费的在线反编译器ethervm。
0x01 Entry
如上图所示,我们将BEC合约的runtime code进行反编译。每份ETH智能合约的入口点都是dispatch函数,通过解析交易的用户输入数据calldata(msg.data),获取相应参数进入被调用函数逻辑。用户调用合约中函数时,calldata的前4bytes指定被调用函数的签名。在dispatch函数中,若calldata.length < 4或calldata[0x00:0x04]未匹配到合约任一函数,则进入合约回调函数(如果合约作者并未写回调函数逻辑,则什么也不发生)。
0x02 Function Signature
在dispatch函数中,我们首先定位到balanceOf与transfer这两个函数。以太坊中使用keccak256算法,对函数名与参数类型计算后取前4字节作为函数签名,后续字节作为传入参数,以32字节大小处理。
address参数长度为20bytes,在balanceOf函数中,0x02 ** 0xa0 - 0x01为0xfff...ffff(20bytes),
msg.data[0x04:0x24] & 0x02 ** 0xa0 - 0x01
这一行反编译代码含义是将msg.data[0x04:0x24]的低位20bytes数值保留,高位12bytes数值置0。var2即为balanceOf(address)的参数。同理在transfer(address,uint256)中msg.data[0x04:0x24]的低位20bytes为传入的address参数,msg.data[0x24:0x44]为uint256参数。
Web3.js与Web3.py都提供相应的API做函数签名运算,当然也可以直接使用Keccak-256哈希函数计算:
https://www.4byte.directory/ 同时,读者可在Ethereum Function Signature Database中查询特定函数签名所对应的函数
0x03 mapping数据类型
在源码中我们可以看到BEC在构造函数中定义了映射变量balanceOf
mapping (address => uint256) public balanceOf;
以太坊智能合约中public类型的全局变量会被编译为constant函数,以便查询合约内部数据信息。
此处arg0为address变量,balanceOf返回该地址的token余额。当合约定义了多个mapping (address => uint256)映射变量时,不同变量参与地址哈希运算的index不同。如上图第二行所示,我们可以看到balanceOf参与storage地址哈希值计算的index(memory[0x20:0x40]
)被编译器设置为1。
0x04 有保护的transfer函数
接下来我们定位到transfer(address _to, uint256 value)
的反编译代码逻辑,将其逐一解析:
var var1 = arg0;
if (!(var1 & 0x02 ** 0xa0 - 0x01)) { revert(memory[0x00:0x00]); }
revert是交易回滚指令,这一句表示传入地址参数_to若为0x0地址,则结束执行,交易回滚。
var var2 = arg1;
if (var2 <= 0x00) { revert(memory[0x00:0x00]); }
这一句表示传入uint256参数_value转账金额若为0,则结束执行,交易回滚。
以上用solidity语句还原则为:
require(_to != 0x0);
require(_value > 0);
接下来是交易发起者的token余额检测:
memory[0x00:0x20] = msg.sender;
memory[0x20:0x40] = 0x01;
if (storage[keccak256(memory[0x00:0x40])] < arg1) { revert(memory[0x00:0x00]); }
由balanceOf函数已知该处storage[keccak256(memory[0x00:0x40])]
为balanceOf(msg.sender)
,即交易发起者的余额。这一句表示若交易发起者的token余额小于转账金额,则结束执行,交易回滚。
memory[0x00:0x20] = arg0 & 0x02 ** 0xa0 - 0x01;
memory[0x20:0x40] = 0x01;
var temp0 = storage[keccak256(memory[0x00:0x40])];
if (temp0 + arg1 <= temp0) { revert(memory[0x00:0x00]); }
这一步检测balanceOf(_to) + _value
是否上溢出。
var temp1 = 0x02 ** 0xa0 - 0x01;
var temp2 = temp1 & msg.sender;
memory[0x00:0x20] = temp2;
memory[0x20:0x40] = 0x01;
var temp3 = keccak256(memory[0x00:0x40]);
var temp4 = arg1;
storage[temp3] = storage[temp3] - temp4;
var temp5 = arg0 & temp1;
memory[0x00:0x20] = temp5;
var temp6 = keccak256(memory[0x00:0x40]);
storage[temp6] = temp4 + storage[temp6];
上述反编译语句还原成solidity语言为:
balanceOf[msg.sender] = balanceOf[msg.sender] - _value;
balanceOf[_to] = balanceOf[_to] + _value;
后续反编译语句中对内存赋值与log(...)等,都是transfer函数中Transfer Event的反编译结果,不修改合约的数据状态。
0x05 transferMulti函数中的溢出漏洞
我们首先来到transferMulti的输入参数读取部分,看看数组类型参数在calldata中是如何分布的:
先说结论,对于参数为两个数组类型的函数,calldata[0x04:0x24]内存储着第一个数组参数的长度的存储位置与calldata[0x4]的偏移位置,calldata[0x24:0x44]存储着第二个数组参数的长度的存储位置与calldata[0x4]的偏移位置。EVM通过获取数组参数的长度,向后读取20bytes*长度的数值存储至内存中,并传递内存存储地址给inline function。对于其他不同的函数参数类型,读者可在Remix中自实现相应函数,通过提供的Debug功能观察EVM对参数的处理情况。
在上图中,temp21为address[]的长度,temp23为address[]的占用的字节长度,var2为memory中address[]的存储地址(包括数组长度);同理var3为memory中uint256[]的存储地址(包括数组长度)。我们可通过一条攻击溢出漏洞的交易来查看calldata的数值分布,见下图:
[0] : 0x40指向[2] : 0x02,后续0x02 * 0x20 长度的数值为address[000000000000000000000000dd8ef7dbe457f1fbf546acb6cc73f4940322af9a,000000000000000000000000dd8ef7dbe457f1fbf546acb6cc73f4940322af9a]
[1] : 0xa0指向[5] : 0x02,后续0x02 * 0x20 长度的数值为uint256[8000000000000000000000000000000000000000000000000000000000000000, 8000000000000000000000000000000000000000000000000000000000000000]
如上图,接下来我们分析函数function transferMulti(var arg0, var arg1)
的逻辑,memory[arg0:arg0+0x20]
为用户本次交易调用所传入address[]数组的长度,memory[arg1:arg1+0x20]
为用户本次交易调用所传入uint256[]数组的长度,若二者不等则revert。
当调用者传入address[]数组的长度大于0时,进入else逻辑:
此处memory[var6 + 0x20: var6 + 0x20 + 0x20]
存放着uint256[0]的数值,不难看出上图为一个loop逻辑,将用户传入uint256[]数组中所有值累加置var0中,之后跳转至label_0996继续执行(0x0996指runtime bytecode中该指令offset)。
memory[0x00:0x20] = msg.sender;
memory[0x20:0x40] = 0x01;
if (storage[keccak256(memory[0x00:0x40])] < var0) { revert(memory[0x00:0x00]); }
结合前文中提到mapping变量balanceOf的解析,我们不难看出,若balanceOf(msg.sender)
小于所有要转账的金额累计和,则合约执行revert。在又一次参数数组的长度判断之后,该函数开始循环为address[]参数中的地址一一分发uint256[]中转账金额。虽然在此处同样存在上溢漏洞,但黑客无法在此获利。
BEC合约真正致命的溢出漏洞,是在label_096F中对uint256[]数组所有值做累加时,未检测上溢出。黑客构造恶意uint256[]参数,使var0在溢出后被构造成一个任意小的数值,得以绕过balanceOf(msg.sender) >= var0的检测,达到任意分发代币token的目的。
在下一篇文章中,我将介绍EOS智能合约的逆向技巧,解析对EOS智能合约编译后WASM字节码的逆向分析过程。