Red TeamChrome Remote DesktopPersistenceWindows

Turning Chrome Remote Desktop into Pure Red Team Ops

Smukx
Smukx@5mukx
July 29, 2026 · 12 min read
TL;DR

This post, we are going to explore how we can take a popular Google product, Chrome Remote Desktop (CRD), and look at it from a red team perspective, turning its existing features into red team tool, almost like a spyware-kinda scenario.

So, in this post, we are going to explore how we can take a popular Google product, Chrome Remote Desktop (CRD), and look at it from a red team perspective, turning its existing features into red team tool, almost like a spyware-kinda scenario.

How it was all started

About two years ago, I had a lab PC with CRD installed so I could work remotely. Every time I started a session, the host would pop up that tiny little bar like this. This was to make sure the user knows that someone has connected to that box.

CRD disconnect notification banner
Fig 1: CRD disconnect notification banner

It quickly became annoying to work with. Every remote session caused the notification bar to appear for a few seconds before disappearing again. After seeing it countless times, I got mad and decided to find a way to disable it permanently.

That decision turned into a rabbit hole, and that's where things started to get interesting.

Part 1. Hiding the banner

The first thing I decided to do was inspect the files installed by Chrome Remote Desktop.

TL;DR: You can find the Chrome Remote Desktop files under the Google directory in C:\Program Files (x86).

Since Chrome Remote Desktop is an open-source project, my next stop was the Chromium source code. I started by exploring the project's resources directory to understand how the application was structured.

One of the first things that caught my attention was the localization resources. As shown below, all of the UI strings are stored under /remoting/remoting_strings_en-GB.xtb.

CRD localization resources
Fig 2: Localization resources in the Chromium source tree

That gave me some initial context, but it also raised more questions than answers.

While tracing the localization files, I came across something interesting. Looking through remoting_strings.grd, I found an entry that immediately caught my attention.

remoting_strings.grd entry
Fig 3: Entry inside `remoting_strings.grd`

This turned out to be one of the core resource definition files. Looking further, I noticed it was referenced as an rc_header, which immediately stood out. Following that reference eventually led me to remoting_core.dll.

That seemed promising, so I decided to investigate it further.

rc_header reference
Fig 4: `rc_header` reference pointing toward `remoting_core.dll`

Interestingly, the Chrome Remote Desktop installation contains only a single DLL under the Remote Desktop directory: remoting_core.dll. At that point, everything started to line up. With only one core library present, it was a strong indicator that this DLL was responsible for most of the application's functionality.

The next step was to correlate that with the Chromium source code. Looking at remoting/host/disconnect_window_win.cc, I found the implementation related to the disconnect notification window, which gave me a much clearer picture of how the feature was implemented.

disconnect_window_win.cc source
Fig 5: `disconnect_window_win.cc` showing the notification window creation

From the source, I could see that the notification window remains visible for a maximum of 10 seconds before it fades out. That immediately narrowed down where the behavior was being controlled.

CreateDialogParam function
Fig 6: The CreateDialogParam function in the source

This function does the following things:

  • CreateDialogParam → Windows loads a dialog resource and shows it as a window
  • MAKEINTRESOURCE(IDD_DISCONNECT) → that resource is named by the id IDD_DISCONNECT
  • CURRENT_MODULE() → load it from the same module that is running the host code (remoting_core.dll on a real install)

So now the next question is simple. What number is IDD_DISCONNECT?

The answer for that is in remoting/host/win/core_resource.h. Google already defines it for us:

#define IDD_DISCONNECT                   110
#define IDD_CONTINUE                     111

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

So:

  • Dialog id 110 = disconnect / share bar (IDD_DISCONNECT)
  • Dialog id 111 = the continue session dialog (IDD_CONTINUE)
  • Control 1002 = the “sharing with …” text (IDC_DISCONNECT_SHARINGWITH)

That is why I can say with confidence the mini banner is RT_DIALOG // 110 // 111 inside remoting_core.dll.

This was the most useful information I found.

Here are the sources:

Where does the Gmail / username text come from?

Same file. When the session starts, CRD takes the remote client jid (which is actually from a .json file we will talk about in this blog), the username part (often [email protected]), and writes it into the dialog:

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

Later it fills the control:

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

So the box is the dialog resource. The email-ish name is runtime text on top of that dialog.

For my understanding I wrote a small program that uses the CreateDialogParam API.

CreateDialogParam sample program
Fig 7: Sample program using `CreateDialogParam`

Program output:

CreateDialogParam program output
Fig 8: Program output showing the created dialog

Once you see that API once in a tiny sample, the Chromium code becomes easy. Same idea. Different resource id.

Checking the real DLL

Source can lie if the product build is different. So I also checked the installed remoting_core.dll.

In the PE resource tree in the resource section (.rsrc):

PE resource tree
Fig 9: PE resource tree of `remoting_core.dll`

  • Dialog ids present: only 110 and 111
  • Dialog 110 and 111 have many language copies (around 53, en-US is lang 1033)

Chromium names the share bar IDD_DISCONNECT and hardcodes it as 110 in core_resource.h. On Windows the host creates it with CreateDialogParam(CURRENT_MODULE(), MAKEINTRESOURCE(IDD_DISCONNECT), ...). So this is not a free floating custom window. It is a classic Win32 dialog resource, loaded from the same module as the host code. On disk that module is remoting_core.dll.

When I reversed the DLL it made that claim concrete. All the dialog templates live in .rsrc. The PE resource tree only had two dialogs: 110 (share / disconnect bar) and 111 (continue dialog). Language 1033 is Global en-US.

For dialog 110 en-US the blob landed at:

Value
File offset0x1CDBF30
RVA0x1F02330
VA (ImageBase + RVA)0x11F02330

DLGTEMPLATEEX bytes
Fig 10: Raw `DLGTEMPLATEEX` bytes for dialog 110

Those bytes correspond to a Microsoft DLGTEMPLATEEX structure. The first fields identify the extended dialog template, with dlgVer = 1 and signature = 0xFFFF, confirming that this is the EX variant rather than the standard DLGTEMPLATE.

The fixed header occupies 26 bytes. The dialog's position and dimensions are stored as four signed 16-bit values, beginning at offset +18 from the start of the template:

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

In IDA that layout mapped cleanly onto a local type applied at 0x11F02330:

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
};

On the unpatched en-US template the measured values were:

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

The interesting part is that Chromium never hardcodes the dialog's dimensions. The source code only references the dialog resource ID. The actual width and height are stored inside the PE resource.

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

How I hide the banner

The approach was simple. Instead of modifying the runtime logic (which I had tried and failed with multiple times), I patched the dialog template itself by setting cx and cy to 0 for dialog resource 110 across every language variant.

Windows still creates the dialog, but with no visible size, the notification banner disappears. The remote session continues to work normally.

disconnect_window_win.cc still runs as expected; the only thing changed is the dialog template stored in the PE resource.

flowchart LR A["disconnect_window_win.cc"] B["CreateDialogParam()"] C["MAKEINTRESOURCE(IDD_DISCONNECT)"] D["core_resource.h<br/>IDD_DISCONNECT = 110"] E["remoting_core.dll"] F["RT_DIALOG #110"] G["DLGTEMPLATEEX"] H["x, y, cx, cy<br/>@ +18"] A --> B B --> C D --> C C --> E E --> F F --> G G --> H style A fill:#1a2c4a,stroke:#3380b8,color:#fff style B fill:#333,stroke:#888,color:#fff style C fill:#333,stroke:#888,color:#fff style D fill:#4a2c1a,stroke:#b87333,color:#fff style E fill:#1a472a,stroke:#2d8659,color:#fff style F fill:#2c2c54,stroke:#6c63ff,color:#fff style G fill:#4b4b4b,stroke:#999,color:#fff style H fill:#8b0000,stroke:#ff4444,color:#fff

Python patcher

The script walks every RT_DIALOG / 110 entry, checks it is DLGTEMPLATEEX (dlgVer=1, signature=0xFFFF), then zeros the four layout values at offset +18.

pip install pefile

Patch:

python patch_crd_sharebar.py .\remoting_core.dll

Restore:

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()

Code looks small but it was actually a pain in the ass during development. After several attempts I succeeded in patching things.

Outputs:

Patch output showing language variants
Fig 11: Patcher output listing language variants

Patch output before values
Fig 12: Patcher zeroing the dialog dimensions

Patch complete
Fig 13: Patch completed successfully

Now you can stop the chromoting services and copy the patched DLL into Program Files (x86), then paste it. Restart the host service. Connect again. Banner is gone.

Results

Before


Fig 14: Banner visible before patching

After


Fig 15: Banner gone after patching

At this point I had what I wanted. Quiet desktop. CRD still online. But wait? What if I made someone install this? OMG I can monitor and spy on them 24/7 without any servers.

But how can we do that? We need a Google account and you need to set this up manually by entering the PIN. If you do setup via SSH install, how's that possible? That's where part 2 comes from. =)


Part 2. Further research: host.json and the private key

After the banner patch worked, the next questions were simple. How does this host stay registered? Where does it store identity? What else is useful for red team style access?

Windows services often put config under ProgramData. So I took a peek there:

C:\ProgramData\Google\Chrome Remote Desktop\

ProgramData CRD directory
Fig 16: CRD config directory under `ProgramData`

Two files stood out:

FileWhat it is
host.jsonMain host config (id, keys, hashes)
host_unprivileged.jsonSmaller config for low-priv parts

host.json is the important one. On a normal install you need admin or SYSTEM to read it.

Google knows this machine as a host. Your PIN unlocks a session. The service uses keys from disk to prove the host is real.

Simple view:

host.json contents
Fig 17: Contents of `host.json`

Field names can change a bit by version. On builds around 150.0.7871.x the idea is the same:

  • Host info (id, name, owner email)
  • private_key as base64 RSA key data
  • host_secret_hash made from the PIN (not the PIN itself)

Why this matters for red teaming

Usually, this host.json on a machine does the following things to make everything work:

flowchart TD J["host.json<br/>private_key + PIN hash"] H["Target host<br/>SYSTEM daemon"] G["Google CRD directory"] R["Operator<br/>CRD client"] PIN["PIN check<br/>hash vs host.json"] S["Remote session<br/>desktop access"] J -->|"loaded at start"| H H -->|"registers / stays online"| G R -->|"login as host owner"| G G -->|"lists host · no shell yet"| R R -->|"connect + enter PIN"| H H --> PIN PIN -->|"PIN matches hash"| S PIN -->|"PIN wrong"| X["Access denied"] style J fill:#8b0000,stroke:#ff4444,color:#fff style H fill:#333,stroke:#888,color:#fff style G fill:#1a1a2e,stroke:#5555aa,color:#fff style R fill:#1a472a,stroke:#2d8659,color:#fff style PIN fill:#4a2c1a,stroke:#b87333,color:#fff style S fill:#1a472a,stroke:#2d8659,color:#fff style X fill:#4a1a1a,stroke:#aa5555,color:#fff

So if you somehow replace the host.json generated by another account, you can easily set up CRD without any normal procedures. =)

Weaponization idea

So we can weaponize this by using the following techniques:

Since Chrome Remote Desktop is installed using an .msi package, you can create a stager (also known as an initial access script) to install the .msi file, retrieve and configure the required JSON files, and start the Chromoting service.

Or

You can automate it by simply passing the --pin=123456 parameter during the initial Chrome Remote Desktop setup after the CRD installation. This undocumented 6-digit PIN parameter was blogged in TrustedSec, under point #7, "Undocumented Parameter."

You can create an initial script to automate this process; however, I do not recommend relying on this technique because the keys generated through the "Set up via SSH" method are rotational and only remain valid for a very limited period, sometimes not even a full day (based on my testing).

Or (the easy one)

You can modify the .msi package to include your generated host.json and host_unprivileged.json files, configure it to create the directory C:\ProgramData\Google\Chrome Remote Desktop\, and copy the required files during installation. After that, you can configure it to start the service, since MSI installers typically run with elevated privileges, making this a straightforward approach.


The End and Thank You

I started with a banner that annoyed me for my personal work, then that led to making a complete monitoring tool. lol!

If you like this read, leave a like and follow for more. See yaa.