Ilustracja do wpisu: Strona bez aktualizacjiЗображення згенеровано ШІ

Сайт без оновлень: два зломи, 7000 чужих файлів і шкідливий код в оновлювачі

Ззовні він працював цілком нормально. А всередині — сліди двох зломів, шкідливий код, що маскувався під оновлювач, і сім тисяч чужих файлів. Кейс очищення сайту, який ніхто не оновлював чотири роки.

Безпека

Сім тисяч чужих файлів, сліди двох окремих зломів і шкідливий код, що маскувався під оновлювач. Ось що я знайшов на сайті, який «та ж працював» — бо ззовні він виглядав цілком нормально. Його ніхто не оновлював чотири роки.

Це був сайт на Joomla, але річ тут не в Joomla. На WordPress механізм точнісінько такий самий: система без оновлень — це відчинені двері, і чим довше ніхто туди не зазирає, тим менше лишається що рятувати. Описую цей випадок повністю, бо він добре показує дві речі — як насправді виглядає видалення зараження і скільки коштує відсутність обслуговування.

Сам сайт: каталог продукції компанії із садівничої галузі, два окремі розділи з контентом (для роздрібу та для гурту), головна сторінка у YOOtheme Pro Page Builder. Працював він на Joomla 3, підтримка якої закінчилася у 2023 році.

Перехід із трійки на четвірку ми зробили раніше вручну, через FTP: заміна файлів плюс оновлення бази. Сайт піднявся і працював. Тепер настала черга Joomla 5 — і тут вбудований оновлювач упав із повідомленням «AJAX error». Друга спроба — те саме.

Обсяг робіт виявився значно більшим, ніж «доведи оновлення до кінця». Під капотом сиділа незавершена міграція з трійки — бо ручна заміна файлів ніколи не реєструє розширення в базі й не видаляє того, що зникло в новій версії, — а поруч понад сім тисяч чужих файлів у каталозі компонента оновлення, сліди двох окремих зломів і база, яка брехала про власну версію.

Умови роботи: доступ до FTP, phpMyAdmin і панелі Joomla. Без доступу до панелі хостингу. Це обмеження проходить крізь усю історію.

Чому це не було звичайне оновлення

Оновлювач Joomla робить чотири речі: завантажує пакет, розпаковує файли, видаляє файли, прибрані в новій версії, і мігрує базу. При ручному переході через FTP з трійки на четвірку виконується лише другий пункт. Файли перезаписуються, але нічого не видаляється і нічого не реєструється в базі.

У результаті сайт працює, а під ним лишається все, що було раніше. Рік за роком. При переході на Joomla 5 цей борг спливає весь одразу. Це той самий механізм, про який я писав у статті про те, чому регулярні оновлення сайту такі важливі: одне пропущене оновлення — це дешево, чотири роки пропущених оновлень — це вже окремий проєкт.

Тому замість того щоб натиснути «оновити», я почав з інвентаризації: що насправді лежить на цьому сервері і що, на думку бази, там лежить.

«AJAX error» під час оновлення Joomla — це не збій, а ліміти PHP

Перше, що я шукав, — причину того «AJAX error». Joomla показує її в панелі, треба лише знати де — System → System Information → PHP Information:

max_execution_time = 30 s
memory_limit = 128 MB
upload_max_filesize = 2 MB
post_max_size = 8 MB

Пакет оновлення Joomla 5 важить кілька десятків мегабайтів. Коли upload_max_filesize виставлено на 2 MB, опція «Upload & Update» фізично неможлива, а за тридцять секунд на виконання скрипту розпакування просто не має шансів вкластися. Це не був збій оновлювача. Це був тайм-аут.

Зазвичай такі ліміти піднімають у панелі хостингу. Панелі не було. .htaccess із директивами php_value теж відпадав, бо PHP тут працює як fpm-fcgi — такий запис не лише не спрацює, а й покладе сервер з помилкою 500. На щастя, сама версія PHP тут була актуальна (8.3), а сьогодні це базова умова, а не розкіш — старіші гілки PHP вже не отримують патчів безпеки.

Рішення — файл .user.ini у кореневому каталозі сайту:

memory_limit = 512M
max_execution_time = 300
max_input_time = 300
upload_max_filesize = 128M
post_max_size = 128M
max_input_vars = 5000

Зміни набувають чинності протягом п’яти хвилин (user_ini.cache_ttl). Після оновлення сторінки System Information показала всі нові значення в колонці «local» поруч із незмінними «master». Хостинг цього не заблокував.

База, яка брехала про свою версію

Наступна зупинка: System → Maintenance → Database. І тут засвітилася перша червона лампочка.

Joomla CMS Database Version: 6.0.0-2024-07-12
 Manifest Version: 4.4.14

На диску Joomla 4.4.14, а в таблиці #__schemas версія схеми 6.0.0, ще й з датою з майбутнього. Це не косметика. Механізм міграції порівнює версію, записану в базі, з назвами SQL-файлів на диску і запускає лише ті, що за сортуванням стоять вище. За запису 6.0.0 усі дельти Joomla 5 були б мовчки пропущені — без жодного повідомлення про помилку.

Я отримав би файли п’ятірки на диску і структуру бази від четвірки. І це саме той сценарій, у якому потім пів ночі шукаєш причину.

Звідки взялася ця версія? Joomla записує в #__schemas найвищу назву файлу, яку знайде в administrator/components/com_admin/sql/updates/mysql/. Я пішов туди подивитися.

Видалення вірусів: що саме залишив по собі зловмисник

1. Два файли, що прикидалися міграціями Joomla 6

У каталозі міграцій лежало 67 файлів. Шістдесят п’ять мали одну спільну мітку часу — момент завантаження Joomla 4 через FTP. І два вибивалися з ряду:

6.0.0-2023-07-12.sql 617 B maj 2024
6.0.0-2024-07-12.sql 624 B sierpień 2024

Обидва старші за інсталяцію Joomla 4. Вони не прийшли з жодним пакетом. Пережили міграцію з трійки саме тому, що завантаження через FTP перезаписує, але не видаляє.

Жоден із них не містив SQL.

Перший виявився файлом .htaccess — і не абияким. Він блокував виконання всіх PHP-файлів (з переліком кожного варіанта регістру літер, від php через pHp до php8), з одним винятком: поіменного списку назв wp-login.php, wp-logln.php, wp-admnn.php, wp-conffq.php, wp-lock.php, wp-temp.php, reviall.php. Набір, що вдає WordPress, на сайті на Joomla.

Другий був копією index.php з Joomla 3. Чистий код, без eval, без base64 — просто оригінальний файл, збережений під фальшивим розширенням.

Механізм хитрий: кнопка «Update Structure» у панелі не аналізує цих файлів, а виконує їх як SQL. А назва, що за сортуванням стоїть найвище, гарантує, що Joomla запише в #__schemas саме її.

Це також урок щодо моїх власних сканувань. Попередній прохід перевіряв файли з назвою .htaccess і скрипти .php. Він не перевіряв, чи вміст файлу відповідає його розширенню — і тому ці два проскочили.

2. Повний відбиток проникнення — сканування за датами

Замість шукати далі за сигнатурами, я змінив метод: вивів список усіх файлів, змінених у проміжку 25–28 травня, незалежно від типу.

Результат: 3170 файлів. З них:

  • 3169 × .htaccess по 237 байтів, рекурсивно підкинуті практично в кожен каталог сайту. Вміст: саме лише правило, що блокує PHP, без винятків.
  • 3 штуки з винятком Allow from all, що поіменно вказував на конкретний файл: qdUVgwa.php у modules/mod_related_items/, ZLyLV.php у language/pl-PL/, третій — у images/headers/.
  • 1 файл /media/com_fields/js/admin-fields-madai.min.js — справжній файл Joomla називається admin-fields-modal.min.js. Переставлені літери, а всередині не JavaScript, а той самий index.php з трійки.

Усі — з інтервалом у кілька десятків секунд, того самого ранку.

Складається цілісна картина: зловмисник заблокував виконання PHP на всьому сайті — це глушить конкурентні зараження і збиває з пантелику сканери — а собі залишив три хвіртки, розблоковані за назвою.

Добра новина: пошук цих трьох назв по всьому дереву нічого не знайшов. Самих бекдорів уже не було, їх видалили під час попереднього прибирання. Від них лишився каркас. Це підтвердила й перевірка: жоден PHP-файл не змінювався з 20 травня по 1 червня.

Видалення 3169 файлів .htaccess я запустив скриптом із жорстким фільтром — він видаляв лише файли з такою назвою, розміром 150–400 байтів, що містили правило Deny і сигнатуру цієї серії, і водночас не містили Allow from all. Якби десь лишився ще один файл із винятком, скрипт мав про нього повідомити, а не видалити. Повідомив про нуль. Нуль помилок.

3. Сім тисяч файлів чужої Joomla в оновлювачі

Під час перевірки компонента оновлення спливло те, чого я не очікував. У administrator/components/com_joomlaupdate/ лежала повна інсталяція Joomla 3 з лютого 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.dist

Разом 7364 файли. Це не файли компонента — це каталоги основного дерева Joomla. Хтось чотири роки тому розпакував повний пакет не в те місце, і так воно й лишилося.

Справжній com_joomlaupdate — це вісім позицій. Усе інше полетіло — після попереднього перегляду зі списком «залишаю недоторканим» і запобіжником, що пропускав кожен каталог, у якому був хоч один свіжий файл.

Це має практичне значення, а не лише естетичне: саме цей компонент от-от мав виконати оновлення до Joomla 5, а всередині носив закопану копію трійки разом із бібліотеками vendor з 2022 року та їхніми вразливостями — у каталозі, доступному через браузер.

4. Прибирання розширень

Чотирнадцять позицій, у два заходи.

Через панель пішли: Phoca Commander (мертвий — видавав 500 behavior::framework not found, тобто викликав API Joomla 3), сім розширень старого пошуку з трійки, а також бібліотеки LIB_IDNA і LIB_PHPUTF8.

Останні чотири — com_mailto, mod_status, plg_authentication_gmail, plg_system_p3p — деінсталятор Joomla відмовився видаляти, видаючи Call to a member function children() on null. Причина: це порожні записи в таблиці розширень, файлів маніфесту яких давно немає, а деінсталятор намагається розпарсити цей неіснуючий XML. Сама Joomla визнає це в повідомленні: «Component Uninstall: Can’t uninstall. Please remove manually.» Їх прибрав SQL-запит, разом із осиротілим екземпляром модуля.

Принагідно урок, який береже чимало нервів: версія «3.0.0» у списку розширень не означає сміття. Після натискання «Refresh Cache» компонент Articles — тобто ядро Joomla — перескочив з 3.0.0 на 4.0.0. Це був лише неоновлений запис. Якби я пройшовся списком із pre-update check по черзі, викинув би пів CMS. У випадку старого пошуку розрізнити допомагала тека плагіна: search — це Joomla 3, finder — Smart Search із п’ятірки.

Незавершена міграція з трійки

Pre-update check блокував оновлення одним пунктом: Database Table Structure Up to Date = No, з однією несправністю — відсутністю індексу idx_level у таблиці Smart Search.

Кнопка «Update Structure» цього не виправляла. Бо проблема була більшою, ніж Joomla здатна була побачити.

Таблиця finder_taxonomy мала структуру з Joomla 3: шість колонок, без lft, rgt, level, path, language і alias, які четвірка додала під час переходу на модель вкладеного дерева. Поруч стояло шістнадцять шардових таблиць finder_links_terms0–termsf, яких Joomla 4 взагалі не використовує.

Увесь файл міграції 4.0.0-2018-04-29.sql так ніколи й не виконався. Верифікатор повідомляв про одну проблему, бо перевіряє виключно команди ALTER TABLE — перебудову через DROP і CREATE він просто не бачить.

Добра новина: усі таблиці були порожні. Smart Search ніколи не використовувався. Замість писати SQL з пам’яті, я згенерував його скриптом безпосередньо з файлів міграції, що лежали на цьому сервері, з підставленим префіксом — рівно ті команди, які виконала б сама Joomla, і в її порядку. 77 команд виконано, 8 пропущено (дублікати індексів — нормальна ситуація).

Після цього Maintenance → Database показав «All database table structures are up to date», а pre-update check — усі вимоги зеленим.

Оновлення — і три речі, які поклали сайт

Оновлення до 5.4.8 пройшло коректно. Файли встали, база змігрувала. І сайт перестав працювати.

Три причини, одна за одною, і всі однієї природи: код з епохи Joomla 3, якого п’ятірка не приймає.

Плагін 2018 року

PHP Fatal error: Could not check compatibility between
PlgFieldsRepeatable::onCustomFieldsPrepareDom($field, DOMElement $parent, JForm $form)
... because class JForm is not available

Плагін Fields – Repeatable, версія 3.9.0. У сигнатурі методу тип JForm — клас, якого в Joomla 5 немає. PHP видає помилку вже під час завантаження класу, при кожному запиті, тож лягли і фронтенд, і панель.

І тут мушу визнати власну помилку. Я бачив цей плагін раніше, у групі «немає інформації про сумісність» у pre-update check, і вирішив, що ця група суто інформаційна. Формально вона не блокувала оновлення. Неформально саме вона поклала сайт п’ятнадцять хвилин потому. Під час наступної міграції я сприйматиму цю групу як список на видалення, а не на ігнорування.

Кеш шаблону

Після вимкнення плагіна панель ожила, тож я взявся за встановлення YOOtheme Pro 5. І воно зупинилося на:

Folder::delete: Could not delete folder. Path: [ROOT]/templates/yootheme/cache

Каталог кешу старого шаблону не вдавалося видалити, тож встановлення обірвалося на півдорозі. Після видалення його через FTP і повторного встановлення з’явилося наступне повідомлення — Class "Joomla\Plugin\System\YOOtheme\YOOtheme" not found. Це вже була звичайна застаріла мапа класів: очищення /cache і /administrator/cache вирішило справу.

Сім викликів JText у шапці

Найцікавіша з цієї трійки, бо симптом вводив в оману.

Сайт повертав 500, але рендерив контент — статті, тексти, кнопки. Лише без шапки. Ні логотипа, ні меню. А в тегу <title> сиділо повідомлення про помилку: Class "JText" not found.

Винними виявилися три файли макета шапки в дочірньому шаблоні, яких ніхто не чіпав з лютого 2020 року:

templates/<child>/templates/header.php 1 ×
templates/<child>/templates/header-mobile.php 4 ×
templates/<child>/templates/header-logo.php 2 ×

Сім викликів JText::_('TPL_YOOTHEME_MENU') і JText::_('TPL_YOOTHEME_SEARCH') — тобто підписи «Меню» і «Пошук». JText — це псевдонім класу з Joomla 3, видалений у п’ятірці. Шапка падала на першому з них, Joomla переривала рендеринг шаблону, віддавала те, що встигла зібрати, і виставляла код 500.

Заміна на \Joomla\CMS\Language\Text:: — з копією .bak біля кожного файлу — і сайт повернувся повністю.

Варто додати, бо це поширене непорозуміння: плагін Behaviour – Backward Compatibility був увімкнений, з опцією «Classes Aliases» на Yes. Цього не вистачило. Не варто покладатися на нього як на захист від старого коду.

«Extension is not currently installed» — Discover і 70 відсутніх розширень ядра

Після того як сайт піднявся, панель і далі поводилася дивно. Розділові панелі — System, Content, Menus, Users — світили порожнечею. Mail Templates і Scheduled Tasks повертали 404. А журнал оновлення мав кількасот записів одного типу:

Refresh Manifest Cache failed: <rozszerzenie> Extension is not currently installed

Пояснення виявилося банальним і охоплювало все одразу. Extensions → Discover знайшов 70 розширень ядра Joomla 4 і 5, які ручна міграція так і не зареєструвала в базі. Файли лежали на диску від дня оновлення. Бракувало лише записів.

У списку: Mail Templates, Scheduled Tasks, Workflows, Guided Tours, шаблон Cassiopeia, Extension – Namespace Updater (будує мапу класів під час кожного встановлення розширення), FileSystem – Local (без нього медіаменеджер працює як каліка), Behaviour – Taggable і Versionable (теги та історія версій статей), Media Action Crop/Resize/Rotate, повний набір двофакторної автентифікації — код електронною поштою, TOTP, Passkeys, YubiKey — а також усі плагіни Web Services і Task.

Позначити все і натиснути «Install» — і за кілька десятків секунд справу зроблено. Нічого не завантажується — Discover лише реєструє те, що вже лежить на диску.

Це найважливіший висновок з усієї історії: після кожної ручної міграції Joomla першим ділом має бути Discover. Якби я запустив його на початку, заощадив би кілька годин.

Панелі, мапа сайту та решта

Порожні розділові панелі потребували окремого ремонту, бо модулів на позиціях cpanel-* Discover не створює — їх додає лише чиста інсталяція. Їхні визначення я витягнув з installation/sql/mysql/base.sql з офіційного пакета 5.4.8, пропустивши колонки id і asset_id, щоб вони не конфліктували з наявними модулями.

І тут чекала пастка, яку варто запам’ятати. Після вставлення рядків модулі і далі не рендерилися, попри published = 1 і правильну позицію. Їм бракувало рядка в таблиці #__assets. Цей запис створюється лише тоді, коли відкрити кожен модуль у панелі й натиснути Save & Close. До того ж двадцять записів були заблоковані після невдалих спроб збереження — тож перед подальшою роботою System → Global Check-in.

Окремо OSMap: панель компонента і публічна XML-мапа повертали 500 з повідомленням Class "Alledia\OSMap\Factory" not found. Версія 5.0.10 була ще з часів до міграції. Встановлення актуальної 5.1.8 поверх повернуло і панель, і мапу — а це було важливо, бо весь цей час Google не мав звідки взяти sitemap.

Результат

  • Joomla 5.4.8, сайт і панель працюють, усі 34 перевірені екрани панелі відповідають коректно.
  • Сліди двох окремих заражень видалено — 3169 підкинутих файлів .htaccess, два файли, сховані під фальшивими розширеннями, мертві каркаси після бекдорів.
  • 7364 файли чужої Joomla 3 вирізано з нутрощів компонента оновлення.
  • Завершено міграцію з трійки — таблиці Smart Search, 70 незареєстрованих розширень ядра, адміністративні панелі.
  • Мапа сайту повернулася, двофакторна автентифікація доступна, версіонування статей і теги працюють як слід.

Наступний стрибок — на Joomla 6 — буде зовсім іншим досвідом. Сьогоднішні муки взялися не з того, що Joomla 5 складна, а з чотирьох років боргу, що тягнувся ще від трійки. Цього боргу більше немає.

Такий сайт я проводжу через роботу, починаючи з діагностики, а не з вгадування: спершу інвентаризація файлів і бази, потім резервна копія, і лише тоді чищення та оновлення. Результати інших проєктів — у розділі «Реалізації».

Два висновки, які стосуються і вашого сайту

Перший: зараження не видно ззовні. Цей сайт нормально завантажувався весь той час, поки хтось тримав на ньому сім тисяч файлів і будував собі власну інфраструктуру. Власник дізнався б про це лише тоді, коли Google вивісить попередження або хостинг заблокує акаунт, — тобто тоді, коли вже болить. Як виглядає очищення такого сайту і скільки воно коштує, я описав тут: видалення вірусів.

Другий: усього цього можна було уникнути. Жодна з цих дірок не була витонченою — зловмисники зайшли через розширення, яких ніхто не оновлював роками. Двох годин на місяць вистачило б, щоб ця історія не сталася. У цьому й полягає обслуговування сайту: оновлення, резервні копії і хтось, хто зазирає всередину раніше, ніж це зробить хтось інший.

Арифметика проста. Очищення зараженого сайту коштує 1 000 зл, інтернет-магазину — 1 750 зл, плюс тижні відновлення позицій у Google. Обслуговування починається від 199 зл на місяць і робить так, що такий рахунок ніколи не приходить.


У вас сайт на Joomla 3 або 4, і ви відкладаєте оновлення, бо «та ж працює»? Це саме той момент, коли варто зазирнути під капот — поки цього не зробив хтось інший. Напишіть мені — я перевірю, що сидить на вашому сервері, і чесно скажу, чи це година роботи, чи дві безсонні ночі.