mirror of
https://github.com/lunchcat/sif.git
synced 2026-07-28 14:37:01 -07:00
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>
197 lines
7.4 KiB
Go
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)
|
|
}
|