תנאי מרוץ - Race Conditions¶
מהם תנאי מרוץ באפליקציות ווב¶
תנאי מרוץ (Race Condition) הוא פגם אבטחה שנוצר כאשר אפליקציה מעבדת בקשות מרובות במקביל ומבצעת פעולות על משאב משותף ללא סנכרון מתאים. התוקף מנצל את חלון הזמן הצר בין בדיקת תנאי לבין ביצוע הפעולה - מה שמכונה TOCTOU (Time Of Check To Time Of Use).
בניגוד לחולשות קלאסיות כמו XSS או SQLi, תנאי מרוץ לא מבוססים על קלט זדוני אלא על תזמון מדויק. האפליקציה עושה בדיוק את מה שהיא אמורה לעשות - פעם אחת. הבעיה היא שהיא לא מוגנת מפני ביצוע מקבילי של אותה פעולה.
הבנת חלון הזמן - TOCTOU¶
הבעיה הבסיסית נראית כך:
זמן -->
Thread A: [CHECK balance >= 100] -------- [DEDUCT 100] -------- [DONE]
Thread B: [CHECK balance >= 100] -------- [DEDUCT 100] -------- [DONE]
יתרה התחלתית: 100
תוצאה צפויה: Thread A מצליח, Thread B נכשל
תוצאה בפועל: שניהם מצליחים! יתרה: -100
חלון הפגיעות הוא הזמן שבין ה-CHECK ל-DEDUCT. אם שתי בקשות מגיעות בתוך חלון זה, שתיהן יעברו את הבדיקה לפני שאחת מהן מעדכנת את המשאב.
סוגי תנאי מרוץ¶
עקיפת מגבלות - Limit Overrun¶
הסוג הנפוץ ביותר. התוקף מנצל את חלון הזמן בין בדיקת מגבלה לבין עדכון המצב.
דוגמה - שימוש בקוד הנחה מספר פעמים:
קוד פגיע ב-Node.js:
app.post('/apply-discount', async (req, res) => {
const { code } = req.body;
const userId = req.session.userId;
// CHECK - בדיקה אם הקוד כבר שומש
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' });
}
// חלון פגיעות - TOCTOU gap
// USE - החלת ההנחה
await db.query(
'UPDATE cart SET discount = 20 WHERE user_id = ?',
[userId]
);
// סימון הקוד כמשומש
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 - חלון פגיעות
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 - חלון פגיעות
$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']];
}
תנאי מרוץ בנקודת קצה בודדת - Single-Endpoint¶
שליחת בקשות זהות רבות לאותה נקודת קצה בו-זמנית. זה הסוג הפשוט ביותר ליישום. כל הדוגמאות למעלה הן מסוג זה.
תנאי מרוץ מרובי נקודות קצה - Multi-Endpoint¶
ניצול מרוץ בין שתי פעולות שונות הפועלות על אותו משאב:
POST /cart/add-item (מוסיף מוצר לעגלה)
POST /cart/checkout (משלם על העגלה)
תרחיש תקיפה:
1. הוספת מוצר זול לעגלה
2. שליחת checkout במקביל להחלפת המוצר למוצר יקר
3. התשלום עובר על המחיר הזול, אבל המוצר שנשלח הוא היקר
תנאי מרוץ של בנייה חלקית - Partial Construction¶
גישה לאובייקט לפני שהאתחול שלו הושלם:
app.post('/register', async (req, res) => {
// שלב 1: יצירת המשתמש
const user = await User.create({
email: req.body.email,
password: hashPassword(req.body.password)
});
// חלון פגיעות - המשתמש קיים אבל ללא הרשאות מוגדרות
// שלב 2: הגדרת הרשאות
await Permissions.create({
userId: user.id,
role: 'user',
verified: false
});
});
אם תוקף מצליח להתחבר עם המשתמש בחלון שבין שלב 1 לשלב 2, יתכן שיקבל הרשאות ברירת מחדל שונות (או ללא הגבלות כלל).
תקיפות רגישות לזמן - Time-Sensitive Attacks¶
ניצול פעולות המבוססות על חותמות זמן:
import hashlib, time
def generate_reset_token(email):
timestamp = str(int(time.time()))
token = hashlib.sha256(
(email + timestamp).encode()
).hexdigest()
return token
אם שני משתמשים מבקשים איפוס סיסמה באותה שנייה, הטוקנים שלהם עשויים להיות זהים (אם ה-hash מבוסס על זמן + email בלבד). תוקף יכול לבקש איפוס לעצמו ולקורבן בו-זמנית ולהשתמש בטוקן שקיבל.
טכניקות מעשיות לניצול¶
תקיפת חבילה בודדת - 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 לא זמין, משתמשים בטכניקה הבאה:
- שליחת כל הבקשות חוץ מהבית האחרון של כל אחת
- השרת מחזיק את כל החיבורים פתוחים ומחכה להשלמה
- שליחת הבית האחרון של כל הבקשות בו-זמנית
- כל הבקשות מושלמות ומתחילות להתעבד יחד
שימוש ב-Burp Repeater - שליחה מקבילית¶
ב-Burp Suite ניתן לבצע תקיפת תנאי מרוץ בצורה פשוטה:
- פתיחת טאב חדש ב-Repeater עבור כל בקשה
- בחירת כל הטאבים הרלוונטיים
- יצירת קבוצה חדשה (New Group)
- בחירת "Send group in parallel (single-packet attack)"
- לחיצה על Send
הגדרות הקבוצה:
סקריפט Turbo Intruder¶
לתקיפות מורכבות יותר, Turbo Intruder מאפשר שליטה מלאה:
def queueRequests(target, wordlists):
engine = RequestEngine(
endpoint=target.endpoint,
concurrentConnections=1,
engine=Engine.BURP2
)
# הכנת הבקשות - שליחת כולן חוץ מהבית האחרון
for i in range(20):
engine.queue(
target.req,
gate='race1'
)
# שחרור כל הבקשות בו-זמנית
engine.openGate('race1')
def handleResponse(req, interesting):
table.add(req)
סקריפט מתקדם יותר עם מעקב אחרי תוצאות:
def queueRequests(target, wordlists):
engine = RequestEngine(
endpoint=target.endpoint,
concurrentConnections=1,
engine=Engine.BURP2
)
# תקיפת מרוץ עם קוד הנחה
for i in range(30):
engine.queue(
target.req,
gate='discount'
)
engine.openGate('discount')
def handleResponse(req, interesting):
# סינון תגובות מוצלחות
if req.status == 200:
table.add(req)
# סימון תגובות עם הנחה שהוחלה
if 'discount applied' in req.response.lower():
req.label = 'DISCOUNT APPLIED'
table.add(req)
תנאי מרוץ ברמת בסיס הנתונים¶
גם כאשר הקוד נראה תקין, בסיס הנתונים עצמו עלול להיות פגיע:
קריאה-עדכון-כתיבה - 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;
-- תוצאה סופית: balance = 700
-- הפסדנו 500 שקלים!
שני ה-Threads קראו את אותו ערך (1000) לפני שאחד מהם עדכן. העדכון השני דורס את הראשון.
פגיעות Check-Then-Act ב-SQL¶
-- פגיע: שתי שאילתות נפרדות
SELECT count FROM inventory WHERE item_id = 5; -- count = 1
-- אם count > 0:
UPDATE inventory SET count = count - 1 WHERE item_id = 5;
-- מוגן: שאילתה אטומית אחת
UPDATE inventory SET count = count - 1
WHERE item_id = 5 AND count > 0;
-- בודקים את מספר השורות שהושפעו
הגנות¶
טרנזקציות עם רמת בידוד נכונה¶
-- שימוש ב-SERIALIZABLE isolation level
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
-- FOR UPDATE נועל את השורה - threads אחרים יחכו
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;
פעולות אטומיות¶
-- במקום SELECT ואז UPDATE, שימוש בפעולה אטומית אחת
UPDATE accounts
SET balance = balance - 100
WHERE id = 1 AND balance >= 100;
-- בדיקת הצלחה
SELECT ROW_COUNT(); -- 1 = הצליח, 0 = אין מספיק יתרה
נעילות - 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' });
}
// ניסיון להכניס את המפתח - יכשל אם כבר קיים (UNIQUE constraint)
try {
await db.query(
'INSERT INTO idempotency_keys (key, user_id) VALUES (?, ?)',
[idempotencyKey, req.session.userId]
);
} catch (e) {
// המפתח כבר קיים - הבקשה כבר טופלה
return res.status(409).json({ error: 'Duplicate request' });
}
// ביצוע ההעברה בצורה בטוחה
await performTransfer(req.body);
res.json({ success: true });
});
שימוש באילוץ UNIQUE בבסיס הנתונים¶
-- במקום לבדוק בקוד אם הקוד שומש, ליצור אילוץ ייחודי
CREATE TABLE used_codes (
user_id INT,
code VARCHAR(50),
used_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY unique_usage (user_id, code)
);
-- ניסיון ההכנסה יכשל אם כבר קיים - ללא חלון מרוץ
INSERT INTO used_codes (user_id, code) VALUES (?, ?);
-- אם הצליח - הקוד לא שומש קודם
-- אם נכשל עם duplicate key - הקוד כבר שומש
סיכום¶
תנאי מרוץ הם חולשה רבת עוצמה שלעיתים קרובות מתעלמים ממנה. הנקודות המרכזיות:
- חפשו פעולות של CHECK ואז ACT על משאב משותף
- השתמשו בתקיפת חבילה בודדת (HTTP/2) לתזמון מדויק
- בדקו הן נקודות קצה בודדות והן שילובים של נקודות קצה שונות
- הגנה נכונה דורשת פתרון ברמת בסיס הנתונים - לא רק ברמת הקוד