Skip to content

Commit 3e00297

Browse files
TBX3Dvmfunc
andauthored
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>
1 parent 1d7e219 commit 3e00297

4 files changed

Lines changed: 309 additions & 0 deletions

File tree

internal/config/config.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ type Settings struct {
6666
Framework bool
6767
Crawl bool
6868
CrawlDepth int
69+
TLSCert bool
70+
TLSCertPort int
6971
Passive bool
7072
Probe bool
7173
SARIF string // path to write a sarif 2.1.0 report to ("" = off)
@@ -166,6 +168,8 @@ func registerFlags(settings *Settings) *goflags.FlagSet {
166168
flagSet.BoolVar(&settings.Framework, "framework", false, "Enable framework detection"),
167169
flagSet.BoolVar(&settings.Crawl, "crawl", false, "Enable web crawling (spider same-host links/scripts/forms)"),
168170
flagSet.IntVar(&settings.CrawlDepth, "crawl-depth", defaultCrawlDepth, "Max crawl recursion depth"),
171+
flagSet.BoolVar(&settings.TLSCert, "tls-cert", false, "Enable tls certificate recon (mine SANs, issuer, posture from the leaf cert)"),
172+
flagSet.IntVar(&settings.TLSCertPort, "tls-cert-port", 0, "Port for tls certificate recon (default 443)"),
169173
flagSet.BoolVar(&settings.Passive, "passive", false, "Enable passive subdomain/url discovery (zero traffic to target)"),
170174
flagSet.BoolVar(&settings.Probe, "probe", false, "Probe the target for liveness (status, title, server, redirect chain)"),
171175
)

internal/scan/tlscert.go

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
/*
2+
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
3+
: :
4+
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
5+
: ▄█ █ █▀ · BSD 3-Clause License :
6+
: :
7+
: (c) 2022-2026 vmfunc, xyzeva, :
8+
: lunchcat alumni & contributors :
9+
: :
10+
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
11+
*/
12+
13+
package scan
14+
15+
import (
16+
"bytes"
17+
"crypto/tls"
18+
"crypto/x509"
19+
"fmt"
20+
"net"
21+
"net/url"
22+
"strconv"
23+
"time"
24+
25+
"github.com/vmfunc/sif/internal/logger"
26+
"github.com/vmfunc/sif/internal/output"
27+
)
28+
29+
// tlsCertExpirySoonWindow flags a leaf certificate as "expiring soon" inside
30+
// this window, mirroring the threshold nmap's ssl-cert and testssl.sh use.
31+
const tlsCertExpirySoonWindow = 30 * 24 * time.Hour
32+
33+
// TLSCertResult holds what the target's leaf certificate reveals: extra
34+
// hostnames from the SAN list, issuer/validity metadata, and posture flags a
35+
// human would want surfaced without reading the cert by hand.
36+
type TLSCertResult struct {
37+
Subject string `json:"subject"`
38+
Issuer string `json:"issuer"`
39+
SANs []string `json:"sans"`
40+
NotBefore string `json:"not_before"`
41+
NotAfter string `json:"not_after"`
42+
SerialNumber string `json:"serial_number"`
43+
SelfSigned bool `json:"self_signed"`
44+
Expired bool `json:"expired"`
45+
ExpiringSoon bool `json:"expiring_soon"`
46+
Wildcard bool `json:"wildcard"`
47+
NewSubdomains []string `json:"new_subdomains"` // SANs not already known to be the target host
48+
ChainLength int `json:"chain_length"`
49+
}
50+
51+
func (r *TLSCertResult) ResultType() string { return "tlscert" }
52+
53+
var _ ScanResult = (*TLSCertResult)(nil)
54+
55+
// tlsDial is a var so tests can substitute a fake dialer without touching the
56+
// network.
57+
var tlsDial = func(addr string, timeout time.Duration) (*tls.Conn, error) {
58+
dialer := &net.Dialer{Timeout: timeout}
59+
// InsecureSkipVerify is deliberate: this module mines whatever certificate
60+
// the target presents, self-signed or expired included, rather than only
61+
// certs that pass validation like the main http client requires.
62+
return tls.DialWithDialer(dialer, "tcp", addr, &tls.Config{InsecureSkipVerify: true}) //nolint:gosec // recon target, not a trust decision
63+
}
64+
65+
// TLSCert connects to the target on the given port (443 when unset) and mines
66+
// its leaf certificate: SANs become candidate subdomains, issuer/validity feed
67+
// posture flags (self-signed, expired, expiring soon). Unlike Passive's crt.sh
68+
// and certspotter feeds this is an active probe against the target itself, so
69+
// it catches certs that were never logged to a public CT log (short-lived
70+
// certs not yet propagated, internal CAs) at the cost of touching the target.
71+
func TLSCert(targetURL string, port int, timeout time.Duration, logdir string) (*TLSCertResult, error) {
72+
log := output.Module("TLSCERT")
73+
log.Start()
74+
75+
parsed, err := url.Parse(targetURL)
76+
if err != nil {
77+
return nil, fmt.Errorf("parse target url %q: %w", targetURL, err)
78+
}
79+
host := parsed.Hostname()
80+
if host == "" {
81+
return nil, fmt.Errorf("target url %q has no host", targetURL)
82+
}
83+
if port == 0 {
84+
port = 443
85+
}
86+
addr := net.JoinHostPort(host, strconv.Itoa(port))
87+
88+
sanitizedURL := stripScheme(targetURL)
89+
if logdir != "" {
90+
if err := logger.WriteHeader(sanitizedURL, logdir, "tls certificate recon"); err != nil {
91+
log.Error("error creating log file: %v", err)
92+
return nil, fmt.Errorf("create tlscert log: %w", err)
93+
}
94+
}
95+
96+
conn, err := tlsDial(addr, timeout)
97+
if err != nil {
98+
log.Warn("tls dial %s failed: %v", addr, err)
99+
return nil, fmt.Errorf("tls dial %q: %w", addr, err)
100+
}
101+
defer func() { _ = conn.Close() }()
102+
103+
state := conn.ConnectionState()
104+
if len(state.PeerCertificates) == 0 {
105+
return nil, fmt.Errorf("tls handshake with %q presented no certificates", addr)
106+
}
107+
leaf := state.PeerCertificates[0]
108+
109+
result := buildTLSCertResult(leaf, state.PeerCertificates, host)
110+
111+
logTLSCertResult(log, sanitizedURL, logdir, result)
112+
113+
log.Complete(len(result.SANs), "san entries")
114+
return result, nil
115+
}
116+
117+
// buildTLSCertResult turns a parsed leaf certificate into the recon-facing
118+
// result: SAN-derived hostnames, issuer/validity, and posture flags.
119+
func buildTLSCertResult(leaf *x509.Certificate, chain []*x509.Certificate, targetHost string) *TLSCertResult {
120+
now := time.Now()
121+
122+
sanSet := make(map[string]struct{}, len(leaf.DNSNames))
123+
for _, name := range leaf.DNSNames {
124+
sanSet[normalizeHost(name)] = struct{}{}
125+
}
126+
sans := sortedKeys(sanSet)
127+
128+
var newSubs []string
129+
for _, san := range sans {
130+
if san != normalizeHost(targetHost) {
131+
newSubs = append(newSubs, san)
132+
}
133+
}
134+
135+
wildcard := false
136+
for _, name := range leaf.DNSNames {
137+
if len(name) > 1 && name[0] == '*' {
138+
wildcard = true
139+
break
140+
}
141+
}
142+
143+
// self-signed: issuer == subject (raw DER, not the human-readable string)
144+
// and the cert's own signature verifies against its own TBS bytes.
145+
// CheckSignatureFrom(leaf) is the wrong tool here - it additionally
146+
// requires CA key-usage/basic-constraints bits a self-signed leaf usually
147+
// doesn't set, so it false-negatives on exactly the certs this flag exists
148+
// to catch.
149+
selfSigned := bytes.Equal(leaf.RawIssuer, leaf.RawSubject) &&
150+
leaf.CheckSignature(leaf.SignatureAlgorithm, leaf.RawTBSCertificate, leaf.Signature) == nil
151+
152+
return &TLSCertResult{
153+
Subject: leaf.Subject.String(),
154+
Issuer: leaf.Issuer.String(),
155+
SANs: sans,
156+
NotBefore: leaf.NotBefore.UTC().Format(time.RFC3339),
157+
NotAfter: leaf.NotAfter.UTC().Format(time.RFC3339),
158+
SerialNumber: leaf.SerialNumber.String(),
159+
SelfSigned: selfSigned,
160+
Expired: now.After(leaf.NotAfter),
161+
ExpiringSoon: !now.After(leaf.NotAfter) && leaf.NotAfter.Sub(now) < tlsCertExpirySoonWindow,
162+
Wildcard: wildcard,
163+
NewSubdomains: newSubs,
164+
ChainLength: len(chain),
165+
}
166+
}
167+
168+
func logTLSCertResult(log *output.ModuleLogger, sanitizedURL, logdir string, result *TLSCertResult) {
169+
log.Info("subject: %s", result.Subject)
170+
log.Info("issuer: %s", result.Issuer)
171+
if result.SelfSigned {
172+
log.Warn("certificate is self-signed")
173+
}
174+
if result.Expired {
175+
log.Warn("certificate expired %s", result.NotAfter)
176+
} else if result.ExpiringSoon {
177+
log.Warn("certificate expires soon: %s", result.NotAfter)
178+
}
179+
for _, san := range result.NewSubdomains {
180+
log.Success("san: %s", output.Highlight.Render(san))
181+
}
182+
183+
if logdir == "" {
184+
return
185+
}
186+
187+
sb := fmt.Sprintf("Subject: %s\nIssuer: %s\nNotBefore: %s\nNotAfter: %s\nSelfSigned: %v\nExpired: %v\nWildcard: %v\n",
188+
result.Subject, result.Issuer, result.NotBefore, result.NotAfter, result.SelfSigned, result.Expired, result.Wildcard)
189+
if len(result.SANs) > 0 {
190+
sb += "\nSANs:\n"
191+
for _, san := range result.SANs {
192+
sb += " " + san + "\n"
193+
}
194+
}
195+
_ = logger.Write(sanitizedURL, logdir, sb)
196+
}

internal/scan/tlscert_test.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/*
2+
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
3+
: :
4+
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
5+
: ▄█ █ █▀ · BSD 3-Clause License :
6+
: :
7+
: (c) 2022-2026 vmfunc, xyzeva, :
8+
: lunchcat alumni & contributors :
9+
: :
10+
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
11+
*/
12+
13+
package scan
14+
15+
import (
16+
"crypto/ecdsa"
17+
"crypto/elliptic"
18+
"crypto/rand"
19+
"crypto/x509"
20+
"crypto/x509/pkix"
21+
"math/big"
22+
"testing"
23+
"time"
24+
)
25+
26+
// makeLeaf builds a self-signed leaf cert with the given SANs and validity so
27+
// the posture flags can be exercised without a live tls handshake.
28+
func makeLeaf(t *testing.T, cn string, sans []string, notBefore, notAfter time.Time) *x509.Certificate {
29+
t.Helper()
30+
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
31+
if err != nil {
32+
t.Fatalf("generate key: %v", err)
33+
}
34+
tmpl := &x509.Certificate{
35+
SerialNumber: big.NewInt(1),
36+
Subject: pkix.Name{CommonName: cn},
37+
DNSNames: sans,
38+
NotBefore: notBefore,
39+
NotAfter: notAfter,
40+
}
41+
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
42+
if err != nil {
43+
t.Fatalf("create cert: %v", err)
44+
}
45+
leaf, err := x509.ParseCertificate(der)
46+
if err != nil {
47+
t.Fatalf("parse cert: %v", err)
48+
}
49+
return leaf
50+
}
51+
52+
func TestBuildTLSCertResult(t *testing.T) {
53+
now := time.Now()
54+
leaf := makeLeaf(t, "example.com",
55+
[]string{"example.com", "api.example.com", "*.cdn.example.com"},
56+
now.Add(-24*time.Hour), now.Add(365*24*time.Hour))
57+
58+
res := buildTLSCertResult(leaf, []*x509.Certificate{leaf}, "example.com")
59+
60+
if !res.SelfSigned {
61+
t.Error("self-signed leaf not flagged as self-signed")
62+
}
63+
if !res.Wildcard {
64+
t.Error("*.cdn.example.com SAN not flagged as wildcard")
65+
}
66+
if res.Expired || res.ExpiringSoon {
67+
t.Errorf("year-long cert flagged expired=%v soon=%v", res.Expired, res.ExpiringSoon)
68+
}
69+
// the target host itself must not appear as a "new" subdomain; the wildcard
70+
// SAN is normalized to its base (the *. prefix is stripped) and Wildcard is
71+
// flagged separately.
72+
want := map[string]bool{"api.example.com": true, "cdn.example.com": true}
73+
if len(res.NewSubdomains) != len(want) {
74+
t.Fatalf("new subdomains = %v, want the two non-target SANs", res.NewSubdomains)
75+
}
76+
for _, s := range res.NewSubdomains {
77+
if !want[s] {
78+
t.Errorf("unexpected new subdomain %q", s)
79+
}
80+
}
81+
}
82+
83+
func TestBuildTLSCertResultExpiry(t *testing.T) {
84+
now := time.Now()
85+
86+
expired := buildTLSCertResult(
87+
makeLeaf(t, "old.example.com", []string{"old.example.com"}, now.Add(-48*time.Hour), now.Add(-1*time.Hour)),
88+
nil, "old.example.com")
89+
if !expired.Expired || expired.ExpiringSoon {
90+
t.Errorf("past-NotAfter cert: expired=%v soon=%v, want expired", expired.Expired, expired.ExpiringSoon)
91+
}
92+
93+
soon := buildTLSCertResult(
94+
makeLeaf(t, "soon.example.com", []string{"soon.example.com"}, now.Add(-1*time.Hour), now.Add(72*time.Hour)),
95+
nil, "soon.example.com")
96+
if soon.Expired || !soon.ExpiringSoon {
97+
t.Errorf("cert inside the soon window: expired=%v soon=%v, want soon", soon.Expired, soon.ExpiringSoon)
98+
}
99+
}

sif.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -683,6 +683,16 @@ func (app *App) scanTarget(url, storeDir string, wantReport bool) (targetScan, e
683683
}
684684
}
685685

686+
if app.settings.TLSCert {
687+
result, err := scan.TLSCert(url, app.settings.TLSCertPort, app.settings.Timeout, app.settings.LogDir)
688+
if err != nil {
689+
log.Errorf("Error while running tls certificate recon: %s", err)
690+
} else if result != nil {
691+
moduleResults = append(moduleResults, NewModuleResult(result))
692+
scansRun = append(scansRun, "TLSCert")
693+
}
694+
}
695+
686696
if app.settings.Passive {
687697
result, err := scan.Passive(url, app.settings.Timeout, app.settings.LogDir)
688698
if err != nil {

0 commit comments

Comments
 (0)