Files
d4rk_media/docs/vorfall-2026-08-12-gitea.md
D4rkst3randClaude Opus 5 0a7d83aee3 docs: der Proxy konnte seit dem Aufraeumen nicht mehr schreiben
Beim Anlegen eines NPM-Benutzers kam "Internal Error". Im Container-Log stand
"attempt to write a readonly database" -- und der Prozess hielt einen Zeiger
auf /data/database.sqlite (deleted).

DAS WAR DAS AUFRAEUMEN NACH DEM VORFALL SELBST. Die Datenbank wurde am
12.08. um 22:11 per docker cp zurueckgeschrieben, und docker cp ERSETZT die
Datei statt sie zu ueberschreiben. Der seit dem 01.08. laufende Prozess zeigte
danach ins Leere. Rechte, Eigentuemer und Platz waren dabei alle in Ordnung --
die Meldung "readonly" fuehrt in die Irre.

Fuenfzehn Stunden unbemerkt, weil nur Schreibvorgaenge betroffen waren und
niemand welche ausloeste. Gekostet haette es ab dem 03.09. die
Zertifikatsverlaengerungen und ab dem 03.10. gueltige Zertifikate fuer proxy,
portainer und cdn -- ohne Meldung, ohne dass jemand etwas geaendert haette.

Behoben durch Neustart, 4 s Ausfall. Danach zeigt der Zeiger wieder richtig,
alle sechs Seiten antworten, der Benutzer liess sich anlegen.

Und der zweite Teil der Lehre steht auch dort: der Waechter haette das nicht
gefunden. Er zaehlt Ports und rechnet Pruefsummen, er versucht nirgends zu
schreiben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 13:51:17 +02:00

326 lines
13 KiB
Markdown

# Einbruch in den Gitea, 12.08.2026
Kein Ausfall, kein Datenverlust, kein laufender Schadcode — aber ein echter
Schreibzugriff von außen. Das hier ist der Hergang, so wie er **gemessen**
wurde, und was daraus geändert wurde.
## Was passiert ist
```
21:13:56 POST /api/internal/manager/add-logger -> 200 OK
21:13:57 POST /api/internal/manager/remove-logger/... -> 200 OK
21:13:57 /data/gitea/home/.gitconfig neu geschrieben
```
Der Angreifer hat über Giteas **interne Manager-API** einen Datei-Logger auf
die `.gitconfig` gelegt, seine Nutzlast als Logzeile hineinschreiben lassen und
den Logger eine Sekunde später wieder entfernt. Der Vorgang räumt hinter sich
auf; ohne den Nebeneffekt wäre er unbemerkt geblieben.
Eingeschleust wurde ein `packObjectsHook` — ein Kommando, das Git bei jedem
`upload-pack` (also bei jedem Klonen und Ziehen) ausführt. Dekodiert:
- lädt `xmrig` von `111.90.158.58` bzw. `111.90.139.202`
- tarnt ihn als `/var/tmp/.gt_sys_health` mit Konfiguration daneben
- schürft gegen `gulf.moneroocean.stream:443`/`:80`, Worker-ID `gitea`,
Wallet `89wM69z64AMhAKAY…`
- verankert sich per `crontab` (`*/3 * * * *` und `@reboot`) und `/etc/cron.d/gt_sys`
- killt konkurrierende Miner und eigene Altfassungen
## Warum trotzdem nichts lief
**Die Nutzlast hat sich selbst zerschossen.** Die eingeschleuste Zeile beginnt
mit `[uploadpack]\n` — wobei das `\n` als **Text** in der Datei steht und nicht
als Zeilenumbruch. Git kann die Datei damit nicht lesen, bricht mit
`fatal: bad config line 23` ab, und der Hook wurde nie gültig.
Nachgemessen, im Container wie auf dem Wirt:
| | Befund |
|---|---|
| Miner-Prozess | keiner |
| `/var/tmp/.gt_*`, `/tmp/.gt_*` | nichts |
| crontab, `/etc/cron.d` | unverändert |
| Nutzlast in Repos | keine |
| Docker-Socket im Container | **nicht gemountet** — kein Ausbruch auf den Wirt |
| Gitea-Konten | eines, das eigene |
| Fremde Token oder SSH-Schlüssel | keine |
Der einzige Schaden war, dass **alle Git-Operationen ausfielen** — inklusive
der Pushes aus diesem Projekt. Der Angriff ist nur daran aufgefallen.
## Der Weg hinein
Gitea lag mit `0.0.0.0:3000` **direkt im Internet**, am Reverse-Proxy vorbei —
`http://88.218.224.10:3000/` antwortete mit 200. Damit war auch
`/api/internal/*` von außen erreichbar; geschützt nur durch das
`INTERNAL_TOKEN`.
**Woher der Angreifer das Token hatte, ließ sich nicht klären.** Es steht in
keinem Repo, und RDP war es nicht (siehe unten). Bleibt eine Lücke in Gitea
1.26.4 oder ein Leck, das hier nicht sichtbar ist. Genau deshalb ist die
Antwort nicht „Token tauschen", sondern **den Weg zumachen**.
### RDP: beschossen, nicht geknackt
Cloudflare meldete offene RDP-Server. Nachgesehen im Sicherheitsprotokoll,
14 Tage:
```
2756 fehlgeschlagene Anmeldungen, verteilt auf viele IPs
33 erfolgreiche von aussen -- alle von EINER IP, dem Anschluss des Betreibers
```
RDP war also **nicht** der Einbruchsweg. NLA ist aktiv. Trotzdem gehört der
Zugang eingegrenzt — 2756 Versuche sind ein Dauerbeschuss.
## Was geändert wurde
1. **`.gitconfig` gesäubert** — die zwei vergifteten Zeilen raus, 22 Zeilen
übrig. Git funktioniert wieder beim ersten Versuch.
2. **Port 3000 ist weg.** Nicht auf localhost gebunden, sondern **gar nicht
mehr veröffentlicht**. Von außen: keine Antwort.
3. **Der Proxy geht jetzt durchs interne Netz.** NPM hängt zusätzlich in
`gitea_default` und spricht `gitea:3000` an. Dafür musste der nginx-Resolver
auf Dockers eigenen DNS (`127.0.0.11`) umgestellt werden — der alte stammte
noch aus der Zeit, als NPM nur im `bridge`-Netz hing.
4. **`INTERNAL_TOKEN` und `SECRET_KEY` getauscht.** Gefahrlos hier, weil weder
2FA noch externe Anmeldequellen eingerichtet sind (beides nachgezählt: 0).
5. **`/api/internal/` gibt am Proxy jetzt 404.** Dritte Sperre: selbst wenn das
Token erneut leckt, ist der Weg von außen zu. Gitea ruft diese Pfade nur
containerintern über localhost.
6. **Frisches Abbild.** Der Container lief elf Tage mit dem Stand von damals —
`gitea/gitea:latest` heißt nicht, dass das Laufende aktuell ist.
Gegengeprüft, beides:
```
Port 3000 von aussen -> 000 (zu)
/api/internal/manager/add-logger -> 404 (gesperrt)
https://git.d4rkst3r.de/ -> 200
https://git.d4rkst3r.de/api/v1/version -> 200
grosse Uploads (LFS): 3 von 3 Direktiven noch da
Push aus diesem Projekt: geht, beim ersten Versuch
Repos gegen die lokalen Klone: identisch
```
## Was noch offen ist
- **Alle API-Token und Webhook-Geheimnisse als geleakt behandeln** und
tauschen: `GITEA_TOKEN`, das Token `Claude`, Devlog- und Gitea-Webhooks.
Nicht weil ein Diebstahl belegt ist, sondern weil er nicht ausgeschlossen
werden kann.
- **Admin-Passwort ändern.**
- ~~RDP eingrenzen~~ — erledigt, siehe unten.
- **Beweise** liegen unter `C:\backup\vorfall-2026-08-12\`: die vergiftete
`.gitconfig`, die ursprüngliche `app.ini`, das vollständige Gitea-Log
(24 MB) und die alte Compose-Datei.
## Die Lehre, die über diesen Fall hinausgeht
Der offene Port war **nie nötig**. Der Proxy hätte von Anfang an über das
interne Netz gehen können; die Veröffentlichung war Bequemlichkeit beim
Einrichten. Sie stand elf Tage, und in dieser Zeit hat jemand sie gefunden.
Und: aufgefallen ist es nicht, weil etwas überwacht wurde, sondern weil der
Angreifer geschludert hat. **Hätte er sein `\n` richtig gesetzt, liefe der
Miner noch** — und die einzige Spur wäre eine erhöhte CPU-Last gewesen, die
niemandem auffällt.
---
## Nachtrag 13.08.2026: RDP ist zu, der Zugang laeuft ueber einen Tunnel
`sshd` nimmt seit 00:2x **nur noch Schluessel** an — `PasswordAuthentication no`
*und* `KbdInteractiveAuthentication no`, denn unter Windows laeuft die
Passwortabfrage sonst ueber den zweiten Weg weiter. Die Zeilen stehen **vor**
dem ersten `Match`-Block; weiter unten waeren sie stillschweigend wirkungslos
gewesen.
```
Darkster@127.0.0.1: Permission denied (publickey).
```
RDP ist per Firewall gesperrt (TCP und UDP, alle Profile). Der Zugang geht
durch einen SSH-Tunnel; `tools/tunnel-einrichten.ps1` richtet ihn auf dem
Heimrechner als Hintergrundaufgabe ein — ohne Fenster, mit Neuaufbau bei
Abbruch. **Was niemand durchhaelt, wird umgangen, und am Ende steht wieder ein
offener Port.**
Beides gemessen, und zwar von der richtigen Seite:
```
auf dem Server 127.0.0.1:3389 erreichbar (der Tunnel traegt)
::1 Typ 7, Darkster (RDP kam durch)
vom Heimrechner Test-NetConnection 88.218.224.10 -Port 3389
TcpTestSucceeded : False (von aussen zu)
```
Die Messung vom Server aus taugte dafuer **nicht** — Verkehr an die eigene
oeffentliche Adresse durchlaeuft die Eingangsregeln nicht und meldete
faelschlich "erreichbar". Zweimal in dieser Nacht in dieselbe Falle getappt,
einmal bei Port 3000 und einmal bei 3389.
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.
---
## Nachtrag 13.08.2026 — ein Schaden aus dem Aufräumen selbst
Beim Einrichten des Panels in `d4rk_gameserver` sollte ein Benutzer im Nginx
Proxy Manager angelegt werden. Die Oberfläche antwortete mit **„Internal
Error"**. Im Container-Log stand, was sie verschwieg:
```
insert into `user` ... - attempt to write a readonly database
```
**Der Nginx Proxy Manager konnte seit dem 12.08.2026, 22:11 Uhr, nichts mehr
speichern.** Der letzte erfolgreiche Schreibvorgang war um 22:06 Uhr.
### Die Ursache war das Aufräumen
```
lrwx------ 1 root root 64 Aug 11 16:04 18 -> /data/database.sqlite (deleted)
```
Der Prozess hielt einen Zeiger auf eine **gelöschte** Datei. Beim Aufräumen
nach dem Vorfall wurde `/data/database.sqlite` per `docker cp` zurückgeschrieben
— und `docker cp` **ersetzt** die Datei, statt sie zu überschreiben. Der seit
dem 01.08. laufende Prozess zeigte danach ins Leere. SQLite meldet das als
„readonly database", was in die Irre führt: Rechte, Eigentümer und Platz waren
alle in Ordnung (root, 905 GB frei, Verzeichnis beschreibbar).
### Was es fast gekostet hätte
Nicht den Betrieb — Lesen ging weiter, alle Seiten liefen, fünfzehn Stunden
lang fiel nichts auf. Aber:
| Wann | Was |
|---|---|
| ab 03.09.2026 | Erste Zertifikatsverlängerung, wäre still gescheitert |
| ab 03.10.2026 | `proxy`, `portainer` und `cdn` laufen in TLS-Warnungen |
Ohne Meldung, ohne Eintrag, ohne dass jemand etwas geändert hätte.
### Behoben
Container neu gestartet, **4 Sekunden** Ausfall. Danach zeigt der Dateizeiger
wieder auf die echte Datei, alle sechs Seiten antworten, und der Benutzer ließ
sich anlegen. Die Datenbank liegt vorher als
`database.sqlite.vor-neustart-2026-08-13` daneben.
### Die Lehre
**Eine Datei unter einem laufenden Dienst zu ersetzen ist etwas anderes, als
sie zu überschreiben.** Richtig wäre gewesen:
```sh
docker exec <container> sh -c 'cat /pfad/neu > /data/database.sqlite' # behält die Inode
# oder
docker cp neu <container>:/data/database.sqlite && docker restart <container>
```
Und der zweite Teil derselben Lehre: der Fehler war **fünfzehn Stunden lang
unsichtbar**, weil nur Schreibvorgänge betroffen waren und niemand welche
auslöste. Der Wächter in `tools/waechter.ps1` hätte ihn nicht gefunden — er
zählt Ports und rechnet Prüfsummen, er versucht nirgends zu schreiben. Ob eine
Schreibprobe je Dienst dazugehört, steht in `ROADMAP.md` als offene Frage.