Reentrancy attacks ยังคงเป็นหนึ่งในภัยคุกคามที่รู้จักกันดีและเรื้อรังต่อความปลอดภัยของสมาร์ตคอนแทรกต์บน Ethereum และเชนที่เข้ากันได้กับ EVM แม้ว่าจะมีความพยายามของวงการอย่างต่อเนื่องมาหลายปี โปรโตคอลใหม่ ๆ ยังคงเผชิญกับช่องโหว่ reentrancy ทำให้เกิดความสูญเสียทางการเงินอย่างมีนัยสำคัญ—บางครั้งมีมูลค่าถึงหลายล้านดอลลาร์ การโจมตีเหล่านี้มักเกิดขึ้นในสัญญา DeFi lending, staking และ bridge ที่มีการเรียกภายนอกและการโอนโทเคนบ่อยครั้ง
ในบทความนี้ เราจะวิเคราะห์กลไกของการโจมตีแบบ reentrancy ระบุรูปแบบสัญญาที่มีช่องโหว่ทั่วไป และแบ่งปันกลยุทธ์ป้องกันที่ผ่านการทดสอบ โดยอิงจากการวิเคราะห์การตรวจสอบสมาร์ตคอนแทรกต์กว่า 255 รายการที่ Soken เราจะเน้นให้เห็นว่าการเขียนโค้ด Solidity อย่างละเอียดอ่อนและกระบวนการทดสอบที่ครอบคลุมสามารถป้องกันภัย reentrancy ได้อย่างไร บทความนี้ยังเชื่อมโยงไปยังความเสี่ยงที่เกี่ยวข้อง เช่น การใช้งาน delegatecall ผิดวิธีและกลยุทธ์ flash loan เพื่อให้เข้าใจภาพรวมที่จำเป็นสำหรับนักพัฒนา Web3 หรือผู้ตรวจสอบความปลอดภัยที่จริงจัง
การโจมตีแบบ Reentrancy คืออะไร และมันใช้ประโยชน์จากสมาร์ตคอนแทรกต์อย่างไร?
การโจมตีแบบ reentrancy เกิดขึ้นเมื่อสัญญาที่เป็นอันตรายเรียกใช้ฟังก์ชันของสัญญาที่มีช่องโหว่อย่างซ้ำ ๆ ก่อนที่จะเสร็จสิ้นการดำเนินการในรอบแรก โดยการใช้ประโยชน์จากสถานะของสัญญาที่ไม่สอดคล้องกัน
ในทางปฏิบัติ ช่องโหว่ reentrancy จะเกิดขึ้นจากการเปลี่ยนแปลงสถานะและการเรียกภายนอกที่จัดลำดับไม่ดีภายในสัญญา เมื่อสัญญาทำการโอน ETH หรือโทเคนผ่านการ call หรือ transfer ไปยังที่อยู่ที่ไม่น่าเชื่อถือ ผู้รับนั้นสามารถกลับเข้าฟังก์ชันที่มีช่องโหว่ซ้ำได้ก่อนที่ตัวแปรสถานะจะถูกอัปเดต ทำให้นักโจมตีสามารถถอนเงินหรือใช้ประโยชน์จากตรรกะของสัญญาได้โดยไม่คาดคิด
ตัวอย่างเช่น การโจมตี DAO ที่มีชื่อเสียงในปี 2016 ส่งผลให้ถูกขโมยเงินไปเกือบ 60 ล้านดอลลาร์ผ่านช่องโหว่ reentrancy ซึ่งเป็นบทเรียนที่ควรระวัง ตั้งแต่นั้นมา วงการได้ระบุว่าต้นเหตุหลักคือการอัปเดตยอดเงินหรือสถานะของสัญญาจะเกิดขึ้น หลัง การเรียกภายนอก ทำให้เกิดช่องว่างสถานะที่ไม่สอดคล้องกันที่นักโจมตีใช้ประโยชน์
ข้อมูลเชิงลึกจากการตรวจสอบของ Soken: “ในการตรวจสอบความปลอดภัย DeFi ที่ผ่านมามากกว่า 40% เราพบรูปแบบ reentrancy ในรูปแบบบางอย่าง โดยมักเป็นการแปรผันละเอียดอ่อนที่เกี่ยวข้องกับ proxy contracts หรือ layered calls การระบุจุดเหล่านี้จำเป็นต้องวิเคราะห์การเรียกระหว่างสัญญาอย่างละเอียดเกินกว่าการตรวจสอบสัญญาเดี่ยวเพียงอย่างเดียว”
รูปแบบ Solidity ทั่วไปที่ก่อให้เกิดช่องโหว่ Reentrancy
ช่องโหว่ reentrancy ส่วนใหญ่เกิดจากลำดับการทำงานเฉพาะ: การเรียกภายนอก (เช่น การโอน ETH หรือการเรียกโทเคน) ที่เกิดขึ้นก่อนที่สัญญาจะอัปเดตตัวแปรสถานะภายใน
รูปแบบที่มีช่องโหว่อย่างมากคือ:
function withdraw(uint256 amount) public {
require(balances[msg.sender] >= amount);
(bool success, ) = msg.sender.call{value: amount}("");
require(success);
balances[msg.sender] -= amount;
}
ตรงนี้ การเรียกภายนอกด้วย call.value() เกิดขึ้น ก่อน จะหักยอดเงินของผู้ใช้ ระหว่างการเรียกนี้ ฟังก์ชัน fallback ของนักโจมตีสามารถเรียกใช้งาน withdraw() ซ้ำได้ ทำให้ถอนเงินได้ก่อนที่ยอดเงินจะถูกอัปเดต
โครงสร้างที่เสี่ยงหลักได้แก่:
- เรียกภายนอกก่อนการอัปเดตสถานะ: ส่ง ETH หรือโทเคนก่อนอัปเดตยอดเงิน
- ใช้
callหรือsendโดยไม่มีมาตรการป้องกัน: การเรียกภายนอกผ่านฟังก์ชันระดับต่ำมักหลีกเลี่ยงการตรวจสอบและสามารถเรียกใช้ fallback functions ได้ - ไม่มีการป้องกัน reentrancy: ไม่มี mutex หรือกลไกล็อกอื่น
- สายการเรียกที่ซับซ้อนโดยใช้ delegatecall: อาจเปิดช่องโหว่ reentrancy โดยอ้อมผ่านการเรียกภายนอกในสัญญาที่ถูกเรียก
ในทางตรงกันข้าม รูปแบบที่ปลอดภัยจะกลับลำดับเป็น:
function withdraw(uint256 amount) public {
require(balances[msg.sender] >= amount);
balances[msg.sender] -= amount; // อัปเดตสถานะก่อน
(bool success, ) = msg.sender.call{value: amount}("");
require(success);
}
วิธีป้องกันการโจมตี Reentrancy ใน Solidity: แนวทางปฏิบัติที่ดีที่สุดและเครื่องมือ
แนวทางป้องกันที่ได้ผลที่สุดต่อการโจมตี reentrancy คือ อัปเดตสถานะก่อนการเรียกภายนอก พร้อมทั้งใช้รูปแบบป้องกัน reentrancy อย่างชัดเจน
เทคนิคมาตรฐานในการลดความเสี่ยง:
-
Checks-Effects-Interactions Pattern:
ควรอัปเดตสถานะภายในก่อนทำการเรียกภายนอกเสมอ เพื่อหลีกเลี่ยงสถานะของสัญญาที่ไม่สอดคล้องกันขณะเรียกภายนอก -
Reentrancy Guards / Mutexes:
ReentrancyGuardของ Solidity จาก OpenZeppelin ใช้ mutex ง่าย ๆ เพื่อบล็อกการเรียกซ้อนกัน ใช้งานโดย:
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract SecureContract is ReentrancyGuard {
mapping(address => uint256) balances;
function withdraw(uint256 amount) public nonReentrant {
require(balances[msg.sender] >= amount);
balances[msg.sender] -= amount;
(bool success, ) = msg.sender.call{value: amount}("");
require(success);
}
}
-
จำกัดการเรียกภายนอก:
ลดการโอนหรือการโต้ตอบในฟังก์ชันที่สำคัญ—ใช้วิธีให้ผู้ใช้ถอนเงิน (pull) แทนการโอนแบบอัตโนมัติ (push) -
การวิเคราะห์สแตติกและเครื่องมืออัตโนมัติ:
เครื่องมืออย่าง Slither และเฟรมเวิร์กการตรวจสอบของ Soken แจ้งเตือนช่องโหว่ reentrancy -
ใช้เวอร์ชัน Solidity ล่าสุดและฟีเจอร์ใหม่:
Solidity 0.8+ มีการตรวจสอบการล้นในตัว แต่ความเสี่ยง reentrancy ยังอยู่ ดังนั้นควรรวมโค้ดที่ปลอดภัยกับรูปแบบการออกแบบที่เหมาะสม
แนวทางของ Soken: การตรวจสอบของเราผสมผสานการสร้างแบบจำลองภัยคุกคามด้วยมือ การดำเนินการเชิงสัญลักษณ์ และการทำ fuzz testing ซึ่งตรวจหา flow پیچیده reentrancy ที่เครื่องสแกนทั่วไปมักมองไม่เห็น
ตัวอย่างจริงของการโจมตี Reentrancy และผลกระทบ
แม้ว่าจะมีการรับรู้เพิ่มขึ้น การโจมตี reentrancy ยังคงแพร่หลาย ด้านล่างเป็นเปรียบเทียบเหตุการณ์โจมตีที่แสดงความหลากหลายและต้นทุนของการโจมตีดังกล่าว:
| เหตุการณ์ | วันที่ | จำนวนเงินสูญเสีย | รูปแบบที่ถูกโจมตี | บทเรียนสำคัญ |
|---|---|---|---|---|
| The DAO Hack | 2016-06 | ~$60 ล้านดอลลาร์ | Reentrancy ใน splitDAO proposal | อัปเดตสถานะก่อนเรียกภายนอก |
| The Lendf.Me Hack | 2020-04 | $25 ล้าน+ | Flash loan เสริม reentrancy | รวม reentrancy guard + ความทนทาน flash loan |
| Euler Finance Hack | 2023-03 | $197 ล้าน | Reentrancy ซ้อน + delegatecall | สายการเรียกซับซ้อนเพิ่มความเสี่ยง |
| Recent DeFi Bridge Hack | 2025-11 | $15 ล้าน | โจมตี transfer token แบบ reentrancy | ตรวจสอบการโต้ตอบข้ามสัญญา |
หมายเหตุ: การตรวจสอบของ Soken เน้นความปลอดภัยแบบหลายชั้น โดยเฉพาะบริเวณการเรียกระหว่างสัญญาและมาตรฐานโทเคนที่อาจเรียก fallback functions แบบไดนามิก
Delegatecall และ Flash Loan: ตัวเร่งความเสี่ยง Reentrancy
Delegatecall ที่รันโค้ดในบริบทของสัญญาที่เรียก อาจปกปิดความเสี่ยง reentrancy และเพิ่มพื้นผิวการโจมตี โดยเฉพาะเมื่อใช้ร่วมกับ flash loans
การโจมตี reentrancy มักใช้ประโยชน์จาก flash loan เพื่อเพิ่มทุนสำหรับการถอนซ้ำ ๆ:
- Flash loans มอบสภาพคล่องก้อนใหญ่ทันทีในธุรกรรมเดียว
- นักโจมตีใช้เงินกู้ flash เพื่อกระตุ้นการเรียก reentrant เพื่อถอนสภาพคล่องของโปรโตคอลก่อนคืนเงินกู้
- การใช้ delegatecall อาจเรียกโค้ดภายนอกที่ไม่น่าเชื่อถือ แก้ไขสถานะสัญญาหรือเปิดช่องทางให้เกิดการกลับเข้าซ้ำ
มุมมองความปลอดภัย: “จากประสบการณ์ของ Soken การป้องกัน reentrancy ขยายไปไกลกว่าสัญญาเดียวถึงการออกแบบโปรโตคอลโดยรวม โดยเฉพาะเมื่อ delegatecall และ flash loans ทำงานร่วมกัน สัญญาจำเป็นต้องผสมผสาน reentrancy guards กับการตรวจสอบการเข้าถึงและการเช็คเฉพาะ flash loan (เช่น ขีดจำกัดจำนวนเงินกู้ และข้อจำกัดการเรียกซ้ำ)”
ตารางเปรียบเทียบ: เทคนิคป้องกัน Reentrancy ทั่วไป
| เทคนิค | คำอธิบาย | ข้อดี | ข้อเสีย | การใช้งาน |
|---|---|---|---|---|
| Checks-Effects-Interactions | อัปเดตสถานะก่อนเรียกภายนอก | ง่ายและมีประสิทธิภาพ | ต้องมีวินัย | การปฏิบัติพื้นฐานด้านความปลอดภัย |
| Reentrancy Guard (mutex) | ใช้ตัวแก้ไขของ Solidity ป้องกันการกลับเข้าซ้ำ | บล็อกการเรียกซ้อนชัดเจน | เพิ่มค่าแก๊สเล็กน้อย | แนะนำสำหรับฟังก์ชันถอนเงินทั้งหมด |
| Pull Payment Pattern | ให้ผู้ใช้ถอนเงินโดยสมัครใจ | ลดความเสี่ยงจากการโอนอัตโนมัติ | ต้องมีปฏิสัมพันธ์จากผู้ใช้ | เหมาะกับสัญญา escrow และ staking |
| Static and Dynamic Analysis | ตรวจสอบโค้ดด้วยอัตโนมัติและแมนนวล | ตรวจพบช่องโหว่แต่เนิ่น ๆ | อาจพลาด flow ซับซ้อนข้ามสัญญา | จำเป็นสำหรับความมั่นใจก่อนปล่อย |
| Flash Loan & Delegatecall Checks | ตรวจสอบผู้เรียกและบริบทธุรกรรม | ป้องกัน reentrancy ที่ถูกเร่งด้วยเงินกู้ | ซับซ้อนในการใช้งาน | สำคัญในโปรโตคอล DeFi ที่มีการกู้ |
ตัวอย่างโค้ด Solidity: สัญญาที่มีช่องโหว่ Reentrancy และการแก้ไข
นี่คือตัวอย่างสัญญาที่มีช่องโหว่ และเวอร์ชันที่แข็งแกร่งขึ้นด้วย reentrancy guard:
// ช่องโหว่: ถอนเงินก่อนอัปเดตยอดเงิน
contract Vulnerable {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount, "Insufficient funds");
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
balances[msg.sender] -= amount; // ช่องโหว่: อัปเดตสถานะหลังการเรียกภายนอก
}
}
แก้ไขโดยใช้ ReentrancyGuard ของ OpenZeppelin และอัปเดตสถานะก่อนการโต้ตอบ:
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract Secure is ReentrancyGuard {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint256 amount) external nonReentrant {
require(balances[msg.sender] >= amount, "Insufficient funds");
balances[msg.sender] -= amount; // อัปเดตสถานะก่อนเรียกภายนอก
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
}
คำแนะนำผู้เชี่ยวชาญ: ควรรวม checks-effects-interactions กับ reentrancy guards เสมอ การตรวจสอบกราฟการเรียกสัญญาและการทดสอบด้วย fuzzing สามารถเปิดเผยช่องโหว่ reentrancy ที่ซับซ้อนได้ก่อนการปรับใช้
ช่องโหว่ reentrancy ยังคงเป็นภัยคุกคามร้ายแรงต่อสมาร์ตคอนแทรกต์ โดยเฉพาะในระบบนิเวศ DeFi ที่ซับซ้อนซึ่งใช้ flash loans และ delegatecall การป้องกันที่ได้ผลที่สุดคือการปฏิบัติตามวินัยโค้ด (checks-effects-interactions) การใช้ reentrancy guards อย่างชัดเจน การตรวจสอบอย่างเข้มงวด และการยืนยันบริบทของธุรกรรม
ที่ Soken งานวิจัยด้านความปลอดภัยของเราผสมผสานเทคนิคแมนนวลและอัตโนมัติที่สั่งสมจากการตรวจสอบ 282+ สมาร์ตคอนแทรกต์ ซึ่งช่วยในการระบุและแก้ไขช่องโหว่ reentrancy และความเสี่ยงที่เกี่ยวข้อง การยึดมั่นในแนวทางปฏิบัติที่ดีที่สุดและวิธีการทดสอบที่ละเอียดถี่ถ้วนจึงเป็นสิ่งจำเป็นสำหรับทุกโปรเจกต์ที่จัดการเงินของผู้ใช้
ต้องการคำปรึกษาด้านความปลอดภัยจากผู้เชี่ยวชาญ? ทีมผู้ตรวจสอบของ Soken ได้ตรวจสอบสมาร์ตคอนแทรกต์กว่า 255 รายการและปกป้องมูลค่าโปรโตคอลมากกว่า 2 พันล้านดอลลาร์ ไม่ว่าคุณจะต้องการ การตรวจสอบอย่างครบถ้วน, ประเมินความปลอดภัย X-Ray ฟรี, หรือช่วยนำทางด้าน กฎระเบียบคริปโต เราพร้อมช่วยคุณเสมอ