mirror of
https://github.com/BishopFox/cloudfox.git
synced 2026-11-08 17:07:29 -10:00
414 lines
13 KiB
Go
414 lines
13 KiB
Go
package aws
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/BishopFox/cloudfox/aws/sdk"
|
|
"github.com/BishopFox/cloudfox/internal"
|
|
"github.com/aws/aws-sdk-go-v2/aws"
|
|
"github.com/aws/aws-sdk-go-v2/service/sts"
|
|
"github.com/bishopfox/awsservicemap"
|
|
"github.com/sirupsen/logrus"
|
|
)
|
|
|
|
type EKSModule struct {
|
|
// General configuration data
|
|
// These interfaces are used for unit testing
|
|
EKSClient sdk.EKSClientInterface
|
|
IAMClient sdk.AWSIAMClientInterface
|
|
|
|
Caller sts.GetCallerIdentityOutput
|
|
AWSRegions []string
|
|
AWSOutputType string
|
|
AWSTableCols string
|
|
PmapperDataBasePath string
|
|
|
|
Goroutines int
|
|
AWSProfile string
|
|
SkipAdminCheck bool
|
|
WrapTable bool
|
|
ServiceMap *awsservicemap.AwsServiceMap // Shared service map to avoid repeated HTTP requests
|
|
pmapperMod PmapperModule
|
|
pmapperError error
|
|
iamSimClient IamSimulatorModule
|
|
// Main module data
|
|
Clusters []Cluster
|
|
CommandCounter internal.CommandCounter
|
|
// Used to store output data for pretty printing
|
|
output internal.OutputData2
|
|
modLog *logrus.Entry
|
|
}
|
|
|
|
type Cluster struct {
|
|
AWSService string
|
|
Region string
|
|
Name string
|
|
Endpoint string
|
|
Public string
|
|
OIDC string
|
|
NodeGroup string
|
|
Role string
|
|
Admin string
|
|
CanPrivEsc string
|
|
}
|
|
|
|
func (m *EKSModule) EKS(outputDirectory string, verbosity int) {
|
|
// These struct values are used by the output module
|
|
m.output.Verbosity = verbosity
|
|
m.output.Directory = outputDirectory
|
|
m.output.CallingModule = "eks"
|
|
localAdminMap := make(map[string]bool)
|
|
|
|
m.modLog = internal.TxtLog.WithFields(logrus.Fields{
|
|
"module": m.output.CallingModule,
|
|
})
|
|
if m.AWSProfile == "" {
|
|
m.AWSProfile = internal.BuildAWSPath(m.Caller)
|
|
}
|
|
|
|
fmt.Printf("[%s][%s] Enumerating EKS clusters for account %s.\n", cyan(m.output.CallingModule), cyan(m.AWSProfile), aws.ToString(m.Caller.Account))
|
|
// Initialized the tools we'll need to check if any workload roles are admin or can privesc to admin
|
|
//fmt.Printf("[%s][%s] Attempting to build a PrivEsc graph in memory using local pmapper data if it exists on the filesystem.\n", cyan(m.output.CallingModule), cyan(m.AWSProfile))
|
|
m.pmapperMod, m.pmapperError = InitPmapperGraph(m.Caller, m.AWSProfile, m.Goroutines, m.PmapperDataBasePath)
|
|
m.iamSimClient = InitIamCommandClient(m.IAMClient, m.Caller, m.AWSProfile, m.Goroutines)
|
|
|
|
// if m.pmapperError != nil {
|
|
// fmt.Printf("[%s][%s] No pmapper data found for this account. Using cloudfox's iam-simulator for role analysis.\n", cyan(m.output.CallingModule), cyan(m.AWSProfile))
|
|
// } else {
|
|
// fmt.Printf("[%s][%s] Found pmapper data for this account. Using it for role analysis.\n", cyan(m.output.CallingModule), cyan(m.AWSProfile))
|
|
// }
|
|
fmt.Printf("[%s][%s] For context and next steps: https://github.com/BishopFox/cloudfox/wiki/AWS-Commands#%s\n", cyan(m.output.CallingModule), cyan(m.AWSProfile), m.output.CallingModule)
|
|
|
|
wg := new(sync.WaitGroup)
|
|
semaphore := make(chan struct{}, m.Goroutines)
|
|
|
|
// Create a channel to signal the spinner aka task status goroutine to finish
|
|
spinnerDone := make(chan bool)
|
|
//fire up the the task status spinner/updated
|
|
go internal.SpinUntil(m.output.CallingModule, &m.CommandCounter, spinnerDone, "regions")
|
|
|
|
//create a channel to receive the objects
|
|
dataReceiver := make(chan Cluster)
|
|
|
|
// Create a channel to signal to stop
|
|
receiverDone := make(chan bool)
|
|
|
|
go m.Receiver(dataReceiver, receiverDone)
|
|
|
|
for _, region := range m.AWSRegions {
|
|
wg.Add(1)
|
|
m.CommandCounter.Pending++
|
|
go m.executeChecks(region, wg, semaphore, dataReceiver)
|
|
|
|
}
|
|
|
|
wg.Wait()
|
|
//time.Sleep(time.Second * 2)
|
|
|
|
// Send a message to the spinner goroutine to close the channel and stop
|
|
spinnerDone <- true
|
|
<-spinnerDone
|
|
receiverDone <- true
|
|
<-receiverDone
|
|
|
|
// Perform role analysis
|
|
if m.pmapperError == nil {
|
|
for i := range m.Clusters {
|
|
m.Clusters[i].Admin, m.Clusters[i].CanPrivEsc = GetPmapperResults(m.SkipAdminCheck, m.pmapperMod, &m.Clusters[i].Role)
|
|
}
|
|
} else {
|
|
for i := range m.Clusters {
|
|
m.Clusters[i].Admin, m.Clusters[i].CanPrivEsc = GetIamSimResult(m.SkipAdminCheck, &m.Clusters[i].Role, m.iamSimClient, localAdminMap)
|
|
}
|
|
}
|
|
|
|
// This is the complete list of potential table columns
|
|
m.output.Headers = []string{
|
|
"Account",
|
|
"Region",
|
|
"Name",
|
|
//"Endpoint",
|
|
"Public",
|
|
//"OIDC",
|
|
"NodeGroup",
|
|
"Role",
|
|
"IsAdminRole?",
|
|
"CanPrivEscToAdmin?",
|
|
}
|
|
|
|
// If the user specified table columns, use those.
|
|
// If the user specified -o wide, use the wide default cols for this module.
|
|
// Otherwise, use the hardcoded default cols for this module.
|
|
var tableCols []string
|
|
// If the user specified table columns, use those.
|
|
if m.AWSTableCols != "" {
|
|
// If the user specified wide as the output format, use these columns.
|
|
// remove any spaces between any commas and the first letter after the commas
|
|
m.AWSTableCols = strings.ReplaceAll(m.AWSTableCols, ", ", ",")
|
|
m.AWSTableCols = strings.ReplaceAll(m.AWSTableCols, ", ", ",")
|
|
tableCols = strings.Split(m.AWSTableCols, ",")
|
|
// If the user specified wide as the output format, use these columns.
|
|
} else if m.AWSOutputType == "wide" {
|
|
tableCols = []string{
|
|
"Account",
|
|
"Region",
|
|
"Name",
|
|
"Endpoint",
|
|
"Public",
|
|
"OIDC",
|
|
"NodeGroup",
|
|
"Role",
|
|
"IsAdminRole?",
|
|
"CanPrivEscToAdmin?",
|
|
}
|
|
// Otherwise, use the default columns.
|
|
} else {
|
|
tableCols = []string{
|
|
"Region",
|
|
"Name",
|
|
//"Endpoint",
|
|
"Public",
|
|
//"OIDC",
|
|
"NodeGroup",
|
|
"Role",
|
|
"IsAdminRole?",
|
|
"CanPrivEscToAdmin?",
|
|
}
|
|
}
|
|
|
|
// Remove the pmapper row if there is no pmapper data
|
|
if m.pmapperError != nil {
|
|
sharedLogger.Errorf("%s - %s - No pmapper data found for this account. Skipping the pmapper column in the output table.", m.output.CallingModule, m.AWSProfile)
|
|
tableCols = removeStringFromSlice(tableCols, "CanPrivEscToAdmin?")
|
|
}
|
|
|
|
// Table rows
|
|
|
|
for i := range m.Clusters {
|
|
m.output.Body = append(
|
|
m.output.Body,
|
|
[]string{
|
|
aws.ToString(m.Caller.Account),
|
|
m.Clusters[i].Region,
|
|
m.Clusters[i].Name,
|
|
//m.Clusters[i].Endpoint,
|
|
m.Clusters[i].Public,
|
|
//m.Clusters[i].OIDC,
|
|
m.Clusters[i].NodeGroup,
|
|
m.Clusters[i].Role,
|
|
m.Clusters[i].Admin,
|
|
m.Clusters[i].CanPrivEsc,
|
|
},
|
|
)
|
|
}
|
|
|
|
var seen []string
|
|
for _, cluster := range m.Clusters {
|
|
if !internal.Contains(cluster.Name, seen) {
|
|
seen = append(seen, cluster.Name)
|
|
}
|
|
}
|
|
|
|
if len(m.output.Body) > 0 {
|
|
m.output.FilePath = filepath.Join(outputDirectory, "cloudfox-output", "aws", fmt.Sprintf("%s-%s", m.AWSProfile, aws.ToString(m.Caller.Account)))
|
|
o := internal.OutputClient{
|
|
Verbosity: verbosity,
|
|
CallingModule: m.output.CallingModule,
|
|
Table: internal.TableClient{
|
|
Wrap: m.WrapTable,
|
|
},
|
|
}
|
|
o.Table.TableFiles = append(o.Table.TableFiles, internal.TableFile{
|
|
Header: m.output.Headers,
|
|
Body: m.output.Body,
|
|
TableCols: tableCols,
|
|
Name: m.output.CallingModule,
|
|
})
|
|
o.PrefixIdentifier = m.AWSProfile
|
|
o.Table.DirectoryName = filepath.Join(outputDirectory, "cloudfox-output", "aws", fmt.Sprintf("%s-%s", m.AWSProfile, aws.ToString(m.Caller.Account)))
|
|
o.WriteFullOutput(o.Table.TableFiles, nil)
|
|
m.writeLoot(o.Table.DirectoryName, verbosity)
|
|
fmt.Printf("[%s][%s] %d clusters with a total of %d node groups found.\n", cyan(m.output.CallingModule), cyan(m.AWSProfile), len(seen), len(m.output.Body))
|
|
} else {
|
|
fmt.Printf("[%s][%s] No clusters found, skipping the creation of an output file.\n", cyan(m.output.CallingModule), cyan(m.AWSProfile))
|
|
}
|
|
|
|
}
|
|
|
|
func (m *EKSModule) executeChecks(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan Cluster) {
|
|
defer wg.Done()
|
|
|
|
// Use shared ServiceMap instance if provided, otherwise create a new one
|
|
servicemap := m.ServiceMap
|
|
if servicemap == nil {
|
|
servicemap = &awsservicemap.AwsServiceMap{
|
|
JsonFileSource: "DOWNLOAD_FROM_AWS",
|
|
}
|
|
}
|
|
res, err := servicemap.IsServiceInRegion("eks", r)
|
|
if err != nil {
|
|
m.modLog.Error(err)
|
|
}
|
|
if res {
|
|
m.CommandCounter.Total++
|
|
wg.Add(1)
|
|
m.getEKSRecordsPerRegion(r, wg, semaphore, dataReceiver)
|
|
}
|
|
}
|
|
|
|
func (m *EKSModule) Receiver(receiver chan Cluster, receiverDone chan bool) {
|
|
defer close(receiverDone)
|
|
for {
|
|
select {
|
|
case data := <-receiver:
|
|
m.Clusters = append(m.Clusters, data)
|
|
case <-receiverDone:
|
|
receiverDone <- true
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *EKSModule) writeLoot(outputDirectory string, verbosity int) {
|
|
path := filepath.Join(outputDirectory, "loot")
|
|
err := os.MkdirAll(path, os.ModePerm)
|
|
if err != nil {
|
|
m.modLog.Error(err.Error())
|
|
m.CommandCounter.Error++
|
|
}
|
|
pullFile := filepath.Join(path, "eks-kubeconfig-commands.txt")
|
|
|
|
var out string
|
|
out = out + fmt.Sprintln("#############################################")
|
|
out = out + fmt.Sprintln("# The profile you will use to perform these commands is most likely not the profile you used to run CloudFox")
|
|
out = out + fmt.Sprintln("# Set the $profile environment variable to the profile you are going to use to inspect the repositories.")
|
|
out = out + fmt.Sprintln("# E.g., export profile=found_creds")
|
|
out = out + fmt.Sprintln("#############################################")
|
|
out = out + fmt.Sprintln("")
|
|
|
|
var seen []string
|
|
for _, cluster := range m.Clusters {
|
|
|
|
if !internal.Contains(cluster.Name, seen) {
|
|
out = out + fmt.Sprintf("aws --profile $profile --region %s eks update-kubeconfig --name %s\n", cluster.Region, cluster.Name)
|
|
seen = append(seen, cluster.Name)
|
|
}
|
|
|
|
}
|
|
err = os.WriteFile(pullFile, []byte(out), 0644)
|
|
if err != nil {
|
|
m.modLog.Error(err.Error())
|
|
m.CommandCounter.Error++
|
|
}
|
|
|
|
if verbosity > 2 {
|
|
fmt.Println()
|
|
fmt.Printf("[%s][%s] %s \n", cyan(m.output.CallingModule), cyan(m.AWSProfile), green("Use the commands below to authenticate to EKS and set up your kubeconfig"))
|
|
fmt.Printf("[%s][%s] %s \n\n", cyan(m.output.CallingModule), cyan(m.AWSProfile), green("Note: Just because you have the eks:updatekubeconfig permission, this does not"))
|
|
fmt.Printf("[%s][%s] %s \n\n", cyan(m.output.CallingModule), cyan(m.AWSProfile), green(" mean your IAM user has permissions in the cluster."))
|
|
|
|
fmt.Print(out)
|
|
fmt.Printf("[%s][%s] %s \n\n", cyan(m.output.CallingModule), cyan(m.AWSProfile), green("End of loot file."))
|
|
}
|
|
|
|
fmt.Printf("[%s][%s] Loot written to [%s]\n", cyan(m.output.CallingModule), cyan(m.AWSProfile), pullFile)
|
|
|
|
}
|
|
|
|
func (m *EKSModule) getEKSRecordsPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan Cluster) {
|
|
defer func() {
|
|
m.CommandCounter.Executing--
|
|
m.CommandCounter.Complete++
|
|
wg.Done()
|
|
|
|
}()
|
|
semaphore <- struct{}{}
|
|
defer func() {
|
|
<-semaphore
|
|
}()
|
|
var clusters []string
|
|
|
|
clusters, err := sdk.CachedEKSListClusters(m.EKSClient, aws.ToString(m.Caller.Account), r)
|
|
if err != nil {
|
|
m.modLog.Error(err.Error())
|
|
m.CommandCounter.Error++
|
|
return
|
|
}
|
|
|
|
for _, clusterName := range clusters {
|
|
var role string
|
|
var oidc string = ""
|
|
var publicEndpoint = ""
|
|
clusterDetails, err := sdk.CachedEKSDescribeCluster(m.EKSClient, aws.ToString(m.Caller.Account), clusterName, r)
|
|
if err != nil {
|
|
m.modLog.Error(err.Error())
|
|
m.CommandCounter.Error++
|
|
}
|
|
|
|
//nodeGroups = append(nodeGroups, DescribeCluster.Cluster.)
|
|
endpoint := aws.ToString(clusterDetails.Endpoint)
|
|
if clusterDetails.Identity != nil && clusterDetails.Identity.Oidc != nil {
|
|
oidc = aws.ToString(clusterDetails.Identity.Oidc.Issuer)
|
|
|
|
}
|
|
if clusterDetails.ResourcesVpcConfig != nil {
|
|
publicEndpoint = strconv.FormatBool(clusterDetails.ResourcesVpcConfig.EndpointPublicAccess)
|
|
}
|
|
// if DescribeCluster.Cluster.ResourcesVpcConfig.PublicAccessCidrs[0] == "0.0.0.0/0" {
|
|
// publicCIDRs := "0.0.0.0/0"
|
|
// } else {
|
|
// publicCIDRs := "specific IPs"
|
|
// }
|
|
|
|
ListNodeGroups, err := sdk.CachedEKSListNodeGroups(m.EKSClient, aws.ToString(m.Caller.Account), clusterName, r)
|
|
|
|
if len(ListNodeGroups) > 0 {
|
|
for _, nodeGroup := range ListNodeGroups {
|
|
|
|
nodeGroupDetails, err := sdk.CachedEKSDescribeNodeGroup(m.EKSClient, aws.ToString(m.Caller.Account), clusterName, nodeGroup, r)
|
|
if err != nil {
|
|
m.modLog.Error(err.Error())
|
|
m.CommandCounter.Error++
|
|
}
|
|
|
|
role = aws.ToString(nodeGroupDetails.NodeRole)
|
|
|
|
dataReceiver <- Cluster{
|
|
AWSService: "EKS",
|
|
Name: clusterName,
|
|
Region: r,
|
|
Endpoint: endpoint,
|
|
Public: publicEndpoint,
|
|
OIDC: oidc,
|
|
NodeGroup: nodeGroup,
|
|
Role: role,
|
|
Admin: "",
|
|
CanPrivEsc: "",
|
|
}
|
|
}
|
|
} else {
|
|
role = aws.ToString(clusterDetails.RoleArn)
|
|
dataReceiver <- Cluster{
|
|
AWSService: "EKS",
|
|
Name: clusterName,
|
|
Region: r,
|
|
Endpoint: endpoint,
|
|
Public: publicEndpoint,
|
|
OIDC: oidc,
|
|
NodeGroup: "N/A",
|
|
Role: role,
|
|
Admin: "",
|
|
CanPrivEsc: "",
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|