__PAGEZEROセグメントでヌルぽ体験

前回、Mach-Oのセグメント定義をロードしたときに__PAGEZEROセグメントと言うのが出てきた。興味を引かれたのでもうちょっと触ってみる。環境はx86_64。


__PAGEZEROセグメントに関して、アップルのリファレンスにはこう書いてある。

__PAGEZEROセグメントは実行形式ファイルの最初のセグメントとして静的リンカによって作成される。このセグメントは仮想メモリアドレス 0 に配置され、メモリ保護属性は全て禁止と設定される。このために、大抵の場合はプログラミングのミスである、NULL へのアクセスが発生するとプログラムは直ちに異常終了することになる。
__PAGEZEROセグメントのサイズは現在のアーキテクチャでの仮想メモリページの1ページ分である。(Intel, PowerPC系ならば 4096 バイト) __PAGEZEROにはデータが含まれないため、ファイル上でのサイズは 0 となる(segment command の filesize が 0)。


つまり、OS XC言語では NULL は 0 であり、NULLポインタ経由でデータやコードにアクセスすると、仮想メモリアドレス 0 の位置にアクセスすることになり、でもその場所は読み書き実行全て禁止されているのでメモリ保護違反でプロセスは強制終了させられますよー。ということかな。

セグメントが配置される仮想メモリの保護属性は segment_command_64 構造体の以下のメンバで表される。

vm_prot_t maxprot;   /* maximum VM protection */
vm_prot_t initprot;  /* initial VM protection */

ビットごとに保護の意味が定義されていて、 mach/vm_prot.h で確認できる。

(抜粋。ほんとはもっとコメントがいっぱい)
#define VM_PROT_NONE       ((vm_prot_t) 0x00)
#define VM_PROT_READ       ((vm_prot_t) 0x01)  /* read permission */
#define VM_PROT_WRITE      ((vm_prot_t) 0x02)  /* write permission */
#define VM_PROT_EXECUTE    ((vm_prot_t) 0x04)  /* execute permission */
#define VM_PROT_DEFAULT    (VM_PROT_READ|VM_PROT_WRITE)
#define VM_PROT_ALL        (VM_PROT_READ|VM_PROT_WRITE|VM_PROT_EXECUTE)
#define VM_PROT_NO_CHANGE  ((vm_prot_t) 0x08)
#define VM_PROT_COPY       ((vm_prot_t) 0x10)
#define VM_PROT_WANTS_COPY ((vm_prot_t) 0x10)

実験

次の事を確認するために実験してみる

  • 実行形式ファイルの__PAGEZERO セグメントのメモリ保護属性は 「全て禁止」になっているか
  • 仮想メモリアドレス 0 にアクセスするとプログラムは異常終了するか
  • メモリ保護属性を 「全て許可」 に変更すると 仮想メモリアドレス 0 にアクセスしても異常終了しなくなるか


次のようなコードで実験してみる。(zero.s)

01: .text
02: .globl start
03: start:
04:     movq $0, %rax           # NULL 
05:     movq hoge(%rip), %rdx   # prepare data
06:     movq %rdx, (%rax)       # write to *NULL
07:     xorq %rdx, %rdx
08:     movq (%rax), %rdx       # load from *NULL
09: 
10:     movq $0x2000001, %rax
11:     movq hoge(%rip), %rdi
12:     syscall
13: .data
14: hoge:
15:     .word 0x1234

ところで、メモリアクセスにはセグメント機構というものを経由するはずだが、このコードで仮想メモリアドレス 0 にアクセスすることになるのかな??
なります。x86_64ではフラットメモリモデルなので全てのセグメントのベースは 0。つまりリニアアドレス=実効アドレス。(注: ここで言うセグメントはMach-Oのセグメントとは別物。GDTで定義されるセグメントのこと。)
(Intel 64 and IA-32 Architectures Software Developer’s Manual Volume 1参照)


コンパイル

$ as -o zero.o zero.s && ld -o zero zero.o


__PAGEZERO セグメントのメモリ保護属性を確認

$ otool -l zero
zero:
Load command 0
      cmd LC_SEGMENT_64
  cmdsize 72
  segname __PAGEZERO
   vmaddr 0x0000000000000000
   vmsize 0x0000000000001000
  fileoff 0
 filesize 0
  maxprot 0x00000000
 initprot 0x00000000
   nsects 0
    flags 0x0
(略)

確かにmaxprot, initprotは全てのフラグがクリアされていて、全てのアクセスが禁止となっている。後でこの値を (VM_PROT_READ | VM_PROT_WRITE | VM_PROT_EXECUTE) (=7) に変えてやると「全て許可」の場合の実験が出来そうだな。


実行

$ ./zero 
Segmentation fault


gdbで実行

(gdb) disas start
Dump of assembler code for function start:
0x0000000000001fd9 :   mov    $0x0,%rax
0x0000000000001fe0 :   mov    0x19(%rip),%rdx        # 0x2000 
0x0000000000001fe7 :  mov    %rdx,(%rax)
0x0000000000001fea :  xor    %rdx,%rdx
0x0000000000001fed :  mov    (%rax),%rdx
0x0000000000001ff0 :  mov    $0x2000001,%rax
0x0000000000001ff7 :  mov    0x2(%rip),%rdi        # 0x2000 
0x0000000000001ffe :  syscall 
End of assembler dump.
(gdb) run
Starting program: zero 

Program received signal EXC_BAD_ACCESS, Could not access memory.
Reason: KERN_INVALID_ADDRESS at address: 0x0000000000000000
0x0000000000001fe7 in start ()
(gdb)

確かに0x1fe7で仮想メモリアドレス 0 に書き込みに行くところで 不正なアクセス として終了させられている。


Mach-Oファイル中の__PAGEZEROセグメントのメモリ保護属性を「全て許可(=7)」に変更する。
ハイライト部分が maxprot と initprot (各4バイト)

これを↓こう変更する。

otoolで確認

$ otool -l zero
zero:
Load command 0
      cmd LC_SEGMENT_64
  cmdsize 72
  segname __PAGEZERO
   vmaddr 0x0000000000000000
   vmsize 0x0000000000001000
  fileoff 0
 filesize 0
  maxprot 0x00000007
 initprot 0x00000007
   nsects 0
    flags 0x0

maxprot, initprot共に「全て許可(=7)」になっている。


実行

$ .zero
$ echo $?
52

異常終了しなくなった!!!!!!!!!! 終了コードの52というのは 0x1234 の下位バイト 0x34 を10進数にしたものだな。


gdbでも実行

01: (gdb) disas start
02: Dump of assembler code for function start:
03: 0x0000000000001fd9 :   mov    $0x0,%rax
04: 0x0000000000001fe0 :   mov    0x19(%rip),%rdx        # 0x2000 
05: 0x0000000000001fe7 :  mov    %rdx,(%rax)
06: 0x0000000000001fea :  xor    %rdx,%rdx
07: 0x0000000000001fed :  mov    (%rax),%rdx
08: 0x0000000000001ff0 :  mov    $0x2000001,%rax
09: 0x0000000000001ff7 :  mov    0x2(%rip),%rdi        # 0x2000 
10: 0x0000000000001ffe :  syscall 
11: End of assembler dump.
12: (gdb) b *0x1fe7
13: Breakpoint 1 at 0x1fe7
14: (gdb) run
15: Starting program: zero 
16: 
17: Breakpoint 1, 0x0000000000001fe7 in start ()
18: (gdb) i reg rdx rax
19: rdx            0x1234   4660
20: rax            0x0      0
21: (gdb) si
22: 0x0000000000001fea in start ()
23: (gdb) si
24: 0x0000000000001fed in start ()
25: (gdb) i reg rdx rax
26: rdx            0x0      0
27: rax            0x0      0
28: (gdb) si
29: 0x0000000000001ff0 in start ()
30: (gdb) i reg rdx rax
31: rdx            0x1234   4660
32: rax            0x0      0
33: (gdb) c
34: Program exited with code 064.
35: (gdb)
  • 17行目: さっき落ちた 0x1fe7 でブレーク
  • 18〜20行目: 書き込む値は 0x1234, 書き込むアドレスは 0x0
  • 21行目: 書き込み実行
  • 23行目: 読み込みに備えて rdx を 0 にクリア
  • 25行目〜27行目: 読み込み先のレジスタ(rdx)は値0, 読み込み元のアドレスは 0
  • 28行目: 読み込み実行
  • 30〜32行目: 先程アドレス0の位置に書き込んだ値(0x1234)がrdxに読み込まれている。
  • 34行目: 終了コードがなぜ064・・・・。

おおお。仮想メモリアドレス 0 の場所に読み書きできてる!!!


Mach-Oファイルのメモリ保護属性を変更すれば__PAGEZEROセグメントへの読み書きが可能で有ることがわかったので、次はコードが実行できるかどうかを試す。

レジスタの値しか参照しないポータブルな関数を用意して、そのコードを__PAGEZEROセグメントにコピーし、絶対アドレス指定のcall命令により仮想メモリアドレス 0 に有るコードを実行してみる。


ソース(zero_ex.s)

01: .text
02: .globl start
03: start:
04:         movq $0, %rdi                 # copy to
05:         leaq func(%rip), %rsi         # copy from
06:         movq func_size(%rip), %rcx    # size in bytes
07:         cld                           # clear direction flag
08:         rep movsb                     # copyyyyyyy
09: 
10:         movq $0x1, %rdi               # prepare arguments
11:         movq $0x2, %rsi
12: 
13:         movq $0, %rdx                 # call func at address 0
14:         call *%rdx                    # absolute address call
15: 
16:         movq $0x2000001, %rax         # sys exit
17:         xorq %rdi, %rdi               # exit code
18:         syscall
19:         jmp .                         # infinite loop
20: func:
21:         addq %rdi, %rsi
22:         movq %rsi, %rax
23:         ret
24: .data
25: func_size:
26:         .long . - func
  • 4〜8行目: memcopy の様なもの。func関数を仮想メモリアドレス 0 にコピーしている
  • 10〜11行目: funcの引数を用意。ただの足し算関数なので 1 と 2 を用意
  • 13〜14行目: 絶対アドレス 0 への関数呼び出し
  • 20〜23行目: コピーされる関数。rdi,rsiレジスタの値を足しあわせてraxレジスタに入れるだけ


コンパイル

$ as -o zero_ex.o zero_ex.s && ld -o zero_ex zero_ex.o


__PAGEZEROセグメントのメモリ保護属性

$ otool -l zero_ex
zero_ex:
Load command 0
      cmd LC_SEGMENT_64
  cmdsize 72
  segname __PAGEZERO
   vmaddr 0x0000000000000000
   vmsize 0x0000000000001000
  fileoff 0
 filesize 0
  maxprot 0x00000000
 initprot 0x00000000
   nsects 0
    flags 0x0

メモリ保護属性は「全て禁止」


実行

$ ./zero_ex
Segmentation fault

gdbで実行(抜粋)

(gdb) disas start
Dump of assembler code for function start:
0x0000000000001fbc :   mov    $0x0,%rdi
0x0000000000001fc3 :   lea    0x2f(%rip),%rsi        # 0x1ff9 
0x0000000000001fca :  mov    0x2f(%rip),%rcx        # 0x2000 
0x0000000000001fd1 :  cld    
0x0000000000001fd2 :  rep movsb %ds:(%rsi),%es:(%rdi)
0x0000000000001fd4 :  mov    $0x1,%rdi
0x0000000000001fdb :  mov    $0x2,%rsi
0x0000000000001fe2 :  mov    $0x0,%rdx
0x0000000000001fe9 :  callq  *%rdx
0x0000000000001feb :  mov    $0x2000001,%rax
0x0000000000001ff2 :  xor    %rdi,%rdi
0x0000000000001ff5 :  syscall 
0x0000000000001ff7 :  jmp    0x1ff7 
End of assembler dump.
(gdb) run
Starting program: zero_ex 

Program received signal EXC_BAD_ACCESS, Could not access memory.
Reason: KERN_INVALID_ADDRESS at address: 0x0000000000000000
0x0000000000001fd2 in start ()

予想通り、0x1fd2 の movsb 命令で アドレス 0 に書き込もうとしたところで落ちている。


バイナリエディタでメモリ保護属性を、maxprot, initprot共に「全て許可(=7)」に変更して、otoolでメモリ保護属性を確認。

$ otool -l ./zero_ex
./zero_ex:
Load command 0
      cmd LC_SEGMENT_64
  cmdsize 72
  segname __PAGEZERO
   vmaddr 0x0000000000000000
   vmsize 0x0000000000001000
  fileoff 0
 filesize 0
  maxprot 0x00000007
 initprot 0x00000007
   nsects 0
    flags 0x0

maxprot,initprot共に「全て許可」に変わっている。


実行。

$ ./zero_ex
$ echo $?
0

実行できた!!!!


gdbで実行

(gdb) disas start
Dump of assembler code for function start:
0x0000000000001fbc :   mov    $0x0,%rdi
0x0000000000001fc3 :   lea    0x2f(%rip),%rsi        # 0x1ff9 
0x0000000000001fca :  mov    0x2f(%rip),%rcx        # 0x2000 
0x0000000000001fd1 :  cld    
0x0000000000001fd2 :  rep movsb %ds:(%rsi),%es:(%rdi)
0x0000000000001fd4 :  mov    $0x1,%rdi
0x0000000000001fdb :  mov    $0x2,%rsi
0x0000000000001fe2 :  mov    $0x0,%rdx
0x0000000000001fe9 :  callq  *%rdx
0x0000000000001feb :  mov    $0x2000001,%rax
0x0000000000001ff2 :  xor    %rdi,%rdi
0x0000000000001ff5 :  syscall 
0x0000000000001ff7 :  jmp    0x1ff7 
(gdb) disas func
Dump of assembler code for function func:
0x0000000000001ff9 :    add    %rdi,%rsi
0x0000000000001ffc :    mov    %rsi,%rax
0x0000000000001fff :    retq   
End of assembler dump.
(gdb) b *0x1fd2    ← 関数をコピするところでブレイク
Breakpoint 1 at 0x1fd2
(gdb) run
Starting program: /Users/teru/Documents/low_level/blog_macho_struct/zero_ex 

Breakpoint 1, 0x0000000000001fd2 in start ()
(gdb) i reg rsi rdi rcx
rsi            0x1ff9   8185  ← コピー元アドレス(funcのアドレス)
rdi            0x0      0     ← コピー先アドレス
rcx            0x7      7     ← ループカウンタのような物
(gdb) si
0x0000000000001fd2 in start ()
(gdb) i reg rsi rdi rcx
rsi            0x1ffa   8186
rdi            0x1      1
rcx            0x6      6
(gdb) si 6
0x0000000000001fd4 in start ()
(gdb) i reg rsi rdi rcx
rsi            0x2000   8192
rdi            0x7      7
rcx            0x0      0     ← 0 になるまで (rsi)から(rdi)へ1バイトずつコピーした
(gdb) si 3
0x0000000000001fe9 in start ()
(gdb) i reg rdi rsi rdx rip
rdi            0x1      1    ← これと
rsi            0x2      2    ← これはアドレス0にコピーしたfuncへの引数
rdx            0x0      0    ← 絶対アドレス指定のcallはレジスタ経由でないとアドレス指定できないので飛び先(=0)を設定
rip            0x1fe9   0x1fe9  ← 飛びますよ〜〜〜
(gdb) si
0x0000000000000000 in ?? ()  ← 0 来た!!!
(gdb) i rip
rip            0x0      0    ← でもripは次の命令のアドレスなのでまだfuncは実行されていない
(gdb) si
0x0000000000000003 in ?? ()  ← アドレス0の命令が実行された!!!
(gdb) i reg rdi rsi rax 
rdi            0x1      1
rsi            0x3      3    ← 1 + 2 = 3 の値が入っている
rax            0x1fbc   8124
(gdb) si 2
0x0000000000001feb in start () ← 関数数呼び出しの次の命令に戻った。
(gdb) c 
Continuing.

Program exited normally.

という感じで、__PAGEZEROセグメントでコード実行することもできた。


長くなってきたので詳細の記述は省くけど、同様の方法で「実行だけ許可しない(VM_PROT_READ | VM_PROT_WRITE)(=3)」などのメモリ保護属性でもその通りの動作をすることも確認できた。

実験結果

  • 実行形式ファイルの__PAGEZERO セグメントのメモリ保護属性は 「全て禁止」になっているか
    • なっていた。値は 0 だった。initprot, maxprotの各ビットの意味はvm_prot.hに 定義されていた
  • 仮想メモリアドレス 0 にアクセスするとプログラムが異常終了するか
    • 丁度仮想メモリアドレス 0 にアクセスしたところで異常終了した
  • メモリ保護属性を 「全て許可」 に変更すると 仮想メモリアドレス 0 にアクセスしても異常終了しなくなるか
    • 異常終了しなくなった。仮想メモリアドレス 0 の場所にデータを読み書きすることができた
    • 実行もできた。

まとめ

  • __PAGEZEROセグメントのおかげで NULL へのアクセスが有ったときに直ちにプログラムを異常終了させることができる
  • セグメントコマンド中の保護属性を書き換えてしまえば異常終了しなくなる。保護属性ってちゃんと効いてるんだなー(当たり前か ^^;)
  • 関数を別の場所にコピーしてそれを実行するとか、実は初体験だった。
  • ストリング命令も初体験だった。便利だ。