תנאי 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 לא זמין, משתמשים בטכניקה הבאה:
- שליחת כל הבקשות חוץ מהבית האחרון של כל אחת
- השרת מחזיק את כל החיבורים פתוחים ומחכה להשלמה
- שליחת הבית האחרון של כל הבקשות בו-זמנית
- כל הבקשות מושלמות ומתחילות להתעבד יחד
שימוש ב-Burp Repeater - שליחה מקבילית¶
ב-Burp Suite ניתן לבצע תקיפת תנאי race בצורה פשוטה:
- פתיחת טאב חדש ב-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
)
# 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) לתזמון מדויק
- בדקו הן נקודות קצה בודדות והן שילובים של נקודות קצה שונות
- הגנה נכונה דורשת פתרון ברמת בסיס הנתונים - לא רק ברמת הקוד