HU

Egy webshell-betörés anatómiája: feltöltéstől a rootig

Egy megosztott VPS-en négy nap telt el a webshell feltöltése és a támadás között. A belépési pont egy fájlfeltöltő volt, ami PHP-t is kiszolgált.

Közeli fénykép egy fém lakatról egy ajtón, hideg, kékes tónusban.
Fotó: Kaffeebart (Unsplash)

Egy megosztott VPS-t kaptunk kézbe, amelyen több különálló Laravel-alkalmazás futott egymás mellett, egy publikus marketingoldal, néhány API és egy admin-felület fájlkezelővel. A gépről az üzemeltető büszkén mondta, hogy SSH-zni „csak VPN-en át lehet”. Ez igaz is volt, és pontosan ezért volt megtévesztő. A támadó ugyanis egyetlen SSH-kapcsolatot sem használt, mert végig a nyilvános HTTPS-en jött be, a webalkalmazáson keresztül.

Ez az eset azért tanulságos, mert semmi egzotikus nincs benne. Minden lépése egy ismert, régóta dokumentált hiba, és minden lépése megelőzhető lett volna egy-egy sornyi konfigurációval.

A belépési pont: egy fájlfeltöltő, ami PHP-t is kiszolgált

Az admin-alkalmazásban volt egy fájlfeltöltő. A feltöltött fájlok a Laravel szokásos helyére kerültek, a storage/app/public/files/… alá, amit a public/storage symlink tesz kívülről elérhetővé. A validáció a kiterjesztést és a kép fejlécét nézte, a tartalmat nem.

A támadó egy polyglot fájlt töltött fel, aminek az elején egy érvényes JPEG-fejléc állt, utána pedig PHP-kód. A böngészőnek kép, a PHP-nek program. Ez önmagában még nem lett volna baj, mert egy feltöltött .php fájl csak akkor veszélyes, ha a szerver le is futtatja. Itt pedig lefuttatta, mert az nginx vhost minden .php-t a PHP-FPM-nek adott, függetlenül attól, hol van a fájlrendszerben:

# A sebezhető minta: BÁRMELY .php lefut, a feltöltési mappában is
location ~ \.php$ {
    fastcgi_pass unix:/var/run/php/php8.2-fpm.sock;
    include fastcgi_params;
}

Egy Laravel-alkalmazásban egyetlen PHP-fájlnak van dolga lefutni, a public/index.php front controllernek. Minden más .php, pláne egy feltöltési mappában, vagy hiba, vagy támadás. A helyes minta ezért nem azt mondja meg, mit tiltunk, hanem azt, mi az egyetlen, amit engedünk:

# A javítás: csak a front controller futhat, minden más .php tilos
location = /index.php {
    fastcgi_pass unix:/var/run/php/php8.2-fpm.sock;
    include fastcgi_params;
}
location ~ \.php$ {
    deny all;
    return 403;
}

Ez az egy blokk az egész támadási osztályt kizárja, mert hiába kerül fel egy webshell bárhová, nem indul el.

A négy nap, amíg látszólag semmi sem történt

A logokban a feltöltés és a tényleges támadás között négy nap telt el. A nulladik napon került fel a webshell, és aznap egy Telegram-bot lekérte egyszer. Ez a tipikus „megjelöltem, később visszajövök” minta. A negyedik napon hajnalban jött vissza a támadó, és ekkor kezdte el POST-kérésekkel vezérelni a shellt.

Ez a csúszás fontos tanulság. A betörés nem egy pillanat, hanem egy folyamat, és a legnagyobb esély az észlelésre pont ebben a csendes ablakban van. Egy egyszerű riasztás arra, hogy „új .php fájl jelent meg egy feltöltési vagy asset-mappában”, négy napig ott állt volna kihasználatlanul.

deployerből root

A webshell a PHP-FPM felhasználójaként futott; nevezzük deployer-nek. Ez a fiók nem volt sudoer, tehát a jelszó nem segített volna. A támadó ehelyett egy néhány megabájtos eszközkészletet töltött le a shellen át, és egy helyi kernel-exploittal lett rootból.

Rootként az első dolga egy rejtett kapu volt, vagyis egy új sor a /etc/passwd-ben, 0 felhasználó- és csoportazonosítóval, a legitim rendszerfiókokra hajazó névvel. Egy ilyen sor a felületes szemlének normálisnak tűnik, közben teljes jogú root-hozzáférés.

A tanulság itt kettős. Egyrészt a PHP-FPM felhasználója fusson a lehető legkevesebb joggal, elkülönítve. Másrészt, és ez a kényelmetlen rész: root-szintű kompromittálás után egyetlen takarítás sem ad teljes bizonyosságot. Egy root-jogú támadó módosíthat bináris fájlt, hamisíthat időbélyeget, törölhet naplót. A takarítás csökkenti a kárt; a biztos megoldás az újratelepítés.

A backdoor, ami kifelé tárcsázott

A perzisztencia legérdekesebb darabja egy gsocket (reverse-relay) backdoor volt, két lábon állva. Egy óránként futó felhasználói cron és egy root systemd-szolgáltatás, mindkettő ártalmatlan kernelfolyamatnak álcázott néven futtatta a binárist.

Itt kapcsolódik vissza a VPN-es kényelemérzet. A gsocket nem nyit bejövő portot, amit egy tűzfal blokkolhatna. Ehelyett kifelé épít kapcsolatot egy köztes relay-hálózaton át. Ehhez semmilyen bejövő szabály nem kell, sem SSH, sem nyitott port. A „csak VPN-en át lehet SSH-zni” szabály teljesült végig, és teljesen érdektelen volt, mert a támadónak nem kellett bejönnie, hiszen a szerver ment ki hozzá.

A gyakorlati következmény pedig az, hogy a bejövő szűrés fontos, de a kimenő forgalom szűrése legalább annyira az. Egy production szervernek ritkán van dolga tetszőleges kimenő kapcsolatot építeni.

Amit a támadó akart: SEO-spam

Nem adatlopás, nem zsarolóvírus. A publikus oldal index.php-jába egy néhány soros betoldás került, amely a keresőrobotoknak (Googlebot, Bingbot és társai a User-Agent alapján) egy másik, spam-tartalmat szolgált ki egy külső CDN-ről, a valódi látogatóknak viszont a rendes oldalt. Ez a „cloaking”, aminek a célja a fertőzött, jó reputációjú domain kihasználása idegen tartalom kereső-rangsorolására.

Ennek van egy alattomos utóhatása is. A támadó a feltöltött igazolófájllal be tudja jelölni magát a Google Search Console-ban a domain tulajdonosaként, és a helyi fájl törlése ezt nem szünteti meg, mert a Search Console-ban külön kell eltávolítani.

Amit tanultunk

Egyetlen soros hibák láncolata volt, tehát a védekezés is soronként áll össze:

  • Csak a front controller futhat. Az nginx index.php-only mintája az egész webshell-osztályt kizárja, függetlenül attól, hová kerül a fájl.
  • A feltöltés ne a webroot alá menjen, vagy ha muszáj, ott a PHP-futtatás legyen explicit tiltva. A feltöltést tartalom szerint validáld, ne csak kiterjesztés és fejléc alapján.
  • A PHP-FPM felhasználója fusson minimális joggal, hogy egy webshell ne legyen egyenlő a teljes gép elvesztésével.
  • Szűrd a kimenő forgalmat. A modern backdoorok kifelé tárcsáznak; a bejövő tűzfal önmagában hamis biztonságérzet.
  • Figyeld a változást. Új .php egy feltöltési mappában, új sor a /etc/passwd-ben, új systemd-egység, mind olcsó és magas jelértékű riasztás.
  • A naplómegőrzés legyen elég hosszú. Nálunk a rendszernapló három napra, a webkiszolgáló-log két hétre nyúlt vissza. Egy több napig lappangó behatolásnál ez dönti el, hogy rekonstruálható-e a belépési pont, vagy sem.
  • Root-kompromittálás után újratelepítés. Ez a kényelmetlen, de egyetlen igazán megbízható válasz.

Egyik pont sem új, és éppen ez a lényeg. A betöréshez nem kellett nulladik napi sérülékenység, csak néhány kihagyott alapbeállítás. A védelemhez sem kell több annál, mint hogy azt a néhány sort tényleg megírja valaki, mielőtt szükség lenne rá.