diff options
| -rw-r--r-- | .gitignore | 5 | ||||
| -rw-r--r-- | PLAN.md | 48 | ||||
| -rw-r--r-- | cmd/mitmuxd/main.go | 62 | ||||
| -rw-r--r-- | go.mod | 3 | ||||
| -rw-r--r-- | internal/ca/ca.go | 153 | ||||
| -rw-r--r-- | internal/proxy/proxy.go | 153 |
6 files changed, 424 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1915ed4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/bin/ +*.pem +*.db +*.db-wal +*.db-shm @@ -0,0 +1,48 @@ +# mitmux - Intercepting Proxy TUI (Burp/Caido replacement) + +## Overview +Daily-driver intercepting proxy for manual pentest work, terminal-based. +Prior art to read before writing code: Cruster (Rust, built on +hudsucker) - same problem, worth studying even though this build is Go. + +## Stack +- Language: Go - memory safety on hostile input matters here more than + in the other projects, since this parses attacker-adjacent traffic +- TLS interception: Go's own `crypto/tls` + a CA cert generator + (analogous to `rcgen`) for per-domain leaf certs +- Proxy core: `net/http` + manual `CONNECT` handling, or a MITM proxy + library if one fits without fighting Go's aggressive header + normalization +- Storage: SQLite in WAL mode - blob columns for raw request/response + bytes, FTS5 index for search across bodies +- UI: Bubble Tea + Lipgloss (TUI), same family as the packet analyzer's + Go sibling if that ever gets built + +## Architecture sketch (important - don't skip this) +- Split proxy engine from TUI. Headless daemon owns the listening + socket and the DB; TUI is a client over a Unix socket. The proxy + keeps running when the UI restarts, and a web UI or CLI scanner can + be bolted on later without touching the engine. +- Store raw bytes as the source of truth. Parse into a display view, + never re-serialize for storage - request smuggling, header injection, + and parser-differential bugs depend on the original malformed framing + surviving. For Repeater specifically, write requests as raw bytes + over the socket rather than through a normalizing HTTP client. + +## Build order +1. Proxy + CA cert generation + plaintext HTTP passthrough +2. TLS interception (per-host cert generation, install CA) +3. History view (SQLite storage, raw bytes preserved) in the TUI +4. Repeater (raw-byte send/resend, the feature used daily) +5. Search/filter (FTS5) +6. Match-and-replace rules +7. Intruder-equivalent (last, optional) + +## Open questions +- HTTP/2: handle natively (decided) - full fidelity over MITM'd + connections rather than downgrading to HTTP/1.1. Adds complexity to + CONNECT handling, stream framing, and step 3 storage (multiplexed + streams over one connection need per-stream request/response + boundaries, not just per-connection ones). +- CA install UX per OS (Linux/macOS/Windows trust stores) +- Whether WebSocket interception is v1 or a later addition diff --git a/cmd/mitmuxd/main.go b/cmd/mitmuxd/main.go new file mode 100644 index 0000000..400ca82 --- /dev/null +++ b/cmd/mitmuxd/main.go @@ -0,0 +1,62 @@ +// Command mitmuxd is the mitmux headless proxy daemon. It owns the +// listening socket and (in later build steps) the traffic database; a +// TUI or other client attaches separately without interrupting capture. +package main + +import ( + "context" + "flag" + "log" + "os" + "os/signal" + "syscall" + "time" + + "mitmux/internal/ca" + "mitmux/internal/proxy" +) + +func main() { + listen := flag.String("listen", "127.0.0.1:8080", "proxy listen address") + caDir := flag.String("ca-dir", "", "directory for CA cert/key (default: XDG config dir)") + flag.Parse() + + dir := *caDir + if dir == "" { + d, err := ca.Dir() + if err != nil { + log.Fatalf("resolve CA dir: %v", err) + } + dir = d + } + + root, err := ca.EnsureCA(dir) + if err != nil { + log.Fatalf("load CA: %v", err) + } + log.Printf("CA ready: %s", root.Cert.Subject.CommonName) + + srv := proxy.New(*listen) + + errCh := make(chan error, 1) + go func() { + errCh <- srv.ListenAndServe() + }() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + + select { + case err := <-errCh: + if err != nil { + log.Fatalf("proxy: %v", err) + } + case sig := <-sigCh: + log.Printf("received %s, shutting down", sig) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := srv.Shutdown(ctx); err != nil { + log.Fatalf("shutdown: %v", err) + } + } +} @@ -0,0 +1,3 @@ +module mitmux + +go 1.26.5 diff --git a/internal/ca/ca.go b/internal/ca/ca.go new file mode 100644 index 0000000..949c2fd --- /dev/null +++ b/internal/ca/ca.go @@ -0,0 +1,153 @@ +// Package ca manages mitmux's root CA: generating it on first run and +// loading it on subsequent runs. Leaf certificate signing for TLS +// interception is added in a later build step. +package ca + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "errors" + "fmt" + "math/big" + "os" + "path/filepath" + "time" +) + +const ( + certFileName = "ca.pem" + keyFileName = "ca-key.pem" +) + +// CA holds the root certificate and key used to sign per-host leaf +// certificates during TLS interception. +type CA struct { + Cert *x509.Certificate + Key *ecdsa.PrivateKey + CertPEM []byte + KeyPEM []byte +} + +// Dir returns the directory mitmux stores its CA material in +// (XDG config dir, e.g. ~/.config/mitmux). +func Dir() (string, error) { + cfg, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("resolve config dir: %w", err) + } + return filepath.Join(cfg, "mitmux"), nil +} + +// EnsureCA loads the CA from dir, generating and persisting a new one if +// none exists yet. +func EnsureCA(dir string) (*CA, error) { + certPath := filepath.Join(dir, certFileName) + keyPath := filepath.Join(dir, keyFileName) + + certPEM, certErr := os.ReadFile(certPath) + keyPEM, keyErr := os.ReadFile(keyPath) + if certErr == nil && keyErr == nil { + return load(certPEM, keyPEM) + } + if !errors.Is(certErr, os.ErrNotExist) && certErr != nil { + return nil, fmt.Errorf("read %s: %w", certPath, certErr) + } + if !errors.Is(keyErr, os.ErrNotExist) && keyErr != nil { + return nil, fmt.Errorf("read %s: %w", keyPath, keyErr) + } + + ca, err := generate() + if err != nil { + return nil, fmt.Errorf("generate CA: %w", err) + } + if err := persist(dir, ca); err != nil { + return nil, fmt.Errorf("persist CA: %w", err) + } + return ca, nil +} + +func generate() (*CA, error) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, err + } + + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return nil, err + } + + tmpl := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{ + CommonName: "mitmux local CA", + Organization: []string{"mitmux"}, + }, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().AddDate(10, 0, 0), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature | x509.KeyUsageCRLSign, + BasicConstraintsValid: true, + IsCA: true, + MaxPathLenZero: true, + } + + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + return nil, err + } + cert, err := x509.ParseCertificate(der) + if err != nil { + return nil, err + } + + keyDER, err := x509.MarshalECPrivateKey(key) + if err != nil { + return nil, err + } + + return &CA{ + Cert: cert, + Key: key, + CertPEM: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), + KeyPEM: pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}), + }, nil +} + +func load(certPEM, keyPEM []byte) (*CA, error) { + certBlock, _ := pem.Decode(certPEM) + if certBlock == nil { + return nil, errors.New("no PEM block found in CA certificate") + } + cert, err := x509.ParseCertificate(certBlock.Bytes) + if err != nil { + return nil, fmt.Errorf("parse CA certificate: %w", err) + } + + keyBlock, _ := pem.Decode(keyPEM) + if keyBlock == nil { + return nil, errors.New("no PEM block found in CA key") + } + key, err := x509.ParseECPrivateKey(keyBlock.Bytes) + if err != nil { + return nil, fmt.Errorf("parse CA key: %w", err) + } + + return &CA{Cert: cert, Key: key, CertPEM: certPEM, KeyPEM: keyPEM}, nil +} + +func persist(dir string, ca *CA) error { + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + if err := os.WriteFile(filepath.Join(dir, certFileName), ca.CertPEM, 0o644); err != nil { + return err + } + if err := os.WriteFile(filepath.Join(dir, keyFileName), ca.KeyPEM, 0o600); err != nil { + return err + } + return nil +} diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go new file mode 100644 index 0000000..c9b6195 --- /dev/null +++ b/internal/proxy/proxy.go @@ -0,0 +1,153 @@ +// Package proxy is the mitmux proxy engine: a forward HTTP proxy that, +// at this build stage, passes traffic through unmodified. CONNECT +// requests (HTTPS) are tunneled raw rather than intercepted - TLS +// interception is a later build step. +package proxy + +import ( + "context" + "io" + "log" + "net" + "net/http" + "time" +) + +// hopByHopHeaders are stripped before forwarding a request or response, +// per RFC 7230 6.1 - they are meaningful only between a client and its +// immediate next hop, not end-to-end. +var hopByHopHeaders = []string{ + "Connection", + "Proxy-Connection", + "Keep-Alive", + "Proxy-Authenticate", + "Proxy-Authorization", + "TE", + "Trailers", + "Transfer-Encoding", + "Upgrade", +} + +// Server is a forward proxy listener. +type Server struct { + Addr string + + transport *http.Transport + server *http.Server +} + +// New creates a proxy Server bound to addr (e.g. "127.0.0.1:8080"). +func New(addr string) *Server { + s := &Server{ + Addr: addr, + transport: &http.Transport{ + Proxy: nil, + DialContext: (&net.Dialer{ + Timeout: 10 * time.Second, + }).DialContext, + ForceAttemptHTTP2: false, + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + }, + } + s.server = &http.Server{ + Addr: addr, + Handler: http.HandlerFunc(s.handle), + } + return s +} + +// ListenAndServe starts the proxy and blocks until it stops. +func (s *Server) ListenAndServe() error { + log.Printf("proxy listening on %s", s.Addr) + return s.server.ListenAndServe() +} + +// Shutdown gracefully stops the proxy. +func (s *Server) Shutdown(ctx context.Context) error { + return s.server.Shutdown(ctx) +} + +func (s *Server) handle(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodConnect { + s.handleConnect(w, r) + return + } + s.handleHTTP(w, r) +} + +// handleConnect tunnels a CONNECT request raw, byte for byte, without +// terminating TLS. This is the passthrough behavior for HTTPS traffic +// until TLS interception is implemented. +func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) { + dst, err := net.DialTimeout("tcp", r.Host, 10*time.Second) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + defer dst.Close() + + hijacker, ok := w.(http.Hijacker) + if !ok { + http.Error(w, "hijacking not supported", http.StatusInternalServerError) + return + } + src, _, err := hijacker.Hijack() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer src.Close() + + if _, err := src.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n")); err != nil { + return + } + + done := make(chan struct{}, 2) + go func() { + io.Copy(dst, src) + done <- struct{}{} + }() + go func() { + io.Copy(src, dst) + done <- struct{}{} + }() + <-done +} + +// handleHTTP forwards a plain (non-CONNECT) proxy request and copies the +// response back unmodified. +func (s *Server) handleHTTP(w http.ResponseWriter, r *http.Request) { + if !r.URL.IsAbs() { + http.Error(w, "mitmux: request must use absolute-form URI (configure as a proxy, not a target)", http.StatusBadRequest) + return + } + + outReq := r.Clone(r.Context()) + outReq.RequestURI = "" + stripHopByHop(outReq.Header) + + resp, err := s.transport.RoundTrip(outReq) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + defer resp.Body.Close() + + stripHopByHop(resp.Header) + for k, vv := range resp.Header { + for _, v := range vv { + w.Header().Add(k, v) + } + } + w.WriteHeader(resp.StatusCode) + io.Copy(w, resp.Body) +} + +func stripHopByHop(h http.Header) { + for _, k := range hopByHopHeaders { + h.Del(k) + } +} |