diff --git a/.appveyor.yml b/.appveyor.yml deleted file mode 100644 index c45a2dd..0000000 --- a/.appveyor.yml +++ /dev/null @@ -1,20 +0,0 @@ -version: "{build}" - -clone_folder: c:\gopath\src\github.com\haccer\subjack - -environment: - GOPATH: c:\gopath - -install: - - echo %PATH% - - echo %GOPATH% - - set PATH=%GOPATH%\bin;c:\go\bin;%PATH% - - go version - -build: false - -test_script: - - go get github.com/haccer/available - - go get github.com/miekg/dns - - go get github.com/valyala/fasthttp - - go build github.com/haccer/subjack diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..57a5d7e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,19 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - run: go build ./... + - run: go vet ./... + - run: go test ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..063a56c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,47 @@ +name: Release + +on: + release: + types: [created] + +permissions: + contents: write + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + include: + - goos: linux + goarch: amd64 + - goos: linux + goarch: arm64 + - goos: darwin + goarch: amd64 + - goos: darwin + goarch: arm64 + - goos: windows + goarch: amd64 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Build + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + run: | + ext="" + if [ "$GOOS" = "windows" ]; then ext=".exe"; fi + go build -o subjack-${GOOS}-${GOARCH}${ext} . + - name: Upload release asset + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + run: | + ext="" + if [ "$GOOS" = "windows" ]; then ext=".exe"; fi + gh release upload "${{ github.event.release.tag_name }}" subjack-${GOOS}-${GOARCH}${ext} diff --git a/.gitignore b/.gitignore index 91603bf..8b4cc2d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,21 +1,29 @@ -# common files -*~ -*.log -*.bak -*.tmp -*.swp -*.lock - -# .gitignore Go Template -# Binaries for programs and plugins +# Binaries +# Ignore the built binary, not the subjack/ source directory +/subjack +!/subjack/ *.exe *.exe~ *.dll *.so *.dylib -# Test binary, build with `go test -c` +# Test binary, built with `go test -c` *.test -# Output of the go coverage tool, specifically when used with LiteIDE +# Output of the go coverage tool *.out + +# Go vendor directory +/vendor/ + +# IDE and editor files +.idea/ +.vscode/ +*.swp +*~ + +# Misc +*.log +*.bak +*.tmp diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 0e402a4..0000000 --- a/.travis.yml +++ /dev/null @@ -1,4 +0,0 @@ -language: go -go: master -sudo: false -script: go test ./... diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ae4165b --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,108 @@ +# Contributing to Subjack + +Thanks for your interest in contributing to Subjack! This guide will help you get started. + +## Getting Started + +1. Fork the repository +2. Clone your fork: + ```bash + git clone https://github.com//subjack.git + cd subjack + ``` +3. Create a feature branch: + ```bash + git checkout -b my-feature + ``` + +## Development Setup + +Subjack requires the Go version specified in `go.mod` or later. Verify your installation: + +```bash +go version +``` + +Build the project: + +```bash +go build . +``` + +Run the linter: + +```bash +go vet ./... +``` + +Run tests: + +```bash +go test ./... +``` + +## Project Structure + +``` +subjack/ +├── main.go # CLI entry point and flag parsing +├── subjack/ +│ ├── subjack.go # Core orchestration logic +│ ├── dns.go # DNS resolution and NXDOMAIN checking +│ ├── fingerprint.go # Fingerprint matching logic +│ ├── http.go # HTTP client (fasthttp) +│ ├── output.go # Result formatting and file I/O +│ └── fingerprints.json # Embedded service fingerprints +├── .github/workflows/ # CI and release pipelines +├── go.mod +└── go.sum +``` + +## Adding a New Fingerprint + +To add detection for a new vulnerable service, add an entry to `subjack/fingerprints.json`: + +```json +{ + "service": "Example Service", + "cname": ["example.com"], + "fingerprint": ["The unique error string shown on unclaimed pages"], + "nxdomain": false +} +``` + +- **service** — Name of the service. +- **cname** — CNAME patterns that identify the service. +- **fingerprint** — Strings found in the HTTP response body when the subdomain is claimable. +- **nxdomain** — Set to `true` if the takeover relies on the CNAME target being unregistered rather than an HTTP fingerprint. + +You can use [Can I take over XYZ?](https://github.com/EdOverflow/can-i-take-over-xyz) as a starting point, but always verify the vulnerability independently through your own testing. + +## Making Changes + +- Follow standard Go conventions and format your code with `gofmt`. +- Keep changes focused — one feature or fix per pull request. +- Ensure `go build ./...` and `go vet ./...` pass before submitting. +- Add or update tests where applicable. + +## Submitting a Pull Request + +1. Push your branch to your fork: + ```bash + git push origin my-feature + ``` +2. Open a pull request against the `master` branch. +3. Describe what your change does and why. +4. CI will run `go build`, `go vet`, and `go test` automatically — make sure all checks pass. + +## Reporting Issues + +Open an issue on GitHub with: + +- A clear description of the problem or suggestion. +- Steps to reproduce (for bugs). +- Expected vs. actual behavior. + +## License + +By contributing, you agree that your contributions will be licensed under the [Apache License 2.0](LICENSE). diff --git a/README.md b/README.md index aae5fdb..b7efbda 100644 --- a/README.md +++ b/README.md @@ -1,103 +1,153 @@ # subjack -[![Go Report Card](https://goreportcard.com/badge/github.com/haccer/subjack)](https://goreportcard.com/report/github.com/haccer/subjack) -[![GoDoc](https://godoc.org/github.com/haccer/subjack/subjack?status.svg)](http://godoc.org/github.com/haccer/subjack/subjack) +[![Go Report Card](https://goreportcard.com/badge/github.com/haccer/subjack)](https://goreportcard.com/report/github.com/haccer/subjack) +[![GoDoc](https://godoc.org/github.com/haccer/subjack/subjack?status.svg)](http://godoc.org/github.com/haccer/subjack/subjack) [![GitHub license](https://img.shields.io/github/license/haccer/subjack.svg)](https://github.com/haccer/subjack/blob/master/LICENSE)

subjack logo
- Subdomain Takeover Tool + DNS Takeover Scanner

-**Subjack 2.0 coming soon** +Subjack is a DNS takeover scanner written in Go designed to scan a list of domains concurrently and identify ones that are able to be hijacked. With Go's speed and efficiency, this tool really stands out when it comes to mass-testing. Always double check the results manually to rule out false positives. -Subjack is a Subdomain Takeover tool written in Go designed to scan a list of subdomains concurrently and identify ones that are able to be hijacked. With Go's speed and efficiency, this tool really stands out when it comes to mass-testing. Always double check the results manually to rule out false positives. +Subjack detects: -Subjack will also check for subdomains attached to domains that don't exist (NXDOMAIN) and are **available to be registered**. No need for dig ever again! This is still cross-compatible too. - -**What's New? (Last Updated 09/17/18)** -- Custom fingerprint support -- New Services (Re-added Zendesk && Added Readme, Bitly, and more) -- Slight performance enhancements +- **CNAME takeovers** — dangling CNAMEs pointing to unclaimed third-party services +- **NS delegation takeovers** — expired nameserver domains and dangling cloud DNS zones (Route 53, Google Cloud DNS, Azure DNS, DigitalOcean, Vultr, Linode) +- **Stale A records** — A records pointing to dead IPs on cloud providers (AWS, GCP, Azure, DigitalOcean, Linode, Vultr, Oracle) +- **Zone transfers (AXFR)** — misconfigured nameservers leaking entire zone files, with NS hostname bruteforcing +- **SPF include takeovers** — expired domains in SPF `include:` directives enabling email spoofing +- **MX record takeovers** — expired mail server domains enabling email interception +- **CNAME chain takeovers** — multi-level CNAME chains where intermediate targets are claimable +- **SRV record takeovers** — SRV records pointing to expired/registrable domains +- **NXDOMAIN registration** — domains that don't exist and are available to be registered ## Installing Requires [Go](https://golang.org/dl/) -`go get github.com/haccer/subjack` +``` +go install github.com/haccer/subjack@latest +``` -## How To Use: +## Usage -Examples: -- `./subjack -w subdomains.txt -t 100 -timeout 30 -o results.txt -ssl` +``` +subjack -w subdomains.txt -t 100 -timeout 30 -o results.txt -ssl +``` -Options: -- `-d test.com` if you want to test a single domain. -- `-w domains.txt` is your list of subdomains. -- `-t` is the number of threads (Default: 10 threads). -- `-timeout` is the seconds to wait before timeout connection (Default: 10 seconds). -- `-o results.txt` where to save results to. For JSON: `-o results.json` -- `-ssl` enforces HTTPS requests which may return a different set of results and increase accuracy. -- `-a` skips CNAME check and sends requests to every URL. **(Recommended)** -- `-m` flag the presence of a dead record, but valid CNAME entry. -- `-v` verbose. Display more information per each request. -- `-c` Path to configuration file. +| Flag | Description | Default | +|------|-------------|---------| +| `-d` | Single domain to check | | +| `-w` | Path to wordlist of subdomains | | +| `-t` | Number of concurrent threads | `10` | +| `-timeout` | Seconds to wait before connection timeout | `10` | +| `-o` | Output results to file (use `.json` extension for JSON output) | | +| `-ssl` | Force HTTPS connections (may increase accuracy) | `false` | +| `-a` | Send requests to every URL, not just those with identified CNAMEs **(recommended)** | `false` | +| `-m` | Flag dead CNAME records even if the domain is not available for registration | `false` | +| `-r` | Path to a list of DNS resolvers (one IP per line, falls back to `8.8.8.8` on failure) | | +| `-ns` | Check for NS takeovers (expired NS domains + dangling cloud DNS delegations) | `false` | +| `-ar` | Check for stale A records pointing to dead IPs (may require root for ICMP) | `false` | +| `-axfr` | Check for zone transfers (AXFR) including NS bruteforce | `false` | +| `-mail` | Check for SPF include and MX record takeovers | `false` | +| `-v` | Display more information per request | `false` | + +## Stdin Support + +Subjack can read domains from stdin, making it easy to pipe output from other tools: -## Practical Use +``` +subfinder -d example.com | subjack -ssl -o results.json +cat domains.txt | subjack -t 20 -o results.txt +``` + +## Nameserver Takeover -You can use [scanio.sh](https://gist.github.com/haccer/3698ff6927fc00c8fe533fc977f850f8) which is kind of a PoC script to mass-locate vulnerable subdomains using results from Rapid7's Project Sonar. This script parses and greps through the dump for desired CNAME records and makes a large list of subdomains to check with subjack if they're vulnerable to Hostile Subdomain Takeover. Of course this isn't the only method to get a large amount of data to test. **Please use this responsibly ;)** +With the `-ns` flag, subjack performs two types of nameserver takeover checks: -## Adding subjack to your workflow +**Expired NS domains**: Checks if any of a domain's nameservers have expired and are available for purchase. An attacker who registers an expired nameserver can take full control of all DNS for that domain — they can point any record anywhere, intercept email, issue certificates, and more. -```go -package main +**Dangling NS delegations**: Detects when a domain's NS records point to cloud DNS providers but the hosted zone has been deleted. Subjack queries each nameserver directly for an SOA record — if all return `SERVFAIL` or `REFUSED`, the zone is gone and potentially claimable. Supported providers: -import ( - "fmt" - "encoding/json" - "io/ioutil" - "strings" +- AWS Route 53 (`ns-*.awsdns-*`) +- Google Cloud DNS (`ns-cloud-*.googledomains.com`) +- Azure DNS (`ns*-*.azure-dns.*`) +- DigitalOcean DNS (`ns*.digitalocean.com`) +- Vultr DNS (`ns*.vultr.com`) +- Linode DNS (`ns*.linode.com`) - "github.com/haccer/subjack/subjack" -) - +``` +subjack -w subdomains.txt -ns -o results.json +``` + +## Stale A Record Detection -func main() { - var fingerprints []subjack.Fingerprints - config, _ := ioutil.ReadFile("custom_fingerprints.json") - json.Unmarshal(config, &fingerprints) +With the `-ar` flag, subjack will resolve A records and check if the IP address is actually alive. When a company terminates a cloud server but forgets to remove the DNS A record, the IP gets released back to the provider's pool. An attacker can spin up new instances on that provider until they land on the same IP, gaining control of the subdomain. - subdomain := "dead.cody.su" - /* Use subjack's advanced detection to identify - if the subdomain is able to be taken over. */ - service := subjack.Identify(subdomain, false, false, 10, fingerprints) +Subjack identifies the cloud provider (AWS, GCP, Azure, DigitalOcean, Linode, Vultr, Oracle) when possible, making it easier to target the right platform. Detection uses ICMP ping (requires root) with a TCP fallback on ports 80/443. - if service != "" { - service = strings.ToLower(service) - fmt.Printf("%s is pointing to a vulnerable %s service.\n", subdomain, service) - } -} ``` +sudo subjack -w subdomains.txt -ar -o results.json +``` + +Results are flagged as `STALE A RECORD` and should be verified manually — a non-responding IP doesn't always mean it's reclaimable. -See the [godoc](https://godoc.org/github.com/haccer/subjack/subjack) for more functions. +## Zone Transfer Detection -## FAQ -**Q:** What should my wordlist look like? +With the `-axfr` flag, subjack will attempt DNS zone transfers (AXFR) which can expose an entire domain's DNS records. Subjack goes beyond just testing the domain's official nameservers — it also bruteforces common nameserver hostnames (`ns1`, `dns-0`, `ns-backup`, etc.) because hidden or forgotten nameservers are often left unsecured even after the primary ones have been locked down. -**A:** Your wordlist should include a list of subdomains you're checking and should look something like: ``` -assets.cody.su +subjack -d example.com -axfr -o results.json +subjack -w domains.txt -axfr -o results.json +``` + +Results are flagged as `ZONE TRANSFER` with the vulnerable nameserver and number of records exposed. + +## Email Takeover Detection + +With the `-mail` flag, subjack checks for two email-based takeover vectors: + +**SPF include takeover**: Parses SPF TXT records and checks if any `include:` domains are expired and available for registration. An attacker who registers the included domain can send fully authenticated emails as the target, bypassing SPF and DMARC. + +**MX record takeover**: Checks if any MX record targets are expired and available for registration. An attacker who controls the mail server can intercept all inbound email — password resets, 2FA codes, and more. + +``` +subjack -w domains.txt -mail -o results.json +``` + +## CNAME Chain and SRV Detection + +These checks run automatically on every scan: + +**CNAME chain takeover**: Follows multi-level CNAME chains (up to 10 deep) and checks if any intermediate target is claimable. Standard CNAME detection only checks the first hop — chains catch deeper takeover opportunities. + +**SRV record takeover**: Checks common SRV records (SIP, XMPP, LDAP, Kerberos, IMAP, CalDAV, etc.) for targets that are expired and available for registration. + +## Practical Use + +You can use [scanio.sh](https://gist.github.com/haccer/3698ff6927fc00c8fe533fc977f850f8) which is kind of a PoC script to mass-locate vulnerable subdomains using results from Rapid7's Project Sonar. This script parses and greps through the dump for desired CNAME records and makes a large list of subdomains to check with subjack if they're vulnerable to hostile subdomain takeover. **Please use this responsibly.** + +## Wordlist Format + +Your wordlist should include a list of subdomains, one per line: + +``` +assets.xen.world assets.github.com -b.cody.su +b.xen.world big.example.com -cdn.cody.su -dev.cody.su +cdn.xen.world +dev.xen.world dev2.twitter.com ``` ## References -Extra information about Hostile Subdomain Takeovers: -- [https://github.com/EdOverflow/can-i-take-over-xyz](https://github.com/EdOverflow/can-i-take-over-xyz) -- [https://labs.detectify.com/2014/10/21/hostile-subdomain-takeover-using-herokugithubdesk-more/](https://labs.detectify.com/2014/10/21/hostile-subdomain-takeover-using-herokugithubdesk-more/) +Extra information about DNS takeovers: + +- [Can I take over XYZ?](https://github.com/EdOverflow/can-i-take-over-xyz) +- [Hostile Subdomain Takeover using Heroku/GitHub/Desk + More](https://labs.detectify.com/2014/10/21/hostile-subdomain-takeover-using-herokugithubdesk-more/) +- [Can I take over DNS?](https://github.com/indianajson/can-i-take-over-dns) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..94444b9 --- /dev/null +++ b/go.mod @@ -0,0 +1,26 @@ +module github.com/haccer/subjack + +go 1.25.1 + +require ( + github.com/haccer/available v0.0.0-20240515180643-5940a1670ee3 + github.com/miekg/dns v1.1.72 + github.com/valyala/fasthttp v1.69.0 +) + +require ( + github.com/PuerkitoBio/goquery v1.8.0 // indirect + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/andybalholm/cascadia v1.3.1 // indirect + github.com/domainr/whois v0.1.0 // indirect + github.com/klauspost/compress v1.18.2 // indirect + github.com/saintfish/chardet v0.0.0-20120816061221-3af4cd4741ca // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/zonedb/zonedb v1.0.3544 // indirect + golang.org/x/mod v0.31.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect + golang.org/x/tools v0.40.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2a2320a --- /dev/null +++ b/go.sum @@ -0,0 +1,73 @@ +github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U= +github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c= +github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA= +github.com/domainr/whois v0.1.0 h1:36I1Hu+5pfvJzSXjnxN3lmIXeNNlZJmM5fkk9zlRF2o= +github.com/domainr/whois v0.1.0/go.mod h1:/6Ej6qU9Xcl/8we/QKFWhJlvUlqmEDGXgHzOwbazVpo= +github.com/domainr/whoistest v0.0.0-20180714175718-26cad4b7c941 h1:E7ehdIemEeScp8nVs0JXNXEbzb2IsHCk13ijvwKqRWI= +github.com/domainr/whoistest v0.0.0-20180714175718-26cad4b7c941/go.mod h1:iuCHv1qZDoHJNQs56ZzzoKRSKttGgTr2yByGpSlKsII= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/haccer/available v0.0.0-20240515180643-5940a1670ee3 h1:2AVXrUaMjg4mXqpfa6pLz9BpyjMlbgar7SJCUXtrRVA= +github.com/haccer/available v0.0.0-20240515180643-5940a1670ee3/go.mod h1:fvazc0w1/p6PNECfXmVagg+dq8RitGXEouay/HtNyPg= +github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= +github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/miekg/dns v1.1.46/go.mod h1:e3IlAVfNqAllflbibAZEWOXOQ+Ynzk/dDozDxY7XnME= +github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= +github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= +github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32 h1:W6apQkHrMkS0Muv8G/TipAy/FJl/rCYT0+EuS8+Z0z4= +github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms= +github.com/saintfish/chardet v0.0.0-20120816061221-3af4cd4741ca h1:NugYot0LIVPxTvN8n+Kvkn6TrbMyxQiuvKdEwFdR9vI= +github.com/saintfish/chardet v0.0.0-20120816061221-3af4cd4741ca/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI= +github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw= +github.com/wsxiaoys/terminal v0.0.0-20160513160801-0940f3fc43a0/go.mod h1:IXCdmsXIht47RaVFLEdVnh1t+pgYtTAhQGj73kz+2DM= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/zonedb/zonedb v1.0.3544 h1:u5a3xOaI338FclecJ/H6J7fiImVEZ/qZomJnVYIlTeM= +github.com/zonedb/zonedb v1.0.3544/go.mod h1:h9mfHV/S6lboOkltULrbNY52cd7JZo6MbxIiqKMWPLg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.6-0.20210726203631-07bc1bf47fb2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/main.go b/main.go index 4f9e9ad..54c3f23 100644 --- a/main.go +++ b/main.go @@ -9,32 +9,34 @@ import ( ) func main() { - GOPATH := os.Getenv("GOPATH") - Project := "/src/github.com/haccer/subjack/" - configFile := "fingerprints.json" - defaultConfig := GOPATH + Project + configFile - o := subjack.Options{} - flag.StringVar(&o.Domain, "d", "", "Domain.") + flag.StringVar(&o.Domain, "d", "", "Single domain to check.") flag.StringVar(&o.Wordlist, "w", "", "Path to wordlist.") - flag.IntVar(&o.Threads, "t", 10, "Number of concurrent threads (Default: 10).") - flag.IntVar(&o.Timeout, "timeout", 10, "Seconds to wait before connection timeout (Default: 10).") - flag.BoolVar(&o.Ssl, "ssl", false, "Force HTTPS connections (May increase accuracy (Default: http://).") - flag.BoolVar(&o.All, "a", false, "Find those hidden gems by sending requests to every URL. (Default: Requests are only sent to URLs with identified CNAMEs).") - flag.BoolVar(&o.Verbose, "v", false, "Display more information per each request.") - flag.StringVar(&o.Output, "o", "", "Output results to file (Subjack will write JSON if file ends with '.json').") - flag.StringVar(&o.Config, "c", defaultConfig, "Path to configuration file.") - flag.BoolVar(&o.Manual, "m", false, "Flag the presence of a dead record, but valid CNAME entry.") - - flag.Parse() + flag.IntVar(&o.Threads, "t", 10, "Number of concurrent threads.") + flag.IntVar(&o.Timeout, "timeout", 10, "Seconds to wait before connection timeout.") + flag.BoolVar(&o.Ssl, "ssl", false, "Force HTTPS connections (may increase accuracy).") + flag.BoolVar(&o.All, "a", false, "Send requests to every URL, not just those with identified CNAMEs.") + flag.BoolVar(&o.Verbose, "v", false, "Display more information per request.") + flag.StringVar(&o.Output, "o", "", "Output results to file (use .json extension for JSON output).") + flag.StringVar(&o.ResolverList, "r", "", "Path to a list of DNS resolvers.") + flag.BoolVar(&o.Manual, "m", false, "Flag dead CNAME records even if the domain is not available for registration.") + flag.BoolVar(&o.CheckNS, "ns", false, "Check if nameservers are available for purchase (NS takeover).") + flag.BoolVar(&o.CheckAR, "ar", false, "Check for stale A records pointing to dead IPs (may require root for ICMP).") + flag.BoolVar(&o.CheckAXFR, "axfr", false, "Check for zone transfers (AXFR) including NS bruteforce.") + flag.BoolVar(&o.CheckMail, "mail", false, "Check for SPF include and MX record takeovers.") flag.Usage = func() { - fmt.Printf("Usage of %s:\n", os.Args[0]) + fmt.Fprintf(os.Stderr, "Usage: %s [options]\n\nOptions:\n", os.Args[0]) flag.PrintDefaults() } - if flag.NFlag() == 0 { + flag.Parse() + + stat, _ := os.Stdin.Stat() + o.Stdin = (stat.Mode() & os.ModeCharDevice) == 0 + + if flag.NFlag() == 0 && !o.Stdin { flag.Usage() os.Exit(1) } diff --git a/subjack/arecord.go b/subjack/arecord.go new file mode 100644 index 0000000..467f8a2 --- /dev/null +++ b/subjack/arecord.go @@ -0,0 +1,245 @@ +package subjack + +import ( + "fmt" + "net" + "time" + + "github.com/miekg/dns" +) + +// cloudCIDRs maps cloud providers to their known IP ranges. +var cloudCIDRs = map[string][]string{ + "AWS": { + "3.0.0.0/8", + "13.36.0.0/14", + "13.204.0.0/14", + "13.208.0.0/14", + "13.236.0.0/14", + "13.248.0.0/14", + "18.192.0.0/15", + "18.232.0.0/14", + "52.0.0.0/11", + "54.144.0.0/14", + "54.248.0.0/15", + "107.20.0.0/14", + }, + "GCP": { + "34.0.0.0/9", + "35.184.0.0/13", + "104.196.0.0/14", + }, + "Azure": { + "13.64.0.0/11", + "20.0.0.0/11", + "20.32.0.0/11", + "20.64.0.0/10", + "20.128.0.0/16", + "20.184.0.0/13", + "40.64.0.0/10", + "40.112.0.0/13", + "51.0.0.0/11", + "52.96.0.0/12", + "104.208.0.0/13", + }, + "DigitalOcean": { + "45.55.0.0/16", + "46.101.0.0/17", + "68.183.0.0/16", + "104.131.0.0/18", + "104.236.0.0/16", + "107.170.0.0/16", + "128.199.0.0/18", + "134.122.0.0/16", + "134.209.0.0/17", + "137.184.0.0/16", + "138.68.0.0/16", + "138.197.0.0/16", + "139.59.0.0/17", + "142.93.0.0/16", + "143.198.0.0/16", + "159.65.0.0/16", + "162.243.0.0/17", + "167.71.0.0/16", + "188.166.0.0/17", + }, + "Linode": { + "45.33.0.0/17", + "45.56.64.0/18", + "45.79.0.0/16", + "50.116.0.0/18", + "69.164.192.0/19", + "96.126.96.0/19", + "104.237.128.0/19", + "139.144.0.0/16", + "139.162.0.0/17", + "172.104.0.0/15", + "172.232.0.0/13", + }, + "Vultr": { + "45.32.0.0/16", + "64.176.0.0/18", + "66.55.128.0/19", + "66.135.0.0/19", + "108.61.0.0/19", + "139.84.128.0/18", + "139.180.128.0/18", + "149.28.0.0/16", + "155.138.0.0/17", + "207.246.0.0/16", + "216.238.64.0/18", + }, + "Oracle": { + "129.80.0.0/16", + "129.144.0.0/16", + "130.35.0.0/16", + "130.61.0.0/16", + "132.145.0.0/16", + "138.1.0.0/16", + "138.2.0.0/16", + "140.238.0.0/16", + "141.147.0.0/16", + "144.21.0.0/16", + "150.136.0.0/16", + "152.67.0.0/16", + "152.69.0.0/16", + "158.101.0.0/16", + }, +} + +// parsedNets is built once from cloudCIDRs for fast lookups. +var parsedNets []cloudNet + +type cloudNet struct { + provider string + network *net.IPNet +} + +func init() { + for provider, cidrs := range cloudCIDRs { + for _, cidr := range cidrs { + _, network, err := net.ParseCIDR(cidr) + if err != nil { + continue + } + parsedNets = append(parsedNets, cloudNet{provider: provider, network: network}) + } + } +} + +func identifyProvider(ip net.IP) string { + for _, cn := range parsedNets { + if cn.network.Contains(ip) { + return cn.provider + } + } + return "" +} + +func resolveA(domain string, o *Options) []net.IP { + msg := new(dns.Msg) + msg.SetQuestion(domain+".", dns.TypeA) + resp, err := dnsExchange(msg, o.resolvers, time.Duration(o.Timeout)*time.Second) + if err != nil { + return nil + } + + var ips []net.IP + for _, a := range resp.Answer { + if t, ok := a.(*dns.A); ok { + ips = append(ips, t.A) + } + } + return ips +} + +func isHostDead(ip string, timeout time.Duration) bool { + // Try ICMP ping first (requires root) + conn, err := net.DialTimeout("ip4:icmp", ip, timeout) + if err == nil { + conn.Close() + // Connection succeeded — host might be alive, verify with actual ping + return !ping(ip, timeout) + } + + // Fall back to TCP connect on common ports + for _, port := range []string{"80", "443"} { + conn, err := net.DialTimeout("tcp", ip+":"+port, timeout) + if err == nil { + conn.Close() + return false + } + } + return true +} + +func ping(ip string, timeout time.Duration) bool { + conn, err := net.DialTimeout("ip4:icmp", ip, timeout) + if err != nil { + return false + } + defer conn.Close() + + conn.SetDeadline(time.Now().Add(timeout)) + + // ICMP Echo Request + msg := []byte{ + 8, 0, 0, 0, // Type 8 (Echo), Code 0, Checksum placeholder + 0, 1, 0, 1, // Identifier, Sequence + } + // Calculate checksum + cs := icmpChecksum(msg) + msg[2] = byte(cs >> 8) + msg[3] = byte(cs) + + if _, err := conn.Write(msg); err != nil { + return false + } + + buf := make([]byte, 128) + _, err = conn.Read(buf) + return err == nil +} + +func icmpChecksum(data []byte) uint16 { + var sum uint32 + for i := 0; i < len(data)-1; i += 2 { + sum += uint32(data[i])<<8 | uint32(data[i+1]) + } + if len(data)%2 == 1 { + sum += uint32(data[len(data)-1]) << 8 + } + sum = (sum >> 16) + (sum & 0xffff) + sum += sum >> 16 + return ^uint16(sum) +} + +func checkARecord(domain string, o *Options) { + ips := resolveA(domain, o) + if len(ips) == 0 { + return + } + + timeout := time.Duration(o.Timeout) * time.Second + for _, ip := range ips { + if !isHostDead(ip.String(), timeout) { + continue + } + + provider := identifyProvider(ip) + var service, detail string + if provider != "" { + service = "STALE A RECORD" + detail = fmt.Sprintf("%s (%s) - IP %s appears dead", domain, provider, ip) + } else { + service = "STALE A RECORD" + detail = fmt.Sprintf("%s - IP %s appears dead", domain, ip) + } + + fmt.Printf("[%s%s%s] %s\n", colorGreen, service, colorReset, detail) + if o.Output != "" { + msg := fmt.Sprintf("[%s] %s\n", service, detail) + writeOutput(service, domain, msg, o.Output) + } + } +} diff --git a/subjack/arecord_test.go b/subjack/arecord_test.go new file mode 100644 index 0000000..25427ce --- /dev/null +++ b/subjack/arecord_test.go @@ -0,0 +1,71 @@ +package subjack + +import ( + "net" + "testing" +) + +func TestIdentifyProvider(t *testing.T) { + tests := []struct { + ip string + expected string + }{ + // AWS + {"3.5.1.1", "AWS"}, + {"52.10.0.1", "AWS"}, + {"54.144.0.1", "AWS"}, + // GCP + {"34.10.0.1", "GCP"}, + {"35.190.0.1", "GCP"}, + {"104.196.1.1", "GCP"}, + // Azure + {"13.64.0.1", "Azure"}, + {"20.1.0.1", "Azure"}, + {"40.64.0.1", "Azure"}, + // DigitalOcean + {"68.183.1.1", "DigitalOcean"}, + {"159.65.1.1", "DigitalOcean"}, + {"143.198.1.1", "DigitalOcean"}, + // Linode + {"45.79.1.1", "Linode"}, + {"172.104.1.1", "Linode"}, + {"139.144.1.1", "Linode"}, + // Vultr + {"45.32.1.1", "Vultr"}, + {"149.28.1.1", "Vultr"}, + {"155.138.1.1", "Vultr"}, + // Oracle + {"129.80.1.1", "Oracle"}, + {"150.136.1.1", "Oracle"}, + {"152.67.1.1", "Oracle"}, + // Unknown + {"1.1.1.1", ""}, + {"192.168.1.1", ""}, + {"127.0.0.1", ""}, + } + + for _, tt := range tests { + ip := net.ParseIP(tt.ip) + got := identifyProvider(ip) + if got != tt.expected { + t.Errorf("identifyProvider(%s) = %q, want %q", tt.ip, got, tt.expected) + } + } +} + +func TestIcmpChecksum(t *testing.T) { + // Standard ICMP echo request + msg := []byte{8, 0, 0, 0, 0, 1, 0, 1} + cs := icmpChecksum(msg) + if cs == 0 { + t.Error("icmpChecksum returned 0 for non-zero input") + } + + // Verify checksum is correct by checking complement + msg[2] = byte(cs >> 8) + msg[3] = byte(cs) + verify := icmpChecksum(msg) + if verify != 0 { + t.Errorf("icmpChecksum verification failed, got %d, want 0", verify) + } +} diff --git a/subjack/axfr.go b/subjack/axfr.go new file mode 100644 index 0000000..5bff181 --- /dev/null +++ b/subjack/axfr.go @@ -0,0 +1,120 @@ +package subjack + +import ( + "fmt" + "net" + "strings" + "time" + + "github.com/miekg/dns" +) + +// Common nameserver hostname prefixes to bruteforce. +var nsPrefixes = []string{ + "ns", "ns0", "ns1", "ns2", "ns3", "ns4", "ns5", + "dns", "dns0", "dns1", "dns2", "dns3", + "dns-0", "dns-1", "dns-2", "dns-3", + "dns-a", "dns-b", "dns-c", "dns-d", + "ns-a", "ns-b", "ns-c", "ns-d", + "ns-0", "ns-1", "ns-2", "ns-3", + "nsa", "nsb", "nsc", "nsd", + "dnsa", "dnsb", "dnsc", "dnsd", + "ns-primary", "ns-secondary", + "ns-backup", "ns-slave", "ns-master", + "primary", "secondary", + "auth-ns0", "auth-ns1", "auth-ns2", + "cdns1", "cdns2", "cdns3", + "pdns1", "pdns2", +} + +// checkAXFR attempts zone transfers against a domain's nameservers +// and bruteforced nameserver hostnames. +func checkAXFR(domain string, o *Options) { + // Extract the base domain for NS bruteforcing + baseDomain := getBaseDomain(domain) + timeout := time.Duration(o.Timeout) * time.Second + + // 1. Try AXFR against actual NS records + for _, ns := range lookupNS(domain, o) { + nsHost := strings.TrimSuffix(ns, ".") + ":53" + if tryAXFR(domain, nsHost, timeout, o) { + return + } + } + + // Also try NS records of the base domain if different + if baseDomain != domain { + for _, ns := range lookupNS(baseDomain, o) { + nsHost := strings.TrimSuffix(ns, ".") + ":53" + if tryAXFR(domain, nsHost, timeout, o) { + return + } + } + } + + // 2. Bruteforce common NS hostnames + for _, prefix := range nsPrefixes { + candidate := prefix + "." + baseDomain + ips, err := net.LookupHost(candidate) + if err != nil || len(ips) == 0 { + continue + } + if tryAXFR(domain, candidate+":53", timeout, o) { + return + } + // Also try AXFR for the base domain on this NS + if baseDomain != domain { + if tryAXFR(baseDomain, candidate+":53", timeout, o) { + return + } + } + } +} + +func tryAXFR(domain, nsHost string, timeout time.Duration, o *Options) bool { + transfer := &dns.Transfer{ + DialTimeout: timeout, + ReadTimeout: timeout, + WriteTimeout: timeout, + } + + msg := new(dns.Msg) + msg.SetAxfr(dns.Fqdn(domain)) + + ch, err := transfer.In(msg, nsHost) + if err != nil { + return false + } + + var records []string + for envelope := range ch { + if envelope.Error != nil { + return false + } + for _, rr := range envelope.RR { + records = append(records, rr.String()) + } + } + + if len(records) == 0 { + return false + } + + service := "ZONE TRANSFER" + detail := fmt.Sprintf("%s - AXFR successful on %s (%d records)", domain, nsHost, len(records)) + + fmt.Printf("[%s%s%s] %s\n", colorGreen, service, colorReset, detail) + if o.Output != "" { + msg := fmt.Sprintf("[%s] %s\n", service, detail) + writeOutput(service, domain, msg, o.Output) + } + return true +} + +func getBaseDomain(domain string) string { + parts := strings.Split(domain, ".") + if len(parts) <= 2 { + return domain + } + return strings.Join(parts[len(parts)-2:], ".") +} diff --git a/subjack/axfr_test.go b/subjack/axfr_test.go new file mode 100644 index 0000000..d6517d1 --- /dev/null +++ b/subjack/axfr_test.go @@ -0,0 +1,24 @@ +package subjack + +import "testing" + +func TestGetBaseDomain(t *testing.T) { + tests := []struct { + domain string + expected string + }{ + {"example.com", "example.com"}, + {"sub.example.com", "example.com"}, + {"deep.sub.example.com", "example.com"}, + {"a.b.c.d.example.com", "example.com"}, + {"com", "com"}, + {"", ""}, + } + + for _, tt := range tests { + got := getBaseDomain(tt.domain) + if got != tt.expected { + t.Errorf("getBaseDomain(%q) = %q, want %q", tt.domain, got, tt.expected) + } + } +} diff --git a/subjack/azure.go b/subjack/azure.go new file mode 100644 index 0000000..e34e90b --- /dev/null +++ b/subjack/azure.go @@ -0,0 +1,102 @@ +package subjack + +import ( + "bytes" + "encoding/json" + "net/http" + "strings" + "time" +) + +type tmCheckRequest struct { + Name string `json:"name"` + Type string `json:"type"` +} + +type tmCheckResponse struct { + NameAvailable bool `json:"nameAvailable"` + Reason string `json:"reason"` +} + +// verifyAzure performs secondary verification for Azure services to reduce +// false positives. Returns the service name if confirmed vulnerable, or an +// empty string if it's a false positive. +func verifyAzure(service, cname string, o *Options) string { + switch service { + case "AZURE-TRAFFICMANAGER": + return verifyTrafficManager(cname, o) + case "AZURE-CLOUDAPP": + // cloudapp.net has a reservation period after deletion and is being + // deprecated. Flag with reduced confidence. + return service + " (UNVERIFIED)" + default: + return service + } +} + +// verifyTrafficManager checks the Azure API to see if a Traffic Manager +// profile name is actually available for registration. +func verifyTrafficManager(cname string, o *Options) string { + name := extractTMName(cname) + if name == "" { + return "" + } + + available, err := checkTrafficManagerAvailable(name, time.Duration(o.Timeout)*time.Second) + if err != nil { + // API requires auth or failed — can't verify, flag as unverified + return "AZURE-TRAFFICMANAGER (UNVERIFIED)" + } + + if available { + return "AZURE-TRAFFICMANAGER" + } + + // Name is taken — this is a false positive + return "" +} + +func extractTMName(cname string) string { + cname = strings.TrimSuffix(cname, ".") + suffix := ".trafficmanager.net" + if !strings.HasSuffix(cname, suffix) { + return "" + } + return strings.TrimSuffix(cname, suffix) +} + +func checkTrafficManagerAvailable(name string, timeout time.Duration) (bool, error) { + reqBody, _ := json.Marshal(tmCheckRequest{ + Name: name, + Type: "microsoft.network/trafficmanagerprofiles", + }) + + url := "https://management.azure.com/providers/Microsoft.Network/checkTrafficManagerNameAvailability?api-version=2022-04-01" + + client := &http.Client{Timeout: timeout} + resp, err := client.Post(url, "application/json", bytes.NewReader(reqBody)) + if err != nil { + return false, err + } + defer resp.Body.Close() + + if resp.StatusCode == 401 || resp.StatusCode == 403 { + return false, errAuthRequired + } + + var result tmCheckResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return false, err + } + return result.NameAvailable, nil +} + +var errAuthRequired = &azureError{"azure API requires authentication"} + +type azureError struct { + msg string +} + +func (e *azureError) Error() string { + return e.msg +} diff --git a/subjack/azure_test.go b/subjack/azure_test.go new file mode 100644 index 0000000..adb7c88 --- /dev/null +++ b/subjack/azure_test.go @@ -0,0 +1,25 @@ +package subjack + +import "testing" + +func TestExtractTMName(t *testing.T) { + tests := []struct { + cname string + expected string + }{ + {"myprofile.trafficmanager.net", "myprofile"}, + {"myprofile.trafficmanager.net.", "myprofile"}, + {"test-app.trafficmanager.net", "test-app"}, + {"example.com", ""}, + {"trafficmanager.net", ""}, + {".trafficmanager.net", ""}, + {"", ""}, + } + + for _, tt := range tests { + got := extractTMName(tt.cname) + if got != tt.expected { + t.Errorf("extractTMName(%q) = %q, want %q", tt.cname, got, tt.expected) + } + } +} diff --git a/subjack/chain.go b/subjack/chain.go new file mode 100644 index 0000000..9a02177 --- /dev/null +++ b/subjack/chain.go @@ -0,0 +1,136 @@ +package subjack + +import ( + "fmt" + "strings" + "time" + + "github.com/haccer/available" + "github.com/miekg/dns" +) + +const maxCNAMEDepth = 10 + +// checkCNAMEChain follows the full CNAME chain and checks if any intermediate +// or terminal target is an NXDOMAIN with a registrable domain. +func checkCNAMEChain(domain string, o *Options) { + timeout := time.Duration(o.Timeout) * time.Second + var chain []string + current := domain + + for i := 0; i < maxCNAMEDepth; i++ { + msg := new(dns.Msg) + msg.SetQuestion(dns.Fqdn(current), dns.TypeCNAME) + resp, err := dnsExchange(msg, o.resolvers, timeout) + if err != nil { + break + } + + var target string + for _, a := range resp.Answer { + if t, ok := a.(*dns.CNAME); ok { + target = t.Target + break + } + } + if target == "" { + break + } + + target = strings.TrimSuffix(target, ".") + chain = append(chain, target) + current = target + } + + // Need at least 2 links to be a chain (beyond what normal detection catches) + if len(chain) < 2 { + return + } + + // Check each link in the chain for takeover opportunities + for i, link := range chain { + if i == 0 { + // Skip first link — already caught by normal CNAME detection + continue + } + + if isNXDOMAIN(link, o) && available.Domain(link) { + service := "CNAME CHAIN TAKEOVER" + chainStr := domain + " -> " + strings.Join(chain[:i+1], " -> ") + detail := fmt.Sprintf("%s - %s is available for registration (chain: %s)", domain, link, chainStr) + fmt.Printf("[%s%s%s] %s\n", colorGreen, service, colorReset, detail) + if o.Output != "" { + msg := fmt.Sprintf("[%s] %s\n", service, detail) + writeOutput(service, domain, msg, o.Output) + } + return + } + } +} + +// checkSRV looks up common SRV records and checks if any targets are +// expired or available for registration. +func checkSRV(domain string, o *Options) { + srvPrefixes := []string{ + "_sip._tcp.", + "_sip._udp.", + "_sips._tcp.", + "_xmpp-client._tcp.", + "_xmpp-server._tcp.", + "_jabber._tcp.", + "_h323ls._udp.", + "_h323cs._tcp.", + "_imap._tcp.", + "_imaps._tcp.", + "_submission._tcp.", + "_pop3._tcp.", + "_pop3s._tcp.", + "_caldav._tcp.", + "_caldavs._tcp.", + "_carddav._tcp.", + "_carddavs._tcp.", + "_ldap._tcp.", + "_ldaps._tcp.", + "_kerberos._tcp.", + "_kerberos._udp.", + "_kpasswd._tcp.", + "_kpasswd._udp.", + "_minecraft._tcp.", + "_ts3._udp.", + "_autodiscover._tcp.", + "_mta-sts._tcp.", + } + + timeout := time.Duration(o.Timeout) * time.Second + + for _, prefix := range srvPrefixes { + qname := prefix + domain + msg := new(dns.Msg) + msg.SetQuestion(dns.Fqdn(qname), dns.TypeSRV) + resp, err := dnsExchange(msg, o.resolvers, timeout) + if err != nil { + continue + } + + for _, a := range resp.Answer { + srv, ok := a.(*dns.SRV) + if !ok { + continue + } + target := strings.TrimSuffix(srv.Target, ".") + if target == "" || target == "." { + continue + } + + if isNXDOMAIN(target, o) && available.Domain(target) { + service := "SRV TAKEOVER" + detail := fmt.Sprintf("%s - SRV %s points to %s which is available for registration", domain, prefix, target) + fmt.Printf("[%s%s%s] %s\n", colorGreen, service, colorReset, detail) + if o.Output != "" { + msg := fmt.Sprintf("[%s] %s\n", service, detail) + writeOutput(service, domain, msg, o.Output) + } + } + } + } +} diff --git a/subjack/dns.go b/subjack/dns.go index bb15fed..3c81f53 100644 --- a/subjack/dns.go +++ b/subjack/dns.go @@ -2,69 +2,99 @@ package subjack import ( "fmt" - "net" - "strings" + "math/rand/v2" + "time" "github.com/haccer/available" "github.com/miekg/dns" ) -func (s *Subdomain) dns(o *Options) { - config := o.Fingerprints +const defaultResolver = "8.8.8.8:53" + +func pickResolver(resolvers []string) string { + if len(resolvers) == 0 { + return defaultResolver + } + return resolvers[rand.IntN(len(resolvers))] + ":53" +} + +func dnsExchange(msg *dns.Msg, resolvers []string, timeout time.Duration) (*dns.Msg, error) { + client := &dns.Client{Timeout: timeout} + resolver := pickResolver(resolvers) + resp, _, err := client.Exchange(msg, resolver) + if err != nil && resolver != defaultResolver { + resp, _, err = client.Exchange(msg, defaultResolver) + } + return resp, err +} + +func check(url string, o *Options) { + o.sem <- struct{}{} + defer func() { <-o.sem }() + + if o.CheckNS { + checkNS(url, o) + checkDanglingNS(url, o) + } + + if o.CheckAR { + checkARecord(url, o) + } + + if o.CheckAXFR { + checkAXFR(url, o) + } + + if o.CheckMail { + checkSPF(url, o) + checkMX(url, o) + } + + checkCNAMEChain(url, o) + checkSRV(url, o) if o.All { - detect(s.Url, o.Output, o.Ssl, o.Verbose, o.Manual, o.Timeout, config) - } else { - if VerifyCNAME(s.Url, config) { - detect(s.Url, o.Output, o.Ssl, o.Verbose, o.Manual, o.Timeout, config) - } + detect(url, o) + return + } - if o.Verbose { - result := fmt.Sprintf("[Not Vulnerable] %s\n", s.Url) - c := "\u001b[31;1mNot Vulnerable\u001b[0m" - out := strings.Replace(result, "Not Vulnerable", c, -1) - fmt.Printf(out) + if verifyCNAME(url, o) { + detect(url, o) + return + } - if o.Output != "" { - if chkJSON(o.Output) { - writeJSON("", s.Url, o.Output) - } else { - write(result, o.Output) - } - } - } + if o.Verbose { + printResult("", url, o) } } -func resolve(url string) (cname string) { - cname = "" - d := new(dns.Msg) - d.SetQuestion(url+".", dns.TypeCNAME) - ret, err := dns.Exchange(d, "8.8.8.8:53") +func resolveCNAME(domain string, o *Options) string { + msg := new(dns.Msg) + msg.SetQuestion(domain+".", dns.TypeCNAME) + resp, err := dnsExchange(msg, o.resolvers, time.Duration(o.Timeout)*time.Second) if err != nil { - return + return "" } - for _, a := range ret.Answer { + for _, a := range resp.Answer { if t, ok := a.(*dns.CNAME); ok { - cname = t.Target + return t.Target } } - return cname + return "" } -func nslookup(domain string) (nameservers []string) { - m := new(dns.Msg) - m.SetQuestion(dotDomain(domain), dns.TypeNS) - ret, err := dns.Exchange(m, "8.8.8.8:53") +func lookupNS(domain string, o *Options) []string { + msg := new(dns.Msg) + msg.SetQuestion(domain+".", dns.TypeNS) + resp, err := dnsExchange(msg, o.resolvers, time.Duration(o.Timeout)*time.Second) if err != nil { - return + return nil } - nameservers = []string{} - - for _, a := range ret.Answer { + var nameservers []string + for _, a := range resp.Answer { if t, ok := a.(*dns.NS); ok { nameservers = append(nameservers, t.Ns) } @@ -73,37 +103,24 @@ func nslookup(domain string) (nameservers []string) { return nameservers } -func nxdomain(nameserver string) bool { - if _, err := net.LookupHost(nameserver); err != nil { - if strings.Contains(fmt.Sprintln(err), "no such host") { - return true - } +func isNXDOMAIN(host string, o *Options) bool { + msg := new(dns.Msg) + msg.SetQuestion(dns.Fqdn(host), dns.TypeA) + resp, err := dnsExchange(msg, o.resolvers, time.Duration(o.Timeout)*time.Second) + if err != nil { + return false } - - return false + return resp.Rcode == dns.RcodeNameError } -func NS(domain, output string, verbose bool) { - nameservers := nslookup(domain) - for _, ns := range nameservers { - if verbose { - msg := fmt.Sprintf("[*] %s: Nameserver is %s\n", domain, ns) - fmt.Printf(msg) - - if output != "" { - write(msg, output) - } - } - - if nxdomain(ns) { - av := available.Domain(ns) - - if av { - msg := fmt.Sprintf("[!] %s's nameserver: %s is available for purchase!\n", domain, ns) - fmt.Printf(msg) - if output != "" { - write(msg, output) - } +func checkNS(domain string, o *Options) { + for _, ns := range lookupNS(domain, o) { + if isNXDOMAIN(ns, o) && available.Domain(ns) { + service := "NS TAKEOVER" + msg := fmt.Sprintf("[%s] %s - nameserver %s is available for purchase!", service, domain, ns) + fmt.Printf("[%s%s%s] %s - nameserver %s is available for purchase!\n", colorGreen, service, colorReset, domain, ns) + if o.Output != "" { + writeOutput(service, domain, msg+"\n", o.Output) } } } diff --git a/subjack/dot.go b/subjack/dot.go deleted file mode 100644 index 5731867..0000000 --- a/subjack/dot.go +++ /dev/null @@ -1,9 +0,0 @@ -package subjack - -func dotDomain(domain string) string { - return domain + "." -} - -func joinHost(server string) string { - return server + ":53" -} diff --git a/subjack/email.go b/subjack/email.go new file mode 100644 index 0000000..4c0c5b8 --- /dev/null +++ b/subjack/email.go @@ -0,0 +1,83 @@ +package subjack + +import ( + "fmt" + "strings" + "time" + + "github.com/haccer/available" + "github.com/miekg/dns" +) + +// checkSPF parses SPF TXT records for include: domains and checks if any +// are expired or available for registration. +func checkSPF(domain string, o *Options) { + msg := new(dns.Msg) + msg.SetQuestion(dns.Fqdn(domain), dns.TypeTXT) + resp, err := dnsExchange(msg, o.resolvers, time.Duration(o.Timeout)*time.Second) + if err != nil { + return + } + + for _, a := range resp.Answer { + txt, ok := a.(*dns.TXT) + if !ok { + continue + } + record := strings.Join(txt.Txt, "") + if !strings.HasPrefix(record, "v=spf1") { + continue + } + + for _, part := range strings.Fields(record) { + if !strings.HasPrefix(part, "include:") { + continue + } + includeDomain := strings.TrimPrefix(part, "include:") + if includeDomain == "" { + continue + } + + if isNXDOMAIN(includeDomain, o) && available.Domain(includeDomain) { + service := "SPF TAKEOVER" + detail := fmt.Sprintf("%s - SPF include:%s is available for registration", domain, includeDomain) + fmt.Printf("[%s%s%s] %s\n", colorGreen, service, colorReset, detail) + if o.Output != "" { + msg := fmt.Sprintf("[%s] %s\n", service, detail) + writeOutput(service, domain, msg, o.Output) + } + } + } + } +} + +// checkMX checks if any MX record targets are expired or available for registration. +func checkMX(domain string, o *Options) { + msg := new(dns.Msg) + msg.SetQuestion(dns.Fqdn(domain), dns.TypeMX) + resp, err := dnsExchange(msg, o.resolvers, time.Duration(o.Timeout)*time.Second) + if err != nil { + return + } + + for _, a := range resp.Answer { + mx, ok := a.(*dns.MX) + if !ok { + continue + } + mxHost := strings.TrimSuffix(mx.Mx, ".") + if mxHost == "" { + continue + } + + if isNXDOMAIN(mxHost, o) && available.Domain(mxHost) { + service := "MX TAKEOVER" + detail := fmt.Sprintf("%s - mail server %s is available for registration", domain, mxHost) + fmt.Printf("[%s%s%s] %s\n", colorGreen, service, colorReset, detail) + if o.Output != "" { + msg := fmt.Sprintf("[%s] %s\n", service, detail) + writeOutput(service, domain, msg, o.Output) + } + } + } +} diff --git a/subjack/file.go b/subjack/file.go deleted file mode 100644 index 78ac6b6..0000000 --- a/subjack/file.go +++ /dev/null @@ -1,126 +0,0 @@ -package subjack - -import ( - "bufio" - "encoding/json" - "io/ioutil" - "log" - "os" - "strings" -) - -type Results struct { - Subdomain string `json:"subdomain"` - Vulnerable bool `json:"vulnerable"` - Service string `json:"service,omitempty"` - Domain string `json:"nonexist_domain,omitempty"` -} - -func open(path string) (lines []string, Error error) { - file, err := os.Open(path) - if err != nil { - log.Fatalln(err) - } - - defer file.Close() - - scanner := bufio.NewScanner(file) - - for scanner.Scan() { - lines = append(lines, scanner.Text()) - } - - return lines, scanner.Err() -} - -func chkJSON(output string) (json bool) { - json = false - - if strings.Contains(output, ".json") { - if output[len(output)-5:] == ".json" { - json = true - } - } - - return json -} - -func write(result, output string) { - f, err := os.OpenFile(output, os.O_RDWR|os.O_APPEND|os.O_CREATE, 0600) - if err != nil { - log.Fatalln(err) - } - - defer f.Close() - - _, err = f.WriteString(result) - if err != nil { - log.Fatalln(err) - } -} - -func writeJSON(service, url, output string) { - var r Results - if strings.Contains(service, "DOMAIN") { - r = Results{ - Subdomain: strings.ToLower(url), - Vulnerable: true, - Service: "unregistered domain", - Domain: strings.Split(service, " - ")[1], - } - } else { - if service != "" { - r = Results{ - Subdomain: strings.ToLower(url), - Vulnerable: true, - Service: strings.ToLower(service), - } - } else { - r = Results{ - Subdomain: strings.ToLower(url), - Vulnerable: false, - } - } - } - - f, err := os.OpenFile(output, os.O_CREATE|os.O_RDWR, 0600) - if err != nil { - log.Fatalln(err) - } - - defer f.Close() - - file, err := ioutil.ReadAll(f) - if err != nil { - log.Fatalln(err) - } - - var data []Results - json.Unmarshal(file, &data) - data = append(data, r) - - results, _ := json.Marshal(data) - - wf, err := os.OpenFile(output, os.O_CREATE|os.O_RDWR, 0600) - if err != nil { - log.Fatalln(err) - } - - defer wf.Close() - - wf.Write(results) -} - -func fingerprints(file string) (data []Fingerprints) { - config, err := ioutil.ReadFile(file) - if err != nil { - log.Fatalln(err) - } - - err = json.Unmarshal(config, &data) - if err != nil { - log.Fatalln(err) - } - - return data -} diff --git a/subjack/fingerprint.go b/subjack/fingerprint.go index fcc7433..f695480 100644 --- a/subjack/fingerprint.go +++ b/subjack/fingerprint.go @@ -2,129 +2,78 @@ package subjack import ( "bytes" - "fmt" "strings" "github.com/haccer/available" ) -type Fingerprints struct { +type Fingerprint struct { Service string `json:"service"` Cname []string `json:"cname"` Fingerprint []string `json:"fingerprint"` Nxdomain bool `json:"nxdomain"` } -/* -* Triage step to check whether the CNAME matches -* the fingerprinted CNAME of a vulnerable cloud service. - */ -func VerifyCNAME(subdomain string, config []Fingerprints) (match bool) { - cname := resolve(subdomain) - match = false - -VERIFY: - for n := range config { - for c := range config[n].Cname { - if strings.Contains(cname, config[n].Cname[c]) { - match = true - break VERIFY +func verifyCNAME(subdomain string, o *Options) bool { + cname := resolveCNAME(subdomain, o) + for _, fp := range o.fingerprints { + for _, c := range fp.Cname { + if strings.Contains(cname, c) { + return true } } } - - return match + return false } -func detect(url, output string, ssl, verbose, manual bool, timeout int, config []Fingerprints) { - service := Identify(url, ssl, manual, timeout, config) - - if service != "" { - result := fmt.Sprintf("[%s] %s\n", service, url) - c := fmt.Sprintf("\u001b[32;1m%s\u001b[0m", service) - out := strings.Replace(result, service, c, -1) - fmt.Printf(out) - - if output != "" { - if chkJSON(output) { - writeJSON(service, url, output) - } else { - write(result, output) - } - } - } - - if service == "" && verbose { - result := fmt.Sprintf("[Not Vulnerable] %s\n", url) - c := "\u001b[31;1mNot Vulnerable\u001b[0m" - out := strings.Replace(result, "Not Vulnerable", c, -1) - fmt.Printf(out) - - if output != "" { - if chkJSON(output) { - writeJSON(service, url, output) - } else { - write(result, output) - } - } - } +func detect(url string, o *Options) { + service := identify(url, o) + printResult(service, url, o) } -/* -* This function aims to identify whether the subdomain -* is attached to a vulnerable cloud service and able to -* be taken over. - */ -func Identify(subdomain string, forceSSL, manual bool, timeout int, fingerprints []Fingerprints) (service string) { - body := get(subdomain, forceSSL, timeout) - - cname := resolve(subdomain) - +func identify(subdomain string, o *Options) string { + cname := resolveCNAME(subdomain, o) if len(cname) <= 3 { cname = "" } - service = "" - nx := nxdomain(subdomain) - -IDENTIFY: - for f := range fingerprints { - - // Begin subdomain checks if the subdomain returns NXDOMAIN - if nx { - - // Check if we can register this domain. - dead := available.Domain(cname) - if dead { - service = "DOMAIN AVAILABLE - " + cname - break IDENTIFY - } + if isNXDOMAIN(subdomain, o) { + if available.Domain(cname) { + return "DOMAIN AVAILABLE - " + cname + } - // Check if subdomain matches fingerprinted cname - if fingerprints[f].Nxdomain { - for n := range fingerprints[f].Cname { - if strings.Contains(cname, fingerprints[f].Cname[n]) { - service = strings.ToUpper(fingerprints[f].Service) - break IDENTIFY + for _, fp := range o.fingerprints { + if fp.Nxdomain { + for _, c := range fp.Cname { + if strings.Contains(cname, c) { + service := strings.ToUpper(fp.Service) + if strings.HasPrefix(service, "AZURE-") { + service = verifyAzure(service, cname, o) + if service == "" { + return "" + } + } + return service } } } + } - // Option to always print the CNAME and not check if it's available to be registered. - if manual && !dead && cname != "" { - service = "DEAD DOMAIN - " + cname - break IDENTIFY - } + if o.Manual && cname != "" { + return "DEAD DOMAIN - " + cname } - // Check if body matches fingerprinted response - for n := range fingerprints[f].Fingerprint { - if bytes.Contains(body, []byte(fingerprints[f].Fingerprint[n])) { - service = strings.ToUpper(fingerprints[f].Service) - break + return "" + } + + body := httpGet(subdomain, o.Ssl, o.Timeout) + for _, fp := range o.fingerprints { + for _, pattern := range fp.Fingerprint { + if bytes.Contains(body, []byte(pattern)) { + return strings.ToUpper(fp.Service) } } } - return service + return "" } diff --git a/fingerprints.json b/subjack/fingerprints.json similarity index 63% rename from fingerprints.json rename to subjack/fingerprints.json index f9af7cc..b1ea5a0 100644 --- a/fingerprints.json +++ b/subjack/fingerprints.json @@ -1,14 +1,4 @@ [ - { - "service": "fastly", - "cname": [ - "fastly" - ], - "fingerprint": [ - "Fastly error: unknown domain" - ], - "nxdomain": false - }, { "service": "github", "cname": [ @@ -19,23 +9,13 @@ ], "nxdomain": false }, - { - "service": "heroku", - "cname": [ - "herokuapp" - ], - "fingerprint": [ - "herokucdn.com/error-pages/no-such-app.html" - ], - "nxdomain": false - }, { "service": "pantheon", "cname": [ "pantheonsite.io" ], "fingerprint": [ - "The gods are wise, but do not know of the site which you seek." + "404 error unknown site!" ], "nxdomain": false }, @@ -59,16 +39,6 @@ ], "nxdomain": false }, - { - "service": "teamwork", - "cname": [ - "teamwork.com" - ], - "fingerprint": [ - "Oops - We didn't find your site." - ], - "nxdomain": false - }, { "service": "helpjuice", "cname": [ @@ -92,7 +62,9 @@ { "service": "s3 bucket", "cname": [ - "amazonaws" + ".s3.amazonaws.com", + ".s3-website.amazonaws.com", + ".s3-website-" ], "fingerprint": [ "The specified bucket does not exist" @@ -105,7 +77,8 @@ "ghost.io" ], "fingerprint": [ - "The thing you were looking for is no longer here, or never was" + "Site unavailable", + "Failed to resolve DNS path for this host" ], "nxdomain": false }, @@ -119,16 +92,6 @@ ], "nxdomain": false }, - { - "service": "uservoice", - "cname": [ - "uservoice.com" - ], - "fingerprint": [ - "This UserVoice subdomain is currently available!" - ], - "nxdomain": false - }, { "service": "surge", "cname": [ @@ -172,186 +135,286 @@ "nxdomain": false }, { - "service": "wishpond", + "service": "bigcartel", "cname": [ - "wishpond.com" + "bigcartel.com" ], "fingerprint": [ - "https://www.wishpond.com/404?campaign=true" + "

Oops! We could’t find that page.

" ], "nxdomain": false }, { - "service": "aftership", + "service": "campaignmonitor", "cname": [ - "aftership.com" + "createsend.com" ], "fingerprint": [ - "Oops.

The page you're looking for doesn't exist." + "Double check the URL or Error Code: 404

" + "Web App - Unavailable", + "Error 404 - Web app not found." ], - "nxdomain": false + "nxdomain": true }, { - "service": "bigcartel", + "service": "azure-blob", "cname": [ - "bigcartel.com" + ".blob.core.windows.net" ], "fingerprint": [ - "

Oops! We could’t find that page.

" + "The specified resource does not exist.", + "BlobNotFound" ], - "nxdomain": false + "nxdomain": true }, { - "service": "campaignmonitor", + "service": "azure-cdn", "cname": [ - "createsend.com" + ".azureedge.net" + ], + "fingerprint": [], + "nxdomain": true + }, + { + "service": "azure-cloudapp", + "cname": [ + ".cloudapp.net", + ".cloudapp.azure.com" + ], + "fingerprint": [], + "nxdomain": true + }, + { + "service": "azure-api", + "cname": [ + ".azure-api.net" + ], + "fingerprint": [], + "nxdomain": true + }, + { + "service": "azure-hdinsight", + "cname": [ + ".azurehdinsight.net" + ], + "fingerprint": [], + "nxdomain": true + }, + { + "service": "readme", + "cname": [ + "readme.io" ], "fingerprint": [ - "Double check the URL or
\nLearn more about Worksites.net" ], "nxdomain": false }, { - "service": "simplebooklet", + "service": "kickofflabs", "cname": [ - "simplebooklet.com" + "kickofflabs.edgeapp.net" ], "fingerprint": [ - "We can't find this \nLearn more about Worksites.net" + "Sorry, couldn't find the status page" + ], + "nxdomain": false + }, + { + "service": "strikingly", + "cname": [ + "s.strikinglydns.com" + ], + "fingerprint": [ + "PAGE NOT FOUND" + ], + "nxdomain": false + }, + { + "service": "surveysparrow", + "cname": [ + "surveysparrow.com" + ], + "fingerprint": [ + "Account not found." + ], + "nxdomain": false + }, + { + "service": "uberflip", + "cname": [ + "read.uberflip.com" + ], + "fingerprint": [ + "The URL you've accessed does not provide a hub." + ], + "nxdomain": false + }, + { + "service": "uptimerobot", + "cname": [ + "stats.uptimerobot.com" + ], + "fingerprint": [ + "page not found" ], "nxdomain": false } diff --git a/subjack/http.go b/subjack/http.go new file mode 100644 index 0000000..2ce4add --- /dev/null +++ b/subjack/http.go @@ -0,0 +1,34 @@ +package subjack + +import ( + "crypto/tls" + "time" + + "github.com/valyala/fasthttp" +) + +var httpClient = &fasthttp.Client{ + TLSConfig: &tls.Config{InsecureSkipVerify: true}, +} + +func httpGet(url string, ssl bool, timeout int) []byte { + req := fasthttp.AcquireRequest() + defer fasthttp.ReleaseRequest(req) + + resp := fasthttp.AcquireResponse() + defer fasthttp.ReleaseResponse(resp) + + scheme := "http://" + if ssl { + scheme = "https://" + } + + req.SetRequestURI(scheme + url) + req.Header.Add("Connection", "close") + + httpClient.DoTimeout(req, resp, time.Duration(timeout)*time.Second) + + body := make([]byte, len(resp.Body())) + copy(body, resp.Body()) + return body +} diff --git a/subjack/nstakeover.go b/subjack/nstakeover.go new file mode 100644 index 0000000..ed75dc1 --- /dev/null +++ b/subjack/nstakeover.go @@ -0,0 +1,109 @@ +package subjack + +import ( + "fmt" + "regexp" + "strings" + "time" + + "github.com/miekg/dns" +) + +type cloudDNSProvider struct { + name string + pattern *regexp.Regexp +} + +var cloudDNSProviders = []cloudDNSProvider{ + { + name: "Route53", + pattern: regexp.MustCompile(`(?i)^ns-\d+\.awsdns-\d+\.(com|net|org|co\.uk)\.?$`), + }, + { + name: "Google Cloud DNS", + pattern: regexp.MustCompile(`(?i)^ns-cloud-[a-z]\d+\.googledomains\.com\.?$`), + }, + { + name: "Azure DNS", + pattern: regexp.MustCompile(`(?i)^ns\d+-\d+\.azure-dns\.(com|net|org|info)\.?$`), + }, + { + name: "DigitalOcean DNS", + pattern: regexp.MustCompile(`(?i)^ns\d+\.digitalocean\.com\.?$`), + }, + { + name: "Vultr DNS", + pattern: regexp.MustCompile(`(?i)^ns\d+\.vultr\.com\.?$`), + }, + { + name: "Linode DNS", + pattern: regexp.MustCompile(`(?i)^ns\d+\.linode\.com\.?$`), + }, +} + +func identifyDNSProvider(ns string) string { + ns = strings.TrimSuffix(ns, ".") + for _, p := range cloudDNSProviders { + if p.pattern.MatchString(ns) { + return p.name + } + } + return "" +} + +// checkDanglingNS looks for dangling NS delegations to cloud DNS providers. +// If a domain's nameservers belong to a cloud provider but the hosted zone +// has been deleted, direct SOA queries will return SERVFAIL or REFUSED. +func checkDanglingNS(domain string, o *Options) { + nameservers := lookupNS(domain, o) + if len(nameservers) == 0 { + return + } + + var cloudNSes []string + var provider string + + for _, ns := range nameservers { + p := identifyDNSProvider(ns) + if p != "" { + cloudNSes = append(cloudNSes, strings.TrimSuffix(ns, ".")+":53") + provider = p + } + } + if len(cloudNSes) == 0 { + return + } + + timeout := time.Duration(o.Timeout) * time.Second + client := &dns.Client{Timeout: timeout} + + soaMsg := new(dns.Msg) + soaMsg.SetQuestion(dns.Fqdn(domain), dns.TypeSOA) + soaMsg.RecursionDesired = false + + failed := 0 + for _, ns := range cloudNSes { + resp, _, err := client.Exchange(soaMsg, ns) + if err != nil { + continue + } + if resp.Rcode == dns.RcodeServerFailure || resp.Rcode == dns.RcodeRefused { + failed++ + } + } + + // All cloud NS must return failure to confirm + if failed == 0 || failed != len(cloudNSes) { + return + } + + service := "NS DELEGATION TAKEOVER" + nsList := strings.Join(cloudNSes, ", ") + detail := fmt.Sprintf("%s - dangling %s delegation (%s)", domain, provider, nsList) + + fmt.Printf("[%s%s%s] %s\n", colorGreen, service, colorReset, detail) + if o.Output != "" { + msg := fmt.Sprintf("[%s] %s\n", service, detail) + writeOutput(service, domain, msg, o.Output) + } +} diff --git a/subjack/nstakeover_test.go b/subjack/nstakeover_test.go new file mode 100644 index 0000000..29b6ced --- /dev/null +++ b/subjack/nstakeover_test.go @@ -0,0 +1,46 @@ +package subjack + +import "testing" + +func TestIdentifyDNSProvider(t *testing.T) { + tests := []struct { + ns string + expected string + }{ + // Route53 + {"ns-123.awsdns-45.com", "Route53"}, + {"ns-2047.awsdns-99.net", "Route53"}, + {"ns-500.awsdns-10.org", "Route53"}, + {"ns-1.awsdns-0.co.uk", "Route53"}, + {"ns-1.awsdns-0.co.uk.", "Route53"}, + // Google Cloud DNS + {"ns-cloud-a1.googledomains.com", "Google Cloud DNS"}, + {"ns-cloud-d2.googledomains.com.", "Google Cloud DNS"}, + // Azure DNS + {"ns1-01.azure-dns.com", "Azure DNS"}, + {"ns2-05.azure-dns.net", "Azure DNS"}, + {"ns3-03.azure-dns.org", "Azure DNS"}, + {"ns4-09.azure-dns.info", "Azure DNS"}, + // DigitalOcean DNS + {"ns1.digitalocean.com", "DigitalOcean DNS"}, + {"ns3.digitalocean.com.", "DigitalOcean DNS"}, + // Vultr DNS + {"ns1.vultr.com", "Vultr DNS"}, + {"ns2.vultr.com.", "Vultr DNS"}, + // Linode DNS + {"ns1.linode.com", "Linode DNS"}, + {"ns5.linode.com.", "Linode DNS"}, + // Not a cloud provider + {"ns1.example.com", ""}, + {"dns.google", ""}, + {"a.iana-servers.net", ""}, + {"", ""}, + } + + for _, tt := range tests { + got := identifyDNSProvider(tt.ns) + if got != tt.expected { + t.Errorf("identifyDNSProvider(%q) = %q, want %q", tt.ns, got, tt.expected) + } + } +} diff --git a/subjack/output.go b/subjack/output.go new file mode 100644 index 0000000..37eb36a --- /dev/null +++ b/subjack/output.go @@ -0,0 +1,135 @@ +package subjack + +import ( + "bufio" + _ "embed" + "encoding/json" + "fmt" + "log" + "os" + "strings" + "sync" +) + +//go:embed fingerprints.json +var fingerprintsJSON []byte + +const ( + colorGreen = "\033[32;1m" + colorRed = "\033[31;1m" + colorReset = "\033[0m" +) + +type Result struct { + Subdomain string `json:"subdomain"` + Vulnerable bool `json:"vulnerable"` + Service string `json:"service,omitempty"` + Domain string `json:"nonexist_domain,omitempty"` +} + +var outputMu sync.Mutex + +func readLines(path string) ([]string, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + + var lines []string + scanner := bufio.NewScanner(file) + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + return lines, scanner.Err() +} + +func printResult(service, url string, o *Options) { + if service != "" { + colored := colorGreen + service + colorReset + fmt.Printf("[%s] %s\n", colored, url) + } else if o.Verbose { + fmt.Printf("[%sNot Vulnerable%s] %s\n", colorRed, colorReset, url) + } + + if o.Output != "" { + if service != "" { + plain := fmt.Sprintf("[%s] %s\n", service, url) + writeOutput(service, url, plain, o.Output) + } else { + plain := fmt.Sprintf("[Not Vulnerable] %s\n", url) + writeOutput("", url, plain, o.Output) + } + } +} + +func initOutput(path string) { + if strings.HasSuffix(path, ".json") { + os.WriteFile(path, []byte("[]"), 0600) + } else { + os.WriteFile(path, nil, 0600) + } +} + +func writeOutput(service, url, plain, output string) { + outputMu.Lock() + defer outputMu.Unlock() + + if strings.HasSuffix(output, ".json") { + appendJSON(service, url, output) + } else { + appendText(plain, output) + } +} + +func appendText(text, path string) { + f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0600) + if err != nil { + log.Fatalln(err) + } + defer f.Close() + + if _, err := f.WriteString(text); err != nil { + log.Fatalln(err) + } +} + +func appendJSON(service, url, path string) { + var r Result + if strings.Contains(service, "DOMAIN") { + r = Result{ + Subdomain: strings.ToLower(url), + Vulnerable: true, + Service: "unregistered domain", + Domain: strings.Split(service, " - ")[1], + } + } else if service != "" { + r = Result{ + Subdomain: strings.ToLower(url), + Vulnerable: true, + Service: strings.ToLower(service), + } + } else { + r = Result{ + Subdomain: strings.ToLower(url), + Vulnerable: false, + } + } + + var data []Result + if existing, err := os.ReadFile(path); err == nil { + json.Unmarshal(existing, &data) + } + data = append(data, r) + + out, _ := json.Marshal(data) + os.WriteFile(path, out, 0600) +} + +func loadFingerprints() []Fingerprint { + var data []Fingerprint + if err := json.Unmarshal(fingerprintsJSON, &data); err != nil { + log.Fatalln(err) + } + return data +} diff --git a/subjack/output_test.go b/subjack/output_test.go new file mode 100644 index 0000000..6e03341 --- /dev/null +++ b/subjack/output_test.go @@ -0,0 +1,52 @@ +package subjack + +import ( + "encoding/json" + "testing" +) + +func TestLoadFingerprints(t *testing.T) { + fps := loadFingerprints() + if len(fps) == 0 { + t.Fatal("loadFingerprints returned empty slice") + } + + // Verify structure + for _, fp := range fps { + if fp.Service == "" { + t.Error("fingerprint has empty service name") + } + } +} + +func TestFingerprintsJSON(t *testing.T) { + var fps []Fingerprint + if err := json.Unmarshal(fingerprintsJSON, &fps); err != nil { + t.Fatalf("fingerprints.json is invalid JSON: %v", err) + } + + services := make(map[string]bool) + for _, fp := range fps { + if fp.Service == "" { + t.Error("fingerprint has empty service name") + } + if services[fp.Service] { + t.Errorf("duplicate service name: %s", fp.Service) + } + services[fp.Service] = true + + if !fp.Nxdomain && len(fp.Fingerprint) == 0 { + // Non-nxdomain services should have fingerprints (except worksites with empty cname) + hasCname := false + for _, c := range fp.Cname { + if c != "" { + hasCname = true + break + } + } + if hasCname { + t.Errorf("service %s has no fingerprints and nxdomain=false", fp.Service) + } + } + } +} diff --git a/subjack/requests.go b/subjack/requests.go deleted file mode 100644 index db57ff5..0000000 --- a/subjack/requests.go +++ /dev/null @@ -1,35 +0,0 @@ -package subjack - -import ( - "crypto/tls" - "github.com/valyala/fasthttp" - "time" -) - -func get(url string, ssl bool, timeout int) (body []byte) { - req := fasthttp.AcquireRequest() - req.SetRequestURI(site(url, ssl)) - req.Header.Add("Connection", "close") - resp := fasthttp.AcquireResponse() - - client := &fasthttp.Client{TLSConfig: &tls.Config{InsecureSkipVerify: true}} - client.DoTimeout(req, resp, time.Duration(timeout)*time.Second) - - return resp.Body() -} - -func https(url string, ssl bool, timeout int) (body []byte) { - newUrl := "https://" + url - body = get(newUrl, ssl, timeout) - - return body -} - -func site(url string, ssl bool) (site string) { - site = "http://" + url - if ssl { - site = "https://" + url - } - - return site -} diff --git a/subjack/subjack.go b/subjack/subjack.go index f992aba..6d4a875 100644 --- a/subjack/subjack.go +++ b/subjack/subjack.go @@ -1,7 +1,9 @@ package subjack import ( + "bufio" "log" + "os" "sync" ) @@ -14,49 +16,68 @@ type Options struct { Ssl bool All bool Verbose bool - Config string Manual bool - Fingerprints []Fingerprints + CheckNS bool + CheckAR bool + CheckAXFR bool + CheckMail bool + ResolverList string + Stdin bool + fingerprints []Fingerprint + resolvers []string + sem chan struct{} } -type Subdomain struct { - Url string -} - -/* Start processing subjack from the defined options. */ func Process(o *Options) { var list []string var err error - urls := make(chan *Subdomain, o.Threads*10) - - if(len(o.Domain) > 0){ + if len(o.Domain) > 0 { list = append(list, o.Domain) - } else { - list, err = open(o.Wordlist) + } else if o.Wordlist != "" { + list, err = readLines(o.Wordlist) + if err != nil { + log.Fatalln(err) + } + } + + o.fingerprints = loadFingerprints() + + if o.Output != "" { + initOutput(o.Output) } - - if err != nil { - log.Fatalln(err) + + if o.ResolverList != "" { + o.resolvers, err = readLines(o.ResolverList) + if err != nil { + log.Fatalln(err) + } } - - o.Fingerprints = fingerprints(o.Config) + o.sem = make(chan struct{}, o.Threads) + + urls := make(chan string, o.Threads*10) wg := new(sync.WaitGroup) for i := 0; i < o.Threads; i++ { wg.Add(1) go func() { + defer wg.Done() for url := range urls { - url.dns(o) + check(url, o) } - - wg.Done() }() } - for i := 0; i < len(list); i++ { - urls <- &Subdomain{Url: list[i]} + if o.Stdin { + scanner := bufio.NewScanner(os.Stdin) + for scanner.Scan() { + urls <- scanner.Text() + } + } else { + for _, u := range list { + urls <- u + } } close(urls)