@browsercore/testing 0.1.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- package/captures/DIVERGENCE_REPORT.md +325 -0
- package/captures/IMPERSIONATION_GAPS_REPORT.md +367 -0
- package/captures/README.md +56 -0
- package/captures/REVIEW_FINDINGS.md +71 -0
- package/captures/_probe/analyze.py +67 -0
- package/captures/_probe/capture_bytes.py +127 -0
- package/captures/_probe/chrome131_determinism.py +32 -0
- package/captures/_probe/diagnose_capture.py +59 -0
- package/captures/_probe/golden_analysis.py +91 -0
- package/captures/_probe/inspect_golden.py +55 -0
- package/captures/_probe/inspect_sv.py +16 -0
- package/captures/_probe/make_golden.py +170 -0
- package/captures/_probe/output/chrome100.json +219 -0
- package/captures/_probe/output/chrome101.json +219 -0
- package/captures/_probe/output/chrome104.json +219 -0
- package/captures/_probe/output/chrome107.json +220 -0
- package/captures/_probe/output/chrome110.json +220 -0
- package/captures/_probe/output/chrome116.json +220 -0
- package/captures/_probe/output/chrome119.json +219 -0
- package/captures/_probe/output/chrome120.json +219 -0
- package/captures/_probe/output/chrome123.json +223 -0
- package/captures/_probe/output/chrome124.json +224 -0
- package/captures/_probe/output/chrome131.json +224 -0
- package/captures/_probe/output/chrome131_fresh.json +224 -0
- package/captures/_probe/output/chrome133a.json +224 -0
- package/captures/_probe/output/chrome136.json +224 -0
- package/captures/_probe/output/chrome99.json +219 -0
- package/captures/_probe/output/edge101.json +219 -0
- package/captures/_probe/output/edge99.json +219 -0
- package/captures/_probe/output/firefox133.json +227 -0
- package/captures/_probe/output/firefox135.json +230 -0
- package/captures/_probe/output/safari155.json +210 -0
- package/captures/_probe/output/safari170.json +214 -0
- package/captures/_probe/output/safari170_fresh.json +214 -0
- package/captures/_probe/output/safari180.json +216 -0
- package/captures/_probe/output/safari180_ios.json +216 -0
- package/captures/_probe/output/safari184.json +215 -0
- package/captures/_probe/output/safari260.json +211 -0
- package/captures/_probe/output/summary.json +2520 -0
- package/captures/_probe/parse_existing_settings.py +15 -0
- package/captures/_probe/peetprint.py +39 -0
- package/captures/_probe/probe.py +137 -0
- package/captures/_probe/raw_bytes/chrome-131_client_hello.bin +0 -0
- package/captures/_probe/raw_bytes/chrome-131_client_hello.hex +32 -0
- package/captures/_probe/raw_bytes/firefox-133_client_hello.bin +0 -0
- package/captures/_probe/raw_bytes/firefox-133_client_hello.hex +32 -0
- package/captures/_probe/raw_bytes/safari-17_client_hello.bin +0 -0
- package/captures/_probe/raw_bytes/safari-17_client_hello.hex +32 -0
- package/captures/_probe/relay_capture.py +137 -0
- package/captures/_probe/socks_capture.py +138 -0
- package/captures/_probe/validate_fresh.py +66 -0
- package/captures/_probe/validate_golden.py +97 -0
- package/captures/_probe/verify_capture.py +68 -0
- package/captures/chrome-131/http2/settings.bin +0 -0
- package/captures/chrome-131/http2/settings.meta.json +9 -0
- package/captures/chrome-131/oracle_capture.reference.json +224 -0
- package/captures/chrome-131/tls/client_hello.bin +0 -0
- package/captures/chrome-131/tls/client_hello.meta.json +20 -0
- package/captures/chrome-140/http2/settings.bin +0 -0
- package/captures/chrome-140/http2/settings.meta.json +9 -0
- package/captures/chrome-140/tls/client_hello.bin +0 -0
- package/captures/chrome-140/tls/client_hello.meta.json +12 -0
- package/captures/firefox-128/tls/client_hello.bin +0 -0
- package/captures/firefox-128/tls/client_hello.meta.json +12 -0
- package/captures/firefox-133/http2/settings.bin +0 -0
- package/captures/firefox-133/http2/settings.meta.json +9 -0
- package/captures/firefox-133/oracle_capture.reference.json +227 -0
- package/captures/firefox-133/tls/client_hello.bin +0 -0
- package/captures/firefox-133/tls/client_hello.meta.json +20 -0
- package/captures/safari-17/http2/settings.bin +0 -0
- package/captures/safari-17/http2/settings.meta.json +9 -0
- package/captures/safari-17/oracle_capture.reference.json +214 -0
- package/captures/safari-17/tls/client_hello.bin +0 -0
- package/captures/safari-17/tls/client_hello.meta.json +20 -0
- package/dist/bench/bench.d.ts +22 -6
- package/dist/bench/bench.d.ts.map +1 -1
- package/dist/bench/bench.js +105 -10
- package/dist/bench/bench.js.map +1 -1
- package/dist/captures/manifest.d.ts.map +1 -1
- package/dist/captures/manifest.js +2 -3
- package/dist/captures/manifest.js.map +1 -1
- package/dist/categories/cookies.d.ts +3 -2
- package/dist/categories/cookies.d.ts.map +1 -1
- package/dist/categories/cookies.js +228 -11
- package/dist/categories/cookies.js.map +1 -1
- package/dist/categories/redirects.d.ts +8 -2
- package/dist/categories/redirects.d.ts.map +1 -1
- package/dist/categories/redirects.js +97 -12
- package/dist/categories/redirects.js.map +1 -1
- package/dist/e2e/cert-gen.d.ts +30 -0
- package/dist/e2e/cert-gen.d.ts.map +1 -0
- package/dist/e2e/cert-gen.js +158 -0
- package/dist/e2e/cert-gen.js.map +1 -0
- package/dist/e2e/parse-clienthello.d.ts +177 -0
- package/dist/e2e/parse-clienthello.d.ts.map +1 -0
- package/dist/e2e/parse-clienthello.js +343 -0
- package/dist/e2e/parse-clienthello.js.map +1 -0
- package/dist/e2e/sink-server.d.ts +109 -0
- package/dist/e2e/sink-server.d.ts.map +1 -0
- package/dist/e2e/sink-server.js +292 -0
- package/dist/e2e/sink-server.js.map +1 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/fingerprint/index.d.ts +5 -2
- package/dist/fingerprint/index.d.ts.map +1 -1
- package/dist/fingerprint/index.js +3 -2
- package/dist/fingerprint/index.js.map +1 -1
- package/dist/fingerprint/ja3.d.ts.map +1 -1
- package/dist/fingerprint/ja3.js +33 -4
- package/dist/fingerprint/ja3.js.map +1 -1
- package/dist/fingerprint/ja4-errors.d.ts +17 -0
- package/dist/fingerprint/ja4-errors.d.ts.map +1 -0
- package/dist/fingerprint/ja4-errors.js +20 -0
- package/dist/fingerprint/ja4-errors.js.map +1 -0
- package/dist/fingerprint/ja4-reader.d.ts +44 -0
- package/dist/fingerprint/ja4-reader.d.ts.map +1 -0
- package/dist/fingerprint/ja4-reader.js +112 -0
- package/dist/fingerprint/ja4-reader.js.map +1 -0
- package/dist/fingerprint/ja4.d.ts +11 -43
- package/dist/fingerprint/ja4.d.ts.map +1 -1
- package/dist/fingerprint/ja4.js +54 -141
- package/dist/fingerprint/ja4.js.map +1 -1
- package/dist/fingerprint/ja4h.d.ts +46 -0
- package/dist/fingerprint/ja4h.d.ts.map +1 -0
- package/dist/fingerprint/ja4h.js +50 -0
- package/dist/fingerprint/ja4h.js.map +1 -0
- package/dist/golden/golden.d.ts +15 -1
- package/dist/golden/golden.d.ts.map +1 -1
- package/dist/golden/golden.js +35 -22
- package/dist/golden/golden.js.map +1 -1
- package/dist/index.d.ts +17 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +26 -1
- package/dist/index.js.map +1 -1
- package/dist/reference/browser-provider.d.ts +33 -0
- package/dist/reference/browser-provider.d.ts.map +1 -0
- package/dist/reference/browser-provider.js +60 -0
- package/dist/reference/browser-provider.js.map +1 -0
- package/dist/reference/curl-provider.d.ts +40 -0
- package/dist/reference/curl-provider.d.ts.map +1 -0
- package/dist/reference/curl-provider.js +96 -0
- package/dist/reference/curl-provider.js.map +1 -0
- package/dist/reference/dump.d.ts +42 -0
- package/dist/reference/dump.d.ts.map +1 -0
- package/dist/reference/dump.js +102 -0
- package/dist/reference/dump.js.map +1 -0
- package/dist/reference/node-reference.d.ts.map +1 -1
- package/dist/reference/node-reference.js +2 -5
- package/dist/reference/node-reference.js.map +1 -1
- package/dist/reference/reference-errors.d.ts +36 -0
- package/dist/reference/reference-errors.d.ts.map +1 -0
- package/dist/reference/reference-errors.js +43 -0
- package/dist/reference/reference-errors.js.map +1 -0
- package/dist/reference/reference-types.d.ts +33 -0
- package/dist/reference/reference-types.d.ts.map +1 -0
- package/dist/reference/reference-types.js +9 -0
- package/dist/reference/reference-types.js.map +1 -0
- package/dist/reference/reference.d.ts +14 -77
- package/dist/reference/reference.d.ts.map +1 -1
- package/dist/reference/reference.js +43 -207
- package/dist/reference/reference.js.map +1 -1
- package/dist/rfc/rfcTests.d.ts +46 -8
- package/dist/rfc/rfcTests.d.ts.map +1 -1
- package/dist/rfc/rfcTests.js +203 -16
- package/dist/rfc/rfcTests.js.map +1 -1
- package/dist/types.d.ts +2 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/utils.d.ts +3 -0
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +11 -5
- package/dist/utils.js.map +1 -1
- package/package.json +61 -55
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Parse the existing synthetic chrome-140 settings.bin to document what it actually contains."""
|
|
3
|
+
import struct
|
|
4
|
+
p="/Users/matte/projects/browsercore/testing/captures/chrome-140/http2/settings.bin"
|
|
5
|
+
with open(p,"rb") as f: d=f.read()
|
|
6
|
+
print(f"chrome-140 settings.bin: {len(d)} bytes, hex: {d.hex()}")
|
|
7
|
+
length=struct.unpack(">I", b'\x00'+d[0:3])[0]
|
|
8
|
+
ftype=d[3]; flags=d[4]; stream=struct.unpack(">I",d[5:9])[0]
|
|
9
|
+
print(f"frame: length={length} type=0x{ftype:02x} flags=0x{flags:02x} stream={stream}")
|
|
10
|
+
SETTING_IDS_REV = {1:"HEADER_TABLE_SIZE",2:"ENABLE_PUSH",3:"MAX_CONCURRENT_STREAMS",
|
|
11
|
+
4:"INITIAL_WINDOW_SIZE",5:"MAX_FRAME_SIZE",6:"MAX_HEADER_LIST_SIZE"}
|
|
12
|
+
for i in range(0,len(d)-9,6):
|
|
13
|
+
sid=struct.unpack(">H", d[9+i:11+i])[0]
|
|
14
|
+
val=struct.unpack(">I", d[11+i:15+i])[0]
|
|
15
|
+
print(f" setting {SETTING_IDS_REV.get(sid,sid)} = {val}")
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import json, os
|
|
3
|
+
OUT = "/Users/matte/projects/browsercore/testing/captures/_probe/output"
|
|
4
|
+
|
|
5
|
+
def get_sv(ext_list):
|
|
6
|
+
for e in ext_list:
|
|
7
|
+
if 'supported_versions' in e.get('name',''):
|
|
8
|
+
return e.get('versions', [])
|
|
9
|
+
return None
|
|
10
|
+
|
|
11
|
+
PROFILES = ["chrome124","chrome131","chrome133a","chrome136","chrome120","chrome119","chrome116","chrome110",
|
|
12
|
+
"firefox133","firefox135",
|
|
13
|
+
"safari170","safari180","safari184",
|
|
14
|
+
"edge99","edge101"]
|
|
15
|
+
|
|
16
|
+
print(f"{'profile':14s} | supported_versions")
|
|
17
|
+
print("-"*90)
|
|
18
|
+
for p in PROFILES:
|
|
19
|
+
d = json.load(open(os.path.join(OUT, f"{p}.json")))
|
|
20
|
+
tls = d['tls']
|
|
21
|
+
sv = get_sv(tls['extensions'])
|
|
22
|
+
print(f"{p:14s} | {sv}")
|
|
23
|
+
|
|
24
|
+
print("\n\n=== PEETPRINT (full ordering fingerprint) ===")
|
|
25
|
+
for p in PROFILES:
|
|
26
|
+
d = json.load(open(os.path.join(OUT, f"{p}.json")))
|
|
27
|
+
pp = d['tls'].get('peetprint','')
|
|
28
|
+
print(f"\n{p}:")
|
|
29
|
+
print(f" {pp}")
|
|
30
|
+
|
|
31
|
+
print("\n\n=== SETTINGS + WINDOW_UPDATE + AKAMAI (for settings.frame divergence) ===")
|
|
32
|
+
for p in PROFILES:
|
|
33
|
+
d = json.load(open(os.path.join(OUT, f"{p}.json")))
|
|
34
|
+
h2 = d['http2']
|
|
35
|
+
settings = wu = None
|
|
36
|
+
for fr in h2.get('sent_frames',[]):
|
|
37
|
+
if fr['frame_type']=='SETTINGS': settings = fr.get('settings')
|
|
38
|
+
if fr['frame_type']=='WINDOW_UPDATE': wu = fr.get('increment')
|
|
39
|
+
print(f"{p:14s} | SETTINGS={settings} WINDOW_UPDATE={wu} akamai={h2.get('akamai_fingerprint')}")
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Probe multiple browser profiles with curl_cffi against tls.peet.ws/api/all.
|
|
4
|
+
Saves full JSON per profile + a summary TSV of key fingerprint fields.
|
|
5
|
+
"""
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
import os
|
|
9
|
+
import traceback
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from curl_cffi import requests
|
|
12
|
+
|
|
13
|
+
OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "output")
|
|
14
|
+
os.makedirs(OUT, exist_ok=True)
|
|
15
|
+
|
|
16
|
+
PROFILES = [
|
|
17
|
+
"chrome99", "chrome100", "chrome101", "chrome104", "chrome107",
|
|
18
|
+
"chrome110", "chrome116", "chrome119", "chrome120", "chrome123",
|
|
19
|
+
"chrome124", "chrome131", "chrome133a", "chrome136",
|
|
20
|
+
"edge99", "edge101",
|
|
21
|
+
"safari155", "safari170", "safari180", "safari184",
|
|
22
|
+
"safari180_ios", "safari260",
|
|
23
|
+
"firefox133", "firefox135",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
URL = "https://tls.peet.ws/api/all"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def probe(profile):
|
|
30
|
+
resp = requests.get(URL, impersonate=profile, timeout=30)
|
|
31
|
+
resp.raise_for_status()
|
|
32
|
+
data = resp.json()
|
|
33
|
+
# Save raw JSON
|
|
34
|
+
with open(os.path.join(OUT, f"{profile}.json"), "w") as f:
|
|
35
|
+
json.dump(data, f, indent=2)
|
|
36
|
+
return data
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def extract(d, profile):
|
|
40
|
+
"""Extract summary fields from a tls.peet.ws response."""
|
|
41
|
+
tls = d.get("tls", {})
|
|
42
|
+
h2 = d.get("http2", {})
|
|
43
|
+
# SETTINGS
|
|
44
|
+
settings_map = {}
|
|
45
|
+
settings_order = []
|
|
46
|
+
window_update = None
|
|
47
|
+
headers_list = []
|
|
48
|
+
header_order = []
|
|
49
|
+
priority_info = None
|
|
50
|
+
akamai = h2.get("akamai_fingerprint", "")
|
|
51
|
+
for fr in h2.get("sent_frames", []):
|
|
52
|
+
if fr.get("frame_type") == "SETTINGS":
|
|
53
|
+
for s in fr.get("settings", []):
|
|
54
|
+
k, v = s.split(" = ", 1)
|
|
55
|
+
settings_map[k] = v
|
|
56
|
+
settings_order.append(k)
|
|
57
|
+
elif fr.get("frame_type") == "WINDOW_UPDATE":
|
|
58
|
+
window_update = fr.get("increment")
|
|
59
|
+
elif fr.get("frame_type") == "HEADERS":
|
|
60
|
+
headers_list = fr.get("headers", [])
|
|
61
|
+
header_order = [h.split(":")[0] for h in headers_list]
|
|
62
|
+
if "priority" in fr:
|
|
63
|
+
priority_info = fr["priority"]
|
|
64
|
+
# TLS extension order (names)
|
|
65
|
+
ext_names = [e.get("name", "?") for e in tls.get("extensions", [])]
|
|
66
|
+
# supported_versions + key_share groups order + sig_algs + ciphers
|
|
67
|
+
supported_versions = []
|
|
68
|
+
key_share_groups = []
|
|
69
|
+
sig_algs = []
|
|
70
|
+
ec_point_formats = []
|
|
71
|
+
ciphers = tls.get("ciphers", [])
|
|
72
|
+
for e in tls.get("extensions", []):
|
|
73
|
+
nm = e.get("name", "")
|
|
74
|
+
if "supported_versions" in nm:
|
|
75
|
+
supported_versions = e.get("supported_versions", [])
|
|
76
|
+
elif "key_share" in nm:
|
|
77
|
+
for k in e.get("shared_keys", []):
|
|
78
|
+
key_share_groups += list(k.keys())
|
|
79
|
+
elif "signature_algorithms" in nm:
|
|
80
|
+
sig_algs = e.get("signature_algorithms", [])
|
|
81
|
+
elif "ec_point_formats" in nm or "psk_key_exchange" in nm:
|
|
82
|
+
pass
|
|
83
|
+
if "ec_point_formats" in nm:
|
|
84
|
+
ec_point_formats = e.get("elliptic_curves", [])
|
|
85
|
+
# request headers ordering (non-pseudo)
|
|
86
|
+
request_headers = [h.split(":")[0] for h in headers_list]
|
|
87
|
+
pseudo_order = [h for h in request_headers if h.startswith(":")]
|
|
88
|
+
regular_order = [h for h in request_headers if not h.startswith(":")]
|
|
89
|
+
return {
|
|
90
|
+
"profile": profile,
|
|
91
|
+
"http_version": d.get("http_version"),
|
|
92
|
+
"user_agent": d.get("user_agent"),
|
|
93
|
+
"ja3_hash": tls.get("ja3_hash"),
|
|
94
|
+
"ja3": tls.get("ja3"),
|
|
95
|
+
"ja4": tls.get("ja4"),
|
|
96
|
+
"ja4_r": tls.get("ja4_r"),
|
|
97
|
+
"tls_version_negotiated": tls.get("tls_version_negotiated"),
|
|
98
|
+
"tls_version_record": tls.get("tls_version_record"),
|
|
99
|
+
"ciphers": ciphers,
|
|
100
|
+
"settings_order": settings_order,
|
|
101
|
+
"settings_map": settings_map,
|
|
102
|
+
"window_update": window_update,
|
|
103
|
+
"akamai_fingerprint": akamai,
|
|
104
|
+
"pseudo_header_order": pseudo_order,
|
|
105
|
+
"regular_header_order": regular_order,
|
|
106
|
+
"priority_info": priority_info,
|
|
107
|
+
"extension_names": ext_names,
|
|
108
|
+
"supported_versions": supported_versions,
|
|
109
|
+
"key_share_groups": key_share_groups,
|
|
110
|
+
"sig_algs": sig_algs,
|
|
111
|
+
"ec_point_formats": ec_point_formats,
|
|
112
|
+
"num_extensions": len(ext_names),
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def main():
|
|
117
|
+
results = []
|
|
118
|
+
failures = []
|
|
119
|
+
for p in PROFILES:
|
|
120
|
+
try:
|
|
121
|
+
d = probe(p)
|
|
122
|
+
s = extract(d, p)
|
|
123
|
+
results.append(s)
|
|
124
|
+
print(f"OK {p:16s} ja3={s['ja3_hash']} ja4={s['ja4']} settings={s['settings_order']}")
|
|
125
|
+
except Exception as e:
|
|
126
|
+
failures.append((p, str(e)))
|
|
127
|
+
print(f"FAIL {p:16s} -> {e}")
|
|
128
|
+
# Save summary
|
|
129
|
+
with open(os.path.join(OUT, "summary.json"), "w") as f:
|
|
130
|
+
json.dump({"probed_at": datetime.now(timezone.utc).isoformat(),
|
|
131
|
+
"results": results, "failures": failures}, f, indent=2)
|
|
132
|
+
print(f"\n=== {len(results)} OK, {len(failures)} FAILED ===")
|
|
133
|
+
print("Failures:", failures)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
if __name__ == "__main__":
|
|
137
|
+
main()
|
|
Binary file
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
0000: 16 03 01 07 00 01 00 06 fc 03 03 dd 4a 8c f2 b0
|
|
2
|
+
0010: 4d f4 86 81 b1 04 f1 46 58 ff ef 12 b5 09 e0 c5
|
|
3
|
+
0020: 82 2a 60 66 79 05 c4 fe 46 54 44 20 47 fd 5a 59
|
|
4
|
+
0030: b5 61 5a a1 e5 bc 14 77 cb e2 2d 43 5d a0 a3 9f
|
|
5
|
+
0040: fb 6f b7 35 37 c4 92 14 47 a3 92 ba 00 20 7a 7a
|
|
6
|
+
0050: 13 01 13 02 13 03 c0 2b c0 2f c0 2c c0 30 cc a9
|
|
7
|
+
0060: cc a8 c0 13 c0 14 00 9c 00 9d 00 2f 00 35 01 00
|
|
8
|
+
0070: 06 93 1a 1a 00 00 fe 0d 01 1a 00 00 01 00 01 ec
|
|
9
|
+
0080: 00 20 ab 64 17 1c 93 3d b9 b7 ce 53 f7 bd 10 7d
|
|
10
|
+
0090: fe c6 25 da 94 44 ef c0 f2 6f 95 f6 02 07 17 10
|
|
11
|
+
00a0: 70 21 00 f0 2f 41 3f fb 36 f5 34 4d 0d f8 a1 83
|
|
12
|
+
00b0: 71 d8 0e 0f 4c bd d8 92 2d 24 17 10 8a b3 b9 ce
|
|
13
|
+
00c0: c5 a7 72 c0 f5 bf c3 d2 9f 25 d9 23 8f 3d 65 da
|
|
14
|
+
00d0: ad ba 3c c1 36 56 13 10 af 30 f7 46 f4 05 94 3a
|
|
15
|
+
00e0: 3f c9 d2 f4 b7 a3 e9 ed 67 7b d7 c9 c1 da 27 75
|
|
16
|
+
00f0: b8 fa 51 e3 16 01 da 0a 59 19 11 a1 f7 da 22 07
|
|
17
|
+
0100: da 02 28 71 45 d1 3e f3 06 cd 33 4f dc d1 96 a6
|
|
18
|
+
0110: 80 0c 48 ac 70 2f d8 1d 7e 33 48 98 19 72 6a 07
|
|
19
|
+
0120: 73 45 64 75 0d a5 fa bd b7 0b b8 f4 79 d7 d8 46
|
|
20
|
+
0130: 40 21 c5 4b 65 00 50 fd 4c da 9a 8c c5 d6 4c 90
|
|
21
|
+
0140: 28 95 84 be 4d d1 84 8d 9b 8e 66 0a 5b 4a 64 a3
|
|
22
|
+
0150: 13 eb 2c 01 d0 2d 44 f5 11 27 4c f9 e4 c9 f0 b8
|
|
23
|
+
0160: ce 2c 60 8f be 1d e0 dc 6c b1 c2 5d ba 5d 8c ea
|
|
24
|
+
0170: 88 8a 64 09 41 fe 8a 4f d5 fb cd 53 21 8e af 46
|
|
25
|
+
0180: 11 53 2b 1f d8 e2 59 d6 ab 79 4a 8a 35 85 22 b4
|
|
26
|
+
0190: e2 88 50 4d 00 2d 00 02 01 01 00 17 00 00 00 0b
|
|
27
|
+
01a0: 00 02 01 00 00 10 00 0e 00 0c 02 68 32 08 68 74
|
|
28
|
+
01b0: 74 70 2f 31 2e 31 00 23 00 00 00 12 00 00 44 69
|
|
29
|
+
01c0: 00 05 00 03 02 68 32 00 33 04 ef 04 ed 9a 9a 00
|
|
30
|
+
01d0: 01 00 11 ec 04 c0 04 7a 9d cc 10 30 0d e5 84 e0
|
|
31
|
+
01e0: 87 60 a0 5a c8 de b9 57 a3 a2 b4 40 f7 32 4f 80
|
|
32
|
+
01f0: a5 32 b9 47 65 dc a6 15 b8 cc 81 90 45 78 69 0b
|
|
Binary file
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
0000: 16 03 01 07 2c 01 00 07 28 03 03 0a 65 63 35 d1
|
|
2
|
+
0010: 4c 8d 12 18 0c 76 91 8c 07 34 c6 1b c1 7a ea c4
|
|
3
|
+
0020: 62 8e ab fa 79 86 d5 59 2b dd a3 20 b5 9b d5 86
|
|
4
|
+
0030: 75 5f 8b c6 5c 72 ca f2 54 34 f9 61 47 34 af c2
|
|
5
|
+
0040: 67 50 10 3f ff e9 65 45 f1 0f 58 c5 00 22 13 01
|
|
6
|
+
0050: 13 03 13 02 c0 2b c0 2f cc a9 cc a8 c0 2c c0 30
|
|
7
|
+
0060: c0 0a c0 09 c0 13 c0 14 00 9c 00 9d 00 2f 00 35
|
|
8
|
+
0070: 01 00 06 bd 00 17 00 00 ff 01 00 01 00 00 0a 00
|
|
9
|
+
0080: 10 00 0e 11 ec 00 1d 00 17 00 18 00 19 01 00 01
|
|
10
|
+
0090: 01 00 0b 00 02 01 00 00 23 00 00 00 10 00 0e 00
|
|
11
|
+
00a0: 0c 02 68 32 08 68 74 74 70 2f 31 2e 31 00 05 00
|
|
12
|
+
00b0: 05 01 00 00 00 00 00 22 00 0a 00 08 04 03 05 03
|
|
13
|
+
00c0: 06 03 02 03 00 33 05 2f 05 2d 11 ec 04 c0 71 60
|
|
14
|
+
00d0: 02 c2 b4 7d c1 a1 5c 1f e6 93 09 fc 5d ac 99 10
|
|
15
|
+
00e0: da 70 c3 16 f1 41 ea 22 b5 9f 03 94 0d 53 57 0d
|
|
16
|
+
00f0: 29 c2 52 49 4e 76 92 c2 5b 29 7e ee 35 0f 48 5c
|
|
17
|
+
0100: 3a 0a 55 37 dc 87 07 17 34 03 79 c5 7e 87 95 a1
|
|
18
|
+
0110: c2 cb 75 fa f4 0a 1e 9a a0 13 57 62 2a 37 bd 48
|
|
19
|
+
0120: 5c 62 90 27 25 50 19 6d 48 23 47 33 25 4a b1 f8
|
|
20
|
+
0130: 65 04 56 01 33 eb 06 a0 b4 bb 5d 12 70 b6 ea 77
|
|
21
|
+
0140: 27 53 57 56 8a 4d dc a7 2f 3e 86 10 03 a0 b7 2c
|
|
22
|
+
0150: e9 a1 f0 30 6a 84 b4 46 ed 58 60 0f 64 8b 21 41
|
|
23
|
+
0160: 05 60 86 ba 5b 7b 64 4c 4b b7 06 fc 8e ae c9 8a
|
|
24
|
+
0170: b7 d5 9b 74 87 7e d7 eb 3d 62 6a 62 5d da c9 a6
|
|
25
|
+
0180: 54 79 fe aa 00 ef 69 99 54 19 14 54 6c 4e c8 aa
|
|
26
|
+
0190: 52 e1 16 c1 ab 73 84 79 c0 a8 e5 f0 1b c1 52 cf
|
|
27
|
+
01a0: b1 27 c0 ff 57 46 35 41 92 b5 01 ac 06 41 32 ba
|
|
28
|
+
01b0: 82 00 0b 87 a4 2d 99 7e 59 a1 c5 45 f7 1f cc 66
|
|
29
|
+
01c0: 8a de 1c 73 40 fa 14 08 fa 6d 28 51 5e 96 b7 a3
|
|
30
|
+
01d0: 60 d8 83 f0 08 83 ab 20 bf 8f 98 ca 97 fc 66 fb
|
|
31
|
+
01e0: f2 7e 19 73 b1 ac 06 2a 99 eb ac 15 bb 3d 79 f9
|
|
32
|
+
01f0: 4c f3 24 1e b4 63 40 a4 57 2f 44 8b a0 f7 a4 2a
|
|
Binary file
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
0000: 16 03 01 02 00 01 00 01 fc 03 03 e9 b2 26 d5 ec
|
|
2
|
+
0010: 37 3d 47 70 f8 29 c1 b0 96 fb 2d eb be 32 04 8e
|
|
3
|
+
0020: a5 99 7e ec 07 1b d1 3d da e7 a9 20 c8 61 d3 f4
|
|
4
|
+
0030: c9 17 71 a2 5c a6 b3 f6 60 c4 40 64 97 ec e8 2d
|
|
5
|
+
0040: 40 c5 f0 42 9a 2c f1 c8 5d 2c 33 c4 00 2a 2a 2a
|
|
6
|
+
0050: 13 01 13 02 13 03 c0 2c c0 2b cc a9 c0 30 c0 2f
|
|
7
|
+
0060: cc a8 c0 0a c0 09 c0 14 c0 13 00 9d 00 9c 00 35
|
|
8
|
+
0070: 00 2f c0 08 c0 12 00 0a 01 00 01 89 3a 3a 00 00
|
|
9
|
+
0080: 00 17 00 00 ff 01 00 01 00 00 0a 00 0c 00 0a 6a
|
|
10
|
+
0090: 6a 00 1d 00 17 00 18 00 19 00 0b 00 02 01 00 00
|
|
11
|
+
00a0: 10 00 0e 00 0c 02 68 32 08 68 74 74 70 2f 31 2e
|
|
12
|
+
00b0: 31 00 05 00 05 01 00 00 00 00 00 0d 00 18 00 16
|
|
13
|
+
00c0: 04 03 08 04 04 01 05 03 02 03 08 05 08 05 05 01
|
|
14
|
+
00d0: 08 06 06 01 02 01 00 12 00 00 00 33 00 2b 00 29
|
|
15
|
+
00e0: 6a 6a 00 01 00 00 1d 00 20 77 0a 4e b5 d4 f0 fb
|
|
16
|
+
00f0: 66 f2 37 91 d4 0b b5 f1 6e 05 1b 7e c6 89 78 82
|
|
17
|
+
0100: 34 a3 37 a9 3f 57 98 87 5f 00 2d 00 02 01 01 00
|
|
18
|
+
0110: 2b 00 0b 0a 6a 6a 03 04 03 03 03 02 03 01 00 1b
|
|
19
|
+
0120: 00 03 02 00 01 8a 8a 00 01 00 00 15 00 d7 00 00
|
|
20
|
+
0130: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
|
|
21
|
+
0140: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
|
|
22
|
+
0150: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
|
|
23
|
+
0160: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
|
|
24
|
+
0170: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
|
|
25
|
+
0180: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
|
|
26
|
+
0190: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
|
|
27
|
+
01a0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
|
|
28
|
+
01b0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
|
|
29
|
+
01c0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
|
|
30
|
+
01d0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
|
|
31
|
+
01e0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
|
|
32
|
+
01f0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Transparent TLS relay that captures the authentic ClientHello bytes.
|
|
4
|
+
|
|
5
|
+
curl_cffi connects to https://tls.peet.ws:443 but we resolve tls.peet.ws to
|
|
6
|
+
127.0.0.1 (via a hosts redirect done by connecting to a local port with the
|
|
7
|
+
Host header set to tls.peet.ws). Actually simpler: we listen locally, and when
|
|
8
|
+
a client connects we:
|
|
9
|
+
1. read the first flight (ClientHello) from the client — this has SNI=tls.peet.ws
|
|
10
|
+
because curl_cffi derives SNI from the URL host, which we keep as tls.peet.ws.
|
|
11
|
+
2. open a real TCP+TLS connection to the actual tls.peet.ws
|
|
12
|
+
3. save the ClientHello bytes
|
|
13
|
+
4. forward those bytes + relay the rest bidirectionally
|
|
14
|
+
|
|
15
|
+
The trick: curl_cffi gets the real tls.peet.ws IP via DNS but we intercept by
|
|
16
|
+
having curl_cffi connect through us. We do this by pointing curl_cffi at
|
|
17
|
+
https://tls.peet.ws/ and overriding resolution with a custom approach:
|
|
18
|
+
curl_cffi/requests allows `impersonate` and we can set the request to go to a
|
|
19
|
+
local socket via a pre-resolved address. Simplest reliable method: add an
|
|
20
|
+
entry mapping tls.peet.ws -> 127.0.0.1 using a tiny DNS override is complex.
|
|
21
|
+
|
|
22
|
+
Instead we use the CONNECT-less approach: curl_cffi lets us pass the URL as
|
|
23
|
+
https://tls.peet.ws and we intercept resolution by monkeypatching via a custom
|
|
24
|
+
transport is heavy. So we use the PROXY feature: set https proxy to our local
|
|
25
|
+
relay. With an HTTP CONNECT proxy, curl still sends SNI=tls.peet.ws inside the
|
|
26
|
+
tunnel and we relay raw bytes (no decryption), so we can sniff the ClientHello.
|
|
27
|
+
"""
|
|
28
|
+
import socket, threading, time, struct, json, os
|
|
29
|
+
from datetime import datetime, timezone
|
|
30
|
+
from curl_cffi import requests
|
|
31
|
+
|
|
32
|
+
RAW = "/Users/matte/projects/browsercore/testing/captures/_probe/raw_bytes"
|
|
33
|
+
os.makedirs(RAW, exist_ok=True)
|
|
34
|
+
|
|
35
|
+
RELAY_HOST = "127.0.0.1"
|
|
36
|
+
RELAY_PORT = 8443
|
|
37
|
+
REAL_HOST = "tls.peet.ws"
|
|
38
|
+
REAL_PORT = 443
|
|
39
|
+
|
|
40
|
+
def relay_capture(profile, outname):
|
|
41
|
+
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
42
|
+
try:
|
|
43
|
+
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
44
|
+
except Exception:
|
|
45
|
+
pass
|
|
46
|
+
server.bind((RELAY_HOST, RELAY_PORT))
|
|
47
|
+
server.listen(1)
|
|
48
|
+
server.settimeout(15)
|
|
49
|
+
|
|
50
|
+
client_hello = {"data": None}
|
|
51
|
+
upstream_done = threading.Event()
|
|
52
|
+
|
|
53
|
+
def accept_and_relay():
|
|
54
|
+
try:
|
|
55
|
+
client_sock, addr = server.accept()
|
|
56
|
+
client_sock.settimeout(8)
|
|
57
|
+
# Read initial flight from client (ClientHello).
|
|
58
|
+
initial = b""
|
|
59
|
+
try:
|
|
60
|
+
while True:
|
|
61
|
+
chunk = client_sock.recv(4096)
|
|
62
|
+
if not chunk:
|
|
63
|
+
break
|
|
64
|
+
initial += chunk
|
|
65
|
+
if len(initial) >= 5:
|
|
66
|
+
rec_len = struct.unpack(">H", initial[3:5])[0]
|
|
67
|
+
if len(initial) >= 5 + rec_len:
|
|
68
|
+
break
|
|
69
|
+
except socket.timeout:
|
|
70
|
+
pass
|
|
71
|
+
client_hello["data"] = initial
|
|
72
|
+
|
|
73
|
+
# Now connect to the real server and forward.
|
|
74
|
+
upstream = socket.create_connection((REAL_HOST, REAL_PORT), timeout=8)
|
|
75
|
+
upstream.settimeout(8)
|
|
76
|
+
upstream.sendall(initial)
|
|
77
|
+
# Bidirectional relay until one side closes.
|
|
78
|
+
def relay(src, dst):
|
|
79
|
+
try:
|
|
80
|
+
while True:
|
|
81
|
+
d = src.recv(4096)
|
|
82
|
+
if not d:
|
|
83
|
+
break
|
|
84
|
+
dst.sendall(d)
|
|
85
|
+
except Exception:
|
|
86
|
+
pass
|
|
87
|
+
t1 = threading.Thread(target=relay, args=(client_sock, upstream))
|
|
88
|
+
t2 = threading.Thread(target=relay, args=(upstream, client_sock))
|
|
89
|
+
t1.start(); t2.start()
|
|
90
|
+
t1.join(timeout=15); t2.join(timeout=15)
|
|
91
|
+
try: client_sock.close()
|
|
92
|
+
except Exception: pass
|
|
93
|
+
try: upstream.close()
|
|
94
|
+
except Exception: pass
|
|
95
|
+
except Exception as e:
|
|
96
|
+
client_hello["server_error"] = str(e)
|
|
97
|
+
finally:
|
|
98
|
+
try: server.close()
|
|
99
|
+
except Exception: pass
|
|
100
|
+
upstream_done.set()
|
|
101
|
+
|
|
102
|
+
t = threading.Thread(target=accept_and_relay)
|
|
103
|
+
t.start()
|
|
104
|
+
time.sleep(0.4)
|
|
105
|
+
|
|
106
|
+
# Use our relay as an HTTPS proxy. curl will do CONNECT tls.peet.ws:443
|
|
107
|
+
# through 127.0.0.1:8443, then TLS with SNI=tls.peet.ws inside.
|
|
108
|
+
proxy = f"http://{RELAY_HOST}:{RELAY_PORT}"
|
|
109
|
+
captured_client_error = None
|
|
110
|
+
try:
|
|
111
|
+
# First do a plain GET through the proxy to the real host.
|
|
112
|
+
requests.get(
|
|
113
|
+
f"https://{REAL_HOST}/api/all",
|
|
114
|
+
impersonate=profile,
|
|
115
|
+
proxies={"https": proxy, "http": proxy},
|
|
116
|
+
timeout=20,
|
|
117
|
+
verify=True,
|
|
118
|
+
)
|
|
119
|
+
except Exception as e:
|
|
120
|
+
captured_client_error = str(e)[:300]
|
|
121
|
+
|
|
122
|
+
t.join(timeout=20)
|
|
123
|
+
data = client_hello.get("data")
|
|
124
|
+
if data:
|
|
125
|
+
path = os.path.join(RAW, f"{outname}_client_hello.bin")
|
|
126
|
+
with open(path, "wb") as f:
|
|
127
|
+
f.write(data)
|
|
128
|
+
print(f" [{profile}] captured {len(data)} bytes -> {path}")
|
|
129
|
+
else:
|
|
130
|
+
print(f" [{profile}] FAILED capture: {client_hello.get('server_error')} / {captured_client_error}")
|
|
131
|
+
return data
|
|
132
|
+
|
|
133
|
+
PROFILES = [("chrome131", "chrome-131"), ("firefox133", "firefox-133"), ("safari170", "safari-17")]
|
|
134
|
+
for prof, outname in PROFILES:
|
|
135
|
+
print(f"\n=== Relay-capturing {prof} -> {outname} ===")
|
|
136
|
+
relay_capture(prof, outname)
|
|
137
|
+
time.sleep(0.5)
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
SOCKS5 proxy that captures the raw TLS ClientHello.
|
|
4
|
+
|
|
5
|
+
curl_cffi connects to the SOCKS5 proxy saying "connect to tls.peet.ws:443".
|
|
6
|
+
We open the real connection, complete the SOCKS handshake, then relay — but
|
|
7
|
+
capture the first TLS record (ClientHello) the client sends before forwarding
|
|
8
|
+
it upstream. Because the destination is tls.peet.ws, SNI is correct.
|
|
9
|
+
"""
|
|
10
|
+
import socket, struct, threading, time, os
|
|
11
|
+
from curl_cffi import requests
|
|
12
|
+
|
|
13
|
+
RAW = "/Users/matte/projects/browsercore/testing/captures/_probe/raw_bytes"
|
|
14
|
+
os.makedirs(RAW, exist_ok=True)
|
|
15
|
+
|
|
16
|
+
RELAY_HOST = "127.0.0.1"
|
|
17
|
+
RELAY_PORT = 8443
|
|
18
|
+
REAL_HOST = "tls.peet.ws"
|
|
19
|
+
REAL_PORT = 443
|
|
20
|
+
|
|
21
|
+
def socks_capture(profile, outname):
|
|
22
|
+
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
23
|
+
try:
|
|
24
|
+
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
25
|
+
except Exception:
|
|
26
|
+
pass
|
|
27
|
+
server.bind((RELAY_HOST, RELAY_PORT))
|
|
28
|
+
server.listen(1)
|
|
29
|
+
server.settimeout(20)
|
|
30
|
+
|
|
31
|
+
result = {"data": None}
|
|
32
|
+
|
|
33
|
+
def handle():
|
|
34
|
+
try:
|
|
35
|
+
client, addr = server.accept()
|
|
36
|
+
client.settimeout(10)
|
|
37
|
+
# ---- SOCKS5 handshake ----
|
|
38
|
+
# Client sends: VER=0x05, NAUTH, auth methods
|
|
39
|
+
hdr = client.recv(2)
|
|
40
|
+
if not hdr or hdr[0] != 0x05:
|
|
41
|
+
client.close(); return
|
|
42
|
+
nmethods = hdr[1]
|
|
43
|
+
client.recv(nmethods) # read auth methods
|
|
44
|
+
# Reply: no auth required
|
|
45
|
+
client.sendall(b'\x05\x00')
|
|
46
|
+
# Client request: VER, CMD=0x01(connect), RSV, ATYP, DST.ADDR, DST.PORT
|
|
47
|
+
req = client.recv(4)
|
|
48
|
+
if not req or req[0] != 0x05 or req[1] != 0x01:
|
|
49
|
+
client.close(); return
|
|
50
|
+
atyp = req[3]
|
|
51
|
+
if atyp == 0x01: # IPv4
|
|
52
|
+
dst = socket.inet_ntoa(client.recv(4))
|
|
53
|
+
elif atyp == 0x04: # IPv6
|
|
54
|
+
dst = socket.inet_ntop(socket.AF_INET6, client.recv(16))
|
|
55
|
+
elif atyp == 0x03: # domain
|
|
56
|
+
dlen = client.recv(1)[0]
|
|
57
|
+
dst = client.recv(dlen).decode()
|
|
58
|
+
else:
|
|
59
|
+
client.close(); return
|
|
60
|
+
port_bytes = client.recv(2)
|
|
61
|
+
dst_port = struct.unpack(">H", port_bytes)[0]
|
|
62
|
+
# Connect to the real destination
|
|
63
|
+
upstream = socket.create_connection((REAL_HOST, REAL_PORT), timeout=10)
|
|
64
|
+
upstream.settimeout(10)
|
|
65
|
+
# SOCKS success reply (bound addr = 0.0.0.0:0)
|
|
66
|
+
client.sendall(b'\x05\x00\x00\x01' + socket.inet_aton('0.0.0.0') + struct.pack(">H", 0))
|
|
67
|
+
|
|
68
|
+
# ---- Now relay, capturing client's first TLS flight ----
|
|
69
|
+
initial = b""
|
|
70
|
+
try:
|
|
71
|
+
while True:
|
|
72
|
+
chunk = client.recv(4096)
|
|
73
|
+
if not chunk:
|
|
74
|
+
break
|
|
75
|
+
initial += chunk
|
|
76
|
+
if len(initial) >= 5:
|
|
77
|
+
rec_len = struct.unpack(">H", initial[3:5])[0]
|
|
78
|
+
if len(initial) >= 5 + rec_len:
|
|
79
|
+
break
|
|
80
|
+
except socket.timeout:
|
|
81
|
+
pass
|
|
82
|
+
result["data"] = initial
|
|
83
|
+
# forward captured bytes + continue relay
|
|
84
|
+
upstream.sendall(initial)
|
|
85
|
+
def relay(src, dst_s):
|
|
86
|
+
try:
|
|
87
|
+
while True:
|
|
88
|
+
d = src.recv(4096)
|
|
89
|
+
if not d: break
|
|
90
|
+
dst_s.sendall(d)
|
|
91
|
+
except Exception:
|
|
92
|
+
pass
|
|
93
|
+
t1 = threading.Thread(target=relay, args=(client, upstream))
|
|
94
|
+
t2 = threading.Thread(target=relay, args=(upstream, client))
|
|
95
|
+
t1.start(); t2.start()
|
|
96
|
+
t1.join(timeout=20); t2.join(timeout=20)
|
|
97
|
+
try: client.close()
|
|
98
|
+
except Exception: pass
|
|
99
|
+
try: upstream.close()
|
|
100
|
+
except Exception: pass
|
|
101
|
+
except Exception as e:
|
|
102
|
+
result["server_error"] = str(e)
|
|
103
|
+
finally:
|
|
104
|
+
try: server.close()
|
|
105
|
+
except Exception: pass
|
|
106
|
+
|
|
107
|
+
t = threading.Thread(target=handle)
|
|
108
|
+
t.start()
|
|
109
|
+
time.sleep(0.4)
|
|
110
|
+
|
|
111
|
+
proxy = f"socks5h://{RELAY_HOST}:{RELAY_PORT}"
|
|
112
|
+
try:
|
|
113
|
+
requests.get(
|
|
114
|
+
f"https://{REAL_HOST}/api/all",
|
|
115
|
+
impersonate=profile,
|
|
116
|
+
proxies={"https": proxy, "http": proxy},
|
|
117
|
+
timeout=25,
|
|
118
|
+
verify=True,
|
|
119
|
+
)
|
|
120
|
+
except Exception as e:
|
|
121
|
+
result["client_error"] = str(e)[:300]
|
|
122
|
+
|
|
123
|
+
t.join(timeout=25)
|
|
124
|
+
data = result.get("data")
|
|
125
|
+
if data:
|
|
126
|
+
path = os.path.join(RAW, f"{outname}_client_hello.bin")
|
|
127
|
+
with open(path, "wb") as f:
|
|
128
|
+
f.write(data)
|
|
129
|
+
print(f" [{profile}] captured {len(data)} bytes -> {path}")
|
|
130
|
+
else:
|
|
131
|
+
print(f" [{profile}] FAILED: {result.get('server_error')} / {result.get('client_error')}")
|
|
132
|
+
return data
|
|
133
|
+
|
|
134
|
+
PROFILES = [("chrome131", "chrome-131"), ("firefox133", "firefox-133"), ("safari170", "safari-17")]
|
|
135
|
+
for prof, outname in PROFILES:
|
|
136
|
+
print(f"\n=== SOCKS-capturing {prof} -> {outname} ===")
|
|
137
|
+
socks_capture(prof, outname)
|
|
138
|
+
time.sleep(0.5)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Fresh re-probe chrome131 + safari170 through oracle, compare to captured bytes."""
|
|
3
|
+
import json, os, hashlib, struct
|
|
4
|
+
from curl_cffi import requests
|
|
5
|
+
OUT = "/Users/matte/projects/browsercore/testing/captures/_probe/output"
|
|
6
|
+
|
|
7
|
+
def parse_ja3(data):
|
|
8
|
+
pos=5
|
|
9
|
+
handshake_len=struct.unpack(">I", b'\x00'+data[6:9])[0]; pos=9
|
|
10
|
+
end=9+handshake_len
|
|
11
|
+
ver=struct.unpack(">H", data[pos:pos+2])[0]; pos+=2
|
|
12
|
+
pos+=32
|
|
13
|
+
sid=data[pos]; pos+=1+sid
|
|
14
|
+
csl=struct.unpack(">H", data[pos:pos+2])[0]; pos+=2
|
|
15
|
+
ciphers=[struct.unpack(">H", data[pos+i:pos+i+2])[0] for i in range(0,csl,2)]
|
|
16
|
+
pos+=csl
|
|
17
|
+
cl=data[pos]; pos+=1+cl
|
|
18
|
+
exts=[]; sg=[]; ecpf=[]
|
|
19
|
+
if pos+2<=end:
|
|
20
|
+
el=struct.unpack(">H", data[pos:pos+2])[0]; pos+=2
|
|
21
|
+
eend=pos+el
|
|
22
|
+
while pos+4<=eend:
|
|
23
|
+
et=struct.unpack(">H", data[pos:pos+2])[0]
|
|
24
|
+
xl=struct.unpack(">H", data[pos+2:pos+4])[0]; pos+=4
|
|
25
|
+
exts.append(et)
|
|
26
|
+
if et==0x000a and xl>=4:
|
|
27
|
+
ll=struct.unpack(">H", data[pos:pos+2])[0]
|
|
28
|
+
for i in range(0,ll,2): sg.append(struct.unpack(">H", data[pos+2+i:pos+4+i])[0])
|
|
29
|
+
elif et==0x000b and xl>=1:
|
|
30
|
+
ll=data[pos]
|
|
31
|
+
for i in range(ll): ecpf.append(data[pos+1+i])
|
|
32
|
+
pos+=xl
|
|
33
|
+
return ver, ciphers, exts, sg, ecpf
|
|
34
|
+
|
|
35
|
+
def ja3hash(ver,ciphers,exts,sg,ecpf):
|
|
36
|
+
s=",".join([str(ver), "-".join(str(c) for c in ciphers), "-".join(str(e) for e in exts), "-".join(str(g) for g in sg), "-".join(str(f) for f in ecpf)])
|
|
37
|
+
return hashlib.md5(s.encode()).hexdigest(), s
|
|
38
|
+
|
|
39
|
+
RAW="/Users/matte/projects/browsercore/testing/captures/_probe/raw_bytes"
|
|
40
|
+
|
|
41
|
+
for profile, cap, label in [("chrome131","chrome-131","chrome-131"),("safari170","safari-17","safari-17")]:
|
|
42
|
+
# fresh oracle probe
|
|
43
|
+
r=requests.get("https://tls.peet.ws/api/all", impersonate=profile, timeout=30)
|
|
44
|
+
od=r.json()
|
|
45
|
+
# save fresh
|
|
46
|
+
with open(os.path.join(OUT,f"{profile}_fresh.json"),"w") as f:
|
|
47
|
+
json.dump(od,f,indent=2)
|
|
48
|
+
oja3=od['tls']['ja3_hash']
|
|
49
|
+
oja3str=od['tls']['ja3']
|
|
50
|
+
oexts=[e['name'] for e in od['tls']['extensions']]
|
|
51
|
+
# captured bytes
|
|
52
|
+
with open(os.path.join(RAW,f"{cap}_client_hello.bin"),"rb") as f: d=f.read()
|
|
53
|
+
ver,ciphers,exts,sg,ecpf=parse_ja3(d)
|
|
54
|
+
cja3, cja3str = ja3hash(ver,ciphers,exts,sg,ecpf)
|
|
55
|
+
# GREASE-stripped comparison
|
|
56
|
+
def strip_grease(vals): return [v for v in vals if (v & 0x0f0f)!=0x0a0a]
|
|
57
|
+
cja3_strip, cja3str_strip = ja3hash(ver, strip_grease(ciphers), strip_grease(exts), strip_grease(sg), ecpf)
|
|
58
|
+
print(f"\n{label}:")
|
|
59
|
+
print(f" oracle ja3={oja3} str={oja3str}")
|
|
60
|
+
print(f" capture ja3={cja3} str={cja3str}")
|
|
61
|
+
print(f" capture ja3 (GREASE-stripped)={cja3_strip} str={cja3str_strip}")
|
|
62
|
+
print(f" match stripped? {cja3_strip==oja3}")
|
|
63
|
+
print(f" oracle exts: {oexts}")
|
|
64
|
+
print(f" capture exts: {exts} (hex {[hex(e) for e in exts]})")
|
|
65
|
+
# check SNI present in capture
|
|
66
|
+
print(f" capture has server_name(0)? {0 in exts}")
|