Files
sif/internal/scan/tlscert.go
T
3e0029755e 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>
2026-07-22 22:35:51 +00:00

197 lines
7.4 KiB
Go

/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · 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)
}