mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-29 18:36:55 +00:00
Merge pull request #664 from CyberMind-FR/fix/662-restore-ng-source
Some checks are pending
License Headers / check (push) Waiting to run
Some checks are pending
License Headers / check (push) Waiting to run
fix(#662): restore Go PoC source lost to .gitignore
This commit is contained in:
commit
da71515d79
4
packages/secubox-toolbox-ng/.gitignore
vendored
4
packages/secubox-toolbox-ng/.gitignore
vendored
|
|
@ -1,3 +1,3 @@
|
|||
sbxmitm
|
||||
/sbxmitm
|
||||
*.test
|
||||
/cmd/sbxmitm/sbxmitm
|
||||
cmd/sbxmitm/sbxmitm
|
||||
|
|
|
|||
311
packages/secubox-toolbox-ng/cmd/sbxmitm/main.go
Normal file
311
packages/secubox-toolbox-ng/cmd/sbxmitm/main.go
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
// SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
// Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
//
|
||||
// SecuBox-Deb :: toolbox-ng :: forging MITM PoC (#662 Phase 1)
|
||||
//
|
||||
// De-risking spike for migrating the R3 MITM engine off Python mitmproxy onto a
|
||||
// multi-core Go core. Pure standard library (no external modules) so it builds
|
||||
// offline and cross-compiles to arm64 with `GOOS=linux GOARCH=arm64 go build`.
|
||||
//
|
||||
// It is NOT wired into the live R3 path. It proves the discriminating
|
||||
// capabilities the engine analysis flagged as risky:
|
||||
// - forge per-host leaf certs from the EXISTING ca-wg CA (client trust intact),
|
||||
// - request short-circuit 204 (ad_ghost block),
|
||||
// - response body inject (banner / ad-CSS),
|
||||
// - SNI splice passthrough (tls_splice),
|
||||
// - TLS ClientHello capture for JA4 (ja4 addon) via crypto/tls.GetCertificate.
|
||||
//
|
||||
// Runs as an HTTP CONNECT proxy for easy smoke-testing (`curl -x`). The live
|
||||
// engine will run transparent (SO_ORIGINAL_DST) — same handlers, different
|
||||
// accept path (Phase 2+).
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ── CA + per-host leaf forging ──────────────────────────────────────────────
|
||||
|
||||
// CA holds the loaded forging CA (reused from ca-wg) + a per-host leaf cache.
|
||||
type CA struct {
|
||||
cert *x509.Certificate
|
||||
key crypto.Signer
|
||||
mu sync.Mutex
|
||||
cache map[string]*tls.Certificate
|
||||
}
|
||||
|
||||
func loadCA(certPath, keyPath string) (*CA, error) {
|
||||
cpem, err := os.ReadFile(certPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read ca cert: %w", err)
|
||||
}
|
||||
kpem, err := os.ReadFile(keyPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read ca key: %w", err)
|
||||
}
|
||||
cblk, _ := pem.Decode(cpem)
|
||||
if cblk == nil {
|
||||
return nil, fmt.Errorf("ca cert: no PEM block")
|
||||
}
|
||||
cert, err := x509.ParseCertificate(cblk.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse ca cert: %w", err)
|
||||
}
|
||||
kblk, _ := pem.Decode(kpem)
|
||||
if kblk == nil {
|
||||
return nil, fmt.Errorf("ca key: no PEM block")
|
||||
}
|
||||
key, err := parseKey(kblk.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse ca key: %w", err)
|
||||
}
|
||||
return &CA{cert: cert, key: key, cache: map[string]*tls.Certificate{}}, nil
|
||||
}
|
||||
|
||||
func parseKey(der []byte) (crypto.Signer, error) {
|
||||
if k, err := x509.ParsePKCS8PrivateKey(der); err == nil {
|
||||
if s, ok := k.(crypto.Signer); ok {
|
||||
return s, nil
|
||||
}
|
||||
}
|
||||
if k, err := x509.ParsePKCS1PrivateKey(der); err == nil {
|
||||
return k, nil
|
||||
}
|
||||
if k, err := x509.ParseECPrivateKey(der); err == nil {
|
||||
return k, nil
|
||||
}
|
||||
return nil, fmt.Errorf("unsupported CA key format")
|
||||
}
|
||||
|
||||
// forge returns a leaf cert for host signed by the CA, cached.
|
||||
func (c *CA) forge(host string) (*tls.Certificate, error) {
|
||||
host = strings.ToLower(strings.TrimSpace(host))
|
||||
c.mu.Lock()
|
||||
if tc, ok := c.cache[host]; ok {
|
||||
c.mu.Unlock()
|
||||
return tc, nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{CommonName: host},
|
||||
NotBefore: time.Now().Add(-1 * time.Hour),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
DNSNames: []string{host},
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, c.cert, c.key.Public(), c.key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
leaf, err := x509.ParseCertificate(der) // parsed cert has Raw populated (Verify needs it)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tc := &tls.Certificate{Certificate: [][]byte{der, c.cert.Raw}, PrivateKey: c.key, Leaf: leaf}
|
||||
c.mu.Lock()
|
||||
c.cache[host] = tc
|
||||
c.mu.Unlock()
|
||||
return tc, nil
|
||||
}
|
||||
|
||||
// ── Pure handler logic (the ported addon decisions) ─────────────────────────
|
||||
|
||||
type Policy struct {
|
||||
AdHosts []string // ad_ghost: 204 these (suffix match)
|
||||
SpliceHosts []string // tls_splice: passthrough, no MITM (suffix match)
|
||||
Inject []byte // banner / ad-CSS marker injected before </head> or </body>
|
||||
}
|
||||
|
||||
func suffixMatch(host string, pats []string) bool {
|
||||
h := strings.ToLower(strings.TrimSpace(host))
|
||||
for _, p := range pats {
|
||||
p = strings.ToLower(p)
|
||||
if h == p || strings.HasSuffix(h, "."+p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// action: "block" (204), "splice" (passthrough), or "mitm".
|
||||
func (p Policy) action(host string) string {
|
||||
if suffixMatch(host, p.SpliceHosts) {
|
||||
return "splice"
|
||||
}
|
||||
if suffixMatch(host, p.AdHosts) {
|
||||
return "block"
|
||||
}
|
||||
return "mitm"
|
||||
}
|
||||
|
||||
// injectMarker inserts p.Inject before </head> (else </body>, else prepends).
|
||||
func (p Policy) injectMarker(body []byte) []byte {
|
||||
if len(p.Inject) == 0 || bytes.Contains(body, p.Inject) {
|
||||
return body
|
||||
}
|
||||
for _, tag := range [][]byte{[]byte("</head>"), []byte("</body>")} {
|
||||
if i := bytes.Index(bytes.ToLower(body), bytes.ToLower(tag)); i >= 0 {
|
||||
out := make([]byte, 0, len(body)+len(p.Inject))
|
||||
out = append(out, body[:i]...)
|
||||
out = append(out, p.Inject...)
|
||||
out = append(out, body[i:]...)
|
||||
return out
|
||||
}
|
||||
}
|
||||
return append(append([]byte{}, p.Inject...), body...)
|
||||
}
|
||||
|
||||
// ── JA4 ClientHello capture (the Go-feasibility proof for the ja4 addon) ─────
|
||||
|
||||
// ja4ish builds a compact handshake fingerprint from the fields crypto/tls
|
||||
// exposes in ClientHelloInfo (SNI, TLS versions, cipher count, ALPN). A FULL
|
||||
// JA4 also needs the extension list, which requires a raw-ClientHello-bytes
|
||||
// peek before stdlib parsing — feasible (Phase 4); this proves the material is
|
||||
// reachable in Go without Python.
|
||||
func ja4ish(h *tls.ClientHelloInfo) string {
|
||||
maxVer := uint16(0)
|
||||
for _, v := range h.SupportedVersions {
|
||||
if v > maxVer {
|
||||
maxVer = v
|
||||
}
|
||||
}
|
||||
alpn := "none"
|
||||
if len(h.SupportedProtos) > 0 {
|
||||
alpn = h.SupportedProtos[0]
|
||||
}
|
||||
return fmt.Sprintf("t%04x_c%02d_a%s_sni=%s", maxVer, len(h.CipherSuites), alpn, h.ServerName)
|
||||
}
|
||||
|
||||
// ── CONNECT-proxy MITM wiring ────────────────────────────────────────────────
|
||||
|
||||
type Proxy struct {
|
||||
ca *CA
|
||||
pol Policy
|
||||
jaSink func(string) // JA4 observations (logged; a sidecar in prod)
|
||||
}
|
||||
|
||||
func (px *Proxy) serverTLSConfig() *tls.Config {
|
||||
return &tls.Config{
|
||||
GetCertificate: func(h *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
if px.jaSink != nil {
|
||||
px.jaSink(ja4ish(h)) // capture handshake fingerprint
|
||||
}
|
||||
name := h.ServerName
|
||||
if name == "" {
|
||||
name = "unknown.local"
|
||||
}
|
||||
return px.ca.forge(name)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (px *Proxy) handleConnect(w http.ResponseWriter, r *http.Request) {
|
||||
host := r.URL.Hostname()
|
||||
hj, ok := w.(http.Hijacker)
|
||||
if !ok {
|
||||
http.Error(w, "no hijack", 500)
|
||||
return
|
||||
}
|
||||
client, _, err := hj.Hijack()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer client.Close()
|
||||
io.WriteString(client, "HTTP/1.1 200 Connection Established\r\n\r\n")
|
||||
|
||||
if px.pol.action(host) == "splice" {
|
||||
// passthrough: raw TCP to upstream, no TLS interception (tls_splice).
|
||||
up, err := net.DialTimeout("tcp", r.URL.Host, 10*time.Second)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer up.Close()
|
||||
go io.Copy(up, client)
|
||||
io.Copy(client, up)
|
||||
return
|
||||
}
|
||||
|
||||
// MITM: TLS-terminate the client with a forged cert (+ ClientHello capture).
|
||||
tconn := tls.Server(client, px.serverTLSConfig())
|
||||
if err := tconn.Handshake(); err != nil {
|
||||
return
|
||||
}
|
||||
defer tconn.Close()
|
||||
br := newReader(tconn)
|
||||
req, err := http.ReadRequest(br)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
req.URL.Scheme, req.URL.Host = "https", r.URL.Host
|
||||
|
||||
if px.pol.action(host) == "block" {
|
||||
writeRaw(tconn, 204, "No Content", map[string]string{"X-SecuBox-Ng": "blocked"}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
// proxy upstream, inject into HTML bodies.
|
||||
up := &http.Client{Timeout: 30 * time.Second}
|
||||
req.RequestURI = ""
|
||||
resp, err := up.Do(req)
|
||||
if err != nil {
|
||||
writeRaw(tconn, 502, "Bad Gateway", nil, nil)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||
if strings.Contains(resp.Header.Get("Content-Type"), "text/html") {
|
||||
body = px.pol.injectMarker(body)
|
||||
}
|
||||
hdr := map[string]string{"Content-Type": resp.Header.Get("Content-Type")}
|
||||
writeRaw(tconn, resp.StatusCode, resp.Status, hdr, body)
|
||||
}
|
||||
|
||||
func main() {
|
||||
caCert := flag.String("ca-cert", "/etc/secubox/toolbox/ca-wg/ca.pem", "CA cert PEM")
|
||||
caKey := flag.String("ca-key", "/etc/secubox/toolbox/ca-wg/key.pem", "CA key PEM")
|
||||
addr := flag.String("listen", ":8090", "CONNECT proxy listen addr")
|
||||
flag.Parse()
|
||||
ca, err := loadCA(*caCert, *caKey)
|
||||
if err != nil {
|
||||
log.Fatalf("CA load: %v", err)
|
||||
}
|
||||
px := &Proxy{
|
||||
ca: ca,
|
||||
pol: Policy{
|
||||
AdHosts: []string{"doubleclick.net", "googlesyndication.com"},
|
||||
SpliceHosts: []string{"googlevideo.com", "fbcdn.net"},
|
||||
Inject: []byte("<!-- sbx-ng banner -->"),
|
||||
},
|
||||
jaSink: func(s string) { log.Printf("ja4 %s", s) },
|
||||
}
|
||||
srv := &http.Server{Addr: *addr, Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodConnect {
|
||||
px.handleConnect(w, r)
|
||||
return
|
||||
}
|
||||
http.Error(w, "CONNECT only (PoC)", 405)
|
||||
})}
|
||||
log.Printf("sbxmitm PoC listening on %s (CA %s)", *addr, *caCert)
|
||||
log.Fatal(srv.ListenAndServe())
|
||||
}
|
||||
156
packages/secubox-toolbox-ng/cmd/sbxmitm/main_test.go
Normal file
156
packages/secubox-toolbox-ng/cmd/sbxmitm/main_test.go
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
// SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
// Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// genTestCA writes a self-signed CA (cert+key PEM) to dir, mirroring ca-wg.
|
||||
func genTestCA(t *testing.T, dir string) (certPath, keyPath string) {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "SecuBox Test CA"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
IsCA: true,
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, key.Public(), key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
certPath = filepath.Join(dir, "ca.pem")
|
||||
keyPath = filepath.Join(dir, "key.pem")
|
||||
cf, _ := os.Create(certPath)
|
||||
pem.Encode(cf, &pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
cf.Close()
|
||||
kder, _ := x509.MarshalPKCS8PrivateKey(key)
|
||||
kf, _ := os.Create(keyPath)
|
||||
pem.Encode(kf, &pem.Block{Type: "PRIVATE KEY", Bytes: kder})
|
||||
kf.Close()
|
||||
return certPath, keyPath
|
||||
}
|
||||
|
||||
func TestForgeChainsToCA(t *testing.T) {
|
||||
cp, kp := genTestCA(t, t.TempDir())
|
||||
ca, err := loadCA(cp, kp)
|
||||
if err != nil {
|
||||
t.Fatalf("loadCA: %v", err)
|
||||
}
|
||||
leaf, err := ca.forge("ads.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("forge: %v", err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
pool.AddCert(ca.cert)
|
||||
if _, err := leaf.Leaf.Verify(x509.VerifyOptions{Roots: pool, DNSName: "ads.example.com"}); err != nil {
|
||||
t.Fatalf("forged leaf does not chain to CA / wrong SAN: %v", err)
|
||||
}
|
||||
leaf2, _ := ca.forge("ads.example.com")
|
||||
if leaf2 != leaf {
|
||||
t.Fatal("forge not cached")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionDecision(t *testing.T) {
|
||||
p := Policy{AdHosts: []string{"doubleclick.net"}, SpliceHosts: []string{"googlevideo.com"}}
|
||||
cases := map[string]string{
|
||||
"ads.doubleclick.net": "block",
|
||||
"doubleclick.net": "block",
|
||||
"r1.googlevideo.com": "splice",
|
||||
"news.example.com": "mitm",
|
||||
"notdoubleclick.net": "mitm",
|
||||
}
|
||||
for host, want := range cases {
|
||||
if got := p.action(host); got != want {
|
||||
t.Errorf("action(%q)=%q want %q", host, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectMarker(t *testing.T) {
|
||||
p := Policy{Inject: []byte("<!--SBX-->")}
|
||||
out := string(p.injectMarker([]byte("<html><head></head><body>hi</body></html>")))
|
||||
if !contains(out, "<!--SBX--></head>") {
|
||||
t.Fatalf("marker not injected before </head>: %s", out)
|
||||
}
|
||||
if string(p.injectMarker([]byte(out))) != out {
|
||||
t.Fatal("inject not idempotent")
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TestClientHelloCaptureAndForge: a real localhost TLS handshake proves the Go
|
||||
// core forges a per-SNI cert from the CA that the client trusts AND that the
|
||||
// ClientHello (JA4 material) is captured.
|
||||
func TestClientHelloCaptureAndForge(t *testing.T) {
|
||||
cp, kp := genTestCA(t, t.TempDir())
|
||||
ca, err := loadCA(cp, kp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var mu sync.Mutex
|
||||
var captured string
|
||||
px := &Proxy{ca: ca, jaSink: func(s string) { mu.Lock(); captured = s; mu.Unlock() }}
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
go func() {
|
||||
c, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
s := tls.Server(c, px.serverTLSConfig())
|
||||
s.Handshake()
|
||||
s.Close()
|
||||
}()
|
||||
|
||||
pool := x509.NewCertPool()
|
||||
pool.AddCert(ca.cert)
|
||||
conn, err := tls.Dial("tcp", ln.Addr().String(), &tls.Config{ServerName: "example.com", RootCAs: pool})
|
||||
if err != nil {
|
||||
t.Fatalf("client handshake against forged cert failed (CA not trusted / forge broken): %v", err)
|
||||
}
|
||||
conn.Close()
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if captured == "" {
|
||||
t.Fatal("ClientHello not captured")
|
||||
}
|
||||
if !contains(captured, "sni=example.com") {
|
||||
t.Fatalf("JA4 capture missing SNI: %q", captured)
|
||||
}
|
||||
t.Logf("captured JA4-ish: %s", captured)
|
||||
}
|
||||
31
packages/secubox-toolbox-ng/cmd/sbxmitm/util.go
Normal file
31
packages/secubox-toolbox-ng/cmd/sbxmitm/util.go
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
// SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
// Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
)
|
||||
|
||||
func newReader(c net.Conn) *bufio.Reader { return bufio.NewReader(c) }
|
||||
|
||||
// writeRaw writes a minimal HTTP/1.1 response onto a (TLS) conn.
|
||||
func writeRaw(c io.Writer, code int, status string, headers map[string]string, body []byte) {
|
||||
if status == "" {
|
||||
status = "OK"
|
||||
}
|
||||
fmt.Fprintf(c, "HTTP/1.1 %d %s\r\n", code, status)
|
||||
fmt.Fprintf(c, "Content-Length: %d\r\n", len(body))
|
||||
fmt.Fprintf(c, "Connection: close\r\n")
|
||||
for k, v := range headers {
|
||||
if v != "" {
|
||||
fmt.Fprintf(c, "%s: %s\r\n", k, v)
|
||||
}
|
||||
}
|
||||
io.WriteString(c, "\r\n")
|
||||
if len(body) > 0 {
|
||||
c.Write(body)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user