Hackers Rode Qubole’s AWS Access Into Customer Accounts, Audit Your Cross-Account Roles Now

Between October 14 and 26, attackers with control of a Qubole-owned AWS account used cross-account IAM roles to breach multiple customer environments. The attack window is known, so you can hunt your CloudTrail logs…

The breach that matters here isn’t the one in Qubole’s environment. It’s the one in yours, the one you authorized years ago in a Terraform module nobody’s touched since, the one that says this third party may assume this role in my account. Between October 14 and 26, 2026, someone with control of a Qubole-owned AWS account used exactly that permission to step into multiple customer environments and take secrets and data. Wiz disclosed it on November 10. The attackers didn’t pick a lock. They used the key customers handed over at onboarding.

Qubole is a cloud data lake and analytics platform, the kind of service you point at your own S3 buckets and warehouses so it can run Spark, Presto, and similar workloads against data you’d rather not copy out of your account. The standard integration pattern is a cross-account IAM role: you create a role in your account, set its trust policy to allow Qubole’s AWS account to assume it, and attach a policy broad enough for the platform to do its job. Read your buckets. Describe your clusters. Sometimes a lot more than that, because tightening the policy was a day-two task that never came.

That’s the whole mechanism. There’s no exotic exploit chain to diagram. When you let an external AWS account assume a role, AWS checks two things at assume time: does the trust policy permit that principal, and, if the trust policy requires it, does the caller present the correct ExternalId. If the vendor’s account is the attacker’s account for a couple of weeks, both checks pass. STS hands back temporary credentials. Everything downstream looks like legitimate vendor activity, because by every control AWS enforces, it is.

Scope is deliberately vague in the reporting, and I’m going to leave it vague: “multiple” customers, secrets and data stolen. How Qubole’s credentials were compromised in the first place hasn’t been disclosed, so I won’t guess. What I can tell you is that the attack window is known, which is a gift. If you’re a Qubole customer, you can go read your own logs right now and find out whether you were one of the “multiple.” Most breach victims don’t get that luxury for months.

Find out if the role was used against you

Start with CloudTrail. Every AssumeRole call lands there, and the record tells you which external account made it. You do not need to know Qubole’s account ID to hunt, you need to know which of your roles are wired to it, then look at who assumed them during the window. Here’s the fast pass with the CLI, read-only:

run.shbash, zsh
# Pull AssumeRole events during the known window.
# Adjust the region; CloudTrail lookup is per-region.
aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=EventName,AttributeValue=AssumeRole \
  --start-time 2026-10-14T00:00:00Z \
  --end-time   2026-10-27T00:00:00Z \
  --query 'Events[].CloudTrailEvent' \
  --output text | \
  jq -r 'fromjson
    | select(.userIdentity.type=="AWSAccount" or .userIdentity.accountId!=env.MY_ACCOUNT_ID)
    | [.eventTime, .userIdentity.accountId, .requestParameters.roleArn, .sourceIPAddress] | @tsv'

Set MY_ACCOUNT_ID to your own account so the filter surfaces assumptions by foreign accounts. The lookup-events API only covers 90 days and is rate-limited, which for this window is fine. If you’ve got a CloudTrail Lake or an Athena table over an S3 trail, that’s the better tool, it crosses regions and lets you pivot from the assumption to what happened next. The pivot is the part people skip.

An AssumeRole event by itself is noise. The signal is the roleSessionName and the temporary accessKeyId STS issued, because every subsequent API call made with those credentials carries them. Once you have a suspect session, chase it:

query.sqlSQL
-- Athena over a CloudTrail S3 table.
-- Step 1: find the suspect assumptions (foreign account into your roles).
SELECT eventtime,
       useridentity.accountid        AS calling_account,
       requestparameters,
       responseelements
FROM cloudtrail_logs
WHERE eventname = 'AssumeRole'
  AND eventtime BETWEEN '2026-10-14T00:00:00Z' AND '2026-10-27T00:00:00Z'
  AND useridentity.accountid <> '111111111111'   -- your account id
ORDER BY eventtime;
​
-- Step 2: take the accessKeyId from responseelements above,
-- then see everything that session touched.
SELECT eventtime, eventsource, eventname, sourceipaddress, errorcode
FROM cloudtrail_logs
WHERE useridentity.accesskeyid = 'ASIA_SESSION_KEY_FROM_STEP_1'
  AND eventtime BETWEEN '2026-10-14T00:00:00Z' AND '2026-10-28T00:00:00Z'
ORDER BY eventtime;

What you’re hunting for in step two is activity that doesn’t match the integration’s normal job. Secrets Manager GetSecretValue calls. s3:GetObject against buckets the analytics role had no business reading. rds:DescribeDBInstances and DescribeDBClusters as reconnaissance. IAM enumeration, ListRoles, ListPolicies, GetAccountAuthorizationDetails, is a tell that somebody is mapping your account rather than running a Spark job. A sudden spike in AccessDenied errors is its own signal: it means the credentials were trying things the role couldn’t do, which legitimate automation rarely does at volume.

I’m not going to hand you a list of “the API calls the attacker used,” because Wiz hasn’t published one and I won’t invent it. Hunt for behavior that’s inconsistent with the vendor’s purpose, not for a specific signature.

Now assume you weren’t told about the next one

The Qubole window is bounded. The lesson isn’t. You almost certainly have cross-account roles trusting other vendors whose next bad week you won’t hear about until Wiz writes it up. So inventory them. This enumerates every role in the account, parses the trust policy, and flags external principals and, critically, whether an ExternalId condition is present. Report-only; it changes nothing:

run.shbash, zsh
#!/usr/bin/env bash
# Report cross-account trust relationships and missing ExternalId.
set -euo pipefail
MY_ACCOUNT=$(aws sts get-caller-identity --query Account --output text)
​
aws iam list-roles --query 'Roles[].RoleName' --output text | tr '\t' '\n' | while read -r role; do
  doc=$(aws iam get-role --role-name "$role" \
        --query 'Role.AssumeRolePolicyDocument' --output json)
​
  # Pull every AWS principal ARN/account in the trust policy.
  echo "$doc" | jq -r --arg me "$MY_ACCOUNT" --arg role "$role" '
    .Statement[]? | select(.Effect=="Allow")
    | . as $stmt
    | ([.Principal.AWS] | flatten | .[]?) as $p
    | select(($p | tostring | contains($me)) | not)         # external only
    | ($stmt.Condition.StringEquals["sts:ExternalId"] // $stmt.Condition.StringLike["sts:ExternalId"] // "NONE") as $eid
    | [$role, $p, ("ExternalId=" + ($eid|tostring))] | @tsv'
done

Rows where ExternalId=NONE are the ones to care about. A cross-account role with no external ID and no other conditions is a role any attacker who learns the ARN and account relationship can attempt to assume, and ARNs leak, because people paste them into docs and tickets and Terraform state. The external ID is the shared secret that stops a confused-deputy assume. It is not a strong secret, but its absence is a finding.

If you run Microsoft-side tooling too, the same logic applies to any Azure or Entra cross-tenant access you’ve granted data vendors, check Get-MgPolicyCrossTenantAccessPolicyPartner for inbound trust you’ve forgotten about. Different cloud, identical failure mode: a trust you authorized, operated by someone whose breach is now your breach.

The grim part: an external ID would not have stopped this. The attacker controlled Qubole’s account, and Qubole knew the external ID, it’s in their config, that’s the point. External IDs stop impersonation of the vendor, not compromise of the vendor. To limit the blast radius when the vendor itself is the attacker, you need conditions that constrain what the session can do and alerting that notices when it does something new.

What to actually do, in order

  1. If you use Qubole, hunt this week. Run the CloudTrail queries above against Oct 14–27. If a foreign account assumed your role and then read secrets, buckets, or RDS metadata, treat it as confirmed access: rotate every credential that role could reach, starting with anything in Secrets Manager and Parameter Store, and review S3 and database access logs for the same session’s reach. Don’t wait for Qubole to tell you your scope.
  2. Require an external ID on every cross-account role, today. Add "Condition": {"StringEquals": {"sts:ExternalId": "<unique-value>"}} to each trust policy flagged NONE, coordinated with the vendor so their config matches. Use a distinct value per integration, never reuse one across vendors.
  3. Shrink the role’s reach to its actual job. An analytics platform rarely needs secretsmanager:GetSecretValue or IAM read. Scope resources explicitly, and add aws:SourceIp conditions pinned to the vendor’s published egress ranges where they publish them. Session tags and a DurationSeconds ceiling cut how long a stolen session stays useful.
  4. Alert on foreign-account assumptions. Wire a CloudTrail/EventBridge rule that fires when any account outside your org assumes a role, and route it somewhere a human reads. You want the alert that says “your data vendor just assumed a role from an IP it’s never used” before Wiz does.
  5. Put cross-account roles on a review cadence. Treat every external principal as standing third-party risk, re-justified quarterly. Dead integrations keep their trust policies long after the contract ends.

The uncomfortable truth under all of this: AWS did exactly what you told it to. The trust held. That’s not a bug you can patch, it’s a decision you made and stopped looking at. Go look at it before the next vendor’s bad week becomes yours.