收集 Linux 命令行执行的命令

2,965 阅读2分钟

本篇文章的目的为收集在命令行执行的所有命令,除了将所有的命令发送到 Elasticsearch 进行保存之外,还需要为敏感命令做告警。

要做到这些的核心在于 PROMPT_COMMAND 这个环境变量,它的作用是,在出现 shell 命令输入提示符之前,作为命令来执行这个变量。

因此,我们可以将这个变量定义为一个命令,然后看看它的效果:

# export PROMPT_COMMAND="date '+%F %T'"
2019-04-23 11:25:53 # 在出现下面的提示符之前执行了 date 命令
# a
-bash: a: command not found
2019-04-23 11:25:56 # 再次出现
# v
-bash: v: command not found
2019-04-23 11:25:58 # 每次命令行提示符出现之前它都会出现

这就相当于每执行一次命令就会执行一次 PROMPT_COMMAND。有了这个基础之后,我们就可以让其收集所有用户执行的命令。

read 命令

首先,由于 PROMPT_COMMAND 执行的时机是上个命令结束,命令行输出之前,因此我们可以使用 histroy 1 获取上一次执行的命令。但是由于该命令的输出结果前面会带上命令的序号,我们需要去掉它。

这样一来,第一个版的 PROMPT_COMMAND 的结果为:

# export PROMPT_COMMAND="history 1 | { read _ cmd; echo \$cmd; }"

看起来挺复杂,其实很简单:

  • histroy 1 的结果会传递到大括号,目的是去掉命令前面的序号;
  • 大括号相当于开启了一个匿名函数,将这两个命令作为一个整体,不过它不会开启一个子 shell;
  • read 是 shell 内部的子命令,它会将空格作为分隔符。

我们一般使用 read 来读取键盘的输入,不过它还可以帮我们去掉命令前的序号。由于我们这里定义两个变量,因此 read 会对输入的结果使用空格分割 1 次,分割后的结果第一部分给变量 _,另一部分给变量 cmd

很显然,序号给了 _,然后我们 echo $cmd 就能够拿到上一次执行的命令了。只所以这里的 $ 前面加了转义符号 \,是因为这个命令是在 shell 环境下输入的,它会直接将 $cmd 作为变量解释了,使用转义是防止它直接解释。

其实你可以直接在命令行来测试 read 的效果:

# read x y z
23232 xxxxx ewewewe ssssss zzzzz
# echo "$x | $y | $z"
23232 | xxxxx | ewewewe ssssss zzzzz

收集相关信息

我们光收集历史命令是没什么用的,还应该收集如下信息:

  • 命令执行的时间
  • 执行命令时所在的目录
  • 当前执行命令的用户
  • 登录的用户(登录后可能会 su 切换到其他用户)
  • 用户所在的 tty(可能会同时开多个 shell)
  • 登录的 ip
  • 执行的命令

以上这些信息都需要执行命令来获取,组合起来就是这样的:

date "+%F %T"; pwd; whoami; who -u am i | { read user tty _ _ _ _ ip; echo $user $tty $ip| tr -d "()"; }; history 1 | { read _ cmd; echo $cmd; }

别急着使用,你需要看看有哪些命令,以及这些命令是干啥的就行,因为这毕竟不是最终版本。

如果定义了 HISTTIMEFORMAT 环境变量,history 的输出结果可能就不是我们想要的了。因此我们应该在用户登录之后将 HISTTIMEFORMAT 设置为空,为了防止用户修改,你可以将它设置为只读。但是一旦将其设置为只读,那么在系统重启之前,这个值无法修改。

vim /etc/profile
export HISTTIMEFORMAT=""
readonly HISTTIMEFORMAT

logger 命令

我们拿到这些信息之后肯定不能只是将其输出,而是将其存放在一个文件中。如果直接将其追加到一个文件中,是会有问题的。操作系统上肯定不止一个用户,不同的用户都会执行命令,那么这个日志文件的属主属组应该改成啥?日志文件要不要切割?要不要删除?这些都是要考虑的问题。

虽然我们最终都是要输出到文件中,直接追加的方式虽然简单,但是不好控制。最好的方式是使用 logger 命令将其输出 rsyslog,让 rsyslog 帮我们写到文件中,依托 rsyslog 强大的功能,我们可以对日志文件做更多的事情。

logger 命令我们只会用到两个选项:

  • -p:指定输出的基础设施和日志等级;
  • -t:指定 tag

我们需要修改 rsyslog 的配置文件,让其接收我们发送给它的日志并输出到文件中。这里将日志输出到 /var/log/bashlog。

# vim /etc/rsyslog.d/bashlog.conf
local6.debug /var/log/bashlog

检查 rsyslog 配置,然后重启:

# rsyslogd -N1
# /etc/init.d/rsyslog/restart

然后测试一把,看看 /var/log/bashlog 是否存在你想要的内容。

echo "hehe" | logger -t bashlog -p local6.debug

如果将这些都赋值给 PROMPT_COMMAND 变量,会显得很复杂,我们可以将这些命令定义到一个文件中,然后将这个文件赋值给 PROMPT_COMMAND。

# vim /etc/collect_cmd.sh
echo `date "+%F_%T"; pwd; whoami; who -u am i | { read user tty _ _ _ _ ip; echo $user $tty $ip| tr -d "()"; }; history 1 | { read _ cmd; echo $cmd; }` | logger -t bashlog -p local6.debug

# chmod +x /etc/collect_cmd.sh
# export PROMPT_COMMAND="/etc/collect_cmd.sh"

需要注意的是,脚本就写这一行,不要加上 #!/bin/bash,否则 history 命令执行不会有任何结果,原因不明。

最终用户每在命令行执行一次命令,就会在日志文件中增加一条类似这样的行:

Apr 25 14:23:03 localhost bashlog: 2019-04-25_14:23:03 root root pts/0 10.201.2.170 cat /etc/collect_cmd.sh

前面的日期日志、主机名、程序名都是 rsyslog 自动添加的,后面才是我们发送过去的内容。

升级 rsyslog

我们之所以能够收集用户执行的命令,核心就是 PROMPT_COMMAND。虽然我们现在定义好了它,但是难免它被人修改(普通用户也行),用户只要登录后执行 unset PROMPT_COMMAND,那么你的一切设置都将付诸东流。所以最好的方式就是将这个变量设置为只读。

前面我们已经将所收集到的日志存放到了文件中了,其实到这一步日志收集已经完成,但是为了便于之后发送到 Elasticsearch,我准备将这些日志以 json 格式写入到文件中。

怎么做呢?还是通过 rsyslog。只不过 CentOS6 默认的 rsyslog 版本太低,功能有限,需要将其升级到最新版才行。

升级 rsyslog 没有什么风险,我司生产环境升级到 rsyslog8 跑了两年多,没有任何问题。

在官网可以直接下载对应的 yum repo 文件,然后 yum update rsyslog 就升级到最新版了。

我这里将所有 rsyslog 相关的包都下载下来,然后创建了一个本地的 yum 仓库,便于内网机器下载升级。

升级后需要修改一行配置,有些配置不兼容:

# 修改前
*.emerg                                                 *

# 修改后
*.emerg                                                 :omusrmsg:*

修改完成后直接重启:

service rsyslog restart

解析日志

rsyslog 通过 mmnormalize 模块进行日志解析,解析后的内容为 json 格式,而这个模块使用的解析功能来自于 Liblognorm。关于 Liblognorm 的解析语法直接看官方文档即可。

为什么要解析成 json 格式?主要的原因是 Elasticsearch 存储的就是 json 格式的数据,我们可以直接将解析好的数据直接发送到 Elasticsearch,而无需使用 Logstash 解析。

先下载模块:

yum install rsyslog-mmnormalize liblognorm5-utils

liblognorm5-utils 用来检测解析规则是否正确,下面会用到。

当我们将执行的命令发送给 rsyslog 后,我们需要解析的是下面的内容,不包括我们上面看到的 rsyslog 自动添加的时间日期等信息。

 2019-04-30_14:01:45 /root root root pts/0 10.201.2.170 vim hehe

它会在开头添加一个空格,这个空格是怎么来的我也不清楚,因此我们要预留一个空格。

mmnormalize 使用时,需要指定一个解析库。这个解析库遵循 liblognorm5 的语法:

# vim /etc/bashlog.rb
version=2

# 冒号后面的空格就是上面提到的空格
rule=: %
    time:word
    # 两个百分号之间的空格是 date 和 pwd 命令之间的空格
    % %
    directory:word
    % %
    exec_user:word
    % %
    login_user:word
    % %
    tty:word
    % %
    src_ip:ipv4
    % %
    command:rest
    %

这个文件就是一个解析库,用于解析上面的内容。虽然以 rb 结尾,但是和 ruby 没有关系。

简单的解释下它的作用:

  • version=2 必须处于第一行,并且这一行只能是这几个字符,不能加任意字符进去。它表示使用的是 v2 引擎,官方推荐使用 v2,但是 v2 不一定比 v1 功能更丰富,但是我们用够了。如果没写,或者写错了,将使用 v1 引擎;
  • 规则的写法就是 rule=,它后面的冒号 : 用来分割 tag 的,也就是说等号和冒号之间可以加上 tag。我们不需要 tag,但是得把冒号写上;
  • 冒号 : 就是字段解析了,要解析的字段使用百分号 % 包起来。百分号中通过冒号 : 进行分割,冒号前是字段的名称(json 中的对象名),冒号后是 Liblognorm 内置的字段类型,字段类型后面可以加参数,使用中括号 {} 引用,只是上面没有使用,每个字段的参数都不一样,有的有,有的没有;
  • 百分号中允许存在空格和换行符,这样就可以写成多行,而不用都写在一行,看起来更美观;
  • 使用 Liblognorm 进行解析时,空格是一对一的。假如两个字段间有三个空格,那么写解析规则时,两个百分号之间必须要有三个空格。有时你无法确定空格数量怎么办?使用 whitespace 这种类型;
  • 字段类型(这里只列出常用的,更多的看官方文档即可):
    • word:空格外的任意字符,也就是看到空格后就终止匹配;
    • whitespace:匹配所有空格,直到碰到第一个非空格字符。也就是在有不止一个空格的情况下使用它非常合适;
    • date-rfc3164:rsyslog 的时间字段;
    • ipv4:ipv4 地址;
    • rest:直接匹配到行尾;
    • -:匹配但不显示,它一般用于丢弃字段,比如它和 whitespace 类型配在一起就非常合适。

测试一把解析库:

# echo " 2019-04-30_14:01:45 /root root root pts/0 10.201.2.170 vim hehe" | lognormalizer -r /etc/bashlog.rb -e json
{ "command": "vim hehe", "src_ip": "10.201.2.170", "tty": "pts\/0", "login_user": "root", "exec_user": "root", "directory": "\/root", "time": "2019-04-30_14:01:45" }

这就是解析后的结果,唯一的缺点就是会在 / 前面加上转译符 \

现在只需要简单的配置下 rsyslog 就能够将解析后的 json 数据保存到文件中。

# vim /etc/rsyslog.d/bashlog.conf
# 加载模块
module(load="mmnormalize")

template(name="all-json" type="list"){
  property(name="$!all-json")
  constant(value="\n") # 如果没有这行,解析后的信息不会换行
}

if $syslogfacility-text == 'local6' and $syslogseverity-text == 'debug' then {
  action(type="mmnormalize" rulebase="/etc/bashlog.rb")
  action(type="omfile" File="/var/log/bashlog" template="all-json")
}

该文件之前的内容可以删掉了。

我们首先定义了一个模板,这个模板是配合解析用的,解析一条消息,就将解析后的 json 格式的信息保存在 $!all-json 这个变量中,然后就可以定义 action 将其保存在文件中,或者 NoSQL 中。

本来是打算将其直接发送到 kafka/elasticsearch,但是考虑到 rsyslog 只会当时将消息发送出去,如果发送不成功它不会重发,因此还是将其保存到文件中,然后通过 filebeat 对文件进行读取并发送。

通过 omfile 还能定义文件的属主属组,文件权限等,默认属主属组为 root,权限 600。

重启 rsyslog 之后,我们就可以在 /var/log/bashlog 中看到我们执行的命令了。

为了让 PROMPT_COMMAND 用户登录就生效,我们可以将之定义在 /etc/profile 中,且将其定义成只读。

vim /etc/profile
export PROMPT_COMMAND="/etc/collect_cmd.sh"
readonly PROMPT_COMMAND

改进

通过上面的方式我们可以收集命令行日志并将其解析,但是还是会存在问题。当你在命令行空回车而不输入任何东西时,执行 history 1 会获得上一次执行的命令,没有什么问题。但是如果你上一次都为空,那么你这次的收集的命令就是空,解析会失败。

你会从解析的日志文件中看到这样的内容:

{ "originalmsg": " 2019-05-01_14:19:19 \/home\/user1 user1 root pts\/0 10.201.2.170", "unparsed-data": "" }

我们解析规则是默认登录后面的 ip 后面还会有内容,当其没有内容时就会解析失败。这种情况会出现在你使用 su - 命令切换到其他用户,且切换后直接空回车。

针对这样的情况,我们应该判断用户输入的命令是否为空,如果为空就直接退出脚本。因此我们的 /etc/collect_cmd.sh 可以做如下修改:

cmd=`history 1 | { read _ cmd; echo $cmd; }`
[ -z "$cmd" ] && exit # 当其为空时就直接退出
echo `date "+%F_%T"; pwd; whoami; who -u am i | { read user tty _ _ _ _ ip; echo $user $tty $ip| tr -d "()"; }; echo $cmd` | logger -t bashlog -p local6.debug

还有最后一点是针对 HISTTIMEFORMATPROMPT_COMMAND 这两个环境变量的,为了避免出现解析异常,最好将这两个变量都设为只读,这样任何用户都无法修改它的内容。如果不设置为只读,用户在自己家目录下的 .bashrc 或者直接在命令行就可以对其重新设置。

我们可以将之都定义在 /etc/profile,如果想要在 /etc/profile.d 中单独用一个文件来保存,不要将 HISTTIMEFORMAT 的定义放在其中,不然其他用户登录会报错,提示修改只读的变量。

unset HISTTIMEFORMAT
readonly HISTTIMEFORMAT
export PROMPT_COMMAND="/etc/collect_cmd.sh"
readonly PROMPT_COMMAND