Siedem tysięcy obcych plików, ślady dwóch osobnych włamań i malware podszywający się pod aktualizator. Tyle znalazłem na stronie, która „przecież działała” — bo z zewnątrz wyglądała normalnie. Nikt jej nie aktualizował przez cztery lata.
To był serwis na Joomli, ale nie o Joomlę tu chodzi. Mechanizm jest identyczny na WordPressie: system bez aktualizacji to otwarte drzwi, a im dłużej nikt nie zagląda, tym mniej zostaje do uratowania. Opisuję ten przypadek w całości, bo dobrze pokazuje dwie rzeczy — jak naprawdę wygląda usuwanie infekcji i ile kosztuje jej brak.
Sama strona: katalog produktów firmy z branży ogrodniczej, dwie ścieżki treści (osobno dla detalu i dla hurtu), strona główna w YOOtheme Pro Page Builder. Stała na Joomli 3, której wsparcie skończyło się w 2023 roku.
Przejście z trójki na czwórkę zrobiliśmy wcześniej ręcznie, przez FTP: podmiana plików plus aktualizacja bazy. Strona wstała i działała. Teraz przyszła kolej na Joomlę 5 — i tu wbudowany aktualizator wywalił się komunikatem „AJAX error”. Druga próba, to samo.
Zakres okazał się znacznie większy niż „dokończ aktualizację”. Pod spodem siedziało niedokończone przejście z trójki — bo ręczna podmiana plików nigdy nie rejestruje rozszerzeń w bazie i nie kasuje tego, co z nowej wersji zniknęło — a obok tego ponad siedem tysięcy obcych plików w katalogu komponentu aktualizacji, ślady dwóch osobnych włamań i baza, która kłamała o własnej wersji.
Warunki pracy: dostęp do FTP, phpMyAdmina i panelu Joomli. Bez dostępu do panelu hostingu. To ograniczenie przewija się przez całą historię.
Dlaczego to nie była zwykła aktualizacja
Aktualizator Joomli robi cztery rzeczy: pobiera paczkę, rozpakowuje pliki, kasuje pliki usunięte w nowej wersji i migruje bazę. Przy ręcznym przejściu FTP-em z trójki na czwórkę wykonuje się tylko drugi punkt. Pliki się nadpisują, ale nic się nie kasuje i nic nie rejestruje w bazie.
Efekt jest taki, że strona działa, a pod spodem zostaje wszystko, co było wcześniej. Rok po roku. Przy przejściu na Joomlę 5 ten dług wypływa naraz. To ten sam mechanizm, o którym pisałem przy okazji tego, dlaczego regularne aktualizacje strony są kluczowe: jedna zaległość jest tania, cztery lata zaległości to już projekt.
Dlatego zamiast klikać „aktualizuj”, zacząłem od inwentaryzacji: co właściwie leży na tym serwerze i co baza myśli, że leży.
„AJAX error” przy aktualizacji Joomli to nie awaria, to limity PHP
Pierwsza rzecz, której szukałem, to przyczyna tamtego „AJAX error”. Joomla ma ją w panelu, wystarczy wiedzieć gdzie — System → System Information → PHP Information:
max_execution_time = 30 s
memory_limit = 128 MB
upload_max_filesize = 2 MB
post_max_size = 8 MBPaczka aktualizacyjna Joomli 5 waży kilkadziesiąt megabajtów. Przy upload_max_filesize ustawionym na 2 MB opcja „Upload & Update” jest fizycznie niemożliwa, a przy trzydziestu sekundach na wykonanie skryptu rozpakowywanie nie ma szans się zmieścić. To nie była awaria aktualizatora. To był timeout.
Normalnie podnosi się to w panelu hostingu. Panelu nie było. .htaccess z dyrektywami php_value też odpadał, bo PHP działa tu jako fpm-fcgi — taki zapis nie tylko nie zadziała, ale wywali serwer w 500. Sama wersja PHP była tu na szczęście aktualna (8.3), a to dziś warunek wstępny, nie luksus — starsze gałęzie PHP nie dostają już łatek bezpieczeństwa.
Rozwiązaniem jest plik .user.ini w katalogu głównym strony:
memory_limit = 512M
max_execution_time = 300
max_input_time = 300
upload_max_filesize = 128M
post_max_size = 128M
max_input_vars = 5000Zmiany wchodzą do pięciu minut (user_ini.cache_ttl). Po odświeżeniu System Information pokazał komplet nowych wartości w kolumnie „local” obok niezmienionych „master”. Hosting nie zablokował.
Baza, która kłamała o swojej wersji
Kolejny przystanek: System → Maintenance → Database. I tu zapaliła się pierwsza czerwona lampka.
Joomla CMS Database Version: 6.0.0-2024-07-12
Manifest Version: 4.4.14Na dysku Joomla 4.4.14, a w tabeli #__schemas wersja schematu 6.0.0, z datą z przyszłości. To nie jest kosmetyka. Mechanizm migracji porównuje wersję zapisaną w bazie z nazwami plików SQL na dysku i uruchamia tylko te, które sortują się wyżej. Przy wpisie 6.0.0 wszystkie delty Joomli 5 zostałyby pominięte w ciszy — bez jednego komunikatu błędu.
Dostałbym pliki piątki na dysku i strukturę bazy z czwórki. I to jest dokładnie ten scenariusz, w którym potem szuka się przyczyny przez pół nocy.
Skąd wzięła się ta wersja? Joomla zapisuje do #__schemas najwyższą nazwę pliku, jaką znajdzie w administrator/components/com_admin/sql/updates/mysql/. Poszedłem tam zobaczyć.
Odwirusowanie: co dokładnie zostawił po sobie napastnik
1. Dwa pliki udające migracje Joomli 6
W katalogu migracji leżało 67 plików. Sześćdziesiąt pięć miało jeden wspólny znacznik czasu — moment wgrania Joomli 4 przez FTP. I dwa, które odstawały:
6.0.0-2023-07-12.sql 617 B maj 2024
6.0.0-2024-07-12.sql 624 B sierpień 2024Obydwa starsze niż instalacja Joomli 4. Nie przyszły z żadną paczką. Przetrwały migrację z trójki dokładnie dlatego, że wgrywanie FTP-em nadpisuje, ale nie kasuje.
Żaden z nich nie zawierał SQL-a.
Pierwszy był plikiem .htaccess — i to nie byle jakim. Blokował wykonywanie wszystkich plików PHP (z wyliczeniem każdego wariantu wielkości liter, od php przez pHp po php8), z jednym wyjątkiem: imiennej listy nazw wp-login.php, wp-logln.php, wp-admnn.php, wp-conffq.php, wp-lock.php, wp-temp.php, reviall.php. Zestaw udający WordPressa na stronie na Joomli.
Drugi był kopią index.php z Joomli 3. Czysty kod, bez eval, bez base64 — po prostu oryginalny plik zapisany pod fałszywym rozszerzeniem.
Mechanizm jest sprytny: przycisk „Update Structure” w panelu nie interpretuje tych plików, tylko wykonuje je jako SQL. A nazwa sortująca się najwyżej gwarantuje, że Joomla właśnie ją wpisze do #__schemas.
To jest też lekcja o moich własnych skanach. Wcześniejszy przebieg sprawdzał pliki nazwane .htaccess i skrypty .php. Nie sprawdzał, czy zawartość pliku zgadza się z jego rozszerzeniem — i dlatego te dwa przeszły.
2. Pełny odcisk wejścia — skan po datach
Zamiast szukać dalej po sygnaturach, zmieniłem metodę: wylistowałem wszystkie pliki zmodyfikowane w oknie 25–28 maja, niezależnie od typu.
Wynik: 3170 plików. Z tego:
- 3169 ×
.htaccesspo 237 bajtów, wrzucone rekurencyjnie do praktycznie każdego katalogu w serwisie. Treść: sama reguła blokująca PHP, bez wyjątku. - 3 sztuki z wyjątkiem
Allow from allwskazującym imiennie na konkretny plik:qdUVgwa.phpwmodules/mod_related_items/,ZLyLV.phpwlanguage/pl-PL/, trzeci wimages/headers/. - 1 plik
/media/com_fields/js/admin-fields-madai.min.js— prawdziwy plik Joomli nazywa sięadmin-fields-modal.min.js. Przestawione litery, a w środku nie JavaScript, tylko ten samindex.phpz trójki.
Wszystkie w odstępie kilkudziesięciu sekund, tego samego poranka.
Układa się to w spójny obraz: napastnik zablokował wykonywanie PHP w całym serwisie — co wycisza konkurencyjne infekcje i myli skanery — a sobie zostawił trzy furtki odblokowane z nazwy.
Dobra wiadomość: przeszukanie całego drzewa po tych trzech nazwach nie znalazło nic. Same backdoory już nie istniały, usunięte przy wcześniejszym sprzątaniu. Został po nich szkielet. Potwierdziła to też kontrola: żaden plik PHP nie był modyfikowany między 20 maja a 1 czerwca.
Usunięcie 3169 plików .htaccess puściłem skryptem z twardym filtrem — kasował wyłącznie pliki o tej nazwie, w przedziale 150–400 bajtów, zawierające regułę Deny i sygnaturę tej serii, a jednocześnie niezawierające Allow from all. Gdyby gdzieś został jeszcze jeden plik z wyjątkiem, miał go zgłosić zamiast skasować. Zgłosił zero. Zero błędów.
3. Siedem tysięcy plików obcej Joomli w aktualizatorze
Przy okazji sprawdzania komponentu aktualizacji wyszło coś, czego się nie spodziewałem. W administrator/components/com_joomlaupdate/ leżała kompletna instalacja Joomli 3 z lutego 2020:
administrator/ 2521 plików libraries/ 1700 plików
media/ 1519 plików components/ 466 plików
plugins/ 432 plików templates/ 260 plików
layouts/ 188 plików modules/ 160 plików
language/ 90 plików + cli/ bin/ includes/ images/ cache/ tmp/
+ index.php, LICENSE.txt, README.txt, htaccess.txt, robots.txt.distRazem 7364 pliki. To nie są pliki komponentu — to katalogi głównego drzewa Joomli. Ktoś cztery lata temu rozpakował pełną paczkę do niewłaściwego miejsca i tak zostało.
Prawdziwy com_joomlaupdate to osiem pozycji. Cała reszta poleciała, po wcześniejszym podglądzie z listą „zostawiam nietknięte” i bezpiecznikiem pomijającym każdy katalog zawierający choć jeden świeży plik.
Ma to znaczenie praktyczne, nie tylko porządkowe: to jest komponent, który za chwilę miał przeprowadzić aktualizację do Joomli 5, a w środku nosił zakopaną kopię trójki wraz z bibliotekami vendor z 2022 roku i ich podatnościami — w katalogu osiągalnym przez przeglądarkę.
4. Sprzątanie rozszerzeń
Czternaście pozycji, w dwóch turach.
Przez panel poszły: Phoca Commander (martwy — wywalał 500 behavior::framework not found, czyli wołał API Joomli 3), siedem rozszerzeń starej wyszukiwarki z trójki oraz biblioteki LIB_IDNA i LIB_PHPUTF8.
Cztery ostatnie — com_mailto, mod_status, plg_authentication_gmail, plg_system_p3p — deinstalator Joomli odmówił usunąć, wywalając Call to a member function children() on null. Powód: to puste wpisy w tabeli rozszerzeń, których pliki manifestu dawno nie istnieją, a deinstalator próbuje ten nieistniejący XML sparsować. Sam Joomla przyznaje to w komunikacie: „Component Uninstall: Can’t uninstall. Please remove manually.” Poszły zapytaniem SQL, razem z osieroconą instancją modułu.
Przy okazji lekcja, która oszczędza sporo nerwów: wersja „3.0.0″ w liście rozszerzeń nie oznacza śmiecia. Po kliknięciu „Refresh Cache” komponent Articles — czyli rdzeń Joomli — przeskoczył z 3.0.0 na 4.0.0. To był tylko nieodświeżony wpis. Gdybym poszedł listą z pre-update checku po kolei, wyrzuciłbym połowę CMS-a. Przy starej wyszukiwarce rozróżnienie dawał folder wtyczki: search to Joomla 3, finder to Smart Search z piątki.
Niedokończona migracja z trójki
Pre-update check blokował aktualizację jednym punktem: Database Table Structure Up to Date = No, z jedną usterką — brakiem indeksu idx_level na tabeli Smart Search.
Przycisk „Update Structure” tego nie naprawiał. Bo problem był większy, niż Joomla potrafiła zobaczyć.
Tabela finder_taxonomy miała strukturę z Joomli 3: sześć kolumn, bez lft, rgt, level, path, language i alias, które czwórka dodała przy przejściu na model drzewa zagnieżdżonego. Obok stało szesnaście tabel shardowych finder_links_terms0–termsf, których Joomla 4 w ogóle nie używa.
Cały plik migracji 4.0.0-2018-04-29.sql nigdy się nie wykonał. Weryfikator zgłaszał jeden problem, bo sprawdza wyłącznie polecenia ALTER TABLE — przebudowa przez DROP i CREATE jest dla niego niewidoczna.
Dobra wiadomość: wszystkie tabele były puste. Smart Search nigdy nie był używany. Zamiast pisać SQL z pamięci, wygenerowałem go skryptem wprost z plików migracji leżących na tym serwerze, z podstawionym prefiksem — dokładnie te polecenia, które wykonałaby sama Joomla, w jej kolejności. 77 poleceń wykonanych, 8 pominiętych (duplikaty indeksów, sytuacja normalna).
Po tym Maintenance → Database pokazał „All database table structures are up to date”, a pre-update check — komplet wymagań na zielono.
Aktualizacja — i trzy rzeczy, które położyły stronę
Aktualizacja do 5.4.8 wykonała się poprawnie. Pliki weszły, baza się zmigrowała. I strona przestała działać.
Trzy przyczyny, jedna po drugiej, wszystkie tej samej natury: kod z epoki Joomli 3, którego piątka nie przyjmuje.
Wtyczka z 2018 roku
PHP Fatal error: Could not check compatibility between
PlgFieldsRepeatable::onCustomFieldsPrepareDom($field, DOMElement $parent, JForm $form)
... because class JForm is not availableWtyczka Fields – Repeatable, wersja 3.9.0. W sygnaturze metody typ JForm — klasa, której w Joomli 5 nie ma. PHP wywala błąd przy samym ładowaniu klasy, przy każdym żądaniu, więc padł i frontend, i panel.
I tu muszę przyznać własny błąd. Widziałem tę wtyczkę wcześniej, w grupie „brak informacji o kompatybilności” w pre-update checku, i uznałem tę grupę za informacyjną. Formalnie nie blokowała aktualizacji. Nieformalnie to ona położyła stronę piętnaście minut później. Przy kolejnej migracji tę grupę traktuję jak listę do usunięcia, nie do zignorowania.
Cache szablonu
Po wyłączeniu wtyczki panel wstał, więc poszła instalacja YOOtheme Pro 5. I zatrzymała się na:
Folder::delete: Could not delete folder. Path: [ROOT]/templates/yootheme/cacheKatalog cache starego szablonu nie dał się skasować, więc instalacja przerwała się w połowie. Po usunięciu go przez FTP i ponownej instalacji pojawił się kolejny komunikat — Class "Joomla\Plugin\System\YOOtheme\YOOtheme" not found. To była już zwykła nieaktualna mapa klas: wyczyszczenie /cache i /administrator/cache załatwiło sprawę.
Siedem wywołań JText w nagłówku
Najciekawszy z tej trójki, bo objaw mylił.
Strona zwracała 500, ale renderowała treść — artykuły, teksty, przyciski. Tylko bez nagłówka. Ani logo, ani menu. A w znaczniku <title> siedział komunikat błędu: Class "JText" not found.
Winne okazały się trzy pliki układu nagłówka w szablonie potomnym, nietknięte od lutego 2020:
templates/<child>/templates/header.php 1 ×
templates/<child>/templates/header-mobile.php 4 ×
templates/<child>/templates/header-logo.php 2 ×Siedem wywołań JText::_('TPL_YOOTHEME_MENU') i JText::_('TPL_YOOTHEME_SEARCH') — czyli etykiety „Menu” i „Szukaj”. JText to alias klasy z Joomli 3, usunięty w piątce. Nagłówek wywracał się na pierwszym z nich, Joomla przerywała renderowanie szablonu, oddawała to, co zdążyła zbudować, i ustawiała kod 500.
Zamiana na \Joomla\CMS\Language\Text:: — z kopią .bak przy każdym pliku — i strona wróciła w całości.
Warto dodać, bo to częste nieporozumienie: wtyczka Behaviour – Backward Compatibility była włączona, z opcją „Classes Aliases” na Yes. Nie wystarczyła. Nie ma co na nią liczyć jako na zabezpieczenie przed starym kodem.
„Extension is not currently installed” — Discover i 70 brakujących rozszerzeń rdzenia
Po postawieniu strony panel dalej zachowywał się dziwnie. Pulpity sekcyjne — System, Content, Menus, Users — świeciły pustką. Mail Templates i Scheduled Tasks zwracały 404. A log aktualizacji miał kilkaset wpisów jednego rodzaju:
Refresh Manifest Cache failed: <rozszerzenie> Extension is not currently installedWyjaśnienie okazało się banalne i obejmowało wszystko naraz. Extensions → Discover znalazł 70 rozszerzeń rdzenia Joomli 4 i 5, których ręczna migracja nigdy nie zarejestrowała w bazie. Pliki były na dysku od dnia aktualizacji. Brakowało tylko wpisów.
Na liście: Mail Templates, Scheduled Tasks, Workflows, Guided Tours, szablon Cassiopeia, Extension – Namespace Updater (buduje mapę klas przy każdej instalacji rozszerzenia), FileSystem – Local (bez niej menedżer mediów jest kaleki), Behaviour – Taggable i Versionable (tagi i historia wersji artykułów), Media Action Crop/Resize/Rotate, komplet uwierzytelniania dwuskładnikowego — kod e-mail, TOTP, Passkeys, YubiKey — oraz wszystkie wtyczki Web Services i Task.
Zaznaczenie wszystkich i „Install” załatwiło to w kilkanaście sekund. Nic się nie pobiera — Discover tylko rejestruje to, co już leży na dysku.
To jest najważniejszy wniosek z całej tej historii: po każdej ręcznej migracji Joomli pierwszą rzeczą powinien być Discover. Gdybym uruchomił go na początku, oszczędziłbym kilka godzin.
Pulpity, mapa witryny i reszta
Puste pulpity sekcyjne wymagały osobnej naprawy, bo modułów na pozycjach cpanel-* Discover nie tworzy — te zakłada tylko świeża instalacja. Ich definicje wyciągnąłem z installation/sql/mysql/base.sql z oficjalnej paczki 5.4.8, pomijając kolumny id i asset_id, żeby nie kolidowały z istniejącymi modułami.
I tu czekała pułapka warta zapamiętania. Po wstawieniu wierszy moduły nadal się nie renderowały, mimo published = 1 i poprawnej pozycji. Brakowało im wiersza w tabeli #__assets. Dopiero otwarcie każdego w panelu i Save & Close dorabia ten wpis. Dodatkowo dwadzieścia rekordów było zablokowanych po nieudanych próbach zapisu — System → Global Check-in przed dalszą pracą.
Osobno OSMap: panel komponentu i publiczna mapa XML zwracały 500 z komunikatem Class "Alledia\OSMap\Factory" not found. Wersja 5.0.10 pochodziła sprzed migracji. Instalacja aktualnej 5.1.8 na wierzch przywróciła i panel, i mapę — co miało znaczenie, bo przez ten czas Google nie miało skąd pobrać sitemapy.
Efekt
- Joomla 5.4.8, strona i panel działają, wszystkie 34 sprawdzone widoki panelu odpowiadają poprawnie.
- Ślady dwóch osobnych infekcji usunięte — 3169 podrzuconych plików
.htaccess, dwa pliki ukryte pod fałszywymi rozszerzeniami, martwe ramy po backdoorach. - 7364 pliki obcej Joomli 3 wycięte z wnętrza komponentu aktualizacji.
- Dokończona migracja z trójki — tabele Smart Search, 70 niezarejestrowanych rozszerzeń rdzenia, pulpity administracyjne.
- Mapa witryny wróciła, uwierzytelnianie dwuskładnikowe dostępne, wersjonowanie artykułów i tagi działają jak należy.
Następny skok — na Joomlę 6 — będzie zupełnie innym doświadczeniem. Dzisiejsza męka nie wzięła się z tego, że Joomla 5 jest trudna, tylko z czterech lat długu ciągnącego się od trójki. Tego długu już nie ma.
Przez taki serwis przechodzę od diagnozy, nie od zgadywania: najpierw inwentaryzacja plików i bazy, potem kopia, dopiero potem czyszczenie i aktualizacja. Efekty innych wdrożeń są w portfolio.
Dwa wnioski, które przekładają się na Twoją stronę
Pierwszy: infekcji nie widać z zewnątrz. Ta strona ładowała się normalnie przez cały czas, kiedy ktoś trzymał na niej siedem tysięcy plików i budował własne zaplecze. Właściciel dowiedziałby się dopiero wtedy, gdy Google wywiesi ostrzeżenie albo hosting zablokuje konto — czyli wtedy, gdy już boli. Jak wygląda wyczyszczenie takiej strony i ile kosztuje, opisałem tutaj: usuwanie wirusów.
Drugi: to wszystko było do uniknięcia. Żadna z tych dziur nie była wyrafinowana — weszli przez rozszerzenia, których nikt nie aktualizował od lat. Dwie godziny miesięcznie wystarczyłyby, żeby ta historia się nie wydarzyła. Na tym polega opieka nad stroną: aktualizacje, kopie zapasowe i ktoś, kto zagląda, zanim zajrzy ktoś inny.
Rachunek jest prosty. Wyczyszczenie zainfekowanej strony to 1 000 zł, sklepu 1 750 zł, plus tygodnie odbudowywania pozycji w Google. Opieka zaczyna się od 249 zł miesięcznie i sprawia, że ten rachunek nigdy nie przychodzi.
Masz stronę na Joomli 3 albo 4 i odkładasz aktualizację, bo „przecież działa”? To jest dokładnie ten moment, w którym warto zajrzeć pod maskę — zanim zrobi to ktoś inny. Napisz — sprawdzę, co siedzi na Twoim serwerze, i powiem wprost, czy to godzina pracy, czy dwie noce.
FAQ — o co pytają Klienci
Czy można zaktualizować Joomlę 3 bezpośrednio do Joomli 5?
Nie. Trzeba przejść przez Joomlę 4: najpierw 3.10 → 4.4, dopiero potem 4.4 → 5.x. Każdy z tych kroków migruje bazę własnym zestawem plików SQL, a Joomla 5 zakłada, że struktura z czwórki już istnieje. Pominięcie pośredniego kroku kończy się stroną, która wstaje, ale ma bazę w strukturze sprzed migracji.
Dlaczego aktualizacja Joomli kończy się błędem „AJAX error"?
W większości przypadków to nie awaria aktualizatora, tylko za niskie limity PHP. Sprawdź w System → System Information → PHP Information wartości max_execution_time (potrzeba 120–300 s), memory_limit (256–512 MB) oraz upload_max_filesize i post_max_size (minimum 64 MB — paczka waży kilkadziesiąt). Jeśli nie masz dostępu do panelu hostingu, podniesiesz je plikiem .user.ini w katalogu głównym strony.
Co zrobić po ręcznej migracji Joomli przez FTP?
Przede wszystkim uruchomić Extensions → Discover. Podmiana plików przez FTP nie rejestruje w bazie rozszerzeń rdzenia, które doszły w nowej wersji — na opisywanej stronie brakowało ich siedemdziesięciu, co dawało setki błędów „Extension is not currently installed”. Potem System → Maintenance → Database i usunięcie rozszerzeń pozostałych po starej wersji, bo to one najczęściej wywracają stronę po aktualizacji.


