ReEntrance Attack in Solidity
ภาคต่อจาก fallback()
Tl;Dr: ReEntrance attack คือการที่ contract A ใช้ช่องว่างจากการเรียกใช้คำสั่ง ที่มีการโอน Ether ใน contract B เข้าไปยัง contract A ขณะที่ฟังก์ชันใน contract B ที่ถูกเรียกยังทำงานไม่เสร็จ contract A สามารถเรียกใช้ fallback() ใน contract A โดยอัตโนมัติ (เพราะมี Ether เข้ามา) เพื่อเข้าไปโอน ether จาก contract B ออกมาได้อีกเรื่อย ๆ จนหมด
ReEntrance attack: Any interaction from a contract (A) with another contract (B) and any transfer of Ether hands over control to that contract (B). This makes it possible for B to call back into A before this interaction is completed.
Introduction
จากเรื่อง fallback() ที่ได้อธิบายไปแล้ว ซึ่งเป็นส่วนสำคัญ ในการเข้าใจ การทำงานของ ReEntrance attack โดย attacker สามารถ เรียกใช้ฟังก์ชันที่ โอน ether ส่งต่อไปยัง wallet ของตัวเองได้จนหมด
EtherStore contract
ตัวอย่าง EtherStore contract ที่มีช่องโหว่ของ ReEntrance Attack ใน contract นี้ จะมีฟังก์ชัน deposit()
และ withdraw()
ให้ฝาก ether เข้ามาใน EtherStore contract และ สามารถโอน ether ออกไปได้ เมื่อเวลาผ่านไป 1 สัปดาห์ ถ้าดูตัวอย่าง code แบบไม่สังเกตอะไร ในฟังก์ชัน withdraw() ก็เหมือน ทำงานได้ปรกติดี เพราะมีเงื่อนไขในการตรวจสอบ 3 เงื่อนไขคือ
balances[msg.sender] > _amount
คนถอน จำเป็นต้องมี etherใน contract นั้น_amount <= WITHDRAWAL_LIMIT
จำนวนการถอนแต่ละครั้ง ต้องไม่เกิน limit ที่ตั้งไว้block.timestamp >= lastWithdrawTime[msg.sender] + 1 weeks
ไม่สามารถถอน ether ติดกันภายใน 1 สัปดาห์ได้
สมมุติว่า มี Bob, Alice และคนอื่น ๆ โอน ether เข้ามาใน EtherStore แล้ว ทำให้ EtherStore จะมี ether สะสมอยู่จำนวนหนึ่ง
จากเงื่อนไขที่กำหนด ดูเหมือนจะปรกติ เดี๋ยวเราลองมาดูการโจมตีแบบ ReEntrance กันครับ
ReEntrance Attack
เริ่มการโจมตี โดยการ Deploy Attack contract ที่มีการส่ง address ที่ EtherStore contract ที่ deploy แล้ว เพื่อ เชื่อมโยงไปยัง ether contract จากนั้นใช้ Attack contract โดยการเรียกใชั ฟังก์ชัน attack() มีการเรียกใช้ etherStore.withdraw(1 ether)
เป็นการเรียกใช้ withdraw()
ใน EtherStore contract นั่นเอง
สิ่งที่เกิดขึ้น ในการ โจมตี ลำดับตามนี้
etherStore.deposit{value: 1 ether}()
หมายความว่าจะมีการโอน ether เข้าไปยัง EtherStore contract 1 ether ทำให้ มีการไปเรียกใช้ deposit() ใน EtherStore และในbalances[msg.sender];
นั้น msg.sender จะเป็น address ของ Attack contract นั่นเอง (ปรกติ แล้วจะต้องเป็น address ของ wallet ของผู้ที่สั่งฝาก ether เข้าไป)- เมื่อมี ether เข้าโอนเข้ามาใน EtherStore สิ่งที่เกิดขึ้น คือ มันจะต้องเรียก
receive()
หรือfallback()
ใน EtherStore contract แต่ใน EtherStore contract ไม่มี 2 ฟังก์ชันนี้ จึงไม่เกิดอะไรขึ้นต่อ และย้อนกลับไปยัง Attack contract ต่อไป etherStore.withdraw(1 ether)
ใน Attack contract มีการถอน 1 ether จาก EtherStore contract ทำให้ มีการไปเรียกฟังก์ชัน withdraw() ใน EtherStore contract- ก่อนหน้านี้ ที่ฝาก ether เข้าไปใน EtherStore แล้ว จาก require() ทั้ง 3 ข้อ ก็ผ่านหมด มีข้อสุดท้าย เรื่องเงื่อนไขเวลา ที่ดูแปลกไปหน่อย แต่ก็ถือว่า ผ่าน เพราะ ตอนที่ฝาก ether เข้าไปใน EtherStore นั้น สังเกตได้ว่า ยังไม่มีการกำหนดค่า
lastWithdrawTime[msg.sender]
นั่นทำให้ เมื่อฝากไปแล้ว หากยังไม่เคยถอน ก็จะสามารถถอน ether ได้เลย เพราะblock.timestamps > 0 + 1 weeks
แต่ถ้าเคยถอนแล้ว จะถอนครั้งต่อไป ต้องรอก่อน อย่างน้อย 1 สัปดาห์ - EtherStore contract สั่งให้ ถอน ether ผ่านคำสั่ง
msg.sender.call{value: _amount}()
ทำให้ มีการโอน ether ออกจาก EtherStore contract ไปยัง Attacker contract เพราะ Attacker contract คือ ผู้ที่สั่งให้โอน (โดยคำสั่งที่เหลือจะรอค้างไว้ ทำให้สามารถเข้ามาถอนได้ต่อ) - เมื่อมีการโอน ether เข้ามายัง Attack contract ฟังก์ชัน
fallback()
ของ attack contract จะถูกเรียกใช้โดยอัตโนมัติ ซึ่งในfallback()
มีการตรวจสอบว่า EtherStore contract ยังมี ether เหลืออยู่ไหม ถ้าเหลือ ก็สั่งโอน ether ออกมาต่อทีละ 1 ether - การสั่งโอน ether จาก EtherStore contract ออกมา วน กลับไปทำข้อ 3–6 ใหม่อีกรอบ และ จะวนซ้ำไปเรื่อย ๆ จนกว่า ether ใน EtherStore contract จะหมด และ นี้คือ สิ่งที่เรียกว่า ReEntrace เพราะมันวนกลับเข้าไป (ReEntrance) ซ้ำหลาย ๆ รอบ ตั้งแต่
msg.sender.call{}()
และ ในwithdraw()
ของ EtherStore contract ยังทำไม่เสร็จ - สุดท้าย ether จะถูกโอนออกจาก EtherStore contract จนหมด จากนั้นจึงไปทำคำสั่งที่เหลือ และ ether ก็ถูกโอนออกไปหมดแล้ว
ขอสรุป ReEntrance attack ด้วยภาพนี้
ReEntrance protection
- ให้ update ข้อมูล (state variable) ก่อน ในตัวอย่างคือ
balances[msg.sender]
การโอน (เรียกฟังก์ชันภายนอก) ether ออกไป ดังนี้
แล้วค่อย update ข้อมูล เหตุการณ์ คล้าย ๆ แบบนี้ เป็นช่องโหว่สำคัญใน smart contract หลายอัน
2. ใช้ ReEntrance Guard เพื่อให้เรียกใช้ withdraw() ได้ครั้งเดียว ถ้า withdraw() ยังทำงานไม่เสร็จ จะไม่สามารถเข้ามา เรียกใช้ withdraw() ได้อีก จากการดักของตัวแปร locked
สุดท้ายนี้ ขอแนะนำ ReEntrance Guard แบบ Gas optimization เปลี่ยนจาก bool เป็น unit256 จาก OpenZepplin ขอบคุณ Unnawut L. สำหรับคำแนะนำครับ
อ้างอิง:
- https://solidity-by-example.org/hacks/re-entrancy/
- https://docs.soliditylang.org/en/v0.8.6/security-considerations.html
- https://www.youtube.com/watch?v=4Mm3BCyHtDY
- https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/security/ReentrancyGuard.sol?fbclid=IwAR1oC2KNdUuL_6cb_36_ahis_T9S_hl8NEZGLqx-7IhLjo886biYWB_9Q9A