CyberWave 2025 is an “indie” national CTF held by JKTCoders. I think it’s open for everyone, not just high schoolers, because I seen some PETIR sub-team in the leaderboards—one is on the podium! The challenges was not that hard, I guess, or at least, it can be easily beaten by AIs. What’s interesting about this CTF is that it uses the fixed scoring system, amidst virtually all other CTFs using the dynamic scoring one (actuall easier & better). Wait, they actually do use dynamic, but the set the starting values differently and the recline value (or whatever u call it) small. …I think?
Also, if u read the description above, u may guess that, well, I AM THE BURDEN.
I didn’t know why but it seems like I was short-circuiting.
The thing is this competition started at 8:00—but got delayed—and I just had ANOTHER competition (ARA OlimpIt) at 09:00 till around 10:15.
It was an olympiad about IOT and cyber security, and thanks to my teammate covering IOT questions for me, so I didn’t even study for it.
We even completed and submitted our answers just one hour in. Like, it was nothing to worry about.
I might get a lil freaky and silly (laughing at the questions), but that’s about it.
But how can’t I focus on the damn CTF?
I CAN’T SOLVE GROGOL FOR HOURS, but when I up-solve it from scratch, I just did it in freaking 15 minutes?!?!
Why didn’t I tried the other rev challenges then?
Well, duh, if only the goat genggx. didn’t solved them already…
So yeah, I scored 0 points for the team.
I don’t know how to feel about myself right now…
…
Also, you can see the online gdocs version of this write-up here! Have fun reading!! :>
REVERSE ENGINEERING
[600] Grogol [9 Solves]
Description
Although Grogol asks for a number greater than infinity, just reverse the check and secure your glorious victory now.
Download files here.
Overview
We are given a 64-bit ELF binary acting as a custom stack-based calculator. TL;DR: It tokenizes (strtok) the input, hashes the tokens to be used as opcodes, and executes them on a custom Virtual Machine (VM). The goal is to provide an input string that results in the final stack’s top value of 31337.
Analysis
Upon opening the binary in IDA Pro, we analyze main. From here on, decompilation code snippets have been renamed for readability; for you! Interestingly, this is a RISC-V binary!! Something refreshing for once… The program sets up standard buffering and reads input via Fgets.
287if ( !Fgets(Input, 4096, STDIN) )
288 goto L_ExitFail;
289if ( (unsigned __int64)Strlen(Input) > 21 )
290{
291 Print(2, "Your input is too long! ->>>>");
292L_ExitFail:
293 Status = 1;
294 goto L_EXITFAIL;
295}See that?! We have a constraint. The input length must be less than or equal to 21 characters. Next…
The input is split into tokens using Strtok (space, tab, newline). These tokens are then hashed using a custom algo, then mapped to opcodes with actions such as addition, multiplication, and push-to-stack. It calculates a rolling XOR hash for each token; effectively, we can just brute-force all possible values for a byte (256 of them) and create a reverse map of the hashes opcodes.
I looked into this RISC-V reference, and found a decompilation error:
1121 state_A = 1;
1122 state_B = 0;
1123 if ( *pBuf1024 )
1124 {
1125 pBuf1024_3 = pBuf1024;
1126 LowNib = 1;
1127 Len_1 = 1LL - (_QWORD)pBuf1024;
1128 do
1129 {
1130 Len = Len_1 + (_BYTE)pBuf1024_3;
1131 state_A = LowNib >> 1;
1132 state_B = (__int64)&pBuf1024_3[Len_1];
1133 Val = (unsigned __int8)*pBuf1024_3++;
1134 LowNib = Val ^ (LowNib >> 1);
1135 state_A = LowNib;
1136 }
1137 while ( *pBuf1024_3 );
1138 if ( ((unsigned __int8)(16 * (*pBuf1024 ^ Len)) | (unsigned __int64)(LowNib & 0xF)) == 0x27 )
1139 {
1140 A_ccumulator += 30;
1141 ::A_ccumulator = A_ccumulator;
1142 }This loop, which reoccurs for every time an opcode check is made (how redundant), calculates the State values, and then does a check to see if it matches the opcode (0x27 in the example). The multiply by 16 is the same as « 4, meaning, it shifts the value into the higher nibble.
We do not need to reverse the collision check perfectly if we simply brute-force short strings (maybe a char, even) that result in the opcodes.
As I said, the binary implements a custom stack machine:
- Accumulator: stores the current intermediate value.
- HighPart: Stores large multiplied values (e.g., when multiplying by 1000 or 1,000,000).
- Multiplier: Toggles between 1 and -1 (for subtraction/negative numbers).
- Stack: Used to store operands and operators.
312 else
313 {
314 Idx = 0;
315 }
316 Buf1024[Idx] = 0;
317 pBuf1024 = (_BYTE *)Buf1024[0];
318 Multiplier = 1;
319 A_ccumulator = ::A_ccumulator;
320 B_HighPart = ::B_HighPart;
321 C_ExprStackPtr = ::C_ExprStackPtr;
322 D = ::D;The VM processes these tokens in a loop. When the loop finishes, it performs a final calculation.
1411 v209 = ::B_HighPart;
1412 v210 = ::A_ccumulator * Multiplier;
1413 v211 = ::D;
1414 v212 = &STACK[2 * ::C_ExprStackPtr];
1415 v213 = ::C_ExprStackPtr + 1;
1416 ::A_ccumulator = 0;
1417 ::B_HighPart = 0;
1418 ++::C_ExprStackPtr;
1419 *(_DWORD *)v212 = 1;
1420 v212[1] = v210 + v209;
1421 Multiplier = 1;It then checks if the top of the stack is 31337.
1498if ( STACK[2 * STACK_TOP + 1] != 31337 )Next, we map the opcodes to the actions they perform. I used Helix to make this easier, using regexes and macros to make a dictionary of the opcode hex and its function. I got the following:
| Opcode | Action |
|---|---|
| 6A | TOP += 7 |
| 34 | TOP += 40 |
| DF | TOP += 18 |
| 1f | TOP += 15 |
| a5 | TOP += 9 |
| 79 | TOP += 2 |
| 28 | TOP += 5 |
| 71 | TOP += 10 |
| 38 | TOP += 11 |
| 6F | TOP += 19 |
| 2D | TOP += 12 |
| EE | TOP += 14 |
| 00 | TOP += 6 |
| 2F | TOP += 4 |
| CE | TOP += 13 |
| 31 | TOP += 50 |
| 82 | TOP += 90 |
| AF | TOP += 17 |
| 02 | TOP += 8 |
| 11 | TOP += 3 |
| 67 | TOP += 60 |
| 41 | TOP += 70 |
| 27 | TOP += 30 |
| c9 | TOP += 1 |
| 4E | TOP += 16 |
| 30 | TOP += 80 |
| 20 | TOP += 20 |
| f2 | TOP *= 100 |
| C0 | TOP *= Multiplier * 1e3 -> B |
| aa | TOP *= Multiplier * 1e6 -> B |
| 5a | TOP *= Multiplier * 1e9 -> B |
| CA | TOP *= Multiplier * 1e12 -> B |
| 6e | Multiplier = -1 |
| 10 | push TOP; push multiplication operator |
| 37 | push TOP; push division operator |
| 89 | push TOP; push subtraction operator |
| 4c | push TOP; push addition operator |
| e8 | push TOP; push modulo operator |
For the push instructions, they push the current immediate value, and also an operator.
Solution
Now that we know what we’re dealing with, let’s find the opcodes to construct 31337. We are limited by input length (21 chars), so we cannot simply add 1 thirty-one thousand times. We must use multiplication.
31337 = 31000 + 337
31337 = (31 x 1000) + (3 x 100) + 37
31337 = ((30 + 1) x 1000) + (3 x 100) + 30 + 7
So, the VM Instruction Sequence is
- Load 30: ADD_30 (Accumulator = 30)
- Add 1: ADD_1 (Accumulator = 31)
- Multiply by 1000 & Store: MUL_1000
- B_HighPart becomes 31,000.
- A_ccumulator becomes 0.
- Load 3: ADD_3 (Accumulator = 3)
- Multiply by 100: MUL_100 (Accumulator = 300)
- Note: MUL_100 acts directly on A_ccumulator, unlike MUL_1000.
- Add 30: ADD_30 (Accumulator = 330)
- Add 7: ADD_7 (Accumulator = 337)
At program termination, the VM sums A_ccumulator and B_HighPart:
337 + 31000 = 31337
What’s left is getting the input that translates to our target opcodes. Since we can’t type “ADD_30”, we need to find short strings that generate the required hash opcodes. I tried with just a char, but not a single one of them produces is the opcode we want. We’ll use itertools.product to brute-force these collisions. Referring back to the opcodes we extracted above, our payload will be:
- ADD_30: 0x27
- ADD_1: 0xC9
- MUL_1000: 0xC0
- ADD_3: 0x11
- MUL_100: 0xF2
- ADD_7: 0x6A
Solver:
|
|

Flag: cyberwave{belajar_rev_di_google_aja}
[230] Sega Saturn [17 Solves]
Description
sedikit bit membuat sakit kepala
Download files here.
Overview
We’re given a website (for a rev challenge?) that looks like this:

That’s a fake flag obvi. Something interesting show up when we “View Page Source”:
|
|
Analyze
Looking at the source code, we find a <script> block containing the challenge logic. That was easy. There are two immediate red herrings:
- A variable flag = “cyberwave{gu3s5_th3_fl4g_4nD_try_h4rd3r}”.
- A DOM update setting the text to “Flag: cyberwave{f1nD_tH3__fl4g_4nd_try_h4rd3r}”.
These are clearly decoys obviously, duh. The real data of interest is a large, unused integer array named seq and an encode function. Straightforward! Just decode it!
It implements a custom encryption scheme driven by RNG, a standard Linear Congruential Generator (LCG), specifically the “MinStd” generator used in C++’s minstd_rand0. 🤓 (AI, not me)
18function RNG(seed) {
19 return function() {
20 seed = (seed * 48271) % 2147483647; // Multiplier A=48271, Modulo M=2^31-1
21 return seed;
22 };
23}On the other hand, the encode function in the script uses a hardcoded seed of 1337. However, decoding seq using seed = 1337 yields garbage (talks about inconsistency). So, I guess, the seq array provided in the source was generated with a secret seed that we must recover.
Let’s analyze the rng() usage per character. For every character in the flag, the RNG is called exactly three times:
- r1: Used for adj (modulo 93).
- r2: Used for XOR key (lower 8 bits).
- r3: Used for padding (lower 24 bits).
The output for one character is stored as a 32-bit integer big.
big = (encrypted_byte << 24) | (r3 & 0xFFFFFF)
This structure leaks the lower 24 bits of every 3rd RNG output directly in the ciphertext.
Since the LCG operates modulo 231−1 (approx 2.1 billion), recovering the full state from 24 bits is trivial because the missing information is small (7 bits).
Solution
We can perform a brute-force attack to recover the internal state of the RNG.
- Take the first element of seq, seq[0]. The lower 24 bits represent the 3rd RNG output (r3) modulo 224.
L0 = seq[0] & 0xFFFFFF
- Brute-Force High Bits: The actual RNG value r3 must satisfy:
r3 = (k x 224) + L0
- Since the LCG modulus M = 231−1, the maximum value for r3 is slightly less than 231.
Max k ≈ 231/224 = 27 = 128
We only need to iterate k from 0 to 127 to find the true r3!
- Verification: For each candidate r3:
- Run the RNG forward 3 steps to generate r4, r5, r6.
- Compare the lower 24 bits of r6 with
seq[1] & 0xFFFFFF. - If they match, we have found the correct state.
- Backtracking: Once we have the correct r3, we can mathematically step backwards to find the initial seed (s0) used to start the sequence. This requires the modular multiplicative inverse of the multiplier A = 48271.
sprev = (snext x A−1) (mod M)
Applying this 3 times from r3 gives us the initial seed. Once the seed is recovered, decryption is a straightforward reversal of the encoding steps:
- Initialize RNG with the recovered seed.
- Loop through seq:
- Generate 3 RNG values: r1,r2,r3.
- Calculate adj from r1.
- Calculate xor_key from r2.
- Extract encrypted_byte from the top 8 bits of the seq value (val » 24).
- Reverse XOR: temp = encrypted_byte ^ xor_key.
- Reverse NibbleSwap: The operation is symmetric. temp = nibbleSwap(temp).
- Reverse Addition: plain_char = (temp - adj) % 256.
Solver:
|
|

Flag: cyberwave{c4n_yoU_5olV3_th1s_l1f3_eQu4t10n}
[610] KeygenMe [26 Solves]
Description
Vendor bilang ini sistem license paling aman
Download files here.
Overview
We’re given an ELF x64 Linux (you know what that means) binary! TL;DR: Just jump to the success branch lol lmao xd. This challenge wants us to generate a license key for any given name, but we’re not doing that today!
Analyze
Load this into IDA Pro, and we’ll have the main function. Now, would you care to look at it???
|
|
This is an actually short program compared to what we’ve seen so far. To spare you the details, we don’t actually need to know what the license generation algorithm is; You can skip this section and go to Solution. What it does is:
- reads Username and License,
- enforces a strict license format: CW25-XXXX-XXXX-XXXX, where XXXX are 4-character Hex strings, based on the Username,
- and these hex strings are parsed into three 16-bit integers: chunk1, chunk2, and chunk3.
The thing that the author wants us to reverse is:
- The code iterates through the Username twice to generate constraints.
2. Loop 1: Computes a hash i using a rolling XOR/Rotate algorithm.
3. Loop 2: Computes two state variables, P and Q, using complex bitwise operations (ROL4, ROR4, multiplication by primes like 257, 31, 17). - These values (P, Q, i) are mathematically deterministic based solely on the Username.
- The program calculates R and S from the username hashes.
- It then checks if the user-provided chunk1, chunk2, and chunk3 match specific values derived from R and S:
7. chunk1 must equal (S ^ R) (masked to 16 bits).
8. chunk2 and chunk3 follow similar derived formulas. - Implication: For any given username, there is exactly one mathematically valid License Key.
- If the key is valid (“Access Granted!”), the program proceeds to decrypt the flag.
- It selects a decryption function (A1XorA2 or A1XorA2_0) based on MagicIdk (which is derived from the key chunks and hash i).
- It iterates 36 times, updating a PRNG state G using sub_5555555554F0(G) and XORing the result with EncryptedFlag.
Solution
Just look at it. Can’t you see? Both the username and the license key have nothing to do with the flag decryption and printing. So? All we need is to just get to the success branch (the one with “Access Granted”) and let the binary print the flag for us!
We can do this in IDA, or maybe even gdb if you’re into it. Since this solution is quite short, I’ll add the tools that I used here and the steps that I took:
- Run ‘$IDA_INSTALL/IDA Debug Server/linuxserver64’ ‘./cyberwave_keygenme’ on a supported machine (in my case, an Ubuntu Server machine using UTM).
- On IDA Pro 9.0, put a breakpoint:
|
|
- Next, at top center, choose “Remote Linux Debugger”, Debugger » Start Process, input my machine’s hostname/IP, then OK.
- Run the debugger, input our name and “license key”, and we’ll hit the breakpoint.
- Now change RIP in the “General registers” window to where the success message puts call is located (0x555555555325).
- Then just “Continue process…”! Have a good flag!

Flag: cyberwave{keygenme_easy_medium_2025}