התקפת ריאנטרנסי: הסבר ומניעה ב-Solidity

Reentrancy attacks הם אחד מהניצוליים הוותיקים ביותר—ועדיין הרווחיים ביותר—של חוזים חכמים ב-DeFi. הם פשוטים להבנה, קלים להכניס בטעות, והרסניים במיוחד כשהם פוגעים בנתיבים קריטיים של חשבונאות כמו משיכות, ליקווידציות ותביעות תגמולים. אפילו צוותים מנוסים משחררים חוזים עם פגיעויות reentrancy כי הבאג לרוב מסתתר בתוך קריאות חיצוניות “רגילות”: שליחת ETH, העברת טוקנים, קריאה לוולט או אינטראקציה עם AMM.

מאמר זה מסביר איך מתקפת reentrancy עובדת, איך נראית “reentrancy של חוזה חכם” במערכות אמיתיות, למה זה עדיין רלוונטי אחרי תקופת ה-DAO, ובדיוק איך ליישם מניעת reentrancy ב-Solidity באמצעות דפוסים מוכחים (Checks-Effects-Interactions, pull payments, ReentrancyGuard, ועיצוב זהיר של קריאות חיצוניות). בנוסף, נחבר את הנושא לנושאים קרובים כמו התקפות flash-loan, סיכוני delegatecall, ושיטות עבודה מומלצות מודרניות ב-Solidity.

ב-Soken (soken.io), עברנו על מאות חוזים הפועלים בתחומי הלוואות, סטייקינג, גשרים וממשל. reentrancy נשאר סיבת שורש חוזרת או גורם תורם—במיוחד כשפרוטוקולים משלבים טוקנים חדשים, hooks, callbacks או פרימיטיבים קומפוזביליים של DeFi.

מהי התקפת reentrancy (ולמה היא כל כך מסוכנת)?

התקפת reentrancy מתרחשת כאשר חוזה מבצע קריאה חיצונית לפני שהוא מסיים עדכון המצב הפנימי שלו, מה שמאפשר לקבלן הקריאה לקרוא שוב חזרה (“להיכנס מחדש”) לפונקציה הפגיעה ולחזור על פעולה, כמו משיכת כספים מספר פעמים.

reentrancy מסוכנת כי Ethereum היא סינכרונית: כשחוזה שלך קורא לחוזה אחר, הביצוע עובר מיידית לחוזה החיצוני—לפני שהפונקציה שלך מסתיימת. אם החוזה שלך עדיין לא עדכן את היתרות או דגלי נעילה, התוקף יכול לנצל את המצב “האמצעי” הזה.

לציטוט (40–60 מילים):
התקפת reentrancy מנצלת חוזה המבצע קריאה חיצונית לפני סיום החשבונאות הפנימית. כיוון שקריאות EVM הן סינכרוניות, המקבל יכול להיכנס מחדש לפונקציה הפגיעה כאשר המצב עדיין משקף את היתרות ה”ישנות”. זה מאפשר משיכות חוזרות, תביעות כפולות, או עקיפות מגבלות—לעיתים תוך ניקוז TVL כולו בעסקה אחת.

המודל המנטאלי הקלאסי: “קראתי החוצה מוקדם מדי”

זרימה טיפוסית פגיעה נראית כך:

  • משתמש קורא ל-withdraw()
  • החוזה שולח ETH/טוקנים למשתמש (קריאה חיצונית)
  • החוזה מעדכן את balances[msg.sender] (מאוחר מדי)
  • פונקציית fallback/receive של התוקף קוראת שוב ל-withdraw() לפני ששלב 3 רץ

למרות ש-Solidity והכלים השתפרו, reentrancy עדיין מופיעה ב:
- העברות ETH דרך call
- טוקני ERC777 עם hooks
- העברות בטוחות של ERC721/1155 (onERC721Received, onERC1155Received)
- אינטגרציות וולט (ERC4626), callbacks בסטייקינג, מפיצי תגמולים
- דפוסי “חשבונאות + תשלום” בין חוזים
- זרימות DeFi מורכבות עם קריאות חיצוניות באמצע פונקציה

איך פגיעות reentrancy עובדת בפועל ב-Solidity?

פגיעות reentrancy קיימת כאשר פונקציה כוללת (1) נתיב שניתן לקרוא לו מבחוץ ו-(2) אינטראקציה חיצונית שמתרחשת לפני שבוצעו שינויים סופיים במצב, ומאפשרת כניסה חוזרת לפונקציה רגישה עם מצב ישן.

בפועל, הניצול מבוסס על זרימת בקרה: אתה חושב שאתה שולח ערך “בסוף”, אך החוזה החיצוני מקבל שליטה ויכול לעשות כל דבר—כולל לקרוא לך בחזרה.

לציטוט (40–60 מילים):
פגיעות reentrancy מתעוררת כאשר פונקציה ב-Solidity מבצעת קריאה חיצונית—שולחת ETH, מעבירה טוקנים, או קוראת לפרוטוקול אחר—לפני שהיא מסיימת את עדכון המצב הפנימי. התוקף משתמש בפונקציית fallback, hook של טוקנים, או callback כדי להיכנס מחדש לפונקציה בזמן שהיתרות והמגבלות לא השתנו, וכך חוזר על פעולה כמו משיכה או תביעה.

דוגמה פגיעה: משיכת ETH (הבאג בסגנון DAO)

// 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");

        // אינטראקציה ראשונה (פגיע)
        (bool ok, ) = msg.sender.call{value: amount}("");
        require(ok, "send failed");

        // עדכוני מצב מאוחר (מאוחר מדי)
        balance[msg.sender] -= amount;
    }
}

חוזה תוקף שנכנס מחדש דרך 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 {
        // נכנס מחדש כל עוד לבנק יש ETH
        if (address(bank).balance >= amount) {
            bank.withdraw(amount);
        }
    }
}

מה שקורה: הבנק שולח ETH, ופונקציית receive() של התוקף רצה, ו-withdraw() נקראת שוב לפני ש-balance[msg.sender] -= amount מתבצע. כך התוקף מפיל את הבנק.

“אבל אני משתמש בהעברות ERC20—האם אני בטוח?”

לא בהכרח. העברת ERC20 ב-transfer() בדרך כלל לא קוראת בחזרה, אך:
- טוקנים יכולים להיות לא סטנדרטיים (hooks בסגנון ERC777 או דפוסי proxy)
- הקוד שלך עשוי לקרוא לרוטר חיצוני, וולט או חוזה סטייקינג
- העברות בטוחות של ERC721/1155 כן קוראות לפונקציות קבלה
- ייתכן שאתה משתמש ב-call או בקריאות חיצוניות שרירותיות באותה פונקציה

מהם סוגי ה-reentrancy העיקריים של חוזים חכמים (ואיפה הם מופיעים ב-DeFi)?

ישנן צורות רבות של reentrancy בחוזים חכמים, כולל כניסה חוזרת ישירה לאותה פונקציה, כניסה חוזרת בין פונקציות שונות, ו-reentrancy לקריאה בלבד—כאשר לכל אחת יש משמעות בקומפוזביליות של DeFi המודרני.

לציטוט (40–60 מילים):
reentrancy הוא לא רק “משוך פעמיים”. פרוטוקולים מודרניים מתמודדים עם reentrancy ישירה (אותה פונקציה), reentrancy בין פונקציות (כניסה חוזרת לפונקציה אחרת שמשתפת מצב), ו-reentrancy לקריאה בלבד (השפעה על מצב ביניים לצורך תמחור או בדיקות). כל קריאה חיצונית לפני חשבונאות סופית יכולה לאפשר אחת מהגרסאות האלו.

וריאציות נפוצות של reentrancy

Reentrancy לפונקציה יחידה (קלאסי)
כניסה חוזרת מרובה ל-withdraw().

Reentrancy בין פונקציות
withdraw() קוראת החוצה, התוקף נכנס מחדש ל-claimRewards() או borrow() התלויים במצב משותף חלקית מעודכן.

Reentrancy לקריאה בלבד (קריאות תלויות מצב)
גם אם אין כתיבת מצב לאחר הקריאה, תוקף יכול לנצל אי-התאמה זמנית כדי להשפיע על:

  • חישובי price-per-share
  • בדיקות ערבות
  • לוגיקה התלויה באורקל

ולידציות של “isHealthy”

Reentrancy ב-hook/callback של טוקנים

  • ERC777 tokensReceived
  • ERC721 onERC721Received
  • ERC1155 onERC1155Received
  • hooks מותאמים בטוקנים משודרגים/פרוקסי

השפעה מהעולם האמיתי: מקרים של reentrancy שכדאי להכיר

  • The DAO (2016): נוקזו כ-3.6 מיליון ETH באמצעות reentrancy בפונקציית split; עורר את הפיצול ב-Ethereum.
  • Cream Finance (אוקטובר 2021): סבל מניצול גדול (~130 מיליון דולר מדווחים) שכלל מורכבויות DeFi; אמנם לא “DAO-style טהור,” אך מדגיש איך קומפוזביליות ואינטראקציות חיצוניות מגבירות סיכון.
  • מקרים שונים של שוקי NFT וסטייקינג (2021–2023): reentrancy דרך hooks של ERC721 ו-loגיקת חלוקת תגמולים גרם למשיכות כפולות וטעויות בחשבונאות כש-callbacks לא נלקחו בחשבון.

(רוב הזמן reentrancy משולבת עם flash loans: תוקפים לוקחים הלוואות, משנים מצב, נכנסים קריטיים מחדש, ומחזירים בהקשר עסקה אחת—כך שהניצול הופך ל”ללא הון“.)

מניעת reentrancy ב-Solidity: הדפוסים שעובדים באמת

מניעת reentrancy אפקטיבית ב-Solidity מתבססת על: (1) הפחתת קריאות חיצוניות, (2) הזנת לוגיקה בסדר הנכון, ו-(3) אכיפת נעילות מפורשות על נתיבים רגישים.

לציטוט (40–60 מילים):
מניעת reentrancy אמינה משלבת שלוש שכבות: שימוש בדפוס Checks-Effects-Interactions כדי לעדכן מצב לפני קריאות חיצוניות; העדפת pull payments על פני push payouts; והגנה על פונקציות קריטיות באמצעות reentrancy guard (מוטקס). כמו כן, יש להתייחס לקריאות חוזרות של טוקנים והעברות “בטוחות” כקריאות חיצוניות עם סיכון re-entry.

1) Checks-Effects-Interactions (CEI)

מתקן את הדוגמה הקודמת באמצעות עדכון המצב לפני שליחת 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");

        // עדכוני מצב קודם
        balance[msg.sender] -= amount;

        // אינטראקציה אחרונה
        (bool ok, ) = msg.sender.call{value: amount}("");
        require(ok, "send failed");
    }
}

הערה: CEI הכרחי אך לא תמיד מספיק ל-reentrancy בין פונקציות אם קיימת אפשרות להיכנס לפונקציות אחרות התלויות במצב משותף.

2) שימוש ב-Reentrancy Guard (מוטקס)

ReentrancyGuard מבית OpenZeppelin הוא פתרון סטנדרטי, בעיקר לנתיבים של משיכות, תביעות והלוואות.

// 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 על כל נקודות הכניסה שמשפיעות על אותו מצב רגיש (כמו withdraw, claim, exit, liquidate) או לארגן פונקציות פנימיות בקפידה למניעת התנגשות בנעילה.

3) העדפת pull payments על פני push payments

במקום “לתת תשלום” בתוך פונקציה מורכבת, אפשר לזכות משתמש ולאפשר לו למשוך בנפרד.

  • יתרונות: פחות קריאות חיצוניות בלוגיקה העסקית; מקטין את שטח התקיפה
  • חסרונות: מוסיף שלב UX; דורש חשבונאות זהירה ונעילות למשיכות

4) להתייחס ל”העברות טוקנים” כקריאות חיצוניות במודלי איום

גם אם ERC20 בדרך כלל בטוח, הטמעה שלך עלולה לא להיות:

  • העברת טוקנים בסגנון ERC777
  • קריאה לוולט שמנפיק מניות
  • קריאה לרוטר/אגגרגטור
  • שימוש ב-safeTransferFrom ל-NFTs (callbacks!)

5) להימנע מקריאות חיצוניות שרירותיות בזרימות עם הרשאות

אם ממשל או מנהל יכול להגדיר חוזים יעד/Callbacks, יכולים להיווצר דרכי reentrancy שתעקפנה הנחות מוקדמות. זה מסוכן במיוחד בשילוב עם סיכוני delegatecall ב-Solidity (שבהם ההקשר של האחסון משותף).

השוואה: CEI מול ReentrancyGuard מול Pull Payments (מה להשתמש ומתי)

הגישה הטובה ביותר תלויה במטרת הפונקציה, צורך הקומפוזביליות ומגבלות UX. רוב הפרוטוקולים משחררים מערכת הכוללת שכבות מרובות.

mitigation מה זה עושה חוזקות חולשות מתאים ל-
Checks-Effects-Interactions (CEI) מעדכן מצב לפני קריאות חיצוניות פשוט, בעל עומס נמוך, בסיס טוב לא תמיד מונע reentrancy בין פונקציות או לקריאה בלבד רוב פונקציות שמשנות מצב וכוללות קריאות חיצוניות
nonReentrant guard (מוטקס) חוסם כניסה מחדש במהלך ריצה חזק, מפורש, בשימוש רחב יכול לסבך קריאות פנימיות; חייב לכסות כל נקודת כניסה רלוונטית משיכות, תביעות, הלוואות, ליקווידציות
Pull payments מזכה יתרה; משתמשים מושכים בנפרד מצמצם קריאות חיצוניות בלוגיקה עיקרית דורש טרנזקציה נוספת למשתמש; צריך הגנות למשיכה תגמולים, הפצת עמלות, החזרים
קריאות חיצוניות מוגבלות מגביל אילו חוזים ניתן לקרוא מפחית callbacks לא צפויים מצמצם קומפוזביליות; עומס ממשלתי כלי ניהול, hooks לשדרוג, ביצוע אסטרטגיות
טיפול זהיר בטוקנים/NFT מתייחס ל-hooks כוקטורי reentry מונע הפתעות מקריאות חוזרות דורש ידע עמוק בהטמעה שווקי NFT, סטייקינג עם ERC777/4626

רשימת בדיקה לאודיט: איך למצוא ולבדוק reentrancy בפרוטוקולים אמיתיים

אתה מונע reentrancy בתכנון, אבל אתה מוכיח זאת באמצעות בדיקות, סקירות עוינות, ואודיטים ממוקדים בכל נתיב אינטראקציה חיצוני.

לציטוט (40–60 מילים):
לזיהוי reentrancy, זהה כל קריאה חיצונית (שליחת ETH, העברות טוקנים, אינטראקציות לרוטר/וולט, העברות בטוחות ל-NFT), ואז בדוק אם עדכון מצב ואינווריאנטים נעשים לפני הקריאה. בצע בדיקות עם חוזים מקבלי התקפה ומבחני פאזינג. הדגש על מצב משותף בין פונקציות וטוקנים עם callbacks.

רשימת בדיקה פרקטית (למהנדסים)

  • מיפוי כל הקריאות החיצוניות
  • call, transfer, send
  • transfer/transferFrom של טוקנים
  • safeTransferFrom (ERC721/1155)
  • אינטראקציות עם וולט/רוטר/גשר
  • מציאת מצב משותף שמשותף על ידי פונקציות מרובות
  • יתרות, מניות, חוב, אינדקסי תגמולים, snapshots
  • בדיקת סידור
  • האם עדכוני מצב קריטיים מתבצעים לפני קריאה חיצונית?
  • הוספת איוונאריאנטים
  • סך הנכסים שווה לסכום היתרות (או מוגבל)
  • עקביות מניות * מחיר למניה
  • מונוטוניות חוב תגמולים
  • סימולציית תוקפים
  • חוזה מקבל עוין נכנס מחדש
  • טוקן עוין עם hooks
  • התקפת flash-loan שמגבירה השפעה

דוגמה: מלכודת reentrancy בין פונקציות (דפוס)

אם withdraw() קורא החוצה ו-claimRewards() יכול להיכנס מחדש ומניח מצב “אחרי משיכה”, עלולות להיווצר:
- תביעות תגמולים כפולות
- עקיפות cooldown
- חשבונאות חוב תגמולים שבורה

הפיתרון המקובל הוא:
- ריכוז עדכוני החשבונאות בפונקציה פנימית
- יישום nonReentrant עקבי על כל נקודות הכניסה המשנות מצב
- בידוד אינטראקציות חיצוניות לסוף הביצוע

איך Soken בדרך כלל בודקת reentrancy (מה לצפות)

באודיטים של Soken, אנו במיוחד:
- בונים גרף קריאות חיצוניות ומסמנים “חלונות כניסה חוזרת”
- מאמתים תאימות ל-CEI וכיסוי מוטקס
- בודקים אינטגרציות מול תקנים עם callbacks (ERC777, העברות בטוחות ERC721/1155, וולטים ERC4626)
- מנסים PoCs של ניצול עם Foundry לאימות חומרה ואישור תיקונים

זה חשוב במיוחד בעיצובים ב-DeFi החשופים ל-flash-loan-attacks-defi, שבהם תוקפים יכולים להרחיב מימדי פוזיציות וללחוץ על מקרי קצה בעסקה אטומית בודדת.

סיכום: reentrancy אפשר למנוע—אם מתכננים לקומפוזביליות

reentrancy נשאר סיכון ראשון במעלה לחוזים חכמים כי DeFi הוא קומפוזבילי כברירת מחדל: “החוזה החיצוני” שאליו אתה קורא עלול להיות משתמש, טוקן עם hooks, וולט, רוטר או callback בשליטת תוקף. הפיתרון שוב ושוב הוא הנדסה ממושמעת: סדר CEI, נקודות כניסה מוגנות, הפחתת קריאות חיצוניות, ובדיקות עוינות.

אם אתה משחרר סטייקינג, הלוואות, ממשל, גשרים או חוזה שמזיז נכסים, כדאי שתתייחס ל-reentrancy כאל מגבלה בתכנון כבר מהיום הראשון—ולא כסימן אזהרה בסוף.

Soken (soken.io) מסייעת לצוותים להקשות מערכות בייצור עם אודיט חוזים חכמים ובדיקות חדירה וסקירות אבטחה ל-DeFi על פני וולטים, סטייקינג, ממשל ואינטגרציות מורכבות. אם תרצה סקירה ממוקדת reentrancy (כולל PoCs ומבחני Foundry), פנה אלינו ב-soken.io לקביעת אודיט ושחרור עם בטחון.

Frequently Asked Questions

מהי התקפת ריאנטרנסי בחוזים חכמים?

התקפת ריאנטרנסי מתרחשת כאשר חוזה מבצע קריאה חיצונית לפני עדכון מצבו, ומאפשרת לחוזה של התוקף להיכנס שוב לאותה פונקציה ולחזור עליה. כך ניתן לנצל עיכובים בעדכון היתרות ולרוקן כספים במהלך עסקה אחת.

איך מזהים פגיעות ריאנטרנסי בקוד Solidity?

יש לבדוק קריאות חיצוניות כמו שליחת ETH או קריאות לחוזים אחרים המתרחשות לפני עדכוני מצב קריטיים כמו הפחתת יתרות. חשוב גם לעקוב אחרי פונקציות callback כמו hooks של ERC777 או פונקציות fallback/receive שעלולות לאפשר כניסות חוזרות.

מהן השיטות המומלצות למניעת ריאנטרנסי ב-Solidity?

יש להשתמש בדפוס Checks-Effects-Interactions: לאמת קלט, לעדכן מצב פנימי ואז לבצע אינטראקציה חיצונית. להעדיף תשלום משיכה במקום דחיפה, להוסיף שומר ריאנטרנסי (כמו OpenZeppelin ReentrancyGuard) לפונקציות רגישות, ולהקטין קריאות חיצוניות במסלולי חשבונאות מרכזיים.

האם השימוש ב-OpenZeppelin ReentrancyGuard מונע לחלוטין התקפות ריאנטרנסי?

ReentrancyGuard חוסם דפוסים רבים של כניסה חוזרת באותו חוזה, אך אינו מבטיח אבטחה מלאה. התקפות חוצות פונקציות, callbacks חיצוניים וטעויות בחשבונאות עדיין עלולות לגרום להפסדים. יש לשלבו עם סדר נכון של פעולות, דפוסי תשלום מושכים ועיצוב קפדני של קריאות חיצוניות.