This is a header-inventory lab, not a targeting kit. Target is a lab nginx on loopback and a throwaway HTML page that prints navigator.userAgentData. Goal: capture the HTTP request headers Chrome actually sends (UA and Client Hints), dump a mock getHighEntropyValues JSON, and show the one log_format I use so a defender can see full-version hints without shipping them to every origin. I do not map a build to a CVE, I do not write a version-gated payload, I do not scrape chrome://version.
1Figure 1. Low-entropy brand list is public. Full-version-list is the targeting string. Log it on our edge; do not ask every site for it.
2Chrome -> Sec-CH-UA (brands)
3 -> Sec-CH-UA-Full-Version-List only if we Accept-CH it
4edge log ua_ch_full=... then Permissions-Policy to stop outbound
Lab layout
1labs/ua_ch_lab/
2 nginx.conf # loopback, log_format with $http_sec_ch_ua*
3 index.html # prints userAgentData (mock dump below)
4 access.log # one request, tokens redacted
Browser on the lab VM: Chrome 122 stable, Windows 11 x64, no enterprise policy. Server is nginx on 127.0.0.1:18080. I do not put this vhost on a public address.
Artifact: request headers (UA + Client Hints)
First request, no Accept-CH yet. Chrome still sends the low-entropy hints on HTTPS (this lab uses a local TLS terminator; HTTP/1.1 dump is the same names).
1$ curl -s -D - -o /dev/null \
2 -H 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36' \
3 -H 'sec-ch-ua: "Chromium";v="122", "Not(A:Brand";v="24", "Google Chrome";v="122"' \
4 -H 'sec-ch-ua-mobile: ?0' \
5 -H 'sec-ch-ua-platform: "Windows"' \
6 http://127.0.0.1:18080/
7HTTP/1.1 200 OK
Real Chrome, DevTools → Network → the document request, copied as HTTP (lab TLS host name redacted):
1GET / HTTP/2
2Host: lab.example
3User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36
4sec-ch-ua: "Chromium";v="122", "Not(A:Brand";v="24", "Google Chrome";v="122"
5sec-ch-ua-mobile: ?0
6sec-ch-ua-platform: "Windows"
7sec-fetch-site: none
8sec-fetch-mode: navigate
9sec-fetch-dest: document
10accept-language: en-US,en;q=0.9
Notes I write before asking for more entropy:
- Frozen UA: major is
122, patch is0.0. That is intentional. Do not parseChrome/122.0.0.0as a build id. sec-ch-uabrands:Chromium122,Google Chrome122, GREASE brandNot(A:Brand24. GREASE values rotate; do not key alerts on the GREASE string.- Platform is
Windows. Mobile?0.
That is already enough to say “Chrome 122 desktop”. It is not enough to pin 122.0.6261.94.
Artifact: full-version list, only because we asked
The lab nginx sends Accept-CH once so I can see what a misconfigured origin would collect.
1# snippet — lab vhost only
2server {
3 listen 127.0.0.1:18080;
4 add_header Accept-CH "Sec-CH-UA-Full-Version-List, Sec-CH-UA-Platform-Version, Sec-CH-UA-Arch, Sec-CH-UA-Bitness";
5 add_header Permissions-Policy "ch-ua-full-version-list=(self)";
6 ...
7}
Second navigation, Chrome answers the hints (copied from DevTools, build redacted to a lab value that matches the public 122 train):
1GET / HTTP/2
2Host: lab.example
3User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36
4sec-ch-ua: "Chromium";v="122", "Not(A:Brand";v="24", "Google Chrome";v="122"
5sec-ch-ua-mobile: ?0
6sec-ch-ua-platform: "Windows"
7sec-ch-ua-platform-version: "15.0.0"
8sec-ch-ua-arch: "x86"
9sec-ch-ua-bitness: "64"
10sec-ch-ua-full-version-list: "Chromium";v="122.0.6261.94", "Not(A:Brand";v="10.0.2.3", "Google Chrome";v="122.0.6261.94"
122.0.6261.94 is the string exploit kits used to match a patch window. On a first-party property I may want it for our inventory. On a third-party pixel it is a leak. The lab’s next step is to stop asking.
1# production posture for this note: do not Accept-CH high-entropy hints
2# add_header Accept-CH "";
3add_header Permissions-Policy "ch-ua-full-version-list=(), ch-ua-arch=(), ch-ua-bitness=(), ch-ua-platform-version=()";
JS: navigator.userAgentData mock output
The page is 20 lines. I keep a recorded dump so the note does not depend on opening Chrome while you read it.
1<!-- index.html — lab, no network besides loopback -->
2<pre id="out">…</pre>
3<script>
4async function dump() {
5 const n = navigator;
6 const low = n.userAgentData;
7 const high = low
8 ? await low.getHighEntropyValues([
9 "architecture", "bitness", "fullVersionList",
10 "platformVersion", "model", "uaFullVersion"
11 ])
12 : null;
13 document.getElementById("out").textContent = JSON.stringify({
14 userAgent: n.userAgent,
15 userAgentData: low && {
16 brands: low.brands,
17 mobile: low.mobile,
18 platform: low.platform
19 },
20 highEntropy: high
21 }, null, 2);
22}
23dump();
24</script>
Mock output from the lab Chrome 122 session (serialised, not live):
1{
2 "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
3 "userAgentData": {
4 "brands": [
5 { "brand": "Chromium", "version": "122" },
6 { "brand": "Not(A:Brand", "version": "24" },
7 { "brand": "Google Chrome", "version": "122" }
8 ],
9 "mobile": false,
10 "platform": "Windows"
11 },
12 "highEntropy": {
13 "architecture": "x86",
14 "bitness": "64",
15 "platformVersion": "15.0.0",
16 "model": "",
17 "uaFullVersion": "122.0.6261.94",
18 "fullVersionList": [
19 { "brand": "Chromium", "version": "122.0.6261.94" },
20 { "brand": "Not(A:Brand", "version": "10.0.2.3" },
21 { "brand": "Google Chrome", "version": "122.0.6261.94" }
22 ]
23 }
24}
getHighEntropyValues is the JS twin of Accept-CH. A first-party script that calls it is our inventory. A third-party script that calls it is the same leak as the header. I do not paste a fingerprinting library that hashes this into a tracking id.
Electron / CEF lab dump, for the embedder case:
1User-Agent: Mozilla/5.0 ... Chrome/118.0.5993.159 Electron/27.1.3 Safari/537.36
2sec-ch-ua: "Not_A Brand";v="8", "Chromium";v="118"
3# Electron token is the finding: Chromium major can lag Chrome stable
How a defender logs it
nginx log_format on the edge that we own. I log the hints we already received. I do not enable Accept-CH globally to “get better logs”.
1log_format ua_ch '$remote_addr $status $request '
2 'ua="$http_user_agent" '
3 'ch_ua="$http_sec_ch_ua" '
4 'ch_plat="$http_sec_ch_ua_platform" '
5 'ch_full="$http_sec_ch_ua_full_version_list"';
6access_log /var/log/nginx/ua_ch.log ua_ch;
One line from the lab access log:
1127.0.0.1 200 GET / HTTP/1.1 ua="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36" ch_ua="\"Chromium\";v=\"122\", \"Not(A:Brand\";v=\"24\", \"Google Chrome\";v=\"122\"" ch_plat="\"Windows\"" ch_full="\"Chromium\";v=\"122.0.6261.94\", \"Not(A:Brand\";v=\"10.0.2.3\", \"Google Chrome\";v=\"122.0.6261.94\""
Fleet inventory query I actually run (SIEM-shaped, not a vendor):
1# alert: full-version older than policy floor 122.0.6261.0 on a corp origin
2ch_full matches /Chrome\";v=\"(1[0-1][0-9]|122\.0\.(0|[0-5]|6[0-1][0-9]|6260)\./
3# plus: any third-party response that sent Accept-CH Full-Version-List
4# captured on the outbound proxy, not on the origin
Outbound proxy (corp SSL inspect, lab):
12024-03-25T11:12:04Z dst=ads.example GET /
2 req Accept-CH: Sec-CH-UA-Full-Version-List
3 action: strip_accept_ch reason: high_entropy_hint_to_untrusted
Stripping Accept-CH on the way out is how we stop other people’s pages from asking our browsers for 122.0.6261.94. Logging it on the way in to our origin is how we know our own fleet.
Mitigation
- Auto-update Chrome / Edge. Fingerprinting is cheap; a stale build is the actual problem.
- Do not send
Accept-CHforSec-CH-UA-Full-Version-Liston pages that do not need it. Permissions-Policych-ua-full-version-list=()as default. - Electron: rebuild on current Chromium; the
Electron/…token is a billboard for a lagging major. - Treat internal inventory endpoints that echo
chrome://versionas sensitive. Same string, worse ACL. - Frozen UA means access logs that only store
User-Agentcannot pin a patch. If you need a floor for IR, collect full-version on your origin under policy, or use MDM, not a marketing pixel.
Failed-auth analog: a lab WAF rule that rejects an obviously scripted UA is unrelated to this note. Version is not an auth factor.
What I file after this lab
- Low-entropy:
sec-ch-uaChrome/Chromium 122, platform Windows, UA frozen122.0.0.0 - High-entropy (only after
Accept-CH):full-version-list122.0.6261.94 - JS mock:
navigator.userAgentData.brands+getHighEntropyValuesJSON above - Defender log: nginx
ua_chformat; outbound proxy stripsAccept-CHto untrusted dst - Fix:
Permissions-Policyempty lists; no globalAccept-CH; MDM for fleet versions - Out of scope: CVE↔build matching, exploit kit filters,
chrome://versionscrape
Commands appendix
1# headers as Chrome sent them (DevTools copy) — or curl with the same names
2curl -D - -H 'sec-ch-ua: "Chromium";v="122", "Google Chrome";v="122"' \
3 -H 'sec-ch-ua-platform: "Windows"' http://127.0.0.1:18080/ >/dev/null
4# nginx
5grep ch_full /var/log/nginx/ua_ch.log
6# JS: open index.html on loopback, save the <pre> JSON