#!/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);
}
|