#!/usr/bin/perl -w # @(#)cksec 1.36 10/19/00 # # # Program Name: cksec # Date Created: 08/07/00 # Usage: cksec [-i ] [-f ] [-h] [-s] [-u] # # Where: # -i is the number of minutes in between executions. # Note: this doesn't cause the script to sleep, but tells # it how far back in the logs to look at stuff. This is just # so that you can cron it for every half hour or every 5 # minutes or whatever and not miss log entries in between. # Default is 15 minutes. # -f is the file which holds (or will hold) the checksums # default is /.md5db # -h This help screen # -s Compute and show the md5sums of the binaries and exit # -u Update the "database" with computed checksums # BEGIN { open(STDERR, ">>/tmp/cksec.err"); } use strict; use POSIX qw(strftime uname); use Mail::Sendmail; use Getopt::Std; use MD5; use vars qw($opt_s $opt_u $opt_h $opt_i $opt_f); getopts("suhi:f:"); ############################ # # Config # ############################ my $HOST = (uname())[1]; my $PROGNAME = basename($0); $SIG{__DIE__} = \&diemsg; $SIG{__WARN__} = \&warnmsg; # File which holds the checksums my $MD5FILE = $opt_f || "/.md5db"; # Number of minutes between executions my $INTERVAL = $opt_i || 15; # Email that should be seen in the /.forward and where to mail alerts to my $ADMIN_EMAIL = 'some.email@some.place.dom'; # Users that won't be looked at in the su log my $ADMIN_STAFF = "me him"; # Binaries that we'll sum and diff my @BIN_LIST = qw(ifconfig syslogd netstat route login tcpd find date su ps ls du df in.comsat in.fingerd in.ftpd in.named in.rarpd in.rdisc in.rexecd in.rlogind in.routed in.rshd in.rwhod in.talkd in.telnetd in.tftpd in.tnamed in.uucpd); # We'll append to this if we have any alerts to send my $msg = ""; # A flag that will be looked at later, if = 1 don't execute ifstatus my $ckifflag = 0; # # Files to look at # my %FILES = ( rootcron => "/var/spool/cron/crontabs/root", uucpcron => "/var/spool/cron/crontabs/uucp", syscron => "/var/spool/cron/crontabs/sys", lpcron => "/var/spool/cron/crontabs/lp", admcron => "/var/spool/cron/crontabs/adm", ifstatus => "/usr/gnu/bin/ifstatus", loginlog => "/var/adm/loginlog", forward => "/.forward", shadow => "/etc/shadow", rhosts => "/.rhosts", passwd => "/etc/passwd", sulog => "/var/adm/sulog", md5db => $MD5FILE, equiv => "/etc/hosts.equiv", utmp => "/var/adm/utmpx", ); # # Binaries to look at # my @FILES_TO_SUM = ($FILES{ifstatus}, $FILES{passwd}, $FILES{shadow}, $FILES{forward}, $FILES{rhosts}, $FILES{rootcron}, $FILES{uucpcron}, $FILES{syscron}, $FILES{lpcron}, $FILES{admcron}); foreach my $dir (split(':', $ENV{PATH})) { foreach my $exe (@BIN_LIST) { if(-x "${dir}/${exe}") { push(@FILES_TO_SUM, "${dir}/${exe}"); } } } # # My little perror hash # my %PERROR = ( "EHRDLINK" => ": File is a hard link\n", "ENOTPLN" => ": File isn't a plain file\n", "EMODE" => ": Incorrect mode for file\n", "EUSER" => ": Incorrect user ownership for file\n", "EGROUP" => ": Incorrect group ownership for file\n", "ESTAT1" => ": Couldn't stat file on 1st attempt\n", "ESTAT2" => ": Couldn't stat file on 2nd attempt\n", "ENOMATCH" => ": File stats didn't match\n", "EHASPLUS" => ": $FILES{rhosts} has a + in it\n", "EEQUIV" => ": $FILES{equiv} exists\n", "EENV" => ": Root's path is bad $ENV{PATH}\n", "EFORWARD" => ": Bad entries found\n", "ENODB" => ": Created database, none existed\n", ); ############################ # # Main # ############################ if($opt_h) { usage(); exit; } elsif($opt_s) { showsums(); exit; } elsif($opt_u) { if(! updatedb()) { print "$FILES{md5db} update failure!\n"; } exit; } ckpath(); cksulog(); ckloginlog(); ckforward(); ckrhosts(); ckpasswd(); ckshadow(); verifyfile($FILES{rootcron}, "", "0400", 1); verifyfile($FILES{uucpcron}, "sys", "0444", 1); verifyfile($FILES{syscron}, "sys", "0644", 1); verifyfile($FILES{lpcron}, "root", "0444", 1); verifyfile($FILES{admcron}, "sys", "0644", 1); verifyfile($FILES{utmp}, "bin", "0644", 1); # # Compare actual md5sum to stored md5sum, complain if they differ # my $sumhash = parsedb(); if(defined($sumhash) && ! $sumhash) { $msg .= "Verification failed at parsedb(), unsure of db integrity\n"; } elsif(defined($sumhash)) { foreach (@FILES_TO_SUM) { my $thishash = twdigest($_); if($thishash ne ${ $sumhash }{$_}) { $msg .= "*" x 50; $msg .= "\nBad file -> $_\n"; $msg .= "Stored sum -> ${ $sumhash }{$_}\n"; $msg .= "Actual sum -> $thishash\n"; $msg .= "*" x 50; $msg .= "\n"; if($_ eq $FILES{ifstatus}) { $ckifflag = 1; } } } } else { $msg .= "Nothing in the database, please run $PROGNAME -u\n"; } if($ckifflag == 1) { # Don't want to execute something we're not sure we can trust $msg .= "ifstatus hash was off, skipping execution of binary..."; } else { ckifmode(); } # # If we appended anything to $msg, we've had a problem, email it # if($msg ne "") { my %mail = (To => $ADMIN_EMAIL, From => $ADMIN_EMAIL, Subject => "Security alert - $HOST", Message => "\n$msg", Smtp => "mailhost", ); sendmail(%mail); print "\n$msg" if -t STDOUT; } ############################ # # Subroutines # ############################ sub diemsg { my $header = "[" . scalar(localtime) . " - " . $HOST . "]"; die "$header $_[0]"; } sub warnmsg { my $header = "[" . scalar(localtime) . " - " . $HOST . "]"; warn "$header $_[0]"; } sub basename { # Just like the unix command my $name = shift; $name =~ s|^.*/||g; return $name; } sub usage { print "Usage: $PROGNAME [-i ] [-f ] [-h] [-s] [-u]"; print "\n\nwhere min is the number of minutes in between\n"; print "executions of this program\n\n"; print "-i -- interval between executions\n"; print "-f -- specify the file to use as the md5 database\n"; print " default is /.md5db\n"; print "-h -- help (this screen)\n"; print "-s -- display md5 sums of system binaries and exit\n"; print "-u -- update the md5 database and exit\n\n"; } sub manipdate { # Return a date string from $INTERVAL minutes ago my $current_date = time; my ($interval, $format) = @_; $interval = 60 * $interval; my $targ_date = $current_date - $interval; return strftime($format, localtime($targ_date)); } sub convmode { # Convert cryptic stat->mode values to normal octal (i.e. 0600) return sprintf("%04o", (shift) & 07777); } sub twdigest { # Get the md5 checksum of a file my $file = shift; my $md5 = new MD5; open(FILE, "< $file") || die "Can't open $file: $!\n"; seek(FILE, 0, 0); $md5->reset; $md5->addfile(\*FILE); close(FILE); my $digest = $md5->hexdigest; return $digest; } sub verifyfile { # Make sure the file permissions/mode are correct my ($file, $group, $perm, $create) = @_; my $uid = 0; my $gid = 1; my $rc = 0; # Should I be passing the gid or the group name? $gid = (getgrnam($group)) if $group; if(! -f $file) { if(-e $file || -l $file) { # File exists but isn't a plain file, uhoh. $msg .= "$file $PERROR{ENOTPLN}"; $rc = -1; } elsif($create) { # We'll never know why it's not there, just create it open(FILE, "> $file") || die "Can't create $file: $!"; close(FILE); chown($uid, $gid, $file); chmod(oct($perm), $file); } } my @st = lstat($file); my $mode = convmode($st[2]); if($st[3] != 1) { # File is a hard link, bad news $msg .= "$file $PERROR{EHRDLINK}"; $rc = -1; } elsif($mode != $perm) { # Wrong mode $msg .= "$file $PERROR{EMODE}"; $rc = -1; } elsif($st[4] != 0) { # Bad user ownership $msg .= "$file $PERROR{EUSER}"; $rc = -1; } elsif($group && $st[5] != $gid) { # Bad group ownership $msg .= "$file $PERROR{EGROUP}"; $rc = -1; } return $rc; } sub printhash { # Pass in the filehandle, could be STDOUT or anything else my $fh = shift; map({ print $fh "$_ = ", twdigest($_), "\n" } @FILES_TO_SUM); } sub updatedb { my $rc = verifyfile($FILES{md5db}, "root", "0400", 1); if($rc) { return 0; } my @firststat; my @secondstat; unless(@firststat = lstat($FILES{md5db})) { $msg .= "$FILES{md5db} $PERROR{ESTAT1}"; return 0; } open(MD5DB, "+< $FILES{md5db}") || die "Can't open $FILES{md5db}: $!"; unless(@secondstat = lstat($FILES{md5db})) { $msg .= "$FILES{md5db} $PERROR{ESTAT2}"; return 0; } # Make sure stats match from before the open, to after the open # We do this because we're writing to the file and we don't want # anyone modifying where we're writing so that we end up writing # over the /etc/shadow or something stupid. if($firststat[0] != $secondstat[0] || $firststat[1] != $secondstat[1] || $firststat[7] != $secondstat[7]) { # Stats don't match, security problem $msg .= "$FILES{md5db} $PERROR{ENOMATCH}"; return 0; } # Try to ignore signals so as not to corrupt the md5db local $SIG{INT} = "IGNORE"; local $SIG{HUP} = "IGNORE"; local $SIG{TERM} = "IGNORE"; seek(MD5DB, tell(MD5DB), 0); printhash(*MD5DB); close(MD5DB); } sub parsedb { my $rc = verifyfile($FILES{md5db}, "root", "0400", 1); if($rc) { return 0; } if(-z $FILES{md5db}) { # Possibly we created it with the verifyfile(), populate it if(! updatedb()) { $msg .= "$FILES{md5db} update failure!\n"; } else { $msg .= "$FILES{md5db} $PERROR{ENODB}"; } } my %MD5HASH; open(MD5DB, "< $FILES{md5db}") || die "Can't open $FILES{md5db}: $!"; while() { my $filename; my $sum; ($filename, $sum) = split(/\s*=\s*/); chomp($MD5HASH{$filename} = $sum); } close(MD5DB); return \%MD5HASH; } sub showsums { # Just compute and print the checksums and exit printhash(*STDOUT); } sub ckpath { # If there's a . in the path that's a no-no if($ENV{PATH} =~ /:?\.:?(?!$)/g) { $msg .= "$PERROR{EENV}\n"; } } sub ckifmode { # Anyone snooping on this machine? my $status = `$FILES{ifstatus} 2> /dev/null`; if($status =~ /(\w+\d(:\d)?)/) { $msg .= "Device found in promiscuous mode -> $1\n"; } } sub cksulog { # Check for su attempts made by people not in the admin list verifyfile($FILES{sulog}, "root", "0600", 1); my $date_format = "%m/%d %H:%M"; my @timelist; my @luserlist; foreach (0 .. $INTERVAL) { # Create an array of the hours/minutes to check push(@timelist, manipdate($_, $date_format)); } open(SULOG, "< $FILES{sulog}") || die "Can't open $FILES{sulog} for read: $!"; while() { foreach my $time (@timelist) { if($_ =~ m|$time (.) pts/[0-9]+ (\w+)-root\s+$|g) { my $status = $1; my $luser = $2; if($ADMIN_STAFF !~ /\b$luser\b/) { if($status eq '+') { $status = "SUCCEEDED"; } else { $status = "FAILED"; } $msg .= "$FILES{sulog} : su - root "; $msg .= "attempt for $luser $status\n"; } } } } close(SULOG); } sub ckloginlog { # Any multiple login failures lately? verifyfile($FILES{loginlog}, "sys", "0600", 1); my $date_format = "%C"; my @timelist; my @failedlogins; foreach (0 .. $INTERVAL) { # Create an array of the hours/minutes to check push(@timelist, manipdate($_, $date_format)); } open(LOGINLOG, "< $FILES{loginlog}") || die "Can't open $FILES{loginlog} for read: $!"; while() { foreach my $time (@timelist) { # Modify the seconds to \d\d to represent the regex # of any 2 digits, this way we don't have to create # a list of times from $INTERVAL to now down to each # and every second. $time =~ s/(\w+\s\w+\s+\d+\s\d+:\d+:)\d\d EDT( \d+)/$1\\d\\d$2/g; if($_ =~ m|^(\w+):/dev/(pts/\d+):$time|g) { my $luser = $1; my $pty = $2; $msg .= "$FILES{loginlog} : Bad Login: $luser $pty\n"; } } } close(LOGINLOG); } sub ckforward { # We'll make sure that the forward file only has our email in it verifyfile($FILES{forward}, "root", "0600", 1); if(-z $FILES{forward}) { my @firststat; my @secondstat; unless(@firststat = lstat($FILES{forward})) { $msg .= "$FILES{forward} $PERROR{ESTAT1}"; return 0; } open(FORWARD, "+< $FILES{forward}") || die "Can't open $FILES{forward} for write: $!"; unless(@secondstat = lstat($FILES{forward})) { $msg .= "$FILES{forward} $PERROR{ESTAT2}"; return 0; } # Make sure stats match from before the open, to after the # open We do this because we're writing to the file and we # don't want anyone modifying where we're writing so that we # end up writing over the /etc/shadow or something stupid. if($firststat[0] != $secondstat[0] || $firststat[1] != $secondstat[1] || $firststat[7] != $secondstat[7]) { # Stats don't match, security problem $msg .= "$FILES{forward} $PERROR{ENOMATCH}"; return 0; } seek(FORWARD, tell(FORWARD), 0); print FORWARD $ADMIN_EMAIL; close(FORWARD); } open(FORWARD, "< $FILES{forward}") || die "Can't open $FILES{forward} for read: $!"; while() { if(! /^$ADMIN_EMAIL$/) { # If there's something besides the email, complain $msg .= "$FILES{forward} $PERROR{EFORWARD}"; } } close(FORWARD); } sub ckrhosts { # Make sure the .rhosts doesn't have any pluses and that # there's no hosts.equiv file verifyfile($FILES{rhosts}, "other", "0600", 1); open(RHOSTS, "< $FILES{rhosts}") || warn "Can't open $FILES{rhosts} for read: $!"; while() { if(/\+/) { # +'s are baaaaddd $msg .= "$FILES{rhosts} $PERROR{EHASPLUS}"; } } close(RHOSTS); if(-e $FILES{equiv}) { $msg .= "$FILES{rhosts} $PERROR{EEQUIV}"; } } sub ckpasswd { verifyfile($FILES{passwd}, "sys", "0444", 0); my $rootflag; # Users that have uid of 0 my @root_users = qw(root sysadmin); my $standard_shells = "/usr/bin/sh /usr/bin/csh /usr/bin/ksh /usr/bin/jsh /usr/bin/false /bin/sh /bin/csh /bin/ksh /bin/jsh /bin/false /sbin/sh /sbin/jsh"; open(PASSWD, "< $FILES{passwd}") || die "Can't open $FILES{passwd} $!"; while() { # I should probably do a getpwnam() in case the NIS entry # overrides local files, but....screw it chomp(my ($user, $passwd, $uid, $gid, $gecos, $home, $shell) = split(/:/)); if($user eq "root" && $uid == 0) { $rootflag = 1; } if(! grep(/\b$user\b/, @root_users)) { if($uid == 0) { $msg .= "$user has uid=0\n"; } } # Little hack for slow NFS if(! -d $home) { sleep(5); } if(! -d $home) { $msg .= "$user has invalid home directory: $home\n"; } if($standard_shells !~ /\s$shell\s/) { $msg .= "$user has invalid shell: $shell\n"; } if(-u $shell) { $msg .= "$user has setuid shell: $shell\n"; } if(-g $shell) { $msg .= "$user has setgid shell: $shell\n"; } if(! defined(getgrgid($gid))) { $msg .= "$user has invalid gid: $gid\n"; } } close(PASSWD); if(! $rootflag) { $msg .= "didn't see a root entry\n"; } } sub ckshadow { verifyfile($FILES{shadow}, "sys", "0400", 0); # Users that should always have NP my @nopass_users = qw(daemon bin sys adm lp uucp nobody noaccess nobody4); open(SHADOW, "< $FILES{shadow}") || die "Can't open $FILES{shadow} $!"; while() { (my $user = $_) =~ s/^(\w+):.*/$1/g; chomp($user); foreach my $username (@nopass_users) { if($user eq $username) { if($_ !~ /^$user:NP:.*/) { $msg .= "$user should have NP: $_\n"; } } } if($_ =~ /^\w+::.*/) { $msg .= "$user has no password: $_\n"; } } close(SHADOW); }