hashVersion-Downgrade geschlossen (Pentest R167-01, HIGH)
verifyIntegrity waehlte die Pruefstaerke nach der von der Zeile selbst deklarierten hashVersion - und die ist nicht gehasht. Angriff: hashVersion 2->1 setzen, die nur von V2 abgedeckten Felder aendern (success false->true, errorMessage leeren, resourceLabel umschreiben) und den schwachen V1-Hash ueber die 7 unveraenderten Felder nachziehen. Ergebnis: Fehl-Login als Erfolg getarnt, Pruefung meldet "gueltig". An der letzten Zeile der Kette entsteht dabei nicht einmal ein Gap - dauerhaft unsichtbar. Fix: Version-Floor. Die erwartete Pruefstaerke leitet sich aus der Kette ab (MIN(id) WHERE hashVersion >= 2), nicht aus der Selbstauskunft. Ab dieser Grenze muss jede Zeile V2 sein; weicht die deklarierte Version ab, gilt die Zeile selbst als manipuliert. Geprueft wird immer mit dem erwarteten Verfahren. Die Grenze laesst sich durch Herabstufen einzelner Zeilen nicht verschieben. Zusaetzlich konsultiert die Pruefung jetzt das Loeschungs-Manifest: neu unexplainedGaps - nur Luecken ohne protokollierte Loeschung sind erklaerungsbeduerftig. Vorher war das Manifest rein informativ, wodurch sich eine boeswillige Loeschung als harmloser Gap tarnen konnte. Verifiziert (PoC nachgebaut): Downgrade mit Nachfolger erkannt, Downgrade der Tail-Zeile erkannt, Gegenrichtung (V1 faelschlich als V2) erkannt, keine Falschmeldungen auf Bestandsdaten. tsc + vite build gruen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -148,13 +148,21 @@ export async function verifyIntegrity(req: AuthRequest, res: Response) {
|
||||
// echten Angriff aussehen liess (und damit die Meldung entwertete).
|
||||
const tampered = result.tamperedEntries.length;
|
||||
const gaps = result.chainGaps.length;
|
||||
const unexplained = result.unexplainedGaps.length;
|
||||
|
||||
const luecken = gaps > 0
|
||||
? `${gaps} strukturelle Lücken` +
|
||||
(unexplained === 0
|
||||
? ' (alle durch protokollierte Löschungen erklärt)'
|
||||
: unexplained < gaps
|
||||
? `, davon ${unexplained} ohne dokumentierte Löschung`
|
||||
: ' ohne dokumentierte Löschung')
|
||||
: '';
|
||||
|
||||
const message = tampered > 0
|
||||
? `${tampered} MANIPULIERTE Einträge gefunden` +
|
||||
(gaps > 0 ? ` (zusätzlich ${gaps} strukturelle Lücken)` : '')
|
||||
? `${tampered} MANIPULIERTE Einträge gefunden` + (gaps > 0 ? ` (zusätzlich ${luecken})` : '')
|
||||
: gaps > 0
|
||||
? `Keine Manipulation. ${gaps} strukturelle Lücken in der Verkettung ` +
|
||||
'(parallel geschriebene oder gelöschte Einträge) – Inhalte unverändert.'
|
||||
? `Keine Manipulation. ${luecken} – Inhalte unverändert.`
|
||||
: 'Alle Einträge sind unverändert und lückenlos verkettet';
|
||||
|
||||
res.json({
|
||||
@@ -167,6 +175,8 @@ export async function verifyIntegrity(req: AuthRequest, res: Response) {
|
||||
tamperedEntries: result.tamperedEntries,
|
||||
// Meist harmlos: Verkettung unterbrochen, Inhalte selbst unversehrt.
|
||||
chainGaps: result.chainGaps,
|
||||
// Nur Lücken ohne protokollierte Löschung sind erklärungsbedürftig.
|
||||
unexplainedGaps: result.unexplainedGaps,
|
||||
tampered: tampered > 0,
|
||||
message,
|
||||
},
|
||||
|
||||
@@ -562,6 +562,11 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
|
||||
* auf Manipulation – die Zeilen selbst sind unveraendert.
|
||||
*/
|
||||
chainGaps: number[];
|
||||
/**
|
||||
* Teilmenge von `chainGaps`, die NICHT durch ein protokolliertes
|
||||
* Loeschungs-Manifest erklaert ist. Nur diese sind erklaerungsbeduerftig.
|
||||
*/
|
||||
unexplainedGaps: number[];
|
||||
}> {
|
||||
const where: Prisma.AuditLogWhereInput = {};
|
||||
|
||||
@@ -603,17 +608,78 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Version-Floor (Pentest R167-01)
|
||||
//
|
||||
// Vorher bestimmte die Zeile SELBST ueber `hashVersion`, wie streng sie
|
||||
// geprueft wird – und `hashVersion` ist nicht gehasht. Ein Angreifer mit
|
||||
// Schreibzugriff konnte also 2→1 zuruecksetzen, die Felder aendern, die nur
|
||||
// V2 abdeckt (z. B. `success` false→true), und den schwachen V1-Hash ueber
|
||||
// die 7 unveraenderten Felder nachziehen: Die Pruefung meldete "gueltig".
|
||||
//
|
||||
// Jetzt leitet sich die ERWARTETE Version aus der Kette ab: ab der ersten
|
||||
// jemals mit V2 geschriebenen Zeile muss jede weitere Zeile V2 sein. Die
|
||||
// Grenze laesst sich durch das Herabstufen einer einzelnen Zeile nicht
|
||||
// verschieben (das Minimum bleibt), ein Angreifer muesste saemtliche
|
||||
// V2-Zeilen ab der Grenze herabstufen und die komplette Kette neu rechnen –
|
||||
// das entspricht einem vollstaendigen Rehash und damit dem bekannten
|
||||
// Grenzfall "vollstaendig kompromittierter DB-Zugang".
|
||||
//
|
||||
// Betriebshinweis: Bei einem rollierenden Deploy, bei dem kurzzeitig alte und
|
||||
// neue Instanz parallel schreiben, koennen echte V1-Zeilen nach der Grenze
|
||||
// entstehen und werden dann angezeigt. Beim hier ueblichen Deploy
|
||||
// (pull + rebuild + restart einer Instanz) tritt das nicht auf.
|
||||
const v2Cutover = await prisma.auditLog.aggregate({
|
||||
where: { hashVersion: { gte: 2 } },
|
||||
_min: { id: true },
|
||||
});
|
||||
const v2FromId = v2Cutover._min.id;
|
||||
|
||||
// Dokumentierte Loeschungen einlesen: Luecken, die auf einen protokollierten
|
||||
// Retention-Cleanup zurueckgehen, sind erklaert – alle anderen nicht. Vorher
|
||||
// war das Manifest rein informativ und wurde von der Pruefung ignoriert,
|
||||
// wodurch sich eine boeswillige Loeschung als "harmloser Gap" tarnen konnte.
|
||||
const deletionRanges: Array<{ from: number; to: number }> = [];
|
||||
const manifestRows = await prisma.auditLog.findMany({
|
||||
where: { resourceType: 'AuditLog', action: 'DELETE', endpoint: '/api/audit-logs/cleanup' },
|
||||
select: { changesAfter: true },
|
||||
});
|
||||
for (const row of manifestRows) {
|
||||
try {
|
||||
const parsed = JSON.parse(row.changesAfter || '{}');
|
||||
for (const m of parsed.manifest || []) {
|
||||
if (typeof m.fromId === 'number' && typeof m.toId === 'number') {
|
||||
deletionRanges.push({ from: m.fromId, to: m.toId });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Unlesbares Manifest = nicht erklaerend; Luecke bleibt unerklaert.
|
||||
}
|
||||
}
|
||||
const gapErklaert = (prevId: number, curId: number) =>
|
||||
deletionRanges.some((r) => r.from <= curId && r.to >= prevId);
|
||||
|
||||
const tamperedEntries: number[] = [];
|
||||
const chainGaps: number[] = [];
|
||||
const unexplainedGaps: number[] = [];
|
||||
|
||||
for (let i = 0; i < logs.length; i++) {
|
||||
const log = logs[i];
|
||||
|
||||
// Erwartete Pruefstaerke aus der Kette, NICHT aus der Selbstauskunft.
|
||||
const erwarteV2 = v2FromId !== null && log.id >= v2FromId;
|
||||
if (erwarteV2 !== (log.hashVersion >= 2)) {
|
||||
// Deklarierte Version passt nicht zur Position in der Kette →
|
||||
// Downgrade-Versuch (oder manipulierte Version).
|
||||
tamperedEntries.push(log.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Pruefverfahren richtet sich nach der Version, mit der geschrieben wurde.
|
||||
// Version 2 deckt alle Inhaltsspalten ab; Version 1 nur 7 Felder und darf
|
||||
// zusaetzlich die historische Serialisierung nutzen (generateHashLegacy).
|
||||
let hashOk: boolean;
|
||||
if (log.hashVersion >= 2) {
|
||||
if (erwarteV2) {
|
||||
hashOk = log.hash === generateHashV2({
|
||||
userId: log.userId,
|
||||
userEmail: log.userEmail,
|
||||
@@ -663,6 +729,9 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
|
||||
const previousLog = logs[i - 1];
|
||||
if (log.previousHash !== previousLog.hash) {
|
||||
chainGaps.push(log.id);
|
||||
if (!gapErklaert(previousLog.id, log.id)) {
|
||||
unexplainedGaps.push(log.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -675,6 +744,7 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
|
||||
invalidEntries,
|
||||
tamperedEntries,
|
||||
chainGaps,
|
||||
unexplainedGaps,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -97,6 +97,39 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
||||
|
||||
## ✅ Erledigt
|
||||
|
||||
- [x] **🔒 hashVersion-Downgrade geschlossen (Pentest R167-01, HIGH)** (2026-08-18)
|
||||
- Der Pentester hat den Angriffsweg, den ich beim Uebergeben von R166-02
|
||||
selbst als naechsten benannt hatte, als **exploitbar bewiesen** (PoC gegen
|
||||
Live-Code + Live-Daten): `verifyIntegrity` waehlte die Pruefstaerke nach der
|
||||
von der Zeile SELBST deklarierten `hashVersion` – und die ist nicht gehasht.
|
||||
Angriff: `hashVersion` 2→1 setzen, die nur von V2 abgedeckten Felder
|
||||
aendern (`success` false→true, `errorMessage` leeren, `resourceLabel`
|
||||
umschreiben) und den schwachen V1-Hash ueber die 7 unveraenderten Felder
|
||||
nachziehen → Fehl-Login als Erfolg getarnt, Pruefung meldet „gueltig“.
|
||||
Besonders kritisch an der **letzten Zeile** der Kette: dort entsteht nicht
|
||||
einmal ein Gap → dauerhaft unsichtbar.
|
||||
- Fix: **Version-Floor.** Die erwartete Pruefstaerke leitet sich aus der Kette
|
||||
ab (`MIN(id) WHERE hashVersion >= 2`), nicht aus der Selbstauskunft. Ab
|
||||
dieser Grenze muss jede Zeile V2 sein; weicht die deklarierte Version von
|
||||
der erwarteten ab, gilt die Zeile selbst als manipuliert. Geprueft wird
|
||||
immer mit dem ERWARTETEN Verfahren. Die Grenze laesst sich durch das
|
||||
Herabstufen einzelner Zeilen nicht verschieben (Minimum bleibt) – ein
|
||||
Angreifer muesste alle V2-Zeilen ab der Grenze herabstufen und die gesamte
|
||||
Kette neu rechnen (= vollstaendiger Rehash, bekannter Grenzfall).
|
||||
- Zusaetzlich: Die Pruefung konsultiert jetzt das **Loeschungs-Manifest**.
|
||||
Neu `unexplainedGaps` – nur Luecken ohne protokollierte Loeschung sind
|
||||
erklaerungsbeduerftig. Vorher war das Manifest rein informativ, wodurch sich
|
||||
eine boeswillige Loeschung als „harmloser Gap“ tarnen konnte.
|
||||
- Verifiziert (PoC nachgebaut): Downgrade-Angriff auf Zeile mit Nachfolger →
|
||||
**erkannt**; auf die Tail-Zeile (erzeugt keinen Gap) → **erkannt**;
|
||||
Gegenrichtung (V1-Altzeile faelschlich als V2 deklariert) → **erkannt**;
|
||||
Ausgangslage und Zustand nach Wiederherstellung jeweils 0 manipuliert
|
||||
(keine Falschmeldungen auf Bestandsdaten). `tsc` + `vite build` gruen.
|
||||
- Betriebshinweis dokumentiert: Bei einem rollierenden Deploy mit kurzzeitig
|
||||
parallel schreibender Alt-Instanz koennen echte V1-Zeilen nach der Grenze
|
||||
entstehen und wuerden angezeigt. Beim hier ueblichen Deploy
|
||||
(pull + rebuild + restart) tritt das nicht auf.
|
||||
|
||||
- [x] **🛡️ Audit-Haerten: Fork, Feldabdeckung, Refresh-Rauschen, Route (Pentest R166)** (2026-08-18)
|
||||
- **R166-01 (HIGH) – Kette forkte weiter.** Mein GET_LOCK-Ansatz gab die
|
||||
Sperre im `finally` INNERHALB des Transaktions-Callbacks frei, also VOR dem
|
||||
|
||||
@@ -1752,7 +1752,7 @@ export const auditLogApi = {
|
||||
return res.data;
|
||||
},
|
||||
verifyIntegrity: async () => {
|
||||
const res = await api.post<ApiResponse<{ valid: boolean; checkedCount: number; invalidEntries: number[]; tamperedEntries: number[]; chainGaps: number[]; tampered: boolean; message: string }>>('/audit-logs/verify');
|
||||
const res = await api.post<ApiResponse<{ valid: boolean; checkedCount: number; invalidEntries: number[]; tamperedEntries: number[]; chainGaps: number[]; unexplainedGaps: number[]; tampered: boolean; message: string }>>('/audit-logs/verify');
|
||||
return res.data;
|
||||
},
|
||||
rehash: async () => {
|
||||
|
||||
Reference in New Issue
Block a user