Collection of writeups from NUS Greyhats
Game
Game 1, 2 and 3 features the identical RPG Maker Game but built for different platforms (exe, APK and web).
For all three games, the flags are split into 5 parts and stored in the following locations
- Flag 1 is an item that's dropped after defeating level 1.
- Flag 2 is an item that's dropped after defeating level 2.
- Flag 3 can be found in a chest after defeating level 3.
- Flag 4 is ingrained into the map after defeating level 4.
- Flag 5 is an item that's obtained after level 5? (i'm not sure but that's not relevant)
Game 1
I used https://www.save-editor.com/tools/rpg_tkool_mz_save.html to modify my rmmzsave file that is generated after playing the game and saving your progress to one of the save slots.
I modified my save file to set the following attributes:
- _items =
{'33':1, '34':1, '35':1, '36':1, '37':1}
- adds Flag 1, 2, 5 to your inventory
- _buffs =
[100,100,100,100,100,100,100,100]
- increase our stats exponentially to one-shot enemies
- _buffTurns=
[100,100,100,100,100,100,100,100]
- ensure that our buffs last long enough
Game 3
This was the same game but on a web browser. This made it significantly easier since we were able to directly modify/patch the javascript as the game was running.
We can set two breakpoints in the javascript code to help us easily obtain the different part of the flag and clear the enemies.
With these two hacks, we can easily obtain the flag in the same way as Game 1.
Game 2
This is the same game but compiled for an APK. If we use 7zip to extract the contents of a zip, we will find the game in assets/www
.
We can host the game by running python -m http.server 8080
in the assets/www
folder, which would give us the exact same challenge as Game 3.
The solution hereinafter is identical to that of Game 3.
Pwn
Screenwriter
We are given the program code as follows
We can notice a few things from the code above
- There is an obvious buffer overflow on our input on the heap.
- After our input is allocated on the heap, two files are opened with
fopen
.
- Files that are opened with
fopen
are backed with an _IO_FILE
struct that is allocated on the heap.
- We are able to
fread
and fwrite
to/from the opened files.
- Our buffer overflow on our input will then overflow into the
_IO_FILE
struct on the heap. Any experienced pwner would identify that this is a FSOP attack.
Let's look at the fields in the _IO_FILE
struct
In order to read the contents of a file, we will need to invoke syscalls which is inefficient.
Instead, when fread
is called for the first time, we first read the entire file content onto the heap.
Afterwards, we set our IO pointers to the allocated file content and return the requested buffer from the heap.
Most importantly, the idea is that upon calling fread
, it will return you the buffer between _IO_read_ptr
and _IO_read_end
. Thus by overwriting those two pointers, we can gain an arbitrary read to any known address.
(i don't remember which exact pointers are necessary to get arbitrary write but we can just write to all of the above. you may read the glibc source code for more information)
Similarly, we can overwrite _IO_write_base
, _IO_write_ptr
, _IO_write_end
, _IO_buf_base
and _IO_buf_end
to gain an arbitrary write for fwrite
.
In order to pop a shell from an arbitrary write, the easiest target for glibc 2.35 would be to overwrite _IO_2_1_stdout
with a one shot payload.
And we get our shell.
Rev
Drivers
Stage 1
After obtaining the shellcode, we can convert it to an exe with https://github.com/repnz/shellcode2exe for ease of analysis with IDA/Ghidra.
We can use a debugger to step through the functions to understand the functionalities.
In order to make our life easier, we can define the dynamically resolved APIs in a struct as follows
After applying the struct and doing more dynamic analysis, we get code as follows
code is cleaned up with comments
We can dump out the extracted shellcode by setting a breakpoint on qmemcpy
and dumpin gout sc_len
of bytes from shellcode
.
Stage 2
The API struct used is identical to Stage 1.
From the above cleaned code, the program reads the following registry keys
Computer\HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\ProgramFilesDir
which has the value C:\Program Files
by default.
Computer\HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\ProgramFilezDir
which contains an unknown value.
The check_flag function is as follows
It takes a bunch of characters from programfiles_dir
and programfilez_dir
and hashes it to check the value.
We can abstract the code logic into python
As you can tell, our input are hexadecimal characters [0-9a-f]. The program checks the hash of the last 16 bytes and the entire 32 bytes of the flag.
In the last 16 bytes of the flag, we have 8 unknown characters. Assuming that the 8 unknown characters are hexadecimal characters, we will have 16**8
possible values.
After recovering the last 16 bytes, there is only 7 unknown characters in the first 16 bytes which is even more trivial to brute force 16**7
.
Finally, we can ask ChatGPT to write us some C++ code to brute force the possibilities.
#include <iostream>
#include <cstdint>
#include <array>
#include <vector>
#include <string>
#include <cstdio>
#include <chrono>
inline uint32_t fnv1a_32(const uint8_t *data, size_t len)
{
static const uint32_t FNV_OFFSET_BASIS = 0x811C9DC5u;
static const uint32_t FNV_PRIME = 0x1000193u;
uint32_t hash = FNV_OFFSET_BASIS;
for (size_t i = 0; i < len; i++) {
hash ^= data[i];
hash *= FNV_PRIME;
}
return hash;
}
int main()
{
const uint32_t TARGET_HASH = 0x7C8DB53D;
std::array<char, 16> plaintext = {
'x', 'f', 'x', '0',
'd', 'x', 'x', '7',
'3', '2', 'x', 'x',
'x', 'x', 'c', '9'
};
auto nibbleToHex = [](uint8_t x) -> char {
return (x < 10) ? (char)('0' + x) : (char)('a' + (x - 10));
};
const char hexDigits[16] = {
'0','1','2','3','4','5','6','7',
'8','9','a','b','c','d','e','f'
};
std::vector<int> unknownPositions = {0,2,5,6,10,11,12,13};
auto startTime = std::chrono::steady_clock::now();
const uint64_t totalCombos = 1ULL << (4 * unknownPositions.size());
uint64_t counter = 0;
bool found = false;
for (uint64_t combo = 0; combo < totalCombos; combo++) {
uint64_t temp = combo;
for (size_t i = 0; i < unknownPositions.size(); i++) {
uint8_t nibble = temp & 0xF;
temp >>= 4;
plaintext[ unknownPositions[i] ] = hexDigits[nibble];
}
uint32_t h = fnv1a_32(
reinterpret_cast<const uint8_t*>(plaintext.data()),
plaintext.size()
);
if (h == TARGET_HASH) {
found = true;
std::string recovered(plaintext.begin(), plaintext.end());
std::cout << "[+] Found match! Plaintext = " << recovered
<< " -> 0x" << std::hex << h << std::dec << "\n";
}
if ((combo % 100000000ULL) == 0) {
std::cout << combo << " / " << totalCombos << " tried...\n";
}
}
auto endTime = std::chrono::steady_clock::now();
double seconds = std::chrono::duration_cast<std::chrono::seconds>(
endTime - startTime
).count();
if (!found) {
std::cout << "[-] No match found in " << totalCombos << " tries.\n";
}
std::cout << "Completed in " << seconds << " seconds.\n";
return 0;
}
Now do the same for the first 16 bytes of the flag. Wrap it in wgmy{}
and submit it!
Stones
https://elijahchia.gitbook.io/ctf-blog/wargames.my-ctf-2024/stones-rev
Sudoku
Observe that the binary is a Pyinstaller compiled executable.Use pyinstxtractor to extract sudoku.pyc
out, and use pylingual to decompile the pyc file.
Write a reverse script to reveal the solution.
This reveals most of the flag: w.my{2ba914045b56c5e58..1b4a593b05746}
Observe that the flag format should be wgmy
, and the "." in the MD5 portion of the flag has to be the letter "f" (as it has to be a valid MD5 hash).
Flag: wgmy{2ba914045b56c5e58ff1b4a593b05746}
Post Quantum Cryptography
Curve
LWE
import os
import random
import numpy as np
import signal
def change_support(support):
while (t := random.randint(0, n - 1)) in support: pass
support[random.randint(0, k - 1)] = t
return support
n = 500; p = 3691; k = 10; m = 20 * n
seed = os.urandom(16)
random.seed(seed)
A = np.zeros((n, m), dtype=int)
support = random.sample(range(n), k)
columns = list(range(m))
random.shuffle(columns)
for i in columns:
if (random.randint(0, 2) == 0):
support = change_support(support)
A[support, i] = [random.randint(0, p - 1) for _ in range(k)]
secure_random = random.SystemRandom()
s = np.array([secure_random.randint(0, p - 1) for _ in range(n)])
e = np.round(np.random.normal(0, 1, size=m)).astype(int)
b = (s @ A + e) % p
import random
import numpy as np
n = 500; p = 3691; k = 10; m = 20 * n
def change_support(support):
while (t := random.randint(0, n - 1)) in support: pass
support[random.randint(0, k - 1)] = t
return support
random.seed(seed)
A = np.zeros((n, m), dtype=int)
support = random.sample(range(n), k)
columns = list(range(m))
random.shuffle(columns)
for i in columns:
if (random.randint(0, 2) == 0):
support = change_support(support)
A[support, i] = [random.randint(0, p - 1) for _ in range(k)]
from sage.matrix.matrix_mod2_dense cimport Matrix_mod2_dense
from sage.matrix.matrix_modn_dense_double cimport Matrix_modn_dense_double
cimport numpy as np
cpdef copy_from_numpy(Matrix_mod2_dense A, np.ndarray[np.int8_t, ndim=2] B):
assert A.nrows() == B.shape[0]
assert A.ncols() == B.shape[1]
for i in range(B.shape[0]):
for j in range(B.shape[1]):
A.set_unsafe(i, j, B[i, j])
cpdef copy_from_numpy_2(Matrix_modn_dense_double A, np.ndarray[np.int64_t, ndim=2] B):
assert A.nrows() == B.shape[0]
assert A.ncols() == B.shape[1]
for i in range(B.shape[0]):
for j in range(B.shape[1]):
A.set_unsafe_int(i, j, B[i, j])
def flatter(M):
if M.nrows() > M.ncols():
M = M.LLL(delta=0.75)
from subprocess import check_output
from re import findall
z = "[[" + "]\n[".join(" ".join(map(str, row)) for row in M) + "]]"
ret = check_output(["flatter", "-rhf", "1.03"], input=z.encode())
return matrix(M.nrows(), M.ncols(), map(ZZ, findall(b"-?\\d+", ret)))
def closest_vector(A, w):
global B
c = sum([abs(a) for row in A for a in row]) + 1
B = matrix.block([
[A, matrix.zero(A.nrows(), 1)],
[matrix(w), c],
])
B = B.LLL()
first_nonzero_row = np.nonzero(np.any(np.array(B), axis=1))[0][0]
B = B[first_nonzero_row:]
assert B[:-1, -1]==0
row=B[-1]
B=B[:-1,:-1]
assert row[-1] != 0
if row[-1] == c:
return B, w - vector(row[:-1])
else:
assert row[-1] == -c
return B, w - vector(-row[:-1])
def solve(A, AS, b, s, e, columns, path, hint):
if AS.nrows() == 0:
return np.array([])
print(AS.nrows(), AS.ncols())
assert AS.nrows() == AS.rank()
def try_reduce(num_columns, i):
nonlocal A, AS, b, s, e, columns
ss = None
if s is not None:
ss = vector(GF(p), s)
columns_slice = slice(i-num_columns, i)
row_slice = np.nonzero(np.sum(np.abs(np.array(AS[:, columns[columns_slice]], dtype=int)), axis=1))[0]
if s is not None:
assert ss * AS[:, columns[columns_slice]] == vector(GF(p), (b-e)[columns[columns_slice]])
assert vector(s[row_slice]) * AS[[*row_slice], columns[columns_slice]] == vector(GF(p), (b-e)[columns[columns_slice]])
B, v1 = closest_vector(
matrix.block(ZZ, [
[matrix.identity(num_columns)*p],
[AS[[*row_slice], columns[columns_slice]]]
]),
vector(ZZ, b[columns[columns_slice]])
)
okay = np.ones(B.ncols(), dtype=int)
for row in B.rows():
if np.linalg.norm(row) < 20:
okay &= ~np.array(row)
else: break
okay=okay.astype(bool)
if e is not None:
v2 = (b-e)[columns[columns_slice]]
assert (v1 == v2)[okay].all()
good_columns=[*np.array(columns[columns_slice])[okay]]
sr = [None]*AS.nrows()
if s is not None:
assert vector(s[row_slice]) * AS[[*row_slice], good_columns] == vector(np.array(v1)[okay])
row_slice = row_slice[~np.any(np.array(AS[[*row_slice], good_columns].left_kernel_matrix(), dtype=int), axis=0)]
for j, sj in zip(row_slice, AS[[*row_slice], good_columns].solve_left(vector(np.array(v1)[okay]))):
if sr[j] is not None:
assert sr[j] == sj
sr[j] = sj
if s is not None:
assert sr[j] == s[j]
remaining_row_slice = np.nonzero(np.array(sr) == None)[0]
s1=None
if s is not None:
s1 = s[remaining_row_slice]
remaining_column_slice = np.any(A[remaining_row_slice]!=0, axis=0)
AS1 = AS[[*remaining_row_slice],[*np.nonzero(remaining_column_slice)[0]]]
A1 = A[remaining_row_slice,:][:,remaining_column_slice]
b1 = (b - vector(GF(p),np.array(sr)[row_slice]) * AS[[*row_slice],:])[remaining_column_slice]
e1 = None
if e is not None:
e1 = e[remaining_column_slice]
assert vector(GF(p),s1)*AS1 == vector(GF(p),b1-e1)
assert vector(GF(p),s)*AS == vector(GF(p),b-e)
return sr, A1, AS1, b1, s1, e1, remaining_column_slice
def candidates_for(num_columns):
nonlocal AS
return sorted((AS[:, columns[i-num_columns:i]].rank(), i) for i in range(num_columns, AS.ncols()+1))[:50]
num_columns, max_rank = path[0]
num_columns=min(num_columns, AS.ncols())
remaining=[*range(num_columns, AS.ncols()+1)]
random.shuffle(remaining)
if hint:
remaining = [hint[0]]
candidates=[]
while remaining:
i = remaining.pop()
columns_slice = slice(i-num_columns, i)
actual_rank = AS[:, columns[columns_slice]].rank()
if actual_rank <= max_rank:
break
candidates.append((actual_rank, i))
if len(candidates) % 200 == 0:
print(sorted(candidates)[:50])
else:
print("!")
candidates = sorted(candidates)
raise RuntimeError(f"no candidates {candidates[:30]}")
print(f"{i=} {actual_rank=}")
global_hints.append(i)
sr, A1, AS1, b1, s1, e1, remaining_column_slice = try_reduce(num_columns, i)
column_remap=np.cumsum(remaining_column_slice)-1
sr2 = solve(A1, AS1, b1, s1, e1, [column_remap[x] for x in columns if remaining_column_slice[x]], path[1:], hint[1:])
sr = np.array(sr)
sr[sr==None] = sr2
return sr
AS = matrix(GF(p), *A.shape)
copy_from_numpy_2(AS, A)
assert AS.rank() == AS.nrows()
global_hints=[]
sr = solve(A, AS, b, s, e, columns, [(100, 30)]*200,
[8830, 9165, 9035, 8726, 1054, 143, 5448, 1708, 2803, 1370, 2342, 532, 3649, 6365, 1034, 1992, 7547, 6017, 1945, 3092, 1158, 3901, 2463, 2285, 1261, 4458, 1207, 3969, 2175, 136, 2519, 754, 1603, 844, 296, 564, 130, 195, 5, 0]
)
sr = solve(A, AS, b, None, None, columns, [(100, 30)]*200,
[8830, 9165, 9035, 8726, 1054, 143, 5448, 1708, 2803, 1370, 2342, 532, 3649, 6365, 1034, 1992, 7547, 6017, 1945, 3092, 1158, 3901, 2463, 2285, 1261, 4458, 1207, 3969, 2175, 136, 2519, 754, 1603, 844, 296, 564, 130, 195, 5, 0]
)
assert (sr==s).all()
while True:
proc = remote("43.217.80.203", 35166)
proc.readuntil(b'seed.hex() = ')
line = proc.readline().strip()
seed = bytes.fromhex(eval(line))
proc.readuntil(b'b.tolist() = ')
line = proc.readline().strip()
b = eval(line)
import random
import numpy as np
n = 500; p = 3691; k = 10; m = 20 * n
def change_support(support):
while (t := random.randint(0, n - 1)) in support: pass
support[random.randint(0, k - 1)] = t
return support
random.seed(seed)
A = np.zeros((n, m), dtype=int)
support = random.sample(range(n), k)
columns = list(range(m))
random.shuffle(columns)
for i in columns:
if (random.randint(0, 2) == 0):
support = change_support(support)
A[support, i] = [random.randint(0, p - 1) for _ in range(k)]
AS = matrix(GF(p), *A.shape)
copy_from_numpy_2(AS, A)
AS_rank = AS.rank()
if AS_rank != AS.nrows():
print(f"insufficient rank {AS_rank}")
proc.kill()
continue
break
sr = solve(A, AS, np.array(b), None, None, columns, [(100, 30)]*200, [])
proc.readuntil(b's: ')
proc.sendline(str(sr.tolist()))
proc.interactive()
LPN
Isogeny
Crypto
Hohoho 3
The whole thing is affine
note that p ^ q ^ r == s
if we send
we can xor the result
and paste the result back
Credentials
https://elijahchia.gitbook.io/ctf-blog/wargames.my-ctf-2024/credentials-crypto
Ricks Algorithm
https://elijahchia.gitbook.io/ctf-blog/wargames.my-ctf-2024/ricks-algorithm-crypto
Ricks Algorithm 2
https://elijahchia.gitbook.io/ctf-blog/wargames.my-ctf-2024/ricks-algorithm-2-crypto
Hohoho 3 Continued
https://elijahchia.gitbook.io/ctf-blog/wargames.my-ctf-2024/hohoho-3-continue-crypto
Misc
Watermarked?
Got this from social media, someone said it's watermarked, is it?
Googling for watermarking methods "github.com"
, we eventually stumble upon Meta Research's repo, Watermark Anything (fits the "got this from social media" part of the chall description)
In the repo, we are given examples of how their method is used to hide 1 or 2 watermarks on the image. Since the image given was a gif with 65 frames, I assumed that each frame contained a letter or a chunk of data of some sort.
With the help from our friendly neighbouhood claude.ai (i didn't want to write the gif extraction), we wrote some code to split each frame of the gif, and adapted their code used to predict which bits have the watermark and what the watermark is
By running the above code, we get the following output:
Converting each frame's binary data to ascii text, we get the following message:
Christmas Gift

https://jyjh.github.io/blog/posts/wgmy24/#dcm-meta
Invisible Ink
https://jyjh.github.io/blog/posts/wgmy24/#invisible-ink
Web
Wordmarket
- Obtain secret by POST request to admin-ajax.php with switch=1
- Register user with
shop_manager
role using the /add_user
endpoint.
- Send POST request to insert path traversal payload into country code field. This will cause
/flag.php
to be included.
Wizard Chamber
- Disable openRASP hooking:
- Read flag:
Dear admin
- Setup FTP server on own server. PHP
file_exists
will allow ftp://
scheme.
- Host payload in
templates/admin_review.twig
on FTP server:
- View review to obtain flag
Warmup 2
Path traversal in Jaguar library, use /proc/self/environ
to read env vars containing flag:
Secret 2
- Kubernetes auth enabled in Vault
- Service account JWT located in
/var/run/secrets/kubernetes.io/serviceaccount/token
, use path traversal from Warmup 2 to retrieve
- Exchange JWT for Vault access token, then fetch the flag:
Forensics
I Cant Manipulate People
tshark -r traffic.pcap -Y "icmp" -T fields -e data | xxd -r -p
Flag: WGMY{1e3b71d57e466ab71b43c2641a4b34f4}
Unwanted Meow
Remove all entries of the word "meow", twice
Flag: WGMY{4a4be40c96ac6314e91d93f38043a634}
Oh Man
Basically follow this writeup to extract NTLMv2 hash, cracking it reveals the password to be password<3
We can then inspect SMB3 traffic and find the output of nanodump
Run scripts/restore_signature
and then dump the LSASS secrets using pypykatz, revealing the flag
Flag: wgmy{fbba48bee397414246f864fe4d2925e4}
Tricky Malware
Use volatility3, pslist
shows that crypt.exe
is ran from "C:\Users\user\Desktop\Blow\crypt.exe"
Dump the binary out using dumpfiles
, observe that it is a Pyinstaller compiled executable. Use pyinstxtractor to extract crypt.pyc
out, and use pylingual to decompile the pyc file.
There's a pastebin link: https://pastebin.com/raw/PDXfh5bb, revealing the flag.
Flag: WGMY{8b9777c8d7da5b10b65165489302af32}
Blockchain
All blockchain writeups can be found here https://jyjh.github.io/blog/posts/wgmy24/#blockchain.