从 system 函数的实现看 Linux 下对环境变量的处理

很久之前写的一篇关于环境变量的总结博客,当时没有写完,最近又翻找出来了,有些虎头蛇尾 😅

从 system 函数的实现看 Linux 下对环境变量的处理

前言

操作系统中的环境变量灵活而强大,Linux 系统中常用的环境变量有 HOME/USER/SHELL/PATH/http_proxy 等,许多程序的运行都依赖环境变量的设置。环境变量也是一个常见的攻击面,环境变量设置不当将可能导致提权、RCE 等系统风险。本篇文章记录了 Linux 下对环境变量的处理,涉及到普通程序中的环境变量处理和一个特殊的程序—— bash 中的环境变量处理。

提到环境变量,就不得不提到 execve 系统调用。这个系统调用的原型如下:

1
int execve(const char *pathname, char *const argv[], char *const envp[]);
  • 第一个参数是可执行程序的路径或一个脚本的路径,如果是脚本,则通常(当然存在例外)需要在脚本内指定解释器,即需要以 #!xxxx 开头。

  • 第二个参数是传递给可执行文件的参数,是一个字符串数组,且数组的第一个元素通常是执行文件的文件名,最后一个元素须是 NULL

  • 第三个参数是传递给新进程的环境变量,也是一个字符串数组,每个环境变量的格式为 key=value,数组的最后一个元素也须是 NULL

在载入一个可执行的 ELF 文件时,参数数组和环境变量数组会分别传递给程序中定义的 main 函数的第二个和第三个参数,其通用的函数原型为:

1
int main(int argc, char *argv[], char *envp[]);

对于 main 函数的参数和返回值就不做过多的介绍。

但是在实际使用 C 库函数编程的时候,如果需要执行一条或多条系统命令,我们并不会直接使用 execve 系统调用,而会使用 system 函数。

犹记得有道题留的后门很有趣:只能输入两个字符,然后将输入作为 system 函数的参数进行执行。这里限制了输入只能是特定的符号和数字,不能有字母。刚开始看到这个后门有点摸不着头脑,直到想出了使用 $0 作为输入,发现竟然可以拿到 shell

学过 shell 脚本的都知道,$1shell 脚本的第一个参数,而 $0 代表的是 shell 脚本的路径。如果按这个规律进行推断,那 C 程序中调用 system("$0") 应该是再执行一遍当前程序,但为什么远程的机器上可以拿到 shell 呢,这就不得不探究 glibcsystem 函数的实现,所有的疑问都可以从源码中获得答案。

在探讨环境变量之前,首先让我们来看看 system 函数的实现。

本文采用的 Linux 系统为 ubuntu-20.04,阅读的 glibc 源码为 2.31,测试程序均编译为 amd64-little

Linux 下 system 函数实现

起初,我以为 glibcsystem 函数实现为 fork+execve+waitpid,那么直接输入 $0 肯定会因为找不到 $0 这个程序而崩溃掉。后来发现 fork+execve+waitpid 确实是对 system 接口的一个实现版本,但 glibc 不是这样做的。阅读源码后发现,glibcsystem 接口的实现更为全面,其在一个新的进程生命周期内做了更多的准备与清理工作。

首先给出 system 函数实现的主调用链:

1
2
3
4
5
6
7
system(__libc_system): sysdeps\posix\system.c#193
    do_system: sysdeps\posix\system.c#102
      __posix_spawn: posix\spawn.c#25
        __spawni: sysdeps\unix\sysv\linux\spawni.c#424
          __spawnix: sysdeps\unix\sysv\linux\spawni.c#312
            CLONE: sysdeps\unix\sysv\linux\spawni.c#67
              __spawni_child:sysdeps\unix\sysv\linux\spawni.c#121

下面一层一层来分析。

实现分析

system

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
int
__libc_system (const char *line)
{
  if (line == NULL)
    /* Check that we have a command processor available.  It might
       not be available after a chroot(), for example.  */
    return do_system ("exit 0") == 0;

  return do_system (line);
}
weak_alias (__libc_system, system)

需要提一下的是,如果需要在静态编译去符号的 ELF 文件中快速定位 system 函数 (如果有的话),可以寻找 exit 0 这个字符串,然后交叉引用即可找到。

do_system

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
  __sigaddset (&sa.sa_mask, SIGCHLD);
  /* sigprocmask can not fail with SIG_BLOCK used with valid input
     arguments.  */
  __sigprocmask (SIG_BLOCK, &sa.sa_mask, &omask);

  __sigemptyset (&reset);
  if (intr.sa_handler != SIG_IGN)
    __sigaddset(&reset, SIGINT);
  if (quit.sa_handler != SIG_IGN)
    __sigaddset(&reset, SIGQUIT);

这里将 SIGCHLD 阻塞,并设置忽略 SIGINTSIGQUIT 信号。

1
2
3
4
5
  status = __posix_spawn (&pid, SHELL_PATH, 0, &spawn_attr,
			  (char *const[]){ (char*) SHELL_NAME,
					   (char*) "-c",
					   (char *) line, NULL },
			  __environ);

开始调用 __posix_spawn 函数。

这里的几个参数:

  • pid:存储子进程的 pid
  • SHELL_PATH:是一个宏,其实就是 "/bin/sh"
  • spawn_attr:暂不关注
  • SHELL_NAME:也是一个宏,定义为 "sh"
  • line:外部传入的命令行参数,也就是 system 的入参
  • __environ:指向当前环境变量列表的指针,是一个全局变量

观察几个参数后发现,system("xxx") 的本质就是 /bin/sh(sh) -c xxx,也就是说会使用系统的 shell 来执行程序;另外,新进程的环境变量继承自原进程。

pwn 的小伙伴想比对 __enviorn 这个全局变量不陌生,默认状态下,这里常常存储着一个栈地址。当然,这个变量我在后面会着重探讨,这里还是先关注 system 的实现机制。

__posix_spawn

1
2
3
4
5
6
7
8
int
__posix_spawn (pid_t *pid, const char *path,
	       const posix_spawn_file_actions_t *file_actions,
	       const posix_spawnattr_t *attrp, char *const argv[],
	       char *const envp[])
{
  return __spawni (pid, path, file_actions, attrp, argv, envp, 0);
}

直接调用 __spawni 函数,前 6 个参数直接传递,最后一个参数给的 0

__spawni

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
int
__spawni (pid_t * pid, const char *file,
	  const posix_spawn_file_actions_t * acts,
	  const posix_spawnattr_t * attrp, char *const argv[],
	  char *const envp[], int xflags)
{
  /* It uses __execvpex to avoid run ENOEXEC in non compatibility mode (it
     will be handled by maybe_script_execute).  */
  return __spawnix (pid, file, acts, attrp, argv, envp, xflags,
		    xflags & SPAWN_XFLAGS_USE_PATH ? __execvpex :__execve);
}

不难发现,最后一个参数决定是使用 __execvpex 还是 __execve,后者直接调用 execve 系统调用,前者做的事情稍微多一点,源码在 posix\execve.c:196

  • 先判断路径是否包含 / 字符,如果不含 /,则会从 PATH 这个环境变量中寻找
  • 如果 PATH 没有找到,就会拼接当前路径,然后执行

__spwanix

1
2
3
4
5
6
  while (argv[argc++] != NULL)
    if (argc == limit)
      {
	errno = E2BIG;
	return errno;
      }

判断参数是不是超过了界限,limit 的值为 0x7ffffff-1

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
  int prot = (PROT_READ | PROT_WRITE
	     | ((GL (dl_stack_flags) & PF_X) ? PROT_EXEC : 0));

  /* Add a slack area for child's stack.  */
  size_t argv_size = (argc * sizeof (void *)) + 512;
  /* We need at least a few pages in case the compiler's stack checking is
     enabled.  In some configs, it is known to use at least 24KiB.  We use
     32KiB to be "safe" from anything the compiler might do.  Besides, the
     extra pages won't actually be allocated unless they get used.  */
  argv_size += (32 * 1024);
  size_t stack_size = ALIGN_UP (argv_size, GLRO(dl_pagesize));
  void *stack = __mmap (NULL, stack_size, prot,
			MAP_PRIVATE | MAP_ANONYMOUS | MAP_STACK, -1, 0);

根据 rdlt_global.dl_stack_flags 设置开启的栈的权限,主要判断是否可执行。然后使用 mmap 映射 8K 的空间作为新进程的栈。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
  /* Child must set args.err to something non-negative - we rely on
     the parent and child sharing VM.  */
  args.err = 0;
  args.file = file;
  args.exec = exec;
  args.fa = file_actions;
  args.attr = attrp ? attrp : &(const posix_spawnattr_t) { 0 };
  args.argv = argv;
  args.argc = argc;
  args.envp = envp;
  args.xflags = xflags;

  __libc_signal_block_all (&args.oldmask);

  /* The clone flags used will create a new child that will run in the same
     memory space (CLONE_VM) and the execution of calling thread will be
     suspend until the child calls execve or _exit.

     Also since the calling thread execution will be suspend, there is not
     need for CLONE_SETTLS.  Although parent and child share the same TLS
     namespace, there will be no concurrent access for TLS variables (errno
     for instance).  */
  new_pid = CLONE (__spawni_child, STACK (stack, stack_size), stack_size,
		   CLONE_VM | CLONE_VFORK | SIGCHLD, &args);

设置好相关参数,然后调用 cloneclone 这个系统调用非常强大,相比于 fork 可以做更精细化的控制,详情可参考 man手册

__spawni_child

注意到 clone 的第一个参数为 __spawni_child,这也是新进程将执行的函数,简要审阅其源码发现

这个函数干了这么件事:

  1. 设置信号处理
  2. 设置进程组 pgid,设置权限相关的 euid/gid
  3. 复制到的文件描述符如果有开启的会关闭,然后打开新的描述符
  4. 需要 chdir 的话会执行 chdir
  5. 执行 execve/execvpe 函数,进入系统调用

子进程执行的时候,父进程会调用 waitpid 从而阻塞直到子进程执行完毕,然后后面都是设置错误号和一些清理工作等。

小节

简要总结一下 system 函数的执行流程如下:

  • glibclinux 下实现的 system 本质上是用 shell/bin/sh 软连接指向的那个程序去执行 sh -c xxxx,进而执行到用户的命令。
  • execve 的第二个参数 argv 的第一个元素为 "sh"
  • 子进程会拷贝一份父进程的环境变量。

第二条就解释了为啥 system("$0") 会获取到一个 shell,因为 $0 实际上就是 sh,而执行 system("sh") 自然会得到一个 shell。当然,这说明远程使用 shell 启动的题目,如果远程是直接使用 execve 去执行一个新进程,则会一直递归执行本身直到进程爆炸退出….

至于 /bin/sh,或者说常见的 shell 程序是怎么执行程序和脚本的,这就是另一个话题了,改日开个新篇讲一讲。

以上就是 glibclinux 下对 system 函数的实现。接下来聊一聊环境变量。

环境变量相关函数分析

这里主要分析 glibc 中涉及到环境变量的几个函数:setenv、putenv、getenv、unsetenv、clearenv

setenv

第一个参数是环境变量的键,第二个参数是环境变量的值,第三个参数为当已存在环境变量时是否将其替换为新值,传入 1 替换,传入 0 则不替换。

代码在 stdlib\setenv.c:251

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
int
setenv (const char *name, const char *value, int replace)
{
  if (name == NULL || *name == '\0' || strchr (name, '=') != NULL)
    {
      __set_errno (EINVAL);
      return -1;
    }

  return __add_to_environ (name, value, NULL, replace);
}

判断 name 是否为空,或是否为空字符串或是否不含有 =,通过检查后调用 __add_to_environ,第三个参数为 NULL,其余参数直接传递。

__add_to_environ 函数需要重点分析一下,因为下面的 putenv 函数也会调用这个函数,下面会一段一段分析,代码在 stdlib\setenv.c:116

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
int
__add_to_environ (const char *name, const char *value, const char *combined,
		  int replace)
{
  char **ep;
  size_t size;

  /* Compute lengths before locking, so that the critical section is
     less of a performance bottleneck.  VALLEN is needed only if
     COMBINED is null (unfortunately GCC is not smart enough to deduce
     this; see the #pragma at the start of this file).  Testing
     COMBINED instead of VALUE causes setenv (..., NULL, ...)  to dump
     core now instead of corrupting memory later.  */
  const size_t namelen = strlen (name);
  size_t vallen;
  if (combined == NULL)
    vallen = strlen (value) + 1;
  //....
}

计算了传入的 name 的长度,并且在 combinedNULL 的时候,计算了 vallen。关注到 putenv 的时候设置的 combinedNULL

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
  /* We have to get the pointer now that we have the lock and not earlier
     since another thread might have created a new environment.  */
  ep = __environ;

  size = 0;
  if (ep != NULL)
    {
      for (; *ep != NULL; ++ep)
	if (!strncmp (*ep, name, namelen) && (*ep)[namelen] == '=')
	  break;
	else
	  ++size;
    }

取全局变量 __environ,这是一个字符串数组,然后其不为空的时候依次取出每一个字符串和 name 也就是 key 进行比较,如果找到一个 key 一样且 key 后面有 = 的字符串,就跳出循环,否则 ++size

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
  if (ep == NULL || __builtin_expect (*ep == NULL, 1))
    {
      char **new_environ;

      /* We allocated this space; we can extend it.  */
      new_environ = (char **) realloc (last_environ,
				       (size + 2) * sizeof (char *));
      if (new_environ == NULL)
	{
	  UNLOCK;
	  return -1;
	}

      if (__environ != last_environ)
	memcpy ((char *) new_environ, (char *) __environ,
		size * sizeof (char *));

      new_environ[size] = NULL;
      new_environ[size + 1] = NULL;
      ep = new_environ + size;

      last_environ = __environ = new_environ;
    }

如果没有找到对应的字符串或者 __environ 数组为空的时候,就会进入到 if 分支。这里会调用 realloc,入参是 last_environ,这是一个全局静态变量,初始状态下为 NULL;大小是 size + 2 个指针大小。

因此,没有找到该环境变量的时候,会扩充数组的大小。判断 __environlast_environ 是否相同,也就是判断是否需要扩充,如果需要的话,就将原数组拷贝过来,并把扩充的那一部分内存的值置为 0,最后对 __environlast_environ 进行赋值。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
  if (*ep == NULL || replace)
    {
      char *np;

      /* Use the user string if given.  */
      if (combined != NULL)
	np = (char *) combined;
      else
	{
	  const size_t varlen = namelen + 1 + vallen;
#ifdef USE_TSEARCH
	  char *new_value;
	  int use_alloca = __libc_use_alloca (varlen);
	  if (__builtin_expect (use_alloca, 1))
	    new_value = (char *) alloca (varlen);
	  else
	    {
	      new_value = malloc (varlen);
	      if (new_value == NULL)
		{
		  UNLOCK;
		  return -1;
		}
	    }
# ifdef _LIBC
	  __mempcpy (__mempcpy (__mempcpy (new_value, name, namelen), "=", 1),
		     value, vallen);
# else
	  memcpy (new_value, name, namelen);
	  new_value[namelen] = '=';
	  memcpy (&new_value[namelen + 1], value, vallen);
# endif

	  np = KNOWN_VALUE (new_value);
	  if (__glibc_likely (np == NULL))
#endif
	    {
#ifdef USE_TSEARCH
	      if (__glibc_unlikely (! use_alloca))
		np = new_value;
	      else
#endif
		{
		  np = malloc (varlen);
		  if (__glibc_unlikely (np == NULL))
		    {
		      UNLOCK;
		      return -1;
		    }

#ifdef USE_TSEARCH
		  memcpy (np, new_value, varlen);
#else
		  memcpy (np, name, namelen);
		  np[namelen] = '=';
		  memcpy (&np[namelen + 1], value, vallen);
#endif
		}
	      /* And remember the value.  */
	      STORE_VALUE (np);
	    }
#ifdef USE_TSEARCH
	  else
	    {
	      if (__glibc_unlikely (! use_alloca))
		free (new_value);
	    }
#endif
	}

      *ep = np;
    }

最后一段 if 有点长。如果需要替换或者原数组为空,就会进入这个分支。

如果 combined 不是 NULL,那么会使用用户传入的 combined 字符串,然后直接把这个指针填到环境变量数组的末尾,就完成了 setenv

如果 combinedNULL,先判断是否定义了 USE_TSEARCH 宏,这个一般是会定义的,如果 value 大小可以用栈分配,就会用栈,否则使用 malloc,一般长度小于 65535 的话会使用栈分配,大于这个长度就会使用 malloc 分配。分配成功后会拷贝 value 过去。

如果定义了 USE_TSEARCH 宏,那么会在树里面寻找这个 value,如果找到了,就会把 malloc 分配的 chunk 给释放掉,而使用二叉树里保存的那个指针;如果没有找到,就会一定用 malloc 分配内存,最后将新的环境变量插入到环境变量数组中。

putenv

代码在 stdlib\putenv.c:52

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
int
putenv (char *string)
{
  const char *const name_end = strchr (string, '=');

  if (name_end != NULL)
    {
      char *name;
#ifdef _LIBC
      int use_malloc = !__libc_use_alloca (name_end - string + 1);
      if (__builtin_expect (use_malloc, 0))
	{
	  name = __strndup (string, name_end - string);
	  if (name == NULL)
	    return -1;
	}
      else
	name = strndupa (string, name_end - string);
#else
# define use_malloc 1
      name = malloc (name_end - string + 1);
      if (name == NULL)
	return -1;
      memcpy (name, string, name_end - string);
      name[name_end - string] = '\0';
#endif
      int result = __add_to_environ (name, NULL, string, 1);

      if (__glibc_unlikely (use_malloc))
	free (name);

      return result;
    }

  __unsetenv (string);
  return 0;
}

首先判断传入的字符串里面是否有=,如果没有,就会调用unsetenv,也就是删除这个环境变量。

如果有=,会使用栈(大部分情况下会用栈,除非设置的键特别长)或者malloc分配字符串里面的键部分,然后调用__add_to_environ (name, NULL, string, 1);,也就是直接用用户传入的这个指针,把这个指针替换环境变量或者放置在环境变量数组的末尾。

若使用malloc分配了name,则会释放这个chunk

从这里可以看出setenvputenv的区别:

  • setenv会拷贝一份环境变量字符串,然后添加到环境变量数组中
  • putenv直接使用用户传入的指针,放置在环境变量数组中

getenv

代码在stdlib\getenv. c:33

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
char *
getenv (const char *name)
{
  size_t len = strlen (name);
  char **ep;
  uint 16_t name_start;

  if (__environ == NULL || name[0] == '\0')
    return NULL;

  if (name[1] == '\0')
    {
      /* The name of the variable consists of only one character.  Therefore
	 the first two characters of the environment entry are this character
	 and a '=' character.  */
#if __BYTE_ORDER == __LITTLE_ENDIAN || !_STRING_ARCH_unaligned
      name_start = ('=' << 8) | *(const unsigned char *) name;
#else
      name_start = '=' | ((*(const unsigned char *) name) << 8);
#endif
      for (ep = __environ; *ep != NULL; ++ep)
	{
#if _STRING_ARCH_unaligned
	  uint 16_t ep_start = *(uint 16_t *) *ep;
#else
	  uint 16_t ep_start = (((unsigned char *) *ep)[0]
			       | (((unsigned char *) *ep)[1] << 8));
#endif
	  if (name_start == ep_start)
	    return &(*ep)[2];
	}
    }
  else
    {
#if _STRING_ARCH_unaligned
      name_start = *(const uint 16_t *) name;
#else
      name_start = (((const unsigned char *) name)[0]
		    | (((const unsigned char *) name)[1] << 8));
#endif
      len -= 2;
      name += 2;

      for (ep = __environ; *ep != NULL; ++ep)
	{
#if _STRING_ARCH_unaligned
	  uint 16_t ep_start = *(uint 16_t *) *ep;
#else
	  uint 16_t ep_start = (((unsigned char *) *ep)[0]
			       | (((unsigned char *) *ep)[1] << 8));
#endif

	  if (name_start == ep_start && !strncmp (*ep + 2, name, len)
	      && (*ep)[len + 2] == '=')
	    return &(*ep)[len + 3];
	}
    }

  return NULL;
}
libc_hidden_def (getenv)

流程为:

  • 判断__environ是否为空或者传入的字符串是否为空,如果某一个为空直接返回空
  • 不为空的时候,就会根据传入的name + "="进行线性寻找,找到了就返回对应的字符串指针,没找到就返回空

unsetenv

stdlib\setenv. c:263

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
int
unsetenv (const char *name)
{
  size_t len;
  char **ep;

  if (name == NULL || *name == '\0' || strchr (name, '=') != NULL)
    {
      __set_errno (EINVAL);
      return -1;
    }

  len = strlen (name);

  LOCK;

  ep = __environ;
  if (ep != NULL)
    while (*ep != NULL)
      {
	if (! strncmp (*ep, name, len) && (*ep)[len] == '=')
	  {
	    /* Found it.  Remove this pointer by moving later ones back.  */
	    char **dp = ep;

	    do
		dp[0] = dp[1];
	    while (*dp++);
	    /* Continue the loop in case NAME appears again.  */
	  }
	else
	  ++ep;
      }

  UNLOCK;

  return 0;
}

总的来看,也是根据传入的键从__envrion变量从前往后查找环境变量,如果找到了,就会删除找到的这个环境变量。但是这里并没有处理删除的环境变量的指针,依赖调用方去释放内存。

clearenv

stdlib\setenv. c:305

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
int
clearenv (void)
{
  LOCK;

  if (__environ == last_environ && __environ != NULL)
    {
      /* We allocated this environment so we can free it.  */
      free (__environ);
      last_environ = NULL;
    }

  /* Clear the environment pointer removes the whole environment.  */
  __environ = NULL;

  UNLOCK;

  return 0;
}
#ifdef _LIBC
libc_freeres_fn (free_mem)
{
  /* Remove all traces.  */
  clearenv ();

  /* Now remove the search tree.  */
  __tdestroy (known_values, free);
  known_values = NULL;
}

如果last_environ == __environ,就说明已经用堆分配内存了,就会遍历__environ并将其每一个字符串指向的内存释放掉,然后将__environ置为NULL

如果使用了二叉树保存环境变量的话还会删除二叉树。

小节

总结一下以上有关环境变量操作的函数:

  • setenv会拷贝一份环境变量,然后插入或替换环境变量到环境变量数组中
  • putenv直接使用传入的环境变量字符串指针,然后把这个指针插入到环境变量数组中或者替换原有指针
  • unsetenv会删除环境变量,但并不会处理被删除的那个环境变量的指针
  • 在查找环境变量的时候,一般是从前往后线性的查找
  • 一旦插入了新的环境变量,__environ会指向一个堆内存指针而不是栈内存
  • 在环境变量变更的过程中,可能会调用reallocmallocfree,还有可能在栈上存有环境变量的内容

bash 对环境变量的处理

当我分析完glibc中对环境变量的查找流程后,下意识地以为bash中对环境变量的处理也是类似的用列表去处理,犯了经验主义的错误。而在查阅了bash中的环境变量处理相关代码后发现,bash处理环境变量使用的数据结构和glibc并不一样。

https://ftp.gnu.org/gnu/bash/bash-5.1.tar.gz下载bash源码,然后定位到main函数,目前关注环境变量的处理部分简易调用链为:

1
2
3
4
5
main ()
	shell_initialize ()
		initialize_shell_variables ()
			bind_variable (): variables. c #3253
				bind_variable_internal (): variables. c #3084

因为bash需要处理的场景比较多,如果有机会,可以再写一篇博客分析bash的处理流程。

定位到bind_variable_internal函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
/* Bind a variable NAME to VALUE in the HASH_TABLE TABLE, which may be the
   temporary environment (but usually is not).  HFLAGS controls how NAME
   is looked up in TABLE; AFLAGS controls how VALUE is assigned */
static SHELL_VAR *
bind_variable_internal (name, value, table, hflags, aflags)
     const char *name;
     char *value;
     HASH_TABLE *table;
     int hflags, aflags;
{
  char *newval, *tname;
  SHELL_VAR *entry, *tentry;
    
  entry = (hflags & HASH_NOSRCH) ? (SHELL_VAR *) NULL : hash_lookup (name, table);
    //....
    
}

从注释可以看出来bash中使用hash table来处理变量,当然也包括环境变量。

那么,结合上面分析的system实现细节,当调用system ("xxx")的时候,实际会执行execve ("/bin/sh", {"sh", "-c", "xxx"}, __environ),然后在bash中,会从前往后遍历__environ数组中的每一个字符串,然后分割出keyvalue,接着根据key将字符串存入到hash表里面。

也就是说,如果__enviorn指向的环境变量数组中有多个key相同的环境变量字符串,那么在bash中处理后,生效的永远是最后那一个。

经过对hashlib. c的分析,发现bash中对hash table的存储方式是拉链法,采用的hash函数为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
/* This is the best 32-bit string hash function I found. It's one of the
   Fowler-Noll-Vo family (FNV-1).

   The magic is in the interesting relationship between the special prime
   16777619 (2^24 + 403) and 2^32 and 2^8. */

#define FNV_OFFSET 2166136261
#define FNV_PRIME 16777619

/* If you want to use 64 bits, use
FNV_OFFSET	14695981039346656037
FNV_PRIME	1099511628211
*/

/* The `khash' check below requires that strings that compare equally with
   strcmp hash to the same value. */
unsigned int
hash_string (s)
     const char *s;
{
  register unsigned int i;

  for (i = FNV_OFFSET; *s; s++)
    {
      /* FNV-1 a has the XOR first, traditional FNV-1 has the multiply first */

      /* was i *= FNV_PRIME */
      i += (i<<1) + (i<<4) + (i<<7) + (i<<8) + (i<<24);
      i ^= *s;
    }

  return i;
}

看了下维基百科上的介绍,这个函数的散列性很好,简单而又高效。

因此,当一对键值对字符串被放入哈希表时,过程是这样的:

  • 根据key字符串计算一个index
  • 在数组链表中查找对应的元素是否为空
  • 如果元素不存在,则直接将value放置在这里,并记录下对应的key
  • 如果存在,首先判断一下key在不在链表中,如果在,那么替换value;如果不在,那么新建一个结构体,把keyvalue的信息在里面,最后用头插法的方式放入链表中

因此,如果存在相同得环境变量,处于链表后面的环境变量会覆盖之前的环境变量。

环境变量的攻击面

这篇博客写到一半的时候,因为一些缘故挺了下来,现在我也不记得大纲了。而我也已经有1年的时间没有从事安全方面的研究。继续编写此小节是为了补全该博客,但此小节可能不会像之前写得那么仔细。

至于环境变量的供给面,这里我姑且总结一些点,便由读者自行发散吧。有些最新的利用点我还没有学习,如果没有提及,还请见谅。

  1. 利用环境变量泄露信息,如典型的__environ变量泄露栈地址
  2. 利用一些敏感的环境变量,如PATH
  3. 结合SUID位进行利用
  4. 通过环境变量的覆盖,覆盖原有的环境变量
  5. glibc中利用GLIBC_TUNABLES环境变量

思考与总结

本文总结了在 glibcsystem 函数的一般实现,并分析了与环境变量有关的相关函数,同时,总结了环境变量的一些攻击面!

Buy me a coffee~
roderick 支付宝支付宝
roderick 微信微信
0%