Zum Hauptinhalt springen
Echtzeit-Radar & Feeds
Alle RSS Feeds ➔
👥 Community & Social
Windows Tipps & SecurityGrafikkarte vor Überhitzung schützen: So geht’s(25.09.2026 um 08:00 Uhr)
••••••••••
Windows Tipps & SecurityGrafikkarte vor Überhitzung schützen: So geht’s(25.09.2026 um 08:00 Uhr)
••••••••••
Intelligence View
⚡ tsecurity.de Intelligence

READ COMMITTED isn't as safe as you think

Every Postgres app I've worked on runs at READ COMMITTED. It's the default. It's what your ORM opens transactions with unless you tell it otherwise. And it's silently allowing bugs you'd assume it prevents. We tend to write database code…

0
↗ Quelle (dev.to)
Reagiere als Erste:r — dein Feedback zählt!

Every Postgres app I've worked on runs at READ COMMITTED. It's the default. It's what your ORM opens transactions with unless you tell it otherwise. And it's silently allowing bugs you'd assume it prevents.



We tend to write database code with a vague sense that the transaction is "protecting" us — that once we wrapped a read and a write in BEGIN / COMMIT, the database would handle the concurrency. Most of the time it does. Right up until two requests hit the same row within the same few milliseconds.



Let's look at what READ COMMITTED actually promises. And what it doesn't.









What READ COMMITTED actually does



READ COMMITTED is one of four isolation levels in the SQL standard. It's Postgres's default. It's also the default in SQL Server and Oracle. MySQL InnoDB defaults to REPEATABLE READ, which is a slightly different story.



What READ COMMITTED prevents is called a dirty read — reading uncommitted data from another transaction. Without isolation, transaction A could read a row that transaction B has changed but not yet committed. If B rolls back, A saw a value that never officially existed. READ COMMITTED closes that hole. Every read in your transaction only sees data that's already committed at the moment of the read. Uncommitted writes from other transactions are invisible.



That's it. That's the whole promise.



Everything else — reading the same row twice and getting different values, running the same query twice and getting different rows, two transactions overwriting each other's changes — READ COMMITTED does nothing about.



That last one is the one that costs you.









The bug you've probably shipped



Here's a NestJS endpoint that increments a counter. notifications_unread for a user, seat_usage for a tenant, like_count for a post — pick your favorite. The shape is always the same:




// notifications.service.ts
async incrementUnread(userId: string) {
const row = await this.repo.findOne({ where: { userId } });
row.count = row.count + 1;
await this.repo.save(row);
}






Read the current value. Add one. Save it back. Familiar? I've written this code more times than I want to admit.



Now two requests hit this endpoint at the same moment. Both are wrapped in transactions. Both are running at READ COMMITTED. Here's what happens:




Time    Transaction A                Transaction B
────── ───────────── ─────────────
t=1 BEGIN
t=2 SELECT count → 5
t=3 BEGIN
t=4 SELECT count → 5
t=5 UPDATE count = 6
t=6 COMMIT
t=7 UPDATE count = 6
t=8 COMMIT






Both transactions read 5. Both computed 6. Both wrote 6. Both committed. The counter went from 5 to 6 instead of 5 to 7.



Notice what didn't happen. No deadlock. No error. No serialization failure. The database did exactly what you asked. You asked for two independent transactions that each read 5 and each wrote 6. It gave you both.



This is a lost update. The name is precise — one of the increments was silently lost.



Under low load you'll never see it. Both requests almost never land in the same millisecond. But on a Friday when traffic spikes, the counter starts drifting. Someone runs a reconciliation against the source-of-truth event log and finds you're off by hundreds. And now you're debugging.









Why the transaction wasn't enough



The instinct most of us have — myself included, for years — is that wrapping something in BEGIN / COMMIT makes it atomic against concurrent access. It doesn't.



Atomicity is about your transaction being all-or-nothing against failure. Isolation is what governs concurrent access. They're separate properties. ACID's A and I are not the same thing, and READ COMMITTED is a very weak setting on the I axis.



At each SELECT, READ COMMITTED takes a fresh snapshot of "what's committed right now." When transaction B does its SELECT at t=4, the value is still 5 — A hasn't committed yet, so B sees the last committed value. When A commits at t=6 and B's UPDATE fires at t=7, Postgres doesn't check that the row is still 5. B's UPDATE is well-formed SQL. Postgres runs it.



Postgres kept its promise: no dirty reads. What it never promised is that your read-then-write pattern would be safe.









The four ways out



There are four ways to fix this. They're not interchangeable — each has a different failure mode.






Atomic SQL



If your update can be expressed as a pure function of the old value, do it in one SQL statement:




// notifications.service.ts
await this.repo.increment({ userId }, 'count', 1);






Which becomes:




UPDATE notifications SET count = count + 1 WHERE user_id = $1;






The read and the write happen inside a single statement, holding a row lock the entire time. No application-level race window exists. Works at any isolation level. No retries. Fastest option.



This is the best fix when it fits. If your logic really is "increment," "decrement," "append to array," "set updated_at" — always prefer atomic SQL. Application-level read-modify-write is the pattern that creates the bug in the first place.






SELECT FOR UPDATE



Pessimistic locking. Acquire a row-level exclusive lock at the SELECT, hold it until the transaction commits:




// notifications.service.ts
await this.dataSource.transaction(async (manager) => {
const row = await manager.findOne(Notification, {
where: { userId },
lock: { mode: 'pessimistic_write' },
});
row.count = row.count + 1;
await manager.save(row);
});






TypeORM's pessimistic_write compiles to SELECT ... FOR UPDATE. When transaction B tries to lock the same row A already holds, B blocks until A commits. B then unblocks, reads the new value (6), and correctly writes 7.



Use this when the update logic is too complex to express as atomic SQL — you need to read some fields, run business logic, then write. SELECT FOR UPDATE gives you a critical section that behaves the way most of us assumed the plain transaction already did.



The cost: it's blocking. Under high contention on a hot row you'll build a queue of transactions waiting for each other, latencies climb, and if you lock multiple rows in different orders in different code paths, you get deadlocks.






Optimistic locking with a version column



Add a version column and check it in the WHERE clause on every UPDATE:




// notification.entity.ts
@Entity()
export class Notification {
@PrimaryColumn()
userId: string;

@Column()
count: number;

@VersionColumn()
version: number;
}






TypeORM auto-manages the version column. Every UPDATE it generates becomes:




UPDATE notifications
SET count = 6, version = 4
WHERE user_id = $1 AND version = 3;






If another transaction updated the row since you read it, the version has moved past 3, your UPDATE matches zero rows, and TypeORM throws an optimistic-lock error. You catch it and retry.



Different failure mode from SELECT FOR UPDATE: no locks, no blocking, but conflicts become retries. Right choice when reads outnumber writes and conflicts are rare — you pay retry cost only when the conflict actually happens, not upfront blocking cost every time.






Raise the isolation level



The last option: opt into REPEATABLE READ. In Postgres, this isn't just what the SQL standard describes — it's snapshot isolation. Your transaction sees a single snapshot of the database from the moment it started. If you try to UPDATE a row that changed since your snapshot, Postgres aborts you:




ERROR: could not serialize access due to concurrent update






You catch the error, retry the transaction, and the second time your snapshot is fresh.




// notifications.service.ts
await this.dataSource.transaction('REPEATABLE READ', async (manager) => {
// ...same code as before
});






Same retry-on-conflict shape as optimistic locking, but the database does the version check for you across every row you read. Useful when you're doing something like "read many rows, compute a decision, write to one row" and want the database to guarantee nothing changed under you.



The cost: you need a retry wrapper around every transaction that uses REPEATABLE READ. Under heavy contention transactions can retry many times or fail entirely. It's the strongest guarantee, and the one that demands the most operational discipline.









What to actually do



Most codebases end up using two of these four, not all four.



Atomic SQL for anything expressible as a pure function of the old row. SELECT FOR UPDATE for the critical sections where atomic SQL doesn't fit. That combination handles the majority of cases with minimal retry logic. Version columns come out when you have a specific high-read table where blocking hurts. REPEATABLE READ comes out when you're already committed to retry logic and want the database to enforce consistency across many rows.



The one thing worth internalizing: the plain transaction — the one your ORM opens by default when you call .transaction() — isn't the protection you think it is. Postgres kept its promise. Just not the promise you assumed.

1. Sofort-Triage & Abwehrmaßnahmen

SOC Incident Playbook: Remote Code Execution (RCE) Defense
1 Warnungen
title: Detect Exploitation - READ COMMITTED isn't as safe as you think
id: b2eda599-1e5e-42e6-a784-755d85d86cdc
status: experimental
description: Automatisch generierte SIEM-Erkennungsregel basierend auf CTI Intelligence
references:
  - https://tsecurity.de/
author: iShareStuff CTI Automated Detection Engine
date: 2026-09-26
logsource:
  category: network_connection
  product: any
detection:
  selection:
      CommandLine|contains:
        - 'exploit'
  condition: selection
falsepositives:
  - Legitime administrative Zugriffe oder Penetrationstests
level: high
tags:
  - attack.initial_access
Syntax validiert (0 Fehler)
rule CTI_Threat_Indicator {
    meta:
        author = "iShareStuff CTI Automated Detection Engine"
        date = "2026-09-26"
        description = "YARA Signature for "
    strings:
        $str = "READ COMMITTED isn\'t as safe a" ascii wide
    condition:
        any of them
}
Syntax validiert (0 Fehler)
index=security sourcetype IN ("cisco:asa", "pan:traffic", "zeek_conn", "suricata", "WinEventLog:Security")
("READ COMMITTED isnt as safe as you think")
| stats count earliest(_time) as first_seen latest(_time) as last_seen by src_ip, dest_ip, dest_host, signature
| eval first_seen=strftime(first_seen, "%Y-%m-%d %H:%M:%S"), last_seen=strftime(last_seen, "%Y-%m-%d %H:%M:%S")
| sort - count
Syntax validiert (0 Fehler)
message: "*READ COMMITTED isnt as safe as you think*"
Syntax validiert (0 Fehler)
CommonSecurityLog
| where Message has "READ COMMITTED isnt as safe as you think"
| summarize EventCount = count(), FirstSeen = min(TimeGenerated), LastSeen = max(TimeGenerated) by SourceIP, DestinationIP, DestinationPort, Activity
| extend DetectionRule = "iShareStuff-CTI-Compiled"
| sort by EventCount desc

2. Cyber Threat Intelligence & Forensik

CTI Threat Relationship Graph2 Knoten / 1 Relationen
CVE / Incident Software MITRE ATT&CK CWE Weakness IoC
🎯
MITRE ATT&CK Matrix Navigator 14 Taktiken
Reconnaissance
-
Resource Development
-
Initial Access
Execution
Persistence
-
Privilege Escalation
Defense Evasion
Credential Access
-
Discovery
-
Lateral Movement
-
Collection
-
Command and Control
Exfiltration
-
Impact
tsecurity.de Cognitive Threat RAG
Fokus-Vektor:

Kognitive Analyse für identifizierte Bedrohung: Erhöhte Bedrohungslage im Bereich READ COMMITTED isn't as safe as you thin.... Basierend auf 368k Vektor-Korrelationen werden sofortige Isolationsmaßnahmen für betroffene Endpunkte empfohlen.

🛡️ Angriffsfläche & Exposure

Netzwerk/Remote-Zugriff ohne Vorauthentifizierung möglich.

⚡ Empfohlene Sofortmaßnahmen
  • 1. Perimeter-Inspektion: Relevante Portfreigaben und exponierte Endpunkte unverzüglich scannen.
  • 2. Patch-Applikation: Hersteller-Hotfix einspielen oder betroffene Daemons in isolierte DMZ-Segmente überführen.
  • 3. Telemetrie & EDR-Alerts: Prozessaufrufe und Child-Processes auf anomale Shell-Spawns überwachen.
🔗 Semantisch verwandte Zero-Days MariaDB 11.7 VEC
Ähnliche Beiträge
🔍 Verwandte News

Auch interessante Nachrichten READ COMMITTED isn't as safe as you think

Thematisch verwandte Begriffe: READ, COMMITTED, isnt, safe · 6 Treffer

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Zum Aktualisieren ziehen
ZERO-DAY CVE-2026-88003 | InvoicePlane is a self-hosted open source application for managing invoi…
Advisory →
tsecurity.de Icon
Offline-Lesen, Eilmeldungen & 0ms Ladezeit

Installiere tsecurity.de direkt auf deinen Home-Bildschirm für das ultimative Vollbild-Magazinerlebnis ohne Browser-Leisten.

Nächster Beitrag