ช่องโหว่ unexpected ether balance ใน Solidity

Nattawat Songsom
5 min readSep 11, 2023

--

มาดูช่องโหว่ที่เกิดจากกลไกการส่ง ETH ใน smart contract กัน

โอเค มาลองเริ่มจากการ design smart contract game กัน

โดย users สามารถโอนทีละ 0.5 ETH เข้ามาเพื่อเล่นในแต่ละรอบได้

ถ้าตอนที่ user โอนเข้ามา ตัว smart contract มียอด ETH ถึงระดับที่กำหนด … user จะได้รับสิทธิ์ในการ claim ETH ในภายหลัง โดยมีเงื่อนไขตามนี้

  • ถ้าโอนเข้ามา แล้ว contract มียอด balance ถึง 3 ETH … user สามารถ claim ได้ 2 ETH
  • ถ้าโอนเข้ามา แล้ว contract มียอด balance ถึง 5 ETH … user สามารถ claim ได้ 3 ETH
  • ถ้าโอนเข้ามา แล้ว contract มียอด balance ถึง 10 ETH … user สามารถ claim ได้ 3 ETH

โดยถ้า contract มียอดถึง 10 ETH แล้ว … user จะไม่สามารถเล่นได้อีก แต่จะเข้าสู่ช่วง claimReward แทน

โดยจะมีการ claimReward เกิดขึ้นได้แค่หนึ่งครั้ง

นั่นคือ เมื่อ contract มียอด balance ถึง 10 ETH แล้ว

user ที่เร็วที่สุดจะสามารถ claim reward ในส่วนของตัวเองออกไปได้

จากนั้นจะเข้าสู่ช่วงลงเงินอีกครั้ง

จนกว่า contract จะมียอด balance ถึง 10 ETH

ดังนั้นช่วงหรือ phase จะวนไปมาแบบนี้ play => claimReward => play นั่นเอง

โอเค พอเขียนเป็น code จะได้ประมาณนี้

contract EtherGame {

uint public payoutMileStone1 = 3 ether;
uint public mileStone1Reward = 2 ether;
uint public payoutMileStone2 = 5 ether;
uint public mileStone2Reward = 3 ether;
uint public finalMileStone = 10 ether;
uint public finalReward = 5 ether;

mapping(address => uint) redeemableEther;
// users pay 0.5 ether. At specific milestones, credit their accounts
function play() public payable {
require(msg.value == 0.5 ether); // each play is 0.5 ether
uint currentBalance = this.balance + msg.value;
// ensure no players after the game as finished
require(currentBalance <= finalMileStone);
// if at a milestone credit the players account
if (currentBalance == payoutMileStone1) {
redeemableEther[msg.sender] += mileStone1Reward;
}
else if (currentBalance == payoutMileStone2) {
redeemableEther[msg.sender] += mileStone2Reward;
}
else if (currentBalance == finalMileStone ) {
redeemableEther[msg.sender] += finalReward;
}
return;
}

function claimReward() public {
// ensure the game is complete
require(this.balance == finalMileStone);
// ensure there is a reward to give
require(redeemableEther[msg.sender] > 0);
uint transferValue = redeemableEther[msg.sender];
redeemableEther[msg.sender] = 0;
msg.sender.transfer(transferValue);
}
}

คำถามคือ code นี้มีช่องโหว่ยังไง ?

.

.

.

.

.

.

.

.

.

.

.

.

.

ประเด็นคือ ถ้าดูเผินๆ user จะสามารถส่ง ETH เข้ามาที่ contract ได้ผ่านทาง function play เท่านั้น

เพราะว่า

  1. contract ไม่ได้ implement payable function อันอื่นไว้
  2. contract ไม่มี function receive/fallback ไว้รับ ETH

แต่สมมติฐานนี้ผิด

เนื่องจากด้วยธรรมชาติ EVM contract นี้ยังสามารถรับ Ether เข้ามาจากทางอื่นได้อีก

เช่น

  1. contract attacker ทำการ self destruct แล้วส่ง ETH ทั้งหมดใน contract ตัวเอง มาที่ contract game ของเรา ด้วย code ประมาณนี้
contract Attack {

...

function attack(address etherGame) public payable {
address payable addr = payable(etherGame);
selfdestruct(addr);
}
}

นี่จะทำให้ logic game เพี้ยน เช่น

สามารถทำให้เกมเล่นต่อไม่ได้อีก

จากบรรทัดนี้

require(currentBalance <= finalMileStone);

ซึ่งอยู่ใน function play

function play() public payable {
require(msg.value == 0.5 ether); // each play is 0.5 ether
uint currentBalance = this.balance + msg.value;
// ensure no players after the game as finished
require(currentBalance <= finalMileStone);
// if at a milestone credit the players account
if (currentBalance == payoutMileStone1) {
redeemableEther[msg.sender] += mileStone1Reward;
}
else if (currentBalance == payoutMileStone2) {
redeemableEther[msg.sender] += mileStone2Reward;
}
else if (currentBalance == finalMileStone ) {
redeemableEther[msg.sender] += finalReward;
}
return;
}

ถ้า contract มี balance 9.5 ETH

attacker สามารถ self destruct เพื่อส่ง 1 ETH เข้ามา

ทำให้ contract มี 10.5 ETH

ซึ่งนี่จะทำให้ไม่สามารถเรียก play() ได้อีก เนื่องจากติดบรรทัดนี้

require(currentBalance <= finalMileStone);

และก็ไม่สามารถ claimReward ได้

function claimReward() public {
// ensure the game is complete
require(this.balance == finalMileStone);
// ensure there is a reward to give
require(redeemableEther[msg.sender] > 0);
uint transferValue = redeemableEther[msg.sender];
redeemableEther[msg.sender] = 0;
msg.sender.transfer(transferValue);
}

เนื่องจากติดที่บรรทัดนี้นั่นเอง

require(this.balance == finalMileStone);

2. attacker ทำการคำนวณ address ของ contract game ก่อนที่ contract game จะถูก deploy แล้วทำการส่ง ETH มารอไว้เลย

โดยถ้า attacker รู้ address ของคน deploy

และดักฟังค่า nonce ของคน deploy อยู่แล้ว

เค้าสามารถคำนวณ address ของ contract ที่จะถูก deploy ได้ด้วยสูตร

keccak256(rlp.encode([<account_address>, <transaction_nonce>])

นี่จะทำให้ logic การคำนวณที่ใช้ค่า ETH balance ของ contract เพี้ยนเช่นกัน

ตัวอย่างคือ

ถ้า attacker ส่ง 0.1 ETH เข้ามาก่อนที่ contract จะถูก deploy

นี่จะทำให้ไม่มีใครมีสิทธิในการ claimReward เลย

เช่น จากบรรทัด

if (currentBalance == payoutMileStone1)

จาก

uint public payoutMileStone1 = 3 ether;
uint public mileStone1Reward = 2 ether;
uint public payoutMileStone2 = 5 ether;
uint public mileStone2Reward = 3 ether;
uint public finalMileStone = 10 ether;
uint public finalReward = 5 ether;

mapping(address => uint) redeemableEther;
// users pay 0.5 ether. At specific milestones, credit their accounts
function play() public payable {
require(msg.value == 0.5 ether); // each play is 0.5 ether
uint currentBalance = this.balance + msg.value;
// ensure no players after the game as finished
require(currentBalance <= finalMileStone);
// if at a milestone credit the players account
if (currentBalance == payoutMileStone1) {
redeemableEther[msg.sender] += mileStone1Reward;
}
else if (currentBalance == payoutMileStone2) {
redeemableEther[msg.sender] += mileStone2Reward;
}
else if (currentBalance == finalMileStone ) {
redeemableEther[msg.sender] += finalReward;
}
return;
}

ถ้าเริ่มต้นด้วย contract ที่มี 0.1 ETH แล้วเติมได้ทีละ 0.5 ETH

จะไม่มีทางที่ balance จะเท่ากับ 2 ETH เป๊ะๆได้

โอเค แล้วเราแก้ปัญหานี้ยังไงได้บ้าง ?

  1. ถ้ายังอยากใช้ this.balance ในการคำนวณ logic อยู่ ให้ใช้การเปรียบเทียบกว้างๆ แทนการเปรียบเทียบแบบเป๊ะๆ เช่นใช้ ≥ ≤ แทน ==

เช่น

contract EtherGame {

uint public payoutMileStone1 = 3 ether;
uint public mileStone1Reward = 2 ether;
uint public payoutMileStone2 = 5 ether;
uint public mileStone2Reward = 3 ether;
uint public finalMileStone = 10 ether;
uint public finalReward = 5 ether;

mapping(address => uint) redeemableEther;
// users pay 0.5 ether. At specific milestones, credit their accounts
function play() public payable {
require(msg.value == 0.5 ether); // each play is 0.5 ether
uint currentBalance = this.balance + msg.value;
// ensure no players after the game as finished
require(currentBalance <= finalMileStone);
// if at a milestone credit the players account
if (currentBalance <= payoutMileStone1) {
redeemableEther[msg.sender] += mileStone1Reward;
}
else if (currentBalance > payoutMileStone1 && currentBalance <= payoutMileStone2) {
redeemableEther[msg.sender] += mileStone2Reward;
}
else if (currentBalance > payoutMileStone2 && currentBalance >= finalMileStone ) {
redeemableEther[msg.sender] += finalReward;
}
return;
}

function claimReward() public {
// ensure the game is complete
require(this.balance >= finalMileStone);
// ensure there is a reward to give
require(redeemableEther[msg.sender] > 0);
uint transferValue = redeemableEther[msg.sender];
redeemableEther[msg.sender] = 0;
msg.sender.transfer(transferValue);
}
}

นี่จะแก้ปัญหา deadlock จาก ETH ที่เข้ามาแบบคาดไม่ถึงได้

แต่ก็จะทำให้ logic ของเกมเพี้ยนเช่นกัน

เช่น

ตอนนี้ใน phase claimReward สามารถ claim ได้มากกว่า 1 ครั้ง แล้วซึ่งไม่ตรงกับ requirement ของเกม

หรืออีกเคสนึง

ปกติแล้ว

redeemableEther[msg.sender] += mileStone1Reward;

จะถูกรันก่อน

redeemableEther[msg.sender] += mileStone2Reward;

แต่ถ้ามีการ force send ETH เข้าไป จะสามารถ bypass ไปบันทึก redeemableEther ของ mileStone2Reward ได้เลย

โอเค วิธีนี้ไม่ได้แก้ปัญหาทั้งหมด

แต่มีอีกวิธีนึงที่แก้ทุกปัญหาได้ นั่นคือ

2. แยก logic contract ให้คำนวณเฉพาะ ETH ที่ส่งเข้ามาในช่องทางที่ design ไว้

โอเค ลองมาดู code กัน

contract EtherGame {

uint public payoutMileStone1 = 3 ether;
uint public mileStone1Reward = 2 ether;
uint public payoutMileStone2 = 5 ether;
uint public mileStone2Reward = 3 ether;
uint public finalMileStone = 10 ether;
uint public finalReward = 5 ether;
uint public depositedWei;

mapping (address => uint) redeemableEther;

function play() public payable {
require(msg.value == 0.5 ether);
uint currentBalance = depositedWei + msg.value;
// ensure no players after the game as finished
require(currentBalance <= finalMileStone);
if (currentBalance == payoutMileStone1) {
redeemableEther[msg.sender] += mileStone1Reward;
}
else if (currentBalance == payoutMileStone2) {
redeemableEther[msg.sender] += mileStone2Reward;
}
else if (currentBalance == finalMileStone ) {
redeemableEther[msg.sender] += finalReward;
}
depositedWei += msg.value;
return;
}

function claimReward() public {
// ensure the game is complete
require(depositedWei == finalMileStone);
// ensure there is a reward to give
require(redeemableEther[msg.sender] > 0);
uint transferValue = redeemableEther[msg.sender];
redeemableEther[msg.sender] = 0;
msg.sender.transfer(transferValue);
}
}

จาก code เราจะใช้ตัวแปร depositedWei ในการคำนวณ logic เกมแทน

ดังนั้น ETH ที่ส่งมาจากการ selfdestruct หรือวิธีอื่นๆ จะไม่ถูกนำมาคำนวณใน logic

นี่ทำให้ contract สามารถป้องกัน unexpected Ether ได้นั่นเอง

Referrences

--

--

No responses yet