// Package discover turns the domains seen in CT into candidate object-storage // bucket names and tests them anonymously. This is the engine that actually // finds open buckets: certificate SANs are custom domains, not bucket names, so // we generate plausible names and ask the providers' list APIs. // // It is aggressively bounded: a global rate limiter caps outbound requests, // candidate names are deduplicated with a TTL cache, and the domain intake is a // random sample so no single domain floods the queue. Contents are never // downloaded; only the list API is called. package discover import ( "context" "io" "net/http" "strings" "sync" "time" "tea.karlcom.de/karl/bucketeer/internal/dns" "tea.karlcom.de/karl/bucketeer/internal/index" "tea.karlcom.de/karl/bucketeer/internal/metrics" "tea.karlcom.de/karl/bucketeer/internal/model" "tea.karlcom.de/karl/bucketeer/internal/sample" ) type Config struct { QPS int // global outbound request rate Workers int // concurrent candidate probes Queue int // sampled domain backlog Indexer *index.Indexer Metrics *metrics.Metrics // OnOpen is called for every anonymously listable bucket with its metadata // index. contents are never downloaded. OnOpen func(ctx context.Context, c model.Candidate, detail string, entries []model.Entry) } type Discoverer struct { cfg Config client *http.Client q *sample.Queue[string] tokens chan struct{} seen *dns.TTLCache[bool] // candidate names already tested stop chan struct{} wg sync.WaitGroup } func New(cfg Config) *Discoverer { if cfg.QPS <= 0 { cfg.QPS = 50 } if cfg.Workers <= 0 { cfg.Workers = 16 } if cfg.Queue <= 0 { cfg.Queue = 65536 } return &Discoverer{ cfg: cfg, client: &http.Client{Timeout: 6 * time.Second}, q: sample.New[string](cfg.Queue), tokens: make(chan struct{}, max(cfg.QPS, 1)), seen: dns.NewTTLCache[bool](1<<20, 64), stop: make(chan struct{}), } } // Submit offers a hostname to the discovery engine. Duplicate registrable // domains and already-tested candidate names are dropped cheaply. func (d *Discoverer) Submit(host string) { d.q.Push(host) } func (d *Discoverer) Start(ctx context.Context) { go d.refill(ctx) for i := 0; i < d.cfg.Workers; i++ { d.wg.Add(1) go d.worker(ctx) } } func (d *Discoverer) Close() { close(d.stop) } func (d *Discoverer) Wait() { d.wg.Wait() } // QueueLen reports the sampled domain backlog. func (d *Discoverer) refill(ctx context.Context) { period := time.Second / time.Duration(d.cfg.QPS) if period <= 0 { period = time.Millisecond } t := time.NewTicker(period) defer t.Stop() for { select { case <-ctx.Done(): return case <-d.stop: return case <-t.C: select { case d.tokens <- struct{}{}: default: } } } } func (d *Discoverer) acquire(ctx context.Context) bool { select { case <-ctx.Done(): return false case <-d.stop: return false case <-d.tokens: return true } } func (d *Discoverer) worker(ctx context.Context) { defer d.wg.Done() for { select { case <-ctx.Done(): return case <-d.stop: return case <-d.q.Notify(): } for { host, ok := d.q.Pop() if !ok { break } d.scanDomain(ctx, host) } } } func (d *Discoverer) scanDomain(ctx context.Context, host string) { for _, name := range candidates(host) { if ctx.Err() != nil { return } d.testName(ctx, name) } } func (d *Discoverer) testName(ctx context.Context, name string) { if _, dup := d.seen.Get(name); dup { return } d.seen.Set(name, true, 12*time.Hour) for _, t := range d.targets(name) { if d.cfg.Metrics != nil { d.cfg.Metrics.BucketCandidates.Add(1) } if !d.acquire(ctx) { return } d.test(ctx, t) } d.scanAzure(ctx, name) } // scanAzure handles Azure's two-level model. The account name maps to DNS // (.blob.core.windows.net), so a nonexistent account fails the first // request and we stop. Anonymous access is per container and Microsoft offers no // anonymous container enumeration, so the container names are the one place a // wordlist is unavoidable. func (d *Discoverer) scanAzure(ctx context.Context, account string) { if !validAzureAccount(account) { return } key := "az:" + account if _, dup := d.seen.Get(key); dup { return } d.seen.Set(key, true, 12*time.Hour) host := account + ".blob.core.windows.net" if !d.acquire(ctx) { return } req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://"+host+"/?comp=list", nil) if err != nil { return } resp, err := d.client.Do(req) if err != nil { return // account does not exist (NXDOMAIN) or is unreachable } _, _ = io.CopyN(io.Discard, resp.Body, 512) resp.Body.Close() // Account exists. Try known/common containers for anonymous listing. var ( entries []model.Entry found []string ) for _, container := range index.AzureContainers(account) { if ctx.Err() != nil { return } if d.cfg.Metrics != nil { d.cfg.Metrics.BucketCandidates.Add(1) } if !d.acquire(ctx) { return } c := model.Candidate{ Hostname: host, Original: host, Resource: account, Container: container, Provider: model.ProviderAzure, Service: model.ServiceBlobStorage, ResourceSpecific: true, Source: model.SourceDiscovery, } e, err := d.cfg.Indexer.List(ctx, c) if err != nil || len(e) == 0 { continue } entries = append(entries, e...) found = append(found, container) } if len(entries) == 0 || d.cfg.OnOpen == nil { return } if d.cfg.Metrics != nil { d.cfg.Metrics.BucketOpen.Add(1) } c := model.Candidate{ Hostname: host, Original: host, Resource: account, Provider: model.ProviderAzure, Service: model.ServiceBlobStorage, ResourceSpecific: true, Source: model.SourceDiscovery, } d.cfg.OnOpen(ctx, c, "azure public container(s): "+strings.Join(found, ","), entries) } // validAzureAccount mirrors Azure's naming rules: 3-24 lowercase alphanumerics. func validAzureAccount(s string) bool { if len(s) < 3 || len(s) > 24 { return false } for i := 0; i < len(s); i++ { c := s[i] if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9') { return false } } return true } // Target is one provider listing endpoint for a candidate name. type Target struct { Name string Provider model.Provider Service model.Service Hostname string // hostname for the indexer/probe candidate URL string // anonymous list URL Region string } func (d *Discoverer) targets(name string) []Target { return []Target{ { Name: name, Provider: model.ProviderAWS, Service: model.ServiceS3, Hostname: name + ".s3.amazonaws.com", URL: "https://" + name + ".s3.amazonaws.com/?list-type=2&max-keys=1", }, { // Virtual-host form so each bucket has a unique hostname (the path // form makes every bucket share storage.googleapis.com and collide // on the cloud_assets unique key). Name: name, Provider: model.ProviderGCP, Service: model.ServiceObjectStorage, Hostname: name + ".storage.googleapis.com", URL: "https://storage.googleapis.com/" + name + "?max-keys=1", }, } } func (d *Discoverer) test(ctx context.Context, t Target) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, t.URL, nil) if err != nil { return } resp, err := d.client.Do(req) if err != nil { return } _, _ = io.CopyN(io.Discard, resp.Body, 512) finalHost := resp.Request.URL.Host resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return } // Listable. Use the post-redirect host (S3 global redirects to a region). c := model.Candidate{ Hostname: t.Hostname, Original: t.Hostname, Resource: t.Name, Region: t.Region, Provider: t.Provider, Service: t.Service, ResourceSpecific: true, Source: model.SourceDiscovery, } if finalHost != "" && strings.HasSuffix(finalHost, ".amazonaws.com") { c.Hostname = finalHost } detail := "discovered open bucket via name enumeration (" + t.Hostname + ")" if d.cfg.Metrics != nil { d.cfg.Metrics.BucketOpen.Add(1) } if d.cfg.OnOpen == nil { return } var entries []model.Entry if d.cfg.Indexer != nil { if e, err := d.cfg.Indexer.List(ctx, c); err == nil { entries = e } } d.cfg.OnOpen(ctx, c, detail, entries) } // --- candidate generation --- // No name guessing: candidates are exactly the labels that occur in the incoming // hostname (the subdomain label and the registrable-domain label). Nothing is // fabricated, permuted, or drawn from a wordlist. // baseLabels returns plausible bucket-name stems from a hostname: the leftmost // label and the registrable-domain label. func baseLabels(host string) []string { host = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), ".")) labels := strings.Split(host, ".") if len(labels) < 2 { return nil } cands := []string{labels[0], labels[len(labels)-2]} seen := map[string]bool{} out := cands[:0] for _, c := range cands { if validLabel(c) && !seen[c] && !skipLabel[c] { seen[c] = true out = append(out, c) } } return out } // Only genuinely non-descriptive labels are skipped; service-ish subdomains // (assets, cdn, media, ...) are kept because they are common bucket names. var skipLabel = map[string]bool{ "www": true, "api": true, "mail": true, "email": true, "smtp": true, "ns": true, "mx": true, "localhost": true, } func candidates(host string) []string { return baseLabels(host) } func validLabel(s string) bool { if len(s) < 3 || len(s) > 40 { return false } for i := 0; i < len(s); i++ { c := s[i] if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-') { return false } } return s[0] != '-' && s[len(s)-1] != '-' } func validBucketName(s string) bool { if len(s) < 3 || len(s) > 63 { return false } for i := 0; i < len(s); i++ { c := s[i] if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-' || c == '.') { return false } } return s[0] != '-' && s[0] != '.' && s[len(s)-1] != '-' && s[len(s)-1] != '.' }