シェルコードとは
ソフトウェアの脆弱性攻撃のペイロードであり、バイトコードで記述されます。そのため、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バイトが除去されたシェルコードを作成することができました。