Fix region slowness and hangs with opt-in/unreachable regions

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
This commit is contained in:
Seth Art committed 2026-06-17 14:29:10 -04:00
1 parent ba4ff4701a
commit 29ab782b31
4 files changed
+86 -13

No files matched your search

+1 -7
View File
@@ -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"
+7 -2
View File
@@ -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(
+1 -1
View File
@@ -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"
+77 -3
View File
@@ -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