前言
近年来,各个大型CTF(Capture The Flag,中文一般译作夺旗赛,在网络安全领域中指的是网络安全技术人员之间进行技术竞技的一种比赛形式)比赛中都有了区块链攻防的身影,而且出现的题目绝大多数都是区块链智能合约攻防。此系列文章我们主要以智能合约攻防为中心,来剖析智能合约攻防的要点,前两篇我们分享了合约反编译,反汇编的基础内容。后续的文章中,我们会继续分享CTF比赛中智能合约常见题型(重入,整数溢出,空投,随机数可控等)及解题思路,相信会给读者带来不一样的收获。
上篇文章中我们分享了CTF比赛中常考的重入漏洞题型,本篇继续来分享CTF比赛中的整数溢出题型,也是比较常见的一类题型,当然多数CTF智能合约题目并不仅仅考察单个漏洞的攻防,可能涉及多个漏洞的组合。
本篇我们以2018年WCTF上BelluminarBank题目为例,给大家分享智能合约整数溢出的题型。解出这道题不仅需要整数溢出攻击,也需用到变量覆盖,权限设置等多个攻击技巧。
题目地址:
由于WCTF智能合约比赛没有在以太坊测试网(ropsten)进行,没有在线的攻防场景,合约具体题目介绍及合约源码已在GitHub给出:https://github.com/beched/ctf/tree/master/2018/wctf-belluminar
题目分析
题目提示
团队需要对字节码进行反向工程,并使用以下攻击:
-
整数溢出绕过存款期限限制;
-
存储溢出以覆盖银行所有者;
-
存储访问权限以泄露私有属性;
-
部署自杀合同以强制将eth发送到目标合同(以解决余额差异)
不一定需要意外的以太攻击,如果使用withdraw()和invest()调用,则可以适当平衡。可能是由于导致错误解决方案的巨大错误所致:withdraw()函数不会更改balances数组。但是仍然需要事先利用整数溢出。
合约说明
Belluminar Bank非常小而特别。其工作方式如下:
- 任何人都可以投资任何金额,并应指定存款期限(在此之前存款将被锁定);
- 存款期限必须比先前客户的存款期限至少长1年;
- 每个存款分配一个账号;
- 帐户0包含31337 wei,由银行所有者(合同创建者)锁定多年;
- 存款期限满一年(如果您不提款),银行所有者可以没收您的存款。
目标是破解这家银行并清空其余额。如果成功,该机器人将向您发送交易数据中的标志。
合约源码
pragma solidity ^0.4.23;
contract BelluminarBank {
struct Investment {
uint256 amount;
uint256 deposit_term;
address owner;
}
//全局变量
Investment[] balances;
uint256 head;
address private owner;
bytes16 private secret; //secret可读取
function BelluminarBank(bytes16 _secret, uint256 deposit_term) public {
secret = _secret;
owner = msg.sender;
if(msg.value > 0) {
balances.push(Investment(msg.value, deposit_term, msg.sender));
}
}
function bankBalance() public view returns (uint256) {
return address(this).balance;
}
//局部变量覆盖全局变量
function invest(uint256 account, uint256 deposit_term) public payable {
if (account >= head && account < balances.length) {
Investment storage investment = balances[account];
investment.amount += msg.value;
} else {
if(balances.length > 0) {
//存在整数溢出
require(deposit_term >= balances[balances.length - 1].deposit_term + 1 years);
}
//局部变量
investment.amount = msg.value;
investment.deposit_term = deposit_term;
investment.owner = msg.sender;
balances.push(investment);
}
}
function withdraw(uint256 account) public {
require(now >= balances[account].deposit_term);
require(msg.sender == balances[account].owner);
msg.sender.transfer(balances[account].amount);
}
function confiscate(uint256 account, bytes16 _secret) public {
require(msg.sender == owner);
require(secret == _secret);
require(now >= balances[account].deposit_term + 1 years);
uint256 total = 0;
for (uint256 i = head; i <= account; i++) {
total += balances[i].amount;
delete balances[i];
}
head = account + 1;
msg.sender.transfer(total);
}
}
合约分析
从题目提示可以得出,本次攻击的目的是拿到合约中的所有余额。并且需要多个漏洞攻击手法。
先来分析合约中的存在转账功能函数withdraw()和confiscate():
function withdraw(uint256 account) public {
require(now >= balances[account].deposit_term);
require(msg.sender == balances[account].owner);
msg.sender.transfer(balances[account].amount);
}
withdraw()函数中,会判断现在的时间是否大于存款的期限,第二句判断调用者地址是否是存款者地址,如果条件满足,就会转出当前合约调用者的存款余额,可以得出该函数并不能转出合约所有余额。
继续来看第二个函数confiscate():
function confiscate(uint256 account, bytes16 _secret) public {
require(msg.sender == owner);
require(secret == _secret);
require(now >= balances[account].deposit_term + 1 years);
uint256 total = 0;
for (uint256 i = head; i <= account; i++) {
total += balances[i].amount;
delete balances[i];
}
head = account + 1;
msg.sender.transfer(total);
}
confiscate()函数中会依次判断所有者地址,secret,存款期限,如果条件满足,之后会对存款数组进行遍历,将得到的资金amount全都赋予total,最终通过transfer()将所有余额转出,也就是说合约所有者可以将之前存款记录的余额全部取出。很明显,该confiscate()函数就是我们最终需要利用的转账函数。
如果要使用confiscate()函数进行转账,我们需要解决该函数中的前三行代码判断条件,由于题目给出了提示-存储溢出以覆盖银行所有者。我们继续分析该合约变量覆盖问题。
从合约源码可看到,该合约中有四个全局变量(balances,head,owner,secret),在solidity中,全局变量存储在storage当中,对于复杂的数据类型,比如array(数组)和struct(结构体),在函数中作为局部变量时,也会默认储存在storage当中。并且solidity的状态变量存储时,都是按照状态在合约中的先后顺序进行依次存储。
也就是说目前合约的四个全局变量存储如下:
storage[0] : balances
storage[1] : head
storage[2] : owner
storage[3] : secret
在invest()函数中,通过Investment storage investment = balances[account];可得到一个结构体变量investment,该变量有三个成员均存在赋值操作,如下:
investment.amount = msg.value;
investment.deposit_term = deposit_term;
investment.owner = msg.sender;
在函数中作为局部变量时,也会默认储存在storage当中,由于结构体变量investment并未对三个成员进行初始化,所以当变量存储时依然会按照顺序存储在storage[0,1,2]中,那么目前storage中的存储为数据为:
这里局部变量覆盖全局变量时要特别注意一点:局部变量amount覆盖全局变量balances时,由于balances变量是数组的长度(目前数组中有合约部署者传入的一组数据,故balances为1),当其他调用者也传入的一组数据,比如传入的msg.value值为1(也就是amount的值为1时),之后变量覆盖后的balances值也为1,但是由于传入了一组数据后,数组的长度balances变为2,由于变量覆盖相互影响的关系,balances的值为2后,amount的值也变为2,也就是说虽然传入的msg.value值为1,但最终amount的值为2。
我们继续来分析合约漏洞,由于上图invest()函数中赋值的三个变量(msg.value,deposit_term,msg.sender)都可控:
第一个变量msg.value,是调用者传入的资金;
第二个变量deposit_term本身的含义是存款期限,调用者可根据自己情况输入需要存款的时间,在合约中发生变量覆盖后则代表head值(存款数据的索引)。并且在invest()函数中存在和deposit_term变量相关联的判断条件require(deposit_term >= balances[balances.length - 1].deposit_term + 1 years);我们将它和confiscate()函数中的判断条件require(now >= balances[account].deposit_term + 1 years);进行对比。
由于我们最终需要利用confiscate()函数中的transfer函数进行转账,如果按照正常逻辑运算,我们存钱后至少需要一年时间才能取出,所以该判断条件(require(now >= balances[account].deposit_term + 1 years);)必须设法绕过。同时我们还需要invest()函数中的判断条件(require(deposit_term >= balances[balances.length - 1].deposit_term + 1 years);)也正常执行。
可以看到这两行代码的条件判断中加减操作并没有做安全防护,这里假如我们使balances[balances.length - 1].deposit_term + 1 years值等于2256,由于solidity的存储关系,这里会发生整数上溢出,最终结果为0,就可以绕过该判断条件。还需要注意的一点为:confiscate()函数中的for循环需要head(由于变量覆盖的关系,head值为deposit_term传入的值)从0开始才能将所有的资金取出,所以需要我们对deposit_term进行两次赋值:第一次赋值为2256 - 1 years(solidity中默认时间单位为秒,故这里的赋值为:2^256 - 3153600 = 115792089237316195423570985008687907853269984665640564039457584007913098103936 ),第二次赋值为0(赋值为0,判断条件也恒成立)。
第三个变量msg.sender,从上图可以看出,该变量传入后覆盖全局变量owner,当前调用者地址就会变为合约所有者,从而就可绕过confiscate()函数中msg.sender == owner判断条件。
由于secret存储在storage(storage变量是指永久存储在区块链中的变量),所以我们可以调用storage索引获取里面的值。
至此confiscate()函数中的前三句判断条件均已满足。
解题思路
通过分析BelluminarBank合约漏洞,我们可以利用整数溢出,变量覆盖,访问权限等漏洞攻击转出合约所有余额。具体解题思路如下:
- 通过调用invest()函数传入account为1,deposit_term为115792089237316195423570985008687907853269984665640564039457584007913098103936,携带的msg.value为1wei。account始终根据第一句的判断条件进行赋值。msg.value赋值amount,再进行balances变量覆盖(由于变量循环赋值的关系),最终结果balances=amount=2;deposit_term值变量覆盖也成为head值;调用者地址msg.sender最终变量覆盖后会成为合约所有者owner。调用之后balances[balances.length - 1].deposit_term + 1 years发生整数溢出,绕过判断条件。
- 继续调用invest()函数传入account为2,deposit_term为0,携带的msg.value为2wei,msg.value赋值amount,再进行balances变量覆盖(由于变量循环赋值的关系),最终结果balances=amount=3;deposit_term值变量覆盖也成为head值为0,相当于还原head原始的值。
- 由于balances变量的循环覆盖的关系,最终的合约余额会有差别,可通过合约自毁或者withdraw()函数调整合约余额。
- 调用confiscate()函数传入两个参数:account为1,secret值为我们之后通过storage获取的密码值,最终取走合约所有的余额。
攻击演示
本次攻击演示在ropsten测试网进行,使用工具为Remix+Matemask+myetherwallet
Remix在线编辑器:http://remix.ethereum.org/
MetaMask钱包插件:https://metamask.io/
MyEtherWallet在线钱包:https://www.myetherwallet.com/
1.首先部署BelluminarBank漏洞合约
使用在线编辑器Remix通过Meta Mask在线钱包A地址部署BelluminarBank合约,部署时给合约传入参数为:
value:31337 wei,deposit_term:0x00000000000000000000000000000001,_secret:1000
(为了方便查看数据,我们将合约源码中的一部分内容进行了可见性修改)
部署完成后目前合约中变量值为以下
2.使用myetherwallet在线钱包调用BelluminarBank合约
在remix中获取api并复制部署的合约地址,填入myetherwallet钱包中。
连接成功
3.调用invest()函数修改合约所有者owner,存款期限数值deposit_term,变量重复覆盖值amount
传入参数为:
value:0.000000000000000001 ETH,account:1,deposit_term:115792089237316195423570985008687907853269984665640564039457584007913098103936。
完成后目前合约中变量值为以下
4.调用invest()函数修改存款期限数值deposit_term(修改head为0),变量重复覆盖值amount
传入参数为:
value:0.000000000000000002 ETH,account:2,deposit_term:0。
完成后目前合约中变量值为以下
虽然上图中显示的合约全部余额为31340 wei,但调用过程中出现循环变量覆盖,导致数组中的余额为31337+2+3 =31342 wei,如下图所示:
为了使合约本身余额与数组中的amount匹配,这里我们选择强制给该转币。
5.通过c地址部署合约并调用taijie()函数自毁合约给BelluminarBank合约转2 wei,平衡合约数组中的余额。
自毁成功后,BelluminarBank合约余额变为31342 wei。
6.调用confiscate()函数最终取走合约所有的余额
传入两个参数:account:2,secret:0x00000000000000000000000000000001
调用完成后,BelluminarBank合约余额变为0。
至此完成攻击演示
总结
本篇文章中,我们通过2018WCTF比赛中的BelluminarBank智能合约题目,详细分析了合约存在的漏洞问题,提供了解题思路并进行了攻击演示,其中使用的相关工具已在文中给出链接,希望对智能合约初学者及爱好者有所帮助,下一篇我们会继续分享CTF智能合约经典题目,请大家持续关注。