feat(sentinel): plumb JA4 into the pipeline — makes non_browser_ja live (ref #826) (#831)
Some checks failed
License Headers / check (push) Has been cancelled

* feat(sbxmitm): plumb SNI-independent client-stack JA4 into the Sentinel FlowMeta — makes non_browser_ja live (ref #826)

* docs(sbxmitm): clarify ja4stack is ad-hoc (not spec JA4), self-consistent with capture (ref #826)

* release: toolbox-ng 0.1.34 — JA4 plumbing (ref #826)

* fix(sbxmitm): exclude GREASE from ja4stack (RFC 8701) so the fingerprint is stable per client (ref #826)

* release: toolbox-ng 0.1.35 — GREASE-stable ja4stack (ref #826)

* feat(sentinel): browser-ja4.txt as an /etc conffile (operator-maintained, upgrade-safe) + capture-based population docs (ref #826)

* release: toolbox-ng 0.1.36 — browser-ja4 /etc conffile (ref #826)

---------

Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
This commit is contained in:
CyberMind 2026-07-07 12:50:55 +02:00 committed by GitHub
parent fe4b12bf64
commit 4f11e03285
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 148 additions and 15 deletions

View File

@ -372,7 +372,7 @@ func buildAnalyzers(packDir, overlayDir string, yaraRules []string) ([]Analyzer,
BoxFile: getenvDefault("SENTINEL_C2_BOX_DOMAINS", "/etc/secubox/waf/haproxy-routes.json"),
CandFile: getenvDefault("SENTINEL_C2_CANDIDATES", "/var/lib/secubox/sentinel/c2-candidates.json"),
LearnedFile: getenvDefault("SENTINEL_C2_LEARNED", "/var/lib/secubox/sentinel/c2-learned.json"),
BrowserJA4: readLinesFile(getenvDefault("SENTINEL_C2_BROWSER_JA4", "/usr/share/secubox/sentinel/browser-ja4.txt")),
BrowserJA4: readLinesFile(getenvDefault("SENTINEL_C2_BROWSER_JA4", "/etc/secubox/sentinel/browser-ja4.txt")),
})
analyzers = append(analyzers, c2)
c2Learner = c2 // package-level handle for the status mux (see http.go wiring)

View File

@ -83,6 +83,53 @@ func ja4ish(h *tls.ClientHelloInfo) string {
return fmt.Sprintf("t%04x_c%02d_a%s_sni=%s", maxVer, len(h.CipherSuites), alpn, h.ServerName)
}
// ja4stack is the SNI-INDEPENDENT part of ja4ish: a client-TLS-stack
// fingerprint (max version, cipher-suite count, first ALPN) with NO server
// name. Unlike ja4ish (which embeds the destination SNI, so it varies per
// host), ja4stack is stable across every flow from the same client stack, so
// it can distinguish a browser from a non-browser client — the input the
// Sentinel C2 auto-learn "non_browser_ja" signal (and browser-ja4.txt) needs.
//
// NOTE: this is an ad-hoc "stack" fingerprint (crypto/tls exposes no raw
// ClientHello bytes, so a spec-compliant JA4 hash isn't computable in pure
// Go here). It is self-consistent — the SENTINEL_JA4_CAPTURE recorder writes
// this exact format into browser-ja4.txt and the signal compares against it —
// but it is NOT interchangeable with a public JA4 blocklist's hashes.
func ja4stack(h *tls.ClientHelloInfo) string {
if h == nil {
return ""
}
// GREASE values (RFC 8701) are randomly injected by browsers into the
// version and cipher lists; they MUST be excluded or the fingerprint jitters
// per handshake (e.g. max version flips between 0x0304 and a GREASE 0xfafa).
maxVer := uint16(0)
for _, v := range h.SupportedVersions {
if isGREASE(v) {
continue
}
if v > maxVer {
maxVer = v
}
}
nCiphers := 0
for _, c := range h.CipherSuites {
if !isGREASE(c) {
nCiphers++
}
}
alpn := "none"
if len(h.SupportedProtos) > 0 {
alpn = h.SupportedProtos[0]
}
return fmt.Sprintf("t%04x_c%02d_a%s", maxVer, nCiphers, alpn)
}
// isGREASE reports whether v is a TLS GREASE placeholder (RFC 8701): the 16
// values 0x0a0a, 0x1a1a, … 0xfafa — both bytes equal, each low nibble 0xa.
func isGREASE(v uint16) bool {
return (v>>8) == (v&0xff) && (v&0x0f) == 0x0a
}
// ── CONNECT-proxy MITM wiring ────────────────────────────────────────────────
type Proxy struct {
@ -169,7 +216,7 @@ func (px *Proxy) maybeRecordAdCandidate(host, site, path string) {
}
func (px *Proxy) serverTLSConfig() *tls.Config {
return px.serverTLSConfigCapture(nil)
return px.serverTLSConfigCapture(nil, nil)
}
// serverTLSConfigCapture is serverTLSConfig with an extra per-handshake hook:
@ -178,12 +225,15 @@ func (px *Proxy) serverTLSConfig() *tls.Config {
// handlers use it to relay the ja4 ClientHello payload (relay.go) WITH the
// client conn's peer IP — which is known at the handler, not inside the TLS
// config. Passing nil yields the plain forging config (CONNECT PoC, tests).
func (px *Proxy) serverTLSConfigCapture(capture func(*tls.ClientHelloInfo)) *tls.Config {
func (px *Proxy) serverTLSConfigCapture(capture func(*tls.ClientHelloInfo), onJA4 func(string)) *tls.Config {
return &tls.Config{
GetCertificate: func(h *tls.ClientHelloInfo) (*tls.Certificate, error) {
if px.jaSink != nil {
px.jaSink(ja4ish(h)) // capture handshake fingerprint
}
if onJA4 != nil {
onJA4(ja4stack(h)) // SNI-independent stack fp → Sentinel FlowMeta.JA4
}
if capture != nil {
capture(h) // ja4 relay material (peer IP threaded in by the handler)
}
@ -261,7 +311,9 @@ func (px *Proxy) handleConnect(w http.ResponseWriter, r *http.Request) {
// MITM: TLS-terminate the client with a forged cert (+ ClientHello capture).
// The capture hook relays the ja4 ClientHello payload for this handshake,
// tagged with the client's peer IP (#662). nil when the relay gate is off.
tconn := tls.Server(client, px.serverTLSConfigCapture(px.captureAndEmitJA4(client)))
var ja4 string // SNI-independent client-stack fp, captured at handshake below
tconn := tls.Server(client, px.serverTLSConfigCapture(px.captureAndEmitJA4(client),
func(s string) { ja4 = s }))
if err := tconn.Handshake(); err != nil {
return
}
@ -270,7 +322,7 @@ func (px *Proxy) handleConnect(w http.ResponseWriter, r *http.Request) {
// Shared post-TLS pipeline. CONNECT dials upstream by the request URL host
// (req.URL.Host set inside), so dialHost is "" → mitmPipeline derives it.
// CONNECT PoC is never an R3 WG client → wg=false.
px.mitmPipeline(tconn, client, host, verdict, "", false)
px.mitmPipeline(tconn, client, host, verdict, "", false, ja4)
}
// mitmPipeline runs the shared post-TLS-handshake MITM logic used by BOTH the
@ -290,7 +342,7 @@ func (px *Proxy) handleConnect(w http.ResponseWriter, r *http.Request) {
// ServerName=host and verifying the cert against host (not the bare IP).
// - wg : the client is an R3 WireGuard peer (10.99.1.0/24); threaded
// into the injected loader's data-wg attribute. CONNECT path passes false.
func (px *Proxy) mitmPipeline(tconn *tls.Conn, rawClient net.Conn, host, verdict, dialHost string, wg bool) {
func (px *Proxy) mitmPipeline(tconn *tls.Conn, rawClient net.Conn, host, verdict, dialHost string, wg bool, ja4 string) {
br := newReader(tconn)
req, err := http.ReadRequest(br)
if err != nil {
@ -486,6 +538,7 @@ func (px *Proxy) mitmPipeline(tconn *tls.Conn, rawClient net.Conn, host, verdict
Host: host,
URL: "https://" + host + req.URL.RequestURI(),
MacHash: clientHash,
JA4: ja4, // SNI-independent client-stack fp (feeds non_browser_ja)
}, nil); action {
case sentinel.ActionBlock, sentinel.ActionSinkhole:
writeRaw(tconn, 403, "Forbidden", map[string]string{

View File

@ -204,3 +204,41 @@ func TestClientHelloCaptureAndForge(t *testing.T) {
}
t.Logf("captured JA4-ish: %s", captured)
}
func TestJA4StackIsSNIIndependent(t *testing.T) {
h1 := &tls.ClientHelloInfo{SupportedVersions: []uint16{0x0304}, CipherSuites: []uint16{1, 2, 3}, SupportedProtos: []string{"h2"}, ServerName: "a.example"}
h2 := &tls.ClientHelloInfo{SupportedVersions: []uint16{0x0304}, CipherSuites: []uint16{1, 2, 3}, SupportedProtos: []string{"h2"}, ServerName: "b.example"}
if ja4stack(h1) != ja4stack(h2) {
t.Errorf("ja4stack must be SNI-independent: %q vs %q", ja4stack(h1), ja4stack(h2))
}
if ja4stack(h1) == ja4ish(h1) {
t.Error("ja4stack must differ from ja4ish (which embeds SNI)")
}
if ja4stack(nil) != "" {
t.Error("ja4stack(nil) must be empty")
}
}
func TestJA4StackFiltersGREASE(t *testing.T) {
// same browser stack, different GREASE injected each handshake → SAME fp.
withGrease1 := &tls.ClientHelloInfo{
SupportedVersions: []uint16{0xfafa, 0x0304, 0x0303},
CipherSuites: []uint16{0x1a1a, 0x1301, 0x1302, 0x1303},
SupportedProtos: []string{"h2"}, ServerName: "a.example",
}
withGrease2 := &tls.ClientHelloInfo{
SupportedVersions: []uint16{0x0a0a, 0x0304, 0x0303},
CipherSuites: []uint16{0xdada, 0x1301, 0x1302, 0x1303},
SupportedProtos: []string{"h2"}, ServerName: "b.example",
}
if ja4stack(withGrease1) != ja4stack(withGrease2) {
t.Errorf("GREASE must not jitter the fp: %q vs %q", ja4stack(withGrease1), ja4stack(withGrease2))
}
// must resolve to the real max version (0x0304) + real cipher count (3), not GREASE
if got := ja4stack(withGrease1); got != "t0304_c03_ah2" {
t.Errorf("ja4stack = %q, want t0304_c03_ah2 (GREASE excluded)", got)
}
if !isGREASE(0xfafa) || !isGREASE(0x0a0a) || isGREASE(0x0304) || isGREASE(0x1301) {
t.Error("isGREASE misclassified a value")
}
}

View File

@ -393,10 +393,12 @@ func (px *Proxy) handleTransparent(client net.Conn) {
// tagged with the REAL transparent peer IP from the raw client conn (#662).
// nil when the relay gate is off. Emitted around Decide → blocked/allowed
// alike, matching the Python addon's per-tls_clienthello behaviour.
tconn := tls.Server(replay, px.serverTLSConfigCapture(px.captureAndEmitJA4(client)))
var ja4 string // SNI-independent client-stack fp, captured at handshake below
tconn := tls.Server(replay, px.serverTLSConfigCapture(px.captureAndEmitJA4(client),
func(s string) { ja4 = s }))
if err := tconn.Handshake(); err != nil {
return
}
defer tconn.Close()
px.mitmPipeline(tconn, client, decisionHost, verdict, dialAddr, wg)
px.mitmPipeline(tconn, client, decisionHost, verdict, dialAddr, wg, ja4)
}

View File

@ -1,7 +1,16 @@
# Known browser JA4/JA3 fingerprints (one per line). A destination polled with
# one of these is treated as browser-driven (an admin dashboard), so the
# non_browser_ja corroborating signal does NOT fire for it.
# Known-browser client-stack fingerprints (ja4stack format: t<ver>_c<ciphers>_a<alpn>,
# GREASE-excluded). One per line; '#' comments allowed. EMPTY by default.
#
# Empty by default: operators/feeds extend this list; an empty file simply
# disables the non_browser_ja signal shortcut (the other C2 corroborating
# signals — rarity, DGA-looking labels, etc. — still apply).
# These are BOARD-SPECIFIC (browsers vary by version), so populate from THIS
# board's own live traffic rather than shipping guessed values:
# 1) set SENTINEL_JA4_CAPTURE=/var/lib/secubox/sentinel/ja4-observed.tsv in
# /etc/secubox/sentinel.env and restart sbx-sentinel;
# 2) let real browsing flow through the MITM for a while;
# 3) keep the fingerprints seen hitting known browser destinations
# (google/gstatic/cloudflare/…) — prefer clear browsers (ALPN h2, TLS 1.3);
# 4) add them here (one per line) and restart sbx-sentinel; unset the capture.
#
# When populated, the C2 auto-learn "non_browser_ja" signal is suppressed for
# these fingerprints (browser-driven traffic), and fires for others. Empty =
# signal disabled (safe: no false positives). Conservative under-listing is
# safe; over-listing risks missing a real C2 that reuses a listed fingerprint.

View File

@ -1,3 +1,33 @@
secubox-toolbox-ng (0.1.36-1~bookworm1) bookworm; urgency=medium
* #826 move browser-ja4.txt to /etc/secubox/sentinel (a conffile, like
c2-allow.txt) so an operator's captured fingerprints survive package
upgrades; default SENTINEL_C2_BROWSER_JA4 points there; postinst chowns it
to secubox-toolbox. Seed is a documented empty template (values are
board-specific, captured via SENTINEL_JA4_CAPTURE).
-- Gerald KERMA <devel@cybermind.fr> Mon, 07 Jul 2026 18:00:00 +0200
secubox-toolbox-ng (0.1.35-1~bookworm1) bookworm; urgency=medium
* #826 exclude TLS GREASE (RFC 8701) from ja4stack — browsers inject random
GREASE into the version/cipher lists, which was making the client-stack
fingerprint jitter per handshake (max version flipping to a GREASE 0xfafa).
Now stable per client stack, so non_browser_ja + browser-ja4 capture work.
-- Gerald KERMA <devel@cybermind.fr> Mon, 07 Jul 2026 17:30:00 +0200
secubox-toolbox-ng (0.1.34-1~bookworm1) bookworm; urgency=medium
* #826 plumb a SNI-independent client-TLS-stack fingerprint (ja4stack) from
the sbxmitm handshake into the Sentinel FlowMeta, so the C2 auto-learn
non_browser_ja signal works on real traffic (JA4 was never reaching the
mirror before). Enables SENTINEL_JA4_CAPTURE to record real browser
fingerprints for browser-ja4.txt. sbxmitm hot-path: nil-safe, no blocking,
empty on the (unreachable here) PSK-resumption path — degrades gracefully.
-- Gerald KERMA <devel@cybermind.fr> Mon, 07 Jul 2026 17:00:00 +0200
secubox-toolbox-ng (0.1.33-1~bookworm1) bookworm; urgency=medium
* #826 fix the MVT/Pegasus feed URL (moved to AmnestyTech/investigations) so

View File

@ -25,6 +25,7 @@ case "$1" in
chown secubox-toolbox:secubox-toolbox /etc/secubox/sentinel 2>/dev/null || true
chmod 0750 /etc/secubox/sentinel 2>/dev/null || true
[ -f /etc/secubox/sentinel/c2-allow.txt ] && chown secubox-toolbox:secubox-toolbox /etc/secubox/sentinel/c2-allow.txt 2>/dev/null || true
[ -f /etc/secubox/sentinel/browser-ja4.txt ] && chown secubox-toolbox:secubox-toolbox /etc/secubox/sentinel/browser-ja4.txt 2>/dev/null || true
fi
;;
abort-upgrade|abort-remove|abort-deconfigure)

View File

@ -53,7 +53,7 @@ override_dh_auto_install:
# shipped browser-JA4 corroborating-signal seed (read-only content).
install -d debian/secubox-toolbox-ng/etc/secubox/sentinel
install -m 0644 debian/c2-allow.txt debian/secubox-toolbox-ng/etc/secubox/sentinel/c2-allow.txt
install -m 0644 debian/browser-ja4.txt debian/secubox-toolbox-ng/usr/share/secubox/sentinel/browser-ja4.txt
install -m 0644 debian/browser-ja4.txt debian/secubox-toolbox-ng/etc/secubox/sentinel/browser-ja4.txt
# #736 media catcher: own its /run log so the append never fails (see conf)
install -d debian/secubox-toolbox-ng/usr/lib/tmpfiles.d
install -m 0644 tmpfiles/zz-secubox-toolbox-ng.conf debian/secubox-toolbox-ng/usr/lib/tmpfiles.d/