לדלג לתוכן

תנאי race - Race Conditions

מהם תנאי race באפליקציות ווב

תנאי race (Race Condition) הוא פגם אבטחה שנוצר כאשר אפליקציה מעבדת בקשות מרובות במקביל ומבצעת פעולות על משאב משותף ללא סנכרון מתאים. התוקף מנצל את חלון הזמן הצר בין בדיקת תנאי לבין ביצוע הפעולה - מה שמכונה TOCTOU (Time Of Check To Time Of Use).

בניגוד לחולשות קלאסיות כמו XSS או SQLi, תנאי race לא מבוססים על קלט זדוני אלא על תזמון מדויק. האפליקציה עושה בדיוק את מה שהיא אמורה לעשות - פעם אחת. הבעיה היא שהיא לא מוגנת מפני ביצוע מקבילי של אותה פעולה.


הבנת חלון הזמן - TOCTOU

הבעיה הבסיסית נראית כך:

Time -->

Thread A:  [CHECK balance >= 100] -------- [DEDUCT 100] -------- [DONE]
Thread B:       [CHECK balance >= 100] -------- [DEDUCT 100] -------- [DONE]

Initial balance: 100
Expected result: Thread A succeeds, Thread B fails
Actual result: both succeed! Balance: -100

חלון הפגיעות הוא הזמן שבין ה-CHECK ל-DEDUCT. אם שתי בקשות מגיעות בתוך חלון זה, שתיהן יעברו את הבדיקה לפני שאחת מהן מעדכנת את המשאב.


סוגי תנאי race

הbypass מגבלות - Limit Overrun

הסוג הנפוץ ביותר. התוקף מנצל את חלון הזמן בין בדיקת מגבלה לבין עדכון המצב.

דוגמה - שימוש בקוד הנחה מספר פעמים:

קוד פגיע ב-Node.js:

app.post('/apply-discount', async (req, res) => {
    const { code } = req.body;
    const userId = req.session.userId;

    // CHECK - check whether the code was already used
    const used = await db.query(
        'SELECT * FROM used_codes WHERE user_id = ? AND code = ?',
        [userId, code]
    );

    if (used.length > 0) {
        return res.status(400).json({ error: 'Code already used' });
    }

    // Vulnerability window - TOCTOU gap

    // USE - apply the discount
    await db.query(
        'UPDATE cart SET discount = 20 WHERE user_id = ?',
        [userId]
    );

    // Mark the code as used
    await db.query(
        'INSERT INTO used_codes (user_id, code) VALUES (?, ?)',
        [userId, code]
    );

    res.json({ success: true, discount: '20%' });
});

בין שורת ה-SELECT לשורת ה-INSERT יש חלון זמן. אם נשלח 20 בקשות זהות במקביל, רובן יעברו את הבדיקה לפני שהראשונה תסמן את הקוד כמשומש.

דוגמה - העברת כסף מעבר ליתרה:

@app.route('/transfer', methods=['POST'])
def transfer():
    sender_id = session['user_id']
    recipient_id = request.form['recipient']
    amount = int(request.form['amount'])

    # CHECK
    sender = db.execute(
        'SELECT balance FROM accounts WHERE id = ?',
        (sender_id,)
    ).fetchone()

    if sender['balance'] < amount:
        return jsonify({'error': 'Insufficient funds'}), 400

    # USE - vulnerability window
    db.execute(
        'UPDATE accounts SET balance = balance - ? WHERE id = ?',
        (amount, sender_id)
    )
    db.execute(
        'UPDATE accounts SET balance = balance + ? WHERE id = ?',
        (amount, recipient_id)
    )
    db.commit()

    return jsonify({'success': True})

דוגמה - מימוש כפול של כרטיס מתנה:

function redeemGiftCard($cardCode, $userId) {
    // CHECK
    $card = $db->query(
        "SELECT * FROM gift_cards WHERE code = ? AND redeemed = 0",
        [$cardCode]
    )->fetch();

    if (!$card) {
        return ['error' => 'Invalid or already redeemed'];
    }

    // USE - vulnerability window
    $db->query(
        "UPDATE users SET balance = balance + ? WHERE id = ?",
        [$card['amount'], $userId]
    );

    $db->query(
        "UPDATE gift_cards SET redeemed = 1 WHERE code = ?",
        [$cardCode]
    );

    return ['success' => true, 'amount' => $card['amount']];
}

תנאי race בנקודת קצה בודדת - Single-Endpoint

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

תנאי race מרובי נקודות קצה - Multi-Endpoint

הexploit race בין שתי פעולות שונות הפועלות על אותו משאב:

POST /cart/add-item     (adds a product to the cart)
POST /cart/checkout     (pays for the cart)

Attack scenario:
1. Add a cheap product to the cart
2. Send checkout in parallel with swapping the product for an expensive one
3. Payment goes through at the cheap price, but the product shipped is the expensive one

תנאי race של בנייה חלקית - Partial Construction

גישה לאובייקט לפני שהאתחול שלו הושלם:

app.post('/register', async (req, res) => {
    // Step 1: create the user
    const user = await User.create({
        email: req.body.email,
        password: hashPassword(req.body.password)
    });

    // Vulnerability window - the user exists but with no permissions set

    // Step 2: set permissions
    await Permissions.create({
        userId: user.id,
        role: 'user',
        verified: false
    });
});

אם תוקף מצליח להתחבר עם המשתמש בחלון שבין שלב 1 לשלב 2, יתכן שיקבל הרשאות ברירת מחדל שונות (או ללא הגבלות כלל).

תקיפות רגישות לזמן - Time-Sensitive Attacks

הexploit פעולות המבוססות על חותמות זמן:

import hashlib, time

def generate_reset_token(email):
    timestamp = str(int(time.time()))
    token = hashlib.sha256(
        (email + timestamp).encode()
    ).hexdigest()
    return token

אם שני משתמשים מבקשים איפוס סיסמה באותה שנייה, הטוקנים שלהם עשויים להיות זהים (אם ה-hash מבוסס על זמן + email בלבד). תוקף יכול לבקש איפוס לעצמו ולקורבן בו-זמנית ולהשתמש בטוקן שקיבל.


טכניקות מעשיות לexploit

תקיפת חבילה בודדת - HTTP/2 Single-Packet Attack

הטכניקה האפקטיבית ביותר. ב-HTTP/2 ניתן לשלוח מספר בקשות בתוך חבילת TCP אחת, כך שכולן מגיעות לשרת באותו רגע בדיוק:

TCP Packet:
  [HTTP/2 Stream 1: POST /apply-discount]
  [HTTP/2 Stream 3: POST /apply-discount]
  [HTTP/2 Stream 5: POST /apply-discount]
  ...
  [HTTP/2 Stream 39: POST /apply-discount]

היתרון: אין jitter של רשת. כל הבקשות מגיעות בו-זמנית ללא תלות באיכות החיבור.

סנכרון הבית האחרון - Last-Byte Synchronization (HTTP/1.1)

כאשר HTTP/2 לא זמין, משתמשים בטכניקה הבאה:

  1. שליחת כל הבקשות חוץ מהבית האחרון של כל אחת
  2. השרת מחזיק את כל החיבורים פתוחים ומחכה להשלמה
  3. שליחת הבית האחרון של כל הבקשות בו-זמנית
  4. כל הבקשות מושלמות ומתחילות להתעבד יחד

שימוש ב-Burp Repeater - שליחה מקבילית

ב-Burp Suite ניתן לבצע תקיפת תנאי race בצורה פשוטה:

  1. פתיחת טאב חדש ב-Repeater עבור כל בקשה
  2. בחירת כל הטאבים הרלוונטיים
  3. יצירת קבוצה חדשה (New Group)
  4. בחירת "Send group in parallel (single-packet attack)"
  5. לחיצה על Send

הגדרות הקבוצה:

Send mode: Single-packet attack
          (sends all requests in a single TCP packet)

סקריפט Turbo Intruder

לתקיפות מורכבות יותר, Turbo Intruder מאפשר שליטה מלאה:

def queueRequests(target, wordlists):
    engine = RequestEngine(
        endpoint=target.endpoint,
        concurrentConnections=1,
        engine=Engine.BURP2
    )

    # Prepare the requests - send all except the last byte of each
    for i in range(20):
        engine.queue(
            target.req,
            gate='race1'
        )

    # Release all the requests simultaneously
    engine.openGate('race1')


def handleResponse(req, interesting):
    table.add(req)

סקריפט מתקדם יותר עם מעקב אחרי תוצאות:

def queueRequests(target, wordlists):
    engine = RequestEngine(
        endpoint=target.endpoint,
        concurrentConnections=1,
        engine=Engine.BURP2
    )

    # Race attack with a discount code
    for i in range(30):
        engine.queue(
            target.req,
            gate='discount'
        )

    engine.openGate('discount')


def handleResponse(req, interesting):
    # Filter successful responses
    if req.status == 200:
        table.add(req)
    # Flag responses where the discount was applied
    if 'discount applied' in req.response.lower():
        req.label = 'DISCOUNT APPLIED'
        table.add(req)

תנאי race ברמת בסיס הנתונים

גם כאשר הקוד נראה תקין, בסיס הנתונים עצמו עלול להיות פגיע:

קריאה-עדכון-כתיבה - Read-Modify-Write

-- Thread A                          -- Thread B
BEGIN;                               BEGIN;
SELECT balance FROM accounts
WHERE id = 1;  -- balance = 1000     SELECT balance FROM accounts
                                     WHERE id = 1;  -- balance = 1000
-- application: 1000 - 500 = 500
UPDATE accounts SET balance = 500    -- application: 1000 - 300 = 700
WHERE id = 1;                        UPDATE accounts SET balance = 700
COMMIT;                              WHERE id = 1;
                                     COMMIT;
-- Final result: balance = 700
-- We lost 500 shekels!

שני ה-Threads קראו את אותו ערך (1000) לפני שאחד מהם עדכן. העדכון השני דורס את הראשון.

פגיעות Check-Then-Act ב-SQL

-- Vulnerable: two separate queries
SELECT count FROM inventory WHERE item_id = 5;  -- count = 1
-- if count > 0:
UPDATE inventory SET count = count - 1 WHERE item_id = 5;

-- Protected: a single atomic query
UPDATE inventory SET count = count - 1
WHERE item_id = 5 AND count > 0;
-- Check the number of affected rows

הגנות

טרנזקציות עם רמת בידוד נכונה

-- Use the SERIALIZABLE isolation level
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN;

SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
-- FOR UPDATE locks the row - other threads will wait

UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;

פעולות אטומיות

-- Instead of SELECT then UPDATE, use a single atomic operation
UPDATE accounts
SET balance = balance - 100
WHERE id = 1 AND balance >= 100;

-- Check success
SELECT ROW_COUNT();  -- 1 = succeeded, 0 = insufficient balance

נעילות - Mutex Locks

const { Mutex } = require('async-mutex');
const mutex = new Mutex();

app.post('/apply-discount', async (req, res) => {
    const release = await mutex.acquire();

    try {
        const used = await db.query(
            'SELECT * FROM used_codes WHERE user_id = ? AND code = ?',
            [req.session.userId, req.body.code]
        );

        if (used.length > 0) {
            return res.status(400).json({ error: 'Already used' });
        }

        await db.query(
            'UPDATE cart SET discount = 20 WHERE user_id = ?',
            [req.session.userId]
        );

        await db.query(
            'INSERT INTO used_codes (user_id, code) VALUES (?, ?)',
            [req.session.userId, req.body.code]
        );

        res.json({ success: true });
    } finally {
        release();
    }
});

מפתחות אידמפוטנטיות - Idempotency Keys

app.post('/transfer', async (req, res) => {
    const idempotencyKey = req.headers['idempotency-key'];

    if (!idempotencyKey) {
        return res.status(400).json({ error: 'Idempotency key required' });
    }

    // Attempt to insert the key - fails if it already exists (UNIQUE constraint)
    try {
        await db.query(
            'INSERT INTO idempotency_keys (key, user_id) VALUES (?, ?)',
            [idempotencyKey, req.session.userId]
        );
    } catch (e) {
        // The key already exists - the request was already handled
        return res.status(409).json({ error: 'Duplicate request' });
    }

    // Perform the transfer safely
    await performTransfer(req.body);
    res.json({ success: true });
});

שימוש באילוץ UNIQUE בבסיס הנתונים

-- Instead of checking in code whether the code was used, create a unique constraint
CREATE TABLE used_codes (
    user_id INT,
    code VARCHAR(50),
    used_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    UNIQUE KEY unique_usage (user_id, code)
);

-- The insert attempt fails if it already exists - no race window
INSERT INTO used_codes (user_id, code) VALUES (?, ?);
-- If it succeeds - the code wasn't used before
-- If it fails with duplicate key - the code was already used

סיכום

תנאי race הם חולשה רבת עוצמה שלעיתים קרובות מתעלמים ממנה. הנקודות המרכזיות:

  • חפשו פעולות של CHECK ואז ACT על משאב משותף
  • השתמשו בתקיפת חבילה בודדת (HTTP/2) לתזמון מדויק
  • בדקו הן נקודות קצה בודדות והן שילובים של נקודות קצה שונות
  • הגנה נכונה דורשת פתרון ברמת בסיס הנתונים - לא רק ברמת הקוד