diff --git a/.gitignore b/.gitignore index 2228e0d..484ec27 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ server/web/ # Die Ablage des Dienstes: Bilder und SQLite. Gehoert ins Volume, nie ins Repo. data/ +.waechter-basis.json diff --git a/docs/vorfall-2026-08-12-gitea.md b/docs/vorfall-2026-08-12-gitea.md index 24d82b7..80ab9e0 100644 --- a/docs/vorfall-2026-08-12-gitea.md +++ b/docs/vorfall-2026-08-12-gitea.md @@ -168,3 +168,94 @@ Seitdem laufen die Angriffe ins Leere: 00:53:12 Connection closed by invalid user root 31.77.227.120 [preauth] alle 12 erfolgreichen Anmeldungen: eine IP, die des Betreibers ``` + +--- + +## Nachtrag 13.08.2026, vormittags: die Inventur, und drei Irrtümer von mir + +### Was von außen wirklich offen war + +Erst eine Messung **vom Heimrechner** hat die Lage geklärt — vom Server aus ist +sie nicht zu beantworten: + +``` +vorher nachher + 80 True (gewollt) 80 True + 81 True NPM-Verwaltung 81 False + 135 True Windows-RPC 135 False +3306 True MariaDB 3306 False + 445/3389/5985/9000/9443/11000 durchweg False +``` + +**Die Verwaltungsoberfläche des Reverse-Proxys und eine Datenbank standen offen +im Internet.** Beides ist jetzt per Firewall gesperrt; die NPM-Oberfläche +erreicht man weiter über `https://proxy.d4rkst3r.de`, weil sie containerintern +auf `127.0.0.1:81` läuft. + +### ⚠️ Irrtum 1: „Docker geht an der Windows-Firewall vorbei" + +**Falsch.** Port 81 wird von Docker veröffentlicht, und die Sperrregel greift +dort sehr wohl — nachgemessen von außen. Ich hatte das Gegenteil behauptet, +nachdem ich es *vom Server aus* geprüft hatte, und deswegen sogar eine Regel +wieder gelöscht. Verkehr an die eigene öffentliche Adresse durchläuft die +Eingangsregeln nicht; diese Messung kann die Frage gar nicht beantworten. + +### ⚠️ Irrtum 2: „Der MariaDB-root hat kein Passwort" + +**Falsch.** Er hat eines — das FiveM benutzt. Dass ich ohne hineinkam, liegt an +der Standardeinrichtung von MariaDB unter Windows: + +```json +"plugin": "mysql_native_password", +"auth_or": [ { }, { "plugin": "gssapi", "authentication_string": "SID:BA" } ] +``` + +`auth_or` heißt „oder": Passwort **oder** Windows-Authentifizierung, und +`SID:BA` ist die Gruppe der lokalen Administratoren. Ich war als Administrator +unterwegs. Ein Angreifer aus dem Internet hat keine Windows-Identität — MySQL +fällt als Einbruchsweg aus. + +Meine Prüfung davor war kaputt (`JSON_LENGTH` auf eine Zeichenkette gibt immer +`NULL`), und statt das zu bemerken, habe ich das Ergebnis geglaubt. + +### Vergessene Dienste — der Verdacht des Betreibers war richtig + +``` +Caddy-AutoStart [Ready] beim Systemstart nie gelaufen +CloudflaredTunnel [Ready] beim Systemstart nie gelaufen +C:\Users\Darkster\.cloudflared\ Tunnel-Zugangsdaten vom 23.03.2026 +``` + +Beide waren **aktiv** und wären beim nächsten Neustart angesprungen. Ein Tunnel +ist ein Eingang, der an jeder Firewall vorbeigeht, weil er von innen aufgebaut +wird. Umfang war eng (`fivem-api.d4rkst3r.de` → `localhost:30121`, wo nichts +lauscht), der Caddyfile leer. Beide Aufgaben sind **abgeschaltet, nicht +gelöscht**. + +**Offen für den Betreiber:** den Tunnel `3a4a599b-…` im Cloudflare-Konto +löschen. Solange er dort registriert ist, sind die Zugangsdaten auf der Platte +gültig. + +Außerdem gefunden: **Azure Arc** (vier Dienste, seit 04.08., Status +`Disconnected`, ohne Abonnement) — vermutlich aus dem Anbieter-Abbild. Und +**IPBan** läuft, ein Brute-Force-Blocker. + +### Der Wächter + +`tools/waechter.ps1`, alle 15 Minuten. Zählt die Ports, die auf allen Adressen +lauschen, und rechnet Prüfsummen über `sshd_config`, +`administrators_authorized_keys`, beide Compose-Dateien sowie Giteas `app.ini` +und `.gitconfig`. Gemeldet wird **nur bei Änderung**, und die Statusseite zeigt +den Stand. + +Vor dem Einrichten an beiden Fällen bewiesen, statt es zu behaupten: + +``` +Testport 45999 geoeffnet -> "NEU offen: 45999" +Datei harmlos geaendert -> Meldung raus, HTTP 204 +Datei zurueckgestellt -> "Wieder in Ordnung (Merker geleert)" +``` + +Er prüft zuerst, ob er überhaupt etwas sehen kann, und meldet sonst +„ungültig" statt „sauber" — die Lehre aus zwei Suchen, die genau das +verwechselt haben. diff --git a/server/src/routes/status.ts b/server/src/routes/status.ts index b55fde4..7c32aee 100644 --- a/server/src/routes/status.ts +++ b/server/src/routes/status.ts @@ -221,6 +221,59 @@ async function pruefen(): Promise { } } + // -- Der Waechter --------------------------------------------------------- + // + // Geschrieben von tools/waechter.ps1 auf dem WIRT -- der Dienst im Container + // sieht weder die Ports der Maschine noch ihre Dateien. Hier steht nur, was + // der Waechter zuletzt gefunden hat. + // + // FEHLT DER EINTRAG GANZ, ist der Waechter nicht eingerichtet. Das ist kein + // Fehler des Dienstes, aber es gehoert gesagt: eine Statusseite, die + // schweigt, weil niemand hinsieht, ist genau das Problem, gegen das der + // Waechter gebaut wurde. + const wRoh = getSetting('waechter.letzte') + if (!wRoh) { + pruefungen.push({ was: 'waechter', ok: true, hinweis: 'nicht eingerichtet' }) + } else { + try { + const w = JSON.parse(wRoh) as { + at: number + ok: boolean + ports?: number + heikel?: number[] + befund?: string[] + } + const stunden = (now() - w.at) / 3_600_000 + if (stunden > 2) { + // Er laeuft alle 15 Minuten. Zwei Stunden Schweigen heisst: er + // laeuft nicht mehr -- und ein Waechter, von dem man glaubt, + // dass er wacht, ist schlimmer als keiner. + pruefungen.push({ + was: 'waechter', + ok: false, + hinweis: `meldet sich seit ${Math.round(stunden)} Stunden nicht`, + }) + } else if (!w.ok) { + pruefungen.push({ + was: 'waechter', + ok: false, + hinweis: (w.befund ?? []).join('; ') || 'Befund ohne Text', + }) + } else { + const heikel = w.heikel?.length + pruefungen.push({ + was: 'waechter', + ok: true, + hinweis: heikel + ? `${w.ports} Ports offen, davon ${heikel} heikel` + : `${w.ports} Ports offen`, + }) + } + } catch { + pruefungen.push({ was: 'waechter', ok: false, hinweis: 'Eintrag unlesbar' }) + } + } + // -- Das Urteil ----------------------------------------------------------- // // ZWEI STUFEN, und der Unterschied ist der zwischen "tut es nicht" und diff --git a/tools/waechter.ps1 b/tools/waechter.ps1 new file mode 100644 index 0000000..e6bbc39 --- /dev/null +++ b/tools/waechter.ps1 @@ -0,0 +1,225 @@ +# Wacht über die Sicherheitslage des Wirts: offene Türen und wichtige Dateien. +# +# pwsh -File tools\waechter.ps1 <- prüfen und ggf. melden +# pwsh -File tools\waechter.ps1 -Uebernehmen <- den JETZIGEN Stand als richtig eintragen +# pwsh -File tools\waechter.ps1 -Still <- prüfen, nur anzeigen, nichts melden +# +# WOZU. In der Nacht zum 13.08.2026 hat jemand über eine offene Tür in den +# Gitea geschrieben (docs/vorfall-2026-08-12-gitea.md). Aufgefallen ist es, +# weil danach ein Push nicht mehr ging -- nicht weil etwas es bemerkt hätte. +# +# DIE STATUSSEITE WAR DIE GANZE ZEIT GRÜN. Sie wäre auch grün geblieben, wenn +# der Miner gelaufen wäre. Es fehlte kein Bildschirm, auf dem man es hätte +# sehen können; es fehlte etwas, das VON SICH AUS Bescheid sagt. +# +# Zwei Prüfungen, und beide hätten den Vorfall doppelt erwischt: +# +# 1. OFFENE TÜREN ZÄHLEN. Port 3000 stand eiskalt elf Tage offen. Diese +# Prüfung hätte am ersten Tag gesagt: "da ist ein Port dazugekommen, den +# niemand eingetragen hat" -- und sie sagt es auch beim nächsten, den +# jemand beim Einrichten aus Bequemlichkeit aufmacht. +# +# 2. WICHTIGE DATEIEN NACHRECHNEN. Der Angriff war eine Datei, die sich um +# 21:13:57 änderte. Eine Prüfsumme über .gitconfig, app.ini, sshd_config +# und die Schlüsseldatei hätte das im nächsten Durchlauf gemeldet. +# +# GEMELDET WIRD NUR BEI ÄNDERUNG. Ein Wächter, der täglich meckert, wird +# stummgeschaltet -- und ist dann schlechter als keiner. Dieselbe Lehre wie bei +# den 900 Fahrzeugbildern. + +[CmdletBinding()] +param( + [string]$Container = 'd4rk-media', + [switch]$Uebernehmen, + [switch]$Still +) + +$ErrorActionPreference = 'Stop' +function Schritt($t) { Write-Host "==> $t" -ForegroundColor Cyan } +function Gut($t) { Write-Host " $t" -ForegroundColor Green } +function Warn($t) { Write-Host " $t" -ForegroundColor Yellow } +function Schlecht($t) { Write-Host " $t" -ForegroundColor Red } + +# Ports, die man auf einem Server aus dem Internet NIE erreichbar haben will -- +# unabhängig davon, ob sie beim Übernehmen des Standes schon dastanden. Diese +# Liste warnt also auch dann, wenn der Zustand "schon immer so" war. Genau das +# ist der Punkt: Port 3000 war auch "schon immer so". +$HEIKEL = @{ + 135 = 'RPC' + 139 = 'NetBIOS' + 445 = 'SMB-Dateifreigabe' + 1433 = 'MS SQL' + 3306 = 'MySQL' + 3389 = 'RDP' + 5432 = 'PostgreSQL' + 5985 = 'WinRM (HTTP)' + 5986 = 'WinRM (HTTPS)' + 6379 = 'Redis' + 27017 = 'MongoDB' + 47001 = 'WinRM-Listener' +} + +# Was nachgerechnet wird. Änderungen daran sind entweder deine -- dann +# übernimmst du sie mit -Uebernehmen -- oder sie sind ein Befund. +$DATEIEN = @( + 'C:\ProgramData\ssh\sshd_config' + 'C:\ProgramData\ssh\administrators_authorized_keys' + 'C:\docker\gitea\docker-compose.yml' + "$PSScriptRoot\..\docker-compose.yml" +) +# Dateien IN Containern, als "container:pfad". +$IM_CONTAINER = @( + 'gitea:/data/gitea/conf/app.ini' + 'gitea:/data/gitea/home/.gitconfig' +) + +# -- Stand erheben -------------------------------------------------------------- + +Schritt 'Offene Tueren zaehlen' +$offen = @(Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | + Where-Object { $_.LocalAddress -in '0.0.0.0', '::' } | + Select-Object -ExpandProperty LocalPort -Unique | Sort-Object) +Gut "$($offen.Count) Ports lauschen auf allen Adressen" + +Schritt 'Wichtige Dateien nachrechnen' +$hashes = @{} +foreach ($d in $DATEIEN) { + $p = try { (Resolve-Path $d -ErrorAction Stop).Path } catch { $null } + if ($p) { $hashes[$d] = (Get-FileHash $p -Algorithm SHA256).Hash } + else { $hashes[$d] = 'FEHLT' } +} +foreach ($eintrag in $IM_CONTAINER) { + $c, $pfad = $eintrag -split ':', 2 + # sha256sum IM Container: die Datei liegt in einem Volume, das der Wirt + # nicht ohne Weiteres lesen kann -- und ein Umweg ueber docker cp wuerde + # bei jedem Lauf eine Kopie anlegen. + $h = (docker exec $c sh -c "sha256sum '$pfad' 2>/dev/null | cut -d' ' -f1") -join '' + $hashes[$eintrag] = if ($h.Trim()) { $h.Trim().ToUpper() } else { 'FEHLT' } +} +Gut "$($hashes.Count) Dateien" + +# -- Mit dem hinterlegten Stand vergleichen ------------------------------------- + +$basisDatei = Join-Path $PSScriptRoot '..\.waechter-basis.json' +$basis = if (Test-Path $basisDatei) { Get-Content $basisDatei -Raw | ConvertFrom-Json } else { $null } + +$jetzt = [ordered]@{ + ports = $offen + dateien = $hashes +} + +if ($Uebernehmen -or -not $basis) { + $jetzt | ConvertTo-Json -Depth 5 | Set-Content $basisDatei -Encoding UTF8 + if (-not $basis) { + Schritt 'Kein hinterlegter Stand -- den jetzigen als richtig eingetragen' + Warn 'Beim ERSTEN Lauf gibt es nichts zu vergleichen. Ab jetzt wird gemeldet,' + Warn 'was sich daran aendert. Sieh die Liste unten einmal durch.' + } else { + Gut 'Stand uebernommen.' + } +} + +$basis = Get-Content $basisDatei -Raw | ConvertFrom-Json +$neu = @($offen | Where-Object { $_ -notin $basis.ports }) +$weg = @($basis.ports | Where-Object { $_ -notin $offen }) +$geaendert = @() +foreach ($k in $hashes.Keys) { + $alt = $basis.dateien.$k + if ($alt -and $alt -ne $hashes[$k]) { $geaendert += $k } +} +$heikelOffen = @($offen | Where-Object { $HEIKEL.ContainsKey([int]$_) }) + +# -- Anzeigen ------------------------------------------------------------------- + +Write-Host '' +if ($neu.Count) { Schlecht "NEU offen: $($neu -join ', ')" } +if ($weg.Count) { Gut "nicht mehr offen: $($weg -join ', ')" } +if ($geaendert.Count) { foreach ($g in $geaendert) { Schlecht "GEAENDERT: $g" } } +if (-not $neu.Count -and -not $geaendert.Count) { Gut 'Keine Aenderung gegenueber dem hinterlegten Stand.' } + +if ($heikelOffen.Count) { + Write-Host '' + Warn 'Diese lauschen auf allen Adressen und gehoeren nicht ins Internet:' + foreach ($p in $heikelOffen) { Warn (" {0,6} {1}" -f $p, $HEIKEL[[int]$p]) } + Warn 'OB sie von aussen erreichbar sind, sagt nur eine Messung VON AUSSEN --' + Warn 'vom Wirt aus antwortet die eigene Adresse auch dann, wenn die Firewall sperrt.' +} + +# -- Melden --------------------------------------------------------------------- +# +# Nur bei AENDERUNG, und nur einmal je Zustand. Dafuer wandert eine Kennung des +# gemeldeten Zustands in die Einstellungen: bleibt sie gleich, bleibt es still. + +if ($Still) { Write-Host ''; Gut 'Nur angezeigt (-Still), nichts gemeldet.'; exit 0 } + +$befund = @() +if ($neu.Count) { $befund += "NEU offen: " + ($neu -join ', ') } +if ($geaendert.Count) { $befund += "geaendert: " + (($geaendert | ForEach-Object { Split-Path $_ -Leaf }) -join ', ') } + +$kennung = if ($befund.Count) { ($befund -join ' | ') } else { '' } + +function LiesEinstellung($schluessel) { + $s = @" +const db = require('better-sqlite3')('/data/media.db', { readonly: true }); +const r = db.prepare('SELECT value FROM settings WHERE key = ?').get('$schluessel'); +console.log(r ? r.value : ''); +"@ -replace "`r", '' + (docker exec $Container node -e $s 2>$null) -join '' +} + +function SchreibEinstellung($schluessel, $wert) { + $s = @" +let roh = ''; +process.stdin.on('data', (d) => (roh += d)).on('end', () => { + const db = require('better-sqlite3')('/data/media.db'); + db.prepare('INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?) ' + + 'ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at') + .run('$schluessel', roh.trim(), Date.now()); +}); +"@ -replace "`r", '' + $wert | docker exec -i $Container node -e $s 2>&1 | Out-Null +} + +# Bericht fuer die Statusseite -- immer, auch wenn nichts zu melden ist. +$bericht = [ordered]@{ + at = [long]([DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()) + ok = ($befund.Count -eq 0) + ports = $offen.Count + heikel = @($heikelOffen) + befund = @($befund) +} +SchreibEinstellung 'waechter.letzte' ($bericht | ConvertTo-Json -Compress) + +$zuletzt = LiesEinstellung 'waechter.merker' +if ($kennung -eq $zuletzt) { + Write-Host '' + Gut 'Nichts Neues -- dieser Zustand wurde schon gemeldet.' + exit 0 +} +SchreibEinstellung 'waechter.merker' $kennung +if (-not $kennung) { Write-Host ''; Gut 'Wieder in Ordnung (Merker geleert).'; exit 0 } + +$webhook = LiesEinstellung 'discord.webhook' +if (-not $webhook) { Write-Host ''; Warn 'Kein Discord-Webhook hinterlegt -- nur angezeigt.'; exit 0 } + +$felder = @() +if ($neu.Count) { $felder += @{ name = 'Neu offen'; value = ($neu -join ', '); inline = $true } } +if ($geaendert.Count) { $felder += @{ name = 'Geaenderte Dateien'; value = (($geaendert | ForEach-Object { '`' + (Split-Path $_ -Leaf) + '`' }) -join "`n"); inline = $false } } + +$rumpf = @{ + username = 'd4rk_media' + embeds = @(@{ + title = 'Die Lage am Server hat sich geaendert' + description = "Etwas ist offen oder anders, was vorher nicht so war.`n" + + "Wenn du das selbst warst: ``pwsh -File tools\waechter.ps1 -Uebernehmen``" + color = 15158332 + fields = $felder + footer = @{ text = 'D4RKST3R // WAECHTER' } + timestamp = (Get-Date).ToUniversalTime().ToString('o') + }) +} | ConvertTo-Json -Depth 6 + +$antwort = $rumpf | curl.exe -s -o NUL -w '%{http_code}' -X POST ` + -H 'content-type: application/json' --data-binary '@-' $webhook +Write-Host '' +if ($antwort -in '200', '204') { Gut "Gemeldet (HTTP $antwort)." } else { Schlecht "Melden fehlgeschlagen: HTTP $antwort" }