การโจมตีแบบ Reentrancy เป็นหนึ่งในช่องโหว่ของ smart contract ที่เก่าแก่ที่สุด—and ยังทำกำไรได้มากที่สุด—ใน DeFi มันเข้าใจได้ไม่ยาก เผลอใส่เข้ามาได้ง่าย และสร้างความเสียหายหนักเมื่อไปกระทบเส้นทางบัญชีหลัก (core accounting paths) อย่างการถอน (withdrawals), การ liquidations และการเคลมรางวัล (reward claims) แม้แต่ทีมที่มีประสบการณ์ก็ยังส่งโค้ดที่มีช่องโหว่ reentrancy ออกสู่ production ได้ เพราะบั๊กมักซ่อนอยู่ในการเรียกภายนอกที่ดู “ปกติ”: การส่ง ETH, โอนโทเคน, เรียก vault หรือโต้ตอบกับ AMM
บทความนี้อธิบาย ว่า reentrancy attack ทำงานอย่างไร, “smart contract reentrancy” ในระบบจริงหน้าตาเป็นแบบไหน ทำไมมันยังสำคัญแม้หลังเหตุการณ์ DAO และวิธีทำ reentrancy prevention in Solidity แบบชัดเจนด้วยแพตเทิร์นที่พิสูจน์แล้ว (Checks-Effects-Interactions, pull payments, ReentrancyGuard และการออกแบบ external-call อย่างระมัดระวัง) นอกจากนี้เรายังเชื่อม reentrancy เข้ากับหัวข้อใกล้เคียงอย่าง flash-loan attacks, delegatecall risks และ best practices ของ Solidity ยุคปัจจุบัน
ที่ Soken (soken.io) เรารีวิวสัญญา production มาแล้วหลายร้อยฉบับ ครอบคลุม lending, staking, bridges และ governance และพบว่า reentrancy ยังเป็นสาเหตุหลักหรือปัจจัยร่วมที่เจอซ้ำ ๆ—โดยเฉพาะเมื่อโปรโตคอลเริ่ม integrate โทเคนใหม่ ๆ, hooks, callbacks หรือ DeFi primitives ที่ composable
Reentrancy attack คืออะไร (และทำไมถึงอันตรายมาก)?
Reentrancy attack เกิดขึ้นเมื่อสัญญา (contract) ทำ external call ก่อนที่จะอัปเดต state ของตัวเองให้เสร็จ ทำให้ฝั่งที่ถูกเรียกสามารถเรียกกลับ (“re-enter”) เข้าสู่ฟังก์ชันที่มีช่องโหว่และทำซ้ำการกระทำอย่างการถอนเงินหลายครั้งได้
Reentrancy อันตรายเพราะ Ethereum เป็นแบบ synchronous: เมื่อสัญญาของคุณเรียกสัญญาอื่น การทำงานจะกระโดดไปที่สัญญาภายนอกทันที—ก่อนที่ฟังก์ชันของคุณจะทำงานจบ หากสัญญาของคุณยังไม่ได้อัปเดตยอดคงเหลือหรือ flag สำหรับล็อก ผู้โจมตีก็สามารถใช้ประโยชน์จากสถานะ “คั่นกลาง” นั้นได้
Quotable (40–60 words):
การโจมตีแบบ reentrancy ใช้ประโยชน์จากสัญญาที่ทำ external call ก่อนปิดงานบัญชีภายในให้เสร็จ เนื่องจากการเรียกใน EVM เป็นแบบ synchronous ฝั่งที่ถูกเรียกสามารถ re-enter ฟังก์ชันที่มีช่องโหว่ได้ในขณะที่ state ยังสะท้อนยอด “เดิม” ส่งผลให้ถอนซ้ำ เคลมซ้ำ หรือข้ามข้อจำกัดได้—มักทำให้ TVL ถูกดูดออกในธุรกรรมเดียว
โมเดลความคิดคลาสสิก: “ฉันเรียกออกไปเร็วเกินไป”
ลำดับที่เปราะบางมักเป็นแบบนี้:
- ผู้ใช้เรียก
withdraw() - สัญญาส่ง ETH/โทเคนให้ผู้ใช้ (external call)
- สัญญาอัปเดต
balances[msg.sender](ช้าเกินไป) - ฟังก์ชัน fallback/receive ของผู้โจมตี re-enter
withdraw()ก่อนข้อ 3 จะทำงาน
แม้ Solidity และ tooling จะดีขึ้นมาก แต่ reentrancy ก็ยังเจอได้ใน:
- การโอน ETH ผ่าน call
- โทเคน ERC777 ที่มี hooks
- การโอนแบบ safe ของ ERC721/1155 (onERC721Received, onERC1155Received)
- การ integrate vault (ERC4626), staking callbacks, reward distributors
- แพตเทิร์น “accounting + payout” ข้ามสัญญา
- DeFi flows ที่ซับซ้อนซึ่งมี external calls เกิดขึ้นกลางฟังก์ชัน
ช่องโหว่ reentrancy ทำงานจริงใน Solidity อย่างไร?
Reentrancy vulnerability จะเกิดเมื่อฟังก์ชันมี (1) เส้นทางที่ถูกเรียกได้จากภายนอก และ (2) มีการโต้ตอบภายนอก (external interaction) ก่อนที่จะ finalize การเปลี่ยนแปลง state สุดท้าย ทำให้ re-enter กลับเข้ามาในฟังก์ชันที่สำคัญได้โดยใช้ state ที่ยังค้างอยู่ (stale state)
ในเชิงปฏิบัติ การโจมตีอาศัย control flow: คุณคิดว่ากำลังส่ง value “ตอนท้าย” แต่สัญญาภายนอกได้รับสิทธิ์ควบคุมการทำงานและทำอะไรก็ได้—รวมถึงเรียกคุณกลับทันที
Quotable (40–60 words):
ช่องโหว่ reentrancy เกิดเมื่อฟังก์ชัน Solidity ทำ external call—ส่ง ETH, โอนโทเคน หรือเรียกโปรโตคอลอื่น—ก่อนที่จะ finalize state ภายใน ผู้โจมตีใช้ fallback, token hook หรือ callback เพื่อ re-enter ฟังก์ชันในขณะที่ยอดคงเหลือและข้อจำกัดยังไม่เปลี่ยน ทำให้ถอนหรือเคลมซ้ำได้
ตัวอย่างที่มีช่องโหว่: การถอน ETH (บั๊กแบบ “DAO-style”)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract VulnerableBank {
mapping(address => uint256) public balance;
function deposit() external payable {
balance[msg.sender] += msg.value;
}
function withdraw(uint256 amount) external {
require(balance[msg.sender] >= amount, "insufficient");
// INTERACTION first (vulnerable)
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "send failed");
// EFFECTS last (too late)
balance[msg.sender] -= amount;
}
}
สัญญาของผู้โจมตีที่ re-enter ผ่าน receive()
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface IVulnerableBank {
function deposit() external payable;
function withdraw(uint256 amount) external;
}
contract ReenterAttacker {
IVulnerableBank public bank;
uint256 public amount;
constructor(address _bank) {
bank = IVulnerableBank(_bank);
}
function attack() external payable {
require(msg.value > 0, "need ETH");
amount = msg.value;
bank.deposit{value: msg.value}();
bank.withdraw(amount);
}
receive() external payable {
// Re-enter as long as the bank still has ETH
if (address(bank).balance >= amount) {
bank.withdraw(amount);
}
}
}
สิ่งที่เกิดขึ้น: ธนาคารส่ง ETH ออกไป จากนั้น receive() ของผู้โจมตีทำงาน และเรียก withdraw() อีกครั้งก่อนที่ balance[msg.sender] -= amount จะถูก execute ผู้โจมตีจึงดูด ETH ออกจากธนาคารได้
“แต่ฉันใช้การโอน ERC20—ปลอดภัยแล้วใช่ไหม?”
ไม่เสมอไป ERC20 transfer() โดยทั่วไปมักไม่เรียกกลับ แต่:
- โทเคนอาจ non-standard (มี hooks แบบ ERC777 หรือแพตเทิร์น proxy)
- โค้ดของคุณอาจเรียก router, vault หรือสัญญา staking ภายนอก
- การโอนแบบ safe ของ ERC721/1155 มี receiver hooks
- คุณอาจใช้ call หรือ external calls แบบ arbitrary ภายในฟังก์ชันเดียวกัน
ประเภทหลักของ smart contract reentrancy (และมักโผล่ที่ไหนใน DeFi)?
Smart contract reentrancy มีหลายรูปแบบ ทั้งการ re-enter ฟังก์ชันเดิมโดยตรง, cross-function reentrancy และ read-only reentrancy—ซึ่งล้วนเกี่ยวข้องกับความ composability ของ DeFi ยุคใหม่
Quotable (40–60 words):
Reentrancy ไม่ได้มีแค่ “ถอนซ้ำสองครั้ง” โปรโตคอลสมัยใหม่ต้องรับมือทั้ง direct reentrancy (ฟังก์ชันเดิม), cross-function reentrancy (re-enter ฟังก์ชันอื่นที่แชร์ state) และ read-only reentrancy (ปั่นสถานะชั่วคราวเพื่อใช้กับราคา/เงื่อนไขตรวจสอบ) เพียงมี external call ก่อนปิดบัญชีก็เปิดทางให้เกิดได้
รูปแบบ reentrancy ที่พบบ่อย
-
Single-function reentrancy (แบบคลาสสิก)
re-enterwithdraw()ซ้ำ ๆ -
Cross-function reentrancy
withdraw()เรียกออกไป แล้วผู้โจมตี re-enterclaimRewards()หรือborrow()ที่พึ่งพา state ร่วมซึ่งอัปเดตไปเพียงบางส่วน -
Read-only reentrancy (การอ่านที่ขึ้นกับ state)
ต่อให้คุณไม่ได้เขียน state หลัง call ผู้โจมตียังสามารถใช้ความไม่สอดคล้องชั่วคราวเพื่อมีอิทธิพลต่อ: - การคำนวณ price-per-share
- การตรวจ collateral
- logic ที่พึ่งพา oracle
-
การตรวจ “isHealthy”
-
Token-hook/callback reentrancy
- ERC777
tokensReceived - ERC721
onERC721Received - ERC1155
onERC1155Received - hooks แบบ custom ในโทเคน upgradeable/proxy
ผลกระทบในโลกจริง: เหตุการณ์ reentrancy ที่ควรรู้
- The DAO (2016): ถูกดูด ~3.6M ETH ผ่าน reentrancy ในฟังก์ชัน split; จุดชนวนให้เกิด hard fork ของ Ethereum
- Cream Finance (Oct 2021): ถูกโจมตีครั้งใหญ่ (รายงาน ~$130M) ที่เกี่ยวข้องกับกลไก DeFi ซับซ้อน; แม้ไม่ใช่ “DAO-style” ล้วน ๆ แต่ชี้ให้เห็นว่าความ composability และ external interactions ทำให้ความเสี่ยงทวีคูณ
- เหตุการณ์ใน NFT marketplace / staking หลายเคส (2021–2023): reentrancy ผ่าน hooks ของ ERC721 receiver และ logic การจ่ายเงิน ทำให้เกิดการถอนซ้ำและบัญชีเพี้ยนซ้ำ ๆ เมื่อไม่ได้คำนึงถึง callbacks
(Reentrancy ยังมักจับคู่กับ flash loans: ผู้โจมตีกู้ทุน ปั่น state re-enter ฟังก์ชันสำคัญ แล้วคืนหนี้ภายในธุรกรรมเดียว ทำให้การโจมตีที่ “ต้องใช้ทุน” กลายเป็นแทบไม่ต้องใช้ทุน)
Reentrancy prevention in Solidity: แพตเทิร์นที่ใช้ได้ผลจริง
การทำ reentrancy prevention in Solidity ที่ได้ผลจริงสรุปได้เป็น: (1) ลด external calls ให้น้อยที่สุด (2) เรียงลำดับ logic ให้ถูกต้อง และ (3) บังคับใช้ reentrancy lock อย่างชัดเจนกับเส้นทางที่อ่อนไหว
Quotable (40–60 words):
การป้องกัน reentrancy ที่เชื่อถือได้ที่สุดควรผสาน 3 ชั้น: ใช้ Checks-Effects-Interactions เพื่ออัปเดต state ก่อนเรียกภายนอก; ใช้ pull payments แทน push payouts; และปกป้องฟังก์ชันสำคัญด้วย reentrancy guard (mutex) รวมถึงมอง token callbacks และ “safe” transfers เป็น external calls ที่มีความเสี่ยง re-entry เสมอ
1) Checks-Effects-Interactions (CEI)
แก้ตัวอย่างก่อนหน้าโดยอัปเดต state ก่อน ส่ง ETH:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract CEIBank {
mapping(address => uint256) public balance;
function deposit() external payable {
balance[msg.sender] += msg.value;
}
function withdraw(uint256 amount) external {
require(balance[msg.sender] >= amount, "insufficient");
// EFFECTS first
balance[msg.sender] -= amount;
// INTERACTION last
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "send failed");
}
}
ข้อควรระวัง: CEI เป็นสิ่งจำเป็น แต่ไม่เสมอไปว่าจะเพียงพอสำหรับ cross-function reentrancy หากยังมีฟังก์ชันอื่นที่ถูก re-enter ได้และพึ่งพา state ร่วม
2) ใช้ Reentrancy Guard (mutex)
ReentrancyGuard ของ OpenZeppelin เป็นมาตรการที่นิยมใช้ โดยเฉพาะเส้นทาง withdraw/claim/borrow
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract GuardedBank is ReentrancyGuard {
mapping(address => uint256) public balance;
function deposit() external payable {
balance[msg.sender] += msg.value;
}
function withdraw(uint256 amount) external nonReentrant {
require(balance[msg.sender] >= amount, "insufficient");
balance[msg.sender] -= amount;
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "send failed");
}
}
สำคัญ: ใส่ nonReentrant กับ ทุก entry points ที่แตะ state อ่อนไหวชุดเดียวกัน (เช่น withdraw, claim, exit, liquidate) หรือออกแบบ internal functions ให้รัดกุมเพื่อเลี่ยงการชนกันของ guard
3) เลือก pull payments แทน push payments
แทนที่จะ “จ่าย” ภายในฟังก์ชันที่ซับซ้อน คุณสามารถ credit ยอดให้ผู้ใช้แล้วให้มาถอนแยกภายหลัง
- ข้อดี: external calls น้อยลงใน business logic; ลด attack surface
- ข้อเสีย: เพิ่มขั้นตอน UX; ต้องทำบัญชีและ guard การถอนให้รัดกุม
4) มอง “token transfers” เป็น external calls ใน threat models
แม้ ERC20 มักปลอดภัย แต่ integration ของคุณ อาจไม่ใช่:
- โอนโทเคน ERC777
- เรียก vault ที่ออก shares
- เรียก router/aggregator
- ใช้ safeTransferFrom สำหรับ NFTs (มี callbacks!)
5) เลี่ยง arbitrary external calls ใน privileged flows
หาก governance หรือแอดมินสามารถกำหนด target contracts/callbacks ได้ คุณอาจสร้างเส้นทาง reentrancy โดยไม่ตั้งใจจนทำให้สมมติฐานเดิมพังได้ ความเสี่ยงจะยิ่งสูงเมื่อรวมกับ delegatecall risks in Solidity (ที่แชร์ storage context)
เปรียบเทียบ: CEI vs ReentrancyGuard vs Pull Payments (ควรใช้อะไรเมื่อไหร่)
แนวทางที่เหมาะที่สุดขึ้นกับวัตถุประสงค์ของฟังก์ชัน ความต้องการด้าน composability และข้อจำกัดด้าน UX โปรโตคอล production ส่วนใหญ่ใช้ หลาย ชั้นพร้อมกัน
Quotable (40–60 words):
ไม่มี mitigation เดียวที่เหมาะกับทุกโปรโตคอล Checks-Effects-Interactions กันความผิดพลาดแบบ “ส่งก่อนแล้วค่อยอัปเดต” ที่พบบ่อยที่สุด แต่ cross-function reentrancy ยังอาจมีอยู่ Reentrancy guards เพิ่ม mutex แบบแข็งแรงให้เส้นทางสำคัญ ส่วน pull payments ลด external calls ใน logic หลักแลกกับ UX และต้องมีระบบบัญชีการถอนที่ทนทาน
| Mitigation | What it does | Strengths | Weaknesses | Best for |
|---|---|---|---|---|
| Checks-Effects-Interactions (CEI) | อัปเดต state ก่อน external calls | เรียบง่าย overhead ต่ำ เป็น baseline ที่ดี | ไม่ได้หยุด cross-function หรือ read-only reentrancy เสมอไป | ฟังก์ชันที่เปลี่ยน state ส่วนใหญ่ที่มี external calls |
nonReentrant guard (mutex) |
บล็อกการ re-entry ระหว่างการ execute | แข็งแรง ชัดเจน ใช้แพร่หลาย | อาจทำให้ internal function calls ซับซ้อน; ต้องครอบคลุม entry points ที่เกี่ยวข้องทั้งหมด | withdrawals, claims, borrows, liquidations |
| Pull payments | credit ยอดไว้ก่อน; ผู้ใช้ถอนทีหลัง | ลด external calls ใน logic หลักให้ต่ำสุด | ผู้ใช้ต้องทำธุรกรรมเพิ่ม; ยังต้องมี withdraw ที่ guard ดี | rewards, fee distributions, refunds |
| Restricted external calls | จำกัดว่าสามารถเรียกสัญญาไหนได้ | ลด callbacks ที่คาดไม่ถึง | composability ลดลง; เพิ่มภาระด้าน governance | admin tools, upgrade hooks, strategy execution |
| Careful token/NFT handling | มอง hooks เป็น reentry vectors | กันเหตุไม่คาดคิดจาก callbacks | ต้องเข้าใจ integration ลึก | NFT marketplaces, staking ที่ใช้ ERC777/4626 |
Audit checklist: วิธีหาและทดสอบ reentrancy ในโปรโตคอลจริง
คุณป้องกัน reentrancy ได้ด้วยการออกแบบ แต่คุณ พิสูจน์ ได้ด้วยการทดสอบ การรีวิวเชิง adversarial และการ audit แบบเจาะจงในทุกเส้นทางที่มี external interaction
Quotable (40–60 words):
การตรวจหา reentrancy ให้ระบุ external call ทุกจุด (ส่ง ETH, โอนโทเคน, โต้ตอบ router/vault, NFT safe transfers) จากนั้นไล่ trace ว่า state updates และ invariants ถูก finalize ก่อน call หรือไม่ ทดสอบด้วยสัญญา receiver ที่เป็นอันตรายและ fuzzing โดยเน้น cross-function state ร่วมและโทเคนที่มี callbacks
เช็กลิสต์ใช้งานจริง (สำหรับวิศวกร)
- ทำแผนที่ external calls ทั้งหมด
call,transfer,send- token
transfer/transferFrom safeTransferFrom(ERC721/1155)- การโต้ตอบ vault/router/bridge
- หา shared state ที่ถูกแตะโดยหลายฟังก์ชัน
- balances, shares, debt, reward indices, snapshots
- ตรวจลำดับ
- state สำคัญถูกอัปเดตก่อน external call หรือไม่?
- เพิ่ม invariants
- total assets == sum of balances (หรืออยู่ในขอบเขตที่ตรวจได้)
- ความสอดคล้องของ shares * price-per-share
- reward debt ต้องเป็น monotonic
- จำลองผู้โจมตี
- malicious receiver ทำ re-enter
- malicious token ที่มี hooks
- การโจมตีที่ใช้ทุนจาก flash-loan เพื่อขยายผลกระทบ
ตัวอย่าง: กับดัก cross-function reentrancy (แพตเทิร์น)
หาก withdraw() เรียกออกไป และ claimRewards() ถูก re-enter ได้โดยสมมติว่าอยู่ใน state “หลังถอน” แล้ว อาจเกิด:
- เคลมรางวัลซ้ำ
- ข้าม cooldowns
- ระบบ reward debt accounting พัง
แนวทางป้องกันที่พบบ่อยคือ:
- รวมการอัปเดตบัญชีไว้ใน internal function กลาง
- ใช้ nonReentrant อย่างสม่ำเสมอในทุก state-mutating entry points
- แยก external interactions ไปไว้ท้ายสุดของการ execute
วิธีที่ Soken มักรีวิว reentrancy (สิ่งที่คุณจะได้เจอ)
ในการ audit ของ Soken เราจะ: - สร้าง external call graph และทำโน้ต “reentry windows” - ตรวจการทำตาม CEI และความครอบคลุมของ mutex - ทดสอบ integration กับมาตรฐานที่มี callbacks (ERC777, ERC721/1155 safe transfers, ERC4626 vaults) - ลองทำ exploit PoCs ใน Foundry เพื่อยืนยันระดับความรุนแรงและยืนยันว่าแก้แล้วจริง
สิ่งนี้สำคัญมากในดีไซน์ DeFi ที่เปิดรับ flash-loan-attacks-defi ซึ่งผู้โจมตีสามารถขยายขนาด position และกดดัน edge cases ได้ในธุรกรรมแบบ atomic เพียงครั้งเดียว
สรุป: Reentrancy ป้องกันได้—ถ้าคุณออกแบบเพื่อความ composability
Reentrancy ยังเป็นความเสี่ยงระดับท็อปของ smart contract เพราะ DeFi เป็น composable โดยค่าเริ่มต้น: “สัญญาภายนอก” ที่คุณเรียกอาจเป็นผู้ใช้ โทเคนที่มี hooks, vault, router หรือ callback ที่ผู้โจมตีควบคุมอยู่ การแก้ไขจึงไม่ค่อยใช่ทริกเดียว แต่เป็นวินัยทางวิศวกรรม: การจัดลำดับ CEI, การ guard entry points, ลด external calls และทดสอบแบบ adversarial
หากคุณกำลังปล่อย staking, lending, governance, bridges หรือสัญญาใด ๆ ที่เคลื่อนย้ายสินทรัพย์ คุณควรมอง reentrancy เป็นข้อกำหนดด้านการออกแบบตั้งแต่วันแรก—not เป็นแค่ lint warning ตอนท้าย
Soken (soken.io) ช่วยทีมเสริมความแข็งแกร่งให้ระบบ production ด้วย smart contract auditing & penetration testing และ DeFi security reviews ครอบคลุม vaults, staking, governance และ integration ที่ซับซ้อน หากคุณต้องการรีวิวแบบโฟกัส reentrancy (รวม PoCs และ Foundry tests) ติดต่อเราที่ soken.io เพื่อจองคิว audit และส่งโปรดักชันได้อย่างมั่นใจ