From d95f15f03e225e1cb8a5d4f7ba5f86a71697c6d1 Mon Sep 17 00:00:00 2001 From: Carlos Polop Date: Fri, 27 Feb 2026 15:07:09 +0100 Subject: [PATCH] f --- .../az-tokens-and-public-applications.md | 333 +++++++++++++++++- 1 file changed, 330 insertions(+), 3 deletions(-) diff --git a/src/pentesting-cloud/azure-security/az-basic-information/az-tokens-and-public-applications.md b/src/pentesting-cloud/azure-security/az-basic-information/az-tokens-and-public-applications.md index cf1576a82..f5cdddbb3 100644 --- a/src/pentesting-cloud/azure-security/az-basic-information/az-tokens-and-public-applications.md +++ b/src/pentesting-cloud/azure-security/az-basic-information/az-tokens-and-public-applications.md @@ -208,6 +208,334 @@ microsoft_office_bearer_tokens_for_graph_api = ( pprint(microsoft_office_bearer_tokens_for_graph_api) ``` +## NAA / BroCI (Nested App Authentication / Broker Client Injection) + +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. + +These refresh tokens must be minted in that broker context (a regular refresh token usually cannot be used as a BroCI refresh token). + +### Goal and purpose + +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. + +From an offensive perspective, this matters because: + +- It can unlock pre-consented first-party app paths that are not accessible with standard refresh exchanges. +- It can return access tokens for high-value APIs (for example, Microsoft Graph) under app identities with broad delegated permissions. +- It expands post-authentication token pivoting opportunities beyond classic FOCI client switching. + +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. + +NAA/BroCI token exchanges are **not** the same as a regular OAuth refresh exchange. + +- A regular refresh token (for example obtained via device code flow) is usually valid for standard `grant_type=refresh_token` operations. +- A BroCI request includes additional broker context (`brk_client_id`, broker `redirect_uri`, and `origin`). +- Microsoft validates whether the presented refresh token was minted in a matching brokered context. +- 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"). +- You generally cannot "convert" a normal refresh token into a BroCI-valid one in code. +- You need a refresh token already issued by a compatible brokered flow. + +### Mental model + +Think of BroCI as: + +`user session -> brokered refresh token issuance -> brokered refresh call (brk_client_id + redirect_uri + origin) -> access token for target trusted app/resource` + +If any part of that broker chain does not match, the exchange fails. + +### Where to find a BroCI-valid refresh token + +In authorized testing/lab scenarios, one practical way is browser portal traffic collection: + +1. Sign in to `https://entra.microsoft.com` (or Azure portal). +2. Open DevTools -> Network. +3. Filter for: + - `oauth2/v2.0/token` + - `management.core.windows.net` +4. Identify the brokered token response and copy `refresh_token`. +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). + +### Common errors + +- `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. +- `AADSTS7000218`: The selected client flow expects a confidential credential (`client_secret`/assertion), often seen when trying device code with a non-public client. + +
+Python BroCI refresh helper (broci_auth.py) + +```python +#!/usr/bin/env python3 +""" +Python implementation of EntraTokenAid Broci refresh flow. + +Equivalent to Invoke-Refresh in EntraTokenAid.psm1 with support for: +- brk_client_id +- redirect_uri +- Origin header + +Usage: + python3 broci_auth.py --refresh-token "" + +How to obtain a Broci-valid refresh token (authorized testing only): + 1) Open https://entra.microsoft.com and sign in. + 2) Open browser DevTools -> Network. + 3) Filter requests for: + - "oauth2/v2.0/token" + - "management.core.windows.net" + 4) Locate the portal broker token response and copy the "refresh_token" value + (the flow should be tied to https://management.core.windows.net//). + 5) Use that token with this script and Broci params: + + python3 broci_auth.py \ + --refresh-token "" \ + --client-id "74658136-14ec-4630-ad9b-26e160ff0fc6" \ + --tenant "organizations" \ + --api "graph.microsoft.com" \ + --scope ".default offline_access" \ + --brk-client-id "c44b4083-3bb0-49c1-b47d-974e53cbdf3c" \ + --redirect-uri "brk-c44b4083-3bb0-49c1-b47d-974e53cbdf3c://entra.microsoft.com" \ + --origin "https://entra.microsoft.com" \ + --token-out +""" + +import argparse +import base64 +import datetime as dt +import json +import re +import sys +import urllib.error +import urllib.parse +import urllib.request +from typing import Any + + +GUID_RE = re.compile( + 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}$" +) +OIDC_SCOPES = {"offline_access", "openid", "profile", "email"} + + +def resolve_api_scope_url(api: str, scope: str) -> str: + """ + Match Resolve-ApiScopeUrl behavior from the PowerShell module. + """ + if GUID_RE.match(api): + base_resource = api + elif api.lower().startswith("urn:") or "://" in api: + base_resource = api + else: + base_resource = f"https://{api}" + + base_resource = base_resource.rstrip("/") + + resolved: list[str] = [] + for token in scope.split(): + if not token.strip(): + continue + if "://" in token: + resolved.append(token) + elif token.lower().startswith("urn:"): + resolved.append(token) + elif token in OIDC_SCOPES: + resolved.append(token) + elif GUID_RE.match(token): + resolved.append(f"{token}/.default") + else: + normalized = ".default" if token in {"default", ".default"} else token + resolved.append(f"{base_resource}/{normalized}") + + return " ".join(resolved) + + +def parse_jwt_payload(jwt_token: str) -> dict[str, Any]: + parts = jwt_token.split(".") + if len(parts) != 3: + raise ValueError("Invalid JWT format.") + payload = parts[1] + padding = "=" * ((4 - len(payload) % 4) % 4) + decoded = base64.urlsafe_b64decode((payload + padding).encode("ascii")) + return json.loads(decoded.decode("utf-8")) + + +def refresh_broci_token( + refresh_token: str, + client_id: str, + scope: str, + api: str, + tenant: str, + user_agent: str, + origin: str | None, + brk_client_id: str | None, + redirect_uri: str | None, + disable_cae: bool, +) -> dict[str, Any]: + api_scope_url = resolve_api_scope_url(api=api, scope=scope) + + headers = { + "User-Agent": user_agent, + "X-Client-Sku": "MSAL.Python", + "X-Client-Ver": "1.31.0", + "X-Client-Os": "win32", + "Content-Type": "application/x-www-form-urlencoded", + } + if origin: + headers["Origin"] = origin + + body: dict[str, str] = { + "grant_type": "refresh_token", + "client_id": client_id, + "scope": api_scope_url, + "refresh_token": refresh_token, + } + if not disable_cae: + body["claims"] = '{"access_token": {"xms_cc": {"values": ["CP1"]}}}' + if brk_client_id: + body["brk_client_id"] = brk_client_id + if redirect_uri: + body["redirect_uri"] = redirect_uri + + data = urllib.parse.urlencode(body).encode("utf-8") + token_url = f"https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token" + req = urllib.request.Request(token_url, data=data, headers=headers, method="POST") + + try: + with urllib.request.urlopen(req) as resp: + raw = resp.read().decode("utf-8") + except urllib.error.HTTPError as e: + err_raw = e.read().decode("utf-8", errors="replace") + try: + err_json = json.loads(err_raw) + short = err_json.get("error", "unknown_error") + desc = err_json.get("error_description", err_raw) + raise RuntimeError(f"{short}: {desc}") from None + except json.JSONDecodeError: + raise RuntimeError(f"HTTP {e.code}: {err_raw}") from None + + tokens = json.loads(raw) + if "access_token" not in tokens: + raise RuntimeError("Token endpoint response did not include access_token.") + return tokens + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Broci refresh flow in Python (EntraTokenAid Invoke-Refresh equivalent)." + ) + parser.add_argument("--refresh-token", required=True, help="Refresh token (required).") + parser.add_argument( + "--client-id", + default="04b07795-8ddb-461a-bbee-02f9e1bf7b46", + help="Client ID (default: Azure CLI).", + ) + parser.add_argument( + "--scope", + default=".default offline_access", + help="Scopes (default: '.default offline_access').", + ) + parser.add_argument( + "--api", default="graph.microsoft.com", help="API resource (default: graph.microsoft.com)." + ) + parser.add_argument("--tenant", default="common", help="Tenant (default: common).") + parser.add_argument( + "--user-agent", + default="python-requests/2.32.3", + help="User-Agent sent to token endpoint.", + ) + parser.add_argument("--origin", default=None, help="Optional Origin header.") + parser.add_argument( + "--brk-client-id", default=None, help="Optional brk_client_id (Broci flow)." + ) + parser.add_argument( + "--redirect-uri", default=None, help="Optional redirect_uri (Broci flow)." + ) + parser.add_argument( + "--disable-cae", + action="store_true", + help="Disable CAE claims in token request.", + ) + parser.add_argument( + "--token-out", + action="store_true", + help="Print access/refresh tokens in output.", + ) + parser.add_argument( + "--disable-jwt-parsing", + action="store_true", + help="Do not parse JWT claims.", + ) + + args = parser.parse_args() + + print("[*] Sending request to token endpoint") + try: + tokens = refresh_broci_token( + refresh_token=args.refresh_token, + client_id=args.client_id, + scope=args.scope, + api=args.api, + tenant=args.tenant, + user_agent=args.user_agent, + origin=args.origin, + brk_client_id=args.brk_client_id, + redirect_uri=args.redirect_uri, + disable_cae=args.disable_cae, + ) + except Exception as e: + print(f"[!] Error: {e}", file=sys.stderr) + return 1 + + expires_in = int(tokens.get("expires_in", 0)) + expiration_time = (dt.datetime.now() + dt.timedelta(seconds=expires_in)).isoformat(timespec="seconds") + tokens["expiration_time"] = expiration_time + + print( + "[+] Got an access token and a refresh token" + if tokens.get("refresh_token") + else "[+] Got an access token (no refresh token requested)" + ) + + if not args.disable_jwt_parsing: + try: + jwt_payload = parse_jwt_payload(tokens["access_token"]) + audience = jwt_payload.get("aud", "") + print(f"[i] Audience: {audience} / Expires at: {expiration_time}") + tokens["scp"] = jwt_payload.get("scp") + tokens["tenant"] = jwt_payload.get("tid") + tokens["user"] = jwt_payload.get("upn") + tokens["client_app"] = jwt_payload.get("app_displayname") + tokens["client_app_id"] = args.client_id + tokens["auth_methods"] = jwt_payload.get("amr") + tokens["ip"] = jwt_payload.get("ipaddr") + tokens["audience"] = audience + if isinstance(audience, str): + tokens["api"] = re.sub(r"/$", "", re.sub(r"^https?://", "", audience)) + if "xms_cc" in jwt_payload: + tokens["xms_cc"] = jwt_payload.get("xms_cc") + except Exception as e: + print(f"[!] JWT parse error: {e}", file=sys.stderr) + return 1 + else: + print(f"[i] Expires at: {expiration_time}") + + if args.token_out: + print("\nAccess Token:") + print(tokens.get("access_token", "")) + if tokens.get("refresh_token"): + print("\nRefresh Token:") + print(tokens["refresh_token"]) + + print("\nToken object (JSON):") + print(json.dumps(tokens, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) +``` + +
+ ## Where to find tokens 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 - [https://github.com/secureworks/family-of-client-ids-research](https://github.com/secureworks/family-of-client-ids-research) - [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) +- [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/) +- [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/) {{#include ../../../banners/hacktricks-training.md}} - - -