2026-09-22 15:53:31 +02:00
|
|
|
// 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"
|
2026-09-22 17:12:30 +02:00
|
|
|
"tea.karlcom.de/karl/bucketeer/internal/sample"
|
2026-09-22 15:53:31 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type Config struct {
|
2026-09-22 17:12:30 +02:00
|
|
|
QPS int // global outbound request rate
|
|
|
|
|
Workers int // concurrent candidate probes
|
|
|
|
|
Queue int // sampled domain backlog
|
2026-09-22 15:53:31 +02:00
|
|
|
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
|
2026-09-22 17:12:30 +02:00
|
|
|
q *sample.Queue[string]
|
2026-09-22 15:53:31 +02:00
|
|
|
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},
|
2026-09-22 17:12:30 +02:00
|
|
|
q: sample.New[string](cfg.Queue),
|
2026-09-22 15:53:31 +02:00
|
|
|
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) {
|
2026-09-22 17:12:30 +02:00
|
|
|
d.q.Push(host)
|
2026-09-22 15:53:31 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
2026-09-22 17:12:30 +02:00
|
|
|
case <-d.q.Notify():
|
2026-09-22 15:53:31 +02:00
|
|
|
}
|
|
|
|
|
for {
|
2026-09-22 17:12:30 +02:00
|
|
|
host, ok := d.q.Pop()
|
2026-09-22 15:53:31 +02:00
|
|
|
if !ok {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
d.scanDomain(ctx, host)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (d *Discoverer) scanDomain(ctx context.Context, host string) {
|
2026-09-22 17:12:30 +02:00
|
|
|
for _, name := range candidates(host) {
|
2026-09-22 15:53:31 +02:00
|
|
|
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 {
|
2026-09-22 17:12:30 +02:00
|
|
|
return []Target{
|
|
|
|
|
{
|
2026-09-22 15:53:31 +02:00
|
|
|
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",
|
2026-09-22 17:12:30 +02:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
// 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).
|
2026-09-22 15:53:31 +02:00
|
|
|
Name: name, Provider: model.ProviderGCP, Service: model.ServiceObjectStorage,
|
|
|
|
|
Hostname: name + ".storage.googleapis.com",
|
|
|
|
|
URL: "https://storage.googleapis.com/" + name + "?max-keys=1",
|
2026-09-22 17:12:30 +02:00
|
|
|
},
|
2026-09-22 15:53:31 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 ---
|
|
|
|
|
|
2026-09-22 16:09:44 +02:00
|
|
|
// 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.
|
2026-09-22 15:53:31 +02:00
|
|
|
|
|
|
|
|
// 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,
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-22 17:12:30 +02:00
|
|
|
func candidates(host string) []string { return baseLabels(host) }
|
2026-09-22 15:53:31 +02:00
|
|
|
|
|
|
|
|
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] != '.'
|
|
|
|
|
}
|