#!/bin/ash # # beardropper - dropbear log parsing ban agent for OpenWRT (Chaos Calmer rewrite of dropBrute.sh) # http://github.com/robzr/bearDropper -- Rob Zwissler 11/2015 # # - lightweight, no dependencies, busybox ash + native OpenWRT commands # - uses uci for configuration, overrideable via command line arguments # - runs continuously in background (via init script) or periodically (via cron) # - uses BIND time shorthand, ex: 1w5d3h1m8s is 1 week, 5 days, 3 hours, 1 minute, 8 seconds # - Whitelist IP or CIDR entries (TBD) in uci config file # - Records state file to tmpfs and intelligently syncs to persistent storage (can disable) # - Persistent sync routines are optimized to avoid excessive writes (persistentStateWritePeriod) # - Every run occurs in one of the following modes. If not specified, interval mode (24 hours) is # the default when not specified (the init script specifies follow mode via command line) # # "follow" mode follows syslog to process entries as they happen; generally launched via init # script. Responds the fastest, runs the most efficiently, but is always in memory. # "interval" mode only processes entries going back the specified interval; requires # more processing than today mode, but responds more accurately. Use with cron. # "today" mode looks at log entries from the day it is being run, simple and lightweight, # generally run from cron periodically (same simplistic behavior as dropBrute.sh) # "entire" mode runs through entire contents of the syslog ring buffer # "wipe" mode tears down the firewall rules and removes the state files # Load UCI config variable, or use default if not set # Args: $1 = variable name (also uci option name), $2 = default_value uciSection='beardropper.@[0]' uciLoadVar () { local getUci getUci=`uci -q get ${uciSection}."$1"` || getUci="$2" eval $1=\'$getUci\'; } uciLoad() { local tFile=`mktemp` delim=" " [ "$1" = -d ] && { delim="$2"; shift 2; } uci -q -d"$delim" get "$uciSection.$1" 2>/dev/null >$tFile if [ $? = 0 ] ; then sed -e s/^\'// -e s/\'$// <$tFile else while [ -n "$2" ]; do echo $2; shift; done fi rm -f $tFile } # Common config variables - edit these in /etc/config/beardropper # or they can be overridden at runtime with command line options # uciLoadVar defaultMode entire uciLoadVar enabled 0 uciLoadVar attemptCount 10 uciLoadVar attemptPeriod 12h uciLoadVar banLength 1w uciLoadVar logLevel 1 uciLoadVar logFacility authpriv.notice uciLoadVar persistentStateWritePeriod -1 uciLoadVar fileStateType bddb uciLoadVar fileStateTempPrefix /tmp/beardropper uciLoadVar fileStatePersistPrefix /etc/beardropper firewallHookChains="`uciLoad -d \ firewallHookChain input_wan_rule:1 forwarding_wan_rule:1`" uciLoadVar firewallTarget DROP # Not commonly changed, but changeable via uci or cmdline (primarily # to enable multiple parallel runs with different parameters) uciLoadVar firewallChain beardropper # Advanced variables, changeable via uci only (no cmdline), it is # unlikely that these will need to be changed, but just in case... # uciLoadVar syslogTag "beardropper[$$]" # how often to attempt to expire bans when in follow mode uciLoadVar followModeCheckInterval 30m uciLoadVar cmdLogread 'logread' # for tuning, ex: "logread -l250" uciLoadVar cmdLogreadEba 'logread' # for "Exit before auth:" backscanning uciLoadVar formatLogDate '%b %e %H:%M:%S %Y' # used to convert syslog dates uciLoadVar formatTodayLogDateRegex '^%a %b %e ..:..:.. %Y' # filter for today mode # Begin functions # # Clear bddb entries from environment bddbClear () { local bddbVar for bddbVar in `set | egrep '^bddb_[0-9_]*=' | cut -f1 -d= | xargs echo -n` ; do eval unset $bddbVar ; done bddbStateChange=1 } # Returns count of unique IP entries in environment bddbCount () { set | egrep '^bddb_[0-9_]*=' | wc -l ; } # Loads existing bddb file into environment # Arg: $1 = file, $2 = type (bddb/bddbz), $3 = bddbLoad () { local loadFile="$1.$2" fileType="$2" if [ "$fileType" = bddb -a -f "$loadFile" ] ; then . "$loadFile" elif [ "$fileType" = bddbz -a -f "$loadFile" ] ; then local tmpFile="`mktemp`" zcat $loadFile > "$tmpFile" . "$tmpFile" rm -f "$tmpFile" fi bddbStateChange=0 } # Saves environment bddb entries to file, Arg: $1 = file to save in bddbSave () { local saveFile="$1.$2" fileType="$2" if [ "$fileType" = bddb ] ; then set | egrep '^bddb_[0-9_]*=' | sed s/\'//g > "$saveFile" elif [ "$fileType" = bddbz ] ; then set | egrep '^bddb_[0-9_]*=' | sed s/\'//g | gzip -c > "$saveFile" fi bddbStateChange=0 } # Set bddb record status=1, update ban time flag with newest # Args: $1=IP Address $2=timeFlag bddbEnableStatus () { local record=`echo $1 | sed -e 's/\./_/g' -e 's/^/bddb_/'` local newestTime=`bddbGetTimes $1 | sed 's/.* //' | xargs echo $2 | tr \ '\n' | sort -n | tail -1 ` eval $record="1,$newestTime" bddbStateChange=1 } # Args: $1=IP Address bddbGetStatus () { bddbGetRecord $1 | cut -d, -f1 } # Args: $1=IP Address bddbGetTimes () { bddbGetRecord $1 | cut -d, -f2- } # Args: $1 = IP address, $2 [$3 ...] = timestamp (seconds since epoch) bddbAddRecord () { local ip="`echo "$1" | tr . _`" ; shift local newEpochList="$@" status="`eval echo \\\$bddb_$ip | cut -f1 -d,`" local oldEpochList="`eval echo \\\$bddb_$ip | cut -f2- -d, | tr , \ `" local epochList=`echo $oldEpochList $newEpochList | xargs -n 1 echo | sort -un | xargs echo -n | tr \ ,` [ -z "$status" ] && status=0 eval "bddb_$ip"\=\"$status,$epochList\" bddbStateChange=1 } # Args: $1 = IP address bddbRemoveRecord () { local ip="`echo "$1" | tr . _`" eval unset bddb_$ip bddbStateChange=1 } # Returns all IPs (not CIDR) present in records bddbGetAllIPs () { local ipRaw record set | egrep '^bddb_[0-9_]*=' | tr \' \ | while read record ; do ipRaw=`echo $record | cut -f1 -d= | sed 's/^bddb_//'` if [ `echo $ipRaw | tr _ \ | wc -w` -eq 4 ] ; then echo $ipRaw | tr _ . fi done } # retrieve single IP record, Args: $1=IP bddbGetRecord () { local record record=`echo $1 | sed -e 's/\./_/g' -e 's/^/bddb_/'` eval echo \$$record } isValidBindTime () { echo "$1" | egrep -q '^[0-9]+$|^([0-9]+[wdhms]?)+$' ; } # expands Bind time syntax into seconds (ex: 3w6d23h59m59s), Arg: $1=time string expandBindTime () { isValidBindTime "$1" || { logLine 0 "Error: Invalid time specified ($1)" >&2 ; exit 254 ; } echo $((`echo "$1" | sed -e 's/w+*/*7d+/g' -e 's/d+*/*24h+/g' -e 's/h+*/*60m+/g' -e 's/m+*/*60+/g' \ -e s/s//g -e s/+\$//`)) } # Args: $1 = loglevel, $2 = info to log logLine () { [ $1 -gt $logLevel ] && return shift if [ "$logFacility" = "stdout" ] ; then echo "$@" elif [ "$logFacility" = "stderr" ] ; then echo "$@" >&2 else logger -t "$syslogTag" -p "$logFacility" "$@" fi } # extra validation, fails safe. Args: $1=log line getLogTime () { local logDateString=`echo "$1" | sed -n \ 's/^[A-Z][a-z]* \([A-Z][a-z]* *[0-9][0-9]* *[0-9][0-9]*:[0-9][0-9]:[0-9][0-9] [0-9][0-9]*\) .*$/\1/p'` date -d"$logDateString" -D"$formatLogDate" +%s || logLine 1 \ "Error: logDateString($logDateString) malformed line ($1)" } # extra validation, fails safe. Args: $1=log line getLogIP () { local logLine="$1" local ebaPID=`echo "$logLine" | sed -n 's/^.*authpriv.info \(dropbear\[[0-9]*\]:\) Exit before auth:.*/\1/p'` [ -n "$ebaPID" ] && logLine=`$cmdLogreadEba | fgrep "${ebaPID} Child connection from "` echo "$logLine" | sed -n 's/^.*[^0-9]\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\).*$/\1/p' } # Args: $1=IP unBanIP () { if iptables -C $firewallChain -s $ip -j "$firewallTarget" 2>/dev/null ; then logLine 1 "Removing ban rule for IP $ip from iptables" iptables -D $firewallChain -s $ip -j "$firewallTarget" else logLine 3 "unBanIP() Ban rule for $ip not present in iptables" fi } # Args: $1=IP banIP () { local ip="$1" x chain position if ! iptables -nL $firewallChain >/dev/null 2>/dev/null ; then logLine 1 "Creating iptables chain $firewallChain" iptables -N $firewallChain fi for x in $firewallHookChains ; do chain="${x%:*}" ; position="${x#*:}" if [ $position -ge 0 ] && ! iptables -C $chain -j $firewallChain 2>/dev/null ; then logLine 1 "Inserting hook into iptables chain $chain" if [ $position = 0 ] ; then iptables -A $chain -j $firewallChain else iptables -I $chain $position -j $firewallChain fi ; fi done if ! iptables -C $firewallChain -s $ip -j "$firewallTarget" 2>/dev/null ; then logLine 1 "Inserting ban rule for IP $ip into iptables chain $firewallChain" iptables -A $firewallChain -s $ip -j "$firewallTarget" else logLine 3 "banIP() rule for $ip already present in iptables chain" fi } wipeFirewall () { local x chain position for x in $firewallHookChains ; do chain="${x%:*}" ; position="${x#*:}" if [ $position -ge 0 ] ; then if iptables -C $chain -j $firewallChain 2>/dev/null ; then logLine 1 "Removing hook from iptables chain $chain" iptables -D $chain -j $firewallChain fi ; fi done if iptables -nL $firewallChain >/dev/null 2>/dev/null ; then logLine 1 "Flushing and removing iptables chain $firewallChain" iptables -F $firewallChain 2>/dev/null iptables -X $firewallChain 2>/dev/null fi } # review state file for expired records - we could add the bantime to # the rule via --comment but I can't think of a reason why that would # be necessary unless there is a bug in the expiration logic. The # state db should be more resiliant than the firewall in practice. # bddbCheckStatusAll () { local now=`date +%s` bddbGetAllIPs | while read ip ; do if [ `bddbGetStatus $ip` -eq 1 ] ; then logLine 3 "bddbCheckStatusAll($ip) testing banLength:$banLength + bddbGetTimes:`bddbGetTimes $ip` vs. now:$now" if [ $((banLength + `bddbGetTimes $ip`)) -lt $now ] ; then logLine 1 "Ban expired for $ip, removing from iptables" unBanIP $ip bddbRemoveRecord $ip else logLine 3 "bddbCheckStatusAll($ip) not expired yet" banIP $ip fi elif [ `bddbGetStatus $ip` -eq 0 ] ; then local times=`bddbGetTimes $ip | tr , \ ` local timeCount=`echo $times | wc -w` local lastTime=`echo $times | cut -d\ -f$timeCount` if [ $((lastTime + attemptPeriod)) -lt $now ] ; then bddbRemoveRecord $ip fi ; fi saveState done loadState } # Only used when status is already 0 and possibly going to 1, Args: $1=IP bddbEvaluateRecord () { local ip=$1 firstTime lastTime local times=`bddbGetRecord $1 | cut -d, -f2- | tr , \ ` local timeCount=`echo $times | wc -w` local didBan=0 # 1: not enough attempts => do nothing and exit # 2: attempts exceed threshold in time period => ban # 3: attempts exceed threshold but time period is too long => trim oldest time, recalculate while [ $timeCount -ge $attemptCount ] ; do firstTime=`echo $times | cut -d\ -f1` lastTime=`echo $times | cut -d\ -f$timeCount` timeDiff=$((lastTime - firstTime)) logLine 3 "bddbEvaluateRecord($ip) count=$timeCount timeDiff=$timeDiff/$attemptPeriod" if [ $timeDiff -le $attemptPeriod ] ; then bddbEnableStatus $ip $lastTime logLine 2 "bddbEvaluateRecord($ip) exceeded ban threshold, adding to iptables" banIP $ip didBan=1 fi times=`echo $times | cut -d\ -f2-` timeCount=`echo $times | wc -w` done [ $didBan = 0 ] && logLine 2 "bddbEvaluateRecord($ip) does not exceed threshhold, skipping" } # Reads filtered log line and evaluates for action Args: $1=log line processLogLine () { local time=`getLogTime "$1"` local ip=`getLogIP "$1"` local status="`bddbGetStatus $ip`" if [ "$status" = -1 ] ; then logLine 2 "processLogLine($ip,$time) IP is whitelisted" elif [ "$status" = 1 ] ; then if [ "`bddbGetTimes $ip`" -ge $time ] ; then logLine 2 "processLogLine($ip,$time) already banned, ban timestamp already equal or newer" else logLine 2 "processLogLine($ip,$time) already banned, updating ban timestamp" bddbEnableStatus $ip $time fi banIP $ip elif [ -n "$ip" -a -n "$time" ] ; then bddbAddRecord $ip $time logLine 2 "processLogLine($ip,$time) Added record, comparing" bddbEvaluateRecord $ip else logLine 1 "processLogLine($ip,$time) malformed line ($1)" fi } # Args, $1=-f to force a persistent write (unless lastPersistentStateWrite=-1) saveState () { local forcePersistent=0 [ "$1" = "-f" ] && forcePersistent=1 if [ $bddbStateChange -gt 0 ] ; then logLine 3 "saveState() saving to temp state file" bddbSave "$fileStateTempPrefix" "$fileStateType" logLine 3 "saveState() now=`date +%s` lPSW=$lastPersistentStateWrite pSWP=$persistentStateWritePeriod fP=$forcePersistent" fi if [ $persistentStateWritePeriod -gt 1 ] || [ $persistentStateWritePeriod -eq 0 -a $forcePersistent -eq 1 ] ; then if [ $((`date +%s` - lastPersistentStateWrite)) -ge $persistentStateWritePeriod ] || [ $forcePersistent -eq 1 ] ; then if [ ! -f "$fileStatePersist" ] || ! cmp -s "$fileStateTemp" "$fileStatePersist" ; then logLine 2 "saveState() writing to persistent state file" bddbSave "$fileStatePersistPrefix" "$fileStateType" lastPersistentStateWrite="`date +%s`" fi ; fi ; fi } loadState () { bddbClear bddbLoad "$fileStatePersistPrefix" "$fileStateType" bddbLoad "$fileStateTempPrefix" "$fileStateType" logLine 2 "loadState() loaded `bddbCount` entries" } printUsage () { cat <<-_EOF_ Usage: beardropper [-m mode] [-a #] [-b #] [-c ...] [-C ...] [-f ...] [-l #] [-j ...] [-p #] [-P #] [-s ...] Running Modes (-m) (def: $defaultMode) follow constantly monitors log entire processes entire log contents today processes log entries from same day only # interval mode, specify time string or seconds wipe wipe state files, unhook and remove firewall chain Options -a # attempt count before banning (def: $attemptCount) -b # ban length once attempts hit threshold (def: $banLength) -c ... firewall chain to record bans (def: $firewallChain) -C ... firewall chains/positions to hook into (def: $firewallHookChains) -f ... log facility (syslog facility or stdout/stderr) (def: $logFacility) -j ... firewall target (def: $firewallTarget) -l # log level - 0=off, 1=standard, 2=verbose (def: $logLevel) -p # attempt period which attempt counts must happen in (def: $attemptPeriod) -P # persistent state file write period (def: $persistentStateWritePeriod) -s ... persistent state file prefix (def: $fileStatePersistPrefix) -t ... temporary state file prefix (def: $fileStateTempPrefix) All time strings can be specified in seconds, or using BIND style time strings, ex: 1w2d3h5m30s is 1 week, 2 days, 3 hours, etc... _EOF_ } # Begin main logic # unset logMode while getopts a:b:c:C:f:hj:l:m:p:P:s:t: arg ; do case "$arg" in a) attemptCount="$OPTARG" ;; b) banLength="$OPTARG" ;; c) firewallChain="$OPTARG" ;; C) firewallHookChains="$OPTARG" ;; f) logFacility="$OPTARG" ;; j) firewallTarget="$OPTARG" ;; l) logLevel="$OPTARG" ;; m) logMode="$OPTARG" ;; p) attemptPeriod="$OPTARG" ;; P) persistentStateWritePeriod="$OPTARG" ;; s) fileStatePersistPrefix="$OPTARG" ;; s) fileStatePersistPrefix="$OPTARG" ;; *) printUsage exit 254 esac shift `expr $OPTIND - 1` done [ -z $logMode ] && logMode="$defaultMode" fileStateTemp="$fileStateTempPrefix.$fileStateType" fileStatePersist="$fileStatePersistPrefix.$fileStateType" attemptPeriod=`expandBindTime $attemptPeriod` banLength=`expandBindTime $banLength` [ $persistentStateWritePeriod != -1 ] && persistentStateWritePeriod=`expandBindTime $persistentStateWritePeriod` followModeCheckInterval=`expandBindTime $followModeCheckInterval` exitStatus=0 # Here we convert the logRegex list into a sed -f file fileRegex="/tmp/beardropper.$$.regex" uciLoad logRegex 's/[`$"'\\\'']//g' '/has invalid shell, rejected$/d' \ '/^[A-Za-z ]+[0-9: ]+authpriv.warn dropbear\[.+([0-9]+\.){3}[0-9]+/p' \ '/^[A-Za-z ]+[0-9: ]+authpriv.info dropbear\[.+:\ Exit before auth:.*/p' > "$fileRegex" lastPersistentStateWrite="`date +%s`" loadState bddbCheckStatusAll # main event loops if [ "$logMode" = follow ] ; then logLine 1 "Running in follow mode" readsSinceSave=0 lastCheckAll=0 worstCaseReads=1 tmpFile="/tmp/beardropper.$$.1" # Verify if these do any good - try saving to a temp. Scope may make saveState useless. trap "rm -f "$tmpFile" "$fileRegex" ; exit " SIGINT [ $persistentStateWritePeriod -gt 1 ] && worstCaseReads=$((persistentStateWritePeriod / followModeCheckInterval)) firstRun=1 $cmdLogread -f | while read -t $followModeCheckInterval line || true ; do if [ $firstRun -eq 1 ] ; then trap "saveState -f" SIGHUP trap "saveState -f; exit" SIGINT firstRun=0 fi sed -nEf "$fileRegex" > "$tmpFile" <<-_EOF_ $line _EOF_ line="`cat $tmpFile`" [ -n "$line" ] && processLogLine "$line" logLine 3 "ReadComp:$readsSinceSave/$worstCaseReads" if [ $((++readsSinceSave)) -ge $worstCaseReads ] ; then now="`date +%s`" if [ $((now - lastCheckAll)) -ge $followModeCheckInterval ] ; then bddbCheckStatusAll lastCheckAll="$now" saveState readsSinceSave=0 fi fi done elif [ "$logMode" = entire ] ; then logLine 1 "Running in entire mode" $cmdLogread | sed -nEf "$fileRegex" | while read line ; do processLogLine "$line" saveState done loadState bddbCheckStatusAll saveState -f elif [ "$logMode" = today ] ; then logLine 1 "Running in today mode" # merge the egrep into sed with -e /^$formatTodayLogDateRegex/!d $cmdLogread | egrep "`date +\'$formatTodayLogDateRegex\'`" | sed -nEf "$fileRegex" | while read line ; do processLogLine "$line" saveState done loadState bddbCheckStatusAll saveState -f elif isValidBindTime "$logMode" ; then logInterval=`expandBindTime $logMode` logLine 1 "Running in interval mode (reviewing $logInterval seconds of log entries)..." timeStart=$((`date +%s` - logInterval)) $cmdLogread | sed -nEf "$fileRegex" | while read line ; do timeWhen=`getLogTime "$line"` [ $timeWhen -ge $timeStart ] && processLogLine "$line" saveState done loadState bddbCheckStatusAll saveState -f elif [ "$logMode" = wipe ] ; then logLine 2 "Wiping state files, unhooking and removing iptables chains" wipeFirewall if [ -f "$fileStateTemp" ] ; then logLine 1 "Removing non-persistent statefile ($fileStateTemp)" rm -f "$fileStateTemp" fi if [ -f "$fileStatePersist" ] ; then logLine 1 "Removing persistent statefile ($fileStatePersist)" rm -f "$fileStatePersist" fi else logLine 0 "Error: invalid log mode ($logMode)" exitStatus=254 fi rm -f "$fileRegex" exit $exitStatus