לדלג לתוכן

תקיפת תהליכים מרובי שלבים - Multi-Step Process Attacks

מהן חולשות בתהליכים מרובי שלבים

תהליכים מרובי שלבים (Multi-Step Processes) הם תהליכים שבהם המשתמש עובר דרך מספר שלבים לפני השלמת פעולה - הרשמה, רכישה, שינוי סיסמה, אישור עסקה, ועוד. חולשות בתהליכים אלו נוצרות כאשר השרת לא מוודא שהמשתמש עבר את כל השלבים בסדר הנכון, או כאשר הנתונים שעוברים בין השלבים ניתנים למניפולציה.


דילוג על שלבים - Step Skipping

הבעיה הבסיסית

תהליך רכישה תקין:

Step 1: POST /checkout/cart          -> cart review
Step 2: POST /checkout/shipping      -> enter shipping address
Step 3: POST /checkout/payment       -> enter payment details
Step 4: POST /checkout/confirm       -> final confirmation

תקיפה - דילוג ישירות לאישור:

Step 1: POST /checkout/cart          -> cart review
Step 4: POST /checkout/confirm       -> confirm with no payment!

קוד פגיע

// Step 1 - cart review
app.post('/checkout/cart', (req, res) => {
    req.session.cart = getCart(req.session.userId);
    res.json({ items: req.session.cart.items, total: req.session.cart.total });
});

// Step 2 - shipping address
app.post('/checkout/shipping', (req, res) => {
    req.session.shippingAddress = req.body.address;
    res.json({ shippingCost: calculateShipping(req.body.address) });
});

// Step 3 - payment
app.post('/checkout/payment', (req, res) => {
    const result = processPayment(req.body.cardDetails, req.session.cart.total);
    req.session.paymentConfirmed = result.success;
    res.json({ paymentId: result.id });
});

// Step 4 - confirmation (vulnerable!)
app.post('/checkout/confirm', (req, res) => {
    // Vulnerable - doesn't check that payment was made
    const order = createOrder({
        userId: req.session.userId,
        items: req.session.cart.items,
        address: req.session.shippingAddress
    });

    clearCart(req.session.userId);
    res.json({ orderId: order.id, message: 'Order confirmed!' });
});

התוקף שולח POST /checkout/confirm ישירות אחרי שלב 1, בלי לעבור דרך שלב התשלום.


מניפולציית פרמטרים בין שלבים

שינוי פרמטרים שעוברים בין שלבים

Step 1: review -> the server returns a summary with total=1337
Step 2: payment -> the client sends total=1337 back
Step 3: confirm -> the server confirms based on the submitted total

תקיפה:

-- original step 2
POST /checkout/payment HTTP/1.1
Content-Type: application/x-www-form-urlencoded

total=1337&cardNumber=4111111111111111&expiry=12/28

-- modified step 2
POST /checkout/payment HTTP/1.1
Content-Type: application/x-www-form-urlencoded

total=1&cardNumber=4111111111111111&expiry=12/28

קוד פגיע:

@app.route('/checkout/payment', methods=['POST'])
def process_payment():
    # Vulnerable - trusts the amount from the client
    total = float(request.form['total'])
    card = request.form['cardNumber']

    # Charges the card the amount sent by the client
    result = charge_card(card, total)

    if result['success']:
        session['payment_amount'] = total  # stores the wrong amount
        return redirect('/checkout/confirm')

תקיפת שידור חוזר - Replay Attacks

שימוש חוזר בתגובת שלב מוצלח

Valid process:
1. POST /transfer -> transfer request
2. Receive confirmation token: token=abc123
3. POST /transfer/confirm?token=abc123 -> confirm the transfer

Attack - reusing the token:
3. POST /transfer/confirm?token=abc123 -> first transfer (successful)
4. POST /transfer/confirm?token=abc123 -> second transfer (reuse!)
5. POST /transfer/confirm?token=abc123 -> third transfer...

קוד פגיע:

app.post('/transfer/confirm', (req, res) => {
    const { token } = req.query;

    // Vulnerable - checks that the token is valid but doesn't invalidate it
    const transfer = pendingTransfers.find(t => t.token === token);

    if (!transfer) {
        return res.status(400).json({ error: 'Invalid token' });
    }

    // Performs the transfer
    executeTransfer(transfer);
    // but doesn't remove the token from the list!

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

הbypass מעקב שלבים מבוסס Session

מניפולציית משתנה session

# The server tracks the current step in the session
@app.route('/wizard/step1', methods=['POST'])
def step1():
    session['current_step'] = 1
    session['data_step1'] = request.form.to_dict()
    return redirect('/wizard/step2')

@app.route('/wizard/step2', methods=['POST'])
def step2():
    if session.get('current_step') != 1:
        return redirect('/wizard/step1')

    session['current_step'] = 2
    session['data_step2'] = request.form.to_dict()
    return redirect('/wizard/step3')

@app.route('/wizard/step3', methods=['POST'])
def step3():
    if session.get('current_step') != 2:
        return redirect('/wizard/step1')

    # Performs the action
    process_wizard(session['data_step1'], session['data_step2'], request.form)

הבעיה: מה קורה אם התוקף פותח שני חלונות?

Window 1: step1 -> step2 (session['current_step'] = 2)
Window 2: step1 -> step2 (session['current_step'] = 2, overwrites window 1)
Window 1: step3 -> succeeds! (current_step == 2)
          but data_step1 and data_step2 are from window 2!

שימוש חוזר בטוקנים בין שלבים

בעיית טוקן לא ייחודי

Password change process:
1. POST /change-password/verify -> sends an SMS code
2. POST /change-password/confirm?code=123456 -> verifies the code
3. POST /change-password/set -> sets a new password

תקיפה - שינוי הנמען בין שלב 2 ל-3:

-- step 1: password change request for our account
POST /change-password/verify HTTP/1.1
Cookie: session=my_session

{"email": "attacker@evil.com"}

-- step 2: verify the code (the code we received via SMS)
POST /change-password/confirm HTTP/1.1
Cookie: session=my_session

{"code": "123456"}

-- step 3: change the parameter to the victim's account!
POST /change-password/set HTTP/1.1
Cookie: session=my_session

{"email": "victim@example.com", "newPassword": "hacked123"}

קוד פגיע:

app.post('/change-password/set', (req, res) => {
    // Checks that the user passed verification
    if (!req.session.passwordVerified) {
        return res.status(403).json({ error: 'Not verified' });
    }

    // Vulnerable - takes the email from the current request
    // instead of from the session where verification took place
    const email = req.body.email;
    const newPassword = req.body.newPassword;

    updatePassword(email, newPassword);
    req.session.passwordVerified = false;

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

הbypass אימות מרובה שלבים

דילוג על שלב 2FA

Login process:
1. POST /login -> authenticate username and password
2. POST /login/2fa -> enter the OTP code
3. GET /dashboard -> access the system

תקיפה:

-- step 1: normal login
POST /login HTTP/1.1
Content-Type: application/x-www-form-urlencoded

username=victim&password=password123

HTTP/1.1 302 Found
Location: /login/2fa
Set-Cookie: session=abc123

-- skip step 2 - direct access
GET /dashboard HTTP/1.1
Cookie: session=abc123

HTTP/1.1 200 OK
-- if the server doesn't check that 2FA was completed, we get access

קוד פגיע:

@app.route('/login', methods=['POST'])
def login():
    user = authenticate(request.form['username'], request.form['password'])
    if user:
        session['user_id'] = user.id
        session['authenticated'] = True
        # Vulnerable - sets authenticated=True before 2FA
        if user.has_2fa:
            return redirect('/login/2fa')
        return redirect('/dashboard')

@app.route('/dashboard')
@login_required  # only checks authenticated=True
def dashboard():
    return render_template('dashboard.html')

מניפולציית session בין משתמשים

-- log in as a regular user (no 2FA)
POST /login HTTP/1.1
username=wiener&password=peter

HTTP/1.1 302 Found
Location: /dashboard
Set-Cookie: session=sess_wiener

-- switch to the victim's session via the 2FA step
POST /login HTTP/1.1
username=victim&password=victimpass

HTTP/1.1 302 Found
Location: /login/2fa
Set-Cookie: session=sess_victim

-- use the victim's session (already authenticated)
-- with a modified user parameter
POST /login/2fa HTTP/1.1
Cookie: session=sess_victim

code=000000&username=victim

-- if the server doesn't check the code properly
-- or if the username can be changed in the session

מניפולציית תהליך רכישה

שינוי מוצר אחרי תשלום

Step 1: add a cheap product (USB cable - $5) to the cart
Step 2: pay $5
Step 3: change the product in the cart to a laptop ($1500) before confirming
Step 4: confirm - receive the laptop for $5
-- step 2: payment
POST /checkout/payment HTTP/1.1
Content-Type: application/json

{"cartId": "cart_123", "amount": 5}

HTTP/1.1 200 OK
{"paymentId": "pay_456", "status": "success"}

-- between step 2 and 3: change the product in the cart
PUT /cart/items/1 HTTP/1.1
Content-Type: application/json

{"productId": 42, "quantity": 1}

-- step 3: confirm
POST /checkout/confirm HTTP/1.1
Content-Type: application/json

{"paymentId": "pay_456", "cartId": "cart_123"}

הגנות

ולידציית שלבים בצד שרת

// Secure step-tracking mechanism
class CheckoutProcess {
    constructor(userId) {
        this.userId = userId;
        this.steps = {
            cart: { completed: false, data: null },
            shipping: { completed: false, data: null },
            payment: { completed: false, data: null },
            confirm: { completed: false, data: null }
        };
        this.currentStep = 'cart';
    }

    canProceedTo(step) {
        const stepOrder = ['cart', 'shipping', 'payment', 'confirm'];
        const targetIndex = stepOrder.indexOf(step);

        // Check that all previous steps were completed
        for (let i = 0; i < targetIndex; i++) {
            if (!this.steps[stepOrder[i]].completed) {
                return false;
            }
        }
        return true;
    }
}

app.post('/checkout/confirm', (req, res) => {
    const process = getCheckoutProcess(req.session.userId);

    // Protected - check that all previous steps were completed
    if (!process.canProceedTo('confirm')) {
        return res.status(400).json({
            error: 'Please complete all previous steps'
        });
    }

    // Additional check that the payment matches the current cart
    if (process.steps.payment.data.amount !== process.steps.cart.data.total) {
        return res.status(400).json({
            error: 'Payment amount mismatch'
        });
    }

    // Confirm the order
    createOrder(process);
    process.steps.confirm.completed = true;
});

טוקנים קריפטוגרפיים לכל שלב

const crypto = require('crypto');

function generateStepToken(userId, step, data) {
    const payload = JSON.stringify({ userId, step, data, timestamp: Date.now() });
    const hmac = crypto.createHmac('sha256', SECRET_KEY);
    hmac.update(payload);
    return {
        payload: Buffer.from(payload).toString('base64'),
        signature: hmac.digest('hex')
    };
}

function verifyStepToken(token, expectedStep) {
    const { payload, signature } = token;
    const hmac = crypto.createHmac('sha256', SECRET_KEY);
    hmac.update(Buffer.from(payload, 'base64').toString());

    if (hmac.digest('hex') !== signature) {
        return null;  // Invalid signature
    }

    const data = JSON.parse(Buffer.from(payload, 'base64').toString());

    if (data.step !== expectedStep) {
        return null;  // Step mismatch
    }

    // Check time validity
    if (Date.now() - data.timestamp > 30 * 60 * 1000) {
        return null;  // Expired (30 minutes)
    }

    return data;
}

ביטול טוקנים חד-פעמיים

app.post('/transfer/confirm', async (req, res) => {
    const { token } = req.body;

    // Protected - atomic operation: check + delete
    const transfer = await db.query(
        'DELETE FROM pending_transfers WHERE token = ? RETURNING *',
        [token]
    );

    if (!transfer) {
        return res.status(400).json({ error: 'Invalid or used token' });
    }

    // The token was deleted - can't be reused
    await executeTransfer(transfer);
    res.json({ success: true });
});

נעילת נתונים אחרי תשלום

app.post('/checkout/payment', async (req, res) => {
    const cart = await getCart(req.session.userId);

    // Create a snapshot of the cart at the moment of payment
    const snapshot = {
        items: JSON.parse(JSON.stringify(cart.items)),
        total: cart.total,
        hash: hashCart(cart)
    };

    const payment = await processPayment(req.body.cardDetails, snapshot.total);

    if (payment.success) {
        // Store the snapshot - not the cart itself
        req.session.paymentSnapshot = snapshot;
        req.session.paymentId = payment.id;
    }
});

app.post('/checkout/confirm', async (req, res) => {
    const snapshot = req.session.paymentSnapshot;
    const currentCart = await getCart(req.session.userId);

    // Check that the cart hasn't changed since payment
    if (hashCart(currentCart) !== snapshot.hash) {
        return res.status(400).json({
            error: 'Cart changed since payment. Please pay again.'
        });
    }

    // Create the order based on the snapshot
    createOrder(snapshot);
});

סיכום

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

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