mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-29 15:37:03 +00:00
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
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:
parent
fe4b12bf64
commit
4f11e03285
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user