CyberWave 2025!

This event was organised by the JKT-CODERS Community as a competition to showcase skills in the world of Capture The Flag (CTF) and modern cyber security.

Reverse Engineering

What is Reverse Engineering? Reverse engineering (RE) in computer science is the process of analyzing a compiled, binary application to understand its inner workings, logic, and structure without access to the original source code. In Capture The Flag (CTF) competitions, it involves breaking down software to uncover hidden flags, bypass licensing, or analyze malware, typically using tools like debuggers and disassemblers.


Sega Saturn

In this challenge, as we can see on the main page that there is a text and the flag which is actually a fake flag, the text is telling that “Sega Saturn should be able to Solve this”

After I checked the source code, I discovered an algorithm which is the implementation of LCG standard (Linear Congruential Generator).

17...
18function RNG(seed) {
19    return function() {
20        seed = (seed * 48271) % 2147483647;
21        return seed;
22    };
23}
24...

And I also discovered an array named seq contains series of integers.

27...
28            const seq = [697438994, 112812148, 1601952528, 848567091, 1409540622, 719284623, 4183535244, 2852213902, 2109397207, 3611470734, 604567242, 1692610300, 2414225221, 1979551723, 3174382114, 425190723, 1060279654, 4283219352, 1099139615, 3427871953, 2056419824, 103242998, 2789231820, 97749902, 3832000502, 2437931514, 337329827, 1784389836, 1971115025, 2430188600, 420768160, 2890064672, 3927353914, 2033350795, 372941141, 2974609317, 1442092933, 2838369745, 3198352587, 230640255, 2641503210, 1721308159, 3764390994];
29...

As we remember on the main page there is a hint, namely “Sega Saturn” which is indicated 32-bit architechture.

The Sega Saturn (セガサターン, Sega Satān) is a 32-bit video game console first released by Sega on November 22, 1994 in Japan, May 11, 1995 in North America, and July 8, 1995 in Europe. It was discontinued in 1998 in North America, Europe, and Australia, and in 2000 in Japan.
Source: Wikipedia

In computer science, signed 32-bit integer has range of data up to $2^{31} - 1$. we know that we need to find each elements of array seq for 32-bit data structure. In the source code before, I saw 1 line which is a little bit crucial.

38...
39                    const big = (x << 24) | (rng() & 0xFFFFFF);
40...

from this one line, I discovered:

  • Bit 31-24 (MSB)
    • x « 24 shifting encrypted characters to the leftmost 8-bit.
  • Bit 23-0 (LSB)
    • rng() & 0xFFFFFF will have an output from LCG, then conduct AND operation with 0xFFFFFF (equals to 24-bit), then placed in the rightmost 24-bit.
  • Bitwise (|)
    • then the two segments before are combined.

I have known that the creator of this challenge leaks the raw internal state in the lowest 24-bit (right side). From here i have an access to $S_{n+1}$ from 24-bit which has been leaked, then i need to find the $S_{n}$ (initial seed).

The LCG worked in 31-bit space before, but there is 24-bit leaked, it means 7-bit is missing (MSB from RNG state). So, i just need to do recovery state as many as 128 iterations.

How? so we need the reverse operation of modular multiplication, which is modular multiplicative inverse.

If $S_{n+1} \equiv (S_n \cdot a) \pmod{m}$, so $S_n \equiv (S_{n+1} \cdot a^{-1}) \pmod{m}$ which $a^{-1}$ is modular invers from 48271 to 2147483647.

With Python, we can calculate $a^{-1}$ using the following Python script:

10...
11MODULUS = 2147483647
12MULTIPLIER = 48271
13
14INVERSE_MULTIPLIER = pow(MULTIPLIER, -1, MODULUS)
15...

To determine the initial seed, I’m using the following Python script:

 1seq = [
 2    697438994, 112812148, 1601952528, 848567091, 1409540622, 719284623, 4183535244,
 3    2852213902, 2109397207, 3611470734, 604567242, 1692610300, 2414225221, 1979551723,
 4    3174382114, 425190723, 1060279654, 4283219352, 1099139615, 3427871953, 2056419824,
 5    103242998, 2789231820, 97749902, 3832000502, 2437931514, 337329827, 1784389836,
 6    1971115025, 2430188600, 420768160, 2890064672, 3927353914, 2033350795, 372941141,
 7    2974609317, 1442092933, 2838369745, 3198352587, 230640255, 2641503210, 1721308159,
 8    3764390994
 9]
10
11MODULUS = 2147483647
12MULTIPLIER = 48271
13
14INVERSE_MULTIPLIER = pow(MULTIPLIER, -1, MODULUS)
15
16leak_lsb = seq[0] & 0xFFFFFF
17recovered_seed = None
18
19for high_bits in range(128):
20    potential_state_curr = (high_bits << 24) | leak_lsb
21    if potential_state_curr >= MODULUS:
22        continue
23
24    s_prev_1 = (potential_state_curr * INVERSE_MULTIPLIER) % MODULUS
25    s_prev_2 = (s_prev_1 * INVERSE_MULTIPLIER) % MODULUS
26    seed_candidate = (s_prev_2 * INVERSE_MULTIPLIER) % MODULUS
27    
28    test_state = seed_candidate
29    for _ in range(6):
30        test_state = (test_state * MULTIPLIER) % MODULUS
31        
32    if (test_state & 0xFFFFFF) == (seq[1] & 0xFFFFFF):
33        recovered_seed = seed_candidate
34        break
35...

After the seed has discovered (313374141), i conduct the decryption process using the following Python script:

35...
36def nibble_swap(byte_input):
37    return ((byte_input & 0x0F) << 4) | ((byte_input & 0xF0) >> 4)
38
39plaintext = ""
40current_rng_state = recovered_seed
41
42for packed_int in seq:
43    r1 = (current_rng_state * MULTIPLIER) % MODULUS
44    r2 = (r1 * MULTIPLIER) % MODULUS
45    r3 = (r2 * MULTIPLIER) % MODULUS
46    current_rng_state = r3
47    
48    encrypted_byte = (packed_int >> 24) & 0xFF
49    encrypted_byte ^= (r2 & 0xFF)
50    encrypted_byte = nibble_swap(encrypted_byte)
51    
52    adjustment = (r1 % 93) + 33
53    decrypted_char_code = (encrypted_byte - adjustment) % 256
54    plaintext += chr(decrypted_char_code)
55
56print(plaintext)

cela@hugo:~$ python3 segasaturn.py
cyberwave{c4n_yoU_5olV3_th1s_l1f3_eQu4t10n}


Grogol

In this challenge I was asked to enter the right input.

cela@hugo:~$ nc 129.226.145.210 317
Input > abcdefg
abcdefg
Your input is inc0rr3ct!

After decompiling the main function (FUN_000103aa), I found that the program limits input to no more than 21 characters.

34...
35lVar6 = FUN_000120f6(auStack_1078,0x1000,PTR_DAT_00088590);
36if (lVar6 != 0) {
37    uVar7 = FUN_0001cc2a(auStack_1078);
38    if (uVar7 < 0x16) { 
39...

Then the program does not process the input as a whole, but breaks it down into a word (let’s say token) separated by space. the program takes the first token and stores it in an array pointer, then takes the next token.

37...
38    if (uVar7 < 0x16) {
39      pbVar8 = (byte *)FUN_0001d348(auStack_1078,&DAT_00057e98); 
40      if (pbVar8 == (byte *)0x0) {
41        lVar6 = 0; 
42      }
43      else {
44        ppbVar17 = local_3078;
45        lVar6 = 0;
46        do {
47          *ppbVar17 = pbVar8; 
48          lVar6 = (long)((int)lVar6 + 1);
49          pbVar8 = (byte *)FUN_0001d348(0,&DAT_00057e98); 
50          if (pbVar8 == (byte *)0x0) break;
51          ppbVar17 = ppbVar17 + 1;
52        } while (lVar6 != 0x3ff);
53      }
54...

Each token found is calculated for its checksum value. so the program here initialises hash = 1, then performs XOR and Bit-Shift.

66...
67          if (*local_3078[0] != 0) {
68            uVar7 = 1; 
69            pbVar8 = local_3078[0];
70            do {
71              pbVar19 = pbVar8 + (1 - (long)local_3078[0]);
72              *puVar16 = (long)uVar7 >> 1;
73              *(byte **)(gp + -0x7b8) = pbVar19;
74              bVar1 = *pbVar8;
75              pbVar8 = pbVar8 + 1;
76              uVar7 = (ulong)bVar1 ^ (long)uVar7 >> 1; 
77              *puVar16 = uVar7;
78            } while (*pbVar8 != 0);
79...

Then there is the checksum formula.

78...
79            if (((long)(int)(((uint)*local_3078[0] ^ (uint)pbVar19) << 4) & 0xffU | uVar7 & 0xf) = =
80                0x10) {
81...

Here I used Gemini (im too lazy ehe) to map the checksum and found the final target, which was 31337.

Conditions (Hex)Ghidra CodeOperation
0x11lVar10 = lVar10 + 3Add 3
0xf2lVar10 = lVar10 * 100Multiply by 100
0xcelVar10 = lVar10 + 13Add 13
0x27lVar10 = lVar10 + 30Add 30
0x6alVar10 = lVar10 + 7Add 7
0x7a69if (stack == 0x7a69)Final Target (31337)

Our goal is to reach the number 31337. Here, we use the following logical calculation:
$((3 \times 100) + 13) \times 100 + 30 + 7 = 31337$

Then I used python to bruteforce the ASCII character to find some sort strings (1-2 chr) that matched the target checksum.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import string

def calculate_checksum(s):
    length = len(s)
    first_char = ord(s[0])
    h = 1
    for char in s:
        h = ord(char) ^ (h >> 1)
    res = (((first_char ^ length) << 4) & 0xFF) | (h & 0xF)
    return res

targets = {
    0x11: "add_3",
    0xf2: "mul_100",
    0xce: "add_13",
    0x27: "add_30",
    0x6a: "add_7"
}

chars = string.ascii_letters + string.digits
print(f"{'Token':<8} | {'Hex':<6} | {'Operation'}")

for c1 in chars:
    for c2 in chars:
        word = c1 + c2
        chk = calculate_checksum(word)
        if chk in targets:
            print(f"{word:<8} | {hex(chk):<6} | {targets[chk]}")
            del targets[chk]

So here we get the output:

TokenHexOperation
cp0x11add_3
dh0x6aadd_7
md0xf2mul_100
ni0xceadd_13
po0x27add_30

Here we get the tokens that we have to input:
$((cp \times md) + ni) \times md + po + dh = 31337$

cela@hugo:~$ nc 129.226.145.210 317
Input > cp md ni md po dh
cp md ni md po dh
cyberwave{belajar_rev_di_google_aja}


KeygenMe

In this challenge I was asked to enter username and license key.

cela@hugo:~$ ./cyberwave_keygenme
Username: test
License Key: test
Access Denied!

After decompiling the main function (FUN_00101080), I found the variable local_128 for username input and the variable local_a8 for license key input.

28...
29printf("Username: ");
30pcVar7 = fgets((char *)local_128,0x80,stdin);
31...
32    printf("License key: ");
33    pcVar7 = fgets(local_a8,0x80,stdin);
34...

And it was also found that the local_a8 input must be 19 characters long with the prefix “CW25” and a dash (-) check at a specific index, indicating that the format for the license key is:
CW25-xxxx-xxxx-xxxx

37...
38        if ((((((sVar8 - 1 < 0x20) && (sVar9 = strlen(local_a8), 
39            sVar9 == 0x13 
40            )) && (local_a4 == '-')) && ((local_a3[4] == '-' && (local_a3[9] == '-')))) && ((
41            local_a8[0] == 'C' 
42            && ((
43            local_a8[1] == 'W' 
44            && (
45            local_a8[2] == '2' 
46            )))))) && (
47            local_a8[3] == '5' 
48            )) {
49                uVar10 = 5;
50...

Then, each of the three ‘XXXX’ is split into three parts and stored in their respective variables.

55...
56                local_162 = 0;
57                local_160 = (code *)((ulong)local_160 & 0xffffffffffff0000);
58                local_158[0] = 0;
59...

The program here performs the first loop at local_128. The loop runs by reading each byte from local_128 (username).

62...
63                    uVar6 = 0x31415926;
64                    pbVar14 = local_128;
65                    while( true ) {
66                        bVar2 = *pbVar14;
67                        pbVar14 = pbVar14 + 1;
68                        if (bVar2 == 0) break;
69                        uVar6 = (uint)bVar2 + uVar6 * 0x21; 
70                        uVar6 = (uVar6 * 0x80 | uVar6 >> 0x19) ^ uVar6; 
71                    }
72...

In order for the program to print “Access Granted”, the input on local_a8 (license key) which is split into local_162, local_160, local_158 must meet the following conditions.

88...
89        if (((local_162 == (ushort)((ushort)uVar6 ^ (ushort)uVar11)) &&
90            ((ushort)local_160 ==
91            (ushort)(((ushort)(uVar6 << 0xb) | (ushort)(uVar6 >> 0x15)) ^ (ushort)(uVar11 >> 5) 
92            ^ 0xbeef))) &&
93            (local_158[0] == (ushort)((ushort)uVar6 * 3 + (short)(uVar11 >> 9) ^ 0xc0de))) {
94                puts("Access Granted!");
95...

Then I used this Python script to solve this challenge.

 1def generate_key(username):
 2    name_bytes = username.encode('ascii')
 3    length = len(name_bytes)
 4
 5    uVar6_initial = 0x31415926
 6    for b in name_bytes:
 7        uVar6_initial = (b + uVar6_initial * 0x21) & 0xFFFFFFFF
 8        uVar6_initial = ((uVar6_initial << 7 | uVar6_initial >> 25) & 0xFFFFFFFF) ^ uVar6_initial
 9
10    uVar11 = 0xa53c9e17
11    uVar6 = 0xc0ffee25
12
13    for b in name_bytes:
14        uVar6 = (uVar6 * 0x101 + b) & 0xFFFFFFFF
15        uVar6 = ((uVar6 << 5 | uVar6 >> 27) & 0xFFFFFFFF) ^ 0xa5c3d2e1
16        uVar11 = (((uVar6 >> 3 ^ (b * 0x1f + uVar11) & 0xFFFFFFFF) * 0x11) + 0x3d) & 0xFFFFFFFF
17
18    uVar6 = ((length * 0x27d4eb2d) ^ uVar6) & 0xFFFFFFFF
19    uVar11 = ((uVar6 << 13 | uVar6 >> 19) & 0xFFFFFFFF) ^ uVar11
20
21    part1 = (uVar6 ^ uVar11) & 0xFFFF
22
23    rot_uVar6 = (uVar6 << 11 | uVar6 >> 21) & 0xFFFF
24    part2 = (rot_uVar6 ^ (uVar11 >> 5) ^ 0xbeef) & 0xFFFF
25
26    part3 = ((uVar6 * 3 + (uVar11 >> 9)) ^ 0xc0de) & 0xFFFF
27
28    return f"CW25-{part1:04X}-{part2:04X}-{part3:04X}"
29
30user = input("Enter Username: ")
31key = generate_key(user)
32print(f"License Key: {key}")

So here we get the output:

cela@hugo:~$ python3 keygenme.py
Enter Username: cela
License Key: CW25-4FA0-A1AA-8D7A

Then I tried entering the same username and license key.

cela@hugo:~$ ./cyberwave_keygenme
Enter Username: cela
License Key: CW25-4FA0-A1AA-8D7A
Access Granted!
cyberwave{keygenme_easy_medium_2025}


Licensed under CC BY-NC-SA 4.0