From 29ab782b31dadca051aa69ae3bb87c1f50d0b01a Mon Sep 17 00:00:00 2001 From: Seth Art Date: Wed, 17 Jun 2026 14:29:10 -0400 Subject: [PATCH] Fix region slowness and hangs with opt-in/unreachable regions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commands were hanging for several minutes when run with low-privilege credentials against accounts where opt-in regions (e.g. me-south-1) are not enabled. Each unreachable region exhausted the full AWS SDK retry budget (3 attempts × ~30s timeout) per service call, compounding across every check in all-checks mode. Three-part fix: 1. TCP reachability probe: Before making any API calls, probe sts.REGION.amazonaws.com:443 with a 3-second timeout. Unreachable regions are filtered out concurrently and cached for the run so the probe only happens once per region. This eliminates multi-minute stalls caused by regions that are not enabled for the account. 2. --max-retries flag: New persistent flag on the aws subcommand (default 3, matching prior behaviour). Setting --max-retries 0 skips all retries, useful when running black-box against an account where most API calls will return 403/401 anyway. 3. Remove me-south-1 from the static fallback region list: This Bahrain opt-in region was the primary offender reported in issue #132. It is excluded from the hardcoded list so accounts that never opted in skip it entirely even before the TCP probe runs. Also fixes a pre-existing bug in GetIamSimResult where a redundant second isRoleAdminBool check after the cache-or-compute block would overwrite a correct "YES" result from the cache with "No" (because isRoleAdminBool stays false on a cache hit). Fixes #132 --- aws/shared.go | 8 +---- cli/aws.go | 9 ++++-- globals/utils.go | 2 +- internal/aws.go | 80 ++++++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 86 insertions(+), 13 deletions(-) diff --git a/aws/shared.go b/aws/shared.go index 566c293..e47f809 100644 --- a/aws/shared.go +++ b/aws/shared.go @@ -19,7 +19,7 @@ var yellow = color.New(color.FgRed).SprintFunc() var blue = color.New(color.FgBlue).SprintFunc() var magenta = color.New(color.FgMagenta).SprintFunc() var green = color.New(color.FgGreen).SprintFunc() -var AWSRegions = []string{"us-east-1", "us-east-2", "us-west-1", "us-west-2", "af-south-1", "ap-east-1", "ap-south-1", "ap-northeast-3", "ap-northeast-2", "ap-southeast-1", "ap-southeast-2", "ap-northeast-1", "ca-central-1", "eu-central-1", "eu-west-1", "eu-west-2", "eu-south-1", "eu-west-3", "eu-north-1", "me-south-1", "sa-east-1"} +var AWSRegions = []string{"us-east-1", "us-east-2", "us-west-1", "us-west-2", "af-south-1", "ap-east-1", "ap-south-1", "ap-northeast-3", "ap-northeast-2", "ap-southeast-1", "ap-southeast-2", "ap-northeast-1", "ca-central-1", "eu-central-1", "eu-west-1", "eu-west-2", "eu-south-1", "eu-west-3", "eu-north-1", "sa-east-1"} var sharedLogger = internal.TxtLogger() func GetIamSimResult(SkipAdminCheck bool, roleArnPtr *string, iamSimulatorMod IamSimulatorModule, localAdminMap map[string]bool) (string, string) { @@ -54,12 +54,6 @@ func GetIamSimResult(SkipAdminCheck bool, roleArnPtr *string, iamSimulatorMod Ia } } - if isRoleAdminBool { - adminRole = "YES" - } else { - adminRole = "No" - } - } else { adminRole = "Skipped" canRolePrivEsc = "Skipped" diff --git a/cli/aws.go b/cli/aws.go index d164670..3c07466 100644 --- a/cli/aws.go +++ b/cli/aws.go @@ -92,8 +92,9 @@ var ( AWSUseCache bool AWSMFAToken string - Goroutines int - Verbosity int + Goroutines int + Verbosity int + AWSMaxRetries int AWSCommands = &cobra.Command{ Use: "aws", @@ -546,6 +547,9 @@ type OrgAccounts struct { func awsPreRun(cmd *cobra.Command, args []string) { gob.Register(&types.Organization{}) + // Set the max retries before any AWS config loading happens + internal.MaxRetries = AWSMaxRetries + // if multiple profiles were used, ensure the management account is first // if AWSProfilesList != "" || AWSAllProfiles { // AWSProfiles = FindOrgMgmtAccountAndReorderAccounts(AWSProfiles, cmd.Root().Version, AWSMFAToken) @@ -2553,6 +2557,7 @@ func init() { AWSCommands.PersistentFlags().BoolVarP(&AWSUseCache, "cached", "c", false, "Load cached data from disk. Faster, but if changes have been recently made you'll miss them") AWSCommands.PersistentFlags().StringVarP(&AWSTableCols, "cols", "t", "", "Comma separated list of columns to display in table output") AWSCommands.PersistentFlags().StringVar(&AWSMFAToken, "mfa-token", "", "MFA Token") + AWSCommands.PersistentFlags().IntVar(&AWSMaxRetries, "max-retries", 3, "Maximum number of AWS API retry attempts. Set to 0 for no retries (faster for black-box/limited-permission enumeration)") AWSCommands.PersistentFlags().StringVar(&PmapperDataBasePath, "pmapper-data-basepath", "", "Supply the base path for the pmapper data files (useful if you have copied them from another machine)\nPoint to the parent directory that contains all of the pmapper data by account numbers. \n\tExample: /path/to/com.nccgroup.principalmapper/\n\tExample: ./pmapperdata/") AWSCommands.AddCommand( diff --git a/globals/utils.go b/globals/utils.go index 8b9d2c2..d0020d6 100644 --- a/globals/utils.go +++ b/globals/utils.go @@ -4,4 +4,4 @@ const CLOUDFOX_USER_AGENT = "cloudfox" const CLOUDFOX_LOG_FILE_DIR_NAME = ".cloudfox" const CLOUDFOX_BASE_DIRECTORY = "cloudfox-output" const LOOT_DIRECTORY_NAME = "loot" -const CLOUDFOX_VERSION = "2.0.5" +const CLOUDFOX_VERSION = "2.0.6" diff --git a/internal/aws.go b/internal/aws.go index c240504..711aad9 100644 --- a/internal/aws.go +++ b/internal/aws.go @@ -7,10 +7,12 @@ import ( "encoding/json" "fmt" "io" + "net" "os" "path/filepath" "regexp" "strings" + "sync" "sync/atomic" "time" @@ -34,6 +36,23 @@ var ( UtilsFs = afero.NewOsFs() credsMap = map[string]aws.Credentials{} ConfigMap = map[string]aws.Config{} + + // MaxRetries controls the maximum number of retry attempts for AWS API calls. + // The default AWS SDK behavior is 3 attempts. Set to 0 for no retries, which + // is useful when running with limited-permission credentials to avoid slow + // timeouts on access-denied errors. + MaxRetries = 3 + + // unreachableRegions tracks regions that failed a TCP connectivity check so + // they can be skipped for the remainder of the run. Protected by a mutex + // since multiple goroutines may probe regions concurrently. + unreachableRegions = make(map[string]bool) + unreachableRegionsMu sync.RWMutex + + // RegionReachabilityTimeout controls how long the TCP probe waits before + // declaring a region unreachable. Default 3 seconds is enough for any + // healthy AWS endpoint. + RegionReachabilityTimeout = 3 * time.Second ) type CloudFoxRunData struct { @@ -132,7 +151,7 @@ func AWSConfigFileLoader(AWSProfile string, version string, AwsMfaToken string) if AwsMfaToken != "" { cfg, err = config.LoadDefaultConfig(context.TODO(), config.WithSharedConfigProfile(AWSProfile), config.WithDefaultRegion("us-east-1"), config.WithRetryer( func() aws.Retryer { - return retry.AddWithMaxAttempts(retry.NewStandard(), 3) + return retry.AddWithMaxAttempts(retry.NewStandard(), MaxRetries) }), config.WithAssumeRoleCredentialOptions(func(options *stscreds.AssumeRoleOptions) { options.TokenProvider = func() (string, error) { return AwsMfaToken, nil @@ -142,7 +161,7 @@ func AWSConfigFileLoader(AWSProfile string, version string, AwsMfaToken string) } else { cfg, err = config.LoadDefaultConfig(context.TODO(), config.WithSharedConfigProfile(AWSProfile), config.WithDefaultRegion("us-east-1"), config.WithRetryer( func() aws.Retryer { - return retry.AddWithMaxAttempts(retry.NewStandard(), 3) + return retry.AddWithMaxAttempts(retry.NewStandard(), MaxRetries) }), config.WithAssumeRoleCredentialOptions(func(options *stscreds.AssumeRoleOptions) { options.TokenProvider = stscreds.StdinTokenProvider }), @@ -210,6 +229,58 @@ func AWSWhoami(awsProfile string, version string, AwsMfaToken string) (*sts.GetC return CallerIdentity, err } +// isRegionReachable does a fast TCP dial to the STS endpoint in the given region. +// STS is available in every region and is a lightweight endpoint to probe. If the +// connection succeeds, the region is reachable. Results are cached so each region +// is only probed once per run. +func isRegionReachable(region string) bool { + unreachableRegionsMu.RLock() + alreadyUnreachable := unreachableRegions[region] + unreachableRegionsMu.RUnlock() + if alreadyUnreachable { + return false + } + + endpoint := fmt.Sprintf("sts.%s.amazonaws.com:443", region) + conn, err := net.DialTimeout("tcp", endpoint, RegionReachabilityTimeout) + if err != nil { + unreachableRegionsMu.Lock() + unreachableRegions[region] = true + unreachableRegionsMu.Unlock() + TxtLog.Warnf("Region %s is unreachable (probe to %s failed: %v) - skipping for this run", region, endpoint, err) + return false + } + conn.Close() + return true +} + +// FilterReachableRegions takes a list of regions and returns only the ones that +// pass a TCP connectivity probe. Unreachable regions are probed in parallel and +// cached so subsequent calls return immediately. +func FilterReachableRegions(regions []string) []string { + // Probe all regions concurrently for speed + type probeResult struct { + region string + reachable bool + } + resultsChan := make(chan probeResult, len(regions)) + + for _, region := range regions { + go func(r string) { + resultsChan <- probeResult{region: r, reachable: isRegionReachable(r)} + }(region) + } + + var reachableRegions []string + for range regions { + result := <-resultsChan + if result.reachable { + reachableRegions = append(reachableRegions, result.region) + } + } + return reachableRegions +} + func GetEnabledRegions(awsProfile string, version string, AwsMfaToken string) []string { cacheKey := fmt.Sprintf("GetEnabledRegions-%s", awsProfile) cached, found := Cache.Get(cacheKey) @@ -234,12 +305,15 @@ func GetEnabledRegions(awsProfile string, version string, AwsMfaToken string) [] if err != nil { TxtLog.Println(err) } - return AWSRegions + reachableRegions := FilterReachableRegions(AWSRegions) + Cache.Set(cacheKey, reachableRegions, cache.DefaultExpiration) + return reachableRegions } for _, region := range regions.Regions { enabledRegions = append(enabledRegions, *region.RegionName) } + enabledRegions = FilterReachableRegions(enabledRegions) Cache.Set(cacheKey, enabledRegions, cache.DefaultExpiration) return enabledRegions