NX(No-eXecute)的實現分析【轉】


轉自:https://hardenedlinux.github.io/system-security/2016/06/01/NX-analysis.html

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來完成,assemble由GNU as完成,link由 GNU 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

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM