mirror of
https://github.com/lunchcat/sif.git
synced 2026-07-28 14:37:01 -07:00
feat(scan): add tls certificate recon module (#274)
Add a -tls-cert scan that actively dials the target's tls port and mines the leaf certificate: SAN entries become candidate subdomains, and issuer/validity feed posture flags (self-signed, expired, expiring soon, wildcard). Unlike the passive crt.sh and certspotter feeds this probes the target directly, so it surfaces certs never logged to a public ct log at the cost of touching the target. Self-signed detection compares raw issuer/subject der and verifies the cert against its own key rather than CheckSignatureFrom, which false-negatives on leaf certs that omit CA key-usage bits. Co-authored-by: vmfunc <vmfunc.lc@gmail.com>
This commit is contained in:
@@ -66,6 +66,8 @@ type Settings struct {
|
||||
Framework bool
|
||||
Crawl bool
|
||||
CrawlDepth int
|
||||
TLSCert bool
|
||||
TLSCertPort int
|
||||
Passive bool
|
||||
Probe bool
|
||||
SARIF string // path to write a sarif 2.1.0 report to ("" = off)
|
||||
@@ -166,6 +168,8 @@ func registerFlags(settings *Settings) *goflags.FlagSet {
|
||||
flagSet.BoolVar(&settings.Framework, "framework", false, "Enable framework detection"),
|
||||
flagSet.BoolVar(&settings.Crawl, "crawl", false, "Enable web crawling (spider same-host links/scripts/forms)"),
|
||||
flagSet.IntVar(&settings.CrawlDepth, "crawl-depth", defaultCrawlDepth, "Max crawl recursion depth"),
|
||||
flagSet.BoolVar(&settings.TLSCert, "tls-cert", false, "Enable tls certificate recon (mine SANs, issuer, posture from the leaf cert)"),
|
||||
flagSet.IntVar(&settings.TLSCertPort, "tls-cert-port", 0, "Port for tls certificate recon (default 443)"),
|
||||
flagSet.BoolVar(&settings.Passive, "passive", false, "Enable passive subdomain/url discovery (zero traffic to target)"),
|
||||
flagSet.BoolVar(&settings.Probe, "probe", false, "Probe the target for liveness (status, title, server, redirect chain)"),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
||||
: ▄█ █ █▀ · BSD 3-Clause License :
|
||||
: :
|
||||
: (c) 2022-2026 vmfunc, xyzeva, :
|
||||
: lunchcat alumni & contributors :
|
||||
: :
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
*/
|
||||
|
||||
package scan
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/vmfunc/sif/internal/logger"
|
||||
"github.com/vmfunc/sif/internal/output"
|
||||
)
|
||||
|
||||
// tlsCertExpirySoonWindow flags a leaf certificate as "expiring soon" inside
|
||||
// this window, mirroring the threshold nmap's ssl-cert and testssl.sh use.
|
||||
const tlsCertExpirySoonWindow = 30 * 24 * time.Hour
|
||||
|
||||
// TLSCertResult holds what the target's leaf certificate reveals: extra
|
||||
// hostnames from the SAN list, issuer/validity metadata, and posture flags a
|
||||
// human would want surfaced without reading the cert by hand.
|
||||
type TLSCertResult struct {
|
||||
Subject string `json:"subject"`
|
||||
Issuer string `json:"issuer"`
|
||||
SANs []string `json:"sans"`
|
||||
NotBefore string `json:"not_before"`
|
||||
NotAfter string `json:"not_after"`
|
||||
SerialNumber string `json:"serial_number"`
|
||||
SelfSigned bool `json:"self_signed"`
|
||||
Expired bool `json:"expired"`
|
||||
ExpiringSoon bool `json:"expiring_soon"`
|
||||
Wildcard bool `json:"wildcard"`
|
||||
NewSubdomains []string `json:"new_subdomains"` // SANs not already known to be the target host
|
||||
ChainLength int `json:"chain_length"`
|
||||
}
|
||||
|
||||
func (r *TLSCertResult) ResultType() string { return "tlscert" }
|
||||
|
||||
var _ ScanResult = (*TLSCertResult)(nil)
|
||||
|
||||
// tlsDial is a var so tests can substitute a fake dialer without touching the
|
||||
// network.
|
||||
var tlsDial = func(addr string, timeout time.Duration) (*tls.Conn, error) {
|
||||
dialer := &net.Dialer{Timeout: timeout}
|
||||
// InsecureSkipVerify is deliberate: this module mines whatever certificate
|
||||
// the target presents, self-signed or expired included, rather than only
|
||||
// certs that pass validation like the main http client requires.
|
||||
return tls.DialWithDialer(dialer, "tcp", addr, &tls.Config{InsecureSkipVerify: true}) //nolint:gosec // recon target, not a trust decision
|
||||
}
|
||||
|
||||
// TLSCert connects to the target on the given port (443 when unset) and mines
|
||||
// its leaf certificate: SANs become candidate subdomains, issuer/validity feed
|
||||
// posture flags (self-signed, expired, expiring soon). Unlike Passive's crt.sh
|
||||
// and certspotter feeds this is an active probe against the target itself, so
|
||||
// it catches certs that were never logged to a public CT log (short-lived
|
||||
// certs not yet propagated, internal CAs) at the cost of touching the target.
|
||||
func TLSCert(targetURL string, port int, timeout time.Duration, logdir string) (*TLSCertResult, error) {
|
||||
log := output.Module("TLSCERT")
|
||||
log.Start()
|
||||
|
||||
parsed, err := url.Parse(targetURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse target url %q: %w", targetURL, err)
|
||||
}
|
||||
host := parsed.Hostname()
|
||||
if host == "" {
|
||||
return nil, fmt.Errorf("target url %q has no host", targetURL)
|
||||
}
|
||||
if port == 0 {
|
||||
port = 443
|
||||
}
|
||||
addr := net.JoinHostPort(host, strconv.Itoa(port))
|
||||
|
||||
sanitizedURL := stripScheme(targetURL)
|
||||
if logdir != "" {
|
||||
if err := logger.WriteHeader(sanitizedURL, logdir, "tls certificate recon"); err != nil {
|
||||
log.Error("error creating log file: %v", err)
|
||||
return nil, fmt.Errorf("create tlscert log: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
conn, err := tlsDial(addr, timeout)
|
||||
if err != nil {
|
||||
log.Warn("tls dial %s failed: %v", addr, err)
|
||||
return nil, fmt.Errorf("tls dial %q: %w", addr, err)
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
state := conn.ConnectionState()
|
||||
if len(state.PeerCertificates) == 0 {
|
||||
return nil, fmt.Errorf("tls handshake with %q presented no certificates", addr)
|
||||
}
|
||||
leaf := state.PeerCertificates[0]
|
||||
|
||||
result := buildTLSCertResult(leaf, state.PeerCertificates, host)
|
||||
|
||||
logTLSCertResult(log, sanitizedURL, logdir, result)
|
||||
|
||||
log.Complete(len(result.SANs), "san entries")
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// buildTLSCertResult turns a parsed leaf certificate into the recon-facing
|
||||
// result: SAN-derived hostnames, issuer/validity, and posture flags.
|
||||
func buildTLSCertResult(leaf *x509.Certificate, chain []*x509.Certificate, targetHost string) *TLSCertResult {
|
||||
now := time.Now()
|
||||
|
||||
sanSet := make(map[string]struct{}, len(leaf.DNSNames))
|
||||
for _, name := range leaf.DNSNames {
|
||||
sanSet[normalizeHost(name)] = struct{}{}
|
||||
}
|
||||
sans := sortedKeys(sanSet)
|
||||
|
||||
var newSubs []string
|
||||
for _, san := range sans {
|
||||
if san != normalizeHost(targetHost) {
|
||||
newSubs = append(newSubs, san)
|
||||
}
|
||||
}
|
||||
|
||||
wildcard := false
|
||||
for _, name := range leaf.DNSNames {
|
||||
if len(name) > 1 && name[0] == '*' {
|
||||
wildcard = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// self-signed: issuer == subject (raw DER, not the human-readable string)
|
||||
// and the cert's own signature verifies against its own TBS bytes.
|
||||
// CheckSignatureFrom(leaf) is the wrong tool here - it additionally
|
||||
// requires CA key-usage/basic-constraints bits a self-signed leaf usually
|
||||
// doesn't set, so it false-negatives on exactly the certs this flag exists
|
||||
// to catch.
|
||||
selfSigned := bytes.Equal(leaf.RawIssuer, leaf.RawSubject) &&
|
||||
leaf.CheckSignature(leaf.SignatureAlgorithm, leaf.RawTBSCertificate, leaf.Signature) == nil
|
||||
|
||||
return &TLSCertResult{
|
||||
Subject: leaf.Subject.String(),
|
||||
Issuer: leaf.Issuer.String(),
|
||||
SANs: sans,
|
||||
NotBefore: leaf.NotBefore.UTC().Format(time.RFC3339),
|
||||
NotAfter: leaf.NotAfter.UTC().Format(time.RFC3339),
|
||||
SerialNumber: leaf.SerialNumber.String(),
|
||||
SelfSigned: selfSigned,
|
||||
Expired: now.After(leaf.NotAfter),
|
||||
ExpiringSoon: !now.After(leaf.NotAfter) && leaf.NotAfter.Sub(now) < tlsCertExpirySoonWindow,
|
||||
Wildcard: wildcard,
|
||||
NewSubdomains: newSubs,
|
||||
ChainLength: len(chain),
|
||||
}
|
||||
}
|
||||
|
||||
func logTLSCertResult(log *output.ModuleLogger, sanitizedURL, logdir string, result *TLSCertResult) {
|
||||
log.Info("subject: %s", result.Subject)
|
||||
log.Info("issuer: %s", result.Issuer)
|
||||
if result.SelfSigned {
|
||||
log.Warn("certificate is self-signed")
|
||||
}
|
||||
if result.Expired {
|
||||
log.Warn("certificate expired %s", result.NotAfter)
|
||||
} else if result.ExpiringSoon {
|
||||
log.Warn("certificate expires soon: %s", result.NotAfter)
|
||||
}
|
||||
for _, san := range result.NewSubdomains {
|
||||
log.Success("san: %s", output.Highlight.Render(san))
|
||||
}
|
||||
|
||||
if logdir == "" {
|
||||
return
|
||||
}
|
||||
|
||||
sb := fmt.Sprintf("Subject: %s\nIssuer: %s\nNotBefore: %s\nNotAfter: %s\nSelfSigned: %v\nExpired: %v\nWildcard: %v\n",
|
||||
result.Subject, result.Issuer, result.NotBefore, result.NotAfter, result.SelfSigned, result.Expired, result.Wildcard)
|
||||
if len(result.SANs) > 0 {
|
||||
sb += "\nSANs:\n"
|
||||
for _, san := range result.SANs {
|
||||
sb += " " + san + "\n"
|
||||
}
|
||||
}
|
||||
_ = logger.Write(sanitizedURL, logdir, sb)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
: :
|
||||
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
||||
: ▄█ █ █▀ · BSD 3-Clause License :
|
||||
: :
|
||||
: (c) 2022-2026 vmfunc, xyzeva, :
|
||||
: lunchcat alumni & contributors :
|
||||
: :
|
||||
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
||||
*/
|
||||
|
||||
package scan
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"math/big"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// makeLeaf builds a self-signed leaf cert with the given SANs and validity so
|
||||
// the posture flags can be exercised without a live tls handshake.
|
||||
func makeLeaf(t *testing.T, cn string, sans []string, notBefore, notAfter time.Time) *x509.Certificate {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: cn},
|
||||
DNSNames: sans,
|
||||
NotBefore: notBefore,
|
||||
NotAfter: notAfter,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatalf("create cert: %v", err)
|
||||
}
|
||||
leaf, err := x509.ParseCertificate(der)
|
||||
if err != nil {
|
||||
t.Fatalf("parse cert: %v", err)
|
||||
}
|
||||
return leaf
|
||||
}
|
||||
|
||||
func TestBuildTLSCertResult(t *testing.T) {
|
||||
now := time.Now()
|
||||
leaf := makeLeaf(t, "example.com",
|
||||
[]string{"example.com", "api.example.com", "*.cdn.example.com"},
|
||||
now.Add(-24*time.Hour), now.Add(365*24*time.Hour))
|
||||
|
||||
res := buildTLSCertResult(leaf, []*x509.Certificate{leaf}, "example.com")
|
||||
|
||||
if !res.SelfSigned {
|
||||
t.Error("self-signed leaf not flagged as self-signed")
|
||||
}
|
||||
if !res.Wildcard {
|
||||
t.Error("*.cdn.example.com SAN not flagged as wildcard")
|
||||
}
|
||||
if res.Expired || res.ExpiringSoon {
|
||||
t.Errorf("year-long cert flagged expired=%v soon=%v", res.Expired, res.ExpiringSoon)
|
||||
}
|
||||
// the target host itself must not appear as a "new" subdomain; the wildcard
|
||||
// SAN is normalized to its base (the *. prefix is stripped) and Wildcard is
|
||||
// flagged separately.
|
||||
want := map[string]bool{"api.example.com": true, "cdn.example.com": true}
|
||||
if len(res.NewSubdomains) != len(want) {
|
||||
t.Fatalf("new subdomains = %v, want the two non-target SANs", res.NewSubdomains)
|
||||
}
|
||||
for _, s := range res.NewSubdomains {
|
||||
if !want[s] {
|
||||
t.Errorf("unexpected new subdomain %q", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTLSCertResultExpiry(t *testing.T) {
|
||||
now := time.Now()
|
||||
|
||||
expired := buildTLSCertResult(
|
||||
makeLeaf(t, "old.example.com", []string{"old.example.com"}, now.Add(-48*time.Hour), now.Add(-1*time.Hour)),
|
||||
nil, "old.example.com")
|
||||
if !expired.Expired || expired.ExpiringSoon {
|
||||
t.Errorf("past-NotAfter cert: expired=%v soon=%v, want expired", expired.Expired, expired.ExpiringSoon)
|
||||
}
|
||||
|
||||
soon := buildTLSCertResult(
|
||||
makeLeaf(t, "soon.example.com", []string{"soon.example.com"}, now.Add(-1*time.Hour), now.Add(72*time.Hour)),
|
||||
nil, "soon.example.com")
|
||||
if soon.Expired || !soon.ExpiringSoon {
|
||||
t.Errorf("cert inside the soon window: expired=%v soon=%v, want soon", soon.Expired, soon.ExpiringSoon)
|
||||
}
|
||||
}
|
||||
@@ -683,6 +683,16 @@ func (app *App) scanTarget(url, storeDir string, wantReport bool) (targetScan, e
|
||||
}
|
||||
}
|
||||
|
||||
if app.settings.TLSCert {
|
||||
result, err := scan.TLSCert(url, app.settings.TLSCertPort, app.settings.Timeout, app.settings.LogDir)
|
||||
if err != nil {
|
||||
log.Errorf("Error while running tls certificate recon: %s", err)
|
||||
} else if result != nil {
|
||||
moduleResults = append(moduleResults, NewModuleResult(result))
|
||||
scansRun = append(scansRun, "TLSCert")
|
||||
}
|
||||
}
|
||||
|
||||
if app.settings.Passive {
|
||||
result, err := scan.Passive(url, app.settings.Timeout, app.settings.LogDir)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user