Mar 6 2024 |
Browserless Entra Device Code Flow
Did you know that it is possible to perform every step in Entra’s OAuth 2.0 Device Code flow — including the user authentication steps — without a browser?
Why that matters:
- Automating authentication flows enables and accelerates comprehensive and ongoing offensive research
- Headless authentication frees red teamers and pentesters from requiring browser or cookie access
- Demonstrating and explaining the automated flow enables future research and tooling by other parties, including automation of other flows
Yes, but:
- These systems change. While this automation works today, slight changes in the future may require updates to the code in this blog post.
- This code does not support any sort of MFA challenge a user may be subjected to during authentication.
Automating the Flow
Automating device code flow requires five requests:
Request One: POST to https://login.microsoftonline.com/common/oauth2/devicecode?api-version=1.0
In this request, the application initiates the flow and receives a “user code” back from the devicecode API. This “user code” is what a human being would enter into the browser later:
# Device Code OAuth flow begins: $body = @{ "client_id" = "1b730954–1685–4b74–9bfd-dac224a7b894" "resource" = "https://graph.microsoft.com" } $UserAgent = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Mobile Safari/537.36" $Headers=@{} $Headers["User-Agent"] = $UserAgent $authResponse = Invoke-RestMethod ` -UseBasicParsing ` -Method Post ` -Uri "https://login.microsoftonline.com/common/oauth2/devicecode?api-version=1.0" ` -Headers $Headers ` -Body $body $OTC = $authResponse.user_code
Request Two: GET to https://login.microsoftonline.com/common/oauth2/deviceauth
The next request is performed “as the user” and is a simple request to the initial deviceauth page:
$session = New-Object Microsoft.PowerShell.Commands.WebRequestSession $session.UserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36" $SecondRequest = $null $SecondRequest = Invoke-WebRequest ` -UseBasicParsing ` -Uri "https://login.microsoftonline.com/common/oauth2/deviceauth" ` -WebSession $session `
The response to this request includes our first collection of cookies and unique identifiers that we must keep track of during the flow:
x-ms-request-id — The value of this response header is required in later requests as the value for “hpgrequestid”
fpc — This cookie is required in later requests
esctx — This cookie is required in later requests
hpgid — A four-digit code that is required in later requests and must be parsed out of the response body HTML
canary — A token required in later requests, must be parsed from the response body HTML
We can assign all of these cookies, tokens, and other identifiers to their relevant variables with some basic parsing of the response headers and body in PowerShell:
$hpgrequestid = $SecondRequest.Headers.'x-ms-request-id' $CookieFPC = (($SecondRequest.Headers.'Set-Cookie' | select -first 1) -Split '; ') -Split '=' | Select -First 2 | Select -Last 1 $CookieESCTX = (($SecondRequest.Headers.'Set-Cookie' | select -first 2 | Select -Last 1) -Split '; ') -Split '=' | Select -First 2 | Select -Last 1 $html = $SecondRequest.Content $pattern = ',"hpgid":(.*?),"pgid"' $match = $html | Select-String -Pattern $pattern if ($match) { $HPGID = $match.Matches.Groups[1].Value Write-Output "HPGID: $HPGID" } else { Write-Output "HPGID not found in the HTML." } $pattern = '","canary":"(.*?)","sCanaryTokenName"' $match = $html | Select-String -Pattern $pattern if ($match) { $desiredString = $match.Matches.Groups[1].Value $Canary = [System.Web.HttpUtility]::UrlEncode($desiredString) Write-Output "Canary: $Canary" } else { Write-Output "Canary not found in the HTML." }
This code block shows what each value looks like:
$hpgrequestid: e2a89eff-5dd0–4262-b27b-4ee2468a5900 $CookieFPC AuxOg1aj0H5EpEVcclZOs4Q $CookieESCTX PAQABAAEAAAD - DLA3VO7QrddgJg7WevrzJCyhs37r8aEEog6PXOivCF953PRt68FvlHkjFnSplN2mNHQwqEBcTTmf5EPXTIRQCQXFrA27_cEk2l3YG0F1JreF8T9WwL5PJldV5XZjdy2RF-A-EtDsFx_MHGWSV-FSw1Prci4lcfiDl7vsxQMqXKGgaUSdNPbA9iJPFfIh8cgAA $HPGID 1119 $Canary t%2frFkhW25KShBl3S2O7pyXaB6GA3P3orfpFUxom3RH4%3d7%3a1%3aCANARY%3acDaoWAl7lE%2f5UyKpxyvQpCptwytSp3fwGEJMnTyNAXQ%3d
Request Three: POST to https://login.microsoftonline.com/common/oauth2/deviceauth
In this request, we include the ESCTX cookie, One-Time Code (OTC), Canary, and hpgrequestid values when POSTing to the deviceauth endpoint:
$session = New-Object Microsoft.PowerShell.Commands.WebRequestSession $session.UserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36" $session.Cookies.Add((New-Object System.Net.Cookie("esctx", $CookieESCTX, "/", ".login.microsoftonline.com"))) $ThirdRequest = $null $ThirdRequest = Invoke-WebRequest ` -UseBasicParsing ` -Uri "https://login.microsoftonline.com/common/oauth2/deviceauth" ` -Method "POST" ` -WebSession $session ` -ContentType "application/x-www-form-urlencoded" ` -Body "otc=$($OTC)&canary=$($Canary)&flowToken=&hpgrequestid=$($hpgrequestid)"
The response will include NEW values for hpgrequestid and ESCTX, so we need to update those values before we use them in subsequent requests:
$hpgrequestid = $ThirdRequest.Headers.'x-ms-request-id' $CookieESCTX = (($ThirdRequest.Headers.'Set-Cookie' | select -First 4 | Select -Last 1) -Split '; ') -Split '=' | Select -First 2 | Select -Last 1
We need to extract the updated value for “canary” from the request body to use in subsequent requests:
# Find the value of "canary" $pattern = '","canary":"(.*?)","sCanaryTokenName"' $match = $html | Select-String -Pattern $pattern if ($match) { $desiredString = $match.Matches.Groups[1].Value $Canary = [System.Web.HttpUtility]::UrlEncode($desiredString) Write-Output "Canary: $Canary" } else { Write-Output "Canary not found in the HTML." }
We also need to extract the value of “sCtx” from the body “sFT”, as they are used in subsequent requests for the values of “OriginalRequest” and “sFT”:
# Find the value of "sCtx" $html = $ThirdRequest.Content $pattern = 'login.microsoftonline.com%2fcommon%2freprocess%3fctx%3d(.*?)u0026mkt=en-USu0026hosted=1' $match = $html | Select-String -Pattern $pattern if ($match) { $desiredString = $match.Matches.Groups[1].Value $OriginalRequest = [System.Web.HttpUtility]::UrlDecode($desiredString) Write-Output "Original request: $OriginalRequest" } else { Write-Output "Original request not found in the HTML." } # Find the value of "sFT" $pattern = '","sFT":"(.*?)","sFTName"' $match = $html | Select-String -Pattern $pattern if ($match) { $sFT = $match.Matches.Groups[1].Value Write-Output "sFT: $sFT" } else { Write-Output "sFT not found in the HTML." }
Request Four: GET to https://login.microsoftonline.com/common/login
This is the request where we finally send the username and password. This request requires the ESCTX, Canary, OriginalRequest, hpgrequestid, and sFT cookies/tokens as well:
$session = New-Object Microsoft.PowerShell.Commands.WebRequestSession $session.UserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36 Req5" $session.Cookies.Add((New-Object System.Net.Cookie("esctx", $CookieESCTX, "/", ".login.microsoftonline.com"))) $FourthRequest = $null $FourthRequest = Invoke-WebRequest ` -UseBasicParsing ` -Uri "https://login.microsoftonline.com/common/login" ` -Method "POST" ` -WebSession $session ` -ContentType "application/x-www-form-urlencoded" ` -Body "i13=0&login=jasonfrank%40specterdev.onmicrosoft.com&loginfmt=jasonfrank%40specterdev.onmicrosoft.com&type=11&LoginOptions=3&lrt=&lrtPartition=&hisRegion=&hisScaleUnit=&passwd=<cleartext password goes here>&ps=2&psRNGCDefaultType=&psRNGCEntropy=&psRNGCSLK=&canary=$($Canary)&ctx=$($OriginalRequest)&hpgrequestid=$($hpgrequestid)&flowToken=$($sFT)&PPSX=&NewUser=1&FoundMSAs=&fspost=0&i21=0&CookieDisclosure=0&IsFidoSupported=1&isSignupPost=0&i19=125513"
Next, we must update the values of ESCTX and sFT from this request’s response:
$CookieESCTX = (($FourthRequest.Headers.'Set-Cookie' | select -First 2 | Select -Last 1) -Split '; ') -Split '=' | Select -First 2 | Select -Last 1 # Find the value of "sFT" $html = $FourthRequest.Content $pattern = '","sFT":"(.*?)","sFTName"' $match = $html | Select-String -Pattern $pattern if ($match) { $sFT = $match.Matches.Groups[1].Value Write-Output "sFT: $sFT" } else { Write-Output "sFT not found in the HTML." }
Request Five: POST to https://login.microsoftonline.com/appverify
In this request, we supply the ESCTX, OriginalRequest, hprequestid, sFT, and Canary cookies/tokens:
$session = New-Object Microsoft.PowerShell.Commands.WebRequestSession $session.UserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36 Req6" $session.Cookies.Add((New-Object System.Net.Cookie("esctx", $CookieESCTX, "/", ".login.microsoftonline.com"))) $FifthRequest = Invoke-WebRequest ` -UseBasicParsing ` -Uri "https://login.microsoftonline.com/appverify" ` -Method "POST" ` -WebSession $session ` -ContentType "application/x-www-form-urlencoded" ` -Body "ContinueAuth=true&ctx=$($OriginalRequest)&hpgrequestid=$($hpgrequestid)&flowToken=$($sFT)&iscsrfspeedbump=false&canary=$($Canary)&i19=43609"
This is equivalent to clicking “Yes” when the browser is asking if you are trying to log into the application. We do not need anything from this request’s response.
Request Six: POST to https://login.microsoftonline.com/Common/oauth2/token?api-version=1.0
Here, we are going back to the context of submitting requests as the application, not the user. In this request, we supply the “device code” we retrieved from the very first request:
$body=@{ "client_id" = "1b730954–1685–4b74–9bfd-dac224a7b894" "grant_type" = "urn:ietf:params:oauth:grant-type:device_code" "code" = $authResponse.device_code } $SixthRequest = Invoke-RestMethod ` -UseBasicParsing ` -Method Post ` -Uri "https://login.microsoftonline.com/Common/oauth2/token?api-version=1.0" ` -Headers $Headers ` -Body $body
If Requests Two through Five resulted in valid authentication, this request response includes a refresh token, access token, and ID token for the user:
$SixthRequest token_type : Bearer scope : Agreement.Read.All Agreement.ReadWrite.All AgreementAcceptance.Read AgreementAcceptance.Read.All AuditLog.Read.All Directory.AccessAsUser.All Directory.ReadWrite.All Group.ReadWrite.All IdentityProvider.ReadWrite.All Policy.ReadWrite.TrustFramework PrivilegedAccess.ReadWrite.AzureAD PrivilegedAccess.ReadWrite.AzureADGroup PrivilegedAccess.ReadWrite.AzureResources TrustFrameworkKeySet.ReadWrite.All User.Invite.All expires_in : 4593 ext_expires_in : 4593 expires_on : 1689961479 not_before : 1689956585 resource : https://graph.microsoft.com access_token : eyJ0eX…ba0hpg refresh_token : 0.AVEA…h2P7mA id_token : eyJ0eX…IiOiIx
Conclusion and What’s Next
In this blog post, I’m sharing my current knowledge and testing regarding automating Entra’s OAuth 2.0 Device Code flow. Please feel free to build on, correct, or adapt this information to your own needs. I’m sharing this now because, frankly, it took a long time to figure out and I want to save others the pain I went through.
I’m using this to research other Entra systems such as Conditional Access. Automating this flow means I can very quickly perform tests against Conditional Access to validate, for example, how the backend systems determine a user’s browser and OS types from the signals in these requests.
Browserless Entra Device Code Flow was originally published in Posts By SpecterOps Team Members on Medium, where people are continuing the conversation by highlighting and responding to this story.