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>
262 lines
10 KiB
Markdown
262 lines
10 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.
|