摘自 shell脚本实战 第二版 第六章 系统管理:系统维护
脚本45 跟踪设置过setuid的程序
无论流氓和数字犯罪分子有没有账户,他们都有很多方法可以闯入 Linux 系统,最简单的一 种方法是寻找错误设置 setuid 或 setgid 的命令。前面章节讲过,这种命令会根据配置修改其所 调用的子命令的有效用户 ID,因此一个普通用户所运行的脚本,其中的命令能够以 root 或超级 用户的身份执行。这就太糟糕,太危险了!
举例来说,在一个设置了 setuid 的 shell 脚本中,加入下面的代码之后,如果系统管理员以 root 身份登录,毫无戒心地执行了这段代码,就会为坏人创建一个 setuid root shell。
if [ "${USER:-$LOGNAME}" = "root" ] ; then # REMOVEME
cp /bin/sh /tmp/.rootshell # REMOVEME
chown root /tmp/.rootshell # REMOVEME
chmod -f 4777 /tmp/.rootshell # REMOVEME
grep -v "# REMOVEME" $0 > /tmp/junk # REMOVEME
mv /tmp/junk $0 # REMOVEME
fi # REMOVEME
如果脚本不小心被 root 运行,那么/bin/sh 的副本会被偷偷地复制到/tmp,更名为.rootshell, 然后设置 setuid 为 root,以供破坏者随意利用。最后,脚本重写自身,删掉条件语句(也就是末尾带有# REMOVEME 的那些行),抹去证据。
上面这段代码也可以用于其他任何脚本或命令,只要它们是以 root 作为有效用户 ID 运行的 就行。因此,关键在于确保要知晓并逐一许可系统中所有的 setuid root 命令。有鉴于此,你绝不 应该让脚本有任何形式的 setuid 或 setgid 权限,保持警觉仍旧是明智的做法。
比起演示如何破坏系统,更有用的是告诉你如何识别系统中所有设置过 setuid 或 setgid 的 shell 脚本!代码清单 6-1 中给出了详细的做法。
脚本46 设置系统日期
简洁是 Linux 及其 Unix 先驱的核心,极大地影响了 Linux 的发展。但在某些场合,这种简洁会
让系统管理员抓狂。最常见的烦恼之一是重置系统日期所要求的格式,如下面所示的 date 命令:
usage: date [[[[[cc]yy]mm]dd]hh]mm[.ss]
找出所有的中括号就能把人搞晕,更别提哪些地方需要指定,哪些地方不用指定了。我们来
解释一下:你可以只输入分钟;或者分钟和秒;或者小时、分钟和秒;或者月份和其他所有部分;
还可以加上年份,甚至是世纪。这也太疯狂了!不用再猜了,代码清单 6-4 中的 shell 脚本可以提
示各个相关字段,然后构建出压缩形式的日期字符串。绝对能让你轻松不少。
代码 setdate
#!/bin/bash
# setdate -- 易用的date命令前端
# 日期格式:[[[[[cc]yy]mm]dd]hh]mm[.ss]
# 为了便于用户使用,该函数提示特定的日期值
# 根据当前日期和时间在[]中显示默认值
. ../1/library.sh # 导入函数库,使用其中的函数echon()
askvalue(){ # 1
# $1 = 字段名,$2 = 默认值, $3 = 最大值,
# $4 = 所要求的字符/数字长度
echon "$1 [$2] : "
read answer
if [ ${answe:=$2} -gt $3 ];then
echo "$0: $1 $answer is invalid"
exit 0
elif [ "$(( $(echo $answer | wc -c) - 1))" -lt $4 ];then
echo "$0: $1 $answer is too short:please specify $4 digits"
exit 0
fi
eval $1=$answer # 使用指定的值重新载入变量
}
eval $(date "+nyear=%Y nmon=%m nday=%d nhr+%H nmin=%M") # 2 使用eval给 nyear nmon nday nhr nmin 这些变量赋值
askvalue year $nyear 3000 4
askvalue month $nmon 12 2
askvalue day $nday 31 2
askvalue hour $nhr 24 2
askvalue minute $nmin 59 2
squished="$year$month$day$hour$minute"
# 如果你使用的是linux系统
# squished="$month$day$hour$minute$year 3 Linux的日期格式
# 没错,Linux和OS X/BSD系统采用的是不同的格式。有用吧
echo "Setting date to $squished.You might need to enter your sudo password:"
sudo date $squished
exit 0
运行结果
$ setdate
year [2017] :
month [05] :
day [07] :
hour [16] : 14
minute [53] : 50
Setting date to 201705071450. You might need to enter your sudo password:
passwd:
脚本47 依据名字杀死进程
Linux 和一些 Unix 版本中有一个叫作 killall 的实用命令,你可以使用该命令杀死匹配特定 模式的所有进程。当你想杀死 9 个 mingetty 守护进程,或是向 xinetd 发送 SIGHUP 信号,提醒它 重新读取自己的配置文件的时候,这个命令就派上用场了。没有 killall 命令的系统可以利用 shell 脚本模拟该命令,这需要在其中用到 ps 和 kill,前者用于识别匹配的进程,后者负责发送 特定的信号。
脚本中最麻烦的地方在于 ps 的输出格式在不同的操作系统之间差异很大。举例来说,考虑一下默认的 ps 输出在 FreeBSD、Red Hat Linux 以及 OS X 上的差别。先来看看 FreeBSD 上的输出:
BSD $ ps
PID TT STAT TIME COMMAND
792 0 Ss 0:00.02 -sh (sh)
4468 0 R+ 0:00.01 ps
# 然后是Red Hat Linux上的输出
RHL $ ps
PID TTY TIME CMD
8065 pts/4 00:00:00 bash
12619 pts/4 00:00:00 ps
# 最后是OS X上的输出
PID TTY TIME CMD
37055 ttys000 0:00.01 -bash
26881 ttys001 0:00.08 -bash
先不说去比照这些 ps 命令,更糟的地方在于 GNU 的 ps 命令还能接受 BSD/SYSV/GNU 风格 的选项。彻底乱套了!
幸运的是,其中一些不一致性可以利用 cu 选项在该特定脚本中避开,这两个选项生成的结 果要一致得多,其中包括进程属主、完整的命令名以及我们真正感兴趣的进程 ID。
这也是第一个真正发挥了 getopts 命令威力的脚本,它使得我们能够处理大量不同的命令行 选项,甚至是可选值。代码清单 6-6 中的脚本可以接受 4 个选项,即-s SIGNAL、-u USER、-t TTY 和-n,其中 3 个选项需要参数。在第一个代码块中你就可以看到这些选项的处理。
代码 killall
#!/bin/bash
# killall -- 向匹配指定进程名的所有进程发送特定信号
# 默认情况下,脚本只杀死属于同一用户的进程,除非你是root
# -s SIGNAL 可指定发送给进程的信号, -u USER可以指定用户
# -t TTY可以指定tty, -n只报告操作结果,不报告具体过程
signal="-INT" # 默认发送中断信号
user="" tty="" donothing=0
while getopts "s:u:t:n" opt;do
case "$opt" in
# 注意下面的技巧:实际的kill命令需要的是-SIGNAL,
# 但我们想要用户指定的是SIGNAL,所以要在前面加上"-".
s ) signal="-$OPTARG"; ;;
u ) if [ ! -z "$tty" ];then
# 逻辑错误:你不能同时指定用户和TTY设备
echo "$0: Error : -u and -t are mutually exclusive." > &2
exit 1
fi
user=$OPTARG; ;;
t ) if [ ! -z "$user" ];then
echo "$0: Error : -u and -t are mutually exclusive." > &2
exit 1
fi
tty=$2; ;;
n ) donothing=1; ;;
? ) echo "Usage: $0 [ -s signal ] [ -u user | -t tty ] [ -n ] pattern" > &2
exit 1
esac
done
# getopts 的选项处理完成
if [ $# -eq 0 ];then
pids=$(ps cu -t $tty |awk "/ $1$/ {print \$2 }") # 1 匹配$1 所在的行 $2 输出pid 信息
elif [ ! -z "$user" ];then
pids=$(ps cu -U $user |awk "/ $1$ {print \$2 }") # 2 匹配$1 所在的行 $2 输出pid 信息
else
pids=$(ps cu -U ${USER:-LOGNAME} | awk "/ $1$/ {print \$2 }") # 3 匹配$1 所在的行 $2 输出pid 信息
fi
# 没找到匹配的进程?这简单!
if [ -z "$pids" ];then
echo "$0: no processes match pattern $1" > &2
exit 1
fi
for pid in $pids;do
# 向id为$pid的进程发送信号$signal:如果进程已经结束,或是用户没有
# 杀死特定进程的权限,等等,那么kill命令可能会有所抱怨、不过没关系
# 反正任务至少是完成了
if [ $donothing -eq 1 ];then
echo "kill $signal $pid" # -n 选项: “显示,但不执行”
else
kill $signal $pid
fi
done
exit 0
运行结果
$ ./killall -n csmount
kill -INT 1292
kill -INT 1296
kill -INT 1306
kill -INT 1310
kill -INT 1318
精益求精
脚本运行的时候会出现一个不大可能但并非不存在的 bug。为了只匹配指定的模式,awk 会 输出匹配该模式以及出现在输入行末尾的前导空格的进程 ID。但是在理论上,可能会有两个进 程,比如说,一个叫作 bash,另一个叫作 emulate bash。如果调用 killall 的时候以 bash 作为 模式,那么这两个进程都能够匹配,但只有前者才是我们真正想要的。要想解决这个问题,以期 在不同的平台上获得一致的结果,可能是件相当棘手的事情。
如果你有这方面的考虑,也可以编写一个着重依赖于 killall 的脚本,除了进程 ID,还能够 依据作业名称对其执行 renice 操作。唯一要做出的改动就是把 kill 换成 renice。调用 renice 可以修改进程的相对优先级,例如,允许你降低大文件传输的优先级,同时提高老板正在使用的 视频编辑器的优先级。
脚本48 验证用户crontab条目
Linux 宇宙① 中最有用的工具之一就是 cron 了,它可以将作业安排在未来的任意时刻,或是 每分钟、每几个小时、每个月、甚至是每年自动执行作业。每位优秀的系统管理员都免不了有一 件脚本利器是通过 crontab 文件运行的。
但是,cron 规范的格式有点棘手,其字段内容可以是数字值、区间、集合,甚至是月份或者 一周中某天的名称。更糟糕的是 crontab 程序在碰到用户错误或系统 cron 文件错误的时候,产生 的相关信息晦涩难懂。
例如,如果在指定周几的时候出现了输入错误,那么 crontab 会报告类似于下面的信息:
"/tmp/crontab.Dj7Tr4vw6R":9: bad day-of-week
crontab: errors in crontab file, can't install
实际上,在样例输入文件中的第 12 行还存在另一个错误,但是 crontab 非得让我们绕一圈 才找到问题所在,这都是拜其糟糕的错误检查功能所赐。
现在可以放弃 crontab 的错误检查方式了,下面这个比较长的 shell 脚本(代码清单 6-8)能 够扫描 crontab 文件,检查语法,确保取值均在合理范围内。shell 脚本中实现这种验证是可行的, 原因之一在于可以将集合和区间视为多个单独的值。所以,要想确定 3-11,或者 4、6、9 是否为 可取的字段值,对于前者,只需要测试 3 和 11;对于后者,只需要测试 4、6、9。
代码 verifycron
#!/bin/bash
# verifycron--Checks a crontab file to ensure that it's
# formatted properly. Expects standard cron notation of
# min hr dom mon dow CMD,
# where min is 0-59, hr is 0-23, dom is 1-31, mon is 1-12 (or names)
# and dow is 0-7 (or names). Fields can be ranges (a-e) or lists
# separated by commas (a,c,z) or an asterisk. Note that the step
# value notation of Vixie cron (e.g., 2-6/2) is not supported by
# this script in its current version.
validNum()
{
# Return 0 if the number given is a valid integer and 1 if not.
# Specify both number and maxvalue as args to the function.
num=$1 max=$2
# Asterisk values in fields are rewritten as "X" for simplicity,
# so any number in the form "X" is de facto valid.
if [ "$num" = "X" ] ; then
return 0
elif [ ! -z $(echo $num | sed 's/[[:digit:]]//g') ] ; then
# Stripped out all the digits, and the remainder isn't empty? No good.
return 1
elif [ $num -gt $max ] ; then
# Number is bigger than the maximum value allowed.
return 1
else
return 0
fi
}
validDay()
{
# Return 0 if the value passed to this function is a valid day name,
# 1 otherwise.
case $(echo $1 | tr '[:upper:]' '[:lower:]') in
sun*|mon*|tue*|wed*|thu*|fri*|sat*) return 0 ;;
X) return 0 ;; # Special case--it's a rewritten "*"
*) return 1
esac
}
validMon()
{
# This function returns 0 if given a valid month name, 1 otherwise.
case $(echo $1 | tr '[:upper:]' '[:lower:]') in
jan*|feb*|mar*|apr*|may|jun*|jul*|aug*) return 0 ;;
sep*|oct*|nov*|dec*) return 0 ;;
X) return 0 ;; # special case, it's an "*"
*) return 1 ;;
esac
}
fixvars()
{
# Translate all '*' into 'X' to bypass shell expansion hassles.
# Save original input as "sourceline" for error messages.
sourceline="$min $hour $dom $mon $dow $command"
min=$(echo "$min" | tr '*' 'X') # minute
hour=$(echo "$hour" | tr '*' 'X') # hour
dom=$(echo "$dom" | tr '*' 'X') # day of month
mon=$(echo "$mon" | tr '*' 'X') # month
dow=$(echo "$dow" | tr '*' 'X') # day of week
}
if [ $# -ne 1 ] || [ ! -r $1 ] ; then
# If no crontab filename is given or it's not readable by the script, fail.
echo "Usage: $0 usercrontabfile" >&2; exit 1
fi
lines=0 entries=0 totalerrors=0
# Go through the crontab file line by line, checking each one.
while read min hour dom mon dow command
do
lines="$(( $lines + 1 ))"
errors=0
if [ -z "$min" -o "${min%${min#?}}" = "#" ] ; then
# If it's a blank line or the first character of the line is "#", skip it.
continue # Nothing to check
fi
((entries++))
fixvars
# At this point, all the fields in the current line are split out into
# separate variables, with all asterisks replaced by "X" for convenience,
# so let's check the validity of input fields...
# Minute check
for minslice in $(echo "$min" | sed 's/[,-]/ /g') ; do
if ! validNum $minslice 60 ; then
echo "Line ${lines}: Invalid minute value \"$minslice\""
errors=1
fi
done
# Hour check
for hrslice in $(echo "$hour" | sed 's/[,-]/ /g') ; do
if ! validNum $hrslice 24 ; then
echo "Line ${lines}: Invalid hour value \"$hrslice\""
errors=1
fi
done
# Day of month check
for domslice in $(echo $dom | sed 's/[,-]/ /g') ; do
if ! validNum $domslice 31 ; then
echo "Line ${lines}: Invalid day of month value \"$domslice\""
errors=1
fi
done
# Month check: Has to check for numeric values and names both.
# Remember that a conditional like "if ! cond" means that it's
# testing whether the specified condition is FALSE, not true.
for monslice in $(echo "$mon" | sed 's/[,-]/ /g') ; do
if ! validNum $monslice 12 ; then
if ! validMon "$monslice" ; then
echo "Line ${lines}: Invalid month value \"$monslice\""
errors=1
fi
fi
done
# Day of week check: Again, name or number is possible.
for dowslice in $(echo "$dow" | sed 's/[,-]/ /g') ; do
if ! validNum $dowslice 7 ; then
if ! validDay $dowslice ; then
echo "Line ${lines}: Invalid day of week value \"$dowslice\""
errors=1
fi
fi
done
if [ $errors -gt 0 ] ; then
echo ">>>> ${lines}: $sourceline"
echo ""
totalerrors="$(( $totalerrors + 1 ))"
fi
done < $1 # read the crontab passed as an argument to the script
# Notice that it's here, at the very end of the "while" loop, that we
# redirect the input so that the user-specified filename can be
# examined by the script!
echo "Done. Found $totalerrors errors in $entries crontab entries."
exit 0
运行结果
$ crontab -l > my.crontab
$ verifycron my.crontab
$ rm my.crontab
# 在包含无效条目的crontab文件上运行脚本verifycron
$ verifycron sample.crontab
Line 10: Invalid day of week value "Mou"
>>>> 10: 06 22 * * Mou /home/ACeSystem/bin/del_old_ACinventories.pl
Line 12: Invalid minute value "99"
>>>> 12: 99 22 * * 1-3,6 /home/ACeSystem/bin/dump_cust_part_no.pl
Done. Found 2 errors in 13 crontab entries.
精益求精
脚本中有几处地方有必要做出改进。验证月份和日期组合的兼容性可以确保用户不会把 cron 作业安排在 2 月 31 日运行。检查是否能成功调用指定的命令也很有用,不过这涉及解析并处理 PATH 变量(一系列目录,要在其中查找脚本中指定的命令),该变量可以在 crontab 文件中明确设 置。最后,还可以加入对@hourly 或@reboot 的支持,cron 使用这些特殊值表示常用的脚本运行 时间。