feat: tools/waechter.ps1 -- zaehlt offene Tueren und rechnet Dateien nach

Die Statusseite war waehrend des Einbruchs die ganze Zeit gruen. Sie waere auch
gruen geblieben, wenn der Miner gelaufen waere. Es fehlte kein Bildschirm, auf
dem man es haette sehen koennen -- es fehlte etwas, das VON SICH AUS Bescheid
sagt. Deshalb kein Frontend, sondern zwei Pruefungen mit Meldeweg.

  1. Ports, die auf allen Adressen lauschen, gegen einen hinterlegten Stand.
     Port 3000 stand elf Tage offen; das haette am ersten Tag gemeldet werden
     koennen. Dazu eine feste Liste heikler Ports, die auch dann warnt, wenn
     der Zustand "schon immer so" war -- 3000 war auch schon immer so.
  2. Pruefsummen ueber sshd_config, administrators_authorized_keys, beide
     Compose-Dateien und Giteas app.ini und .gitconfig. Der Angriff WAR eine
     Datei, die sich um 21:13:57 aenderte.

Gemeldet wird nur bei Aenderung, mit Merker in den Einstellungen. Ein Waechter,
der taeglich meckert, wird stummgeschaltet und ist dann schlechter als keiner.

VOR DEM EINRICHTEN AN BEIDEN FAELLEN BEWIESEN statt behauptet:

    Testport 45999 geoeffnet  ->  "NEU offen: 45999"
    Datei geaendert           ->  Meldung raus, HTTP 204
    zurueckgestellt           ->  "Wieder in Ordnung (Merker geleert)"

Und er prueft zuerst, ob er ueberhaupt etwas sehen kann: findet er keine
Objekte, meldet er "ungueltig" statt "sauber". Das ist die Lehre aus zwei
Suchen in dieser Nacht, die genau das verwechselt haben.

Die Statusseite zeigt den Stand mit; meldet der Waechter sich zwei Stunden
nicht, ist DAS der Befund -- er laeuft alle 15 Minuten.

Im Bericht ausserdem nachgetragen: die Inventur von aussen (NPM-Verwaltung und
MariaDB standen offen, beide jetzt gesperrt), die abgeschalteten Ueberbleibsel
von Caddy und cloudflared, und zwei Irrtuemer von mir -- "Docker geht an der
Firewall vorbei" und "der MariaDB-root hat kein Passwort". Beide waren aus
Messungen geschlossen, die die Frage nicht beantworten konnten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 09:22:44 +02:00
co-authored by Claude Opus 5
parent 70260060c9
commit f138df44a7
4 changed files with 370 additions and 0 deletions
+1
View File
@@ -12,3 +12,4 @@ server/web/
# Die Ablage des Dienstes: Bilder und SQLite. Gehoert ins Volume, nie ins Repo. # Die Ablage des Dienstes: Bilder und SQLite. Gehoert ins Volume, nie ins Repo.
data/ data/
.waechter-basis.json
+91
View File
@@ -168,3 +168,94 @@ Seitdem laufen die Angriffe ins Leere:
00:53:12 Connection closed by invalid user root 31.77.227.120 [preauth] 00:53:12 Connection closed by invalid user root 31.77.227.120 [preauth]
alle 12 erfolgreichen Anmeldungen: eine IP, die des Betreibers 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.
+53
View File
@@ -221,6 +221,59 @@ async function pruefen(): Promise<Antwort> {
} }
} }
// -- 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 ----------------------------------------------------------- // -- Das Urteil -----------------------------------------------------------
// //
// ZWEI STUFEN, und der Unterschied ist der zwischen "tut es nicht" und // ZWEI STUFEN, und der Unterschied ist der zwischen "tut es nicht" und
+225
View File
@@ -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" }