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
| Auswirkung | Details |
|---|---|
| Sonstiges | Bereich: Sonstiges Vorteil erlangen - Angreifer können Vorteile gegenüber legitimen Benutzern erlangen, indem sie die eingeschränkte Aktion mehrfach ausführen. |
| Integrität | Bereich: Integrität Anwendungsdaten modifizieren - Wiederholte Aktionen können Datenintegrität beschädigen, wie Stimmenzahlen oder Lagerbestände. |
| Sonstiges | Bereich: 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
- MITRE Corporation. "CWE-837: Improper Enforcement of a Single, Unique Action." https://cwe.mitre.org/data/definitions/837.html
- OWASP. "Business Logic Security Cheat Sheet." https://cheatsheetseries.owasp.org/cheatsheets/Business_Logic_Security_Cheat_Sheet.html
- OWASP. "Testing for Business Logic." OWASP Testing Guide.