VPS Hosting

Kako smo ažurirali 24 WordPress instalacije kroz QEMU Guest Agent, a jednu namerno preskočili

10 min čitanja

Povod je bio javno objavljen opseg WordPress verzija povezan sa wp2shell ranjivošću. Imali smo više QEMU virtuelnih mašina, različite distribucije, nekoliko kontrolnih panela i popriličan broj domena. Ručno prijavljivanje na svaki server, traženje instalacija i pokretanje update-a nije dolazilo u obzir.

Ne zato što je ručni rad ispod časti, nego zato što je u ovoj situaciji lako nešto prevideti. Jedan server koristi Plesk, drugi cPanel, treći myVesta. Negde je WordPress u public_html, negde u Plesk subscription direktorijumu, a negde postoji još jedna instalacija unutar staging ili cgi-bin direktorijuma. Spisak domena iz DNS-a takođe nije dovoljan. Domen može postojati, a da više nije hostovan. Može biti aktivan u web serveru, ali izostavljen iz kontrolnog panela. Može postojati WordPress koji nije direktno vezan za javni domen.

Zato smo krenuli od hypervisora, preko QEMU Guest Agenta.

Prvo smo morali da utvrdimo šta uopšte postoji

Inventar je prošao kroz sve aktivne libvirt goste sa dostupnim QEMU Guest Agentom. Unutar svake virtuelne mašine provereni su izvori koji već znaju nešto o hostovanim domenima:

  • cPanel/WHM API
  • Plesk CLI
  • Vesta i myVesta CLI
  • aktivna nginx konfiguracija
  • Apache virtual host konfiguracija

Nije bilo mrežnog skeniranja, pokušaja prijave na sajtove ili pogađanja domena preko sertifikata. Čitali smo stanje koje već postoji na serveru.

To je odmah pokazalo koliko inventar domena ume da bude prljav. Pored stvarnih sajtova pojavili su se autodiscoverwebmailcpanel, privremeni cPanel hostovi, lokalni Plesk nazivi, localhost.localdomainexample.com i slične tehničke vrednosti.

Prva verzija filtera imala je i malu, ali poučnu grešku. Regex za hostname prihvatao je IPv4 adresu kao domen, jer su numeričke labele sasvim lepo prolazile kroz [a-z0-9]. Nije bezbednosni problem, ali jeste dobar način da dobiješ izveštaj kojem posle ne veruješ. Dodata je posebna provera koja odbacuje IPv4 oblik pre validacije hostname-a.

Još jedna greška bila je banalnija. Regex je slučajno prelomljen u dva reda i Bash skripta nije mogla ni da se pokrene. bash -n je to odmah otkrio. Takve stvari su dobar podsetnik da bezbednosni dizajn ne znači mnogo ako skripta nije prošla ni sintaksnu proveru.

Konačni inventar je sačuvan u dva oblika: detaljan TSV sa VM-om, izvorom i domenom, kao i očišćena lista javnih domena.

WordPress smo tražili po fajlovima, ne po naslovnoj strani sajta

Sledeći korak nije bio HTTP zahtev ka svakom domenu. To bi otvorilo dodatna pitanja: redirekcije, CDN, zaštitni servisi, staging sajtovi bez DNS zapisa, ugašeni virtual hostovi i instalacije koje postoje na disku, ali nisu javno dostupne.

Umesto toga, kroz Guest Agent su pregledana uobičajena web stabla:

/var/www
/home
/srv/www
/usr/local/www

Tražen je wp-includes/version.php, uz ograničenje na isti filesystem i preskakanje poznatih problematičnih stabala poput cPanel virtfs direktorijuma i korisničkog .trash.

Verzija je pročitana direktno iz fajla. PHP u toj fazi nije izvršavan. To smanjuje mogućnost da kompromitovan wp-config.php, plugin ili tema utiču na sam audit.

Pronađene instalacije zatim su mapirane na domene. Za cPanel smo koristili document root podatke iz WHM API-ja. Kod Pleska i myVeste pomogli su standardni rasporedi direktorijuma i poznati domeni iz inventara. Ispostavilo se da je mapiranje po najbližoj komponenti putanje bitno. Jedan Plesk subscription može sadržati više dodatnih domena, pa naziv subscriptiona nije nužno domen konkretne WordPress instalacije.

Audit je pronašao 25 WordPress instalacija u verzijama koje su odgovarale pogođenom opsegu koji smo proveravali.

Tu je već bilo nekoliko zanimljivih slučajeva:

  • glavni sajt i zaseban WordPress u poddirektorijumu
  • staging instalacije
  • addon domeni unutar Plesk subscriptiona
  • dve instalacije povezane sa istim javnim domenom
  • WordPress fajlovi čija baza nije odgovarala konfigurisanom prefiksu tabela

Poslednji slučaj se kasnije pokazao kao najvažniji.

Dry-run je korisniji kada ume da kaže ne

Ažuriranje je planirano preko WP-CLI-ja, uz zahtev da se ostane na postojećoj release grani. Za to postoji zvanična komanda:

wp core update --minor

Pre bilo kakve izmene skripta proverava:

  • da li QEMU Guest Agent odgovara
  • da li postoji podržan PHP CLI sa mysqli ekstenzijom
  • da li je WP-CLI upotrebljiv
  • ko je Unix vlasnik instalacije
  • da vlasnik nije root
  • koji je home direktorijum vlasnika
  • da li vlasnik može da piše u WordPress root, wp-admin i wp-includes
  • trenutnu WordPress verziju
  • rezultat wp core is-installed
  • da li WP-CLI vidi minor update

Svaka WordPress komanda izvršava se kao vlasnik sajta preko runuser. Nismo popravljali neuspešnu proveru sa chmod -Rchown -R ili sličnim čekićem. Ako instalacija nije upisiva pod svojim stvarnim vlasnikom, ona se preskače.

Dry-run je otkrio nekoliko problema koje jednostavna petlja sa wp core update ne bi rešila lepo.

Na nekim VM-ovima WP-CLI nije bio instaliran. Na jednom Plesk serveru WP-CLI je postojao, ali njegov shebang koristi /usr/bin/env php, dok generička komanda php nije postojala. Plesk PHP je bio dostupan na putanji poput:

/opt/plesk/php/8.4/bin/php

Rešenje nije bilo pravljenje novog globalnog symlinka. Skripta pronalazi odgovarajući PHP CLI i pokreće WP-CLI eksplicitno:

/opt/plesk/php/8.4/bin/php /usr/local/bin/wp

Tako ne menjamo sistemski PATH i ne nagađamo koju PHP verziju administrator želi kao podrazumevanu.

Tamo gde WP-CLI stvarno nedostaje, uveden je poseban prepare režim. On preuzima zvanični PHAR, potpis i javni ključ, proverava objavljeni fingerprint i GPG potpis, testira PHAR iz privremenog direktorijuma, pa ga instalira kao:

root:root 0755 /usr/local/bin/wp

Nepoznat ili pogrešan potpis prekida instalaciju. Nema tihog nastavka.

Jedna baza je bila zdrava, ali WordPress ipak nije bio instaliran

Najzanimljiviji rezultat došao je sa jedne staging instalacije.

wp db check je uspešno proverio veliki broj tabela. Na prvi pogled delovalo je da je baza sasvim upotrebljiva. Međutim:

wp core is-installed

vratio je grešku.

WP-CLI je prijavio da wp-config.php očekuje prefiks wpstg0_, dok su u bazi pronađene WordPress tabele sa drugim prefiksom.

Prva verzija logike prihvatala je uspešan wp db check kao rezervnu potvrdu i nastavila preflight. To je bilo previše optimistično. Provera integriteta MariaDB tabela ne dokazuje da trenutni WordPress root i njegov wp-config.php predstavljaju instaliranu aplikaciju nad tim tabelama.

Fail-closed ponašanje je zato pooštreno. Neuspešan wp core is-installed sada uvek preskače instalaciju. wp db check se i dalje izvršava kao dijagnostika, ali više ne daje dozvolu za update.

Ta odluka je ostavila jednu instalaciju neažuriranu. I to je bio ispravan rezultat. Bolje je dobiti exit code 1 i jasan SKIPPED_NOT_INSTALLED nego uspešan izveštaj posle izmene pogrešnih fajlova ili baze.

Šta se desilo tokom primene

Za svaku instalaciju koja je prošla proveru napravljen je zaseban SQL backup u home direktorijumu vlasnika:

~/.wp-core-update-backups/TIMESTAMP/

Tek nakon uspešnog exporta pokrenut je core update. Posle update-a provereni su:

  • nova verzija
  • ista major.minor grana
  • da verzija više nije u pogođenom opsegu
  • WordPress DB verzija
  • core checksums
  • ownership i permission vrednosti važnih direktorijuma

Rezultat je bio:

  • 24 uspešno ažurirane WordPress instalacije
  • jedna instalacija bezbedno preskočena
  • nijedan prelazak na novu major ili minor granu
  • nijedan neuspešan DB backup među ažuriranim instalacijama
  • nijedna uočena promena vlasništva i osnovnih permission vrednosti

Verzije su prešle sa 6.9.4 na 6.9.5, odnosno sa 7.0 i 7.0.1 na 7.0.2.

WordPress je tokom nekih core update-a automatski ažurirao i prevode. To nije bila zasebna komanda naše skripte, već ponašanje WordPress updatera. Kompletan izlaz je sačuvan, pa se i taj detalj vidi u logu.

Checksum upozorenje nije presuda

Na nekoliko instalacija wp core verify-checksums prijavio je dodatne fajlove:

wp-admin/error_log
wp-includes/.htaccess
wp-cli.phar
wp-gv.zip
wp-cloudflare-single-purge.tar

Većina error_log fajlova verovatno ima sasvim obično objašnjenje. .htaccess unutar wp-includes može biti lokalna zaštita. Arhive i lokalni wp-cli.phar zahtevaju više pažnje.

Sama poruka File should not exist ne dokazuje kompromitovanje. Ona kaže samo da fajl nije deo zvaničnog WordPress paketa za tu verziju i lokalizaciju.

Skripta ih zato ne briše. Automatsko brisanje svega što checksum ne prepoznaje bilo bi prilično opasno, posebno na starijim i prilagođenim instalacijama. Ti nalazi idu u log i pregledaju se odvojeno.

Na jednoj staging instalaciji checksum nije prolazio pre update-a zbog nedostajućeg standardnog fajla i više dodatnih fajlova. Posle punog lokalizovanog core update-a zvanični core je ponovo prošao proveru, dok su lokalni dodatni fajlovi ostali navedeni kao upozorenje. To je korisna razlika: integritet zvaničnih core fajlova je potvrđen, ali lokalni sadržaj nije automatski proglašen bezbednim.

Ažurirana verzija nije isto što i čist server

Zatvaranje ranjive verzije rešava trenutni verzijski problem. Ne govori nam da li je ranjivost ranije iskorišćena.

Ako je instalacija bila javno dostupna dok je koristila pogođenu verziju, i dalje ima smisla proveriti:

  • dodatne PHP fajlove u core direktorijumima
  • neočekivane administratore
  • izmene u wp-config.php
  • aktivne pluginove i must-use pluginove
  • cron događaje
  • nedavne izmene fajlova
  • web i PHP logove
  • procese i outbound konekcije
  • poznate web shell obrasce

Checksum je koristan signal, ali nije malware skener. Verzijski audit takođe nije incident response.

Od tri skripte do jednog alata

Posle uspešne sanacije spojili smo inventar, audit i update logiku u jedan alat: QGA WordPress Manager.

Pokretanje bez argumenata otvara interaktivni meni:

chmod 0700 qga-wp-manager.sh
./qga-wp-manager.sh

Postoje dva profila:

  • wp2shell, podrazumevani profil za ciljanu proveru poznatog pogođenog opsega
  • minor, za proveru svih pronađenih instalacija i update unutar postojeće grane

Meni vodi kroz host check, inventar, audit, dry-run, pripremu WP-CLI-ja i primenu update-a. PREPARE i APPLY traže da korisnik ručno ukuca tačnu potvrdu. Prazan Enter otkazuje radnju.

Napredni komandni režim je ostao zbog automatizacije i cron poslova, ali više nije glavni način korišćenja.

Kod i dokumentacija su javno dostupni:

github.com/lukapaunovic/qga-wp-manager

Alat nije čarobno dugme. Guest Agent može da izvršava veoma moćne komande unutar VM-a, često sa visokim privilegijama. Bezbednost ovde dolazi iz ograničenog skupa operacija, validacije ulaza, timeout-a, izvršavanja WordPress komandi kao vlasnik sajta, obaveznog backup-a i spremnosti da se problematična instalacija ostavi na miru.

U ovom poslu najvredniji rezultat nije bio broj 24. Bila je ona jedna instalacija koju skripta nije dirala.