Note: Secureum security pitfalls & best practices 101 (Part 2)
Secureum ได้ทำ list bug ที่พบบ่อยใน smart contract เอาไว้ มาดูต่อกันครับ
Disclaimer:
- เป็นการ note ไวๆ ไม่ค่อยมี POC นะครับ
- เป็นบทความเพื่อการศึกษา ไม่มีเจตนาร้ายใดๆ
ERC777 Reentrancy
อันนี้เป็น case ที่ใช้ hook ลักษณะ beforeTransfer ของ ERC777 ในการทำ Reentrancy attack
โดยจาก case ที่เกิดขึ้นจริง attacker ได้ทำการ reentrancy เพื่อปั๊ม collateral ของ Lending platform โดยมี step คร่าวๆดังนี้
- Attacker ฝาก 100 imBTC เข้า Lending และได้ 100imBTC กลับมา
- Attacker trigger การฝากอีก 50 imBTC ไปที่ Lending
- Lending อ่านยอด CimBTC ของ attacker (จะได้ 100 CimBTC) จากนั้น trigger การดึง 50 imBTC มาจาก attacker
- เนื่องจาก imBTC เป็น ERC777 attacker จึงสามารถตั้งให้มีการรัน code ในลักษณะ beforeTransfer hook ได้ โดย attacker ได้ทำการสั่งถอน 100 imBTC ออกมา
- Lending ทำการถอน 100imBTC ให้ attacker ซึ่งทำให้ตอนนี้ attacker มี 150 imBTC และ 0 CimBTC
- สิ้นสุด beforeTransfer hook attacker ให้ Lending ดึง 50 imBTC ไป และได้ 50 CimBTC มาเพิ่ม กับยอดเดิมในข้อ 3 ได้เป็น 150 CimBTC
สรุป attacker จะมี 150 CimBTC ทั้งๆที่ฝากเอาไว้แค่ 50 imBTC และสามารถวนโจมตีต่อไปได้เรื่อยๆ
Avoid transfer()/send() as reentrancy mitigations
แต่การที่ fix gas ไว้ก็มีข้อเสียตามมาเช่นกัน เพราะอาจจะทำให้มีค่าแก๊สไม่พอในการโอนไปยังปลายทางที่เป็น smart contract เช่นตาม code ด้านล่าง
contract Vulnerable {
function withdraw(uint256 amount) external {
// This forwards 2300 gas, which may not be enough if the recipient
// is a contract and gas costs change.
msg.sender.transfer(amount);
}
}
จริงๆแล้วถ้าใช้ transfer() โอนไปที่ปลายทาง แล้วปลายทางทำ fallback function ไว้รัน code ต่อ ถ้า test แล้วว่า 2300 ไม่พอต่อการโอนแบบเปิดให้ปลายทางทำ fallback function ต่อได้ เราก็คงเปลี่ยนมาใช้ call แล้วระบุค่า gas ให้สูงขึ้นไป ไม่ก็ใช้ gasLeft ไปแทนเลย
แต่ปัญหาอยู่ใน case ที่ เรา test แล้วว่า transfer() ซึ่งมีค่าแก๊ส 2300 นั้นเพียงพอ เราก็ deploy contract ไปตามปกติ
ต่อมามีการ hardfork เปลี่ยนค่า gas cost ของ Opcode ทำให้มีโอกาสที่ค่าแก๊สใน fallback function สูงขึ้นได้
ทีนี้แหละ กลายเป็นว่า transfer() จะ revert เพราะค่า gas ไม่พอ ทั้งๆที่ก่อนมีการ fork ก็ยังพอต่อค่า gas อยู่
วิธีแก้คือเปิดให้ dynamic ค่า gas ได้ โดยกลับมาใช้ call แทน ตาม code
contract Fixed {
function withdraw(uint256 amount) external {
// This forwards all available gas. Be sure to check the return value!
(bool success, ) = msg.sender.call.value(amount)("");
require(success, "Transfer failed.");
}
}
แน่นอนว่าปัญหา Reentrancy ก็จะกลับมา แต่เราสามารถแก้ได้หลายวิธี
part ที่แล้วเราพูดถึงท่า check effect interact ไปแล้ว งั้น part นี้เราจะมาลองใช้ท่า Reentrancy guard กันแทน
โดย concept คือการทำ flag เอาไว้ว่า ยังอยู่ในระหว่างการเรียก function นี้รึเปล่า ถ้าใช่ก็จะป้องกันให้ไม่มีการเรียกซ้ำ จนกว่าการเรียกรอบนั้นๆ จะจบไปก่อน ตาม code
1 contract Guarded {
2 ...
3
4 bool locked = false;
5
6 function withdraw() external {
7 require(!locked, "Reentrant call detected!");
8 locked = true;
9 ...
10 locked = false;
11 }
12}
Private on-chain data
การเก็บข้อมูลที่ต้องเป็น private บน blockchain แม้เราจะใช้ท่า private data แล้ว แต่ก็ป้องกันได้แค่ layer onchain เท่านั้น ยังไงก็สามารถหาทางเข้าถึงข้อมูลนั้นจาก offchain ได้อยู่ดี เช่นจาก code
/*
* @source: https://gist.github.com/manojpramesh/336882804402bee8d6b99bea453caadd#file-odd-even-sol
* @author: https://github.com/manojpramesh
* Modified by Kaden Zipfel
*/
pragma solidity ^0.5.0;
contract OddEven {
struct Player {
address addr;
uint number;
}
Player[2] private players;
uint count = 0;
function play(uint number) public payable {
require(msg.value == 1 ether, 'msg.value must be 1 eth');
players[count] = Player(msg.sender, number);
count++;
if (count == 2) selectWinner();
}
function selectWinner() private {
uint n = players[0].number + players[1].number;
(bool success, ) = players[n%2].addr.call.value(address(this).balance)("");
require(success, 'transfer failed');
delete players;
count = 0;
}
}
code ด้านบนเป็นเกมทายเลข โดยเอาเลขของผู้เล่นทั้งสองคนมาบวกกันแล้ว mod 2 เพื่อหาคนชนะ
ปัญหาคือคนที่ทายก่อนจะเสียเปรียบ เพราะเลขที่ทายจะถูกผู้เล่นอีกคนแอบดู แล้วทายเลขที่ทำให้ชนะได้เสมอ
วิธีการป้องกัน คือแก้ code ให้เก็บ hash ของเลขที่ทายแทน
มาดู code กัน
/*
* @source: https://github.com/yahgwai/rps
* @author: Chris Buckland
* Modified by Kaden Zipfel
* Modified by Kacper Żuk
*/
pragma solidity ^0.5.0;
contract OddEven {
enum Stage {
FirstCommit,
SecondCommit,
FirstReveal,
SecondReveal,
Distribution
}
struct Player {
address addr;
bytes32 commitment;
bool revealed;
uint number;
}
Player[2] private players;
Stage public stage = Stage.FirstCommit;
เริ่มจากการวางโครงให้ contract มีแยกเฟส การทาย การเปิดเลขที่แต่ละคนทาย การหาผู้ชนะและให้รางวัลออกจากกัน เพื่อไม่ให้มีการแอบเปิดเลขกันเพื่อโกงการทายเลข
function play(bytes32 commitment) public payable {
// Only run during commit stages
uint playerIndex;
if(stage == Stage.FirstCommit) playerIndex = 0;
else if(stage == Stage.SecondCommit) playerIndex = 1;
else revert("only two players allowed");
// Require proper amount deposited
// 1 ETH as a bet + 1 ETH as a bond
require(msg.value == 2 ether, 'msg.value must be 2 eth');
// Store the commitment
players[playerIndex] = Player(msg.sender, commitment, false, 0);
// Move to next stage
if(stage == Stage.FirstCommit) stage = Stage.SecondCommit;
else stage = Stage.FirstReveal;
}
จากนั้น เมื่อผู้เล่นแต่ละคนทายเลข จะให้ส่ง hash ของเลขนั้นมาแทน
function reveal(uint number, bytes32 blindingFactor) public {
// Only run during reveal stages
require(stage == Stage.FirstReveal || stage == Stage.SecondReveal, "wrong stage");
// Find the player index
uint playerIndex;
if(players[0].addr == msg.sender) playerIndex = 0;
else if(players[1].addr == msg.sender) playerIndex = 1;
else revert("unknown player");
// Protect against double-reveal, which would trigger move to Stage.Distribution too early
require(!players[playerIndex].revealed, "already revealed");
// Check the hash to prove the player's honesty
require(keccak256(abi.encodePacked(msg.sender, number, blindingFactor)) == players[playerIndex].commitment, "invalid hash");
// Update player number if correct
players[playerIndex].number = number;
// Protect against double-reveal
players[playerIndex].revealed = true;
// Move to next stage
if(stage == Stage.FirstReveal) stage = Stage.SecondReveal;
else stage = Stage.Distribution;
}
เมื่อเข้าสู่เฟสการเปิดเลขเพื่อเทียบกัน ผู้เล่นแต่ละคนจะต้องส่งเลขที่ตัวเองทายไว้ก่อนหน้านี้มา พร้อมกับ secret ที่ใช้ในการ hash เลขนั้น
โดยถ้า hash แล้วค่าตรงกัน (ไม่ได้มั่วเลข แต่เป็นเลขเดิมจริงๆ) ก็จะไปยังเฟสการหาผู้ชนะและจ่ายรางวัลตาม code
function distribute() public {
// Only run during distribution stage
require(stage == Stage.Distribution, "wrong stage");
// Find winner
uint n = players[0].number + players[1].number;
// Payout winners winnings and bond
players[n%2].addr.call.value(3 ether)("");
// Payback losers bond
players[(n+1)%2].addr.call.value(1 ether)("");
// Reset the state
delete players;
stage = Stage.FirstCommit;
}
แต่ถ้าไม่ hash data ก็ใช้วิธีเก็บ off chain แทนได้ แต่ก็จะทำให้เสียความโปร่งใส (transparency) ไปนั่นเอง
โดย pattern การเก็บ private data แบบไม่ได้เข้ารหัสก็ถูกบรรจุเป็นหนึ่งใน SWC เช่นกัน
Weak PRNG
การใช้ค่าต่างๆของ blockchain เช่น blockhash, blocktimestamp ในการสุ่มเลข เสี่ยงต่อการถูกปั่นให้เลขที่ทายออกมา ตรงกับเลขที่ attacker อยากได้
โอเค มาลองดูตัวอย่างกัน
pragma solidity ^0.4.24;
//Based on the the Capture the Ether challange at https://capturetheether.com/challenges/lotteries/predict-the-block-hash/
//Note that while it seems to have a 1/2^256 chance you guess the right hash, actually blockhash returns zero for blocks numbers that are more than 256 blocks ago so you can guess zero and wait.
contract PredictTheBlockHashChallenge {
struct guess{
uint block;
bytes32 guess;
}
mapping(address => guess) guesses;
constructor() public payable {
require(msg.value == 1 ether);
}
function lockInGuess(bytes32 hash) public payable {
require(guesses[msg.sender].block == 0);
require(msg.value == 1 ether);
guesses[msg.sender].guess = hash;
guesses[msg.sender].block = block.number + 10;
}
function settle() public {
require(block.number > guesses[msg.sender].block);
bytes32 answer = blockhash(guesses[msg.sender].block);
guesses[msg.sender].block = 0;
if (guesses[msg.sender].guess == answer) {
msg.sender.transfer(2 ether);
}
}
}
จาก code จะเป็น contract ที่ให้คนเข้ามาทายว่าเลข blockhash ของ block ต่อไปคืออะไร ซึ่ง code นี้มี 2 ปัญหาคือ
- blockhash ของ block ถัดไป สามารถถูกเดาได้ หาก attacker เป็น miner ซะเอง จากตัวอย่างจะต้องทาย blockhash ของ block ถัดไปถูก แต่ถ้า miner ปั๊ม resource เพื่อให้ได้เป็นคนที่จะขุด block ถัดไปซะเอง เค้าก็สามารถส่ง guess มา แล้วขุด block ถัดไปให้ได้ blockhash ตรงกับ guess นั้น
- การหา blockhash ของ block ที่พ้นมาแล้วมากกว่า 256 block จะได้ค่าเป็น 0
ดังนั้นสามารถแก้ code เพื่อป้องกันการโจมตีทั้ง 2 ท่าได้โดย
- ตั้งให้ต้องทาย blockhash ที่ห่างไปเยอะๆ เช่น ต้องทาย blockhash ของ 10 block ถัดไป นี่จะช่วยลดโอกาสที่จะถูกโจมตี เพราะ attacker จะต้องขุดให้ได้ 10 block ต่อเนื่องกัน ซึ่งใช้ resource เยอะขึ้น
- ให้การเฉลยเกิดขึ้นภายใน 256 block เพื่อไม่ให้ค่า blockhash เป็น 0
จะได้ code ตามนี้
pragma solidity ^0.4.24;
//Based on the the Capture the Ether challange at https://capturetheether.com/challenges/lotteries/predict-the-block-hash/
//Note that while it seems to have a 1/2^256 chance you guess the right hash, actually blockhash returns zero for blocks numbers that are more than 256 blocks ago so you can guess zero and wait.
contract PredictTheBlockHashChallenge {
struct guess{
uint block;
bytes32 guess;
}
mapping(address => guess) guesses;
constructor() public payable {
require(msg.value == 1 ether);
}
function lockInGuess(bytes32 hash) public payable {
require(guesses[msg.sender].block == 0);
require(msg.value == 1 ether);
guesses[msg.sender].guess = hash;
guesses[msg.sender].block = block.number + 1;
}
function settle() public {
require(block.number > guesses[msg.sender].block +10);
//Note that this solution prevents the attack where blockhash(guesses[msg.sender].block) is zero
//Also we add ten block cooldown period so that a minner cannot use foreknowlege of next blockhashes
if(guesses[msg.sender].block - block.number < 256){
bytes32 answer = blockhash(guesses[msg.sender].block);
guesses[msg.sender].block = 0;
if (guesses[msg.sender].guess == answer) {
msg.sender.transfer(2 ether);
}
}
else{
revert("Sorry your lottery ticket has expired");
}
}
}
note: ตย. เป็นการโจมตีแบบ pow ไม่ใช่ pos