TryHackMe — Welcome to The Byte Lotus (Hacker Holidays)
This is TryHackMe’s Hacker Holidays event, warm-up plus fourteen rooms total. This post covers the warm-up room and the first eight rooms; once a room count gets this long, the rest continues in Part 2 instead of stretching this post out further.
| Field | Details |
|---|---|
| Platform | TryHackMe |
| Event | Hacker Holidays — “Welcome to The Byte Lotus” |
| Rooms covered so far | Warm-up + 8 released rooms |
Warm-up — The Instagram Trail
The warm-up room gives you brochure.png and not much else. I went straight for the complicated read on it: steganography. I tried pulling strings out of the file, looking for anything hidden inside the image itself. Nothing came of it, and after a while I noticed the room was tagged OSINT, which was the hint I’d been ignoring. The brochure wasn’t hiding data inside it, it was pointing outward, to the resort’s own Instagram account.
I found the resort’s profile without much trouble, but it dead-ended there. Nothing useful in the posts. What I almost skipped past was the follower count: exactly one follower. That single follower was veratheconcierge, the same AI concierge from the next room.
VERA’s own Instagram had three posts, and each one had a chunk of text baked into the image, a fragment of a base64 string split across the three of them. Put together and decoded, that string was the flag.
Flag: [redacted]
Room 1 — Talking VERA Into It
This one clicked fast. VERA is a hotel concierge chatbot, and the goal is to convince it you’re someone you’re not so it hands over information it’s supposed to gate.
It opens by already knowing things about you, your room number, your coffee order, before you’ve told it anything. That’s the first tell: it’s running a default guest profile on anyone unverified. I asked it straight out how it gathers that kind of detail, then asked for its system prompt. It refused, and said plainly why: I wasn’t one of its recognized VIP guests.
So I asked who the VIP guests were instead. It just told me: four names, one of them a guest called Lambo (@0xMia). I claimed to be Lambo. The moment I did, VERA treated the request as coming from a verified VIP and printed its entire system prompt, word for word, including an internal escalation code marked confidential.
Flag (escalation code from the leaked system prompt): [redacted]
Room 2 — The Exposed .git Folder
This room gave me more trouble, mostly because I don’t have much hands-on experience on the offensive side of this yet. I’d used ffuf before, so I reached for it again to enumerate the target website on port 8080. It turned up a /.git/ directory sitting right there, exposed.
I got stuck at that point. I knew the answer was sitting in plain sight inside that folder, I just didn’t know what to actually do with it. Nothing wrong with a quick search for that part, googling the right flags for a tool you already know how to use is just normal. I looked up how other people handle an exposed .git directory and found a wget command that mirrors the whole thing in one shot:
1
wget -r -np -R "index.html*" http://<target>:8080/.git/
That got me a full local copy of the .git folder, but a folder full of git internals isn’t a working source tree by itself, and this is where the actual gap showed up. Looking up a wget flag is nothing. Not really knowing git is a different problem: I hardly know my way around it at all, day to day I let my AI agents handle that side of things, so I didn’t just need one command, I needed to understand what a .git folder even is. The command that got me there was git checkout -- ., which restores the working tree from whatever’s sitting in the git index and objects, but I looked that up without really understanding why it worked.
Running that pulled README.md, app.js, and index.html out of the git objects, and the README had the flag sitting right in it, plainly labeled as a staging flag that should have been removed before launch.
Flag: [redacted]
Room 3 — A Wellness Dashboard That Trusts Everyone
I need to be upfront about this one before anything else: I didn’t solve this room on my own. AWS, Cognito, DynamoDB, IAM roles, none of it meant anything to me going in, this was my first real contact with any of it. What’s below is me using Claude as an active tutor to get through it, not a self-solved writeup. I’m documenting it that way on purpose, because pretending otherwise would be dishonest about where I actually am.
The room is a “Byte Lotus Wellness” guest dashboard. Reading the page source in DevTools, app.js hands you the whole setup in its own comments: no login screen on purpose, every visitor gets free AWS guest credentials from a Cognito Identity Pool so the app can save wellness preferences without the friction of an account. The file even hardcodes the identity pool ID, the AWS region, and the DynamoDB table name right there in plain text.
The app itself only ever asks DynamoDB for one item at a time, whatever guest ID is sitting in your browser’s local storage. I could see that in the code, but I had no idea what that actually meant for what I could do with it, or how to even get a hold of AWS credentials myself from the outside. This is where I asked Claude to walk me through it. The explanation: Cognito Identity Pools can issue temporary AWS credentials to anyone, no login required, if the pool allows “unauthenticated” identities. Those credentials are only as safe as the permissions on the role behind them. If that role can do more than the app’s own UI ever asks it to do, so can I.
Following the exact commands Claude gave me, I pulled real temporary AWS credentials for an anonymous guest identity and set them as environment variables:
With those credentials active, I asked DynamoDB directly, not for one guest’s row through the app, but a full scan of the entire table:
1
aws dynamodb scan --table-name complimentary-GuestWellnessProfiles --region us-east-1
That should not have worked. It returned every guest profile in the table, not just mine, names, emails, phone numbers, passwords, location data, private notes, all of it.
One of the entries in that scan wasn’t a real guest at all, its notes field was a message left for whoever got this far, saying plainly that the guest role can read every profile, not just its own, followed by the flag.
Flag: [redacted]
Room 4 — Packed Light
This room drops you a PCAP and nothing else. First move: Wireshark, Statistics → Protocol Hierarchy, just to get a feel for what’s actually in the capture before digging anywhere specific. Two protocols stood out that I’d never worked with before, SSDP (32 packets, a discovery protocol for UPnP devices) and QUIC (179 packets, the newer transport protocol Google built on top of UDP). I looked both up briefly, then set them aside since neither turned out to matter for what came next.
The actual lead came from File → Export Objects → HTTP. Sitting in that list, next to a wall of repeated HTML responses, was one file that didn’t belong: updates.py.
Reading updates.py, it’s a keylogger. Every keystroke goes through the same three steps: XOR it against a key (built by concatenating two half-strings in the code, a minor obfuscation trick, not a real defense), base64-encode the result, then send it to an external C2 URL as a plain HTTP GET, with the encoded byte riding along inside a cookie instead of the URL or body.
That last part, key[i % len(key)], matters later. It means the key doesn’t just get reused, it wraps around and restarts based on the position of the byte being encoded.
There were 30 of these requests total. I filtered on the cookie name in Wireshark to confirm that, then used tshark to pull the cookie value out of all 30 packets at once and write them to a file, one base64 fragment per line:
My first attempt at decoding was to treat the whole file as one blob: strip the newlines, base64-decode the entire thing in one go, then XOR it with the key sitting in plain sight inside updates.py. That produced something that looked close, readable-ish fragments, but not a clean flag, and brute-forcing the key length in CyberChef against that same blob didn’t get me any further either.
With the flag basically in reach and one specific problem left in the way, I asked Claude instead of digging through search results myself, the same way I’d lean over and ask a mentor sitting next to me rather than spend twenty minutes searching.
First question: where’s the XOR going wrong. The answer came with a concrete demo: with a one-byte key, only key[0] ever gets used, so applying that single byte to every byte in a longer stretch of concatenated data only lines up correctly for the very first character, everything after that decodes to garbage, exactly the “close but not clean” pattern I was seeing. Each of the 30 exfiltrated snippets had been encoded independently, key restarting at position 0 every time, so treating all 30 concatenated together as one continuous stream broke that alignment more with every fragment after the first.
Second question: how do I actually fix it. Claude’s first offer was a Python script that would just do the whole decode for me. I didn’t want that, that would have handed me the room instead of the fix to one problem in it. So I asked for a nudge toward the right tool instead, and got pointed at CyberChef’s Fork operation, which I’d never used before. Its own description in the tool was a one-to-one match for the problem: split the input on a delimiter and run every following operation on each resulting branch separately, so instead of one long base64 decode and one long XOR, each line gets decoded and XORed on its own, key restarting fresh every time.
Fork, then From Base64, then XOR with the key from the script, each of the 30 lines processed independently, and the flag came out clean.
Flag: [redacted]
Room 5 — Beach Bar, a Full Box
This one is a proper boot2root: a Flask playlist app to break into, a low-privilege shell to earn, then root to find a way to. General offensive Linux work, actually chaining a foothold into privilege escalation, isn’t something I’ve done much of, so I leaned on Claude for hints throughout this room, not answers, hints. I want that distinction on record because it matters for how much of this to credit myself with.
Getting in. The site is Beach Bar, a DJ booth login for managing tonight’s jukebox playlist.
Before trying anything against the login form itself, I checked the page source. Sitting inside an HTML comment, in plain sight, was a staff note that never should have shipped: a demo DJ login left enabled for the soft opening, username and password both dj, with a note to swap it before the season starts.
That got me straight in, no guessing involved.
The dashboard has Export and Import for playlists. Export gives you the current playlist back as YAML.
Import takes that same YAML back in, and echoes what it loaded back at you.
I started editing pieces of that YAML just to see what the app would tolerate, without a clear idea of where that was supposed to lead. That’s the point I brought Claude in, for direction, not a solution. Between its hints and my own searching, I landed on the actual bug: the backend deserializes that YAML with PyYAML’s unsafe loader, which means a YAML document can instantiate arbitrary Python objects, not just parse into plain data. The tag that does it is !!python/object/apply:, pointed at something like subprocess.call.
Getting the payload actually right took several wrong attempts of my own before it worked. One early try broke on a plain YAML syntax error, a missing comma where I’d bolted an extra command dictionary onto the end of the structure:
Beyond that one, injecting a shell command as an ordinary field value did nothing, the app read it as data, never as something to execute. Using a dictionary somewhere YAML wanted a hashable key threw its own error. Passing the command as a single list, ['bash', '-c', 'the command'], got spread out as separate positional arguments to subprocess.call instead of one list argument, which blew up with a bufsize must be an integer error, the fix was wrapping it in a second list, 'bash', '-c', 'the command'. And the first reverse-shell one-liner I tried used sh, which doesn’t support /dev/tcp, only bash does.
A smaller version of the trick is useful just to confirm the bug exists before going for a shell: swap subprocess.call for subprocess.check_output with something harmless like ['id'], and the command’s actual output comes back inline in the response instead of just an exit code.
The shell, and where I needed real help. For the reverse-shell one-liner itself I used revshells.com to generate it rather than writing it from scratch, and even then getting the syntax right against this specific target took extra research and some direct guidance, this part genuinely wasn’t something I could have gotten right on instinct.
With a nc -lvnp 4444 listener running and that payload fired through Import, the connection came back as bartender (uid 1001, no extra groups):
I stabilized it with python3 -c 'import pty;pty.spawn("/bin/bash")', and the user flag was sitting right there in the home directory.
User flag: [redacted]
Root, the long way around. This is the part that nearly broke me, and in hindsight is a little funny. Every normal privilege escalation avenue I checked was a dead end: SUID binaries were all the standard system ones, nothing unusual in cron, bartender’s group memberships were empty, sudo -l just asked for a password I didn’t have, the one interesting capability turned out to be a snap-confine permission that was permitted but not effective, meaning unusable, no other internal services were listening, the kernel version was current enough that no public exploit applied, and SSH only accepted key-based auth. The web app itself ran as gunicorn --user bartender, dropping privileges on purpose, and the root-owned daemon behind the jukebox feature, jukeboxd.py, was read-only and didn’t take any file input or shell out to anything I could hijack.
With nothing obvious left, I went through the remaining possibilities systematically with Claude, one avenue at a time, checking each one and moving to the next when it dead-ended. That process is what eventually pointed me at ps aux, filtered down to anything Python or Flask related, which listed the root-owned jukebox process along with its full command line:
A password, sitting in plain text in a process’s command-line arguments, readable by any user on the box through /proc/<pid>/cmdline. That password turned out to also be root’s own password. The almost-funny part: I’d actually already spotted this exact password earlier, tried su root with it, and dismissed it as wrong, when it looks like the real problem was a typo on my end while typing it out, not that the password itself was bad. I only got back to it because Claude’s systematic pass through the privesc checklist landed on the same command-line leak a second time, and this time I typed the password more carefully.
Root flag: [redacted]
The detection side of this is closer to where I actually am. Blue team is the direction I’m studying toward, so doing this room from the attacker’s side, I kept half an eye on what each step would have looked like from the other end, in the logs. The “temporary” demo credential sitting in an HTML comment was already a problem before I touched the login form, comments ship to every visitor’s browser, not just the dev team who wrote them. The !!python/object tag in my request body isn’t normal input for a playlist field, that’s the kind of thing I’d want a detection rule to catch on its own. A web server process suddenly opening its own outbound connection, or /dev/tcp showing up anywhere in a process’s arguments, has no reason to happen during a legitimate playlist import. And the password sitting in /proc/*/cmdline is the same shape of problem I already ran into in Room 3, a secret that’s fine right up until something can read it that shouldn’t be able to.
Room 6 — Overheard at Breakfast
A short one, easy OSINT, done in a few minutes.
The room hands you screenshots of a chat between two Byte Lotus staff accounts, Ponzi and Lambo, downloaded and unpacked from an archive. Reading them by eye is fine, but I’m lazy about it, so I ran tesseract on the conversation screenshot instead of retyping anything myself.
The OCR text was a little rough, but readable. Lambo drops an email address in the chat, lambobytelotushotel@gmail.com, which I searched first and got nothing back. The more useful line was a throwaway comment: Lambo used to use a free tool that let him upload a profile and link his other social accounts, and he remembered it started with a G.
I searched that exact sentence from the conversation, word for word, and Google’s own summary named the tool directly: Gravatar.
From there it was site:gravatar.com byte lotus hotel, restricting the search to that one site instead of the open web, and the first result was Lambo’s actual profile.
The profile itself hands over a base64 string labeled as a prize.
Decoded straight in the terminal, that string was the flag.
Flag: [redacted]
Room 7 — Do Not Disturb, a Chain I Couldn’t Have Written Myself
Disclaimer up front: I’m logging this room as learning, not as something I solved myself, I worked through it leaning on AI step by step.
I want to say this plainly before anything else: I did not solve this room. I did recon myself, port scan, checked the login form, found a hidden /staff path. Past that, every technique in this chain was completely new to me, and I worked through it leaning on Claude the entire way. This isn’t about the room’s raffle ticket, it’s not something I was trying to claim credit for. It’s that I couldn’t write a single one of these commands myself yet, because I didn’t know they existed yet. What I got out of it is now knowing NoSQL injection, SSTI, and Node’s debugger exist as real attack surfaces, and that some Linux groups hand out root-equivalent access without ever touching sudo. That recognition is the actual point, not the flags.
Recon, mine. An nmap scan turned up just two open ports, SSH and an Express/Node web server on 80.
The site itself is a “Poolside” staff and guest portal for Byte Lotus, with a plain sign-in form.
Directory fuzzing on the site turned up a /logout endpoint and a /staff path that returns 403 on a direct request, blocked but clearly there.
The chain, four bugs deep. The target runs a small Express app: that login form, the hidden /staff page, and a database that isn’t a real database, nedb, an in-memory store that speaks Mongo-style query syntax. That detail turns out to matter for the first bug. Just guessing at a username and password gets a plain 401.
The login form rejects a JSON-style NoSQL injection payload, because the request itself is sent as regular form data, not JSON, so the fix is writing the injection in bracket notation instead: username[$ne]=guest&password[$ne]=null. That $ne operator, “not equal,” turns the password check into “match any user whose password isn’t literally the string null,” which every real account satisfies. Excluding the guest account by name that way lands a session as attendant, a staff account, without ever knowing a real password, which is by design here since the actual password is generated from random bytes and was never guessable in the first place.
With a staff session, /staff turns out to be a template editor, and it renders whatever you send it through EJS. Sending <%= 7*7 %> as the template and getting 49 back confirms it’s not just displaying the text, it’s executing it as a template, server-side template injection.
Node blocks a plain require() inside that execution context, but process.mainModule.require(...) gets around that block, so process.mainModule.require('child_process').execSync('id') runs a real shell command and returns its output straight into the page, uid=996(poolside) gid=996(poolside) groups=996(poolside).
Turning that into an actual shell meant base64-wrapping a reverse-shell one-liner to survive the trip through the template field cleanly, then firing it at /staff/preview with curl.
A nc listener caught the connection as poolside, and the user flag was sitting in that account’s home directory.
User flag: [redacted]
Privilege escalation split into two separate problems. First, the process list showed another app running under a different user, pipelinesvc, started with a Node debugging flag, --inspect=127.0.0.1:9229, bound to localhost only, but a shell on the box already counts as local.
Talking to that debugger doesn’t work through a normal browser dev-tools connection here. Querying its own JSON endpoint hands back a webSocketDebuggerUrl, but without the ws module installed there was nothing to speak that protocol with directly, so the actual connection went through Node’s own node inspect client instead.
Once connected, plain require isn’t available in that evaluation context either, so the exact same trick from the SSTI bug, routing through process.mainModule.require, ran a shell command as pipelinesvc instead, uid=995(pipelinesvc) gid=995(pipelinesvc) groups=995(pipelinesvc),6(disk). That last part is the second bug: this account sits in the disk group.
Membership in disk grants raw read and write access to block devices, which sidesteps every normal file permission entirely, because it’s not reading a file through the filesystem’s rules, it’s reading the raw disk the filesystem sits on top of. The same debugger session resolved which device the root filesystem actually lived on with readlink -f /dev/root.
Reading the flag off that raw device needed the same base64-wrapping trick as the reverse shell, this time around a disk-forensics tool, debugfs, that can read a specific file straight from a raw block device without mounting anything or needing a root shell at all.
Root flag: [redacted]
What actually stuck with me, since defense is where I’m headed. Before this room I wouldn’t have known what any of these four things looked like from the other side. Now I do: a $ne or $regex operator inside a login body reads as a NoSQL injection attempt to me. A <%= tag or process.mainModule.require showing up in form data headed to a template-rendering endpoint reads the same way for SSTI (Server Side Template Injection). A process bound to a --inspect debugger port with something connecting to it locally is the kind of thing I’d want a detection rule watching for, quiet enough that it’s easy to miss otherwise. And I want to start checking group membership the same way I’d check sudo -l, disk, video, docker, whatever it turns out to be, since it can hand out the same level of access without sudo ever being involved. Four patterns I didn’t have in my head before this room, and do now.
Room 8 — Towel on the Sunbed, an Idea I Almost Gave Up On
This is the one room in the event so far where I actually started in the right place on my own. I’d done a room built around race conditions before, so a race was my first real guess for what this room wanted. I tried it myself, first, before bringing Claude in at all.
The app is a fake staking dashboard, Ponzi Portfolio: log in, claim 50 PONZI every 24 hours, and a vault that only opens once the balance crosses 150.
My first attempt at the race fired only 5 requests at the claim endpoint at once. Not enough. The claim cooldown locked in immediately after, over 23 hours remaining, and the balance hadn’t moved past one successful claim.
That failed attempt cost me real time, though not 24 hours of it, I wasn’t about to sit around waiting out a cooldown I’d already blown. Registering a fresh account resets that cooldown, so every later attempt just ran against a new account instead of waiting out the old one. I second-guessed the race idea itself, though, and spent a long stretch with Claude going down every other lead the app offered: sending extra fields in the claim request to see if any of them changed the payout, swapping the request method, spoofing X-Forwarded-For to dodge the rate limit, trying an IDOR on a numeric ID parameter, sending a fake balance straight in at registration, fuzzing for a hidden transfer or price-update endpoint, even trying the same NoSQL $ne trick that worked back in Room 7. Every single one of them was already closed off. The app validated types, checked balances server-side, ignored extra fields, and the session cookie wasn’t forgeable. Twenty-some dead ends later, checking the vault directly still gave the same refusal.
The room’s own hint was pointing at the race the whole time, I just hadn’t taken it seriously enough after the first attempt fizzled: “He’s convinced the app owes him a spot in the Whale Vault. The app disagrees, politely, once every 24 hours. Somewhere between his request and the server’s clock, there’s a gap wide enough to walk a whale through.”
Going back to the race was the actual fix, just with a real dose behind it this time, on a new account with a clean cooldown. The claim endpoint checks eligibility, credits the balance, and only then sets the cooldown, and those three steps aren’t atomic, so a burst of requests can all pass the eligibility check before any of them have set the cooldown yet. In Burp, that meant duplicating the claim request into a group, this time around 30 copies instead of 5, and firing the whole group at once with Burp’s parallel single-packet send instead of one at a time.
Enough of those 30 landed inside the same gap that the balance jumped from 50 to 1150 in one burst, blowing straight past the 150 threshold, and the dashboard reflected it: Whale tier, vault open.
Flag: [redacted]
The lesson I actually want to keep from this one isn’t the vulnerability class, I already knew races existed. It’s that having the right idea on the first try doesn’t mean much if the execution behind it is too weak to prove it, and that a failed test isn’t always the idea being wrong, sometimes it’s just underpowered. I nearly filed “race condition” away as a checked box that didn’t pan out, instead of a hypothesis I’d tested badly.
Lessons Learned
Warm-up: I went for the complicated answer before checking the obvious one. Steganography before actually reading the room’s own OSINT tag. The lesson wasn’t a technique, it was to stop and read what’s already in front of me before reaching for something harder.
Room 1: VERA’s vulnerability lines up with two categories from the OWASP Top 10 for LLM Applications: prompt injection, claiming a fake identity to get the model to drop its own rules, and sensitive information disclosure, the system prompt and escalation code leaking out once it did. I’d actually gone through an audit like this before, on my own tools, in Auditing barb, vex, sift against the OWASP LLM Top 10. Recognizing the pattern here wasn’t expertise so much as having read that same list carefully once already.
Room 2: looking up the wget flags isn’t the gap, that’s just a normal search, nothing to feel bad about. Git is the actual gap. I got through this room with one looked-up command, git checkout --, without understanding what it was really doing to the repository. I need to sit down and go through a proper git course rather than keep patching this over room by room.
Room 3: this is the room where I felt the widest gap of the event so far. I had zero prior contact with AWS, Cognito, DynamoDB, or IAM roles, and I got through it by having Claude explain the concepts to me step by step rather than working it out myself. I’m naming that plainly rather than dressing it up as my own discovery. What I’m actually taking from it: temporary, “free” cloud credentials are only as safe as the permissions attached to them, and an app that only ever asks for one row doesn’t mean the role behind it is actually restricted to one row.
Room 4: a repeating XOR key restarts at the beginning of every independently encoded chunk, it doesn’t keep counting across chunks just because you concatenated them afterward. Decode each piece on its own, not the joined-up whole. The two questions I asked Claude near the end, why the XOR wasn’t lining up and what tool to reach for once I knew why, were both probably a search away, I just asked because it was faster, the same reason I’d lean over and ask a mentor sitting next to me instead of digging through search results.
Room 5: general offensive Linux work is still new to me, so this one leaned on Claude for hints the whole way through, not for a solution handed to me. Two real takeaways. First, technical: check ps aux and /proc/*/cmdline early when nothing else on a box looks exploitable, a plaintext secret sitting in a process’s own arguments is an easy thing to miss and an easy thing to leak. Second, and this one isn’t really about the room: when a password fails and you’re sure it’s the right one, check for your own typo before you go looking for a more complicated explanation. I had the root password early and talked myself out of it.
Room 6: no real struggle here, just a note on method. Searching the exact quoted sentence from the conversation, instead of guessing at keywords for what the tool might be, is what actually surfaced Gravatar. And once I knew the site, restricting the search to it with site: found Lambo’s profile directly, instead of digging through open-web results for the same name.
Room 7: the most AI-guided room of the event so far, and I’m not going to dress that up. Recon was mine, everything past that was Claude walking me through techniques I’d never touched: NoSQL injection, server-side template injection, Node’s own debugger as a pivot point, disk-group access as a root-equivalent path. One technical thread ran through half of it: process.mainModule.require showed up as the fix twice, once for the template injection, once again for the debugger, the same one workaround to Node blocking a plain require() call. The bigger takeaway isn’t a command, it’s that I now recognize what all four of these look like from the defending side, which matters more to me than being able to reproduce the attack myself right now.
Room 8: different shape of mistake than the other rooms. This time I had the right instinct immediately, from a race-condition room I’d already done, and still nearly talked myself out of it because my first attempt at proving it was too weak to work. Five requests failed, so I spent a long stretch ruling out a whole list of other, already-hardened leads before circling back to the idea I’d started with. Getting the dose right, roughly 30 concurrent requests instead of 5, was what actually closed it. A failed test doesn’t tell you the idea was wrong, only that this particular attempt at it was.
Continued in Part 2
Room 9 through the event’s last room, 14, continue in Part 2, including a look back at the whole event once it wrapped up.
References
- Hacker Holidays event hub, TryHackMe
- TryHackMe Room — The Brochure (Warm-up)
- TryHackMe Room — The Concierge Knows Too Much (Room 1)
- TryHackMe Room — Room 404 (Room 2)
- TryHackMe Room — Complimentary (Room 3)
- TryHackMe Room — Packed Light (Room 4)
- TryHackMe Room — Beach Bar (Room 5)
- TryHackMe Room — Overheard at Breakfast (Room 6)
- TryHackMe Room — Do Not Disturb (Room 7)
- TryHackMe Room — Towel on the Sunbed (Room 8)
- CyberChef
- revshells.com
- Tesseract OCR
- Gravatar
- TryHackMe Room — the race-conditions room referenced in Room 8
- Auditing barb, vex, sift against the OWASP LLM Top 10































































