Files
bucketeer/internal/discover/discover.go
T
StasonJatham fe5c01666b bucket discovery: no name guessing
Remove all fabricated candidate names (suffixes and the wordlist). Discovery now
only tests names that actually occur in the incoming hostname: the subdomain
label and the registrable-domain label (assets.example.com -> assets, example).
Nothing is permuted or drawn from a wordlist.
2026-09-22 16:09:44 +02:00

370 lines
8.9 KiB
Go

// 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"
"math/rand/v2"
"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"
)
type Config struct {
QPS int // global outbound request rate
Workers int // concurrent candidate probes
Queue int // sampled domain backlog
S3 bool
GCS bool
DOSpaces bool
DORegions []string
MaxPerDomain int // cap candidates generated per domain
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 *domainQueue
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
}
if cfg.MaxPerDomain <= 0 {
cfg.MaxPerDomain = 48
}
return &Discoverer{
cfg: cfg,
client: &http.Client{Timeout: 6 * time.Second},
q: newDomainQueue(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) QueueLen() int { return d.q.len() }
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, d.cfg.MaxPerDomain) {
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)
}
}
// 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 {
var out []Target
if d.cfg.S3 {
out = append(out, 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",
})
}
if d.cfg.GCS {
// Use the 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).
out = append(out, Target{
Name: name, Provider: model.ProviderGCP, Service: model.ServiceObjectStorage,
Hostname: name + ".storage.googleapis.com",
URL: "https://storage.googleapis.com/" + name + "?max-keys=1",
})
}
if d.cfg.DOSpaces {
for _, region := range d.cfg.DORegions {
host := name + "." + region + ".digitaloceanspaces.com"
out = append(out, Target{
Name: name, Provider: model.ProviderDigitalOcean, Service: model.ServiceObjectStorage,
Hostname: host, Region: region,
URL: "https://" + host + "/?list-type=2&max-keys=1",
})
}
}
return out
}
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, max int) []string {
out := baseLabels(host)
if max > 0 && len(out) > max {
out = out[:max]
}
return out
}
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] != '.'
}
// --- sampled domain queue ---
type domainQueue struct {
mu sync.Mutex
items []string
cap int
notify chan struct{}
}
func newDomainQueue(capacity int) *domainQueue {
return &domainQueue{cap: capacity, notify: make(chan struct{}, 1)}
}
func (q *domainQueue) push(s string) {
q.mu.Lock()
if len(q.items) < q.cap {
q.items = append(q.items, s)
} else {
q.items[rand.IntN(len(q.items))] = s
}
q.mu.Unlock()
select {
case q.notify <- struct{}{}:
default:
}
}
func (q *domainQueue) pop() (string, bool) {
q.mu.Lock()
n := len(q.items)
if n == 0 {
q.mu.Unlock()
return "", false
}
i := rand.IntN(n)
s := q.items[i]
q.items[i] = q.items[n-1]
q.items = q.items[:n-1]
q.mu.Unlock()
return s, true
}
func (q *domainQueue) len() int {
q.mu.Lock()
defer q.mu.Unlock()
return len(q.items)
}