読者です 読者をやめる 読者になる 読者になる

x86 Linux シェルコード作成

pwn

シェルコードとは

ソフトウェアの脆弱性攻撃のペイロードであり、バイトコードで記述されます。そのため、CPUやOSのバージョンといったプラットフォーム毎に作成されます。 シェルコードという名称は一般的にシェルを起動することが攻撃者にとって楽にマシン全体の制御を奪う方法であり多用されているからですが、実際のところシェルコードはどのような処理も記述することができます。

x86 Linux シェルコード作成の前提知識

アセンブリ言語

シェルコードのバイトコードは、マシン語命令のアーキテクチャによって異なるため、アセンブリ言語で記述することになります。

Linuxシステムコール

OSはカーネル内で入力、出力、プロセス制御、ファイルアセクス、ネットワーク通信と行ったタスクを管理します。 C言語のプログラムは最終的に、こういったタスクをカーネルに対するシステムコールで実現します。

  • hello.c
#include <stdio.h>

int main(){
   printf("Hello, World\n");
   return 0;
}
  • hello.cのコンパイルと実行
$ gcc hello.c
$ ./a.out
Hello, World
  • hello.cが実行するシステムコール
$ strace ./a.out
execve("./a.out", ["./a.out"], [/* 16 vars */]) = 0
brk(0)                                  = 0x8d74000
access("/etc/ld.so.nohwcap", F_OK)      = -1 ENOENT (No such file or directory)
mmap2(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0xb7735000
access("/etc/ld.so.preload", R_OK)      = -1 ENOENT (No such file or directory)
open("/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
fstat64(3, {st_mode=S_IFREG|0644, st_size=25873, ...}) = 0
mmap2(NULL, 25873, PROT_READ, MAP_PRIVATE, 3, 0) = 0xb772e000
close(3)                                = 0
access("/etc/ld.so.nohwcap", F_OK)      = -1 ENOENT (No such file or directory)
open("/lib/i386-linux-gnu/i686/cmov/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\1\1\1\3\0\0\0\0\0\0\0\0\3\0\3\0\1\0\0\0\300\233\1\0004\0\0\0"..., 512) = 512
fstat64(3, {st_mode=S_IFREG|0755, st_size=1738492, ...}) = 0
mmap2(NULL, 1743484, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0xb7584000
mmap2(0xb7728000, 12288, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x1a4000) = 0xb7728000
mmap2(0xb772b000, 10876, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0xb772b000
close(3)                                = 0
mmap2(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0xb7583000
set_thread_area({entry_number:-1, base_addr:0xb7583940, limit:1048575, seg_32bit:1, contents:0, read_exec_only:0, limit_in_pages:1, seg_not_present:0, useable:1}) = 0 (entry_number:6)
mprotect(0xb7728000, 8192, PROT_READ)   = 0
mprotect(0xb7759000, 4096, PROT_READ)   = 0
munmap(0xb772e000, 25873)               = 0
fstat64(1, {st_mode=S_IFCHR|0620, st_rdev=makedev(136, 0), ...}) = 0
mmap2(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0xb7734000
write(1, "Hello, World\n", 13Hello, World
)          = 13
exit_group(0)                           = ?
+++ exited with 0 +++

実際にHello, World!とコンソールに表示させているのはwriteシステムコールです。

write(1, "Hello, World\n", 13Hello, World
)          = 13

Linuxシステムコール番号

どのシステムコールを呼ぶのかをカーネルに依頼する際、動作と紐付けられた番号を知る必要があります。 この番号はsyscall.hを調べることで特定することができます。

$ cat /usr/include/syscall.h
#include <sys/syscall.h>

sys/syscall.hを辿ります。

$ cat /usr/include/i386-linux-gnu/sys/syscall.h
/* This file should list the numbers of the system calls the system knows.
   But instead of duplicating this we use the information available
   from the kernel sources.  */
#include <asm/unistd.h>

asm/unistd.hを辿ります。

$ cat /usr/include/i386-linux-gnu/asm/unistd.h
#ifndef _ASM_X86_UNISTD_H
#define _ASM_X86_UNISTD_H

/* x32 syscall flag bit */
#define __X32_SYSCALL_BIT       0x40000000

# ifdef __i386__
#  include <asm/unistd_32.h>
# elif defined(__ILP32__)
#  include <asm/unistd_x32.h>
# else
#  include <asm/unistd_64.h>
# endif

#endif /* _ASM_X86_UNISTD_H */

32bitなのでasm/unistd_x32.hを辿ります。

$ cat /usr/include/i386-linux-gnu/asm/unistd_32.h | grep write
#define __NR_write 4
#define __NR_writev 146
#define __NR_pwrite64 181
#define __NR_pwritev 334
#define __NR_process_vm_writev 348

asm/unistd_x32.hにはシステムールの番号が列挙されているので、調べたいsystemコール名で検索します。

アセンブリ言語によるLinuxシステムコール

アセンブリ言語でシステムコールを発行するにはmov命令でオペランドにシステムコール番号や引数を設定し、int 0x80を発行します。 mov命令は2つのオペンランド間で値をコピーする命令です。intelシンタックスを用いた場合、最初のオペランドは移送先として、2つめのオペランドが移送元として扱われます。 intはカーネルに割り込みシグナルを送る命令です。この命令のオペランドは1つだけです。Linuxカーネルの場合、0x80という割り込み番号がカーネルに対するシステムコールの発行に対応付けられています。 int 0x80が発行されるとカーネルはeax,ebx,ecx,edxの各レジスタの値に基づきシステムコールを行います。 eaxにはシステムコール番号が格納され、ebx,ecx,edxにシステムコールに対する1つ目、2つ目、3つ目の引数が格納されます。

アセンブリ言語によるHello Worldを書いてみる

アセンブリ言語を用いてwriteシステムコールとexitシステムコールを呼び出し、標準出力にHello, World!という文字列を出力してみます。

  • helloworld.s
BITS 32           ; nasmに対してこれが32bitコードであることを伝える

section .data     ; データセグメント
msg      db    "Hello, world!", 0x0a; 文字列と改行文字

section  .text    ; テキストセグメント
global   _start   ; ELFとリンクを行う際のデフォルトエントリポイント

_start:
   ; SYSCALL: wirte(1, msg, 14)
   mov eax, 4     ; 4をeaxに設定する(4はwriteに相当するシステムコール番号)
   mov ebx, 1     ; 1をebxに設定する(1は標準出力)
   mov ecx, msg   ; 文字列のアドレスをecxに設定する
   mov edx, 14    ; 14をedxに設定する(文字列の長さ)
   int 0x80       ; システムコール発行

   ; SYSCALL: exit(0)
   mov eax, 1     ; 1をeaxに設定する(1はexitに相当するシステムコール番号)
   mov ebx, 0     ; 正常終了する
   int 0x80       ; システムコール発行
  • helloworld.sのアセンブルとリンクと実行
$ nasm -f elf helloworld.s
$ ld helloworld.o
$ ./a.out
Hello, world!

これでアセンブリ言語でHello, World!と出力するプログラムを記述することができました。 しかしhelloworld.oは実行にすべてのものが含まれているわけではないため、実行に先立ってリンクが必要になります。

文字列データが格納されたメモリアドレス取得の問題

先ほどのアセンブリ言語で記述されたシェルコードの違いを明確にしていきましょう。シェルコードは実行可能プログラムではありません。シェルコードではメモリのデータレイアウトを変更したり、他のメモリセグメントを使用することはできません。 またhelloworld.oもシェルコードとは呼べません。シェルコードは自己充足していなければならず、格納場所に依存してはなりません。

helloworld.sではデータセグメントに文字列を格納し、テキストセグメントにコードを記述しています。

section .data     ; データセグメント
msg      db    "Hello, world!", 0x0a; 文字列と改行文字

section  .text    ; テキストセグメント
global   _start   ; ELFとリンクを行う際のデフォルトエントリポイント

しかしシェルコードはどこに格納されても稼働しなければならないため、コードもデータも同一セグメントに格納する必要があります。 eipがこういった文字列を命令として解釈しようとしない限り、このことは何の問題もありません。しかし文字列をデータとしてアクセスする際には最初にその格納アドレスを取得する必要があります。

スタックを用いたアセンブリ命令による解決策

シェルコードが実行される際、メモリのどこに配置されるのかわかりません。このためeipとの相対アドレスから文字列の絶対メモリアドレスを算出する必要があります。

  • shell_hello.s
BITS 32           ; nasmに対してこれが32ビットコードであることを伝える

global _start

_start:

   call mark_below          ; 文字列の次に格納されている命令を呼び出す
   db "Hello, world!", 0x0a ; 文字列と改行文字を格納する

mark_below:
; sszite_t write(int fd, const void *buf, size_t count);
   pop ecx        ; 戻りアドレス(実は文字列へのポインタ)をecxにポップする
   mov eax, 4     ; システムコール番号を設定
   mov ebx, 1     ; 標準出力のファイル記述子
   mov edx, 14    ; 文字列の長さ
   int 0x80       ; システムコール呼び出し

; void _exit(int status);
   mov eax, 1     ; exit()のシステムコール番号
   mov ebx, 0     ; ステータス = 0
   int 0x80       ; システムコール呼び出し

shell_hello.sではcall命令を用いると復帰のために次の命令のアドレスがスタックにプッシュされるという特徴を用いて文字列アドレスを取得しています。 mark_belowをcallすることで処理はmark_belowラベルの処理に移行します。その際、文字列アドレスがスタックにプッシュされます。 mark_belowではスタックから文字列アドレスを取り出しecxに格納しています。

$ nasm -f elf helloshell.s
$ ld -o helloshell helloshell.o
$ ./helloshell
Hello, world!

これでhello, world!を出力する自己完結したプログラムを作成できました。

シェルコードのテスト

$ objdump -M intel -d helloshell | grep '^ ' | cut -f2 | perl -pe 's/(\w{2})\s+/\\x\1/g'
\xe8\x0e\x00\x00\x00\x48\x65\x6c\x6c\x6f\x2c\x20\x77\x6f\x72\x6c\x64\x21\x0a\x59\xb8\x04\x00\x00\x00\xbb\x01\x00\x00\x00\xba\x0e\x00\x00\x00\xcd\x80\xb8\x01\x00\x00\x00\xbb\x00\x00\x00\x00\xcd\x80
char code[] = "\xe8\x0e\x00\x00\x00\x48\x65\x6c\x6c\x6f\x2c\x20\x77\x6f\x72\x6c\x64\x21\x0a\x59\xb8\x04\x00\x00\x00\xbb\x01\x00\x00\x00\xba\x0e\x00\x00\x00\xcd\x80\xb8\x01\x00\x00\x00\xbb\x00\x00\x00\x00\xcd\x80";
int main(int argc, char **argv)
{
  int (*func)();
  func = (int (*)()) code;
  (int)(*func)();
}
$ gcc shell_test.c -o shell_test
$ ./shell_test
Hello, world!

動作しました!

NULLバイト文字列除去

上記のシェルコードは一見正しく動作しますが、NULLバイトが含まれています。文字列を引数として受け取る関数はNULLを終点として考えるので、strcpyなどの脆弱性を突いた攻撃にこのシェルコードを使用することはできません。 シェルコードはNULLバイトを除去することが望ましいです。

$ objdump -M intel -d helloshell

helloshell:     ファイル形式 elf32-i386


セクション .text の逆アセンブル:

08048060 <_start>:
 8048060:       e8 0e 00 00 00          call   8048073 <mark_below>
 8048065:       48                      dec    eax
 8048066:       65 6c                   gs ins BYTE PTR es:[edi],dx
 8048068:       6c                      ins    BYTE PTR es:[edi],dx
 8048069:       6f                      outs   dx,DWORD PTR ds:[esi]
 804806a:       2c 20                   sub    al,0x20
 804806c:       77 6f                   ja     80480dd <mark_below+0x6a>
 804806e:       72 6c                   jb     80480dc <mark_below+0x69>
 8048070:       64 21 0a                and    DWORD PTR fs:[edx],ecx

08048073 <mark_below>:
 8048073:       59                      pop    ecx
 8048074:       b8 04 00 00 00          mov    eax,0x4
 8048079:       bb 01 00 00 00          mov    ebx,0x1
 804807e:       ba 0e 00 00 00          mov    edx,0xe
 8048083:       cd 80                   int    0x80
 8048085:       b8 01 00 00 00          mov    eax,0x1
 804808a:       bb 00 00 00 00          mov    ebx,0x0
 804808f:       cd 80                   int    0x80

初めのcall命令にNULLバイトが3つ含まれています。

8048060:       e8 0e 00 00 00          call   8048073 <mark_below>

この命令はオペランドに基づき、制御の流れを19バイト先に移行します。call命令はプログラムのずっと先、もしくはずっと前まで制御を移行できるようになっており、19バイトといった近距離の制御移行はけた合わせのために0埋めされます。 この問題は2の補数の特徴を活用することで回避できます。-1を2の補数で表すと全てのビットが立った状態になります。実行時に前のアドレスに遡るようなcall命令を用いれば、該当命令のバイトコードにはnullバイトが含まれないようになります。

  • helloshell02.s
BITS 32           ; nasmに対してこれが32ビットコードであることを伝える

global _start

_start:

jmp short one     ; 終端部にあるcall命令に分岐する

two:
; sszite_t write(int fd, const void *buf, size_t count);
   pop ecx        ; 戻りアドレス(実は文字列へのポインタ)をecxにポップする
   mov eax, 4     ; システムコール番号を設定
   mov ebx, 1     ; 標準出力のファイル記述子
   mov edx, 14    ; 文字列の長さ
   int 0x80       ; システムコール呼び出し

; void _exit(int status);
   mov eax, 1     ; exit()のシステムコール番号
   mov ebx, 0     ; ステータス = 0
   int 0x80       ; システムコール呼び出し

one:
   call two       ; nullバイトを避けるための後方分岐
   db "Hello, world!", 0x0a ; 文字列と改行文字を格納する

jpmp shortを使っていることに着目してください。jmp命令もcall命令と同じく小さな範囲の移動では0埋めされます。しかしjmp short命令を使うと移動できる範囲が前後の128byteの範囲に限定されます。call命令にはshort版の命令がありません。

$ nasm -f elf helloshell02.s
$ ld -o helloshell02 helloshell02.o
$ ./helloshell02
Hello, world!
$ objdump -M intel -d helloshell02

helloshell02:     ファイル形式 elf32-i386


セクション .text の逆アセンブル:

08048060 <_start>:
 8048060:       eb 1e                   jmp    8048080 <one>

08048062 <two>:
 8048062:       59                      pop    ecx
 8048063:       b8 04 00 00 00          mov    eax,0x4
 8048068:       bb 01 00 00 00          mov    ebx,0x1
 804806d:       ba 0e 00 00 00          mov    edx,0xe
 8048072:       cd 80                   int    0x80
 8048074:       b8 01 00 00 00          mov    eax,0x1
 8048079:       bb 00 00 00 00          mov    ebx,0x0
 804807e:       cd 80                   int    0x80

08048080 <one>:
 8048080:       e8 dd ff ff ff          call   8048062 <two>
 8048085:       48                      dec    eax
 8048086:       65 6c                   gs ins BYTE PTR es:[edi],dx
 8048088:       6c                      ins    BYTE PTR es:[edi],dx
 8048089:       6f                      outs   dx,DWORD PTR ds:[esi]
 804808a:       2c 20                   sub    al,0x20
 804808c:       77 6f                   ja     80480fd <one+0x7d>
 804808e:       72 6c                   jb     80480fc <one+0x7c>
 8048090:       64 21 0a                and    DWORD PTR fs:[edx],ecx

call命令のnullバイトは無くなりました! しかし、他にも多くのnullバイトが含まれています。

08048062 <two>:
 8048062:       59                      pop    ecx
 8048063:       b8 04 00 00 00          mov    eax,0x4
 8048068:       bb 01 00 00 00          mov    ebx,0x1
 804806d:       ba 0e 00 00 00          mov    edx,0xe
 8048072:       cd 80                   int    0x80
 8048074:       b8 01 00 00 00          mov    eax,0x1
 8048079:       bb 00 00 00 00          mov    ebx,0x0
 804807e:       cd 80                   int    0x80

eax,ebx,ecx,edx,esi,edi,ebp,espの各レジスタは32ビット幅になっています。昔のintelプロセッサにはax,bx,cx,dx,si,di,bp,spという名称の16ビット幅のレジスタがありました。それを32ビット幅に拡張した際extendを表すeが付加されました。 今でも16ビット幅のレジスタ名称を指定することで、対応する32ビット幅のレジスタの最初の16ビットにアクセスすることができます。さらにax,bx,cx,dxレジスタは8ビット幅のレジスタとしてアクセスすることができます。これはal,ah,bl,bh,cl,ch,dh,dlという名称がつけられています。lはlow byte、hはhigh byteを意味しています。 al,bl,cl,dlといったレジスタを使うことでマシンコード中からnullバイトを除去することができます。 気をつけなければならないのはシェルコードは他の処理を横取りするため、これらのレジスタに何が入っているかわからないことです。よって事前にこれらのレジスタを初期化しなければなりません。しかし、レジスタを0にするためにmov eax 0を使ったのではまたnullバイトが生まれてしまいます。0を使わないようにして初期化しなければなりません。このためにxorを使用することができます。同じレジスタ同士をxorすれば必ず0になります。またシェルコードを小さくするためにincやdecといった命令長が小さな命令を使用したほうがいいでしょう。

BITS 32           ; nasmに対してこれが32ビットコードであることを伝える

global _start

_start:

jmp short one     ; 終端部にあるcall命令に分岐する

two:
; sszite_t write(int fd, const void *buf, size_t count);
   pop ecx        ; 戻りアドレス(実は文字列へのポインタ)をecxにポップする
   xor eax, eax   ; eaxレジスタをゼロクリアする
   xor ebx, ebx   ; ebxレジスタをゼロクリアする
   xor edx, edx   ; edxレジスタをゼロクリアする
   mov al, 4      ; システムコール番号を設定
   inc ebx        ; exbをインクリメントして1(標準出力にする)
   mov dl, 14    ; 文字列の長さ
   int 0x80       ; システムコール呼び出し

; void _exit(int status);
   mov al,  1     ; exit()のシステムコール番号(上位3バイトはまだゼロのまま)
   dec ebx        ; ステータス = 0
   int 0x80       ; システムコール呼び出し

one:
   call two       ; nullバイトを避けるための後方分岐
   db "Hello, world!", 0x0a ; 文字列と改行文字を格納する
$ nasm -f elf helloshell03.s
$ ld -o helloshell03 helloshell03.o
$ ./helloshell03
Hello, world!
$ objdump -M intel -d helloshell03

helloshell03:     ファイル形式 elf32-i386


セクション .text の逆アセンブル:

08048060 <_start>:
 8048060:       eb 13                   jmp    8048075 <one>

08048062 <two>:
 8048062:       59                      pop    ecx
 8048063:       31 c0                   xor    eax,eax
 8048065:       31 db                   xor    ebx,ebx
 8048067:       31 d2                   xor    edx,edx
 8048069:       b0 04                   mov    al,0x4
 804806b:       43                      inc    ebx
 804806c:       b2 0e                   mov    dl,0xe
 804806e:       cd 80                   int    0x80
 8048070:       b0 01                   mov    al,0x1
 8048072:       4b                      dec    ebx
 8048073:       cd 80                   int    0x80

08048075 <one>:
 8048075:       e8 e8 ff ff ff          call   8048062 <two>
 804807a:       48                      dec    eax
 804807b:       65 6c                   gs ins BYTE PTR es:[edi],dx
 804807d:       6c                      ins    BYTE PTR es:[edi],dx
 804807e:       6f                      outs   dx,DWORD PTR ds:[esi]
 804807f:       2c 20                   sub    al,0x20
 8048081:       77 6f                   ja     80480f2 <one+0x7d>
 8048083:       72 6c                   jb     80480f1 <one+0x7c>
 8048085:       64 21 0a                and    DWORD PTR fs:[edx],ecx
$ objdump -M intel -d helloshell03 | grep '^ ' | cut -f2 | perl -pe 's/(\w{2})\s+/\\x\1/g'
\xeb\x13\x59\x31\xc0\x31\xdb\x31\xd2\xb0\x04\x43\xb2\x0e\xcd\x80\xb0\x01\x4b\xcd\x80\xe8\xe8\xff\xff\xff\x48\x65\x6c\x6c\x6f\x2c\x20\x77\x6f\x72\x6c\x64\x21\x0a

nullバイトが除去されたシェルコードを作成することができました。

参考文献