NX(No-eXecute)的实现分析

Shawn: GNU/Linux系统级攻防在历史上曾经停留在用户空间很长的时间,经历了NX/COOKIE/PIE/ASLR/RELRO的进化后后0ldsk00l以及security “researcher”们已经无法通过用户空间触及到“上帝宝藏”(-root-),sgrakkyu和twzi在Phrack Issue 64中的Attacking the Core标志着这个领域正式进入了内核层面的对抗,10年过去了,在新的时代性背景下(Android/IoT/TEE),人们意识到安全应该是一个整体(again?WTH),而单纯依赖于内核层面的攻防无法解决很多老问题,传统的mitigation技术再次在某些场景化的方案中受到重视,NX(armv6中是XN)是其中之一,栈的不可执行最早是由PaX team实现的PAGEEXECSEGEXEC,后来Intel CPU在硬件上支持NX后Ingo Molnar给出了硬件NX的第一版实现Fedora的用户尝鲜,后来则进入了Linux mainline。这篇文档详细的分析了GCC/ld/kernel三个层面的NX的工作路线图。Enjoy it!

NX(No-eXecute)的实现分析

@(mitigation)[NX|gcc|binutils|kernel] –zet

00 导引

以下的代码分析仅限linux kernel/gcc/GNU binutils-as/GNU binutils-ld/ELF.

在计算机安全领域一个很经典的话题就是缓冲区溢出(Buffer Overflow).缓冲区溢出一般时 候伴随着攻击者的篡改堆栈里保存的返回地址,然后执行注入到stack中的shellcode,攻击者 可以发挥想象力仔细编写shellcode进行下一步的攻击,直到完全控制了计算机.这种攻击之 所以能够成功主要原因就是因为stack里的shellcode的可执行.所以主要的防御手段 (mitigation)就是禁止stack里数据的执行(noexecstack).

Noexecstack的实现主要出现在两个地方: compiler-assembler-linker(这里表示一个生成 binary的过程: 编译->汇编->链接器)里和kernel里.在compiler-assembler-linker里的实 现基本上的纯粹的软件实现,结果是在elf的一个stack的section里置位不可以执行.但是捕 获违反stack不可执行这个问题是在kernel里.

在kernel里的实现,随着处理器在(页模式)paging处理过程中涉及到功能寄存器中引入 No-eXecute的配置位,所以实际上kernel在实现NX的时候是在相关的寄存器里置NX的位,在 CPU操作的时候由硬件来做是否可以执行的检查.

本文的描述描述顺序是先描述NX在gcc/binutils里的实现,然后再描述在kernel里的实现.

本文的分析对应的gcc版本是6.1.0,binutils版本是2.26,linux kernel的版本是4.6

01 NX在gcc/binutils里面的实现

在gcc/ld里面有NX相关的选择,gcc/ld都是-z execstack/noexecstack,在gcc 6.1 manual里 跟-z相关的内容如下:

3.14 Options for Linking: -z keyword -z is passed directly on to the linker along with the keyword keyword. See the section in the documentation of your linker for permitted values and their meanings. –gcc 6.1 manual

也就是说gcc将参数-z execstack/noexecstack直接传给了ld(linker).

gcc -### -z execstack test.c

Using built-in specs.
COLLECT_GCC=gcc
COLLECT_LTO_WRAPPER=/usr/lib/gcc/x86_64-linux-gnu/4.8/lto-wrapper
Target: x86_64-linux-gnu
 /usr/lib/gcc/x86_64-linux-gnu/4.8/cc1 -quiet -imultiarch x86_64-linux-gnu 
test.c -quiet -dumpbase test.c "-mtune=generic" "-march=x86-64" -auxbase test 
-fstack-protector -Wformat -Wformat-security -o /tmp/ccgX6EXC.s

 // 调用as
 as --64 -o /tmp/ccVl7H5u.o /tmp/ccgX6EXC.s

 // 调用collect2
 /usr/lib/gcc/x86_64-linux-gnu/4.8/collect2 "--sysroot=/" --build-id 
--eh-frame-hdr -m elf_x86_64 "--hash-style=gnu" --as-needed -dynamic-linker 
/lib64/ld-linux-x86-64.so.2 -z relro 
// 传入的参数
-z execstack 
/usr/lib/gcc/x86_64-linux-gnu/4.8/../../../x86_64-linux-gnu/crt1.o 
/usr/lib/gcc/x86_64-linux-gnu/4.8/../../../x86_64-linux-gnu/crti.o 
/usr/lib/gcc/x86_64-linux-gnu/4.8/crtbegin.o -L/usr/lib/gcc/x86_64-linux-gnu/4.8
 -L/usr/lib/gcc/x86_64-linux-gnu/4.8/../../../x86_64-linux-gnu 
-L/usr/lib/gcc/x86_64-linux-gnu/4.8/../../../../lib -L/lib/x86_64-linux-gnu 
-L/lib/../lib -L/usr/lib/x86_64-linux-gnu -L/usr/lib/../lib 
-L/usr/lib/gcc/x86_64-linux-gnu/4.8/../../.. /tmp/ccVl7H5u.o -lgcc --as-needed
-lgcc_s --no-as-needed -lc -lgcc --as-needed -lgcc_s --no-as-needed 
/usr/lib/gcc/x86_64-linux-gnu/4.8/crtend.o 
/usr/lib/gcc/x86_64-linux-gnu/4.8/../../../x86_64-linux-gnu/crtn.o

接下来将会详细描述gcc将-z execstack这一参数怎样传给ld(linker)以及这一参数对生成 的ELF文件产生怎样的影响,最后将会分析这样的影响是如何导致stack被执行在kernel里捕 获的.

NX在gcc里的处理

由上面的gcc -###输出可知道gcc只是一个外壳管理程序,严格来说是一个 driver,根据传入的参数来控制各个compile/assemble/link过程.在gcc实现里 compile过程由cc1来完成,assembleGNU as完成,linkGNU ld来完成(GNU社区里有一个备选的链接器:gold).

gcc遇到-z execstack这样的选项时的处理代码如下:

在我的机器上gcc的目录是$HOME/github/gcc

export $SRC=$HOME/github/gcc

// $SRC/gcc/gcc-main.c
// gcc driver的入口代码
int
main (int argc, char **argv)
{
  driver d (false, /* can_finalize */
	    false); /* debug */

  return d.main (argc, argv);
}

// src/gcc/gcc.c
/* driver::main is implemented as a series of driver:: method calls.  */
int
driver::main (int argc, char **argv)
{
  bool early_exit;
	
  set_progname (argv[0]);  // 对调用gcc时指定的名字做处理,删掉前面的目录名,不需
  要过多研究expand_at_files (&argc, &argv); // 对参数做一些扩展处理
  decode_argv (argc, const_cast <const char **> (argv)); // 下面分析
  // 下面的这些函数都是在做gcc的常规处理,跟本文主题关系不大
  global_initializations ();
  build_multilib_strings ();
  set_up_specs ();
  putenv_COLLECT_GCC (argv[0]);
  maybe_putenv_COLLECT_LTO_WRAPPER ();
  maybe_putenv_OFFLOAD_TARGETS ();
  handle_unrecognized_options ();

  if (!maybe_print_and_exit ())
    return 0;

  early_exit = prepare_infiles ();
  if (early_exit)
    return get_exit_code ();
  
  do_spec_on_infiles (); // 这里会调用cc1和as
  maybe_run_linker (argv[0]); // 这里会调用ld
  final_actions ();
  return get_exit_code ();
}

由上面的代码注释可知,我们需要研究3个main入口里的函数,下面依次进行分析.由于代码过 多,下面的分析会删掉跟本文主题无关的代码,函数调用关系由->表示.如果不做申明,源 代码位于src/gcc/gcc.c这个文件里.

在分析之前需要说清楚另外一个问题,那就是-z这个参数的定义问题.

cd $HOME/github/gcc
mkdir build
cd build
../configure --prefix="$HOME/bin" --disable-nls --enable-languages=c,c++
make -j8
make install

编译完成之后,会在build/gcc里有两个跟本文的主题有关的文件: options.c/options.h.这两个文件跟gcc的编译选项处理有很大关系,这两个文件的 生成是几个$SRC/gcc里的awk脚本共同作用的结果.

// Makefile.in
// 注意这里的输入是$(ALL_OPT_FILES)

optionlist: s-options ; @true
s-options: $(ALL_OPT_FILES) Makefile $(srcdir)/opt-gather.awk
	$(AWK) -f $(srcdir)/opt-gather.awk $(ALL_OPT_FILES) > tmp-optionlist
	$(SHELL) $(srcdir)/../move-if-change tmp-optionlist optionlist
	$(STAMP) s-options
options.c: optionlist $(srcdir)/opt-functions.awk $(srcdir)/opt-read.awk \
    $(srcdir)/optc-gen.awk
	$(AWK) -f $(srcdir)/opt-functions.awk -f $(srcdir)/opt-read.awk \
	       -f $(srcdir)/optc-gen.awk \
	       -v header_name="config.h system.h coretypes.h options.h tm.h" < $< > $@	       
options.h: s-options-h ; @true
s-options-h: optionlist $(srcdir)/opt-functions.awk $(srcdir)/opt-read.awk \
    $(srcdir)/opth-gen.awk
	$(AWK) -f $(srcdir)/opt-functions.awk -f $(srcdir)/opt-read.awk \
	       -f $(srcdir)/opth-gen.awk \
	       < $< > tmp-options.h
	$(SHELL) $(srcdir)/../move-if-change tmp-options.h options.h
	$(STAMP) $@

// options.h/options.c输入文件,也就时生成gcc选项处理代码的配置文件.加选项只需要
// 修改这些配置文件,很方便.
# All option source files
ALL_OPT_FILES=$(lang_opt_files) $(extra_opt_files)

// 编译选项配置文件
lang_opt_files=@lang_opt_files@ $(srcdir)/c-family/c.opt $(srcdir)/common.opt

由上面的代码可以知道编译选项的生成过程是输入配置文件,然后awk脚本处理配置文件,然 后输出options.h/options.c

// $SRC/gcc/common.opt里关于-z的内容如下:
// 注意z底下的三个配置,Driver表示这是一个driver处理的选项(考虑一些debug的配置选
项),Joined/Separate表示-z与跟-z本身相对于的参数(在本文中当然是指execstack/
noexecstack)之间需不需要空白符隔开.

z   
Driver Joined Separate

相应的生成代码是:

OPT_z = 1251, 		/* -z */

接着进行gcc对参数处理的分析

// 处理调用gcc时的参数存入decoded_options数组里
decode_argv()->decode_cmdline_options_to_array()->decode_cmdline_option()

static unsigned int
decode_cmdline_option (const char **argv, unsigned int lang_mask,
		       struct cl_decoded_option *decoded)
{
  // awk处理参数配置文件时会将这些参数存进一个数组里cl_options[]
  // 这里argv[0] + 1的值是'z',由此来找到-z在cl_options数组里的索引值
  opt_index = find_opt (argv[0] + 1, lang_mask);
  // const struct cl_option *option
  option = &cl_options[opt_index];
  // -z在配置文件里的定义是Joined Separate,所以会进入这个代码块
  if (joined_arg_flag)
    {
      // 注意下面的+1,arg的值会是"-z"里z之后的下一个字符,是'\0'
      arg = argv[extra_args] + cl_options[opt_index].opt_len + 1 + adjust_len;
      //cl_missing_ok表示-z后面不接参数是否可以,显然是不行
      if (*arg == '\0' && !option->cl_missing_ok)
	{
	  // -z的另外一个配置:Separate
	  if (separate_arg_flag)
	    {
	      // 这里arg的值应该是"execstack"
	      arg = argv[extra_args + 1];
	      result = extra_args + 2;
	      if (arg == NULL)
		result = extra_args + 1;
	      else
		have_separate_arg = true;
	    }
	  else
	      /* Missing argument.  */
	      arg = NULL;
	}
    }
    // 删掉无关代码
    // 这个结构是要传会给调用者的
    decoded->opt_index = opt_index; // OPT_z在cl_options[]里的索引
    decoded->arg = arg;  // "execstack"
    decoded->value = value;
    decoded->errors = errors;
    decoded->warn_message = warn_message;
    // 后面的代码会处理别的参数,在本文的例子里就是待编译的文件:test.c

gcc处理完参数之后会进行对输入文件的各种处理.当然在上面的分析中可知输入文件也是 被处理参数的代码处理的,只不过decoded->opt_index表示这就是输入文件.

gcc drivercc1/as/ld的调用都是通过一个spec文件来进行的,也是一种配置文件. spec配置的语法定义于 [gcc manual: 3.19 Specifying Subprocesses and the Switches to Pass to Them] (https://gcc.gnu.org/onlinedocs/gcc/Spec-Files.html), 与本文涉及到的比较重要的语法如下:

%a 处理as的相关调用. 默认的spec文件叫: asm

%A 处理as相关的调用,默认的spec文件是: asm_final

%(name) 类似于宏替换,将之前定义的name在这里展开

%{S} 当选项S给出时,用-S替换S,注意这里的S是元字符

%{S:X} 对X进行替换操作,当选项-S给出时

%{!S:X} 对X进行替换操作,当选项-S没有给出时

gcc driver对可以调用的子工具的存储在一个统一的数组里.其中compiler->spec就是 相应工具默认的调用参数.


/* The default list of file name suffixes and their compilation specs.  */
static const struct compiler default_compilers[] =
{
  // 只留下部分代表性的数据
  {".cc", "#C++", 0, 0, 0}, {".cxx", "#C++", 0, 0, 0},
  {".cpp", "#C++", 0, 0, 0}, {".cp", "#C++", 0, 0, 0},
  {".c++", "#C++", 0, 0, 0}, {".C", "#C++", 0, 0, 0},
  {".CPP", "#C++", 0, 0, 0}, {".ii", "#C++", 0, 0, 0},
  {".ads", "#Ada", 0, 0, 0}, {".adb", "#Ada", 0, 0, 0},
  {".go", "#Go", 0, 1, 0},
  /* Next come the entries for C.  */
  {".c", "@c", 0, 0, 1},
  // 这里是cc1的spec文件
  {"@c",
     "%{E|M|MM:%(trad_capable_cpp) %(cpp_options) %(cpp_debug_options)}\
      %{!E:%{!M:%{!MM:\
          %{traditional:\
      %eGNU C no longer supports -traditional without -E}\
      %{save-temps*|traditional-cpp|no-integrated-cpp:%(trad_capable_cpp) \
	  %(cpp_options) -o %{save-temps*:%b.i} %{!save-temps*:%g.i} \n\
	    cc1 -fpreprocessed %{save-temps*:%b.i} %{!save-temps*:%g.i} \
	  %(cc1_options)}\
      %{!save-temps*:%{!traditional-cpp:%{!no-integrated-cpp:\
	  cc1 %(cpp_unique_options) %(cc1_options)}}}\
	  // 注意这里!fsyntax-only:%(invoke_as)表示,如果-fsyntax-only没有指定,那
	  // 么就调用as(invoke_as),invoka_as    将会在这里在这里展开,invoke_as定
	  // 义见下面.
      %{!fsyntax-only:%(invoke_as)}}}}", 0, 0, 1},
      {"-",
      "%{!E:%e-E or -x required when input is from standard input}\
      %(trad_capable_cpp) %(cpp_options) %(cpp_debug_options)", 0, 0, 0},
  // 当gcc -S时
  {".s", "@assembler", 0, 0, 0},
  {"@assembler",
   "%{!M:%{!MM:%{!E:%{!S:as %(asm_debug) %(asm_options) %i %A }}}}", 0, 0, 0},

#include "specs.h"
  /* Mark end of table.  */
  {0, 0, 0, 0, 0}
};

// 当gcc编译完输入文件之后,调用as时的driver spec定义.
static const char *invoke_as =
#ifdef AS_NEEDS_DASH_FOR_PIPED_INPUT
"%{!fwpa*:\
   %{fcompare-debug=*|fdump-final-insns=*:%:compare-debug-dump-opt()}\
   // 在下面可以看到很明显的as调用.
   %{!S:-o %|.s |\n as %(asm_options) %|.s %A }\
  }";
#else
"%{!fwpa*:\
   %{fcompare-debug=*|fdump-final-insns=*:%:compare-debug-dump-opt()}\
   %{!S:-o %|.s |\n as %(asm_options) %m.s %A }\
  }";
#endif

接着将会是gcc driver调用相应的工具程序处理输入源文件.由上面的spec可以看到默 认的cc1/as调用以输出汇编代码.

/* 处理输入源文件,根据相应的工具程序的spec输出汇编代码*/
void
driver::do_spec_on_infiles () const
{
  size_t i;

  for (i = 0; (int) i < n_infiles; i++)
    {
      // 根绝输入文件的后缀查找编译器,就是找到上面的default_compilers[]里面的一个
      input_file_compiler
          = lookup_compiler (infiles[i].name, input_filename_length,
			   infiles[i].language);

      if (input_file_compiler) {
	  if (input_file_compiler->spec[0] == '#')
	    ;
	  else {
	      int value;
	      // 根据spec文件来调用相应的工具程序,这里会输出汇编
	      value = do_spec (input_file_compiler->spec);
	      infiles[i].compiled = true;
	  }
	}    
}

最后将是gcc调用linker来处理汇编代码,在这里本文将会研究-z execstack的传递过程.

driver::maybe_run_linker() -> do_spec(link_command_spec);

#define link_command_spec LINK_COMMAND_SPEC
#define LINK_COMMAND_SPEC "\
%{!fsyntax-only:%{!c:%{!M:%{!MM:%{!E:%{!S:\
    // linker在这里定义为collect2,其实只是一个GNU ld的包装
    %(linker) " \
    LINK_PLUGIN_SPEC \
    "%{flto|flto=*:%<fcompare-debug*} \
    %{flto} %{fno-lto} %{flto=*} %l " LINK_PIE_SPEC \
    "%{fuse-ld=*:-fuse-ld=%*} " LINK_COMPRESS_DEBUG_SPEC \
    "%X %{o*} %{e*} %{N} %{n} %{r}\
    // 这里第4个就是本文关注的-z选项
    %{s} %{t} %{u*} %{z} %{Z} %{!nostdlib:%{!nostartfiles:%S}} \
    %{static:} %{L*} %(mfwrap) %(link_libgcc) " \
    VTABLE_VERIFICATION_SPEC " " SANITIZER_EARLY_SPEC " %o " CHKP_SPEC " \
    %{fopenacc|fopenmp|%:gt(%{ftree-parallelize-loops=*:%*} 1):\
	%:include(libgomp.spec)%(link_gomp)}\
    %{fcilkplus:%:include(libcilkrts.spec)%(link_cilkrts)}\
    %{fgnu-tm:%:include(libitm.spec)%(link_itm)}\
    %(mflib) " STACK_SPLIT_SPEC "\
    %{fprofile-arcs|fprofile-generate*|coverage:-lgcov} " SANITIZER_SPEC " \
    %{!nostdlib:%{!nodefaultlibs:%(link_ssp) %(link_gcc_c_sequence)}}\
    %{!nostdlib:%{!nostartfiles:%E}} %{T*}  \n%(post_link) }}}}}}"

接着本文将会分析当遇到LINK_COMMAND_SPEC里的%{z}时进行的操作.

do_spec()->do_spec_2()->do_spec_1()

static int
do_spec_1 (const char *spec, int inswitch, const char *soft_matched_part) {
  // 在这里本文关注的spec将会是%{z}
  const char *p = spec;
  while ((c = *p++))
    switch (inswitch ? 'a' : c) {
      case '%':
	    switch (c = *p++)
		  case '{':
		    p = handle_braces (p);
		    break;

do_spec_1()->handle_braces()

static const char *
handle_braces (const char *p) {
  // 标记"z}"的起始和结束
  atom = p;
  while (ISIDNUM (*p) || *p == '-' || *p == '+' || *p == '='
		 || *p == ',' || *p == '.' || *p == '@')
    p++;
    end_atom = p;
    // p当前的值应该是'}'
    switch (*p) {
	case '&': case '}':
        /** 
        struct switchstr {
          const char *part1;
          const char **args;
          unsigned int live_cond;
          bool known;
          bool validated;
          bool ordering;
        };
        struct switchstr switches[];
        在这里的时候switches[]里面存储的是调用gcc时候的参数,其中有一项是{"z", &"execstack",}
        根据'z'在switches[]里面置part1是"z"的这一项的ordering为1 */
	  
	mark_matching_switches (atom, end_atom, a_is_starred);
	if (*p == '}')
	  process_marked_switches ();
	break;
    }  
}

do_spec_1()->handle_braces()->process_marked_switches()

static inline void
process_marked_switches (void) {
  int i;

  for (i = 0; i < n_switches; i++)
    // 根据上面的ordering的标记调用give_switch (i, 0)
    if (switches[i].ordering == 1) {
	  switches[i].ordering = 0;
	  give_switch (i, 0);
    }
}

do_spec_1()->handle_braces()->process_marked_switches()->give_switch()

static void
give_switch (int switchnum, int omit_first_word) {
  if (!omit_first_word) {
      do_spec_1 ("-", 0, NULL);
      // 这里的part1是"z",这个函数最终的处理会将"z"压入一个类似于C++ STL vertor
      // 的容器argbuf里
      do_spec_1 (switches[switchnum].part1, 1, NULL);
  }

  if (switches[switchnum].args != 0) {
      const char **p;
      for (p = switches[switchnum].args; *p; p++) {
	    const char *arg = *p;
	    // 这里arg的值将会是"execstack",这个函数会将execstack压入argbuf里,到
	    // 这里argbuf的值已经是"z execstack"了,由上面的link_command_spec定义
	    // 中的%{z}和spec文件的相关语义,gcc最终对linker的调用将会是:
	    // collect2 -z execstack ... 这个样子的
	    do_spec_1 (arg, 1, NULL);
	  }
    }
    // ...
}

NX在ld里面的处理

下面将会分析linker遇到-z execstack时进行怎样的处理.对生成的ELF文件产生怎样的 影响.

在binutils/include/elf/common.h里与execstack/noexecstack相关的定义如下:

#define PF_X		(1 << 0)	/* Segment is executable */
#define PF_W		(1 << 1)	/* Segment is writable */
#define PF_R		(1 << 2)	/* Segment is readable */

由于篇幅所限,下面仅仅分析当ld被调用时与-z execstack相关的代码.

	/**
	bfd_link_info里分别有两个位域: 
	unsigned int execstack: 1;
    	unsigned int noexecstack: 1; */
	struct bfd_link_info link_info;
// GUN linker的入口函数
int
main (int argc, char **argv) {
  // 给bfd_link_info赋一些默认值
  // 处理参数,不过-z execstack的处理代码是在binutils里架构相关的结构里定义的
  parse_args (argc, argv);
  // 做一些分配地址之前的准备工作
  lang_process ();
  // 生成一个elf文件
  ldwrite ();
  // ...
}

main()->parse_args()->ldemul_handle_option()->ld_emulation->handle_option()

/** ld_emulation是一个跟架构相关的结构,binutils里面根据后端的不同分开定义是为了
移植和feature的方便,在项目里是很常见的工程设计.*/

// EMULATION_NAME是一个类似于elf_x86_64这样的名字
static bfd_boolean
gld${EMULATION_NAME}_handle_option (int optc) {
  switch (optc) {
    case 'z':
    if (strcmp (optarg, "execstack") == 0) {
      // 在link_info里保存置相关的位
	  link_info.execstack = TRUE;
	  link_info.noexecstack = FALSE;
	} else if (strcmp (optarg, "noexecstack") == 0) {
	  link_info.noexecstack = TRUE;
	  link_info.execstack = FALSE;
	}
	// ...
  }
}

main()->lang_process ()->ldemul_before_allocation()->ld_emulation->before_allocation()

// ld_emulation的初始化定义为:
struct ld_emulation_xfer_struct ld_${EMULATION_NAME}_emulation =
{
  gld${EMULATION_NAME}_before_parse,
  syslib_default,
  hll_default,
  after_parse_default,
  after_open_default,
  after_allocation_default,
  set_output_arch_default,
  ldemul_default_target,
  // ld_emulation->before_allocation()的调用会是这个函数
  gld${EMULATION_NAME}_before_allocation,
  //...
};

gld${EMULATION_NAME}_before_allocation()->bfd_elf_size_dynamic_sections()

bfd_boolean
bfd_elf_size_dynamic_sections (bfd *output_bfd,
			       const char *soname,
			       const char *rpath,
			       const char *filter_shlib,
			       const char * const *auxiliary_filters,
			       struct bfd_link_info *info,
			       asection **sinterpptr,
			       struct bfd_elf_version_tree *verdefs) {
  
  // 根据参数的分析结果,也就是bfd_link_info结构中的execstack/noexecstack来置位
  // stack_flags
  if (info->execstack)
    elf_tdata (output_bfd)->stack_flags = PF_R | PF_W | PF_X;
  else if (info->noexecstack)
    elf_tdata (output_bfd)->stack_flags = PF_R | PF_W;
  // ...
}

ld_write()将会是linker的最后一步,正确执行完将会得到一个目标文件(比如说ELF 格式的可执行文件)

main->ld_write()->bfd_final_link()->bfd_elf_final_link()
->_bfd_elf_compute_section_file_positions()
->assign_file_positions_except_relocs()->assign_file_positions_for_segments()
->map_sections_to_segments()

// 这个结构描述section到segment的对应关系
struct elf_segment_map
{
  /* Next program segment.  */
  struct elf_segment_map *next;
  unsigned long p_type;
  unsigned long p_flags;
  bfd_vma p_paddr;
  unsigned int p_flags_valid : 1;
  unsigned int p_paddr_valid : 1;
  unsigned int includes_filehdr : 1;
  unsigned int includes_phdrs : 1;
  /* 这个segment包含的section数目*/
  unsigned int count;
  /* Sections*/
  asection *sections[1];
};

map_sections_to_segments() {
  // 删掉跟本文无关的代码
  struct elf_segment_map *m;
  if (elf_tdata (abfd)->stack_flags) {
    amt = sizeof (struct elf_segment_map);
    m = bfd_zalloc (abfd, amt);
    if (m == NULL)
	  goto error_return;
    m->next = NULL;
    m->p_type = PT_GNU_STACK;
    /** 根据stack_flags来置位segment的p_flags,最终这个值就是ELF文件的
    Program Header里的p_flag的值,在ELF1.2标准里定义的可选值是: 
    PF_X 0x1
    PF_W 0x2
    PF_R 0x4
    */
    m->p_flags = elf_tdata (abfd)->stack_flags;
    m->p_flags_valid = 1;

    *pm = m;
    pm = &m->next;
  }
}

NX在kernel里的捕获

上面已经介绍了gcc/ld里对-z execstack的处理,总共的影响就是在ELF文件里对应的 program header里置相关的位.下面将会描述在ELF文件里这样的置位前提下,如果违反了 访问规则,kernel如何捕获非法访问.

一般来说kernel执行一个binary的时候会进行下面的代码调用链:

do_execve()->search_binary_handler()->linux_binfmt.load_binary()
// elf文件的情况下load_binary()实际是就是调用load_elf_binary()
static struct linux_binfmt elf_format = {
	.module		= THIS_MODULE,
	.load_binary	= load_elf_binary,
	.load_shlib	= load_elf_library,
	.core_dump	= elf_core_dump,
	.min_coredump	= ELF_EXEC_PAGESIZE,
};

static int load_elf_binary(struct linux_binprm *bprm, struct pt_regs *regs) {
  int executable_stack = EXSTACK_DEFAULT;
  // bprm->buf里存储的就是elf文件的二进制流,读入elf header
  loc->elf_ex = *((struct elfhdr *)bprm->buf);
  // e_phnum表示program header的数目,这里分配的存储是为了容纳elf里的
  // program header在进程地址空间里
  size = loc->elf_ex.e_phnum * sizeof(struct elf_phdr);
  retval = -ENOMEM;
  elf_phdata = kmalloc(size, GFP_KERNEL);
  if (!elf_phdata)
    goto out;
  // 读入elf文件program header进入地址空间
  retval = kernel_read(bprm->file, loc->elf_ex.e_phoff,
			     (char *)elf_phdata, size);
  // ...
  for (i = 0; i < loc->elf_ex.e_phnum; i++, elf_ppnt++)
    if (elf_ppnt->p_type == PT_GNU_STACK) {
      // 在ld里置位的p_flags
        if (elf_ppnt->p_flags & PF_X)
	  executable_stack = EXSTACK_ENABLE_X;
	else
	  executable_stack = EXSTACK_DISABLE_X;
        break;
    }
	//...
  retval = setup_arg_pages(bprm, randomize_stack_top(STACK_TOP),
				 executable_stack);

load_elf_binary()->setup_arg_pages()

// 处理加载的elf对应的进程的初始stack对应的vm_area_struct
int setup_arg_pages(struct linux_binprm *bprm,
		    unsigned long stack_top,
		    int executable_stack) {	
	struct mm_struct *mm = current->mm;
	struct vm_area_struct *vma = bprm->vma;
	unsigned long vm_flags;
    	// ...
    	if (unlikely(executable_stack == EXSTACK_ENABLE_X))
		vm_flags |= VM_EXEC;
	else if (executable_stack == EXSTACK_DISABLE_X)
		vm_flags &= ~VM_EXEC;
	vm_flags |= mm->def_flags;
	vm_flags |= VM_STACK_INCOMPLETE_SETUP;
	// vm_flags里的位会加入到vma里
    	ret = mprotect_fixup(vma, &prev, vma->vm_start, vma->vm_end,
			vm_flags);
	// ...

注意上面建立的vm_area_struct只是存在于进程的虚拟地址空间里.并没有映射实际的RAM, 当这个ELF对stack进行访问时就会进入page fault,处理代码就是do_page_fault()

void do_page_fault(struct pt_regs *regs, unsigned long error_code) {
	// 忽略掉跟本文讨论无关的一系列kernel的检查过程
    	// 对于我们刚刚映射的stack VMA来说会执行到这里
good_area:
	// access_error()会对vma进行常规检查
	if (unlikely(access_error(error_code, vma))) {
		bad_area_access_error(regs, error_code, address);
		return;
	}
	// ...
}

do_page_fault()->access_error()

static inline int
access_error(unsigned long error_code, struct vm_area_struct *vma)
{
	if (error_code & PF_WRITE) {
	  /* write, present and write, not present: */
	    if (unlikely(!(vma->vm_flags & VM_WRITE)))
	      return 1;
	    return 0;
	}
	/* read, present: */
	if (unlikely(error_code & PF_PROT))
		return 1;
	// 假如调用gcc编译elf文件时给出的参数是-z noexecstack,那么stack 
	// vma->flags的VM_EXEC位肯定是清除了的.如果是这种情况,那么access_error()
	// 返回1,do_page_fault()也会返回上一级,对应的必将是kernel的报错.
	if (unlikely(!(vma->vm_flags & (VM_READ | VM_EXEC | VM_WRITE))))
	  return 1;

	return 0;
}

NX软件实现小结

总结一下前面的内容,就是当调用gcc -z execstack test.c时,gcc将参数打包处理传给ld, 由于参数的影响,ld会在生成的ELF文件stack对应的program header里置位p_flags的PF_X值, 当ELF文件执行时,由于RAM需要分配就会触发page fault,然后处理do_page_fault()函数里 调用access_error()以捕获到stack的执行权限错误.

02 NX在kernel/CPU里的实现:

NX在CPU里面的实现跟硬件有很大的关系.所以下面的描述先从硬件相关的寄存器开始描述, 然后进行kernel层面的描述.

NX相关的寄存器

Intel® 64 and IA-32 Architectures Developer’s Manual - System Programming Guide 2.2.1 Extended Feature Enable Register(EFER) IA32_EFER MSR提供了一些IA32e模式相关的使能配置位,还有另外一些位是跟page-access权 限相关的. typedef struct IA32_EFER { long SYSCALL_Enable : 1; // Enables SYSCALL/SYSRET instructions in 64-bit mode long Reserved : 7; // Reserved long IA-32e_Mode_Enable : 1; // Enables IA-32e mode operation long Reserved : 1; // Reserved long IA-32e_Mode_Active : 1; // Indicates IA-32e mode is active when set. long Execute_Disable_Bit_Enable : 1 // Enables page access restriction by preventing instruction // fetches from PAE pages with the XD bit set. // 我们感兴趣的这一位,也就是第12位,这一位也叫作NXE long : 0; } IA32_EFER;

IA32_EFER.NXE仅仅对PAE和IA-32e模式起作用(因为只有PAE/IA-32e模式下的paging单元(页 表项/页目录表项)是64位的).如果IA32_EFER.NXE = 1,从某一线性地址处的指令预取将会被 禁止,即使这一线性地址处的数据访问是允许的.

如果CPUID.80000001H:EDX.NX [bit 20] = 1, IA32_EFER.NXE才能够被设置为1,不支持 CPUID.80000001H的处理器IA32_EFER.NXE不能被设置为1.

4.4.2 Linear-Address Translation with PAE Paging 在PAE paging中,如果IA32_EFER.NXE = 0且PDE/ PTE的P是1, 则XD(63位)是保留的.

(PAE/PTE).63 (XD)跟IA32_EFER.NXE的功能是类似的,只不过存在于页表寄存器/页目录表 寄存器的最高位.

由上面的内容可以知道,要想在MMU层面使用NX,首先需要检测 CPUID.80000001H:EDX.NX [bit 20]是否为1,如果是1进行IA32_EFER.NXE的置位使能,然后按 照需在PAE/PTE里使能第63位(XD).

NX在kernel里的实现

// 在arch/x86/mm/Setup_nx.c有如下的代码:

static int disable_nx;
/*
 * noexec = on|off
 *
 * Control non-executable mappings for processes.
 *
 * on      Enable
 * off     Disable
 */
static int __init noexec_setup(char *str)
{
	if (!str)
		return -EINVAL;
	if (!strncmp(str, "on", 2)) {
		disable_nx = 0;
	} else if (!strncmp(str, "off", 3)) {
		disable_nx = 1;
	}
	x86_configure_nx();
	return 0;
}
// 注册到kernel的启动组件里,可以在boot参数里配置是否启用noexec
early_param("noexec", noexec_setup);

void x86_configure_nx(void)
{
	if (boot_cpu_has(X86_FEATURE_NX) && !disable_nx)
		__supported_pte_mask |= _PAGE_NX;
	else
		__supported_pte_mask &= ~_PAGE_NX;
}

// __supported_pte_mask的初始定义
pteval_t __supported_pte_mask __read_mostly = ~0;

// _PAGE_NX的定义与PAE/PTE的第63位(XD)对应
#if defined(CONFIG_X86_64) || defined(CONFIG_X86_PAE)
#define _PAGE_NX	1 << 63

MMU实现NX的在三层的paging结构中是类似的.下面的描述以PTE为代表来进行.

#define mk_pte(page, pgprot)   pfn_pte(page_to_pfn(page), (pgprot))
// 创建一个能进入PTE的项,对于本文的NX描述来说,这个值肯定是64位的.
static inline pte_t pfn_pte(unsigned long page_nr, pgprot_t pgprot)
{
	return __pte(((phys_addr_t)page_nr << PAGE_SHIFT) |
		     massage_pgprot(pgprot));
}

static inline pgprotval_t massage_pgprot(pgprot_t pgprot)
{
	pgprotval_t protval = pgprot_val(pgprot);
	if (protval & _PAGE_PRESENT)
	    // 这里对每一个实际作用的访问(_PAGE_PRESENT置位),pte_t(最终是要写入
	    // PTE项的)的值的产生都要经过__supported_pte_mask,这个变量里按需存储
	    // 了是否使用_PAGE_NX的信息.最终的pte_t的值会写入PTE项.
	    protval &= __supported_pte_mask;

	return protval;
}

写入PAT/PTE之后,就是CPU自己的操作了,paging之前CPU会检查相应的置位,以决定是否预取 指令.

03 总结

上面我们详细描述了NX在整个系统层的实现细节.在系统安全领域由于stack作为一个可以写 的存储区所以很容易作为攻击者的目标,stack overflow作为经典而且古老的攻击方式给 stack植入shellcode然后因为stack的可执行性,让攻击的门槛非常低.后来引入了canary的 机制,但是canary容易被bypass,真正解决这个问题就是NX的引入,所以NX其实是stack overflow攻击的最重要的解决方案.

live long and prosper