linux如何从shell限制用户的访问命令

2,665 阅读13分钟

这段时间思考了这么一个问题,如何限制 linux 系统中登录用户的访问权限。

方案一

以 rbash 的方式来限制用户的访问权限,在 ubuntu 系统中,直接使用

bash -r

就可以进入 rbash,在 centos 7 系统中,不支持直接使用,可以建立软链接的方式

ln -s /bin/bash /bin/rbash

useradd -s /bin/rbash testuser

这样,新创建的用户 testuser 的 shell 环境就是 rbash 环境。rbash 主要对用户做了如下限制

  • 使用命令cd更改目录
  • 设置或者取消环境变量的设置(SHELL, PATH, ENV, or BASH_ENV)
  • 指定包含参数'/'的文件名
  • 指定包含参数' - '的文件名
  • 使用重定向输出'>', '>>', '> |', '<>' '>&','&>'
  • 使用 exec 内置命令用其他命令代替当前shell命令
  • 使用 -f 或者 -d 选项添加和删除内置命令
  • 使用enable选项启动失效的内置命令
  • 对内置命令制定 -p 选项
  • 使用 set +r 或者 set +o\ 来关闭限制模式

当我们将用户的 shell 环境设置成 rbash 时,用户就会受到以上限制的影响。假如需要更加深入一步,还要限制用户能够访问的命令呢。这个也可以解决。

shell 上执行的命令,都是通过在 $PATH 环境变量中进行查找获取到的,因为 rbash 的缘故,不允许命令中带有路径,也就是不允许 /bin/echo 这种方式执行命令,所以限制用户的访问命令,我们可以修改用户的 PATH 环境变量,比如将 PATH 设置在 /home/testuser/usercmd 中,允许用户访问什么样的命令,就在/home/testuser/usercmd 中创建一个指向该命令的软链接即可,比如 ls 命令

export PATH=/home/testuser/usercmd

ln -s /bin/ls /home/testuser/usercmd/ls

这样,testuser 用户就能够使用 ls 命令了。

假如有这么一个场景,就是对于一线运维人员来说,他们登录的用户权限肯定是受限的,包括路径访问权限,只允许使用有限的命令,这些都可以通过上面的方法来解决,但是,如果允许他们能够重启机器,或者修改网卡配置信息呢,这些可都是需要超级用户权限才能执行的操作,直接通过上面的方式是不能直接执行的。

面对这种场景,我们可以考虑方案二

方案二

笔者使用的环境是 centos7.9,系统内置的 shell 版本是 4.2 版本。笔者在思考这个问题的时候想过,如果非要这么严格的限制用户的权限,那干脆类似交换机那样,专门实现一个shell,或者使用 busybox 裁剪一个shell 好了。这确实是一种可行的方法,但是一般来说,专门实现一个 shell 代价比较高,busybox 裁剪的话,也是很麻烦的。基于此,笔者想到,是不是可以直接在 bash4.2 源代码的基础上,改造一个 shell 出来呢。

也就是说,在每次执行 shell 的时候,都会对我们执行的命令进行一个检查,如果是我们允许的命令,那就继续执行,如果是不允许访问的命令,直接返回结果。而这些允许的命令都是在配置文件中读取的,配置的修改只允许 root 权限才能进行。这样,是不是通过可配置的方式,就直接限定了用户的执行权限和可访问的命令了呢。

经过验证,笔者认为是可以的。下面咱们来慢慢完善这个思路。

首先,我们需要解决以下这几个问题

  1. 修改 bash4.2 源代码,实现读取配置的方式来限制用户能够执行的命令
  2. 如何让用户可以执行某些超级用户权限才能执行的命令

梳理 bash-4.2 源代码

通过一个简单的代码流程图,来看一下shell代码执行的流程,如下所示

shell flow

bash 在读取命令的时候,是通过 yacc 语法解析器来解析 shell 脚本的,同时会生成一个统一的抽象语法树的结构,如下所示

typedef struct command {
  enum command_type type;	/* FOR CASE WHILE IF CONNECTION or SIMPLE. */
  int flags;			/* Flags controlling execution environment. */
  int line;			/* line number the command starts on */
  REDIRECT *redirects;		/* Special redirects for FOR CASE, etc. */
  union {
    struct for_com *For;
    struct case_com *Case;
    struct while_com *While;
    struct if_com *If;
    struct connection *Connection;
    struct simple_com *Simple;
    struct function_def *Function_def;
    struct group_com *Group;
#if defined (SELECT_COMMAND)
    struct select_com *Select;
#endif
#if defined (DPAREN_ARITHMETIC)
    struct arith_com *Arith;
#endif
#if defined (COND_COMMAND)
    struct cond_com *Cond;
#endif
#if defined (ARITH_FOR_COMMAND)
    struct arith_for_com *ArithFor;
#endif
    struct subshell_com *Subshell;
    struct coproc_com *Coproc;
  } value;
} COMMAND;

该结构中 type 表示命令的类型,常用的简单命令,类型为 SIMPLE, WHILE 和 IF 表示 while 循环语句和 if 条件语句,而 CONNECTION 类型,表示该 shell 脚本是通过管道符号连接起来的多个 shell 操作。value 结构,表示的是命令的具体内容。

针对我们的场景,开放给用户有限的命令行,type 类型就是 SIMPLE 或者 CONNECTION。也就是简单命令或者通过管道符号连接的起来的若干个简单命令的 shell 脚本。

接着我们看下 connection 的结构

typedef struct connection {
  int ignore;			/* Unused; simplifies make_command (). */
  COMMAND *first;		/* Pointer to the first command. */
  COMMAND *second;		/* Pointer to the second command. */
  int connector;		/* What separates this command from others. */
} CONNECTION;

connection 结构,包含两个 COMMAND 指针,分别指向管道符号左右两边的shell命令,这是一个递归的关系,first 指针指向管道符号左边的 shell 命令,通常就是一个 SIMPLE 类型的命令,second 指向的是右边的shell命令,而 second 同样可以管道符号连接起来的多个 shell 命令,类型为 CONNECTION,以此类推。

simple_com 的结构就比较简单

typedef struct simple_com {
  int flags;			/* See description of CMD flags. */
  int line;			/* line number the command starts on */
  WORD_LIST *words;		/* The program name, the arguments,
				   variable assignments, etc. */
  REDIRECT *redirects;		/* Redirections to perform. */
} SIMPLE_COM;

line 表示命令起始行,words 就表示命令的具体内容,而 redirects 表示重定向信息。

typedef struct word_desc {
  char *word;		/* Zero terminated string. */
  int flags;		/* Flags associated with this word. */
} WORD_DESC;

/* A linked list of words. */
typedef struct word_list {
  struct word_list *next;
  WORD_DESC *word;
} WORD_LIST;

words 是一个单链表结构,比如 ls -l 这条命令,bash 会将其解释成 ls --color=auto -l,words 结构的组成如下所示

ls word

解析完 shell 脚本之后,在执行命令时,分为三个步骤进行。

  • Find_func() 从 PATH 中查找命令,如果存在,执行;如果不存在,进入下一步
  • 查找命令是否是 builtin 内建命令,如果存在,执行;如果不存在,进入下一步
  • 调用 search_for_command 查找命令,即认为命令的执行方式是 /bin/echo 这种方式,然后在 /bin 这个路径中查找命令,如果存在就执行;如果不存在,说明命令不存在,报错。

shell 执行的流程大概就是上述这个流程,我们需要限制用户能够访问的命令,那么在命令执行之前,检查命令是否符合我们的标准即可,也就是在 execute_command_internal() 函数中,执行 COMMAND 执行进行检查。

添加代码

execute_command.c 中,在执行 execute_in_subshell 函数之前,我们进行检查,添加如下代码

if (running_startup_files == 0) {
    if (check_command_permission_for_smbash(command) == -1) {
      internal_error(_("command is not allowed"));
      return EXECUTION_FAILURE;
    }
}

running_startup_files 为 0 表示执行的 shell 命令不是来自初始化加载的 .bashrc 这些启动脚本之中,在初始化成功之后的 shell 命令,才需要检查。如果检查的命令是我们开放出来的命令,就可以继续执行,否则,返回失败,提示 command is not allowed 的信息。

static int check_command_permission_for_smbash(COMMAND *cmd) {
  if (cmd->type == cm_simple) {
    return check_simple_command_permission_for_smbash(cmd);
  }
  if (cmd->type == cm_connection) {
    if (check_command_permission_for_smbash(cmd->value.Connection->first) == -1) {
      return -1;
    } else {
      return check_command_permission_for_smbash(cmd->value.Connection->second);
    }
  }

  return -1;
}

前面说过,我们只需要考虑 CONNECTION 和 SIMPLE 两种类型的命令,如果是 SIMPLE 类型,直接处理,如果是 CONNECTION 类型的命令,说明命令是通过管道符号连接起来的多条命令,也就是说,需要递归的对每一条命令进行检查。

static int check_simple_command_permission_for_smbash(COMMAND *cmd) {
  if (cmd->type != cm_simple) {
    return -1;
  }

  int res = 0;

  char *cmdstr = cmd->value.Simple->words->word->word;
  if (NULL == cmdstr || strlen(cmdstr) == 0) { 
    return 0;
  }

  // whatever cmdstr is, exit must be supported, printf will be executed each time
  if (strcmp(cmdstr, "exit") == 0 || strcmp(cmdstr, "printf") == 0) return 0;

  if (strstr(cmdstr, "/") != NULL) {
    res = find_path_from_conf(limit_paths, cmdstr);
  } else {
    res = find_cmd_from_conf(limit_commands, cmdstr);
  }

  if (res == -1) {
    internal_error(_("%s "), cmdstr);
  }

  return res;
}

对 SIMPLE 类型的简单命令,检查 words 链表中的第一个节点即可。exit 命令是退出命令,该命令默认是支持的。shell 中有一个环境变量设置,在每次调用执行命令之前都会执行一次,如果没有设置,就默认执行一个打印消息的命令,使用的就是 printf,所以该命令也必须得默认支持。

用户输入的命令,如果是我们放开的命令,就返回成功,如果带有 / 的路径,就搜索可执行文件的路径是否是我们放开的路径,不是就返回 -1。

这样就完成了命令的检查工作。

通过配置加载允许访问的命令

如果将允许访问的命令写死在代码里面,肯定很难符合变化的场景,所以我们将允许访问的命令,设置成可配置的方式,放在 /etc/smbash.conf 中,如下所示

sm_limit_cmd = arpdel, arpset, resumeifconfig, show_DB_log, set_ipconfig, set_ipconfig_file
sm_limit_path = /usr/local/sbin, /usr/local/bin

sm_limit_cmd 表示允许用户访问的命令的集合,而 sm_limit_path 表示允许用户执行带有路径的命令的路径前缀。在 common.h 头文件中,加入一下这几个函数

#define LIMIT_SMBASH_CONF "/etc/smbash.conf"
#define SM_LIMIT_PATH "sm_limit_path"
#define SM_LIMIT_CMD "sm_limit_cmd"

extern int load_conf_file(const char*);
extern int load_from_conf(char ***, char *);
extern int find_cmd_from_conf(char **, char *);
extern int find_path_from_conf(char **, char *);
extern void free_limits(char ***);
extern void trim_string(char *, char);

来分别看下这几个函数,代码比较简单

/* if smbash.conf exists, load limits of commands and paths from it, which contains
   the shell commands and paths only allowed by user */
int load_conf_file(const char* conf) {
  if (access(conf, F_OK) != 0) {
    fprintf(stderr, "access %s failed\n", conf);
    return -1;
  }

  FILE *fconf = fopen(conf, "r");
  char buf[2048] = {0};
  while (fgets(buf, sizeof(buf), fconf) != NULL) {
    trim_string(buf, ' ');
    trim_string(buf, '\r');
    trim_string(buf, '\n');

    if (strncmp(buf, SM_LIMIT_CMD, strlen(SM_LIMIT_CMD)) == 0) {
      load_from_conf(&limit_commands, buf);
    } else if (strncmp(buf, SM_LIMIT_PATH, strlen(SM_LIMIT_PATH)) == 0) {
      load_from_conf(&limit_paths, buf);
    } else 
      continue;
  }

  fclose(fconf);
  return 0;
}

load_conf_file 函数就是从配置文件中加载受限的命令集合和可访问的可执行文件路径,加载的命令和路径分别存放在两个全局变量中

extern char **limit_commands = (char **)NULL;
extern char **limit_paths = (char **)NULL;

如果是命令,就保存到 limit_commands 中,如果是路径就保存到 limit_paths 中。

int load_from_conf(char *** limits, char * buf) {
  if (NULL != *limits) {
    fprintf(stderr, "load %s duplicated\n", LIMIT_SMBASH_CONF);
    return -1;
  }

  // del last comma
  if (buf[strlen(buf) - 1] == ',') {
    buf[strlen(buf) - 1] = '\0';
  }

  char *pstart = strstr(buf, "=");
  if (NULL == pstart) {
    fprintf(stderr, "configuration failed\n");
    return -1;
  }

  pstart ++; // skip '='
  int cnt = 0;
  char *str = pstart;
  while ((str = strstr(str, ",")) != NULL) {
    cnt ++;
    str ++;
  }

  // last elements in limits is NULL as terminate
  *limits = (char **) malloc (sizeof(char *) * (cnt + 1 + 1));
  if (NULL == limits) {
    fprintf(stderr, "malloc failed\n");
    return -1;
  }
  
  char *token = NULL;
  cnt = 0;
  while ((token = (char *)strtok(pstart, ",")) != NULL) {
    pstart = NULL;

    (*limits)[cnt] = (char *) malloc (sizeof(char) * (strlen(token) + 1));
    if (NULL == (*limits)[cnt]) {
      fprintf(stderr, "malloc failed\n");
      return -1;
    }

    memcpy((*limits)[cnt], token, strlen(token));
    (*limits)[cnt][strlen(token)] = '\0';
    cnt ++;
  }
  (*limits)[cnt] = NULL;

  return 0;
} 

void free_limits(char *** plimits) {
  int idx = 0;
  char ** limits = *plimits;
  if (NULL != limits) {
    for (; limits[idx] != NULL; idx ++) {
      free(limits[idx]);
    }

    free(limits);
    limits = NULL;
  }
} 

void trim_string(char * str, char delim) {
  if (NULL == str) { return; }
  char *p = (char *)malloc(sizeof(char) * (strlen(str) + 1));
  if (NULL == p) {
    fprintf(stderr, "malloc failed\n");
    return;
  }

  char *strp = str;
  char *savep = p;
  int idx = 0;
  while (*strp != '\0') {
    if (*strp != delim) {
      *p++ = *strp++;
      idx ++;
    } else 
      strp ++;
  }
  savep[idx] = '\0';

  memcpy(str, savep, strlen(savep));
  str[strlen(savep)] = '\0';

  free(savep);
}

写完配置夹在的代码后,就可以直接在两个全局变量数组中进行查找了,下面给出两个查找函数的实现

int find_cmd_from_conf(char ** limits, char * str) {
  if (NULL == limits || NULL == str) { 
    return -1;
  }
  int idx = 0;
  for (; limits[idx] != NULL; idx ++) {
    if (strcmp(str, limits[idx]) == 0) {
      return idx;
    }
  }

  return -1;
}

int find_path_from_conf(char ** limits, char * path) {
  if (NULL == limits || NULL == path) return -1;

  int idx = 0;
  for (; limits[idx] != NULL; idx ++) {
    if (strncmp(limits[idx], path, strlen(limits[idx])) == 0) {
      return idx;
    }
  }

  return -1;
}

加载配置文件,可以在shell初始化时就加载,这样在检查命令是否符合要求时,直接使用内存中全局变量信息就可以。

代码添加完成之后,编译成 smbash,将 smbash 二进制文件拷贝到 /bin 目录中,在创建用户时,指定 shell 环境

useradd -s /bin/smbash -m testuser

这样,通过 testuser 登录系统时,testuser 用户就只能访问 /etc/smbash.conf 文件中指定的命令了,效果如下

testuser> pwd
-smbash: pwd 
-smbash: command is not allowed

好了,我们已经解决了第一个问题了,通过在 bash4.2 中添加代码的方式,改变 bash 的行为,限制用户能够访问的命令。

接下来,解决第二个问题

如何使 testuser 用户能够正确需要超级权限的操作

我们已经知道,testuser 的 shell 环境是我们新创建的 smbash,该 shell 限制了用户能够使用的命令,当然,对于 su 或者 sudo 这种超级权限的命令,更加是不可能开放出来的。但是对于运维人员来说,有时候,比如修改网卡配置,或者重启计算机这些操作是需要超级用户权限才能执行的,那如何才能做到这一点呢。

首先,testuser 用户需要使用超级用户权限执行命令时,需要使用 sudo 命令,且不输入密码的情况下,就必须将 testuesr 用户加入到 /etc/sudoers 文件中,如下

testuser ALL=(ALL) NOPASSWD:ALL

这样,testuser 就具有了能够使用 sudo 的超级用户权限。但是 sudo 命令,是不会开放给 testuser 用户的,在 testuser 用户中使用 sudo 执行命令时,就会出现如下错误

-smbash: sudo 
-smbash: command is not allowed

但是,我们可以想另一种办法。通过 c 程序,调用 system 库函数的方式来达到这个目的。system 库函数,使用 fork 创建一个子进程,然后执行 execl("/bin/sh", "sh", "-c", command, (char *) 0); 的方式替换子进程中的内容,也就是说,system 库函数实际调用的是 /bin/sh 来执行脚本的,这样就绕过了 testuser 当前的 smbash 的环境,同时,sudo 权限在 /bin/sh 也是可以直接执行的,且不需要密码。这样我们在前面为 testuser 设置的 sudo 超级用户权限就派上用场了。

这个 c 程序非常简单

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

char *default_cmd[] = {
 "shutdown", "reboot", ""
};

char *default_cmd_args[] = {
  "sudo /usr/sbin/shutdown",
  "sudo /usr/bin/reboot"
};

int find_default_cmd(const char* cmd);

在程序中,将需要使用 sudo 权限的命令直接固定起来,因为这种命令是有限的,不会随时变动,毕竟对运维来说,超级用户权限是非常危险的。

#include "limit-exe.h"

int find_default_cmd(const char* cmd) {
  if (NULL == cmd) return -1;

  int idx = 0;
  for (; strlen(default_cmd[idx]) > 0; idx ++) {
    if (strcmp(cmd, default_cmd[idx]) == 0) {
      return idx;
    }
  }

  return -1;
}

int main(int argc, char *argv[]) {
  if (argc < 2) return 0;

  int res = find_default_cmd(argv[1]);
  char cmdstr[256] = {0};
  int idx = 2;  // skip argv[1]
  if (-1 != res) {
    strcat(cmdstr, default_cmd_args[res]);

    while (idx < argc) {
      strcat(cmdstr, " ");
      strcat(cmdstr, argv[idx++]);
    }

    system(cmdstr);
  }

  return 0;
}

上述代码编译成 limit_exe 可执行文件后,安装到 /usr/local/bin/ 中,这样,testuser 用户就可以通过这个程序执行 reboot 或者 shutdown 这两个命令了。当然,还需要最后一步,在 testuser 的 .bashrc 文件中设置这两个命令的 alias 别名

alias shutdown="/usr/local/bin/limit_exe shutdown"
alias reboot="/usr/local/bin/limit_exe reboot"

在将 .bashrc 文件的权限设置为只读权限

chattr -i /home/testuser/.bashrc

这样目的就达到了。

总结

本文用两种方案,来解决限制 linux 用户访问命令的问题。方案一使用 rbash 的方式,但是限制比较多,尤其碰到文中提到的运维场景时就难以解决;方案二通过修改 bash 4.2 源代码的方式限制用户能够访问的命令,同时使用一个取巧的方式,利用 system 库函数的方式,来达到跳过当前 smbash ,调用 bourne shell 去执行 sudo 访问命令,这样在 smbash 受限的环境下也达到执行超级用户权限命令的方法。