Spring til indhold
DAfield-notesincident-response

Jeg troede jeg var blevet hacket

Maskinen åd sig selv: SSH sløv, load i tocifret, containere der genstartede. Det lignede en kryptominer. Det var bare min egen ophobede tilstand — og under oprydningen plantede jeg en tidsindstillet bombe i boot-konfigurationen.

En tilståelse: jeg kører mere infrastruktur, end jeg har nogen ret til. Én Hetzner-dedikeret kasse med omkring 30 services, et halvt dusin devcontainere, et privat Docker-registry, en FreeSWITCH-stack, agent-FTP-ingest fra mit Sony-kamera, plus hvad jeg nu hacker på den uge. Det meste virker det meste af tiden. Når det ikke gør, er fejlmodusene som regel eksotiske.

Det her er historien om hændelsen, der lignede noget eksotisk — »jeg er blevet pwnet«-eksotisk — og viste sig at være den mest kedelige diagnose, der findes. Det er også historien om, hvordan jeg under oprydningen opdagede noget helt urelateret, der spildte 13 TB af min lagerplads, satte mig for at fikse det, gjorde det omhyggeligt — og stille plantede en tidsindstillet bombe i min boot-konfiguration, som ikke gik af før fem dage senere. Den anden del er næste indlæg. Det her er optakten.

Symptomet: kassen åd sig selv

Jeg kom tilbage til serveren efter et par dage væk. SSH var evigheder om at give mig en prompt. Vel inde viste htop en mur af memory pressure, load average på vej op i det tocifrede, services der flappede. Containere genstartede tilfældigt. Kassen var ikke helt nede, men den var den slags utilpas, der får et paranoidt sind til at gå direkte til: der er nogen, der miner krypto på min server.

Jeg var blevet bidt af præcis det tidligere på året — ægte kryptominere, processer ved navn /tmp/runnv/runnv, der tyggede 14 GB RAM hver. Mønstret var velkendt nok til, at jeg antog samme diagnose her.

Det var det ikke. Men før jeg kommer til, hvad det faktisk var: metoden betyder noget.

5-lags-auditten

Når du mistænker kompromittering, gætter du ikke. Du auditerer systematisk. Fem lag:

  • Proceslag. Noget kørende, du ikke kan identificere? Mistænkelige stier (/tmp/*, /dev/shm/*, mærkeligt navngivne binaries)? ps auxf, og kig efter anomalier.
  • Netværkslag. Lyttende sockets, du ikke har sat der? Udgående forbindelser til mærkelige IP'er eller pools? ss -tlnp og ss -tnp | grep ESTABLISHED.
  • Persistenslag. Cron-jobs, systemd-timere, services, ~/.ssh/authorized_keys, /etc/sudoers.d/*. Alt, der lader en angriber overleve en reboot.
  • Filsystemlag. Nyligt ændrede filer i systemmapper: find /etc /usr/local /opt -mtime -7 -ls — snæver tidsvinduet aggressivt ind til, da tingene sidst var fine.
  • Containerlag. Containere, du ikke startede? Image-pulls, du ikke initierede? docker ps -a og docker image ls --digests.

Alle fem kom tilbage rene. Ingen rogue-processer. Ingen uventede lyttere. Ingen nye SSH-nøgler. Ingen modificerede system-binaries. Ingen mystiske containere.

Hvilket efterlod mig med: kassen er syg, men den er syg af legitim workload, ikke ondskab.

Den faktiske synder, i tre dele

Én: en service i crash-loop i tre måneder. systemctl --failed og et blik på journalctl -u freeswitch-logger.service afslørede noget pinligt: en service, jeg havde skrevet måneder forinden og glemt, var genstartet over 655.000 gange. Unit-filen havde en shell-escaping-bug, så kommandoen aldrig kørte. systemd genstartede den trofast. Hver genstart spawnede en proces, allokerede lidt hukommelse, fejlede, exitede og trigger en ny genstart. Gang det med måneder. systemctl mask freeswitch-logger.service — blødningen stoppede.

To: fail2ban i loop på sin egen config-bug. fail2ban skal forsvare mod SSH-brute-forcing. Min havde fejlet at starte rent i — igen — tre måneder, og crash-loopede. Årsag: en dubleret ignoreip-linje i /etc/fail2ban/jail.local. Fix: sed dubletten ud, genstart, se den komme rent op for første gang i en sæson.

Tre: en Docker-losseplads. Den store. docker ps -a | wc -l returnerede 256. Af dem var 230 stoppede. docker volume ls | wc -l returnerede 2.173 — cirka halvdelen dinglende fra containere, der var fjernet uden -v.

Hvordan ophober en kasse 256 containere? Nemt: jeg kører aspire run for at starte mit udviklingsmiljø, og den orkestrator spawner en håndfuld containere hver gang (Next.js dev-servere, .NET Aspire AppHost, lidt Postgres, lidt Redis). Ved Ctrl-C stopper Aspire containerne, men fjerner dem ikke altid. Gang med hver gang jeg startede Aspire over seks måneder — og kassen ender med at holde hundredevis af døde containere, hver med sit eget skrivbare lag og en klynge tilknyttede volumes.

Oprydningen reclaimede cirka 52 GB inde i min hoved-devcontainer alene, og i størrelsesordenen 90 GB på host'en samlet. Load average faldt fra »alarmerende« til »kedeligt« inden for minutter.

Bonus-opdagelsen: 13 TB væk

Mens jeg alligevel var på kassen, kørte jeg lsblk og df -h. Det jeg så, stoppede mig: tre diske — én ~954 GB NVMe og to 7,3 TB HDD'er — alle samlet i ét RAID5-array, der gav 1,8 TB brugbart. Med RAID5 af tre diske størrelses-bestemmes arrayet af den mindste disk. NVMe'en, der lå der, cappede hele arrayets brugbare kapacitet. De øvrige 6,3 TB på hver HDD — cirka 13 TB i alt — var fuldstændig uadresseret.

Det var Hetzners default installimage-layout fra da jeg bestilte kassen ti måneder tidligere. Jeg havde accepteret »automatisk RAID-opsætning« uden at tænke over, hvad de faktisk gjorde — og de gjorde noget, der »virker« i den mest pedantiske forstand (det er redundant! det er et RAID!), mens det spildte det meste af min faktiske kapacitet. Jeg havde været på kassen i ti måneder uden at bemærke det.

At reclaime de 13 TB — og bomben jeg plantede

Planen var enkel i form: konvertér sda og sdb fra MBR til GPT in-place med sgdisk -g, lav en ny partition i den tidligere uadresserede plads, byg et nyt RAID1-array md3, montér som /mnt/bulk, migrér FTP-foto-ingesten derover.

Enkel i form, omhyggelig i udførelse. Jeg tog en fuld block-level-backup til en Synology-NAS over Tailscale først (~415 GB), verificerede den var restorbar, og lavede så disk-arbejdet trin for trin med eksplicit bekræftelse mellem hver destruktiv operation. RAID forblev sundt [UUU] hele vejen. Det nye /mnt/bulk kom rent online. Migreringen gik glat. Jeg rebootede intet under arbejdet — RAID'en blev ændret live; userspace mærkede aldrig noget.

Og det var dér, jeg — uden at vide det — plantede bomben. MBR→GPT-konverteringen ville ødelægge GRUB på sda og sdb. Jeg ræsonnerede, at NVMe'en (som jeg ikke rørte) stadig ville boote, og at BIOS ville falde igennem til den. Det ræsonnement var næsten rigtigt. Det »næsten« kostede mig en hel nat fem dage senere — men det er det næste indlæg.

Læringen

Mistanken om »jeg er blevet hacket« viste sig at være den mest mundane diagnose: en server, der stille kvæltes i sin egen ophobede tilstand, med et par fejlkonfigurerede services, der hældte benzin på. Den faktiske fiks var bare husholdning, gjort grundigt. Jeg gik i seng med god samvittighed. Fem dage senere døde kassen igen — og post-mortem'en sporede årsagen tilbage til en beslutning, jeg traf under præcis denne oprydning.

Vis kommandoerne + det fulde diskbillede

Docker-lossepladsen i tal

$ docker system df
TYPE         TOTAL    ACTIVE   SIZE      RECLAIMABLE
Images       57       12       42.5GB    28.1GB (66%)
Containers   256      26       8.3GB     7.1GB (85%)
Volumes      2173     78       145.2GB   89.4GB (61%)
Build Cache  —        —        12.4GB    12.4GB (100%)

Oprydning, metodisk:

docker container prune -f                       # fjern stoppede containere
docker volume prune --filter "label!=keep" -f   # fjern dinglende volumes
docker image prune -a -f                        # fjern ubrugte images
docker builder prune -a -f                       # nuke build-cachen

Disk-layoutet

NAME        SIZE  MOUNTPOINT
nvme0n1     954G
├─md0         4G  [SWAP]
├─md1         1G  /boot
└─md2       1.8T  /
sda         7.3T
├─md0         …
├─md1         …
└─md2         …
sdb         7.3T
├─md0         …
├─md1         …
└─md2         …

Tre diske, ét RAID5 sized til 2× den mindste disk — NVMe'en cappede arrayet til ~1,9 TB minus paritet. Hver 7,3 TB HDD bidrog med ~950 GB. Resten lå brak.

Hvad jeg tog med

  1. Auditér før du antager. »Jeg er blevet hacket« er næsten aldrig den rigtige diagnose på en personlig server, men den fortjener at blive udelukket metodisk. 5-lags-auditten (proces, netværk, persistens, filsystem, container) tager måske 20 minutter og giver dig enten en ren samvittighed eller et rigtigt spor.
  2. systemctl --failed er det billigste health-check, der findes. En service i crash-loop i tre måneder er usynlig for uptime-monitorering. Men den står lige der. Gør det til en vane — især efter enhver deploy eller config-ændring.
  3. Container-orkestrering uden lifecycle-hygiejne er en disk-brand i slowmotion. Hvert dev-loop-værktøj, der »starter nogle containere for dig«, kræver en matchende vane med også at rydde op. docker system prune ugentligt. Eller byg det ind i din shell. Eller accepter, at dine devcontainer-hosts bliver til lossepladser.
  4. Verificér at dit lager-layout matcher dine forventninger. Hetzners default installimage traf et valg, der var teknisk korrekt og operationelt absurd for min workload. Jeg betalte for lager, jeg ikke brugte, i ti måneder, fordi jeg aldrig kørte lsblk og spurgte »vent, er det her det, jeg ville have?«
  5. Oprydninger, der rører infrastruktur, fortjener en bevidst reboot bagefter. Det er den ene, jeg ikke fulgte. Hvorfor det bed mig, er det næste indlæg.