3 # Debian/OpenSSL Weak Key Detector
5 # Copyright (C) 2008, Florian Weimer <fw@deneb.enyo.de>
7 # Permission to use, copy, modify, and distribute this software for
8 # any purpose with or without fee is hereby granted, provided that the
9 # above copyright notice and this permission notice appear in all
12 # THE SOFTWARE IS PROVIDED "AS IS" AND FLORIAN WEIMER AND HIS
13 # CONTRIBUTORS DISCLAIM ALL WARRANTIES WITH REGARD TO THIS SOFTWARE
14 # INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN
15 # NO EVENT SHALL FLORIAN WEIMER OR HIS CONTRIBUTORS BE LIABLE FOR ANY
16 # SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
17 # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
18 # AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING
19 # OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS
22 # Blacklist data has been provided by Kees Cook, Peter Palfrader and
25 # Patches and comments are welcome. Please send them to
26 # <fw@deneb.enyo.de>, and use "dowkd" in the subject line.
28 # This version is based on commit @PROGRAM_SHA1@
29 # in the GIT repository at <http://repo.or.cz/w/dowkd.git>.
36 usage: $0 [OPTIONS...] COMMAND [ARGUMENTS...]
40 file: examine files on the command line for weak keys
41 host: examine the specified hosts for weak SSH keys
42 (change destination port with "host -p PORT HOST...")
43 user: examine user SSH keys for weakness; examine all users if no
45 quick: check this host for weak keys (encompasses "user" plus
46 heuristics to find keys in /etc)
47 help: show this help screen
48 version: show version information
52 -c FILE: set the database cache file name (default: dowkd.db)
54 dowkd currently handles the following OpenSSH host and user keys,
55 provided they have been generated on a little-endian architecture
56 (such as i386 or amd64):
58 RSA/1024, RSA/2048, RSA1/1024, RSA1/2048
62 (The relevant OpenSSH versions in Debian do not support DSA key
63 generation with other sizes.)
65 OpenVPN shared also detected if they have been created on
66 little-endian architectures.
68 Unencrypted RSA private keys and PEM certificate files generated by
69 OpenSSL are detected, provided they use key lengths of 1024, 2048 or
70 4096 bits (again, only for little-endian architectures).
72 Note that the blacklist by dowkd may be incomplete; it is only
73 intended as a quick check.
83 my $db_version = '@DB_VERSION@';
84 my $program_version = '@PROGRAM_VERSION@';
86 my $db_file = 'dowkd.db';
92 warn "notice: creating database, please wait\n";
93 $db = tie
%db, 'DB_File', $db_file, O_RDWR
| O_CREAT
, 0777, $DB_BTREE
94 or die "error: could not open database: $!\n";
97 while (my $line = <DATA
>) {
98 next if $line =~ /^\**$/;
100 $line =~ /^[0-9a-f]{32}$/ or die "error: invalid data line";
101 $line =~ s/(..)/chr(hex($1))/ge;
105 $found or die "error: no blacklist data found in script\n";
107 # Set at the end so that no incomplete database is left behind.
108 $db{''} = $db_version;
115 $db = tie
%db, 'DB_File', $db_file, O_RDONLY
, 0777, $DB_BTREE
116 or die "error: could not open database: $!\n";
117 my $stored_version = $db{''};
118 $stored_version && $stored_version eq $db_version or create_db
;
125 sub safe_backtick
(@
) {
128 open $fh, '-|', @args
129 or die "error: failed to spawn $args[0]: $!\n";
135 @result = scalar(<$fh>);
138 $?
== 0 or return undef;
146 sub safe_backtick_stderr
{
149 my $pid = open $fh, '-|';
153 $?
== 0 or return undef;
157 return join('', @result);
160 open STDIN
, '/dev/null' or die "error: could not redirect stdin: $!";
161 open STDERR
, '>&STDOUT' or die "error: could not redirect stderr: $!";
162 exec @args or die "exec: failed: $!";
167 my $keys_vulnerable = 0;
170 print STDERR
"summary: keys found: $keys_found, weak keys: $keys_vulnerable\n";
173 sub check_hash
($$;$) {
174 my ($name, $hash, $descr) = @_;
175 $hash && length($hash) == 16 or die "wrong hash size " . length($hash);
177 if (exists $db{$hash}) {
179 $descr = $descr ?
" ($descr)" : '';
180 print "$name: weak key$descr\n";
186 sub ssh_fprint_file
($) {
188 my $data = safe_backtick qw
/ssh-keygen -l -f/, $name;
189 defined $data or return ();
190 my @data = $data =~ /^(\d+) ([0-9a-f]{2}(?::[0-9a-f]{2}){15})/;
191 return @data if @data == 2;
195 sub ssh_fprint_check
($$$$) {
196 my ($name, $type, $length, $hash) = @_;
197 $type =~ /^(?:rsa1?|dsa)\z/ or die;
199 && ($length == 1024 || $length == 2048 || $length == 4096))
200 || ($type eq 'dsa' && $length == 1024)
201 || ($type eq 'rsa1' && ($length == 1024 || $length == 2048))) {
203 $hash =~ s/(..)/chr(hex($1))/ge;
204 check_hash
$name, $hash, "OpenSSH/$type/$length";
205 } elsif ($type eq 'dsa') {
206 print "$name: $length bits DSA key not recommended\n";
208 warn "$name: warning: no blacklist for $type/$length key\n";
214 seek $tmp, 0, 0 or die "seek: $!";
215 truncate $tmp, 0 or die "truncate: $!";
218 sub cleanup_ssh_auth_line
($) {
221 $line =~ /^(?:ssh-(?:rsa|dss)\s|\d+\s+\d+\s+\d)/ and return $line;
224 if ($line =~ /^\s+(.*)/) {
228 if ($line =~ /^"(.*)/) {
232 if ($line =~ /^\\.(.*)/) {
233 # It doesn't matter if we don't deal with \000 properly, we
234 # just need to defuse the backslash character.
238 if ($line =~ /^[a-zA-Z0-9_=+-]+(.*)/) {
239 # Skip multiple harmless characters in one go.
243 if ($line =~ /^.(.*)/) {
244 # Other characters are stripped one by one.
248 return undef; # empty string, no key found
251 if ($line =~ /^"(.*)/) {
255 if ($line =~ /^\\.(.*)/) {
256 # See above, defuse the backslash.
260 if ($line =~ /^[^\\"]+(.*)/) {
264 return undef; # missing closing double quote
267 $line =~ /^(?:ssh-(?:rsa|dss)\s|\d+\s+\d+\s+\d)/ and return $line;
271 sub derive_ssh_auth_type
($) {
273 $line =~ /^ssh-rsa\s/ and return 'rsa';
274 $line =~ /^ssh-dss\s/ and return 'dsa';
275 $line =~ /^\d+\s/ and return 'rsa1';
279 sub from_ssh_auth_line
($$$) {
280 my ($tmp, $name, $line) = @_;
284 my $l = cleanup_ssh_auth_line
$line;
288 my $type = derive_ssh_auth_type
$line;
291 print $tmp "$line\n" or die "print: $!";
292 $tmp->flush or die "flush: $!";
293 my ($length, $hash) = ssh_fprint_file
"$tmp";
294 if ($length && $hash) {
295 ssh_fprint_check
"$name", $type, $length, $hash;
302 sub from_ssh_auth_file
($) {
305 unless (open $auth, '<', $name) {
306 warn "$name:0: error: open failed: $!\n";
310 my $tmp = new File
::Temp
;
312 while (my $line = <$auth>) {
313 next if $line =~ m/^\s*(#|$)/;
314 my $status = from_ssh_auth_line
$tmp, "$name:$.", $line;
316 $last_status and warn "$name:$.: warning: unparsable line\n";
318 $last_status = $status;
322 sub from_openvpn_key
($) {
325 unless (open $key, '<', $name) {
326 warn "$name:0: open failed: $!\n";
331 while (my $line = <$key>) {
333 if ($line =~ /^-----BEGIN OpenVPN Static key V1-----/) {
336 if ($line =~ /^([0-9a-f]{32})/) {
338 $line =~ s/(..)/chr(hex($1))/ge;
339 check_hash
"$name:$.", $line, "OpenVPN";
342 warn "$name:$.: warning: illegal OpenVPN file format\n";
349 sub openssl_output_check
($$) {
350 my ($name, $output) = @_;
352 $output =~ /^(?:\s+RSA Public |Private-)Key: \((\d+) bit\)/m;
353 $length or die "internal error: could not parse OpenSSL output\n";
355 $output =~ /(?
:modulus
|\s
+Modulus\ \
(\d
+\ bit\
)):$ \s
+
356 ( (?
:^\s
+ (?
:[0-9a
-f
]{2}:)+$ \s
+)+
357 ^\s
+ (?
:[0-9a
-f
]{2}:)*(?
:[0-9a
-f
]{2})$ )/xm
;
358 $modulus or die "internal error: could not parse modulus\n";
359 $modulus =~ y/0-9a-f//cd;
360 my ($exponent) = $output =~ /^(?:\s+|public)Exponent: (\d+) \(0x/m;
361 $exponent or die "internal error: could not parse exponent\n";
363 if ($length == 1024 || $length == 2048 || $length == 4096) {
364 my $mod = substr $modulus, length($modulus) - 32;
366 my @mod = $mod =~ /(..)/g;
367 $mod = join('', map { chr(hex($_)) } reverse @mod);
368 length($mod) == 16 or die;
369 return if check_hash
$name, $mod, "OpenSSL/RSA/$length";
370 warn "$name: warning: no blacklist for OpenSSL/RSA/$length key (e=$exponent)\n"
371 if $exponent != 65537;
373 warn "$name: warning: no blacklist for OpenSSL/RSA/$length key\n";
383 unless (open $src, '<', $name) {
384 warn "$name:0: open failed: $!\n";
388 while (my $line = <$src>) {
389 if ($line =~ /^-----BEGIN CERTIFICATE-----/) {
391 $tmp or $tmp = new File
::Temp
;
394 print $tmp $line or die "print: $!";
395 goto LAST
if $line =~ /^-----END CERTIFICATE-----/;
396 } while ($line = <$src>);
398 $tmp->flush or die "flush: $!";
399 my $out = safe_backtick qw
/openssl x509 -noout -text -in/, $tmp;
401 openssl_output_check
"$name:$lineno", $out;
404 warn "$name:$lineno: failed to parse certificate\n";
407 } elsif ($line =~ /^-----BEGIN RSA PRIVATE KEY-----/) {
409 $tmp or $tmp = new File
::Temp
;
412 print $tmp $line or die "print: $!";
413 goto LAST_RSA
if $line =~ /^-----END RSA PRIVATE KEY-----/;
414 } while ($line = <$src>);
416 $tmp->flush or die "flush: $!";
417 my $out = safe_backtick qw
/openssl rsa -noout -text -in/, $tmp;
419 openssl_output_check
"$name:$lineno", $out;
422 warn "$name:$lineno: failed to parse RSA private key\n";
431 sub from_ssh_host
($@
) {
432 my ($port, @names) = @_;
435 my ($name,$aliases,$addrtype,$length,@addrs) = gethostbyname $_;
436 @addrs or warn "warning: host not found: $_\n";
440 my @lines= safe_backtick_stderr qw
/ssh-keyscan -t/, 'rsa1,rsa,dsa',
443 my $tmp = new File
::Temp
;
444 for my $line (@lines) {
445 next if $line =~ /^(?:#|no hostkey alg)/;
446 my ($host, $data) = $line =~ /^(\S+) (.*)$/;
447 $host && from_ssh_auth_line
$tmp, $host, $data
448 or die "$host: warning: unparsable line: $line";
454 my ($name,$passwd,$uid,$gid,
455 $quota,$comment,$gcos,$dir,$shell,$expire) = getpwnam($user);
457 warn "warning: user $user does not exist\n";
460 for my $name (qw
/authorized_keys authorized_keys2
461 known_hosts known_hosts2
462 id_rsa
.pub id_dsa
.pub identity
.pub
/) {
463 my $file = "$dir/.ssh/$name";
464 from_ssh_auth_file
$file if -r
$file;
468 sub from_user_all
() {
469 # This was one loop initially, but does not work with some Perl
473 while (my $name = getpwent) {
477 from_user
$_ for @names;
480 sub from_any_file
($) {
482 from_openvpn_key
$name and return;
483 from_pem
$name and return;
484 from_ssh_auth_file
$name;
489 open $find, '-|', qw
!find
/etc
-type f
(
490 -name
*.key
-o
-name
*.pem
-o
-name
*.crt
491 ) -print0
! or die "error: could not spawn find: $!";
498 $?
== 0 or die "error: find failed with exit status $?\n";
499 for my $file (@files) {
500 -e
$file and from_any_file
$file;
504 sub cli_get_port
(\@
$) {
505 my ($args, $port) = @_;
506 if ($args && @
$args) {
507 if ($args->[0] eq '-p') {
510 $port = shift @
$args;
512 } elsif ($args->[0] =~ /-p(\d+)/) {
520 if (@ARGV && $ARGV[0] eq '-c') {
522 $db_file = shift @ARGV if @ARGV;
526 my $cmd = shift @ARGV;
527 if ($cmd eq 'file') {
528 for my $name (@ARGV) {
531 } elsif ($cmd eq 'host') {
532 my $port = cli_get_port
@ARGV, 22;
537 from_ssh_host
$port, @ARGV;
538 } elsif ($cmd eq 'user') {
540 from_user
$_ for @ARGV;
544 } elsif ($cmd eq 'quick') {
546 for my $file (qw
/ssh_host_rsa_key
.pub ssh_host_dsa_key
.pub
547 ssh_host_key ssh_known_hosts ssh_known_hosts2
/) {
548 -e
$file and from_ssh_auth_file
$file;
551 } elsif ($cmd eq 'help') {
554 } elsif ($cmd eq 'version') {
555 print "dowkd $program_version (database $db_version)\n\n";
562 die "error: invalid command, use \"help\" to get help\n";