Files
cloudfox/aws/workloads.go

561 lines
16 KiB
Go

package aws
import (
"context"
"fmt"
"path/filepath"
"sort"
"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/iam"
iamTypes "github.com/aws/aws-sdk-go-v2/service/iam/types"
"github.com/aws/aws-sdk-go-v2/service/sts"
"github.com/bishopfox/awsservicemap"
"github.com/sirupsen/logrus"
)
type WorkloadsModule struct {
// General configuration data
Caller sts.GetCallerIdentityOutput
AWSRegions []string
AWSProfile string
AWSOutputType string
AWSTableCols string
Goroutines int
WrapTable bool
SkipAdminCheck bool
// Service Clients
EC2Client sdk.AWSEC2ClientInterface
ECSClient sdk.AWSECSClientInterface
LambdaClient sdk.LambdaClientInterface
AppRunnerClient sdk.AppRunnerClientInterface
IAMClient sdk.AWSIAMClientInterface
//LightsailClient sdk.MockedLightsailClient
//SagemakerClient *sagemaker.Client
pmapperMod PmapperModule
pmapperError error
PmapperDataBasePath string
iamSimClient IamSimulatorModule
InstanceProfileToRolesMap map[string][]iamTypes.Role
ServiceMap *awsservicemap.AwsServiceMap // Shared service map to avoid repeated HTTP requests
// Main module data
Workloads []Workload
CommandCounter internal.CommandCounter
modLog *logrus.Entry
// Used to store output data for pretty printing
output internal.OutputData2
}
type Workload struct {
AWSService string
Region string
Type string
Name string
Arn string
Role string
Admin string
CanPrivEsc string
Public string
}
func (m *WorkloadsModule) PrintWorkloads(outputDirectory string, verbosity int) {
// These struct values are used by the output module
m.output.Verbosity = verbosity
m.output.Directory = outputDirectory
m.output.CallingModule = "workloads"
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 compute workloads in all regions for account %s.\n", cyan(m.output.CallingModule), cyan(m.AWSProfile), aws.ToString(m.Caller.Account))
fmt.Printf("[%s][%s] Supported Services: App Runner, EC2, ECS, Lambda \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)
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, "tasks")
//create a channel to receive the objects
dataReceiver := make(chan Workload)
// Create a channel to signal to stop
receiverDone := make(chan bool)
// Create a channel to signal to stop
go m.Receiver(dataReceiver, receiverDone)
// Get roles from instance profiles to use for EC2 instance to role lookups
m.getRolesFromInstanceProfiles()
for _, region := range m.AWSRegions {
wg.Add(1)
m.CommandCounter.Pending++
go m.executeChecks(region, wg, semaphore, dataReceiver)
}
wg.Wait()
// Perform role analysis
if m.pmapperError == nil {
for i := range m.Workloads {
m.Workloads[i].Admin, m.Workloads[i].CanPrivEsc = GetPmapperResults(m.SkipAdminCheck, m.pmapperMod, &m.Workloads[i].Role)
}
} else {
for i := range m.Workloads {
m.Workloads[i].Admin, m.Workloads[i].CanPrivEsc = GetIamSimResult(m.SkipAdminCheck, &m.Workloads[i].Role, m.iamSimClient, localAdminMap)
}
}
// Send a message to the spinner goroutine to close the channel and stop
spinnerDone <- true
<-spinnerDone
receiverDone <- true
<-receiverDone
// Table headers
m.output.Headers = []string{
"Account",
"Service",
"Region",
"Name",
"Arn",
"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, ",")
} else if m.AWSOutputType == "wide" {
tableCols = []string{
"Account",
"Service",
"Region",
"Arn",
"Role",
"IsAdminRole?",
"CanPrivEscToAdmin?",
}
// Otherwise, use the default columns.
} else {
tableCols = []string{
"Service",
"Region",
"Name",
"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?")
}
sort.Slice(m.Workloads, func(i, j int) bool {
return m.Workloads[i].AWSService < m.Workloads[j].AWSService
})
// Table rows
for i := range m.Workloads {
// If the role is an admin or can privesc to admin, make it magenta
if m.Workloads[i].Admin == "YES" || m.Workloads[i].CanPrivEsc == "YES" {
m.Workloads[i].AWSService = magenta(m.Workloads[i].AWSService)
m.Workloads[i].Region = magenta(m.Workloads[i].Region)
m.Workloads[i].Name = magenta(m.Workloads[i].Name)
m.Workloads[i].Role = magenta(m.Workloads[i].Role)
m.Workloads[i].Admin = magenta(m.Workloads[i].Admin)
m.Workloads[i].CanPrivEsc = magenta(m.Workloads[i].CanPrivEsc)
}
m.output.Body = append(
m.output.Body,
[]string{
aws.ToString(m.Caller.Account),
m.Workloads[i].AWSService,
m.Workloads[i].Region,
m.Workloads[i].Name,
m.Workloads[i].Arn,
m.Workloads[i].Role,
m.Workloads[i].Admin,
m.Workloads[i].CanPrivEsc,
},
)
}
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.PrefixIdentifier = m.AWSProfile
o.Table.DirectoryName = filepath.Join(outputDirectory, "cloudfox-output", "aws", fmt.Sprintf("%s-%s", m.AWSProfile, aws.ToString(m.Caller.Account)))
o.Table.TableFiles = append(o.Table.TableFiles, internal.TableFile{
Header: m.output.Headers,
Body: m.output.Body,
TableCols: tableCols,
Name: m.output.CallingModule,
})
// Create another table file that contains only the admin roles or the ones that can privesc to admin. Call another function to do this.
body := m.adminOnlyResults(m.output.Headers, m.output.Body)
o.Table.TableFiles = append(o.Table.TableFiles, internal.TableFile{
Header: m.output.Headers,
Body: body,
TableCols: tableCols,
Name: fmt.Sprintf("%s-admin", m.output.CallingModule),
SkipPrintToScreen: true,
})
o.WriteFullOutput(o.Table.TableFiles, nil)
fmt.Printf("[%s][%s] %s compute workloads found.\n", cyan(m.output.CallingModule), cyan(m.AWSProfile), strconv.Itoa(len(m.output.Body)))
} else {
fmt.Printf("[%s][%s] No compute workloads found, skipping the creation of an output file.\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)
}
func (m *WorkloadsModule) Receiver(receiver chan Workload, receiverDone chan bool) {
defer close(receiverDone)
for {
select {
case data := <-receiver:
m.Workloads = append(m.Workloads, data)
case <-receiverDone:
receiverDone <- true
return
}
}
}
func (m *WorkloadsModule) executeChecks(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan Workload) {
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, _ := servicemap.IsServiceInRegion("ec2", r)
if res {
m.CommandCounter.Total++
wg.Add(1)
go m.getEC2WorkloadsPerRegion(r, wg, semaphore, dataReceiver)
}
res, _ = servicemap.IsServiceInRegion("ecs", r)
if res {
m.CommandCounter.Total++
wg.Add(1)
go m.getECSWorkloadsPerRegion(r, wg, semaphore, dataReceiver)
}
res, err := servicemap.IsServiceInRegion("lambda", r)
if err != nil {
m.modLog.Error(err)
}
if res {
m.CommandCounter.Total++
wg.Add(1)
go m.getLambdaWorkloadsPerRegion(r, wg, semaphore, dataReceiver)
}
// AppRunner is not supported in the aws service region catalog so we have to run it in all regions
m.CommandCounter.Total++
wg.Add(1)
go m.getAppRunnerWorkloadsPerRegion(r, wg, semaphore, dataReceiver)
}
func (m *WorkloadsModule) getEC2WorkloadsPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan Workload) {
defer func() {
m.CommandCounter.Executing--
m.CommandCounter.Complete++
wg.Done()
}()
semaphore <- struct{}{}
defer func() {
<-semaphore
}()
// Get EC2 instances
ec2Instances, err := sdk.CachedEC2DescribeInstances(m.EC2Client, aws.ToString(m.Caller.Account), r)
if err != nil {
m.modLog.Error(err)
}
for _, instance := range ec2Instances {
var profileArn, profileName, name string
// The name is in a tag so we have to do this to grab the value from the right tag
for _, tag := range instance.Tags {
if *tag.Key == "Name" {
name = *tag.Value
}
}
if name == "" {
name = aws.ToString(instance.InstanceId)
}
if instance.IamInstanceProfile != nil {
profileArn = aws.ToString(instance.IamInstanceProfile.Arn)
// Extracting instance profile name from ARN
profileName = strings.Split(profileArn, "/")[len(strings.Split(profileArn, "/"))-1]
// Describe the IAM instance profile
profileOutput, err := sdk.CachedIamGetInstanceProfile(m.IAMClient, aws.ToString(m.Caller.Account), profileName)
if err != nil {
m.modLog.Errorf("failed to get instance profile for %s, %v", profileArn, err)
continue
}
for _, role := range profileOutput.Roles {
dataReceiver <- Workload{
AWSService: "EC2",
Region: r,
Type: "instance",
Name: name,
Arn: fmt.Sprintf("arn:aws:ec2:%s:%s:instance/%s", r, aws.ToString(m.Caller.Account), aws.ToString(instance.InstanceId)),
Role: aws.ToString(role.Arn),
}
}
}
}
}
func (m *WorkloadsModule) getECSWorkloadsPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan Workload) {
defer func() {
m.CommandCounter.Executing--
m.CommandCounter.Complete++
wg.Done()
}()
semaphore <- struct{}{}
defer func() {
<-semaphore
}()
// Get ECS clusters
ecsClusters, err := sdk.CachedECSListClusters(m.ECSClient, aws.ToString(m.Caller.Account), r)
if err != nil {
m.modLog.Error(err)
}
for _, cluster := range ecsClusters {
// Get ECS tasks
taskARNs, err := sdk.CachedECSListTasks(m.ECSClient, aws.ToString(m.Caller.Account), r, cluster)
if err != nil {
m.modLog.Error(err)
}
Tasks, err := sdk.CachedECSDescribeTasks(m.ECSClient, aws.ToString(m.Caller.Account), r, cluster, taskARNs)
if err != nil {
m.modLog.Error(err.Error())
m.CommandCounter.Error++
return
}
for _, task := range Tasks {
var role string
// Get ECS task definition
ecsTaskDefinition, err := sdk.CachedECSDescribeTaskDefinition(m.ECSClient, aws.ToString(m.Caller.Account), r, aws.ToString(task.TaskDefinitionArn))
if err != nil {
m.modLog.Error(err)
}
if ecsTaskDefinition.TaskRoleArn != nil {
role = aws.ToString(ecsTaskDefinition.TaskRoleArn)
}
dataReceiver <- Workload{
AWSService: "ECS",
Region: r,
Type: "task",
Name: getNameFromARN(aws.ToString(task.TaskDefinitionArn)),
Arn: aws.ToString(task.TaskArn),
Role: role,
}
}
}
}
func (m *WorkloadsModule) getLambdaWorkloadsPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan Workload) {
defer func() {
m.CommandCounter.Executing--
m.CommandCounter.Complete++
wg.Done()
}()
semaphore <- struct{}{}
defer func() {
<-semaphore
}()
// Get Lambda functions
lambdaFunctions, err := sdk.CachedLambdaListFunctions(m.LambdaClient, aws.ToString(m.Caller.Account), r)
if err != nil {
m.modLog.Error(err)
}
for _, function := range lambdaFunctions {
//var role string
// if function.Role != nil {
// role = aws.ToString(function.Role)
// }
dataReceiver <- Workload{
AWSService: "Lambda",
Region: r,
Type: "function",
Name: aws.ToString(function.FunctionName),
Arn: aws.ToString(function.FunctionArn),
Role: aws.ToString(function.Role),
}
}
}
func (m *WorkloadsModule) getAppRunnerWorkloadsPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan Workload) {
defer func() {
m.CommandCounter.Executing--
m.CommandCounter.Complete++
wg.Done()
}()
semaphore <- struct{}{}
defer func() {
<-semaphore
}()
// Get App Runner services
appRunnerServices, err := sdk.CachedAppRunnerListServices(m.AppRunnerClient, aws.ToString(m.Caller.Account), r)
if err != nil {
m.modLog.Error(err)
}
for _, service := range appRunnerServices {
var role string
if service.ServiceArn != nil {
role = aws.ToString(service.ServiceArn)
}
dataReceiver <- Workload{
AWSService: "App Runner",
Region: r,
Type: "service",
Name: aws.ToString(service.ServiceName),
Arn: aws.ToString(service.ServiceArn),
Role: role,
}
}
}
func (m *WorkloadsModule) adminOnlyResults(headers []string, body [][]string) [][]string {
var adminOnlyBody [][]string
var adminOnlyHeaders []string
var adminOnlyTableCols []string
var adminColIndex int
var canPrivEscColIndex int
// First find which col is the admin col and which is the can privesc col and simply note which index they are in. Then we can use those indexes to see if the value in the body is "YES".
// For any row where isAdmin or CanPrivEsc is "YES", we will append that row to the adminOnlyBody.
// Find the index of the admin col
for i, header := range headers {
if header == "IsAdminRole?" {
adminOnlyHeaders = append(adminOnlyHeaders, header)
adminOnlyTableCols = append(adminOnlyTableCols, header)
adminColIndex = i
}
}
// Find the index of the can privesc col
for i, header := range headers {
if header == "CanPrivEscToAdmin?" {
adminOnlyHeaders = append(adminOnlyHeaders, header)
adminOnlyTableCols = append(adminOnlyTableCols, header)
canPrivEscColIndex = i
}
}
// Now that we have the indexes of the admin and can privesc cols, we can iterate through the body and append the rows where isAdmin or CanPrivEsc is "YES" to the adminOnlyBody
for _, row := range body {
if row[adminColIndex] == "YES" || row[canPrivEscColIndex] == "YES" {
adminOnlyBody = append(adminOnlyBody, row)
}
}
return adminOnlyBody
}
func (m *WorkloadsModule) getRolesFromInstanceProfiles() {
// The "PaginationControl" value is nil when there's no more data to return.
var PaginationMarker *string
PaginationControl := true
m.InstanceProfileToRolesMap = map[string][]iamTypes.Role{}
for PaginationControl {
ListInstanceProfiles, err := m.IAMClient.ListInstanceProfiles(
context.TODO(),
&(iam.ListInstanceProfilesInput{
Marker: PaginationMarker,
}),
)
if err != nil {
m.modLog.Error(err.Error())
m.CommandCounter.Error++
break
}
for _, instanceProfile := range ListInstanceProfiles.InstanceProfiles {
m.InstanceProfileToRolesMap[aws.ToString(instanceProfile.InstanceProfileId)] = instanceProfile.Roles
}
if aws.ToString(ListInstanceProfiles.Marker) != "" {
PaginationMarker = ListInstanceProfiles.Marker
} else {
PaginationMarker = nil
break
}
}
}