浏览了多篇webmin的CVE-2019-15107后发现,有许多错误的playload或者存在不同版本对应的playload不通用。因此想要了解一下指定版本下的注入点的成因。

漏洞信息

Webmin是目前功能最强大的基于Web的Unix系统管理工具。管理员通过浏览器访问Webmin的各种管理功能并完成相应的管理动作。目前Webmin支持绝大多数的Unix系统,这些系统除了各种版本的linux以外还包括:AIX、HPUX、Solaris、Unixware、Irix和FreeBSD等。

在19年,Pentest上发布了CVE-2019-15107未授权远程代码执行漏洞。写到当用户在webmin(1.890~1.920)版本上开启Webmin密码重置【使用过期的密码提示用户输入新密码】功能后,攻击者可以通过向password_change.cgi功能页面发送特定的POST请求在目标系统中执行任意命令,并且无需身份验证即未授权RCE。而在其中,POST请求需要填写多种参数,而参数之间又有一些特定相关性,因此要通过代码审计才能搞清楚。

环境配置事项

在安装webmin建议使用官方的一键部署脚本安装,可以避免不必要的环境依赖问题。
安装完成后,切记打开【使用过期的密码提示用户输入新密码】功能。
查看miniserv.conf中passwd_mode=2是否已成立。2表示【使用过期的密码提示用户输入新密码】已修改成功。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
syslog=1
session=1
premodules=WebminCore
userfile=/etc/webmin/miniserv.users
keyfile=/etc/webmin/miniserv.pem
passwd_file=/etc/shadow
passwd_uindex=0
passwd_pindex=1
passwd_cindex=2
passwd_mindex=4
passwd_mode=2
preroot=gray-theme
passdelay=1
cipher_list_def=1
root=/usr/libexec/webmin

代码分析

查看webmin漏洞处的关键源代码源代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
#!/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;
}

在如下代码下会判断是否开启了密码重置功能,对应 miniserv.conf中的passwd_mode=2

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

validate input下方代码中会进行判断new1与new2是否符合规定,并两者是否判断相等。

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

假设new1new2并不相等,则会进入到pass_error函数中去。查看pass_error()函数。

1
2
3
4
5
6
7
8
9
10
11
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;
}

可以看出该函数只是在密码不规范下会print错误信息并执行exit
在18-31行处代码可以看出用户提交的user并进行判断

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 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!");
}
}

可以看出此处先利用&引用函数foreign_check()来引入acl-lib.pl文件进行用户信息列表的查询。这里参考了QFtm大佬的文章,是通过设置断点acl::list_users();在对perl代码(如下图)审计,然后通过Dumper函数进行打印。

1
2
3
4
5
6
7
8
9
10
11
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());
}
........

因此在acl::list_users();获得用户信息后,在($wuser) = grep { $_->{'name'} eq $in{'user'}中grep会过滤出已存在的用户,如果用户不存在,则$wuser赋值为undef

注: $_->{'name'}指用户在web界面输入的user参数,$in{'user'}为已存在的webmin用户。
undef是perl中变量未初始化时的默认值。当这个未初始化的变量被当做整型来使用时,那么undef就是0;而被当做字符串来使用时,undef就是空字符串。因此当在perl中使用一个未经过初始化的变量时,程序的运行是没有问题的。

但是在undef在if条件里默认为false即值为0,于是假设用户输入的user参数为空,则

1
2
3
4
if ($wuser->{'pass'} eq 'x') {
# A Webmin user, but using Unix authentication
$wuser = undef;
}

$wuser=undef,当程序继续运行时,在下方的if会将其$wuser判断为真{1=1}。
在进行用户密码与’x’的比较,如果为系统用户的话,$wuser被赋予值为undef,使程序
跳过以下代码:

1
2
3
4
5
6
7
8
9
//更改系统用户账号密码
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(); }

整体利用情况

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

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