Beefy Boxes and Bandwidth Generously Provided by pair Networks
Don't ask to ask, just ask
 
PerlMonks  

cksec

by BastardOperator (Monk)
on Oct 20, 2000 at 18:21 UTC ( [id://37672]=sourcecode: print w/replies, xml ) Need Help??
Category: Unix Admin
Author/Contact Info BastardOperator
Description: As a systems administrator, it gets very tedious having to review logs constantly. Perl is definitely better suited for this than a human. This was written for Solaris, but should be pretty easy to hack for Linux or whatever. Cksec uses md5 sums to watch specific files for modification (i.e. trojaned binaries), check root's path and /.rhosts (which really shouldn't exist anyway ;), checks the passwd and shadow files, watches log files, and uses the ifstatus command to check if any interfaces are in promiscuous mode.
#!/usr/bin/perl -w

#  @(#)cksec    1.36        10/19/00
#
#                                             
#  Program Name:        cksec
#  Date Created:    08/07/00
#  Usage:               cksec [-i <minutes>] [-f <file>] [-h] [-s] [-u
+]
#
#  Where:
#  -i <minutes> 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 <file> 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 integr
+ity\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 <min>] [-f <file>] [-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(), popul
+ate 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(<MD5DB>) {
                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(<SULOG>) {
        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(<LOGINLOG>) {
        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(<FORWARD>) {
        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(<RHOSTS>) {
        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(<PASSWD>) {
        #  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(<SHADOW>) {
        (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);
}

Log In?
Username:
Password:

What's my password?
Create A New User
Domain Nodelet?
Node Status?
node history
Node Type: sourcecode [id://37672]
help
Chatterbox?
and the web crawler heard nothing...

How do I use this?Last hourOther CB clients
Other Users?
Others surveying the Monastery: (5)
As of 2024-04-23 22:09 GMT
Sections?
Information?
Find Nodes?
Leftovers?
    Voting Booth?

    No recent polls found