Unzureichende Durchsetzung einer einzelnen, einzigartigen Aktion

Beschreibung

Unzureichende Durchsetzung einer einzelnen, einzigartigen Aktion ist eine Geschäftslogik-Schwachstelle, bei der Software es nicht schafft, einen Akteur ordnungsgemäß darauf zu beschränken, eine bestimmte Aktion nur einmal auszuführen oder eine einzelne einzigartige Instanz dieser Aktion zu erhalten. Viele Anwendungen haben Operationen, die inhärent einzigartig oder einmalig sein sollten: Abstimmen bei Wahlen, Einlösen von Rabattcodes, Beanspruchen von kostenlosen Testversionen, Einreichen von Anträgen oder Durchführen bestimmter Finanztransaktionen. Wenn diese Einschränkungen nicht ordnungsgemäß serverseitig durchgesetzt werden, können Angreifer Aktionen wiederholen, die singular sein sollten, was möglicherweise Ergebnisse manipuliert, unfaire Vorteile verschafft oder finanzielle Verluste verursacht.

Risiko

Diese Schwachstelle kann schwerwiegende geschäftliche Konsequenzen haben. In Wahlsystemen könnten Angreifer "Wahlurnen füllen" und Wahlergebnisse manipulieren. Im E-Commerce könnten Gutscheincodes oder Werbeangebote mehrfach eingelöst werden, was finanzielle Verluste verursacht. In Ticketing-Systemen könnte derselbe Sitzplatz mehrfach gebucht werden, was zu Überbuchung und Kundenserviceproblemen führt. In Gaming- oder Wettbewerbskontexten könnten Einträge mehrfach eingereicht werden, um Gewinnchancen zu erhöhen. In Finanzsystemen könnten Transaktionen, die einmal erfolgen sollten, dupliziert werden. Die Auswirkung geht über individuelle Ausnutzung hinaus - systematischer Missbrauch kann die Integrität ganzer Systeme untergraben.

Lösung

Implementieren Sie serverseitige Durchsetzung von Aktions-Einzigartigkeit. Verwenden Sie Datenbank-Constraints (Unique-Indizes), um doppelte Einträge zu verhindern. Verfolgen Sie abgeschlossene Aktionen mit persistentem Speicher, nicht nur clientseitigem State oder Cookies. Generieren und validieren Sie Einmal-Tokens für einmalige Operationen. Implementieren Sie Idempotenz-Schlüssel für kritische Operationen, um doppelte Verarbeitung zu verhindern. Verwenden Sie Transaktionen mit geeigneten Isolationsstufen, um Race Conditions zu verhindern. Für Abstimmungs- oder ähnliche Systeme verifizieren Sie die Identität vor Erlauben der Aktion und zeichnen Sie den Abschluss manipulationssicher auf. Implementieren Sie Rate-Limiting als Defense-in-Depth-Massnahme.

Häufige Auswirkungen

AuswirkungDetails
SonstigesBereich: Sonstiges

Vorteil erlangen - Angreifer können Vorteile gegenüber legitimen Benutzern erlangen, indem sie die eingeschränkte Aktion mehrfach ausführen.
IntegritätBereich: Integrität

Anwendungsdaten modifizieren - Wiederholte Aktionen können Datenintegrität beschädigen, wie Stimmenzahlen oder Lagerbestände.
SonstigesBereich: Sonstiges

Geschäftliche Auswirkung - Finanzielle Verluste durch Gutscheinmissbrauch, unfaire Wettbewerbsergebnisse, Überbuchung oder andere Geschäftslogik-Verletzungen.

Beispielcode

Anfälliger Code

# Anfällig: Abstimmungssystem ohne ordnungsgemäße Einzigartigkeits-Durchsetzung
from flask import Flask, request, session

app = Flask(__name__)

votes = {'kandidat_a': 0, 'kandidat_b': 0}

@app.route('/vote', methods=['POST'])
def vulnerable_vote():
    candidate = request.form['candidate']

    # Anfällig: Prüft nur clientseitiges Cookie
    if session.get('has_voted'):
        return 'Bereits abgestimmt', 403

    # Anfällig: Cookie kann vom Client gelöscht/geändert werden
    session['has_voted'] = True
    votes[candidate] += 1

    return 'Stimme erfasst'

# Angreifer löscht Cookies und stimmt erneut ab!
// Anfällig: Gutscheineinlösung ohne serverseitige Verfolgung
public class VulnerableCouponService {

    public boolean redeemCoupon(String couponCode, int userId) {
        Coupon coupon = couponRepository.findByCode(couponCode);

        if (coupon == null || !coupon.isValid()) {
            return false;
        }

        // Anfällig: Keine Prüfung ob Benutzer diesen Gutschein bereits eingelöst hat
        // Keine Verfolgung der Einlösung
        applyDiscount(userId, coupon.getDiscount());

        return true;
    }
}

// Benutzer kann denselben Gutschein mehrfach einlösen
// Anfällig: Kostenlose Testversion-Registrierung
<?php
function vulnerable_start_trial($email) {
    // Anfällig: Prüft nur ob E-Mail als aktiver Benutzer existiert
    $user = get_user_by_email($email);

    if ($user && $user['status'] === 'active') {
        return false;  // Hat bereits ein Konto
    }

    // Anfällig: Keine Prüfung auf frühere Testversionen
    // Benutzer erstellt mehrere Konten mit email+1@, email+2@, etc.
    create_trial_account($email);
    return true;
}
?>
// Anfällig: Wettbewerbsteilnahme-System
app.post('/contest/enter', async (req, res) => {
    const { userId, contestId } = req.body;

    // Anfällig: Race Condition ermöglicht mehrere Einträge
    const existingEntry = await Entry.findOne({ userId, contestId });

    if (existingEntry) {
        return res.status(400).json({ error: 'Bereits teilgenommen' });
    }

    // Zwischen Prüfung und Einfügen kann eine andere Anfrage erfolgreich sein
    await Entry.create({ userId, contestId, timestamp: Date.now() });

    res.json({ success: true });
});
# Anfällig: Einmal-Passwort-Reset-Link
def vulnerable_reset_password(token, new_password):
    reset_request = get_reset_request(token)

    if not reset_request:
        return False

    # Anfällig: Token wird nicht sofort invalidiert
    # Kann mehrfach verwendet werden bevor es abläuft
    user = get_user(reset_request['user_id'])
    user.set_password(new_password)
    user.save()

    # Token sollte hier invalidiert werden!
    return True
// Anfällig: Ticketkauf ohne Sitzplatzsperrung
public class VulnerableTicketService
{
    public bool PurchaseTicket(int seatId, int userId)
    {
        var seat = _db.Seats.Find(seatId);

        // Anfällig: Keine Sperrung - Race Condition
        if (!seat.IsAvailable)
        {
            return false;
        }

        // Mehrere Benutzer können die Prüfung gleichzeitig passieren
        seat.IsAvailable = false;
        seat.OwnerId = userId;
        _db.SaveChanges();

        return true;
    }
}

Korrigierter Code

# Korrigiert: Abstimmungssystem mit serverseitiger Einzigartigkeits-Durchsetzung
from flask import Flask, request, g
import sqlite3

app = Flask(__name__)

@app.route('/vote', methods=['POST'])
def fixed_vote():
    candidate = request.form['candidate']
    user_id = get_authenticated_user_id()

    try:
        # Korrigiert: Datenbank-Constraint verhindert doppelte Stimmen
        db = get_db()
        db.execute(
            'INSERT INTO votes (user_id, candidate, timestamp) VALUES (?, ?, ?)',
            (user_id, candidate, datetime.now())
        )
        db.commit()

        return 'Stimme erfasst'
    except sqlite3.IntegrityError:
        # Unique-Constraint auf user_id verletzt
        return 'Bereits abgestimmt', 403

# Datenbank-Schema:
# CREATE TABLE votes (
#     id INTEGER PRIMARY KEY,
#     user_id INTEGER UNIQUE,  -- Stellt eine Stimme pro Benutzer sicher
#     candidate TEXT,
#     timestamp DATETIME
# );
// Korrigiert: Gutscheineinlösung mit Verfolgung
import javax.persistence.*;

public class FixedCouponService {

    @Transactional
    public boolean redeemCoupon(String couponCode, int userId) {
        Coupon coupon = couponRepository.findByCode(couponCode);

        if (coupon == null || !coupon.isValid()) {
            return false;
        }

        // Korrigiert: Auf existierende Einlösung prüfen
        if (redemptionRepository.existsByUserIdAndCouponId(userId, coupon.getId())) {
            return false;  // Bereits von diesem Benutzer eingelöst
        }

        // Korrigiert: Einlösung atomar aufzeichnen
        Redemption redemption = new Redemption();
        redemption.setUserId(userId);
        redemption.setCouponId(coupon.getId());
        redemption.setTimestamp(Instant.now());

        try {
            redemptionRepository.save(redemption);
            applyDiscount(userId, coupon.getDiscount());
            return true;
        } catch (DataIntegrityViolationException e) {
            // Unique-Constraint-Verletzung - gleichzeitige Einlösung
            return false;
        }
    }
}

// Datenbank: unique constraint auf (user_id, coupon_id)
// Korrigiert: Kostenlose Testversion mit umfassender Verfolgung
<?php
function fixed_start_trial($email, $device_fingerprint, $ip_address) {
    $normalized_email = normalize_email($email);  // +Tags entfernen, etc.

    // Korrigiert: Mehrere Indikatoren für frühere Testversionen prüfen
    $previous_trial = db_query(
        'SELECT * FROM trial_history WHERE
         normalized_email = ? OR
         device_fingerprint = ? OR
         ip_address = ?',
        [$normalized_email, $device_fingerprint, $ip_address]
    );

    if ($previous_trial) {
        return ['success' => false, 'reason' => 'Testversion bereits genutzt'];
    }

    // Korrigiert: Testversion mit mehreren Identifikatoren aufzeichnen
    db_query(
        'INSERT INTO trial_history
         (email, normalized_email, device_fingerprint, ip_address, created_at)
         VALUES (?, ?, ?, ?, NOW())',
        [$email, $normalized_email, $device_fingerprint, $ip_address]
    );

    create_trial_account($email);
    return ['success' => true];
}

function normalize_email($email) {
    // +suffix entfernen, Punkte (für Gmail), in Kleinbuchstaben konvertieren
    $parts = explode('@', strtolower($email));
    $local = preg_replace('/\+.*$/', '', $parts[0]);
    $local = str_replace('.', '', $local);  // Gmail ignoriert Punkte
    return $local . '@' . $parts[1];
}
?>
// Korrigiert: Wettbewerbsteilnahme mit atomaren Operationen
app.post('/contest/enter', async (req, res) => {
    const { userId, contestId } = req.body;

    try {
        // Korrigiert: Upsert mit unique constraint verwenden
        // Atomare Operation verhindert Race Condition
        await Entry.findOneAndUpdate(
            { userId, contestId },
            {
                $setOnInsert: {
                    userId,
                    contestId,
                    timestamp: Date.now()
                }
            },
            { upsert: true, rawResult: true }
        ).then(result => {
            if (result.lastErrorObject.updatedExisting) {
                return res.status(400).json({ error: 'Bereits teilgenommen' });
            }
            res.json({ success: true });
        });
    } catch (error) {
        if (error.code === 11000) {  // Duplicate key error
            return res.status(400).json({ error: 'Bereits teilgenommen' });
        }
        throw error;
    }
});

// MongoDB: db.entries.createIndex({ userId: 1, contestId: 1 }, { unique: true })
# Korrigiert: Einmal-Passwort-Reset-Link
from datetime import datetime, timedelta

def fixed_reset_password(token, new_password):
    # Korrigiert: Transaktion verwenden um Atomarität sicherzustellen
    with db.transaction():
        reset_request = db.query(
            'SELECT * FROM reset_tokens WHERE token = ? FOR UPDATE',
            [token]
        ).fetchone()

        if not reset_request:
            return False

        if reset_request['used']:
            return False  # Bereits verwendet

        if reset_request['expires_at'] < datetime.now():
            return False  # Abgelaufen

        # Korrigiert: Token als verwendet markieren VOR Passwortänderung
        db.execute(
            'UPDATE reset_tokens SET used = TRUE, used_at = ? WHERE token = ?',
            [datetime.now(), token]
        )

        # Jetzt Passwort ändern
        user = get_user(reset_request['user_id'])
        user.set_password(new_password)
        user.save()

    return True
// Korrigiert: Ticketkauf mit pessimistischer Sperrung
public class FixedTicketService
{
    public bool PurchaseTicket(int seatId, int userId)
    {
        using (var transaction = _db.Database.BeginTransaction(
            IsolationLevel.Serializable))
        {
            try
            {
                // Korrigiert: Zeile während Transaktion sperren
                var seat = _db.Seats
                    .FromSqlRaw("SELECT * FROM Seats WHERE Id = {0} FOR UPDATE", seatId)
                    .FirstOrDefault();

                if (seat == null || !seat.IsAvailable)
                {
                    transaction.Rollback();
                    return false;
                }

                seat.IsAvailable = false;
                seat.OwnerId = userId;
                seat.PurchasedAt = DateTime.UtcNow;

                _db.SaveChanges();
                transaction.Commit();

                return true;
            }
            catch
            {
                transaction.Rollback();
                return false;
            }
        }
    }
}

CVE-Beispiele

  • CVE-2008-0294: Ticket-Buchungsanwendung ermöglicht Benutzern das Sperren desselben Sitzplatzes mehrfach, was Überbuchung verursacht.
  • CVE-2005-4051: CMS ermöglicht Benutzern mehrfaches Abstimmen bei Downloads, was Rankings manipuliert.
  • CVE-2002-216: Umfrage-Software ermöglicht wiederholtes Abstimmen durch Manipulation von Browser-Cookies.
  • CVE-2002-1018: Bibliothekssystem ermöglicht mehrfaches Ausleihen desselben E-Books.

Verwandte CWEs

  • CWE-799: Unzureichende Kontrolle der Interaktionsfrequenz (Eltern)
  • CWE-362: Nebenläufige Ausführung mit geteilter Ressource und unzureichender Synchronisation (verwandt)
  • CWE-840: Geschäftslogik-Fehler (Kategorie)

Referenzen

  1. MITRE Corporation. "CWE-837: Improper Enforcement of a Single, Unique Action." https://cwe.mitre.org/data/definitions/837.html
  2. OWASP. "Business Logic Security Cheat Sheet." https://cheatsheetseries.owasp.org/cheatsheets/Business_Logic_Security_Cheat_Sheet.html
  3. OWASP. "Testing for Business Logic." OWASP Testing Guide.