-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
3 changed files
with
258 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,39 @@ | ||
# okta-mfa-prompt-bombing | ||
|
||
Utility script to send Okta Verify MFA prompts to users to simulate MFA prompt bombing | ||
|
||
Users should not confirm prompts unless they were the ones logging in. | ||
|
||
|
||
|
||
## Usage | ||
|
||
Populate the `.env` file with an Okta API token and specify your Okta Org. | ||
|
||
The `OKTA_QUERY` variable sets the query filter to select the users to target. For testing purposes set a single email address. If empty, all users enrolled with Okta Verify push in the Okta org will be targeted. | ||
|
||
## Behavior | ||
|
||
Users are prompted in parallel, max. 10 at a time to avoid breaching API throttling limits on the Okta side. | ||
The script will poll wether the user confirmed or rejected the MFA prompt. Prompts will timeout after 5 minutes if unanswerd (Okta defaults). | ||
|
||
The script will print some basic statistics about the scenario in the end (how many users confirmed, rejected etc.) but I recommend to capture the runtime output so you can run your own analysis e.g. | ||
|
||
`go run prompt-bombing.go 2>&1| tee prompt_bombing.logs` | ||
|
||
### What is the purpose of Multifactor Authentication (MFA)? | ||
|
||
Even if your user account is protected with a strong password, a successful phishing attack or stolen credential can leave you vulnerable. MFA is a core defense preventing account takeovers. In general, accounts using MFA are more secure, since an attacker must compromise both the password and verification method to access your account. If an attacker has access to one, but not both, they will remain locked out. | ||
Recent breaches show that MFA isn’t much of a hurdle for some hackers to clear. | ||
|
||
### MFA prompt bombing | ||
|
||
Once attackers gain access to a valid password they start issuing multiple MFA requests to the end user’s phone until the user accepts, resulting in unauthorized access to the account. | ||
|
||
Methods include: | ||
* Sending multiple MFA requests and hoping the user finally accepts one to make it stop (MFA fatigue). | ||
* Calling the user, pretending to be part of the company, and telling them they need to send an MFA request as part of a company process. | ||
* Paying employees for passwords and MFA approval | ||
|
||
|
||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
OKTA_DOMAIN={yourOrg}.okta.com | ||
OKTA_API_TOKEN={yourAPI token} | ||
# EXAMPLE: Filter users you want to send MFA prompts to based on profile attributes e.g. company, country code, email address etc | ||
# OKTA_QUERY=profile.Company eq \"{Company Name}\" AND status eq \"ACTIVE\"" | ||
OKTA_QUERY="profile.email eq \"{user Email address}\" AND status eq \"ACTIVE\"" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,214 @@ | ||
package main | ||
|
||
import ( | ||
"context" | ||
"fmt" | ||
"log" | ||
"net/url" | ||
"os" | ||
"strings" | ||
"sync" | ||
"time" | ||
|
||
"github.com/joho/godotenv" | ||
"github.com/mitchellh/mapstructure" | ||
"github.com/okta/okta-sdk-golang/v2/okta" | ||
"github.com/okta/okta-sdk-golang/v2/okta/query" | ||
) | ||
|
||
var ( | ||
locCounter = make(map[string]float64) | ||
myMapMutex = sync.RWMutex{} | ||
) | ||
|
||
func filterUsers(ctx context.Context, client *okta.Client, filterString string) []*okta.User { | ||
filter := query.NewQueryParams(query.WithSearch(filterString)) | ||
|
||
totalUserSet, resp, err := client.User.ListUsers(ctx, filter) | ||
if err != nil { | ||
fmt.Printf("Error Getting Users: %v\n", err) | ||
} | ||
|
||
fmt.Printf("%v\n", resp) | ||
count := 0 | ||
for resp.HasNextPage() { | ||
|
||
count += 1 | ||
fmt.Printf("Entering %v time as request %+v\n", count, *resp) | ||
var nextUserSet []*okta.User | ||
newResp, err := resp.Next(ctx, &nextUserSet) | ||
if err != nil { | ||
fmt.Printf("Error Getting next Page: %v\n", err) | ||
} | ||
totalUserSet = append(totalUserSet, nextUserSet...) | ||
resp = newResp | ||
} | ||
|
||
return totalUserSet | ||
} | ||
|
||
func processUser(ctx context.Context, client *okta.Client, orgURL *url.URL, user *okta.User) error { | ||
|
||
time.Sleep(1 * time.Second) | ||
userEmail := (*user.Profile)["email"].(string) | ||
countryCode, ok := (*user.Profile)["countryCode"].(string) | ||
if !ok { | ||
fmt.Println("Country Code not set for user") | ||
countryCode = "UNKNOWN" | ||
} | ||
|
||
myMapMutex.Lock() | ||
locCounter[countryCode+" Total Users"] += 1 | ||
myMapMutex.Unlock() | ||
|
||
factors, _, err := client.UserFactor.ListFactors(ctx, user.Id) | ||
if err != nil { | ||
fmt.Printf("List Factors Error: %s\n", err) | ||
return err | ||
} | ||
|
||
if len(factors) == 0 { | ||
log.Printf("no MFA factors found [%s] for user %s\n", countryCode, userEmail) | ||
return nil | ||
} | ||
|
||
var factorFound bool | ||
var userFactor *okta.UserFactor | ||
for _, factor := range factors { | ||
if factor.IsUserFactorInstance() { | ||
userFactor = factor.(*okta.UserFactor) | ||
if userFactor.FactorType == "push" { | ||
factorFound = true | ||
log.Printf("Found Okta Verify push [%s] for user %s\n", countryCode, userEmail) | ||
myMapMutex.Lock() | ||
locCounter[countryCode+" PUSH ENROLLED"] += 1 | ||
myMapMutex.Unlock() | ||
break | ||
} | ||
} | ||
} | ||
|
||
if !factorFound { | ||
fmt.Printf("no push-type MFA factor found for user %s\n", userEmail) | ||
return nil | ||
} | ||
|
||
result, _, err := client.UserFactor.VerifyFactor(ctx, user.Id, userFactor.Id, okta.VerifyFactorRequest{}, userFactor, nil) | ||
if err != nil { | ||
return err | ||
} | ||
|
||
if result.FactorResult != "WAITING" { | ||
return fmt.Errorf("expected WAITING status for push status, got %q", result.FactorResult) | ||
} | ||
|
||
// Parse links to get polling link | ||
type linksObj struct { | ||
Poll struct { | ||
Href string `mapstructure:"href"` | ||
} `mapstructure:"poll"` | ||
} | ||
links := new(linksObj) | ||
if err := mapstructure.WeakDecode(result.Links, links); err != nil { | ||
return err | ||
} | ||
// Strip the org URL from the fully qualified poll URL | ||
url, err := url.Parse(strings.Replace(links.Poll.Href, orgURL.String(), "", 1)) | ||
if err != nil { | ||
return err | ||
} | ||
start := time.Now() | ||
// Code to measure | ||
for { | ||
|
||
rq := client.CloneRequestExecutor() | ||
req, err := rq.WithAccept("application/json").WithContentType("application/json").NewRequest("GET", url.String(), nil) | ||
if err != nil { | ||
return err | ||
} | ||
var result *okta.VerifyUserFactorResponse | ||
_, err = rq.Do(ctx, req, &result) | ||
if err != nil { | ||
return err | ||
} | ||
|
||
switch result.FactorResult { | ||
case "WAITING": | ||
case "SUCCESS": | ||
log.Printf("%s confirmed Push\n", userEmail) | ||
locCounter[countryCode+" CONFIRMED PUSH"] += 1 | ||
return nil | ||
case "REJECTED": | ||
log.Printf("%s rejected Push\n", userEmail) | ||
locCounter[countryCode+" REJECTED PUSH"] += 1 | ||
return fmt.Errorf("push verification explicitly rejected") | ||
|
||
case "TIMEOUT": | ||
duration := time.Since(start) | ||
fmt.Printf("Push for %s timed out after %v\n", userEmail, duration) | ||
locCounter[countryCode+" TIMEOUT PUSH"] += 1 | ||
return fmt.Errorf("push verification timed out") | ||
|
||
default: | ||
return fmt.Errorf("unknown status code") | ||
} | ||
|
||
select { | ||
case <-ctx.Done(): | ||
return fmt.Errorf("push verification operation canceled") | ||
case <-time.After(3 * time.Second): | ||
} | ||
} | ||
} | ||
|
||
func run(ctx context.Context) error { | ||
|
||
err := godotenv.Load() | ||
if err != nil { | ||
log.Fatal("Error loading .env file") | ||
} | ||
|
||
token := os.Getenv("OKTA_API_TOKEN") | ||
oktaDomain := os.Getenv("OKTA_DOMAIN") | ||
filterQuery := os.Getenv("OKTA_QUERY") | ||
fmt.Printf("User selection based on: %s", filterQuery) | ||
|
||
orgURL, err := url.Parse(fmt.Sprintf("https://%s", oktaDomain)) | ||
if err != nil { | ||
return err | ||
} | ||
|
||
ctx, client, err := okta.NewClient(ctx, | ||
okta.WithToken(token), | ||
okta.WithOrgUrl(orgURL.String()), | ||
okta.WithCache(false), | ||
) | ||
if err != nil { | ||
return fmt.Errorf("error creating client: %s", err) | ||
} | ||
|
||
filteredUsers := filterUsers(ctx, client, filterQuery) | ||
|
||
var wg = sync.WaitGroup{} | ||
// How many users to prompt in parallel | ||
maxGoroutines := 10 | ||
guard := make(chan struct{}, maxGoroutines) | ||
|
||
for index, user := range filteredUsers { | ||
guard <- struct{}{} | ||
wg.Add(1) | ||
go func(n int, user *okta.User) { | ||
log.Printf("Processing user %v ", n) | ||
processUser(ctx, client, orgURL, user) | ||
<-guard | ||
wg.Done() | ||
}(index, user) | ||
} | ||
wg.Wait() | ||
return nil | ||
} | ||
|
||
func main() { | ||
run(context.TODO()) | ||
fmt.Println(locCounter) | ||
} |